Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS

в 11:06, , рубрики: Без рубрики

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 1

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


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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 2

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

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 3

Средняя дневная нагрузка на product-facade сейчас около 300К requests per second</p>" data-abbr="RPS">RPS.

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

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 4
Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 5

Пользователи 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, несколько клиентов, несколько мастер-систем, из которых мы агрегировали и кэшировали данные самым примитивным способом, и регулярные инциденты в продакшне.

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 6

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

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

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

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

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 7

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

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

Плюсы Lazy caching:

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

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

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

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

Минусы Lazy caching:

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

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

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

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

Для его удаления и обновления изначально мы использовали только ограничение срока его жизни — единый <strong>Time-to-live</strong> — это время, после которого данные будут удалены из кэша, оно устанавливается при записи объекта в кэш</p>" data-abbr="TTL">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.

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 10

И теперь, когда мы получаем такое событие, то понимаем, какой кусок данных нужно инвалидировать, и сразу удаляем этот ключ из кэша. Его удаление приводит к дополнительным промахам по запросам в кэш — 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.

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 12

Актуальность и консистентность кэша обеспечивается путем синхронизации удаления из каждого инстанса данных при их изменении. За каждым инстансом приложения закреплена очередь в менеджере очередей. Запись во все очереди осуществляется через общую точку доступа — 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 (Сквозное чтение)

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 14

Плюсы Read-Through:

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

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

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

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

Минусы Read-Through:

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

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

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

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


Write-through (Сквозная запись)
Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 15

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

Плюсы

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

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

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

Минусы

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

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

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


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

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

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 16

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

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

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

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

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

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

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

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

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

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

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 18

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

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 19

Другая проблема внешнего кэша, с которой мы столкнулись, стала причиной того, что в 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, а за это время нужно было выполнить ещё много других операций.

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

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 21

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

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

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

Вывод

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Ребалансировка ключей кэширования при добавлении/удалении шардов приводит к деградации и повышенной нагрузке на сервисы-источники данных. Это проблему частично решает <em>consistent hashing</em></p>" data-abbr="согласованное хэширование">согласованное хэширование.

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

По мере роста разнообразия кэшируемых данных часть наиболее статичных мы стали прихранивать 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 минут, например. И при изменениях в наличии стоков инвалидировать только ключ с информацией о стоках.

Вымывание

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

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 23

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вывод

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 25

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

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 26

И в таких условиях, как у нас, когда есть разные клиенты с разными сценариями запросов, подходят более продвинутые алгоритмы: 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, а затем каждый из них параллельно выполняет один и тот же запрос к системе источнику данных. И чем дороже этот запрос, тем большее влияние он оказывает.

Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS - 27

На ранних стадиях развития проекта нашим ботлнеком часто становились 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. неоптимальный выбор объекта кэширования, который приводит к избыточной инвалидации. Например, кэшируется объект со множеством характеристик и инвалидируется целиком при изменении только одного параметра, который можно было бы закэшировать отдельно.

Автор: Ремнева Мария

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js