Когда у компании много сервисов и данных, то лучше всего иметь план Б на любую ситуацию, например когда нужно быстро оптимизировать ресурсы и работать в режиме «минус один дата‑центр» без просадок, в то время как утилизация серверов при этом стремится к 100%. Смертельный номер? Вполне посильная задача, с которой справилась команда Яндекс Go. 

Мы провели аудит и поняли, что у нас очень много синхронных походов из критичных сервисов в некритичные, а ещё и поллинг. И это требовало внедрения событийной модели. Тысяча микросервисов, 150 команд разработки, несколько языков программирования, и у каждого разработчика своё представление о том, как правильно читать сообщения из Kafka. Библиотека, которую мы раздали командам, быстро бы обросла форками, заплатками и костылями.

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

Меня зовут Алексей Терентьев, я руководитель одной из служб отдела эффективности Яндекс Go. В этой статье я расскажу, как мы прошли путь от простого «прочитал — обработал — закоммитил» к по‑настоящему масштабной архитектуре: со всеми граблями, факапами и конкретными решениями.


Общий осмотр и диагноз

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

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

Это могло осложняться и разного рода багами. Например, в ответе основного сервиса было поле optional_data, которое обогащалось данными из десятка других мест. Упало одно из этих мест, а код не был готов к null. Вместо объекта мы получали падение всего конвейера. В нашем случае код был на C++ (std::optional), и разыменование nullptr приводило к Core dump. Процесс мгновенно падал. Это как если бы двигатель машины заглох, потому что в бардачке перегорела лампочка. Абсурдно, но в мире микросервисов такое сплошь и рядом. Нам явно нужно было изолировать критичные сервисы от всего, что не является абсолютно необходимым для их работы.

Второй проблемой стал поллинг. Давайте предположим, что для отрисовки баллов Плюса на экране заказов Такси мы бы периодически ходили в некоторый сервис пользовательской статистики, который ходил бы в сервис Яндекс Плюс. А чтобы данные не протухали, делал это регулярно, например раз в пять секунд. Теперь умножаем на количество активных пользователей.

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

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

Наш путь через грабли

В качестве брокера мы использовали Logbroker, внутренний сервис Яндекса, но всё, о чем я говорю, подходит для любого брокера типа Kafka. Мы начали с «наивной» событийной архитектуры: прочитал → обработал → закоммитил.

Прочитали пачку сообщений, обработали, записали в брокер, что дошли до такого‑то офсета. Это гарантирует доставку at‑least‑once (хотя бы один раз). 

Конечно, обрабатывать по одному сообщению за раз — непозволительная роскошь, поэтому мы ввели параллелизм: балковую обработку (параллельное выполнение несколькими воркерами) и пакетную (группировку сообщений для обработки за один вызов).

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

Если у вас небольшая обработка, то такое решение будет работать хорошо, а если что‑то пойдёт не так, обработку можно будет быстро починить, но в масштабной архитектуре последствия ошибок кратно растут. 

Допустим, кто‑то из продуктовой команды выкатил фичу, которая удвоила поток сообщений в главном топике бизнеса. На этот топик было подписано 150 сервисов‑консьюмеров. Они не были готовы к такому потоку, и буферы начали переполняться, обработка замедлилась. В результате лаг коммита (разница между последним сообщением в топике и последним обработанным) стал расти. Проблема кроется в количестве сервисов и куче сопутствующих команд. Надо что‑то с этим сделать

Этот сценарий учит двум вещам. 

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

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

Во‑вторых, необходима параметризация в рантайме: возможность на лету, без перезапуска, изменить количество воркеров, размеры батчей и глубину буферов, а в идеале стоит иметь feature‑флаги для отключения обработки конкретного типа сообщений.

Для расчёта оптимального количества воркеров вывели формулу: n ≥ eps × t, где eps — желаемая пропускная способность (events per second), а t — среднее время обработки одного сообщения. Политику накопления пакета тоже сделали настраиваемой: можно задать либо максимальное время ожидания, либо целевой размер, смотря что наступит раньше.

Другая проблема, с которой мы столкнулись, называется Poison Pill Message. Это когда в топик прилетает всего одно «плохое» сообщение: например, в поле, где всегда был int, попадает string. 

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

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

Чтобы при этом брокер не терял сообщения, мы внедрили Dead Letter Queue — отдельное хранилище для сообщений, которые система не смогла «переварить». Когда обработчик сталкивается с фатальной ошибкой, например, с нарушением контракта, он не пытается бесконечно повторить операцию. Вместо этого берёт проблемное сообщение со всеми метаданными и складывает в таблицу. 

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

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

Вскоре мы поняли, что не все ошибки фатальны. Иногда соседний сервис отвечает 500-й ошибкой из‑за временных проблем.

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

Технически сообщение в Retry Queue — обёртка над оригиналом: {original_message, original_processing, last_attempt_time, last_process_time, …}. Retry‑обработчик использует эту метаинформацию для принятия решений.

Параметры X (количество попыток) и Y (интервал между ретраями) настраиваются отдельно для каждого пайплайна.

Рождение Немезиды

Итак, у нас был рецепт отличной, отказоустойчивой библиотеки для обработки сообщений. В ней были метрики, ретраи, DLQ и гибкая конфигурация. Казалось бы, что может пойти не так, если просто раздать её 150 командам разработки?

Получится кошмар любого CTO, ведь к 2023 году в Яндекс Go было около тысячи микросервисов на нескольких языках, с разными фреймворками и своим легаси. Каждая команда использует библиотеку по‑своему: кто‑то форкнет и добавит «улучшений», кто‑то неправильно настроит DLQ, кто‑то напишет свою логику ретраев поверх нашей. Возникнет зоопарк из сотен несовместимых реализаций.

Что же делать? А что все бэкендеры умеют делать хорошо, единообразно и почти с закрытыми глазами? Конечно, писать HTTP/gRPC‑хендлеры. Так пускай они этим и занимаются.

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

“Nemesis means a righteous infliction of retribution manifested by an appropriate agent” Mr. Pulford, better known as Brick Top

Трансформация библиотеки в платформу заняла 6 месяцев силами команды из 6 человек.

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

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

Совет: если вы захотите сделать такое, продумайте и шаги автоматизации самого процесса тоже. 

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

Преимущества нашего решения

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

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

Технически мы развязали количество партиций в Kafka и количество инстансов вашего сервиса. Раньше, если у вас было две «тяжёлых» партиции и десять подов, работали только два пода, а восемь простаивали. Теперь прокси отправляет HTTP‑запросы (RPC) через балансировщик на все десять ваших инстансов сервиса равномерно: лучше утилизировать CPU сервиса на 100% независимо от числа партиций в топике.

Конечно, централизация создала новые вызовы: Немезида стала критически важным компонентом, единой точкой отказа. Переход на коммунальную прокси означал сдвиг: от pull‑ к push‑модели. Раньше сервис сам решал, когда читать, а теперь сообщения «прилетают» извне, и сервис может быть не готов их принять. Это потребовало дополнительных механизмов защиты.

Например, circuit breaker (автоматический выключатель). Если целевой сервис начинает массово отвечать ошибками, Немезида «размыкает цепь» и на время перестаёт слать туда трафик, давая сервису возможность восстановиться. Конкретные пороги срабатывания подбирали эмпирически: если доля ошибок превышает 50%, circuit breaker «размыкается». Когда процент ошибок падает ниже 20%, обработка возвращается к нормальному режиму. Гистерезис между порогами (50% → 20%) предотвращает «дребезг» при пограничных значениях.

В Такси у всех сервисов встроены рейтлимитеры. И вы тоже не забывайте их использовать, чтобы спастись от перегрузки, если вдруг пойдёт вал сообщений. А circuit breaker поможет не слить их все в DLQ.

Рейтлимитеры применяются к обоим потокам — основной обработке и retry‑очереди. Это предотвращает ситуацию, когда накопившиеся retry‑сообщения после восстановления сервиса создают вторичную перегрузку.

Самым мощным инструментом оказались пайплайны и фильтрация. Мы дали пользователям возможность описывать целый конвейер обработки внутри прокси. Самый частый кейс — фильтрация. 

Например, сервису нужны только сообщения по заказам из определённого города. Раньше он вычитывал весь трафик и фильтровал у себя. Теперь 3/4 ненужного трафика отсеивается на стороне Немезиды, что здорово экономит ресурсы.

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

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

Интересно сравнить этот подход с решением, которое показывал Uber в своей статье в 2021 году. Их прокси — это просто «перекладыватель» сообщений без пайплайнов и фильтрации, зато с высокой пропускной способностью. У Uber нет retry‑очередей, а DLQ — обычный топик Kafka. Мы же выбрали гибкость: пайплайны отсекают до ¾ трафика, балковая обработка переиспользует читателей, DLQ на YTsaurus даёт удобство анализа.

Что в сухом остатке?

Создавая любую платформу, важно с самого начала «проигрывать в голове» сценарии её использования в масштабе 500+ интеграций, которыми владеют 150 разных команд. Сможете ли вы обновить всех? Гарантировать одинаковые стандарты надёжности? 

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

Сейчас на нашей платформе запущено более 500 интеграций, а среднее время запуска новой не превышает четырёх часов. Система отмасштабировалась до 25 юнитов обработки, каждый из которых способен выполнять сложнейшие пайплайны. У нас 181 уникальный автор пайплайнов при команде внедрения всего в 6 человек. Получился настоящий self‑service.

Мы добились и радикального сокращения нагрузки: один из сервисов снизил свой входящий трафик с 11 000 до 2 000 RPS только за счёт переноса фильтрации на нашу сторону, а сервисы, отказавшиеся от поллинга в пользу событий, экономят до 60 процентных пунктов утилизации CPU.