У браузерного сканера штрихкодов есть неудобная развилка. Чтобы показать, что за товар человек держит в руках, надо сходить в товарный 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 КиБ

худший шард 4607.tsv

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 позиции — отфильтрованное подмножество продуктовой базы. Непродовольственные товары и мелкие производители отсутствуют, сканер отвечает «не нашли».

Что стоит унести

  1. Если данные только читают — сервер можно не писать. Уходит целый слой: API, его масштабирование, его логи, его инциденты.

  2. Ключ шардирования берите из формата ключа. Тогда клиент вычисляет имя файла сам, без манифеста.

  3. Смотрите на медиану, а не на средний размер. Средние 1,4 КБ выглядят благополучно, но половина файлов — сотня байт, а худший 331 КиБ.

  4. И на k-анонимность смотрите тоже. Шардирование по префиксу выглядит как приватность, но при медиане в одну строку префикс равен идентификатору. Это ровно та ошибка, которую легко не заметить, если считать только объёмы.

Нерешённого осталось два: k=1 на редких префиксах и инвалидация кэша при стабильных именах файлов. Если сталкивались с похожим — интересно, как выравнивали шарды: добивали до общего размера, склеивали редкие или шли другим путём?

Комментарии (4)


  1. ZurgInq
    08.08.2026 18:00

    Для редкого префикса запрос за шардом равен запросу за товаром: k-анонимность равна единице. Лечится это укрупнением ключа для разреженных префиксов (три цифры вместо четырёх) или добиванием файлов до общего размера. Пока не сделано — и это, пожалуй, главный недостаток текущей схемы.

    Категорически непонятно, в чём экономия дробить 429 КиБ на шарды. Кажется, это сделано больше ради спортивного интереса. Вероятно, разница скачивания файла в ~1КБ и в ~500КБ будет в пределах сотен миллисекунд. Будет больше расходов на пинг и сетевые запросы. И тоже самое касается вопроса анонимности. Вроде как что то и есть, но в итоге всё равно стремится к нулю.

    Если следовать такой логики, можно попробовать:

    1. Добавить ETag на файлы. А если коды обновляются раз в несколько недель, то можно и кэшировать на дни, а не на час.

    2. Допустим, мы хотим анонимности на уровне, что бы не знать, какой именно из ~10-100 товаров отсканировал человек. Вместо того, что бы запрашивать файлы с размером в 1-10 товаров, клиент может всегда запрашивать N случайных файлов. Размер среднего файла у вас указан как 1,4 КБ, что примерно равно MTU. Отсюда могу предположить, что время отдачи ~10 мелких файлов (в пределах ~1кб по http2), будет равно времени отдачи одному среднему файлу.


    1. DiRo Автор
      08.08.2026 18:00

      Согласен: при общем размере 429 КиБ это самое спорное место схемы. Шарды появились из предположения «один скан — один маленький запрос», но без замера времени первого скана на типичной мобильной сети это именно гипотеза, а не доказанная экономия. Целый файл при этом заметно проще и даёт настоящую приватность, а версия в URL плюс долгий immutable-кэш убирает цену на повторных посещениях. ETag тоже поможет, хотя при проверке свежести всё равно останется сетевой round trip.

      Запрос настоящего шарда вместе с N случайными — интересный промежуточный вариант: сервер будет знать только то, что нужный префикс находится среди N. Но такая защита быстро слабеет на повторных сканах — наборы можно пересекать, размеры и популярность шардов сильно различаются, а случайный файл иногда окажется не килобайтным, а одним из тяжёлых. Если сохранять нарезку, я бы скорее склеивал редкие префиксы в группы близкого размера или дополнял ответы до фиксированного размера.

      Так что да: пока замеры не покажут заметный выигрыш первого скана, 1387 шардов больше похожи на переусложнение. Для базы в несколько сотен килобайт один файл, вероятно, честнее.


  1. maxp
    08.08.2026 18:00

    Куча технических мелочей, но какая задача решалась и с какой целью её стоило бы решать не понятно.


    1. DiRo Автор
      08.08.2026 18:00

      Да, я начал с механизма раньше, чем показал сам сценарий. Он простой: человек один раз сканирует код камерой, а интерфейсу нужно показать название товара. Полный код можно отправить во внешний товарный API — тогда его оператор получает код, IP и время. Можно скачать справочник целиком. Мы выбрали третий вариант: наружу уходит только префикс, а браузер забирает один небольшой фрагмент базы.

      Но у этого решения было ещё одно предположение: что для короткой сессии «один код и закрыли» начальная загрузка 429 КиБ достаточно существенна, чтобы ради неё усложнять схему. В статье это не подтверждено замерами. Поэтому техническая задача описана, а ответ на более важный вопрос — зачем здесь нужна такая сложность — действительно остался недоказанным. Эту развилку следовало поставить до устройства файлов.