Розділи
Матеріали

Каскадні відмови в highload-системах: чому одна повільна залежність кладе весь продукт — пояснює Олександр Жартовський

Дар'я Бережна
back-end розробник Олександр Жартовський

Уявіть мікросервісну архітектуру: десятки невеликих програм-сервісів, кожен відповідає за свою частину роботи, разом вони утворюють один продукт. Усе працює штатно. Раптом один запит до бази даних починає виконуватися повільніше — не падає, просто тягнеться. Минає п’ять хвилин, і вся система стоїть, хоча жоден сервіс не зламався, жоден 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 без exponential backoff і jitter у високонавантаженій системі — це бомба сповільненої дії, — каже Жартовський. — Вона цокає тихо, поки навантаження невелике, і спрацьовує в найгірший момент, коли система вже під тиском».

У цього різновиду каскаду є підступна риса: він повільніший за синхронний — хвилини замість секунд. Система деградує поступово і команда часто розуміє масштаб лише тоді, коли черги вже забиті, а лаг обробки вимірюється годинами.

Хибна впевненість і робочі інструменти

Перш ніж говорити про те, як захистити систему від каскадних відмов в асинхронних системах, Жартовський радить розібратися з двома поширеними переконаннями, які створюють відчуття безпеки без самої безпеки.

Перше: у нас Kubernetes, ми захищені. Люди думають так, тому що оркестрація й реплікація справді рятують від крашів — сервіс впав, оркестратор підняв новий, трафік пішов на здорові репліки. Але каскадна відмова — це сповільнення. Сервіс живий, health check зелений, всі репліки працюють. Але вони гальмують однаково, тому що ділять ту саму повільну базу. Kubernetes цієї проблеми не бачить — для нього все справно.

Друге переконання: у нас є retry (повторні спроби), ми переживемо тимчасові збої. На перший погляд логічно — запит не пройшов, чекаємо й пробуємо знову. Проблема в тому, що коли залежність справді повільна (база не знімає навантаження, API недоступний), повторні спроби стають підсилювачем. Повільна залежність отримує подвійне навантаження від ретраїв, гальмує ще сильніше, провокує ще більше ретраїв. Коло замикається, і система сама себе топить.

Що тоді працює? Жартовський називає чотири механізми, які він закладає в архітектуру за замовчуванням.

Таймаути на кожен вихідний виклик. Якщо залежність не відповіла за визначену кількість мілісекунд, з’єднання обривається й повертається помилка. Горутини й потоки перестають накопичуватися в безкінечному очікуванні.

Circuit breaker. Якщо залежність відмовляє кілька разів поспіль, сервіс припиняє звертатися до неї на певний час. Він знімає навантаження з того, що вже не справляється, і дає йому шанс відновитися.

Bulkhead, або ізоляція ресурсів. Для кожної залежності виділяється окремий пул з’єднань і горутин. Коли одна залежність гальмує, вона вичерпує лише свій пул, а решта сервісу продовжує працювати.

І головне — знати свої failure domains. Знайти в системі кластери сервісів, що спираються на спільний ресурс, і ставитися до такого кластера як до єдиної одиниці відмови. «Захищати треба межі цих кластерів, — каже Жартовський. — Доти, доки команда вдає, що кожен сервіс незалежний, вона захищає неправильні точки. Карта прихованих зв’язаностей важливіша за карту викликів».

Точки перелому, які можна передбачити

Каскадні відмови не з’являються як грім серед ясного неба. Вони є передбачуваним наслідком типових архітектурних рішень: спільної бази даних, безконтрольних retry, ілюзії незалежності сервісів. До них можна підготуватися завчасно — потрібно знати, де у вашій системі криються залежності, й поставити захист у цих точках.

Жартовський вивчив ці точки перелому за роками роботи в Україні. Те, що він рекомендує, — досвід, який витримав реальне навантаження. Сьогодні Олександр Жартовський працює як Head of System Applications Development у США, а також розвиває своє консультування — допомагає командам передбачити проблеми в архітектурах.

«Зрілість спеціалістів вимірюється тим, чи знали вони про слабке місце заздалегідь», — підсумовує Жартовський.