Привет, меня зовут Мария, когда-то я работала на шахте, потом на заводе, а 3.5 года назад пришла в Ozon Tech. Сейчас я старший Golang-разработчик в команде product-facade. Это самый высоконагруженный сервис маркетплейса, но так было не всегда.

Хотите узнать, что скрывается под витриной маркетплейса? Что держит нагрузку в 1 миллион запросов в секунду? Толстые кэши или нечто большее? Про то, как устроено наше кэширование, и как мы к этому пришли, — рассказываю в статье.


Роль product-facade в окружающей среде

Масштаб влияния

Наш сервис выполняет роль фасада по товарам и рассчитывает на лету доступность товаров для всех сервисов витрины: каталог, поиск, карточка товара, корзина, страница оформления заказа, избранное, кабинет продавца и все-все-все. У нашего сервиса больше 100 клиентов.

С каждого раздела сайта ozon.ru или мобильного приложения, где есть какая-либо информация о товарах (спойлер: везде), к нам прилетает от одного до нескольких запросов, а ещё за информацией о товарах к нам ходят сервисы по работе с продавцами и сервисы аналитики. В пике мы отдаём 350 Gb/s данных о товарах.

Средняя дневная нагрузка на product-facade сейчас около 300К RPS.

А к осенним распродажам (День холостяка и Черная пятница) наша цель — держать 1 млн RPS. На нагрузочных тестах уже сейчас мы держим 1.2 млн RPS.

Откуда мы берем такие нагрузки?

В 2021 году Ozon начал применять систему продаж с хаммерами. Хаммеры — это товары с провокационной ценой, но в ограниченном количестве, вывешенные на полке главной страницы. Вспоминаем сковородки или булгур по 9 рублей. На старте распродаж хаммеров на сайт резко возрастает нагрузка, желающих урвать товар по скидке очень много. Это приводит к пиковым нагрузкам, к которым мы готовимся весь год.

Пользователи Ozon тестируют нас на нагрузку, а мы тестируем на нагрузку нашу команду нагрузочного тестирования и их инфраструктуру. Их задача — обеспечить во время стресс-тестов подачу нагрузки, прогнозируемой на сезон. Под наши таргеты им приходится дорабатывать свои процессы. Для организации тестов в 1 млн RPS нужна инфраструктура, которая это потянет. О том, как устроено нагрузочное тестирование в компании можно почитать здесь.

Чем мы держим все эти запросы?

  • 666 инстансов сервиса, написанного на Golang, по 7 ядер CPU и 7.5 Gb RAM на каждый

  • 201 шард кэшей — мы используем memcached: 1,1 Tb RAM

  • 42 сервера, выделенных под кэши: 32 net core + 64 app core + 25 Gb/s

Сейчас мы кэшируем данные от 21 мастер-системы. Это и готовые ответы нашего сервиса и сырые данные, которые мы используем в real-time-расчётах.

Для чего нам понадобилось столько кэшей

Итак, 3 года назад перед командой стояли задачи:

  1. Спрятать за фасад единого API десятки мастер-систем, которые хранят критичную информацию о товарах: атрибуты, категории, стоки, склады, ограничения на доставку, информацию о полигонах и т.д.

  2. Снять с этих систем максимум нагрузки.

  3. Ускорить передачу данных потребителям и снизить потребление ресурсов за счёт переиспользования данных.

  4. Стабильно без деградаций response time переносить пиковые нагрузки на сайт.

И всё это в условиях, когда функционал постоянно обрастает новыми фичами, растут объёмы обрабатываемых данных, растёт количество пользователей маркетплейсом — покупателей и селлеров.

Мы ждали от кэширования, что оно:

  1. Позволит нам обслуживать больше клиентов с теми же ресурсами, благодаря:

    • переиспользованию ранее полученных или вычисленных данных;

    • снижению лишней нагрузки с мастер-систем поставщиков данных.

  2. Сократит время ответа сервисов, благодаря ускорению процесса извлечения данных и перемещению их ближе к потребителям.

  3. Стабилизирует работу при пиковых нагрузках и кратковременных отказах систем-поставщиков данных.

На старте у сервиса было несколько десятков тысяч RPS, несколько клиентов, несколько мастер-систем, из которых мы агрегировали и кэшировали данные самым примитивным способом, и регулярные инциденты в продакшне.

За три года мы успели применить множество стратегий кэширования, паттернов, хаков, костылей, подходов к инвалидации и преисполнились в алгоритмах вытеснения данных.

В результате всех полученных психологических травм мы научились всё это довольно эффективно, как нам кажется, комбинировать между собой для достижения лучшего результата и выжили. Но в процессе набили шишки и прострелили себе не одно колено на кэшировании.

Теперь product-facade срезает от 55 до 99 % нагрузки c двух десятков мастер-систем, а наши кэши стали несущими для всей витрины маркетплейса. Это даёт нам возможность затаскивать интересные оптимизации и видеть их эффект на больших масштабах.

Дальше о том, как мы к этому пришли.

Стратегии прогрева кэша

С чего всё начиналось

В качестве внешнего хранилища для кэша на самом начальном этапе был выбран memcached — из-за его простоты и потому, что в компании уже умели с ним работать.

При чтении данных логика была самая простая: сначала мы идём в кэш, если при запросе в кэш получена ошибка (read tcp, connection timeout) или запрошенный ключ не был найден в кэше, мы идём в мастер-систему. Получив данные, мы отдаём их клиенту и асинхронно записываем в кэш. Таким образом, в кэш попадают только те данные, которые запросили у сервиса, и только тогда, когда они кому-то понадобились. Гарантий записи в кэш мы не даём и, если во время записи запрос отвалился с ошибкой, повторных попыток мы не делаем.

У такого подхода есть своё название — ленивое кэширование или Lazy caching.

Чего хорошего и плохого можно сказать об этой стратегии.

Плюсы Lazy caching:

  • Кэш содержит только те объекты, которые действительно запрашивают пользователи.

  • Новые объекты добавляются в кэш только по мере необходимости.

  • Система устойчива к сбоям кэша. Если кэш недоступен, то приложение может взаимодействовать с источником данных напрямую.

  • Простая реализация.

Минусы Lazy caching:

  • Подход допускает промахи кэша.

  • Каждый промах кэша требует совершить три операции, что может приводить к дополнительным задержкам.

  • Приводит к неконсистентности данных. Данные записываются напрямую в основное хранилище, что может привести к тому, что в кэше окажутся неактуальные данные. Для решения этой проблемы необходимо предусматривать дополнительные механизмы инвалидации.

Минусы Lazy caching привели к тому, что впоследствии от нас потребовалось существенно доработать подход к удалению кэша.

Для его удаления и обновления изначально мы использовали только ограничение срока его жизни — единый TTL на 2 часа для всех объектов кэширования разных кусков данных.

Наша система работала по модели согласованности в конечном счёте (англ. eventual consistency) с огромной задержкой на время установленного TTL = 2 часа.

Инвалидация

Кэширование невалидных данных

С кэшированием всегда есть риск, что в него может попасть что-то такое, чего мы бы не хотели там видеть. И таких кейсов, которые могут к этому привести, больше, чем может показаться.

С какими сталкивались мы:

  • мастер-система сдеградировала при ответе и отдала нам неполный ответ, а мы положили его в кэш;

  • в мастер-систему по ошибке пролили некорректные данные, их быстро поправили в основном хранилище, но мы уже положили их в кэш;

  • мастер-система выкатила багованный релиз, в котором стала отдавать невалидные данные. Релиз быстро откатили, но они уже пролились к нам в кэш;

Багованный релиз привел к дублированию всех атрибутов на карточке товара
Багованный релиз привел к дублированию всех атрибутов на карточке товара
  • наш клиент транзитивно стал прокидывать через метадату параметры, меняющие логику формирования ответа одной из мастер-систем под нами — он менял язык описания товара для текущего запроса. Про этот параметр мы ничего не знали и просто кэшировали описание товара как обычно. Так мы стали отдавать русскоязычной аудитории описание товара на китайском языке.

Описание товара на китайском
Описание товара на китайском

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

Масштабы проблем бывают драматическими, и для ликвидации последствий нам нужны были способы, позволяющие быстро сбрасывать кэш для определенных ключей.

Так мы пришли к версионированию ключей кэширования.

Версионирование ключа кэширования

Этот подход применим, когда между ключами нет зависимостей — как раз наш случай.

Все ключи разделены на пару десятков групп с разными префиксами по логическому принципу:

  • {item_id}_description_v1

  • {item_id}_availability_v1

  • {item_id}_attributes_v1

  • и т.д.

Как это работает:

К ключу кэширования добавляется версия:

{item_id}_description_v1

Для инвалидации всех ключей с префиксом description достаточно инкрементировать версию ключа v1 → v2. Версии ключей мы вынесли во внешний конфиг сервиса, поэтому её можно менять в рантайме приложения.

Когда мы меняем версию на новую, сервис начинает читать и писать ключи только для неё, и все ключи с нужным префиксом обновляются при очередном запросе. Так мы справляемся с массовым попаданием в кэш невалидных данных.

Версионирование ключей также позволяет нам выкатывать несовместимые изменения в кэшах.

Неконсистентность и долгое обновление данных на витрине

Поскольку изначально для инвалидации мы использовали только TTL (2h),  это приводило к неконсистентности в данных.

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

Тем временем требования бизнеса ужесточались. В 2021 году к нам пришли с задачей по запуску доставки за 15 минут и заказчик потребовал, чтобы видимость товара на витрине можно было выключить за секунды. Наш примитивный сервис так не мог. Это требовало более умного подхода к инвалидации кэша и иного уровня осознанности. Важная фича оказалась под угрозой, а мы не могли допустить срыва запуска.

Инвалидация по событиям из Kafka

Мы попросили коллег из систем-источников данных оперативно уведомлять нас обо всех изменениях в товарах через брокер сообщений Kafka.

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

И теперь, когда мы получаем такое событие, то понимаем, какой кусок данных нужно инвалидировать, и сразу удаляем этот ключ из кэша. Его удаление приводит к дополнительным промахам по запросам в кэш — cache miss-запросам, но это не критично для нас. При следующем запросе от клиента мы идём в мастер-систему, получаем уже актуальные данные и кладём их в кэш.

Благодаря этому подходу информация на витрине стала обновляться с минимальными задержками — за секунды, если нет лага в очереди топика. А доставка за 15 минут была запущена.

Вдобавок мы смогли увеличить TTL для ключей с 2 часов до 24. Если мы знаем, что описание товара не менялось, то инвалидировать каждые 2 часа его не нужно. Это повысило процент попаданий запросов в кэш на десятки процентов (точные цифры не сохранились) — такой показатель называется Hit ratio.

Hit ratio = количество попаданий в кэш / количество запросов — основной параметр, который характеризует эффективность кэширования.

Если по какой-то причине мы не получаем ивент об изменениях, то в конечном счёте кэш всё равно будет инвалидирован по TTL и данные доедут до витрины.

Итак, в поисках решения проблемы неконсистентности данных и больших задержек в обновлении мы пришли к инвалидации кэша не только по TTL, но ещё добавили логику инвалидации по событиям.

TTL + версионирование + принудительная инвалидация по событию из Kafka.

Превентивные меры

Кэширование сдеградировавшего ответа от мастер-системы может приводить к неловким ситуациям — ломать отображение товара на витрине и наводить панику на техподдержку.

У товаров огромное множество разных атрибутов и признаков. Есть, например, признак isAdult (18+) — его наличие определяет логику отображения товара на сайте или в приложении и порядок оформления заказа. Все товары 18+ должны быть заблюрены на витрине, а для их просмотра и заказа требуется подтверждение возраста.

(Не)вымышленная история: у мастер-системы один раз в ответе моргнул атрибут «18+», в этот момент мы закэшировали товар как обычный. В результате товар «для взрослых» отображается абсолютно для всех посетителей сайта как обычный, пока кэш не протухнет — а это может длиться сутки или дольше. Продавцы и покупатели успевают это заметить.

Отображение товара "для взрослых"
Отображение товара "для взрослых"

Единичное моргание ответа от сервиса-источника данных приводит к тому, что товар специфичной категории может надолго остаться без атрибута «18+», или любого другого, не менее важного признака.

На больших нагрузках любые очень маловероятные события превращаются в десятки и сотни реальных кейсов.

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

Мы договорились с мастер-системами о том, что когда их сервис, следуя принципу «лучше показать покупателю хоть что-то, чем совсем ничего» в моменте отдаёт обеднённые данные, они добавляют в свой ответ дополнительный параметр degraded = true — по этому флагу мы понимаем, что ответ пришёл неполный и кэшируем его не на 24 часа, а только на 1 минуту.

Например, сервис отдал нам диван без атрибута «крупногабаритный товар» или какой-нибудь минимальный набор дефолтных характеристик вместо полноценного описания. Товар покажется на витрине в урезанном виде, и его хотя бы можно будет положить в корзину. А через минуту мы снова пойдём за его описанием и обновим его.

Совсем не кэшировать degraded -данные тоже нельзя, иначе мы просто приложили бы нагрузкой сервис, который и так нестабилен.

1 минута — это компромисс. С одной стороны, часть нагрузки мы всё равно снимаем, с другой — эти невалидные данные быстро обновятся.

TTL + версионирование + принудительная инвалидация по событию из Kafka + кэшировать degradated-данные на небольшой TTL.


Распределенная синхронизация кэша

Перечисленные способы инвалидации кэша в основном применимы для внешнего хранилища (кроме TTL), но что делать, если кэш хранится в инстансах самого приложения?

Когда кэш живёт в памяти приложения, его нужно удалять одновременно из всех инстансов. Если данные изменяются, то любой рассинхрон в их обновлении приведёт к тому, что клиенты начнут получать разные ответы на один запрос, попадая на разные экземпляры приложения. Сейчас мы держим в памяти product-facade только такую информацию, которая допускает рассинхрон в обновлении. Но если вы столкнулись с такой задачей, она решаема. Для этого используются системы распределенной синхронизации. В неё умеет, например, Redis. Реализация этого решения более трудоемка, чем простое удаление одного ключа из общего хранилища.


Как инвалидировать in-memory-кэш сразу во всех инстансах

Довольно непростой задачей является инвалидация и поддержание консистентного состояния локального (in-memory) кэша. В этом может помочь метод PUB-SUB (publish–subscribe) — асинхронный метод связи между сервисами.

Для поддержания консистентности in-memory-кэшей используются менеджеры очередей (RabbitMQ, Redis), поддерживающие этот механизм подписок — PUB-SUB.

Актуальность и консистентность кэша обеспечивается путем синхронизации удаления из каждого инстанса данных при их изменении. За каждым инстансом приложения закреплена очередь в менеджере очередей. Запись во все очереди осуществляется через общую точку доступа — Topic. Сообщения, отправляемые в Topic, попадают во все связанные с ним очереди. При изменении данных любым инстансом приложения, это значение удалится из кэша каждого инстанса. Последующее обращение инициирует запись актуального значения в in-memory-кэш из основного хранилища данных.


Вывод об инвалидации

К чему мы в итоге пришли

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

  • TTL;

  • удаление кэша при изменении данных по событиям;

  • версионирование ключа на случай ЧС;

  • крайний случай, который мы редко используем и только при массовом попадании битых данных в кэш — поочередный рестарт всех шардов memcached или рестарт приложения, если нужно сбросить локальные кэши;

  • если мы знаем, что получили от сервиса неполные данные, кэшируем их на небольшой TTL;

  • у нас есть API, при помощи которого мы можем точечно инвалидировать конкретные ключи.


Мы прошлись по плюсам и минусам ленивого кэширования, и рассказали, как решаем возникающие при этом проблемы обновления и сброса кэша. Теперь вернемся к стратегиям его прогрева.

Другие стратегии кэширования

Однажды к нам пришли с задачей: нужно передавать SEO-ссылки сервисам витрины. Мы стали думать, как лучше их закэшировать. Ленивое кэширование — далеко не единственный способ наполнить кэш данными.

Кликабельные атрибуты в описании товара
Кликабельные атрибуты в описании товара

Существуют способы, которые, в отличие от Lazy caching, сразу решают проблему неконсистентности и исключают cache miss-запросы.

SEO-ссылки редко изменяются и редко пополняются новыми, от нас не требовалось обновлять их в реальном времени, задержки в этом случае были допустимы.

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

Реализация выглядит так:

Логика наполнения и обновления кэша выносится в отдельный модуль. Мы вынесли её в автономную cron-джобу, которая запускается в фоне один раз в час. Cron идёт в API сервиса SEO и, если сервис сообщает, что за прошедшее время произошли изменения, тогда через gRPC stream выгружается дамп с новыми связками «атрибут товара — ссылка», и мы обновляем кэш по новым связкам. Если SEO-сервис сообщил, что изменений нет, тогда ничего не делаем.

Этот подход кэширования называется сквозное чтение или Read-Through.

Read-Through (Сквозное чтение)

Плюсы Read-Through:

  • Так как приложение всегда читает данные напрямую из кэша и логика записи вынесена в отдельный модуль, логика приложения становится проще.

  • Приложение никогда не обращается к сервису-источнику данных, и нагрузка на источник данных сводится к минимуму.

  • Отказ мастер-системы не влияет на стабильность нашего сервиса.

  • Логика исключает промахи кэша — cache-miss-запросы. hitrate = 100%

Минусы Read-Through:

  • Кэш может быть наполнен данными, которые в реальности никто не запрашивает.

  • Нужно предусмотреть обходной способ наполнить кэш в случае его потери. Иначе клиенты останутся на какое-то время без данных.

Для систем с пишущей нагрузкой тоже есть стратегия, позволяющая избежать проблемы неконсистентности данных и промахов кэша — это сквозная запись Write-through.

У нас нет пишущей нагрузки, и у себя мы её пока не используем, но, возможно, вам будет интересно узнать о существовании такого подхода. —>


Write-through (Сквозная запись)

В этой стратегии данные сначала записываются в кэш, а затем в хранилище данных. Операции записи всегда проходят синхронно через кэш в основное хранилище.

Плюсы

  • Упрощает процесс обновления кэша. Кэш всегда актуален.

  • При достаточном объёме памяти позволяет избежать cache-miss-запросов, что позволяет приложению работать эффективнее и быстрее. hitrate =100%

  • Долгая запись при обновлении данных, но обеспечивает быстрое чтение.

Минусы

  • Кэш может быть заполнен ненужными объектами, к которым нет запросов, но они занимают лишнюю память.

  • Ненужные объекты могут вытеснять из памяти более востребованные.

  • Нужен дополнительный механизм, позволяющий заново заполнить кэш при его потере. Комбинирование двух подходов Lazy caching и Write-through решает эту проблему, так как они связаны с противоположными сторонами потоков данных и дополняют друг друга.


Системный caching-дизайн. Локальное и внешнее хранилище кэша

Выбор стратегии наливки данных в кэш и его инвалидация — это ещё не всё, с чем можно промахнуться при системном дизайне. Есть спорные моменты по вопросу выбора локального (данные хранятся в памяти каждого инстанса приложения) и внешнего хранилища. Мы познали недостатки обоих подходов.

Внешнее кэширование

Горячие ключи и неравномерная нагрузка на шарды кэша

Однажды во время нагрузочных тестов мы столкнулись с тем, что все кэши на одном нашем сервере внезапно откинулись — а это 5 шардов. Ситуация стабильно повторялась на каждом стресс-тесте. На сервере утилизировались все доступные сетевые ядра, сеть забивалась, соединения начинали отклоняться и прерываться. Это фатально, так как все сервисы, живущие на таком сервере, становятся недоступны.

Покопавшись в причинах отказа сервера, из метрик запросов на чтение стало ясно, что шарды memcached нагружаются очень неравномерно. При записи в кэш мы используем согласованное хэширование hashring ключей, поэтому причина была не в неравномерном распределении ключей, а в том, что на какие-то ключи приходится намного больше операций чтения, чем на другие. Такие ключи называются горячими. 

Одним из ключей кэширования у нас является локация покупателя из запроса с витрины. В Москве больше всего покупателей, поэтому локация Москвы оказалась тем самым горячим ключом, на который у нас приходится 85% запросов. Это была только первая проблема.

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

Взрывной рост скорости установки новых коннектов
Взрывной рост скорости установки новых коннектов

Для борьбы с горячими ключами и неравномерной нагрузкой есть разные подходы.

Кабанчик приводит такое решение проблемы горячих ключей:

Если известно, что один конкретный ключ — горячий, то простым решением будет добавление в начало или конец этого ключа случайного числа из заданного диапазона. Простое двузначное число из диапазона 1 - 100 позволит равномерно распределить операции записи и чтения по 100 разным ключам и распределить их по разным шардам.

Этот подход имеет смысл только для небольшого числа горячих ключей.

Но «мы пойдём другим путём» (с).

Между сервисом и memcached мы добавили слой in-memory-кэширования. Эти данные если и изменяются, то крайне редко, поэтому дополнительной логики инвалидации и синхронизации между инстансами приложения не требуется. При старте сервиса локальный кэш прогревается данными, которые уже есть в memcached. Если там их нет, тогда сервис один раз сходит за ними в мастер-систему и надолго закэширует в memcached и в in-memory. После прогрева локации берутся уже только из памяти самого приложения, нет никаких сетевых ходок в memcached, поэтому неравномерное распределение операций чтения по ключам нас больше не беспокоит.

Так мы решили проблему неравномерной нагрузки из-за горячих ключей и заодно сократили количество сетевых запросов в memcached.

Потом мы вынесли логику работы с коннектами к memcached в асинхронный воркер. Благодаря этому у нас появилась возможность регулировать скорость создания новых соединений независимо от прилетающей в наш сервис нагрузки. Сервис просто ждёт свободное соединение в течение установленного таймаута и, если за это время memcached не успел его создать, мы идём за данными в мастер-систему. На практике это небольшой процент запросов, поэтому для мастер-систем это некритично, а нам позволяет сохранить стабильную работу системы.

Дорогая десериализация

Другая проблема внешнего кэша, с которой мы столкнулись, стала причиной того, что в 2022 году мы заняли почётное первое место по утилизации CPU среди всех микросервисов Ozon.

Из-за нехватки ресурсов в кластере релиз-инженерам пришлось даже разрабатывать новый алгоритм деплоя в продакшн гибкой канарейкой.

Раньше деплой канареечного релиза занимал такое же количество ресурсов в кластере, как и сам релиз. Это вело к повышенному расходу реквестов в кластере вплоть до невозможности деплоя. Суть гибкой канарейки — канареечный релиз занимает только необходимое количество ресурсов, которое зависит от направляемого на него в данный момент трафика.

Кэш готовых ответов

Product-facade кэширует готовые ответы и хранит их в memcached в виде массива байтов. В качестве протокола межсервисного взаимодействия у нас используется gRPC. И, для того чтобы переложить массив байтов из кэша в ответ сервиса, каждый раз его требовалось десериализовать в структуру протобафа и сериализовать заново. Звучит как бессмысленная трата ресурсов, не так ли? На эту работу уходило до 50% CPU, в наших масштабах хайлоада это были тогда сотни ядер.

Проблема усугублялась по мере роста объёмов данных и ставила под вопрос целесообразность существования сервиса.

Пришлось учиться пробрасывать готовые ответы клиентам из кэша на лету без десериализации. Для этого мы переписали стандартный плагин protogen с ванильной сериализацией и стали подкладывать массив байтов из кэша сразу в соответствующее поле протобафной структуры.

/// Code generated by protoc-gen-go.

type Product struct {

state         protoimpl.MessageState

sizeCache     protoimpl.SizeCache

unknownFields protoimpl.UnknownFields

Description              *description.Description protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"XXX_Description_RawBytes []byte                   json:"-"``

XXX_Description_RawBytes []byte                   json:"-"

}

///

product.XXX_Description_RawBytes = dataFromMemcached []byte


/// Code generated by protoc-gen-go-vtproto

func (m *Product) MarshalToSizedBufferVT(dAtA []byte) (int, error) {

....

if len(m.XXX_Description_RawBytes) > 0 && m.Description == nil {

		size := len(m.XXX_Description_RawBytes)

		i -= size

		copy(dAtA[i:], m.XXX_Description_RawBytes)

		i = encodeVarint(dAtA, i, uint64(size))

		i--

		dAtA[i] = 0x1a

	}

.....

}

Это решило проблему перерасхода ресурсов на десериализацию готовых ответов.

Кэш сырых данных

Помимо готовых ответов, мы кэшируем сырые данные, которые нам необходимо десериализовать, чтобы использовать их в расчётах.

Нам попадались такие товары, для которых десериализация оказывалась настолько дорогой и так замедляла работу витрины, что в ряде случаев она нивелировала пользу кэширования. Этот процесс занимал до 500-800 ms, это было неприемлемо, так как тайм-аут на весь запрос — 1s, а за это время нужно было выполнить ещё много других операций.

Страница товара не прогрузилась из-за долгой обработки запроса
Страница товара не прогрузилась из-за долгой обработки запроса

Некоторые карточки товаров из-за этого просто не прогружались, и к нам поступали обращения от селлеров.

Было несколько попыток сделать этот процесс более эффективным:

memory pool из vtprotobuf у нас не взлетел, мы не заметили на профилях видимых результатов. Но нашли место, где можно было распараллелить десериализацию через семафор. Потом смогли нормализовать кэшируемые данные. И нашли способы сократить объёмы данных в самой мастер-системе. 

В нашем случае эти действия принесли видимый результат, но в каждой подобной ситуации решение будет своим.

Вывод

Там, где применяется внешнее кэширование, есть вероятность, что рано или поздно придется решать вопросы оптимизации десериализации данных из кэша, чтобы минимизировать затраты ресурсов и ускорить работу. Или не использовать memcached.

Альтернативой комбинации memcached + подхаченный плагин protocgen может быть использование Redis и его hashes.

Плюсы внешнего хранения:

  • Все данные хранятся в одном централизованном хранилище, а значит:

    • прогрев кэша происходит быстрее, чем при использовании in-memory-кэша, при котором нужно прогревать отдельно каждый инстанс приложения;

    • проще логика инвалидации, чем in-memory — нужно удалить только один ключ из централизованного места хранения, а не из каждого инстанса.

  • Может хранить большие объемы данных.

  • Ниже нагрузка на сервисы-источники данных при прогреве. Обычно данные достаточно запросить и записать в кэш только один раз. При кэшировании в in-memory данные нужно запросить столько раз, сколько активных инстансов приложения.

  • Надежность — есть возможность использовать персистентное хранилище и репликацию. Есть key-value-хранилища, которые это поддерживают. Например, Redis.

  • Масштабирование — внешнее хранилище кэша и сервис, который его использует, масштабируются независимо по мере необходимости.

Минусы внешнего хранения:

  • Высокие требования к устойчивости к сбоям. Кэши могут стать несущими для всей системы. В этом случае потеря их существенной части может привести к полному отказу всей системы, так как на время прогрева кэша основная нагрузка ляжет на функционал, который не готов к ним.

  • Затраты на десериализацию кэшируемых данных. Они становятся существенными, если кэшируются большие структуры.

  • Неравномерная нагрузка на шарды кэша. Такое происходит, если есть горячие ключи, на которые приходится непропорционально высокая нагрузка.

  • При изменениях форматов ключей или кэшируемых данных необходимо продумывать и обеспечивать их совместимость.

  • Ребалансировка ключей кэширования при добавлении/удалении шардов приводит к деградации и повышенной нагрузке на сервисы-источники данных. Это проблему частично решает согласованное хэширование.

Локальное кэширование

По мере роста разнообразия кэшируемых данных часть наиболее статичных мы стали прихранивать in-memory с TTL на 3 часа lazy-прогревом.

Из-за постоянного роста нагрузки и разрастания функционала мы и не заметили, как отскейлились с 300 инстансов до 900, и обнаружили, что в каждый прогрев локального кэша (при раскатке нового релиза и по мере его протухания) на графиках видны выраженные пики RPS на нижележащие сервисы. Прогреть разом кэши всех 300 инстансов или 900 инстансов — не одно и то же. К тому же, неравномерные нагрузки с выраженными пиками приводят к необходимости устанавливать повышенные реквесты на ресурсы, в чём тоже нет ничего хорошего.

Пиковые нагрузки на сервисы-источники данных в момент протухания локального кэша
Пиковые нагрузки на сервисы-источники данных в момент протухания локального кэша

К счастью, мы используем канареечный деплой — переключаем трафик со старого релиза на новый постепенно, благодаря этому локальные кэши тоже прогреваются постепенно, и мы не прикладываем нижележащие сервисы резкой нагрузкой. Но одновременный рестарт всех инстансов стал недоступной роскошью для нас — из-за холодного старта это вызывало деградацию response time нашего сервиса и множество вопросов со стороны наших коллег из систем-источников данных. Так, благодаря локальному кэшу мы приобрели дополнительное ограничение по скейлингу инстансов сервиса.

Вывод

Обычно кэширование про высокие нагрузки. Где высокие нагрузки, там вполне ожидаемо большое количество инстансов. А значит каждый инстанс на старте и по мере устаревания кэша должен заливать в память данные. Если количество инстансов растёт, то в итоге горизонтальное масштабирование сервиса может упереться в производительность сервисов-источников данных. Появляется дополнительное ограничение горизонтального масштабирования и даже скорости раскатки нового релиза.

Если локальное кэширование делает сервис со множеством инстансов таким неповоротливым, зачем мы вообще решили кэшить что-то локально?

Всё-таки оно имеет преимущества по отношению ко внешнему кэшу:

Плюсы:

  • Отсутствие проблем с горячими ключами, которые могут приводить к ассиметричным нагрузкам на шарды внешнего кэша. Выше мы уже рассмотрели такой кейс.

  • Отсутствие дополнительных расходов на десериализацию кэшированных данных. Поскольку у нас memcached, для нас это актуально.

  • Отсутствие сетевых запросов — нет дополнительной нагрузки на сеть и сетевых задержек.

Минусы:

  • Ограничение горизонтального масштабирования сервиса.

  • Холодный старт. Кэш необходимо прогревать при каждом рестарте/редеплое сервиса. Это приводит к росту нагрузки на сервисы-источники данных и может вызвать деградацию response time.

  • Просадка времени ответа при рестарте инстансов сервиса. При условии холодного старта и ленивого прогрева кэша.

  • Сложная логика инвалидации in-memory-кэша, которая должна обеспечивать консистентность данных в кэше всех инстансов приложения при помощи средств внешней синхронизации.

  • Хранение одних и тех же данных во всех инстансах приложения — потенциально высокое потребление памяти.

Вывод

Как мы решаем, где хранить кэш, почему бы не хранить всё в памяти инстансов сервиса — так и сетку сэкономили бы и время ответа ускорили, RAM всё равно дешевая.

Мы пришли к такой схеме — локально кэшируем тогда, когда выполняются условия:

  • Небольшие объёмы данных, которые редко изменяются и не требуют инвалидации по событию.

    Пример: справочные значения.

  • Когда основная нагрузка приходится на несколько горячих ключей. Разместив их в in-memory-кэше, можно избежать ассиметричных нагрузок на шарды внешнего кэша.

И при этом всегда добавляем вторым слоем memcached. Благодаря этому кэши не теряются при редеплое сервиса, а при устаревании кэша за исходными данными в мастер-систему нужно сходить только один раз одному инстансу приложения. Все остальные инстансы греют свой локальный кэш уже данными из memcached. И неважно, сколько у нас инстансов — 500 или 1500. Memcached легко держит такую нагрузку.

Кстати, Sticky-session потенциально может повысить эффективность локального кэширования, так как запросы клиентов будут попадать на одни и те же инстансы сервиса.

Борьба за хитрейты и алгоритмы вытеснения данных

Гранулярность кэшируемых данных

Кэширование — наш хлеб. Мы постоянно ищем новые способы повысить хитрейты и сделать кэширование эффективней.

Один из способов повышения хитрейта — дробить большие структуры данных на мелкие куски и выделять подмножества, которые позволят:

  1. не удалять из кэша лишнее при изменениях,

  2. хранить отдельно такие куски данных, которые можно сохранять с более долгим TTL.

Пример: есть большой объект с описанием товара, в котором большая часть полей статичны и никогда не изменяются: бренд, размеры, страна-производитель. Но есть одно поле с информацией о наличии стока, которое изменяется регулярно. Есть смысл разделить этот большой объект на два разных и хранить каждый со своим TTL. Статичные данные можно кэшировать на 48 часов или навечно, а информацию о стоках на 30 минут, например. И при изменениях в наличии стоков инвалидировать только ключ с информацией о стоках.

Вымывание

Ещё мы обратили внимание на постоянное вымывание данных из кэшей. Откуда оно берётся?

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

В большинстве случаев кэш имеет меньший объём памяти, чем основное хранилище. И при достижении этого размера часть объектов кэша будет вытесняться.

Вымывание (eviction) кэшей из шардов memcached
Вымывание (eviction) кэшей из шардов memcached

Вымывание полезных данных приводит к лишним промахам кэша и снижению хитрейтов.

Держать в памяти все данные о товарах просто невозможно и кэшировать весь интернет бессмысленно, далеко не все товары имеют стоки на складах и могут никогда не запрашиваться клиентами.

Если товара давно не было в наличии, он мало кому интересен, а значит держать его постоянно в кэше нет смысла. Но такие товары никогда не удаляются из системы, они нужны для того, чтобы покупатели в любой момент смогли посмотреть информацию о товаре, который они купили несколько лет назад или отзывы. В итоге мы имеем больше 80% «мёртвых» товаров — это такие товары, у которых дольше месяца не было стоков ни на одном складе и не было продаж. Их количество постоянно растёт.

Как я уже упоминала в самом начале, нашими клиентами являются не только сервисы витрины, от которых приходят запросы реальных покупателей с полезной нагрузкой, конвертируемой в заказы, но ещё и аналитические сервисы. Они собирают информацию обо всех товарах, существующих в системе. Раз в день они проходятся по ним. В результате к нам в кэш попадают такие товары, которые не нужны реальным покупателям (виной всему lazy caching). Это и есть основная причина вымывания кэшей.

Так что же мы теряем и теряем ли? — вопрос не праздный. Мы полезли в документацию и исходники, чтобы лучше понять логику вытеснения данных memcached.

Оказалось, что он не так прост. Многие существующие алгоритмы вытеснения уже решают проблему вытеснения полезных данных бесполезными. И вымывание может быть полезным.

В memcached реализован алгоритм Segmented LRU

Segmented Least Recently Used — это модернизированная версия обычного LRU.

Упрощенно: кэш разделен на два сегмента — испытательный и защищённый.

При первом запросе объект попадает в первый сегмент, если объект запросили второй раз, тогда он перекладывается во второй защищённый сегмент. Если повторного запроса не было, тогда такой элемент очень быстро удаляется из кэша. Насколько быстро это произойдет, зависит от конкретных настроек.

Этот метод позволяет избежать переполнения кэша данными, которые не будут использоваться повторно в ближайшее время. Так как защищённый сегмент содержит только те элементы, к которым обращались не менее двух раз.

Вывод

Существует множество алгоритмов вытеснения:

Если бы мы выбрали кэш с вытеснением LRU (Least Recently Used) или FIFO (First in first out), LIFO (Last in first out) работа индексеров действительно была бы для нас проблемой, так как эти алгоритмы не учитывают количество обращений к ключу кэширования.

И в таких условиях, как у нас, когда есть разные клиенты с разными сценариями запросов, подходят более продвинутые алгоритмы: SLRU (Segmented LRU), 2Q (2 queue), MultiQ(Multi queue), LFU (Least frequently used) или адаптивные ARC (Adaptive Replacement Cache), которые могут балансировать между несколькими алгоритмами, подстраиваясь под меняющуюся нагрузку.

Разные NoSQL key-value-хранилища поддерживают разные алгоритмы вытеснения, один или сразу несколько — это следует учитывать при выборе средства кэширования. Redis, например, умеет в несколько режимов: LRU, LFU, random, no-eviction (persistence).

Библиотека ristretto, написанная на Golang, тоже использует достаточно продвинутый способ избавления от лишних данных SampledLFU.

The thundering herd problem или эффект «стаи собак»

Существует ещё один популярный способ прострелить себе колено с помощью кэшей, мы не стали исключением. Проявляться этот эффект может в разных обстоятельствах, но название ему одно — эффект «стаи собак».

Это резкий рост нагрузки на систему, который возникает, когда множество различных процессов приложения (или запросов) одновременно запрашивают один ключ кэша, получают cache miss, а затем каждый из них параллельно выполняет один и тот же запрос к системе источнику данных. И чем дороже этот запрос, тем большее влияние он оказывает.

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

Когда мемкэши начинали сильно деградировать по Response time или не успевали устанавливать дополнительные соединения, наш сервис обрабатывал это так же, как cache miss. И после ходки в мастер-систему записывал в кэш данные, которые не смог получить из кэша. Независимо от того, были ли эти данные в кэше по факту или нет. Это приводило к лавинообразному эффекту. Кэши начинают деградировать, отвечать медленнее или ошибкой, и на каждый такой ответ мы делаем попытку записи в кэш, устраивая таким образом DDoS-атаку пишущей нагрузкой на собственный кэш. Этот эффект похож на известную thundering herd problem.

Другие примеры

  • Истечение срока действия кэша (TTL) популярного товара на маркетплейсе в старт распродажи в интернет-магазине в Чёрную пятницу. Сотни инстансов приложения сначала пойдут за ключом в кэш, не найдут его, и все эти запросы отправятся в мастер-систему, а потом все они пойдут записывать этот ключ в кэш. И хорошо, если все компоненты системы выдержат эту нагрузку.

  • Добавление нового шарда кэша: его память пуста и механизм ребалансировки начинает заполнять её.

Решения thundering herd problem

  • Предварительный прогрев кэша. Это можно сделать, сэмулировав реальные запросы пользователей при старте сервиса и перед тем, как переключить на него трафик. Можно реализовать эту логику в коде самого сервиса или вынести в отдельный скрипт с ручным запуском, который будет прогоняться только по необходимости.

  • Отдавать истекшие данные

    • приложение берёт на себя ответственность за обновление ключа по TTL;

    • кладём в кэш время истечения ключа;

    • при чтении приложение проверяет TTL ключа;

    • если TTL истек, приложение продлевает его ещё на небольшой срок;

    • в это время идет за актуальными данными и обновляет кэш.

Этот способ позволяет снять существенный процент запросов, но не гарантирует только один запрос в основное хранилище.

А что по QA?

Существенная часть наших инцидентов в продакшне была связана с тем, что какие-то сценарии были по недосмотру протестированы нами только с кэшем или только без него.

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

Поэтому теперь все интеграционные функциональные тесты для полноценной проверки логики мы прогоняем сразу в двух режимах: с кэшем и без. Кэширование неслабо добавляет работы по тестированию.

Заключение

«Everything in software architecture is a tradeoff»
Fundamentals of Software Architecture

Мы что-то поняли за это время, и из этого понимания родился чек-лист для самопроверки, которым я хочу поделиться.

Перед тем как начать что-то кэшировать, ответь себе на вопросы

  1. Безопасно ли использовать кэшированное значение?

    Один и тот же фрагмент данных может иметь разные требования к согласованности в разных контекстах.

    Пример: во время оформления онлайн-заказа необходима актуальная информация о наличии товара, поэтому кэширование может быть проблемой. Но на других страницах интернет-магазина информация о наличии товара может быть устаревшей на несколько минут без негативного влияния на пользователей.

  2. Какое допустимое время жизни объектов в кэше?

    Если время жизни объектов в кэше слишком мало, то извлечение данных из основного хранилища и добавление их в кэш будет происходить слишком часто, на это будут уходить лишние ресурсы, а хитрейт будет минимальным, и смысла в кэшировании не будет.

  3. Допустимы ли задержки в обновлении кэша при изменениях в данных или его необходимо инвалидировать сразу?

  4. Как часто изменяются данные?

    Можно навечно закэшировать код страны, потому что в историческом периоде он, скорее всего, не изменится, и это будет нормально. Но кэшировать количество доступных стоков у популярного товара, которые меняются в моменте, возможно, не стоит вообще.

  5. Каков ожидаемый объем кэшируемых данных?

  6. Какие ожидаются сценарии запросов (пользовательское поведение клиентов)?

  7. Ожидаются ли горячие ключи, на которые будет приходиться основная читающая нагрузка?

  8. Эффективно ли будет кэширование?

    Оно эффективно, когда:

    1. данные из кэша приходят быстрее, чем из основного хранилища,

    2. редкая инвалидация,

    3. есть небольшое множество горячих ключей,

    4. чтение преобладает над записью.

    Что снижает эффективность:

    1. частая инвалидация,

    2. кэширование редко запрашиваемых данных,

    3. недостаточный объём кэша,

    4. неоптимальный выбор алгоритма вытеснения данных,

    5. неоптимальный выбор объекта кэширования, который приводит к избыточной инвалидации. Например, кэшируется объект со множеством характеристик и инвалидируется целиком при изменении только одного параметра, который можно было бы закэшировать отдельно.

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


  1. bfDeveloper
    10.11.2023 11:31
    -1

    Простите, что не в личку, но дико задевает. Утилизация в русском языке это про отходы. Вы имели в виду нагрузку или использование.


    1. kma21
      10.11.2023 11:31
      +14

      утилизация ресурсов всегда означала фактическое использование ресурсов. и в русском языке это вполне применимая словарная конструкция.

      в вики:
      https://ru.wiktionary.org/wiki/утилизация

      Этимология

      Происходит от лат. utilisation «использование», далее из utiliser «использовать», далее из utile «полезный», из лат. ūtilis «полезный», из utibilis «годный, пригодный, полезный», далее от гл. uti (вульг. лат. usare) «употреблять, применять, пользоваться», далее из арх. oeti, из праит. *oit-. .


      1. bfDeveloper
        10.11.2023 11:31

        Этимология может быть какой угодно, важно значение в современном языке. Вы же не используете слово "санкция" в значении благославление, освящение? А что, sacer «священный, святой; проклятый».

        В толковом словаре

        УТИЛИЗАЦИЯ, -и; ж. [от лат. utilis - полезный] Использование чего-л. ненужного (отходов производства, быта и т.п.) или не приносящего непосредственной пользы человеку в целях получения (после переработки) какой-л. продукции, энергии и т.п. 


        1. olezhek28
          10.11.2023 11:31
          +10

          А в чем проблема слова? В профессиональной среди утилизацию использую в таком значении, статья из проф сферы, вроде все сходится:)


          1. bfDeveloper
            10.11.2023 11:31
            +2

            Проблема в том, что это не общепринятый термин, это банальный англицизм, который можно заменить на русский эквивалент без потери смысла. Англицизмы сами по себе в профессиональной сфере это нормально, но не когда они пересекаются с существующими русскими словами. Если я скажу, что испытываю симпатию к девушке, то я сочувствую нелёгкой доле (sympathy) или всё же она мне нравится? Жаргон и термины должны избавлять от разночтений, а не добавлять их. В "департаменте утилизации CPU" есть разночтения. Использование б/у железа - очень даже актуальная тема.


            1. unC0Rr
              10.11.2023 11:31
              +4

              сочувствую нелёгкой доле

              Симпатизирую?

              не общепринятый термин

              А может, вы просто его всегда неправильно воспринимали? Да, обычно слово "утилизация" идёт в паре со словом "отходы", но речь не о вывозе на свалку, а именно о полезном применении, переработке.


        1. unC0Rr
          10.11.2023 11:31
          +3

          Вы же не используете слово "санкция" в значении благославление, освящение

          Как же нет, если несанкционированные мероприятия всё время на слуху?


          1. olezhek28
            10.11.2023 11:31
            +3

            По-моему в хайлоуде всегда такое значение было у слова, как в статье описано. Да и в общем то какие могут быть вопросы, если в статье пояснили о чем речь, иначе бы и вовсе не поняли:)


    1. Aconitum_napellus Автор
      10.11.2023 11:31
      +3

      Словосочетание "утилизация CPU" сейчас достаточно устойчиво в профессиональной среде, оно используется в публичных докладах на IT конференциях, поэтому посчитала, что для большинства людей оно будет понятным. А если нет, то станет понятным из содержания статьи.


    1. hVostt
      10.11.2023 11:31
      +3

      Очень давно слово "утилизация" используется крайне активно для описания человеческих и технических ресурсов. Данный термин нельзя заменить словами "нагрузка" и "использование", так как семантика разная. То, что вас это задевает, извините конечно, исключительно ваша проблема.


  1. GoGopher
    10.11.2023 11:31

    Классная статейка!


    1. Aconitum_napellus Автор
      10.11.2023 11:31
      +1

      Спасибо!)


  1. olezhek28
    10.11.2023 11:31

    Вот это я понимаю хайлоад!


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      стараемся!


  1. mv28jam
    10.11.2023 11:31
    +3

    У меня прям до 90% флешбэков случилось.
    Спасибо, прочитал с большим удовольствием.


    1. Aconitum_napellus Автор
      10.11.2023 11:31
      +1

      Очень рада, что откликнулось)


  1. NotSlow
    10.11.2023 11:31
    +5

    Эх... а где-то в параллельной вселенной народ просто закидывает wordpress плюс woocommerce какой-нть, плюс 150 плагинчиков "ну очень нужных"... и все это на какой-нть 1-ядерной 2ггц самой дешевой vps, за нагрузкой на которой даже мысли не возникает ни у кого следить. Страницы генерируются по 5сек и им норм... вот как будет 10сек, тогда решается вопрос переездом на более дорогой тариф. Но опять же, даже мысли не возникает что надо может хотяб кэш-плагинчик установить чтоли.

    Или еще лучше, все это на shared хостинге, от которого вскоре прилетает "превышение нагрузки на cpu". И конечно же для клиента - это хостинг плохой, надо менять... я же ничего такого не сделал, просто стандартный WP с обычными плагинами установил и даже траффика еще нет...


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      ахах) какое разное IT)


    1. d2d8
      10.11.2023 11:31
      +4

      Учитывая, что wordpress это половина сайтов сети, то это не параллельная вселенная, а самая, что ни на есть наша.


  1. CLaiN
    10.11.2023 11:31
    +1

    Очень понятное изложение, отличная статья, спасибо!

    А не рассматривали вариант когда микросервис имеет свой собственный кеширующий прокси спереди, вместо централизованного фасада на все микросервисы?


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      Спасибо! Рада, что получилось донести что-то интересное)

      По поводу кеширующего прокси - не совсем ясна идея, имеется ввиду перед каждым микросервисом свой прокси?


      1. CLaiN
        10.11.2023 11:31

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


        1. Aconitum_napellus Автор
          10.11.2023 11:31

          Сейчас такой вариант не рассматриваем.
          А чем лучше относить затраты на инфраструктурый сервис, а не на бизнесовый?
          У нас кстати не чисто инфраструктурный сервис же, у нас довольно мудреная логика по расчету доступности товара для пользователя, которая зависит от многих факторов и условий доставки.


          1. Aconitum_napellus Автор
            10.11.2023 11:31

            Опечатка в вопросе. Хотела спросить, чем лучше относить затраты на бизнесовый сервис, а не инфраструктурный?


  1. abulanov
    10.11.2023 11:31

    Спасибо огромное, прекрасная статья!


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      Радостно очень, что понравилось!


  1. murkin-kot
    10.11.2023 11:31
    +7

    Немного дёгтя в бочку мёда от комментаторов выше.

    Сопоставим две цифры:

    В пике мы отдаём 350 Gb/s данных о товарах

    и

    наша цель — держать 1 млн RPS

    в результате имеем 350*10^9/10^6 = 350 000 байт на один запрос. Напомню - речь идёт о запросах на остатки товаров. Предположим, что под идентификатор товара используется 8 байт, под количество - ещё 4 байта. Плюс обвязка из бинарного протокола, пусть ещё 8 байт на запрос. Итого - могло бы быть 20 байт вместо 350 килобайт. Как тебе такое, Илон Маск?

    Ещё циферка:

    666 инстансов сервиса, написанного на Golang, по 7 ядер CPU и 7.5 Gb RAM на каждый

    При пике в 1М RPS имеем 1500 запросов к одному серверу в секунду. Да, если всё читать с диска - сервер не потянет. Но на нём, между прочим, есть 7.5 Gb RAM. То есть надо по идентификатору найти в индексированном дереве один узел. Время такого поиска не превышает нескольких микросекунд. То есть, даже с учётом накладных расходов, имеющееся в наличии время в размере 666 микросекунд на запрос является очевидно избыточным. Но ребята не останавливают свою кипучую деятельность и осваивают хитрые схемы управления многоуровневыми кэшами. Ну что тут сказать, чем бы дитя не тешилось - лишь бы не плакало. А бизнес вполне справедливо оплачивает эту потеху.

    В целом хочу сказать спасибо за предоставленные цифры. Понимаю, что последует масса возражений про "да ты ничего не понимаешь, у нас всё намного сложнее, и вообще бизнес нами доволен!". Ну да ничего, главное - для умеющих считать в объёме начальной школы уже очевидна неэффективность принятого в данной корпорации подхода. Хотя разумеется, если кому-то нравится всё мерить чем-нибудь вроде "скорости релизов" или ещё каких-нибудь малопонятных попугаев, приводимых без возможности сравнить с альтернативами, то я заранее готов принять на себя огонь критики и признать своё поражение в борьбе с недоказуемыми утверждениями.


    1. CLaiN
      10.11.2023 11:31
      +14

      У меня при делении 350Гбит/сек на 1млн запросов получается 45кб. Похоже вы перепутали биты и байты.

      Также я не очень понял как json с бэкенда со всей хурмой которую видно на странице товара (описание, цвет, производитель и т.п), не говоря уже про метаданные, у вас влезает в 20 байт. Не знаю конечно чего там на 45кб, без картинки я бы предположил 1-10кб на запрос, с картинкой в среднем 45кб легко может выходить.


      1. murkin-kot
        10.11.2023 11:31
        -4

        У меня при делении 350Гбит/сек на...

        В исходном тексте было: 350 Gb/s. Автор не уточнил, что это, ну а я выбрал наиболее часто используемый вариант трактовки данной аббревиатуры. Если вы настаиваете именно на гигабитах, то попросите автора прямо подтвердить этот момент.

        Также я не очень понял как json с бэкенда со всей хурмой которую видно
        на странице товара (описание, цвет, производитель и т.п), не говоря уже
        про метаданные, у вас влезает в 20 байт

        Ну да, когда нет понимания темы "оптимизация", тогда в запросы включают картинки, ну и развешивают другую "хурму" на десятки, или даже сотни килобайт. Хорошо хоть не мегабайт.


        1. CLaiN
          10.11.2023 11:31
          +4

          Наиболее частый и общепринятый вариант - все таки гигабиты. https://en.wikipedia.org/wiki/Data-rate_units

          Дальше вы как-то не конструктивно пошли, увы.


    1. Aconitum_napellus Автор
      10.11.2023 11:31
      +3

      Уточнение: RPS (request per seconds) - это не тоже самое, что PPS (products per seconds), это разные метрики. В одном запросе может быть больше одного товара.

      А что заставило вас думать, что речь идёт о "Напомню - речь идёт о запросах на остатки товаров"?

      В тексте об этом написано и на схеме визуализировала, что речь идет об информации из нескольких десятков разных систем. В общих масштабах информация об остатках товаров - это примерно ничего от общего объема данных.
      Если вдаваться в детали, то дальше по тексту упоминается множество атрибутов товара, ещё, есть, например, аспекты - эта другая сущность, их тоже может быть множество.
      Помимо существует множество разных сущностей - склеек товаров по разному принципу, чтобы выдавать разного рода рекомендации, и для все них тоже тоже требуется полное описание, и тд и т д и т д.
      В самих запросах при этом ни по одному товару, а от 1 до 1 000, в зависимости от задачи.
      В очень общих чертах об этом в статье упоминается. Но статья совсем не об этом и не про расчёты, поэтому считаю лишним перегружать её этим.


      1. murkin-kot
        10.11.2023 11:31
        -5

        А что заставило вас думать, что речь идёт о "Напомню - речь идёт о запросах на остатки товаров"?

        Ваши слова:

        Наш сервис выполняет роль фасада по товарам и рассчитывает на лету доступность товаров для всех сервисов витрины

        Доступность товара, в общепринятом понимании, есть количество в наличии, то есть остатки. Но разумеется, вы можете включить тему "у меня другая трактовка" и опять попытаться "сохранить лицо".

        Если вдаваться в детали, то дальше по тексту упоминается множество атрибутов товара...

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

        Но в вашей ситуации это всё не нужно. Потому что ваша задача на миллион запросов в секунду намного проще.


        1. Aconitum_napellus Автор
          10.11.2023 11:31
          +2

          Возможно, вы хотите к нам на проект поработать, реализовать лучшие решения? Нам как раз пора думать над подготовкой к следующему сезону)


          1. murkin-kot
            10.11.2023 11:31
            -1

            Нет смысла локально оптимизировать то, что давно мертво в следствии глобальной оптимизации (обычно по критерию "мне так проще", но иногда ещё и деньги участвуют).


    1. koyard
      10.11.2023 11:31
      -1

      Приятно читать, по настоящему фундаментальный, анализ информационной системы данной корпорации. Наконец-то бизнес прозреет и сократит этих детишек. Так их, пусть дворы метут.


  1. martyncev
    10.11.2023 11:31
    +3

    Вот почему-то захотелось поработать в вашей команде и набрать релевантного опыта... Но увы.. Java мое всё.


    1. Aconitum_napellus Автор
      10.11.2023 11:31
      +3

      Моим первым языком тоже была Java, не пожалела, что перешла на golang)


      1. Grabr
        10.11.2023 11:31

        Статья огонь! Спасибо!


        Но не понимаю, что не так с Java. Если нужно экономить потоки, то есть реактивщина (хоть и не всем нравится стиль программирования). А так вышла 21-ая LTS с легкими потоками а-ля Go. Да, пока многие библиотеки еще не адаптированы, и в самой фиче есть, что доработать, но через пару релизов, думаю, будет все окей, к следующему LTS так точно.


        1. Aconitum_napellus Автор
          10.11.2023 11:31

          К java вопросов нет, в озоне она тоже есть) Конкретно на нашем проекте исторически сложилось так, инфраструктура наиболее развита под go.


          1. Grabr
            10.11.2023 11:31

            Тут скорее хочется узнать чем вас зацепил go, возможно даже без сравнения с Java (а можно и со сравнением). Не для для холивара go-java, а из интереса.


    1. arakabar
      10.11.2023 11:31
      +1

      Высоконагруженная Java в Озоне тоже есть


  1. vklimov
    10.11.2023 11:31

    В тексте опечатка? - "Сейчас мы не держим в памяти product-facade такую информацию, которая не допускает рассинхрона в обновлении". 


    1. Aconitum_napellus Автор
      10.11.2023 11:31
      +1

      Опечатки нет, но два отрицания в одном предложении плохо воспринимаются. Поэтому переформулировала более читаемо, спасибо!


  1. plFlok
    10.11.2023 11:31
    +5

    докину ещё несколько трюков, которые в статье [вроде бы] не упомянуты:

    1. Не ставить TTL константой в 2 часа, а рандомизировать в диапазоне 110-130 минут. Иначе ключи, появляющиеся одновременно (например, по крону), протухают тоже одновременно и разом бьют по базе.

    2. Вместо продлевания TTL в решении проблемы "стаи собак" можно зайти с другой стороны - с некоторой малой вероятностью умышленно проигнорировать кеш и отправить один запрос читать напрямую из баз и обновлять ключ. Например, с вероятностью 0.001%. Если это настоящий хайлод-ключ с 1000qps - он будет обновляться раз в ~100 секунд без нагрузки на базу. Если ключ не по-настоящему хайлодный, то его протухание по ttl не ударит по базе.

      В особо упоротых случаях вероятность делают динамической, и чем меньше времени осталось до протухания, тем выше делают вероятность "пробива" кеша.

    И вопрос: вместо размазывания ключа по нескольким индексам сразу с разными префиксами/суффиксами вы выбрали in-memory-кеш с кафкой. Если вы знали о схеме с размазыванием ключей, но выбрали другую, то какие критичные для себя минусы вы увидели в схеме с размазыванием?

    Почему спрашиваю: когда-то на проекте с такими же qps довольно неплохо жили как раз на размазывании, и массовая инвалидация не требовалась: требовалось массовое обновление до актуального состояния. И тогда тот кусок бизнес-логики, который обновлял данные в бд, сам актуализировал их в N репликах ключа, новая нагрузка на кеши и базу не создавалась, обновлённые данные были видны всем. А если и были ошибки сети на обновлении 1 ключа, то они случались так редко, что залипшая 1 из N реплик данных нам вредила не очень сильно как раз из-за сравнительно быстрой актуализации через трюк №2.

    p.s.: имхо, странно, что в статье делается много акцента на инвалидации кешей, а не на их актуализации. Хотя это намного более приятная процедура.


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      Спасибо за дополнение!

       Если вы знали о схеме с размазыванием ключей, но выбрали другую, то какие критичные для себя минусы вы увидели в схеме с размазыванием?

      Критичных минусов в этой схеме для себя не видели, а решили идти таким путем, чтобы заодно снять немного нагрузки с сети. Сеть у нас была "ближайший ботлнек" на наших серверах. Это связано с тем, что мемкешам мало требуется CPU для работы, а данных гоняется очень много. В итоге сетевой интерфейс - это было первое, во что мы уперлись бы.


    1. Aconitum_napellus Автор
      10.11.2023 11:31


      P. S.: имхо, странно, что в статье делается много акцента на инвалидации кешей, а не на их актуализации. Хотя это намного более приятная процедура.

      У нас была мысль сделать так, чтобы при получении ивента об изменениях из кафки мы не просто инвалидировали кэш, а сразу писали новое значение.
      Но мы прикинули, что:
      1) существенного профита относительно текущего подхода нам это не даст, поэтому пока не стали переделывать. По всем ключам, по которым это возможно, у нас хитрейт итак сейчас около 100%.
      2) не для всех ключей у нас это возможно, так как, например, доступность товара у нас кэшируется по локации пользователя. Доступность товара напрямую зависит от неё.

      Но вполне возможно, что в какой-то момент часть инвалидации мы заменим обновлением.


  1. dph
    10.11.2023 11:31
    +2

    Я все-таки не понял, откуда 300k rps нагрузки на этот сервис?
    Или каждый товар на странице ozon всегда приводит к запросу на сервис, т.е. если из поиска показывается, например, 100 товаров, то это дает 100 запросов?
    Впрочем, откуда 3000 страниц поиска в секунду в среднем - тоже не очень понятно (да и обычно там гораздо меньше 100 товаров).
    Хм, а улучшение поиска как метод борьбы с нагрузкой не рассматривалcя? На порядок-два количество просмотров точно можно уменьшить.


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      В одном запросе у нас может быть от 1 до 1 000 товаров, в зависимости от задач клиента.
      Нагрузка складывается из сервисов витрины: поиск, каталог, карточка товара, корзина, избранное, отзывы, рейтинги, чекаут т.д. и т.п., из сервисов кабинета селлера, из аналитических сервисов, и других нетипичных.

      Оптимизациями поиска у нас занимается совсем другой департамент, поэтому, к сожалению, их работу прокомментировать не могу.


  1. Panzerschrek
    10.11.2023 11:31
    +1

    Про проблему стаи собак: читал где-то, что разработчики Discord решили её за счёт поддержания в памяти набора активных запросов к бекенду. Если приходит запрос на те же данные, на которые уже есть активный запрос, то нового обращения не происходит. Когда данные от бекенда будут получены, они будут отданы всем запросам на них.


    1. AterCattus
      10.11.2023 11:31

      В гошке для этого есть стандартный пакет https://pkg.go.dev/golang.org/x/sync/singleflight


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      Спасибо за дополнение!


      Уточните пожалуйста, речь идет о прихранивании набора активных запросов на стороне инстанса сервиса, который выполняет запрос? В рамках одного инстанса так проблема решается, но если инстансов тысяча... или я не уловила идею?


      1. Panzerschrek
        10.11.2023 11:31

        Подробности здесь: https://discord.com/blog/how-discord-stores-trillions-of-messages.
        Насколько я понял, у них это в рамках одного инстанса решается (специфика такая).


        1. Aconitum_napellus Автор
          10.11.2023 11:31

          Спасибо!


  1. sved
    10.11.2023 11:31

    Как может десериализация данных занимать целую секунду? Там мегабайты данных что-ли?
    Зачем вообще что-то десериализировать? Вы же байты кешируете, не так ли?

    С чем связан выбор го для бакэнда? В той же Java вопросы перформанса решаются достаточно просто, есть разные библиотеки для кеширования, развитые средства профайлинга и мониторинга, есть куча разработчиков которые понимают как делать быстро и надёжно. Если бы я стал что-то делать на го, то я бы никогда не достиг бы такой производительности как на Java или C++, просто потому что язык малоизвестный и непонятно как его "готовить".


    1. AterCattus
      10.11.2023 11:31
      +1

      Если в вашем сообщении поменять местами Java и Go, то смысл не изменится :) Непонятность и малый опыт разработчиков на рынке - это что-то лет так 10 назад, или больше.


    1. Aconitum_napellus Автор
      10.11.2023 11:31
      +1

      С чем связан выбор го для бакэнда? В той же Java вопросы перформанса решаются достаточно просто, есть разные библиотеки для кеширования, развитые средства профайлинга и мониторинга, есть куча разработчиков которые понимают как делать быстро и надёжно.

      Выбор связан с уже имеющейся развитой инфраструктурой под go.
      Вряд ли среди джавистов намного больше высококвалифицированных, свободных, и находящихся в активном поиске разработчиков, чем в go.
      В golang тоже вполне себе неплохие средства профайлинга имеются, ими и пользуемся про поиске мест для потимизаций. https://go.dev/blog/pprof


  1. creker
    10.11.2023 11:31

    Для инвалидации in-memory всех инстансов не нужно заводить редисы и прочие pub-sub. Достаточно каждый инстанс посадить на топик без consumer group и пусть каждый читает одни и теже эвенты.

    По thundering herd. Есть простое решение в виде библиотеки singleflight и подобной ей механизмов. Пока один запрос выполняется, все остальные стоят и ждут на локе. После разблокировки все получат один и тот же ответ и дальше пойдет раздача из кэша. Можно сделать лок распределенным, но в большинстве случаев не страшно, если будут локальные локи и каждый инстанс пойдет на промахе один раз в основной стор.


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      Для инвалидации in-memory всех инстансов не нужно заводить редисы и прочие pub-sub. Достаточно каждый инстанс посадить на топик без consumer group и пусть каждый читает одни и теже эвенты.

      Такой способ действительно есть, спасибо за дополнение. Конечно это не учебник и не методичка по системному дизайну, тут не раскрыты все распрастраненные подходы. Но на выбор оптимального решения могут влиять разные факторы, поэтому я бы не стала утверждать, что топик кафки во всех случаях будет лучшим решением. Наример, если на проекте уже используется redis, как основное хранилище, а локальный кэш должен сбрасываться при изменениях в redis.


  1. AterCattus
    10.11.2023 11:31
    +1

    Хорошая статья. Местами прямо перебор с оверинжинирингом и NIH, о чем, в том числе, часть комментов выше, но и полезное есть.


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      Спасибо за фидбек! А можно пожалуйста развернуть мысль, в чем NIH?


  1. gazkom
    10.11.2023 11:31
    -1

    Наверное, у меня какой-то другой озон. Зашел на главную, набрал в поиске запрос. Через 26 секунд перестали крутиться часы в загрузке страницы и показались картинки верхнего ряда и то не все. Зачем тогда это всё? И это не только сегодня так поиск работает.

    Дальше кручу ниже, там такое:

    Вам процесс важен или результат?


    1. Aconitum_napellus Автор
      10.11.2023 11:31

      Оценить дистанционно откуда конкретно у вас возникли такие проблемы, к сожалению, не представляется возможным.

      У нас ведется постоянный мониторинг TTLB, TTFB, LCP, SpeedIndex метрик, и все случаи деградации расследуются для установки причин и их устранения.


      1. gazkom
        10.11.2023 11:31

        У меня нет проблем. Проблемы у озона, и возникают они у разных людей.


  1. VDacc
    10.11.2023 11:31

    Какой у вас объем данных во внешних кэшах?

    1) Проблему прогрева in-memory кэшов можно решать с помощью файлов. Собирать кэш по частоте запросов в файл с помощью оффлайн процесса. На старте дергать файл из Object Storage и десериализовать в память. Это быстрее и дешевле, чем пиковые нагрузки KV хранилища при рестарте.

    Инвалидацию и обновление так же оставить на кафке. Приложение может начать читать обновления с оффсета прописанного в файле кэша.

    2) У вас низкое соотношение ядер к памяти. Если поднять до стандартных 1к4 или 1к8 для RAM-оптимизированных, то можно отказать от внешнего кэша совсем при правильном шардировании или sticky-sessions без изменения логики приложения. Я так сэкономил 200к$/месяц перенеся кэши из редиса в память general purpose AWS EC2-инстансов.

    3) Посмотрите в сторону memory-mapped файлов на SSD и их индексов в памяти. Сам видел 200Гб информации о товарах на SSD для системы рекоммендации от вашего азиатского конкурента. У них latency SLO  был около полсекунды и это включая рекомендации. Но у них не было обновлений. По идее можно хранить последние обновления в памяти, пока новый индекс не подъедет.


  1. amaprograma
    10.11.2023 11:31

    Ого, ачивку за самый большой хайлоад заказывали для моей команды, когда мы выкатили скидки в начале года