
Меня зовут Ескендиров Мурат, я — архитектор сайта в Ви.Tech, IT-дочке ВсеИнструменты.ру. В этой статье расскажу, как мы строили сервис для выдачи карточек товаров, обратывающий до 5 миллиардов запросов в сутки, какие архитектурные решения приняли и с какими проблемами столкнулись в процессе. Расскажу, какие решения сработали, а какие до сих пор остаются нашей головной болью.
Наш e-commerce-сайт за ~20 лет вырос из небольшого PHP-проекта в крупный монолит с несколькими миллионами уникальных товаров.
Несколько лет назад перед сайтом во весь рост встали проблемы, характерные для продуктов, выросших из «гаражного» стартапа в крупный корпоративный продукт:
Никто не знает, как это на самом деле работает.
Любые изменения — это долго и дорого.
Попытки ускорить приводят к настолько хитрым решениям, что через месяц даже сам разработчик, писавший код, не может понять, что же там происходит. А в половине случаев никакого ускорения по факту и нет.
В какой-то момент мы, намучившись, решили вынести в отдельные сервисы наиболее нагруженные части монолита.
Одним из таких сервисов стал сервис информации о товаре. Его задача — агрегировать информацию непосредственно о товаре (название, описание, технические характеристики, габариты, картинки и т. п.), наложить на эту информацию определенную бизнес-логику и отдать её фронту.
Одним из самых жестких требований к сервису было отдавать всю эту информацию в 99% случаев не более чем за 30 мс. При этом необходимо было иметь возможность хранить не менее 10 млн уникальных товаров и выдерживать в пике не менее 300 тысяч запросов в минуту.
Ситуация усугублялась тем, что некоторые мастер-системы, которые поставляют информацию для формирования карточки товара, сами являются достаточно медленными монолитами на PHP. Причем одна из них ни при каких обстоятельствах не смогла бы выдержать синхронное с ней взаимодействие. Ждать их рефакторинга мы не могли, производительность сайта ухудшалась с непрерывным ростом нагрузки. «Заливание железом» уже мало помогало, а нагрузочные тестирования показывали, что при текущем росте трафика на горизонте полугода-год мы сложимся в самый неподходящий момент.
После некоторых раздумий было решено реализовать сервис на Go, не имеющем синхронных взаимодействий с внешними системами и замыкающем всю входящую нагрузку на себе. Благо положительный опыт подобных решений у нас уже был.
По сути, мне надо было создать большой кеш с умной инвалидацией и возможностью сохранения данных между выкатами, чтобы не тащить их каждый раз из мастер-систем.
Как мы выбрали хранилище данных
Требования к времени ответа определили то, как мы будем хранить данные.
Ниже на картинке (картинку надо немного другую, добавить вместо ВЗУ Memcached, Redis, БД и т. д.) приведены примерные времена ответа различных уровней памяти. Откинув чересчур мелкие, мы видим, что наибольшей скоростью обладает оперативная память. На ней и было решено остановиться.

Проблема объема данных и выбор механизма вытеснения
После примерного подсчета объема данных стало понятно, что хранить все в горячем хранилище нерационально. Тут мы пришли к тому, что необходимо реализовать механизм, вытесняющий «лишние» объекты и тем самым ограничивающий максимальный объем данных в горячем хранилище.
Варианты вытеснения:
FIFO
LIFO
LRU
LFU
Более глубокое изучение вопроса привело к тому, что существуют более экзотические варианты, которые при небольшом усложнении дают приятные бонусы. Например, 2Q и ARC, которые защищают кеш от вытеснения данных при последовательном переборе всех элементов. Второй вариант более продвинутый, но по не вполне понятным сейчас причинам мы решили остановиться на 2Q.
В чем же преимущество 2Q перед широко распространенными LRU и LFU?
2Q по сути состоит из двух с половиной независимых кешей:
Входящая очередь по схеме FIFO.
Кеш часто используемых элементов по схеме LRU.
Исходящая очередь FIFO, которая содержит только идентификаторы элементов (та самая «половинка»), но не содержит их данных.
Принцип работы:
Чтобы элемент задержался в кеше надолго, он должен «доказать», что действительно часто используется и имеет право занимать память, а не попал туда случайно, вытеснив более полезного соседа.
Проблема многопоточности и оптимизация доступа
У всех этих видов кешей есть один существенный недостаток — они по сути своей однопоточные. Любое чтение приводит к обновлению служебной информации (передвижение элемента в очереди), а любая запись требует приостановки чтения до завершения записи.
Частично это решается тем, что можно разбить хранилище на множество независимых сегментов и тем самым размазать доступ к ним по нескольким потокам. Что мы и сделали, разбив хранилище по количеству моделей, участвующих в формировании карточки товара.
Теперь хранилища можно сделать типизированными, хранящими по сути указатели на обычные Go-объекты. Это избавило нас от накладных расходов на сериализацию/десериализацию данных при добавлении/извлечении данных из горячего хранилища.
Особенности работы LRU кеша
Ниже схематично изображена структура LRU кеша.

Блок-схема получения данных из LRU кеша

А любая запись по своей природе однопоточная и требует приостановки чтения до завершения записи. Частично это можно решить, разбив хранилище на множество независимых сегментов, тем самым размазав доступ к ним по нескольким потокам. Именно так мы и поступили — разделили хранилище по количеству моделей, участвующих в формировании карточки товара.

Теперь хранилища можно сделать типизированными, хранящими по сути указатели на обычные Go-объекты. Это избавило нас от накладных расходов на сериализацию/десериализацию (кастинг в другом варианте реализации) данных при добавлении и извлечении данных из горячего хранили.
Также мы более пристально посмотрели на наши данные и заметили, что примерно две трети моделей, используемых для формирования карточки товара, имеют небольшое количество сущностей. Например, у нас миллионы товаров, но всего около 15 тысяч категорий товаров. Для таких сущностей, как категория, нет особого смысла городить механизм вытеснения — их очень мало в общей массе данных, и вполне допустимо хранить их в памяти все. К тому же потребность в таких сущностях зачастую выше. (Например, на один товар в среднем необходимо получить 3,5 категории.) Поэтому важно постараться, чтобы такие сущности не вытеснялись из горячего хранилища.
Для таких данных мы решили использовать самый обычный ассоциативный массив. Он не имеет проблем с многопоточным чтением, а изменения в данных достаточно просты и не требуют большого объема операций.
Проблема синхронизации данных
Следующая наша проблема — это синхронизация между горячим и холодным хранилищами при изменении данных. Как известно, инвалидация — это одна из двух главных проблем программирования. Ситуация усугубляется, если данные в кеше представляют собой суперпозицию нескольких исходных сущностей.
Тут мы рассмотрели три наиболее распространенных варианта:
Ничего не делаем — данные в кеше хранятся какое-то строго заданное время, а затем сами «протухают».
Тегированный кеш с инвалидацией по тегам.
Инвалидация по событиям.
Первый вариант мы сразу отбросили, так как хотелось доставлять данные до клиента без существенных задержек.
Второй вариант был довольно интересным, но он вызывал накладные расходы на получение данных из кеша, которые порой превышали полезную работу.
Оставался третий вариант. Как только данные изменяются в холодном хранилище, выбрасывается событие, которое видят все инстансы приложения с горячим хранилищем, и устаревшие данные удаляются. Затем, когда эти данные снова понадобятся, они подгружаются в горячее хранилище из холодного.
Тут мы не стали ничего особо изобретать и взяли самый простой вариант — очереди на стримах Redis. При всех недостатках Redis, в нашем случае в качестве шины синхронизации он оказался идеальным.

Все репозитории БД в приложении у нас обернуты в декораторы, которые при любых изменениях хранящихся в них сущностей выкидывают в Redis сообщение, что сущность такая-то с идентификатором таким-то изменена. Все инстансы с горячим хранилищем получают это сообщение. Дальше мы пошли на некоторое усложнение: мы не удаляем старые данные, если они есть в горячем хранилище, а заставляем горячее хранилище, если оно содержит эти данные, перечитать их из холодного. Таким образом, мы избавили клиентов от лишнего ожидания подгрузки данных из холодного хранилища и улучшили время ответа в высоких перцентилях.
Уже после тестового запуска мы натолкнулись еще на одну проблему, которая, на мой взгляд, свойственна всем асинхронным системам — это неконсистентность данных между стороной продюсера и стороной консьюмера. Мы обнаружили, что со временем данные в горячем хранилище разъезжаются с таковыми в холодном. Таких данных в общей массе было немного, но мелких неприятностей они доставляли изрядно. Эту проблему, строго говоря, мы так и не победили до сих пор, а может, и не особо хотели тратить на это время, так как уже имели рабочие варианты обхода. Мы просто написали сверку между горячим и холодным хранилищами. Работает она до неприличия просто: раз в несколько часов мы берем все идентификаторы сущностей, какие есть в горячем хранилище, и тупо перечитываем данные по ним из холодного хранилища. Данная операция сильно размазана по времени, поэтому мы ее особо не замечаем на общем фоне других операций.
Проблема прогрева горячего хранилища после деплоя
Далее, в процессе эксплуатации, возникла еще одна проблема: при передеплоях в горячем хранилище не было данных. И всем клиентам, которые в этот момент заходили на сайт, приходилось «немного» подождать. Не сказать, что мы заранее не знали о том, что так может быть, — просто понадеялись, что это будет относительно незаметно. Но чугунная реальность показала ошибочность наших суждений.
Необходим прогрев горячего хранилища до того, как на него пойдет клиентский трафик.
Если с теми частями хранилища, которые реализованы по схеме ассоциативного массива, проблем нет (грузи все, что есть в холодном хранилище), то с вытесняемыми данными все сильно сложнее. Что именно прогревать? Просто загрузить N первых записей? N последних? Или через одну? Все решения в лоб хуже, чем вообще ничего не греть, так как записи, к которым редко обращаются, попав в горячее хранилище, будут занимать чье-то место, а вытеснение такого элемента — это тоже не бесплатная операция.
Что делать? Мы начали собирать статистику того, что у нас лежит в горячем хранилище. Раз в 6 часов берем все идентификаторы всех сущностей, какие есть во всех инстансах приложения с горячим хранилищем, и выгружаем их в таблицу в БД. Такие дампы мы храним несколько дней. При передеплое мы берем из этой таблицы те идентификаторы сущностей, которые наиболее часто встречались в горячем хранилище за последние несколько дней, и только эти сущности загружаем в горячее хранилище. Таким образом, мы «почти» восстанавливаем предыдущее состояние горячего хранилища, и клиенты не чувствуют никаких задержек.
У этого решения есть неприятная сторона. Теперь запуск приложения занимает несколько минут, в течение которых данные из холодного хранилища наполняют горячее. Все это время приложение не обслуживает клиентские запросы. Но в нашем случае это довольно просто решается репликацией подов и стратегией деплоя в кубе, и каких-то значительных неудобств это не доставляет.
Как данные попадают в холодное хранилище
А как же данные попадают в холодное хранилище? Тут все достаточно просто. Есть порядка 40–50 консьюмеров, которые слушают события в Kafka от мастер-систем и, выполнив максимально возможную предобработку данных, кладут их в холодное хранилище, заодно сообщив горячему хранилищу, что надо обновиться (это описано выше).
Тут также есть проблема консистентности данных при асинхронном взаимодействии с мастер-системами, но об этом в другой раз.
Результаты нагрузочного тестирования
В итоге, что же мы получили? Чтобы узнать это, мы провели нагрузочное тестирование. Для этого мы взяли виртуальную ноду с 8 CPU и 32 GB RAM и развернули там сервис. Тестирование проводилось при 100% попадании в кеш горячего хранилища.
Выдержка из протокола:
(сюда идет выдержка с результатами тестирования)

Как видно, даже в тестовом режиме сервис успешно выполнил нефункциональные требования по количеству запросов, укладываясь в заданное время ответа. Деградация сервиса начиналась только при нагрузке, превышающей требуемую более чем в два раза.
А вот так сейчас выглядит время ответа одного из инстансов при рабочей нагрузке:

При этом в пиковые моменты мы показываем до 5 млрд карточек товаров в сутки, сохраняя медиану времени формирования ответа около 348 мкс.
Для формирования одного ответа в среднем требуется выполнить примерно 35 запросов к горячему хранилищу и обработать данные из 130 сущностей.
В итоге
Мы получили сервис, который стабильно обрабатывает колоссальные объемы данных в условиях жестких временных ограничений и постоянного роста трафика. Двухуровневая архитектура с горячим и холодным хранилищами, кеш с продвинутой стратегией инвалидации и асинхронная синхронизация позволили обеспечить минимальное время ответа и высокий уровень отказоустойчивости.
Однако, как и в любой сложной системе, остаются компромиссы. Проблемы с консистентностью данных между горячим и холодным хранилищем мы так и не устранили полностью, но текущие механизмы сверки позволяют удерживать ситуацию в допустимых пределах. Циклический прогрев данных после передеплоя тоже накладывает свои ограничения, но это решение оказалось наиболее рациональным с учетом имеющихся ресурсов.
На текущий момент система работает в пределах заданных SLA, а ее архитектура позволяет выдерживать нагрузки, превышающие запланированные в несколько раз. Дальнейшее развитие скорее будет связано с оптимизацией отдельных узких мест, чем с радикальными изменениями в подходе.
Комментарии (11)
Dacom_777
30.08.2025 08:42Несколько миллионов уникальных товаров-вот это загнул, конечно)))
MadridianFox
30.08.2025 08:42Часто, с технической стороны совсем не важно действительно ли это уникальные товары или варианты одного, например футболки разных размеров или даже одна и та же футболка в разных магазинах. В результате каталог товаров может содержать десятки миллионов записей.
Mirzapch
30.08.2025 08:42Ваши миллисекунды ничего не значат, когда для получения товара требуется три часа.
Это после того, как получено сообщение о том, что товар на точке выдачи, и покупатель уже приехал на эту самую точку.
MadridianFox
30.08.2025 08:42Скорость работы сайта влияет на комфорт его использования. Когда ты ищешь в гугле конкретный шуруповерт, и открываешь деталку товара, если деталка не грузится 1-2 секунды, велика вероятность что ты закроешь вкладку и кликнешь на другую ссылку в гугле.
А время генерации страницы зависит от выполнения десятков запросов, и становится критичным чтобы конкретный запрос отвечал за столько то миллисекунд.
Kahelman
30.08.2025 08:42У вас с математикой проблемы:
300 000 запросов в минуту * 60* 24 = 430 млн запросов в сутки.Откуда 5 млрд?
Kahelman
30.08.2025 08:42Особенно порадовало:
«Наш e-commerce-сайт за ~20 лет вырос из небольшого PHP-проекта в крупный монолит с несколькими миллионами уникальных товаров.»
«Одним из самых жестких требований к сервису было отдавать всю эту информацию в 99% случаев не более чем за 30 мс. При этом необходимо было иметь возможность хранить не менее 10 млн уникальных товаров и выдерживать в пике не менее 300 тысяч запросов в минуту»
Насколько миллионов это в любом случае меньше 10. Иначе говорят десятки миллионов.
Даже если у вас 9 млн. Товаров то они тупо влезают в ОЗУ. Если считать что карточка 4096 байт, то надо всего 38 Гб ОЗУ.
Вы взяли для тестов сервер с 32 Гб. ОЗУ и 8 CPU - возьмите 128 Гб ОЗУ и побольше процессоров и проблем не будет.Ещё вопрос кто может генерить 5 млрд. Запросов в сутки?
Страница выдачи обычно не более 100 товаров.
Как правило меньше но-фиг с ним.
Допустим пользователь маньяк и готов просмотреть 100 страниц.
5 млрд /(10000)= 500 000 пользователей в сутки
которые шерстят списки товаров 24*7?У Амазона 80-90 посетителей в сутки,
EBay - 20 млн.
Avito от 6 до 10 млн.Вы пишите что у вас 1.6 млн уникальных посетителей в день. Что может быть правдой, но все равно не стыкуется с вашими данными по запросам.
Странные у вас данные…..
TkachenkoD
30.08.2025 08:42Не понимаю я ваших замечаний.
Я полагаю, что у них сейчас система способна обработать до 5 млрд запросов в сутки.
300к запросов в минуту - изначальное требование к системе.
То, что товары влезают в ОЗУ - так это сейчас они влезают. Добавятся новые данные, увеличится кол-во товаров, и всё, система не работает, нужно больше ОЗУ. Да и все равно останется инвалидация и прогрев.
Kahelman
30.08.2025 08:42Ещё нашел данные
У Амазона около 12 млн «своих продуктов» и около 300-600 млн продуктов на маркетлейсеБезос не звонил с предложением Вас купить?
slonopotamus
30.08.2025 08:42в нашем случае в качестве шины синхронизации он оказался идеальным.
Мы обнаружили, что со временем данные в горячем хранилище разъезжаются с таковыми в холодном.
Идеальная синхронизация.
Ingvarr6
30.08.2025 08:42Мы обнаружили, что со временем данные в горячем хранилище разъезжаются с таковыми в холодном.
Почему возникла эта проблема если при изменении данных в холодном хранилище вызывается событие на обновление данных в горячем?
Coytes
Впечатляюще, отличная работа и анализ требований для результата!