Миссия выполнима: как мы добились актуальности двух тысяч кешей
- воскресенье, 30 ноября 2025 г. в 00:00:04

Привет! Меня зовут Влад, и я разрабатываю сердце витрины Ozon — сервис product-facade. Пару лет назад мы уже делились нашим опытом в этой статье, но с тех пор многое изменилось: выросли нагрузки, появились новые фичи и оптимизации, система стала сложнее и надёжнее.
Прежде чем перейти непосредственно к актуальности кешей, давайте разберёмся, почему это так важно. Представьте: вы добавляете товар в корзину, но что-то пошло не так, и покупку совершить не удаётся — склад больше не возит в ваш ПВЗ. Даже 0.1% таких ошибок — это тысячи недовольных пользователей каждую секунду. А когда что-то массово меняется, разработчики вынуждены расследовать инцидент, чтобы понять, что проблема была всего лишь в устаревших кешах.
Наш сервис — фасад и главный источник информации для любого товара. Каталог, поиск, корзина, страница оформления заказа, избранное, кабинет продавца и всё-всё-всё остальное — наши клиенты. Мы собираем контент, рассчитываем доступность и синхронизируем данные для 200+ микросервисов.
Под (Kubernetes Pod) — минимальная deploy-единица в Kubernetes, которая может содержать один или несколько контейнеров, разделяющих ресурсы.
Valkey — форк Redis, совместимый с его API. Разработан сообществом после изменения лицензии Redis.
Инвалидация — процесс удаления или обновления устаревших данных.
Мастер-система — основной источник данных, сервис, владеющий ими.
Средний дневной трафик у нас 500 тысяч RPS, а на распродажах и нагрузочных тестах — до 2 миллионов.
Каждый запрос в среднем содержит 50 товаров, такая нагрузка превращается в 580 гигабит трафика ежесекундно.
Сама идея довольно проста: 90% данных мы отдаём из кешей, снимая нагрузку с мастер-систем. Но на таких объёмах довольно простая идея «посчитать, положить в кеш, отдавать из кеша» превращается в мощный вызов. Поскольку подов у нас 2000, а кешей в Valkey — 115 терабайт, мы используем множество оптимизаций и хаков, о которых уже рассказывали.
Но есть вопрос, который является нашей ежедневной проблемой: как инвалидировать всё это богатство? Мы живём в условиях, когда ключей миллиарды, данные меняются постоянно, и даже один устаревший ключ может стать проблемой.

Эту схему мы используем годами, она проста и надёжна. Процесс начинается с обновления данных в мастер-системе. Как только изменения применяются, событие отправляется в топик Kafka.
За их обработку отвечает вспомогательный сервис pf-consumer. Он постоянно мониторит десятки топиков и инициирует запросы на инвалидацию данных в product-facade.
Получив которые, product-facade удаляет устаревшие данные из Valkey. Это гарантирует, что в следующий раз пользователи получат актуальную информацию уже из мастер-системы и обновят кеш.

С развитием бизнеса возрастают требования к скорости обработки запросов. Valkey очень производительный, но и его может не хватать, поэтому мы экономим время на синхронных сетевых взаимодействиях, а для части данных отказываемся от них вовсе.
Кроме того, если информация распределена по небольшому множеству ключей (например, по 200), при частых обращениях к этому множеству ключи моментально становятся горячими (часто запрашиваемыми). Это создаёт высокую неравномерность в распределении нагрузки и ухудшает производительность.
Несмотря на то, что обычно In-Memory-кешом называют любой кеш в оперативной памяти, в том числе и Valkey, для упрощения терминологии в этой статье под термином «In-Memory» будем понимать кеш в каждом поде сервиса.
In-Memory-хранилища оправданы при соблюдении некоторых условий:
маленький объём данных с низкой вариативностью,
высокая частота запросов,
небольшая задержка и неконсистентность некритичны.
Простейший пример — информация о складе: она нужна почти всегда при определении доступности товара, и при этом мы чётко знаем, сколько складов у нас есть и как часто появляются новые. Но даже при выполнении всех этих условий решение об использовании In-Memory должно приниматься после тщательного анализа.
Как известно, в программировании две сложности: именование переменных и инвалидация кешей. И если с первым нам могут помочь нейросети, то придумать, как гарантировать актуальность данных в кешах, они не в состоянии. Особенно остро эта проблема проявляется при работе с In-Memory.
Система использует Valkey для распределённого кеширования, синхронизируется с мастер-системой, но параллельно в каждом из 2000 подов живёт свой In-Memory-кеш. Теперь попробуйте обеспечить консистентность данных между всеми этими слоями.

Множественность источников истины — данные теперь живут не только в мастер-системе и Valkey, но и в десятках/сотнях/тысячах экземпляров приложения.
Скорость vs актуальность — скорость работы In-Memory-кеша компенсируется проблемами с моментальной инвалидацией.
Сложность отслеживания — не существует единого места, где можно посмотреть текущее состояние всех кешей.

При сохранении в кеш мы устанавливаем для каждого ключа время (time-to-live), в течение которого мы считаем его актуальным.
Плюсы:
простота реализации: не требуется сложная логика отслеживания изменений данных;
предсказуемость: мы точно знаем, когда произойдёт устаревание кеша;
отказоустойчивость: если система событийной инвалидации выйдет из строя, TTL выступит в роли страховочного механизма, гарантируя, что данные обновятся.
Минусы:
временная несогласованность данных: если они изменятся до истечения TTL, кеш будет устаревшим. Это компромисс между актуальностью, производительностью и сложностью;
неэффективность использования ресурсов: данные могут быть удалены из кеша, даже если они всё ещё актуальны и популярны;
потенциальная нагрузка на мастер-систему: если TTL многих ключей истекает одновременно, это может привести к всплеску запросов для их перезаполнения (мы избегаем подобного, добавляя к TTL некоторую случайную величину).
Стратегии использования:
для данных с низкими требованиями к актуальности: идеально подходит для кеширования статичного или редко меняющегося контента;
как страховочный механизм: использование TTL в сочетании с другими стратегиями (например, с событийной инвалидацией) позволяет гарантировать, что данные рано или поздно будут удалены.
Идея заключается в использовании встроенного в Valkey механизма Pub/Sub. Все поды подписываются на определённый канал в Valkey. При изменении данных в него необходимо записать сообщение с устаревшим ключом, получив которое каждый экземпляр приложения удаляет его из своего In-Memory.
Плюсы:
мгновенная инвалидация: кеш обновляется практически в реальном времени;
эффективное использование ресурсов: при наличии механизма событийной инвалидации мы можем увеличить TTL;
снижение нагрузки на мастер-систему: избегаются ситуации, когда TTL истекает у большого количества ключей и они одновременно запрашиваются из мастер-системы или внешнего кеша.
Минусы:
сложность реализации: требуется разработать и внедрить механизм публикации событий при изменении данных, механизм подписки и инвалидации;
гарантия доставки at-most-once: событие в любом случае будет отправлено только один раз, подтверждение от пода не ожидается, события легко могут теряться, и вы этого никак не заметите (хотя, конечно, можете косвенно это определить по разрыву соединения с Valkey).
Стратегии использования:
обязательное использование в гибридной схеме с TTL: Pub/Sub выступает основным механизмом для мгновенной инвалидации в идеальном сценарии. TTL же страхует от потери сообщения. Без TTL система будет копить несогласованность;
для данных, где допустима временная неточность: Pub/Sub идеален для инвалидации данных, которым критична высокая производительность и хорошая актуальность. Если сообщение потеряется и кеш некоторое время будет содержать устаревшие данные, это не приведёт к критической ошибке, а TTL в конце концов исправит ситуацию;
не рекомендуется для данных, требующих абсолютной гарантии инвалидации: для данных, где любая несогласованность недопустима, чистого Pub/Sub недостаточно. В таких случаях следует рассмотреть использование более надёжных механизмов, таких как Valkey Streams или брокеров сообщений вроде Kafka или RabbitMQ.

Во многом это решение именно для нашей проблемы. Приложение подписывается на события Valkey, генерируемые при изменении ключей или истечении их TTL. Когда исходные данные в мастер-системе изменяются, приложение не публикует событие, а удаляет или обновляет ключ в Valkey. А уже это действие генерирует Keyspace-событие, которое Valkey автоматически публикует в канал Pub/Sub. Все поды, подписанные на этот канал и не имеющие сетевых проблем, получают уведомление и удаляют указанный ключ из своего In-Memory-кеша.
Плюсы:
обладает теми же плюсами, что и Pub/Sub;
самостоятельно управляет рассылкой уведомлений.
Минусы:
сохраняет большую часть минусов Pub/Sub;
все эти действия повышают потребление CPU в Valkey на мастер-ноде, что может стать узким местом в производительности.
Стратегии использования:
для гибридной схемы с TTL Keyspace Notifications служат для мгновенной инвалидации, в то время как TTL выступает страховкой;
не рекомендуется для высоконагруженных систем с большим объёмом записей. Большой поток событий может захлестнуть подписчиков и создать нагрузку на Valkey. В таких случаях лучше использовать более эффективные и надёжные механизмы, вроде Valkey Streams.

Valkey Streams — это устойчивая, упорядоченная структура данных, работающая по принципу лога. При изменении данных в мастер-системе один из подов публикует событие инвалидации в stream. Каждый экземпляр приложения является отдельной consumer group и постоянно слушает этот поток. Получив событие, под удаляет ключ из своего In-Memory-кеша. Механизм потребительских групп и подтверждений гарантирует обработку каждого события каждым подом.
Consumer Group (потребительская группа) — это механизм в системах обмена сообщениями, позволяющий группе потребителей (консьюмеров) совместно обрабатывать данные из одного топика. Участники группы автоматически распределяют между собой разделы (партиции) топика, обеспечивая параллельную обработку и масштабируемость.
Плюсы:
гарантия доставки (at-least-once): в отличие от Pub/Sub, сообщения в Streams сохраняются на диск и ожидают обработки. Механизм подтверждения решает главную проблему Pub/Sub — потерю сообщений;
устойчивость к сбоям: сообщения не теряются при отключении потребителей. Как только под подключается снова, он получает все пропущенные события с момента последнего подтверждения;
буферизация сообщений: Streams выступают в роли буфера, сглаживая пики нагрузки и не позволяя подам быть перегруженными всплеском событий инвалидации.
Минусы:
очень высокая сложность реализации. Кроме общего почти для всех методов механизма публикации и чтения событий, необходимо предусмотреть:
партиционирование. Скорее всего, вас не устроит случай, когда весь поток событий хранится на одной ноде — придётся самостоятельно писать механизм распределения ключей;
удаление старых событий. Придётся очень тщательно настраивать размер потока и время удаления сообщений.
дополнительная нагрузка на Valkey. Необходимость постоянно хранить поток сообщений и управлять ими создаёт более высокую нагрузку на ресурсы по сравнению с Pub/Sub и Keyspace Notifications.
Стратегии использования:
в качестве альтернативы брокерам сообщений (Kafka, RabbitMQ). Streams предоставляют достаточно надёжный и функциональный механизм для построения системы инвалидации без привлечения тяжёлых внешних зависимостей, если Valkey уже используется в проекте;
в гибридной схеме с TTL. Несмотря на высокую надёжность, стратегию всегда следует дублировать TTL. Это страхует на случай багов в логике обработки сообщений или недоступности пода, превышающей лимит хранения сообщений в Streams.

Используем брокер сообщений в качестве шины событий для распространения уведомлений об инвалидации. Когда исходные данные изменяются, в соответствующий топик Kafka публикуется событие с устаревшим ключом. Каждый под является потребителем этой шины. Получив событие, он удаляет этот ключ из In-Memory-кеша.
Плюсы:
гарантии доставки at-least-once. Это ключевое преимущество. Такие брокеры, как Kafka, хранят сообщения на диске и обеспечивают механизм подтверждений. Это гарантирует обработку каждого события каждым потребителем;
устойчивость к пиковым нагрузкам и буферизация. Шина сообщений выступает в роли буфера, сглаживая пики событий. Поды не будут перегружены внезапным всплеском активности и обработают сообщения с той скоростью, на которую способны;
максимум необходимого из коробки: всёуже готово, нужно только реализовать отправку и получение сообщений.
Минусы:
новый компонент. Если сервис не был завязан на Kafka, то появляется новая критичная зависимость и новый инфраструктурный компонент, который необходимо поддерживать и мониторить;
ограниченная масштабируемость при большом количестве подов. Специфика инвалидации такова, что каждый под должен получать все события, а значит, он должен читать все партиции каждого топика, и это становится узким местом в масштабировании. Кроме этого, все поды периодически опрашивают Kafka на предмет новых событий, создавая лишнюю нагрузку на кластер;
проблемы управления. Управлять двумя тысячами консьюмер-групп сложно, вручную точно не получится, нужна надёжная автоматизация, которую тоже придётся постоянно поддерживать.
Стратегия
гибридная схема с TTL. Брокер выступает основным механизмом для мгновенной инвалидации в идеальном сценарии. TTL же страхует.

При проектировании высоконагруженных систем к выбору механизма инвалидации кеша стоит подходить особо тщательно. В нашем случае мы выделили следующие требования:
низкая задержка инвалидации. Обновление кеша должно происходить не позднее чем через несколько секунд после изменения данных;
Event-driven обновление. Инвалидация должна запускаться по событию, а не только после истечения TTL, это позволит достичь актуальность данных в реальном времени;
горизонтальная масштабируемость. Система должна стабильно работать в кластере из тысяч подов;
гарантия доставки. Необходима семантика at-least-once, чтобы ни одно событие об обновлении не было потеряно.
Исходя из этих требований, мы сразу отвергли ряд популярных, но неподходящих для нашего сценария решений:
TTL (Time to Live) использовался ранее, но нам его стало не хватать, так как он противоречит принципу обновления по событию. Он остался, но в качестве страховки;
Valkey Pub/Sub и Keyspace Notifications — эти механизмы не обеспечивают гарантии доставки. Если подписчик отключён в момент публикации события или произойдёт сетевой сбой, сообщение будет утеряно, что недопустимо для наших нужд. И хотя есть хак с отслеживанием соединений к Valkey, потенциальные проблемы с масштабируемостью остаются;
Valkey Streams теоретически подходят под требования, так как предоставляют персистентность и гарантии доставки. Однако мы сочли реализацию на их основе избыточно сложной для нашей задачи;
Apache Kafka казалась очевидным кандидатом благодаря своим персистентным очередям и гарантиям доставки, однако нас остановила потенциальная проблема с масштабируемостью и управляемостью, описанная выше.
Таким образом, ни одно из стандартных решений в чистом виде не удовлетворяло всем заявленным требованиям одновременно.

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

Механизм инвалидации реализован так, чтобы гарантировать согласованность данных и избежать гонок состояний:
инициация запроса. Консьюмер обращается к одному из экземпляров product-facade с запросом на инвалидацию данных;
инвалидация внешнего кеша. Product-facade удаляет из Valkey ключи, связанные с полученными событиями;
анализ данных. Экземпляр product-facade, зная структуру своих данных, определяет, какие конкретно события необходимо переслать консьюмеру для актуализации In-Memory-кешей;
проксирование событий. Консьюмер, получив от фасада информацию о необходимых событиях, перенаправляет их в выделенный топик Kafka;
обработка invalidator'ом. Специализированный сервис pf-in-memory-invalidator потребляет сообщения топика. Причём каждый его под является отдельной консьюмер-группой и получает все сообщения;
широковещательная рассылка по gRPC. Получив событие, invalidator рассылает его через gRPC Stream всем подам product-facade, которые установили с ним соединение при своём запуске;
локальная инвалидация. Каждый экземпляр product-facade, получив уведомление, инвалидирует соответствующие данные в своём In-Memory-кеше.
Стрим (gRPC Stream) — механизм для передачи потока данных через постоянное соединение в gRPC.

Возникает закономерный вопрос: зачем нужен промежуточный топик и роль консьюмера как прокси? Почему бы invalidator'у не подписаться напрямую на топик мастер-системы?
Такое упрощение неминуемо приведёт к гонке состояний (race condition). Вот сценарий, который при этом возникнет:
мастер-система публикует событие об обновлении в свой топик, и product-facade начинает запись новых данных в Valkey;
Invalidator в этот момент получает событие и рассылает его всем экземплярам product-facade;
часть подов product-facade успевает обработать событие раньше, чем завершилась запись в Valkey. Они инвалидируют свой кеш и идут за актуальными данными в Valkey.
Проблема: в Valkey в этот момент ещё находятся старые данные. В результате поды кешируют их и считают актуальными до следующей инвалидации. Это усложнение необходимо, чтобы исключить гонки.
Race condition (гонка состояний) — ошибка, возникающая, когда результат операции зависит от порядка выполнения потоков или узлов.

Архитектура сервиса инвалидации состоит из трёх ключевых компонентов, выстроенных в конвейер обработки событий:
Consumer: ответственен за чтение сообщений из Kafka. Его задача — получить сырое событие и передать его дальше по цепочке;
Broadcaster: ядро системы, принимающее сообщения от Consumer. Для эффективной рассылки множеству подписчиков в нём реализован кастомный worker-pool, направленный на снижение аллокаций памяти;
Subscription: каждый экземпляр представляет собой активное подключение к конкретному product-facade, подписанному на определённый тип событий. Broadcaster итерируется по всем соответствующим подпискам и отправляет через них сообщение.
Гарантия обработки: после того как все целевые поды успешно получают сообщение, инвалидатор фиксирует offset, обеспечивая семантику at-least-once.

Архитектура решения основана на двух ключевых компонентах.
1. Клиент к Invalidator
Этот модуль реализует базовую функциональность для работы со stream.
Подписка на stream: клиент устанавливает соединение и возвращает буферизированный канал, через который потребитель получает события.
Обработка разрыва соединения: при разрыве stream клиент закрывает канал, это служит сигналом бизнес-логике, по которому она понимает, что пока переподключение не произошло, события могут теряться и надо это обработать.
Механизм повторных подключений: для обеспечения устойчивости соединения реализована логика повторных попыток с рандомизированной экспоненциальной задержкой, позволяющая избежать лавинообразного роста нагрузки на invalidator.
Каналы в Go — типизированные объекты для передачи данных между горутинами (легковесными потоками). Обеспечивают синхронизацию без явных блокировок. Могут быть:
буферизованными — данные накапливаются до чтения, если буфер не заполнен;
небуферизованными — отправка и получение блокируют горутины до завершения операции.
Рандомизированная экспоненциальная задержка — алгоритм, используемый для планирования повторных попыток при возникновении сбоев или перегрузки в системе. Его суть заключается в том, что каждая следующая задержка перед повторной попыткой увеличивается по экспоненте с добавлением случайной величины. Это позволяет эффективно снизить нагрузку на систему и избежать эффекта толпы.
2. Специализированная обёртка
Именно этот компонент является центральным элементом системы, инкапсулируя в себе всю сложную бизнес-логику.
Обёртка объединяет In-Memory-кеш и клиент к invalidator, выполняя следующие функции:
управление жизненным циклом подписки — здесь решается, когда именно инициировать первоначальную подписку на stream и когда необходимо выполнить переподписку;
синхронизация состояния — в рамках этой функции определяет стратегию применения поступающих событий инвалидации к данным, хранящимся в In-Memory-кеше;
обработка сбоев — она управляет поведением системы в случае разрыва stream’а (например, предпринимает попытку переподключения, временно блокирует доступ к потенциально устаревшим данным или инициирует полное обновление кеша);
учёт специфики данных — эта функция содержит логику под конкретный тип кешируемых данных, что делает решение гибким и универсальным. По сути, при необходимости инвалидировать какой-то новый тип, мы просто пишем новую реализацию такой обёртки практически без изменений существующего кода.

Нам была критически важна возможность добавлять новые типы событий инвалидации без постоянных доработок и перезапуска самого pf-in-memory-invalidator, поскольку чем меньше вносить в работающую систему изменений, тем больше вероятность того, что она продолжит работать.
Решение. Мы используем тип proto.Any из Protocol Buffers. Это тип, позволяющий хранить произвольное сериализованное сообщение вместе с URL-идентификатором его типа.
Как это работает:
любое событие упаковывается в proto.Any и отправляется в общий топик инвалидатора;
сервис-потребитель создаёт универсальный типизированный клиент;
этот клиент подписывается только на нужные ему типы событий, используя содержащийся в proto.Any URL типа для управления подписками.
Преимущества:
расширяемость: чтобы добавить новый тип события, достаточно начать отправлять его в топик. Инвалидатору не нужны изменения;
экономия CPU: инвалидатор избегает лишних сериализаций/десериализаций для сообщений, которые ему не нужно обрабатывать.
Мы провели исследование и выяснили, что внутри нашего дата-центра разрывы случаются редко. Однако игнорировать эту возможность было бы непрофессионально.
Гарантии Protocol Buffers: реализация гарантирует, что мы узнаем о разрыве соединения, а пока оно живо — сообщения не теряются. Это позволяет нам обработать ситуацию разрыва. Мы выделили три подхода.
1. Скорость важнее актуальности
Поведение. Под продолжает работать с устаревшим кешем.
Действия. Восстанавливаем соединение, запрашиваем полный слепок данных, попутно буферизуя новые события. После загрузки применяем их к слепку (требуется идемпотентность).
2. Скорость важнее актуальности, но нет моментального состояния (snapshot)
Поведение. Быстро восстанавливаем соединение.
Действия. Инвалидируем данные по TTL, пока соединение было разорвано.
3. Актуальность важнее скорости
Поведение. Полностью очищаем In-Memory-кеш.
Действия. До восстановления соединения используем только Valkey, гарантируя актуальность ценой скорости.
Идемпотентность — свойство, при котором повторная обработка одного и того же события не изменяет состояние системы. Гарантирует, что даже при дублировании событий (например, из-за повторной отправки) результат будет идентичен однократному выполнению.

Чтобы один медленный под не заставлял другие ждать, мы реализовали два уровня защиты:
дедлайны: на каждую отправку события устанавливается жёсткий дедлайн (тайм-аут). Его превышение ведёт к разрыву соединения с конкретным подом, которое затем обрабатывается по одной из схем выше;
буферизация: внутри клиента находится буферизированный канал. Он сглаживает пики нагрузки, уменьшая время отклика и позволяя клиенту быстро принимать сообщения, даже если он обрабатывает их с небольшой задержкой.
По итогам тестового запуска, мы обнаружили, что наш внутренний алгоритм балансировки стримов между подами работает недостаточно хорошо, когда стримы живут неограниченно долго. Спустя сутки часть подов pf-inmemory-invalidator'а оказалась вообще без стримов, в то время как на другую часть приходилось в три раза больше клиентов, чем в среднем. Это создавало риск повышенной задержки обработки событий и неравномерного потребления ресурсов.
Для быстрого решения проблемы мы реализовали механизм простого ограничения на стороне pf-in-memory-invalidator. Теперь он отказывается устанавливать новые стримы к подам, у которых их количество превышает заданный лимит.
Как это работает:
максимальное количество стримов на один под задаётся в реальном времени через конфигурацию;
когда product-facade обращается к инвалидатору для установления нового стрима, инвалидатор проверяет текущее количество стримов, которое он уже держит;
если лимит превышен, он отклоняет запрос на подключение. Балансировщик автоматически перенаправляет следующее соединение к другому поду, у которого количество стримов может оказаться ниже лимита.
Преимущества:
простота и надёжность. Решение быстро внедряется и не требует сложных алгоритмов перераспределения существующих соединений;
стабильность. Механизм гарантирует, что ни один из подов не будет перегружен чрезмерным количеством стримов, предотвращая голодание других подов и выравнивая нагрузку;
гибкость. Лимит можно оперативно менять на лету через конфиг, без перезапуска сервиса.
Недостатки:
ручной поиск баланса. Чем больше разница между идеальным распределением стримов и ограничением на под, тем больше неравномерность, но при этом тем быстрее происходит поиск свободного пода. Если небольшие системы могут просто установить в качестве максимального значения стримов идеальное, то крупные такое себе позволить не могут;
ручная корректировка при масштабировании. При масштабировании одного из сервисов нужно масштабировать и ограничение.

Внедрённая система успешно работает в продакшене уже довольно давно и демонстрирует свою высокую эффективность. Вот основные результаты в цифрах.
Масштаб обработки. Всего 48 подов инвалидатора уверенно обрабатывают нагрузку от 4000 gRPC стримов, которые генерируют 2000 подов product-facade.
Производительность. Система гарантирует стабильно низкую задержку инвалидации — всего несколько секунд. При этом она не накапливает лаг в Kafka-топике даже под высокой нагрузкой.
Эффективность против наивного решения. Ключевое достижение — кардинальное снижение нагрузки на Kafka. По сравнению с альтернативной архитектурой, где каждый под product-facade был бы отдельной consumer-группой, нам удалось снизить нагрузку партицию на 97%.
Влияние на кеширование. Внедрение системы увеличило эффективность кеширования, позволив нам увеличивать TTL и, как следствие, кратно снизить нагрузку на мастер-систему.
Пропускная способность и ресурсоэффективность. Нагрузочное тестирование подтвердило, что пропускная способность системы составляет минимум 600 событий в секунду. Что особенно важно, даже при такой избыточной для нас нагрузке каждый под pf-inmemory-invalidator потребляет менее 1 ядра CPU и 256 МБ оперативной памяти.

Текущее решение уже показало себя хорошо, однако мы видим два ключевых направления по его улучшению.
В текущей реализации pf-consumer передаёт события в pf-inmemory-invalidator в том же виде, в котором они поступают из топика мастер-системы. Этот подход прост, но неоптимален с точки зрения сетевого взаимодействия и нагрузки на процессор.
Решение
Реализовать механизм батчинга на стороне pf-consumer. Вместо отправки каждого события отдельно, консьюмер мог бы отправлять их одним сетевым запросом.
Выгоды
Снижение сетевых накладных расходов. Уменьшается количество HTTP-заголовков, TCP-пакетов и общее количество сетевых вызовов.
Повышение пропускной способности. Обработка групп событий за один раз почти всегда эффективнее, чем последовательная обработка одиночных событий.
Уменьшение времени ожидания для pf-inmemory-invalidator. Pf-inmemory-invalidator, действуя как синхронный клиент для product-facade, вынужден ждать ответа на отправку каждого события. При батчевании он отправляет один запрос на множество событий, что сокращает время блокировки и позволяет системе эффективнее обрабатывать пиковые нагрузки.
Текущий механизм балансировки, основанный на статическом ограничении количества стримов, доказал свою простоту и надёжность. Однако он не адаптируется к изменяющейся нагрузке и может быть не самым эффективным.
Решение: перейти от статической конфигурации к динамической балансировке. Для этого можно внедрить внешнее хранилище, которое будет выступать в роли координатора.
Принцип работы
Каждый инстанс pf-in-memory-invalidator периодически публикует в это хранилище свою текущую нагрузку.
Прежде чем создать новый стрим, инстанс обращается к хранилищу, чтобы получить общую картину нагрузки по кластеру.
На основе этих данных принимается решение: можно ли создать новый стрим на этом инстансе или его стоит перенаправить на менее загруженный узел.
Выгоды:
повышение эффективности кластера. Нагрузка распределяется более равномерно, что позволяет полнее использовать ресурсы всех инстансов и избегать ситуаций, когда один узел перегружен, а другие простаивают;
автоматическая адаптация. Система автоматически подстраивается под изменение количества клиентов.
Кеши в распределённых высоконагруженных системах — это постоянный поиск компромисса между производительностью, актуальностью данных и сложностью архитектуры. Наш опыт наглядно демонстрирует, что даже проверенные временем решения, могут оказаться неприменимыми в условиях экстремальных нагрузок и уникальных требований бизнеса.
Ключевой урок, который можно извлечь из нашей истории — универсальных решений не существует. Каждый метод, будь то TTL, событийные модели или гибридные подходы, имеет право на жизнь, но нужно учитывать:
толерантность системы к задержкам;
масштаб кластера;
стоимость ошибки.
Создание pf-in-memory-invalidator стало для нас не просто способом решить конкретную техническую задачу, но и возможностью переосмыслить подходы к проектированию распределённых систем. Мы убедились, что:
гибридные стратегии часто эффективнее чистых решений;
простота ≠ надёжность — кажущаяся сложность кастомного сервиса окупилась снижением операционных рисков;
мониторинг и адаптивность — даже идеально спроектированная система требует постоянного мониторинга в меняющихся условиях.
Для команд, стоящих перед аналогичными вызовами, советуем:
измеряйте всё. Без метрик любые оптимизации слепы;
тестируйте на граничных условиях. Разрыв соединений, перегрузка сети, внезапный рост RPS — система должна оставаться устойчивой даже в экстремальных условиях;
планируйте эволюцию архитектуры. Наш pf-in-memory-invalidator — не финальная точка, а этап, за которым последуют новые оптимизации и улучшения, направленные на консистентность и актуальность наших кешей.
Инвалидация данных останется одной из самых сложных задач в разработке, но именно такие задачи, заставляют нас искать нестандартные подходы и совершенствовать инженерную культуру.
Как показывает практика, иногда лучший способ справиться с проблемой — не подчиниться готовым решениям, а создать своё.

Kubernetes (K8s)
Официальная документация — полное руководство по всем аспектам K8s.
Концепция Pods — что такое поды и как с ними работать.
Valkey
Официальный сайт и документация — форк Redis с иным лицензированием.
Apache Kafka
Официальная документация — всё о концепциях, API и настройке.
gRPC
Официальная документация — руководство по использованию gRPC, включая стримы.
Protocol Buffers (protobuf)
Официальная документация — руководство по синтаксису и использованию protobuf.
Valkey Pub/Sub — механизм публикации/подписки для рассылки событий.
Клиентское кеширование (Tracking) — встроенная поддержка инвалидации локальных кешей через отслеживание изменений ключей.
Valkey Streams — устойчивые потоки для гарантированной доставки событий.
Как департамент утилизации CPU превратился в департамент экономии железа, выдерживающий нагрузку в 1 млн RPS — наша старая статья, во многом актуальная до сих пор.
15 мс на ответ: как мы добились высокой скорости работы API Gateway — решение схожей проблемы от коллег через Pub/Sub.
Кластеры и мир: хроника высокодоступного Pub/Sub в Redis — решение через Redis Streams.