Каскадные отказы в highload-системах: почему одна медленная зависимость кладет весь продукт — объясняет Александр Жартовский
Представьте себе микросервисную архитектуру: десятки небольших программ-сервисов, каждый из которых отвечает за свою часть работы, а вместе они образуют один продукт. Всё работает в штатном режиме. Вдруг один запрос к базе данных начинает выполняться медленнее — не вылетает, просто тянется. Проходит пять минут, и вся система останавливается, хотя ни один сервис не вышел из строя, ни один health check не сработал, а оповещение об error rate так и не поступило. Это каскадный отказ — цепная реакция деградации, при которой замедление работы одного компонента постепенно останавливает остальную часть системы.
Для платформ, обрабатывающих миллионы транзакций в час — финансовых сервисов, телеком-операторов, логистических систем — подобный сбой означает длительный простой.
В этой статье Александр Жартовский, back-end-разработчик с опытом архитектуры распределенных систем, объясняет, как избежать таких сбоев. По его словам, синхронные цепочки между сервисами — когда каждый должен дождаться ответа предыдущего, прежде чем ответить сам, — лишь половина проблемы. Вторая половина — общие ресурсы, такие как единая база данных или сетевой канал, на которых незаметно держится вся платформа. Одно медленное звено способно остановить целую highload-систему даже тогда, когда каждый её сервис формально исправен.
Синхронная цепь
Александр разрабатывал мессенджерные платформы для телеком-операторов — системы, интегрирующие SMS, RCS, Viber, WhatsApp, Telegram, VoIP и другие каналы. Занимая должность Head of System Applications Development, он возглавлял разработку платформы из более чем 40 микросервисов на Go, которые каждую секунду обрабатывают тысячи сообщений. В 2024 году он переехал в США, где работает в качестве независимого эксперта по архитектуре распределенных систем.
Этот механизм хорошо знаком любому опытному архитектору. "Когда инженеры говорят о каскадных сбоях, они почти всегда описывают один сценарий, — объясняет Жартовский. — Сервис A синхронно вызывает сервис B. B начинает тормозить. A ждёт ответа, его горутины (параллельные потоки выполнения в Go) накапливаются в ожидании, исчерпываются — и теперь уже A работает медленно. Далее по цепочке".
В таком случае архитекторы обычно советуют: правильно разбивайте монолит, делайте микросервисы независимыми, устраняйте синхронные цепочки. Жартовский скептически относится к такому совету — он слишком идеализирован. Архитектура сложных систем формируется итеративно. На этапе MVP никто не видит будущего, и реальные связи между процессами и сущностями выявляются спустя годы разработки, когда система уже давно находится в производственной среде.
"В реальности всегда образуются кластеры микросервисов, — говорит он. — Формально это отдельные сервисы, а фактически — один домен, который живет своей общей жизнью. Это нормальное свойство сложных систем, а не ошибка архитектора".
На ранних этапах разработки микросервисной системы Жартовский советует не принимать архитектурных решений, которые впоследствии будет сложно изменить. Оставляйте гибкость там, где это возможно — например, в выборе базы данных, мессенджерного брокера, языка для нового сервиса. То, что кажется правильным решением на этапе MVP, через год может стать узким местом.
А дальше начинается самое интересное. Допустим, вы сделали всё по учебнику: сервисы взаимодействуют через очереди, синхронных цепочек нет, архитектура полностью event-driven. Каскадные сбои всё равно произойдут. Потому что настоящая связанность в распределённых системах любит скрываться.
Зависимость, которой нет на диаграмме
Архитектурная схема микросервисов — это схема с условными стрелками: кто кого вызывает, кто куда отправляет сообщения, откуда приходит запрос и куда уходит ответ. На этой диаграмме почти никогда не появляются базы данных, дисковое пространство, сетевой канал или процессорное время общего узла. А каскадные сбои возникают преимущественно здесь — в общем ресурсе, который никто не считает зависимостью.
Жартовский описывает сценарий, который он видел в различных вариациях много раз. Пять сервисов записывают и читают данные из одной базы PostgreSQL. Каждый работает со своими таблицами, каждый формально независим. Один из них отправляет запрос, который выполняется тридцать секунд вместо десяти миллисекунд — агрегация без индекса или полный просмотр таблицы (full table scan) по большой таблице. Этот единственный запрос блокирует соединение, поглощает ресурсы базы, и четыре других сервиса внезапно начинают тормозить вместе с ним. Их запросы стоят в очереди.
"Вот в чём заключается ловушка, — подчеркивает он. — Ни один из этих четырёх сервисов не зависит от первого. Ни один из них его не вызывает. Но все они опираются на одну общую базу данных Postgres. На диаграмме микросервисов этой связи вообще нет, поэтому при разборе инцидента команда ищет не там".
База данных — лишь самый распространённый пример. Тот же механизм срабатывает на общем диске, когда один сервис бесконтрольно записывает логи и занимает всё пространство, после чего остальные теряют возможность записывать свои логи или временные файлы. То же самое происходит с общим сетевым каналом и процессором на одном узле.
Сервисы, которые на архитектурной схеме выглядят независимыми, на самом деле связаны через инфраструктуру. Эта взаимосвязь незаметна, пока система работает в штатном режиме. Она проявляется только при сбое, когда реагировать уже поздно.
"У нас очереди, это нас не касается"
Самая опасная ошибка, которую Жартовский слышал от команд: если у нас есть очереди сообщений и нет синхронных вызовов, то каскадные отказы нам не грозят. "Это иллюзия, которая дорого обходится", — отмечает он.
Асинхронность действительно избавляет от последовательного торможения, когда сервис A ждёт ответа от B. Устраните синхронные вызовы — и этой проблемы не будет. Но возникает другая, более серьёзная проблема: очередь сообщений растёт, ресурсы системы исчерпываются, и она всё равно выходит из строя — просто медленнее и менее заметно.
Как это выглядит на практике? Жартовский описывает несколько механизмов. Первый — зацикливание обработки событий. Обработчик сообщения во время работы генерирует новое событие, и оно попадает к тому же обработчику или в цепочку, которая возвращается к нему. Очередь растёт экспоненциально. Сервис при этом работает, health check проходит, оркестратор уверен, что всё в порядке. Внутри система утопает в бесконечном потоке сообщений, которые она сама генерирует для себя.
Второй паттерн действует как усилитель — retry storm. Это ситуация, когда повторные попытки обработать неудавшийся запрос многократно усиливают нагрузку на и без того перегруженный компонент. Сервис не смог обработать сообщение и вернул его в очередь для повторной попытки. Причина отказа никуда не исчезла: база работает медленно, внешний API не отвечает. Сообщение обрабатывается снова, снова вылетает, снова возвращается в очередь. Теперь вместо одного сообщения там два. Умножьте на тысячи сообщений в минуту — и очередь растёт быстрее, чем сервис успевает её обрабатывать.
"Retry без экспоненциального отката и джиттера в высокозагруженной системе — это бомба замедленного действия, — говорит Жартовский. — Она тихо тикает, пока нагрузка небольшая, и срабатывает в самый неподходящий момент, когда система уже находится под давлением".
У этого варианта каскада есть одна коварная особенность: он работает медленнее, чем синхронный — минуты вместо секунд. Система деградирует постепенно, и команда часто осознает масштаб проблемы только тогда, когда очереди уже переполнены, а задержка обработки измеряется часами.
Ложная уверенность и рабочие инструменты
Прежде чем говорить о том, как защитить систему от каскадных отказов в асинхронных системах, Жартовский советует разобраться с двумя распространёнными заблуждениями, которые создают ощущение безопасности без самой безопасности.
Во-первых: у нас Kubernetes, мы защищены. Люди так думают, потому что оркестрация и репликация действительно спасают от сбоев — сервис упал, оркестратор запустил новый, трафик переключился на исправные реплики. Но каскадный сбой — это замедление. Сервис жив, health check зелёный, все реплики работают. Но они тормозят одинаково, потому что делят одну и ту же медленную базу. Kubernetes этой проблемы не видит — для него всё в порядке.
Второе убеждение: у нас есть retry (повторные попытки), мы переживем временные сбои. На первый взгляд это логично — запрос не прошел, ждем и пробуем снова. Проблема в том, что когда зависимость действительно медленная (база не справляется с нагрузкой, API недоступен), повторные попытки становятся усилителем. Медленная зависимость получает двойную нагрузку от повторных попыток, тормозит ещё сильнее, провоцирует ещё больше повторных попыток. Круг замыкается, и система сама себя топит.
Что же тогда работает? Жартовский называет четыре механизма, которые он закладывает в архитектуру по умолчанию.
Таймауты на каждый исходящий вызов. Если зависимость не ответила в течение заданного количества миллисекунд, соединение прерывается и возвращается ошибка. Рутина и потоки перестают накапливаться в бесконечном ожидании.
Circuit breaker. Если зависимость отказывает несколько раз подряд, сервис на время перестает обращаться к ней. Он снимает нагрузку с того, что уже не справляется, и даёт ему возможность восстановиться.
Bulkhead, или изоляция ресурсов. Для каждой зависимости выделяется отдельный пул соединений и ресурсов. Когда одна зависимость тормозит, она исчерпывает только свой пул, а остальные сервисы продолжают работать.
И главное — знать свои failure domains. Найти в системе кластеры сервисов, опирающиеся на общий ресурс, и рассматривать такой кластер как единую единицу отказа. "Защищать нужно границы этих кластеров, — говорит Жартовский. — До тех пор, пока команда делает вид, что каждый сервис независим, она защищает не те точки. Карта скрытых связей важнее карты вызовов".
Точки перелома, которые можно предвидеть
Каскадные сбои не возникают как гром среди ясного неба. Они являются предсказуемым следствием типичных архитектурных решений: общей базы данных, бесконтрольных повторных попыток и иллюзии независимости сервисов. К ним можно подготовиться заранее — нужно знать, где в вашей системе скрываются зависимости, и обеспечить защиту в этих точках.
Жартовский изучил эти переломные моменты за годы работы в Украине. То, что он рекомендует, — это опыт, проверенный на практике. Сегодня Александр Жартовский работает в должности Head of System Applications Development в США, а также развивает свою консультационную деятельность — помогает командам предвидеть проблемы в архитектурах.
"Профессионализм специалистов определяется тем, знали ли они об этом слабом месте заранее", — заключает Жартовский.