Ключевые выводы
-
В системах связи реального времени согласованность в конечном счете на путях сигнализации вызовов функционально равнозначна отказу; любая архитектура микросервисов на Java, допускающая нарушение принципа read-your-writes на этих путях, будет приводить к некорректной маршрутизации вызовов в рабочей среде.
-
Повторное воспроизведение событий Kafka при запуске JVM вызывает лавинообразный старт сервисов, из-за которого перестает работать автомасштабирование Kubernetes HPA. Сократить время запуска на шестьдесят процентов можно, заменив глобальные хранилища состояния Kafka локальным слоем кеширования на базе Redis в сервисах Spring Boot.
-
Использование Kafka Streams с RocksDB приводит к непредсказуемым всплескам задержки, вызванным компактизацией, что делает эту связку непригодной для Java-систем связи с требованиями к отклику менее одной секунды.
-
Шаблон Redis «первая запись побеждает» снижает минимальную задержку, вносимую дедупликацией при межкластерном разветвлении gRPC-запросов, с двухсот миллисекунд на каждый переход практически до нулевых накладных расходов на опрос.
-
Один блокирующий синхронный REST-вызов в потоке потребителя Kafka может вызвать каскадное отставание потребителя более чем на тридцать минут, сорвать массовое развертывание для десяти тысяч операторов и оставить систему на базе JVM в частично несогласованном состоянии.
-
Событийно-ориентированная архитектура стала стандартной рекомендацией при создании масштабируемых распределенных систем. Ее преимущества выглядят убедительно: слабая связанность, независимое масштабирование, изоляция отказов и способность обрабатывать огромные объемы данных без жестких синхронных зависимостей. Для платформ совместной работы в реальном времени — таких как контакт-центры, системы унифицированных коммуникаций и видеоконференцсвязи — эти свойства кажутся идеально подходящими.
Я несколько лет занимался разработкой и масштабированием облачной платформы контакт-центра, которая обеспечивала более восьмидесяти тысяч завершенных вызовов в час пик (BHCC) для десяти тысяч одновременно работающих операторов и обрабатывала свыше пяти миллионов транзакций в день. Мы полностью перешли на событийно-ориентированную архитектуру, выбрав Apache Kafka в качестве основной магистрали обмена сообщениями. Результаты оказались неоднозначными — причем именно в тех аспектах, которые никогда не отображаются на архитектурных схемах.
Это не аргумент против событийно-ориентированного проектирования. Это честный рассказ о компромиссах, которые становятся заметны только в рабочей среде, особенно в системах, где отклик в реальном времени — не просто желательная фича, а ключевое требование к продукту.
Фундаментальное противоречие: асинхронность по умолчанию, работа в реальном времени по требованию
Платформы контакт-центров не прощают ошибок. Когда оператор получает входящий вызов, интерфейс должен отобразить новое состояние за миллисекунды, а не за секунды. Когда руководитель просматривает панель своей команды, устаревшие данные о присутствии — это не мелкое неудобство: они в реальном времени влияют на решения по управлению персоналом.
Событийно-ориентированная архитектура по своей природе асинхронна. Каждое сообщение Kafka, которое публикуется, принимается и обрабатывается, добавляет задержку на каждом переходе. В микросервисной архитектуре, где одно действие пользователя запускает цепочку последующих событий — включая механизм маршрутизации, сервис состояния операторов, сервис присутствия и сервис уведомлений пользовательского интерфейса, — эти задержки суммируются.
Мы наблюдали ситуации, когда операторы, совершавшие исходящие вызовы, сталкивались с задержкой интерфейса в две-три секунды, прежде чем в нем отображалось новое состояние вызова. При пиковой нагрузке события ответа на входящий вызов иногда не успевали распространиться вовремя, что приводило к тайм-аутам на стороне источника вызова. Технически система работала корректно. События обрабатывались, однако асинхронная природа конвейера нарушала требуемый продуктом контракт работы в реальном времени.
Мы пришли к выводу, что событийно-ориентированная архитектура отлично подходит для сценариев, в которых допустима согласованность в конечном счете. Однако в системах связи реального времени существуют критические пути — например, сигнализация вызовов, переходы между состояниями операторов и обновления присутствия, — где согласованность в конечном счете функционально равнозначна отказу. Такие пути требуют синхронного или почти синхронного взаимодействия, а не асинхронных конвейеров событий.
Проблема рассогласования кешей: скрытое распределенное состояние
Одно из классических преимуществ событийно-ориентированной архитектуры состоит в том, что каждый сервис поддерживает собственное локальное состояние, формируемое на основе потока событий. Такая архитектура устраняет необходимость в общих базах данных и жесткой связанности. На практике в платформе совместной работы в реальном времени этот подход создает малозаметную, но опасную проблему: рассогласование кешей между экземплярами сервисов.
В нашей системе каждый микросервис поддерживал локальный кеш в оперативной памяти, построенный на основе событий Kafka. В нормальных условиях все экземпляры сервисов потребляли один и тот же поток событий и сохраняли согласованное состояние. Однако в пограничных ситуациях — включая сетевые разделения, отставание потребителей и частичные перезапуски — состояния разных экземпляров начинали расходиться.
Следствием становилась не ошибка и не исключение, а незаметная некорректность: карточки задач для голосовых и чат-взаимодействий зависали в интерфейсах операторов, отображая состояние, которое уже не соответствовало действительности. Поскольку расхождение происходило между кешами в оперативной памяти разных подов, стандартные средства мониторинга его не замечали. Операторы сообщали о зависших карточках, но к тому моменту, когда инженер начинал расследование, состояние нередко уже восстанавливалось само. В некоторых случаях карточки задач оставались зависшими более двадцати четырех часов, прежде чем проблему удавалось обнаружить и устранить.
Именно тогда мы решили перейти на Redis в качестве авторитетного общего хранилища состояния — к третьему поколению нашей архитектуры управления состоянием, подробно описанному далее в этой статье.
Три поколения архитектуры управления состоянием
Эволюция наших стратегий управления состоянием, вероятно, стала самым поучительным этапом развития всей системы. Она включала три поколения архитектуры, каждое из которых решало проблемы предыдущего, одновременно создавая новые.
Поколение 1: глобальные хранилища состояния Kafka
В первом варианте мы использовали глобальные хранилища состояния Kafka Streams — встроенный механизм, который реплицирует данные топика во все поды, предоставляя каждому экземпляру полную локальную копию общего состояния. Такой подход казался идеальным. Каждый под имел полный доступ к состоянию без сетевых вызовов.
Проблема заключалась в задержке синхронизации. Глобальные хранилища состояния реплицируются асинхронно через топики журнала изменений Kafka. Под нагрузкой задержка репликации между подами становилась измеримой. Для контакт-центра реального времени такая задержка была неприемлемой. Поды A и B могли одновременно хранить разные версии состояния вызова одного и того же оператора. Решения о маршрутизации, принятые подом A, могли спустя мгновение вступить в противоречие с решениями пода B. Состояние пользователя и состояние вызова временно расходились таким образом, что это было трудно обнаружить и почти невозможно воспроизвести при тестировании.
Поколение 2: локальный кеш в оперативной памяти через повторное воспроизведение Kafka
Чтобы устранить задержку синхронизации, мы полностью отказались от глобальных хранилищ состояния и заставили каждый под самостоятельно формировать локальный кеш в оперативной памяти из потока событий Kafka. Каждое событие содержало флаг в заголовке — CREATED, UPDATED или DELETED, — а поды независимо поддерживали собственное состояние без межподовой синхронизации и задержек репликации.
Эта архитектура устранила проблему согласованности, но породила две новые. Первой стала задержка запуска: при холодном старте поду приходилось повторно воспроизводить весь журнал событий, чтобы с нуля восстановить свой кеш. В нашей системе этот процесс занимал около пяти минут на каждый под, фактически делая бесполезным горизонтальный автомасштабировщик Kubernetes (HPA), поскольку новые поды, создаваемые при всплесках нагрузки, оставались недоступными в течение пяти минут, пока восстанавливали состояние. Второй проблемой стали пограничные ситуации — например, сетевые разделения, отставание потребителей и частичные перезапуски. Состояния разных экземпляров подов расходились, что приводило к уже описанной проблеме рассогласования кешей: карточки задач зависали более чем на двадцать четыре часа, а незаметная некорректность оставалась невидимой для стандартного мониторинга.
Поколение 3: общий кеш Redis со слоем отказоустойчивости
В окончательной архитектуре локальные кеши были заменены Redis в качестве авторитетного общего хранилища состояния. События Kafka обновляли Redis, а все поды считывали данные непосредственно из Redis. Прямое чтение устранило как несогласованность между подами, так и проблему повторного воспроизведения событий при запуске.
Задержка запуска сократилась на шестьдесят процентов. Поды инициализировались из Redis, а не за счет повторного воспроизведения тысяч событий Kafka. Однако превращение Redis в критически важную зависимость потребовало стратегии обеспечения отказоустойчивости. Сбой Redis не должен был полностью выводить из строя управление состоянием операторов.
Решением стал поток автоматического восстановления. Если при запуске пода Redis был недоступен или заполнен лишь частично, фоновый поток незаметно восстанавливал кеш Redis из потока событий Kafka, не мешая поду обслуживать трафик. Под запускался с неполным состоянием, которое постепенно улучшалось по мере заполнения Redis потоком восстановления, вместо того чтобы пять минут ждать начала приема трафика.
Как показано на рисунке 1, эта архитектура обеспечила согласованность общего состояния, скорость запуска прогретого кеша и отказоустойчивость самовосстанавливающегося механизма — без исходных режимов отказа, характерных для каждого из трех поколений.
Мы пришли к выводу, что управление состоянием в событийно-ориентированных системах нельзя свести к единому готовому решению. Это совокупность архитектурных компромиссов, последствия которых становятся заметны только под реальной нагрузкой. Глобальные хранилища состояния создают задержки синхронизации. Локальные кеши приводят к задержкам при запуске и расхождению данных. Общие кеши создают зависимости от их доступности. Главное — с самого начала проектировать систему с учетом всех трех режимов отказа: использовать Redis как единый источник достоверного состояния, инициализацию из снимка для ускорения запуска и фоновый поток восстановления для обеспечения отказоустойчивости. Не следует выявлять эти требования по одному, после каждого нового инцидента в рабочей среде.
Ограничения партиций: скрытый предел горизонтального масштабирования
На бумаге модель масштабирования Kafka выглядит элегантно: добавляйте партиции, увеличивайте число потребителей и масштабируйтесь линейно. Однако в рабочей системе, где несколько сервисов совместно используют одни и те же топики, возможности оказываются значительно более ограниченными.
В нашей архитектуре основные топики имели по двенадцать партиций, топики среднего уровня — по шесть, а топики с меньшим объемом трафика — по три. Модель групп потребителей устроена так, что максимальное число активных потребителей одного топика равно числу его партиций. Если развернуть больше подов-потребителей, чем имеется партиций, лишние поды будут простаивать, расходуя память и вычислительные ресурсы, но не увеличивая пропускную способность.
Для общих топиков, которые потреблялись несколькими сервисами, такая архитектура создавала жесткий предел: мы не могли независимо масштабировать отдельные сервисы сверх числа партиций, не оплачивая при этом простаивающие поды во всех группах потребителей.
Очевидным решением было увеличить число партиций, однако оно создавало новую проблему. Изменение числа партиций работающего топика Kafka запускает ребалансировку группы потребителей. На время ребалансировки обработка сообщений приостанавливается. В контакт-центре реального времени любая такая пауза имеет существенные операционные последствия. Риск ребалансировки в рабочей среде делал увеличение числа партиций опасной операцией и фактически привязывал нас к значениям, заданным при первоначальном развертывании.
В конечном итоге мы решили эту проблему с помощью двух изменений, выполненных в рамках более масштабного перехода на Redis. Перенос общего состояния из топиков Kafka в Redis значительно сократил межсервисные зависимости от топиков, а объединение чрезмерно раздробленных микросервисов в целостные функциональные сервисы уменьшило число групп потребителей, конкурировавших за партиции. Оба изменения были реактивными: к ним нас подтолкнули проблемы в рабочей среде, а не решения, принятые на этапе первоначального проектирования.
Мы пришли к выводу, что число партиций необходимо осознанно выбирать еще на этапе проектирования, поскольку безопасно изменить его в рабочей среде непросто. По возможности не следует совместно использовать одни и те же топики в нескольких сервисах: отдельные топики для каждого сервиса дают командам независимый контроль над масштабированием. Наконец, важно понимать, что горизонтальное масштабирование Kafka ограничивается числом партиций, а не объемом вычислительных ресурсов.
Межкластерное распространение событий: цена дедупликации
Наша платформа была распределена между двумя кластерами Azure Kubernetes Service: в одном размещался пользовательский интерфейс, а в другом — основное ядро маршрутизации. При этом голосовой коммутатор отдельно работал в Google Cloud Platform (GCP). Такая топология создала проблему дедупликации, которая стала одним из наиболее значимых источников задержки.
Пользовательский интерфейс взаимодействовал с серверной частью по gRPC через отдельные восходящий и нисходящий каналы. Поскольку все серверные gRPC-поды были подписаны на один и тот же канал, каждый под одновременно получал каждое сообщение от пользовательского интерфейса. Без дедупликации все поды независимо обрабатывали бы одно и то же событие и публиковали дублирующиеся нисходящие сообщения.
В первом решении этой проблемы в качестве механизма дедупликации использовалась сама Kafka. Все gRPC-поды записывали полученные сообщения в необработанный топик Kafka, используя call_id в качестве ключа партиционирования. Поскольку Kafka гарантирует, что сообщения с одинаковым ключом партиционирования попадают в одну и ту же партицию, все дубликаты, относящиеся к конкретному вызову, получал один под-потребитель. Этот потребитель принимал первое сообщение, отбрасывал остальные и публиковал результат в нисходящий топик с дедуплицированными данными.
Функционально это решение было корректным, однако оно создавало нарастающую проблему задержки. Интервал опроса по умолчанию у потребителя Kafka составлял сто миллисекунд, поэтому каждое событие на каждом переходе через Kafka неизбежно задерживалось как минимум на сто миллисекунд. Схема дедупликации добавляла к каждому взаимодействию с пользовательским интерфейсом два полных перехода через Kafka: первый — в необработанный топик, второй — из дедуплицированного топика к нисходящим сервисам. Таким образом, одни только минимальные интервалы опроса создавали двести миллисекунд неизбежной задержки, прежде чем событие доходило до нисходящих сервисов, — еще до начала какой-либо обработки, выполнения REST-вызовов или возникновения межкластерных сетевых накладных расходов.
В конечном итоге мы заменили дедупликацию на базе Kafka шаблоном Redis «первая запись побеждает», как показано на рисунке 2. Первый под, которому удавалось успешно закрепить за собой ключ call_id в Redis, становился назначенным обработчиком, а все остальные обработчики немедленно отбрасывали сообщение. Такой подход позволил полностью отказаться от необработанного топика и убрать один полный переход через Kafka с критического пути.
Мы пришли к выводу, что использование Kafka в качестве механизма дедупликации на путях, чувствительных к задержкам, обходится дорого. Интервал опроса создает неизбежный нижний предел задержки на каждом переходе. В системах реального времени координационные шаблоны на базе Redis устраняют этот предел и должны быть основным выбором для дедупликации между экземплярами сервисов.
Особенности JVM: какие дополнительные компромиссы привносит Java
Описанные выше режимы отказа носят архитектурный характер и не зависят от языка реализации. Однако разработка этой системы на Java и JVM создала дополнительные ограничения, которые архитекторам Java-систем следует рассматривать отдельно.
Накладные расходы при запуске Spring Boot
Инициализация контекста приложения Spring Boot заметно увеличивает время запуска еще до того, как потребитель Kafka будет готов обрабатывать события. В нашей системе инициализация контекста Spring Boot добавляла примерно от тридцати до сорока пяти секунд ко времени запуска пода — и это еще до начала регистрации потребителя Kafka. В сочетании с описанной ранее проблемой повторного воспроизведения событий Kafka общее время запуска пода в худших сценариях приближалось к шести минутам, из-за чего проблема автомасштабирования HPA становилась значительно серьезнее, чем в случае использования более легковесной среды выполнения.
Напрямую решить эту проблему позволили два изменения: ленивая инициализация (spring.main.lazy-initialization=true) откладывала создание бинов до их первого использования, а переход на инициализацию из снимка Redis устранил этап повторного воспроизведения Kafka. Вместе эти изменения позволили сократить время запуска подов Spring Boot в нормальных условиях до менее чем девяноста секунд.
Нагрузка на GC при высокопроизводительном потреблении Kafka
При пиковой нагрузке — восемьдесят тысяч завершенных вызовов в час пик и пять миллионов транзакций в день — объем десериализации сообщений Kafka, создания объектов и операций с состоянием создавал значительную нагрузку на сборщик мусора (GC) в куче JVM. В периоды пиковой активности мы наблюдали паузы GC типа stop-the-world, которые способствовали резким всплескам отставания потребителей. Под, столкнувшийся с паузой GC продолжительностью от двухсот до четырехсот миллисекунд в период максимального потребления, начинал отставать от темпа поступления событий в своей партиции, а восстановление могло занимать несколько минут.
Переход на JDK 17 стал самым значимым изменением из всех, которые мы внесли. Усовершенствованная реализация сборщика мусора Garbage-First Garbage Collector (G1GC) в JDK 17 заметно снизила частоту пауз без дополнительной настройки. Помимо обновления JDK, эффект усилили еще три изменения параметров JVM. Целевое значение паузы G1GC (-XX:MaxGCPauseMillis=100) задавало явный верхний предел продолжительности пауз сборщика мусора, уменьшая частоту более длительных остановок при пиковой нагрузке. Многоуровневая компиляция (-XX:+TieredCompilation) позволила JVM сочетать интерпретируемое выполнение, оптимизацию компилятором C1 и оптимизацию компилятором C2 на разных этапах выполнения методов, снижая нагрузку на процессор для часто вызываемых методов потребителя Kafka и интенсивно используемых участков кода управления состоянием. При продолжительной пиковой нагрузке улучшение использования процессора и памяти было измеримым. Наконец, дополнительный эффект дало применение пула объектов. Часто создаваемые объекты-обертки сообщений помещались в пул, что снижало интенсивность выделения памяти и частоту сборки мусора. В совокупности эти изменения позволили привести потребление процессора и памяти к приемлемому уровню при пиковой нагрузке. Это стало существенным улучшением по сравнению с исходным состоянием до перехода на JDK 17, когда нагрузка на GC регулярно вызывала всплески отставания потребителей.
Виртуальные потоки JDK 21
Виртуальные потоки JDK 21, появившиеся в рамках Project Loom, напрямую решают проблемы этого класса. Потребитель Kafka, использующий виртуальные потоки, может выполнять блокирующие REST-вызовы, не блокируя базовый поток-носитель, благодаря чему JVM продолжает планировать другую работу, пока не завершится операция ввода-вывода. Во время описанных здесь инцидентов эта возможность была нам недоступна, однако ее следует оценить в любой Java-системе с потребителями Kafka, где невозможно полностью отказаться от синхронных вызовов нижестоящих сервисов.
Мы пришли к выводу, что архитекторы Java-систем реального времени на базе Kafka должны рассматривать время запуска Spring Boot, поведение пауз GC и блокирующий ввод-вывод в потоках потребителей как первостепенные архитектурные вопросы, а не как детали реализации. Виртуальные потоки JDK 21 существенно решают проблему блокирующего ввода-вывода, а настройка GC и ленивая инициализация уменьшают накладные расходы при запуске. Эти особенности необходимо учитывать заранее и явно.
Kafka Streams и ловушка производительности RocksDB
В нескольких наших сервисах библиотека Kafka Streams использовалась для потоковой обработки с состоянием: события Agent, UserFeature и VoiceAgent объединялись в унифицированные события UserAggregate. На бумаге Kafka Streams выглядела подходящим инструментом: библиотека обеспечивала семантику exactly-once, встроенное управление состоянием и тесную интеграцию с Kafka.
На практике ее профиль производительности плохо соответствовал задачам контакт-центра реального времени.
Kafka Streams использует RocksDB как встроенное хранилище состояния для промежуточных агрегаций. RocksDB — высокопроизводительное дисковое хранилище типа «ключ — значение». Оно быстро работает в пакетных сценариях и задачах, близких к реальному времени, но не обеспечивает скорости хранилища в оперативной памяти. Для платформы, где изменения состояния операторов должны были распространяться за сотни миллисекунд, накладные расходы на дисковый ввод-вывод при материализации данных в RocksDB создавали задержки, незаметные при разработке и тестировании, но значимые под рабочей нагрузкой.
Мы не измеряли задержку компактизации RocksDB напрямую, однако снижение производительности периодически было заметно по сквозному времени распространения состояния операторов при пиковой нагрузке. Корневая причина заключалась в самой топологии: каждый этап преобразования в конвейере Kafka Streams поддерживал собственное промежуточное хранилище состояния на базе RocksDB. При наличии нескольких этапов между источником и приемником операции чтения и записи в RocksDB накапливались на каждой стадии. По нашей оценке, топология с меньшим числом этапов преобразования либо обычный потребитель Kafka, полностью обходящий RocksDB, могли бы консервативно сократить накладные расходы на чтение и запись в хранилище состояния примерно на тридцать процентов — за счет уменьшения числа точек промежуточной материализации. Всплески задержки, вызванные компактизацией, были особенно заметны в периоды пиковой нагрузки, когда фоновая компактизация RocksDB напрямую конкурировала с операциями чтения и записи, обслуживавшими запросы состояния в реальном времени.
Более глубокая проблема заключалась в архитектуре групп потребителей. Одна и та же группа потребителей читала как исходные топики — например, Agent, UserFeature и VoiceAgent, — так и внутренний топик UserAggregate, создаваемый операцией объединения. Отставание по исходным топикам напрямую лишало ресурсов потребителя агрегированных событий: одной медленной партиции на входе было достаточно, чтобы задержать весь конвейер объединения.
Мы пришли к выводу, что Kafka Streams хорошо подходит для аналитических задач и сценариев, близких к реальному времени, где приемлема производительность дискового хранилища RocksDB. При требованиях к отклику менее одной секунды следует минимизировать число этапов преобразования, чтобы сократить количество точек материализации RocksDB: каждое промежуточное хранилище состояния становится множителем задержки во время компактизации. Разделение групп потребителей для исходных и производных топиков не подлежит обсуждению. Общие группы потребителей создают незаметную связанность между этапами конвейера. На путях, чувствительных к задержкам, по возможности следует выбирать обычный потребитель Kafka с состоянием в Redis вместо Kafka Streams с RocksDB.
Каскадный отказ: как задержка нижестоящего сервиса замораживает вышестоящих потребителей
Самый серьезный инцидент в рабочей среде был вызван не единичным отказом, а каскадом, начавшимся с одного медленного вызова REST API и распространившимся вверх по всему конвейеру.
Речь шла о массовом развертывании операторов. Администратор запускал из интерфейса Admin UI развертывание до десяти тысяч операторов, после чего события публиковались в топик Kafka всего с тремя партициями. Наш сервис потреблял эти события и вызывал нижестоящий REST API для развертывания каждого оператора в клиентской облачной среде. В Admin UI был установлен общий тридцатиминутный тайм-аут для всей массовой операции.
Цепочка отказа включала четыре одновременно действовавшие и усиливавшие друг друга проблемы. Во-первых, при наличии всего трех партиций и использовании Kafka Streams двух потоков на партицию не более шести потоков обрабатывали десять тысяч событий развертывания операторов. Во-вторых, каждый поток потребителя выполнял синхронный блокирующий REST-вызов к нижестоящему сервису. Когда производительность нижестоящего REST API ухудшилась под нагрузкой, все шесть потоков заблокировались и оставались в таком состоянии. Пока потоки были заблокированы, потребление сообщений Kafka полностью остановилось. В-третьих, конвейеру развертывания требовалось объединить по три отдельных события на каждого оператора — например, Agent, UserFeature и VoiceAgent — в одно событие UserAggregate, из-за чего через узкое место из трех партиций проходило тридцать тысяч сообщений. В-четвертых, общая группа потребителей приводила к взаимному усилению отставания исходных и агрегированных событий.
Отставание потребителей превысило тридцать минут. Сработал тайм-аут Admin UI. Массовая операция развертывания завершилась сбоем, причем некорректно. Часть операторов была развернута на стороне клиента, а часть — нет. Система осталась в несогласованном состоянии без механизма автоматической сверки.
Для устранения проблемы потребовались два существенных архитектурных изменения. Во-первых, три отдельных события развертывания были объединены в одно унифицированное событие, что сократило объем сообщений на две трети и полностью устранило внутренний топик объединения. Во-вторых, синхронный REST-вызов был заменен очередью Redis. Потоки потребителей записывали запросы на развертывание в Redis и немедленно возвращались к работе, а отдельный пул асинхронных обработчиков независимо выполнял REST-вызовы. С этого момента потоки потребителей больше никогда не блокировались из-за задержек нижестоящих сервисов.
Частичная несогласованность — когда одни операторы были развернуты на стороне клиента, а другие нет — потребовала ручной сверки. Команда восстановления сопоставила подтвержденные записи клиентской системы развертывания с ожидаемым списком из операции Admin UI. Для операторов, которые были подтверждены как развернутые на стороне клиента, но отсутствовали во внутреннем состоянии платформы, операции запускались повторно по отдельности с использованием проверок идемпотентности. Каждый вызов развертывания содержал уникальный идентификатор операции, а нижестоящая система отклоняла повторные запросы для операторов, уже находившихся в развернутом состоянии. Операторы, развертывание которых не удалось на стороне клиента, были выявлены и повторно поставлены в очередь через исправленный конвейер. Полная сверка потребовала примерно трех часов ручной работы инженеров.
Результаты можно было измерить. Отставание потребителей сократилось примерно на пятьдесят процентов, операции развертывания стали безопасными для перезапуска благодаря надежности очереди Redis, а неудачные REST-вызовы автоматически повторялись без повторного потребления сообщений из Kafka.
Мы пришли к выводу, что синхронные блокирующие вызовы внутри потоков потребителей Kafka представляют собой архитектурную угрозу. Одна медленная нижестоящая зависимость способна заморозить потребление всего топика, распространив задержку вверх по конвейеру. Любой поток потребителя, которому необходимо обратиться к внешнему сервису, должен делать это асинхронно: записать запрос в надежную очередь и немедленно вернуться к потреблению следующего сообщения. Поток потребителя критически важно не блокировать: его единственная задача — принять сообщение и передать его дальше, но никогда не блокироваться.
Что я спроектировал бы иначе
Оглядываясь назад, я понимаю, что пять архитектурных решений могли бы существенно изменить ход развития системы:
Использовать синхронные пути для операций, чувствительных к задержкам. Для сигнализации вызовов, переходов между состояниями операторов и уведомлений пользовательского интерфейса следовало применять синхронное взаимодействие или низколатентную публикацию и подписку — например, WebSocket или потоки gRPC, — а не Kafka. Kafka стоило оставить для надежных потоков данных, не чувствительных к задержкам: аналитики, журналов аудита и синхронизации с нижестоящими CRM-системами.
С первого дня использовать общий кеш Redis со слоем отказоустойчивости. Вместо последовательного прохождения трех поколений управления состоянием — глобальных хранилищ состояния, локальных кешей и затем Redis — следовало сразу использовать Redis как авторитетное хранилище состояния в реальном времени, инициализацию из снимка для быстрого запуска и фоновый поток восстановления на случай недоступности Redis. Каждый из этих трех элементов необходим: реализация лишь одного или двух создает новый режим отказа.
Использовать инициализацию в первую очередь из снимка. Любой сервис, которому требуется локальное состояние, должен инициализироваться из снимка, а не за счет полного повторного воспроизведения событий. Механизм снимков необходимо проектировать до развертывания в рабочей среде: внедрять его задним числом значительно сложнее, чем заложить с самого начала.
В первую очередь использовать Redis для дедупликации при межкластерном разветвлении. Любая архитектура, в которой несколько подов одновременно получают одно и то же сообщение, должна с первого дня применять шаблон Redis «первая запись побеждает». Дедупликация на базе Kafka добавляет неизбежную задержку опроса на каждом переходе.
Никогда не блокировать потоки потребителей. Потоки потребителей не должны выполнять синхронные внешние вызовы. Шаблоны асинхронной передачи — например, очереди Redis и отдельные пулы обработчиков — необходимо закладывать с самого начала. Задача потока потребителя — принять сообщение и передать его дальше, и ничего более.
Заключение
Ни одна из этих проблем не была чисто теоретической. Каждая из них проявилась в виде инцидента в рабочей среде: зависших карточек задач, тайм-аутов при развертывании операторов, паралича автомасштабирования, несогласованного состояния вызовов и задержек пользовательского интерфейса. Каждый такой инцидент нарушал требуемый продуктом контракт работы в реальном времени.
Перечисленные проблемы показывают, насколько важна осознанная архитектура событийных систем. Асинхронность следует применять избирательно, потоки потребителей — защищать от вмешательства, а управление состоянием — с самого начала проектировать с учетом всех трех режимов отказа. Топология партиций и устройство групп потребителей должны рассматриваться как ключевые архитектурные решения уже на старте проекта; оставлять их на уровень реализации слишком рискованно.
Лучше всего в рабочей среде обычно проявляют себя не те системы, которые полностью придерживаются одной парадигмы. Наиболее надежны системы, в которых событийно-ориентированные шаблоны применяются осознанно и с ясным пониманием того, где эта модель перестает работать.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
ссылка на оригинал статьи https://habr.com/ru/articles/1058484/