Миллионы товаров и миллисекунды: как устроен специализированный движок каталога

Хочу показать проект, который сейчас запускаю для польского рынка: https://wszyst.pl.
Это агрегатор товаров из разных источников. Сейчас в системе уже больше миллиона реальных товаров, а архитектура рассчитана примерно на три миллиона.
Но на самом деле количество товаров здесь не самое интересное. Гораздо интереснее то, как быстро система работает с таким объёмом данных.
Например, обычная страница каталога с фильтрами, сортировкой и пагинацией обрабатывается примерно за 12 мс. Если необходимые данные уже находятся в горячем пути, время обработки может составлять порядка 0,1 - 0,7 мс.
Это не отдельный синтетический тест базы данных. Это время обработки реальных запросов приложения.
При этом я отдельно тестировал сам движок под нагрузкой. В предыдущих материалах я подробно разбирал результаты тестов, поэтому здесь не буду повторять весь benchmark.
Если коротко, система стабильно выдерживает около 50 тысяч запросов в секунду без ошибок и заметной деградации. В одном из десятиминутных тестов максимальное время ответа страницы оставалось около 0,2 секунды.
При дальнейшем увеличении нагрузки узким местом постепенно становится уже не поиск данных, а пропускная способность самого сервера и сети.
Почему это получилось
В основе проекта нет PostgreSQL, Elasticsearch или другой универсальной системы, которую обычно используют для подобных задач.
Вместо этого я сделал специализированное mmap-based key-value хранилище.
Причина довольно простая: мне не нужна была универсальная база данных.
У проекта очень специфический характер нагрузки. Данные постоянно поступают и индексируются, а затем многократно читаются. Пользователь в основном делает одно и то же: открывает каталог, выбирает категорию, применяет несколько фильтров, сортирует результат и переходит на следующую страницу.
Поэтому вместо того, чтобы заставлять универсальную базу каждый раз выполнять большое количество работы, я решил большую часть этой работы перенести на момент загрузки данных.
Индексы вместо поиска по данным
Поверх хранилища работают собственные индексы, которые я называю Turbo Indexes.
В упрощённом виде это отсортированные массивы идентификаторов документов.
Допустим, у нас есть:
category = phones
brand = Apple
availability = true
Каждому условию соответствует свой отсортированный список ID.
Дальше вместо построения сложных временных структур можно выполнить пересечение этих списков:
phones → Apple → available
intersection
Для таких операций достаточно последовательного прохода по отсортированным данным.
То же самое относится к объединению условий и исключениям. Часть числовых условий, например диапазоны цен, также заранее представлена индексами, чтобы на чтении не приходилось просматривать сами записи товаров.
В результате основной путь запроса становится довольно коротким:
запрос → индексы → ID товаров → данные из mmap → JSON → HTTP
Самая важная идея находится не в индексе
Индексы сами по себе – только часть решения.
Самое существенное изменение архитектуры заключается в том, когда именно выполняется работа.
Обычная схема часто выглядит примерно так:
запрос пользователя → найти данные → отфильтровать → отсортировать → подготовить результат → вернуть
Я стараюсь делать наоборот:
поступление данных → нормализация → подготовка → построение индексов → сохранение
После этого запрос пользователя становится максимально простым:
запрос → индексы → ID → данные → ответ
Это означает, что большая часть вычислений происходит один раз — во время подготовки данных — вместо того чтобы повторяться для каждого пользователя.
У такого подхода есть очевидная обратная сторона.
Загрузка данных становится сложнее. Нужно проверять входную информацию, нормализовать её, строить индексы, следить за их согласованностью и обновлять их при изменениях.
Зато чтение становится очень предсказуемым.
И для каталога, который в основном читается, это оказывается хорошим обменом.
Хранилище и бизнес-логика
Есть ещё один эффект, который оказался для меня довольно важным.
Физическое хранение данных и логика работы каталога достаточно сильно разделены.
Если я завтра полностью изменю способ фильтрации, сортировки или формирования страниц, мне не обязательно менять само хранилище.
В большинстве случаев достаточно изменить индексы и способ их использования.
Это позволяет экспериментировать с бизнес-логикой, не превращая каждое изменение функциональности в изменение всей системы хранения.
Это, конечно, не универсальная архитектура.
Для обычного веб-приложения я бы не стал без необходимости писать собственную базу данных и собственную систему индексации.
Но когда задача очень узкая и хорошо известна заранее, иногда оказывается выгоднее оптимизировать не отдельный компонент, а весь путь прохождения данных.
Ещё один узкий участок – JSON
После того как поиск и чтение данных стали достаточно быстрыми, следующим заметным участком оказался JSON.
Для этого проекта я сделал отдельный инструмент – SilentJSON.
Это zero-allocation JSON encoder/decoder, рассчитанный на структуры с заранее известным layout.
Он намеренно не пытается заменить encoding/json во всех возможных сценариях.
У такого подхода есть ограничения. В частности, он плохо подходит для произвольных динамических структур, большого количества interface{} и других случаев, где структура данных заранее неизвестна.
Зато если структура предсказуема, можно отказаться от значительной части универсальности стандартного JSON-парсера и работать непосредственно с известными полями.
Особенно хорошо SilentJSON показывает себя при обработке больших JSON-файлов. Он поддерживает потоковую обработку и многопоточность, поэтому для некоторых задач импорта получается очень существенная разница.
Но это уже отдельная тема, и о SilentJSON я писал отдельно.
Что в итоге получилось
Сейчас wszyst.pl – это уже не просто эксперимент с базой данных.
Это попытка построить весь путь обработки каталога вокруг одной идеи: максимально перенести работу из момента запроса пользователя в момент подготовки данных.
В итоге получается довольно простой путь чтения:
индекс → ID → mmap → JSON → HTTP
При миллионах товаров этого оказалось достаточно, чтобы получить очень небольшое время обработки запроса и стабильную работу под высокой нагрузкой.
Сейчас в каталоге больше миллиона реальных товаров, а следующая цель – несколько миллионов.
Если интересно посмотреть, как это выглядит не в benchmark, а в работающем приложении, можно просто открыть https://wszyst.pl, выбрать категорию, применить фильтры, поменять сортировку и перейти по страницам.
Мне сейчас особенно интересны реальные сценарии использования. Если найдёте место, где каталог начинает заметно тормозить, это будет гораздо полезнее ещё одного синтетического теста.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.