Отдали базу штрихкодов статикой: 1,9 МБ, 1387 шардов, медиана в одну строку
- воскресенье, 9 августа 2026 г. в 00:00:16
У браузерного сканера штрихкодов есть неудобная развилка. Чтобы показать, что за товар человек держит в руках, надо сходить в товарный API — и отдать туда номер, IP и время. По одному запросу это ничего; по потоку запросов это фактический чек, собранный чужими руками.
Второй путь: скачать базу один раз, порезать на файлы по префиксу номера и раздавать статикой. Браузер берёт нужный файл и ищет сам.
Разбираем второй путь: как устроен, сколько приватности при этом всё равно утекает — а утекает — и где схема не работает. Все числа ниже замерены на файлах, которые лежат в проде.
Сразу снимем главное недоразумение, потому что схему часто описывают красивее, чем она есть.
Запрос к серверу есть. Открыв DevTools, вы увидите GET /tovary/4607.tsv. Это не «ноль запросов», и обещать ноль было бы враньём.
Уходит наружу имя файла — четыре первые цифры номера. Оставшиеся девять цифр, результат поиска и всё остальное остаются в браузере. То есть утечка сокращается с полного тринадцатизначного номера до четырёхзначного префикса, и уходит она на наш сервер, а не в чужой товарный API. Это тот же приём, что у Have I Been Pwned с range-запросами по префиксу хэша: сервер знает диапазон, но не знает элемент.
Насколько это анонимно на самом деле. По-разному, и вот неудобная половина:
в 4607.tsv — 5 316 товаров, запрос за этим файлом не говорит, какой из них отсканировали;
но 722 файла из 1387 содержат ровно одну строку, а 986 (71 %) — три и меньше. Медиана строк в файле равна единице.
Для редкого префикса запрос за шардом равен запросу за товаром: k-анонимность равна единице. Лечится это укрупнением ключа для разреженных префиксов (три цифры вместо четырёх) или добиванием файлов до общего размера. Пока не сделано — и это, пожалуй, главный недостаток текущей схемы.
База — Open Food Facts: ODbL на саму базу, DbCL на содержимое. Отфильтрованное подмножество, которое мы публикуем, является производной базой, поэтому распространяется на тех же условиях ODbL, с атрибуцией «© Open Food Facts contributors» и ссылкой на текст лицензии. Условия и способ получить полную копию лежат рядом с данными, файлом в том же каталоге.
Выгрузка большая, поэтому сборка читает её потоком, а не с диска:
curl -sL https://static.openfoodfacts.org/data/en.openfoodfacts.org.products.csv.gz \ | node scripts/build-product-index.mjs
Длина номера — одна из четырёх: 8, 12, 13 или 14. Оговорка: восьмизначные до выдачи не доживают, потому что следующее правило срезает их целиком. На выходе остаётся 32 756 номеров длины 13 и 16 длины 14, восьмизначных и двенадцатизначных — ноль. То есть ветка про 8 и 12 в фильтре мёртвая, и честнее считать, что справочник хранит EAN-13 и ITF-14.
Контрольная цифра должна сходиться. Приоритет ошибок здесь такой: показать человеку чужой товар из-за опечатки в чужой базе хуже, чем не показать ничего.
Четыре ведущих нуля и больше — мимо. После дополнения номера до тринадцати цифр проверяется префикс 0000: это внутренние обозначения участников, а не то, что напечатано на упаковке. Именно это правило, а не контрольная цифра, отсекает восьмизначные номера — после дополнения нулями они неизбежно начинаются с четырёх нулей.
Географический фильтр. Оставляем номер, выданный в России (префиксы 460–469), либо товар, у которого в списке стран есть Россия. Второе обязательно: импорт со своим префиксом человек сканирует в магазине ровно так же.
После фильтра остаётся 32 772 записи.
Шард — товары с одинаковыми первыми четырьмя цифрами номера, имя файла и есть эти четыре цифры: 4607.tsv.
Ключ вычисляется из самого номера, поэтому клиенту не нужно ничего знать заранее, чтобы понять, в каком файле искать: ни манифеста, ни справочника соответствий. Совпадение с границами стандарта тут неполное — по стандарту фиксированы первые три цифры (префикс национальной организации GS1), четвёртую мы добавили ради размера файлов.
Что получилось в цифрах:
сырой | brotli | |
|---|---|---|
весь справочник, 1387 файлов | 1,85 МиБ | 429 КиБ |
худший шард | 331,6 КиБ | 74,8 КиБ |
средний файл | 1,4 КБ | — |
медианный файл | 0,1 КБ | — |
Разрыв между средним и медианой — не дефект нарезки, а структура выдачи номеров: в диапазоне крупного производителя товаров на порядки больше, чем в случайном. Верхний процент файлов держит больше половины объёма.
Законный вопрос, и ответ не в пользу шардов по всем статьям сразу.
Весь справочник в brotli — 429 КиБ. Это меньше одной картинки в средней статье. Отдать его целиком означает настоящую нулевую утечку: сервер не узнаёт даже префикса. Заодно исчезает проблема k=1 из первого раздела и 1387 файлов из деплоя.
Контраргумент один, зато весомый: 429 КиБ авансом платит каждый, кто просто открыл страницу, а шард — единицы килобайт и только для тех кодов, которые человек реально сканирует. Для сканера, где типовой сценарий «навёл на одну пачку и закрыл», аванс не окупается. Для сценария «сканирую сотню позиций подряд» — окупается с запасом, и там правильный ответ обратный.
Так что выбор не «шарды лучше», а «шарды лучше при коротких сессиях». У нас сессии короткие.
Внутри файла TSV:
4600605012338 Активиа Биойогурт Киви и мюсли Danone
Номер, название, марка. Название режется до 70 символов, марка до 40 и берётся до первой запятой; табы и переводы строк вычищаются на сборке, поэтому экранирование не нужно.
Про «JSON бы раздул файл» — популярный, но преувеличенный довод. Замеры на тех же данных: TSV 1 937 755 Б; NDJSON массивами 2 198 513 (+13 %); один массив массивов 2 201 287 (+14 %); объекты с именами полей 2 916 591 (+50 %). То есть разница драматична только с наивным вариантом, а после сжатия схлопывается ещё сильнее. И JSON.parse нативный — на 331 КиБ он, скорее всего, быстрее ручного разбора.
TSV выбран не ради этих процентов, а ради того, что файл читается глазами и grep: база публичная, ею пользуются не только наши скрипты.
Отдельный файл, четыре колонки: префикс, марки через точку с запятой, число товаров, число марок.
4600605 Простоквашино;Danone;Даниссимо 271 27
GS1 выдаёт префикс предприятия один раз на компанию, поэтому знание об одном товаре распространяется на весь ассортимент. По нашему подмножеству: 5 924 различных префикса, 305 покрывают половину справочника, 1350 — четыре пятых.
Оговорка про названия. В открытом справочнике записана марка, а не изготовитель: под 4600605 — 271 товар и 27 марок одного владельца префикса. Юрлицо в поле марки иногда попадает случайно (0012400 ООО КУБАНСКИЙ КОМБИНАТ ХЛЕБОПРОДУКТОВ), но это особенность заполнения, а не поле данных, и полагаться на него нельзя. Поэтому сводка называет то, что в ней есть, — марки под этим номером.
Кэш живёт час. На .tsv отдаётся Cache-Control: public, max-age=3600, без immutable и без версии в пути. Через час запрос повторится. Правильное решение — версия в пути (/tovary/v3/4607.tsv) и год immutable; это заодно решает инвалидацию при полной пересборке. Не сделано.
Промах по несуществующему шарду — это 404. Четырёхзначных префиксов десять тысяч, файлов 1387. Скан неизвестного товара даёт лишний запрос и отдельный след в логах.
Есть троттлинг. На каталог повешено ограничение частоты: при выкачивании в цикле отдаётся 429 too_many_shards с указанием, что база открыта по ODbL и полную копию можно получить целиком. То есть «раздаём открыто» не равно «даём выкачивать себя запрос за запросом».
Данные не свежие. Справочник — снимок на момент сборки. Инкрементального обновления нет: проще перегенерировать все файлы, чем поддерживать дельты ради базы, которая меняется раз в несколько месяцев.
Покрытие ограничено. 32 772 позиции — отфильтрованное подмножество продуктовой базы. Непродовольственные товары и мелкие производители отсутствуют, сканер отвечает «не нашли».
Если данные только читают — сервер можно не писать. Уходит целый слой: API, его масштабирование, его логи, его инциденты.
Ключ шардирования берите из формата ключа. Тогда клиент вычисляет имя файла сам, без манифеста.
Смотрите на медиану, а не на средний размер. Средние 1,4 КБ выглядят благополучно, но половина файлов — сотня байт, а худший 331 КиБ.
И на k-анонимность смотрите тоже. Шардирование по префиксу выглядит как приватность, но при медиане в одну строку префикс равен идентификатору. Это ровно та ошибка, которую легко не заметить, если считать только объёмы.
Нерешённого осталось два: k=1 на редких префиксах и инвалидация кэша при стабильных именах файлов. Если сталкивались с похожим — интересно, как выравнивали шарды: добивали до общего размера, склеивали редкие или шли другим путём?