Архитектура бэкенда
Таймауты и фолбэки: что вернуть, когда он open
Команда добавляет circuit breaker к флакающему сервису рекомендаций и выкатывает его. Следующий инцидент — брейкер никогда не срабатывает: вызовы рекомендаций не ошибаются, они просто берут 30 секунд, а брейкер считал только ошибки. Без таймаута зависание невидимо брейкеру, поэтому он сидел closed сквозь весь сбой. Команда добавляет таймаут в 1 секунду; теперь брейкер срабатывает правильно. Но главная страница начинает возвращать жёсткий 500 в момент размыкания брейкера, потому что никто не решил, что показывать, когда рекомендаций нет. Два недостающих куска, оба существенны: таймаут — это то, что превращает зависание в считаемый отказ, а фолбэк — это то, что вызывающий возвращает, когда брейкер сработал. Брейкер без обоих — это театр.
Таймаут — это триггер
Брейкер считает отказы, значит что-то должно произвести отказ. Ошибающаяся зависимость делает это сама, но опасный случай из первого урока — медленная зависимость — не производит ошибки вовсе. Она просто берёт вечность. Без таймаута это зависание невидимо: вызов ни успех, ни отказ, он просто в ожидании, и брейкер держит цепь closed, пока каждый вызывающий копится за зависанием.
Так что таймаут — это то, что превращает зависание в считаемый отказ. Hystrix вшивает это по умолчанию — execution.isolation.thread.timeoutInMilliseconds = 1000 — так что любой вызов сверх 1 с провален и скормлен брейкеру. Таймаут надо ставить осознанно: достаточно долго, чтобы дать легитимный медленный-но-нормальный ответ, достаточно коротко, чтобы реальное зависание поймали до того, как оно припрёт ресурсы. Брейкер поверх зависимости без таймаута — самый частый способ, которым брейкер молча ничего не делает.
Фолбэк — это ответ
Раз брейкер open, каждый вызов отклоняется мгновенно — но отклоняется во что? Сырое исключение, всплывающее к пользователю, — жёсткий отказ; брейкер лишь превратил медленный отказ в быстрый. Ценность приходит, если дать вызывающему фолбэк: полезный ответ, когда настоящий недоступен. Частые фолбэки, грубо от лучшего к худшему:
- Деградированный-но-корректный ответ. Спрячь карусель рекомендаций и отрисуй остальную страницу. Пользователь теряет фичу, не страницу.
- Последний-известный-хороший кэш. Отдай чуть устаревшее значение (вчерашние рекомендации, кэшированную цену) вместо ничего.
- Статический дефолт. Универсальный список «популярного», пустой массив, разумный ноль.
- В очередь на потом. Для записей прими запрос и обработай его асинхронно, когда зависимость восстановится (паттерн outbox из юнита про идемпотентность).
- Быстрый отказ с ясной ошибкой. Когда осмысленного фолбэка нет, чистый 503 с
Retry-Afterлучше зависшего 30-секундного запроса.
Искусство — выбирать на каждом месте вызова. Отсутствующая карусель рекомендаций никогда не должна ронять страницу; отсутствующая авторизация платежа обязана провалить заказ, потому что фальшивый успех тут хуже ошибки.
Деградация должна быть редкой, а сброс — это пол
Тонкий сеньорный пункт из практики Google SRE: грациозная деградация не должна срабатывать часто. Фолбэк-путь, который бежит постоянно, недотестирован, скрывает реальную долю отказов и может маскировать хроническую проблему, пока она не станет острой. Деградация — для настоящих инцидентов, и ты должен алертить, когда слишком много серверов входит в деградированный или фолбэк-режим — эта частота сама по себе сигнал.
Когда даже фолбэки не справляются — весь сервис перегружен, не только одна зависимость — последний рубеж это сброс нагрузки: намеренно отклонять долю запросов с 503, чтобы защитить остальное, вместо того чтобы дать всему деградировать в таймауты. Книга Google SRE парует это с LIFO/CoDel-стилем очередей, что отбрасывает запросы, простоявшие в очереди достаточно долго (~10 с), чтобы всё равно промахнуться мимо своего дедлайна — нет смысла тратить ёмкость на запрос, от которого пользователь уже отказался.
Почему это работает
Почему фолбэк, который бежит всё время, это проблема, а не фича? Потому что постоянно срабатывающий фолбэк тихо переопределяет «работает». Если главная страница отдаёт устаревшие рекомендации из кэша на каждый запрос, потому что живой сервис сломан уже неделю, страница выглядит нормально, дашборды выглядят нормально, и никого не пейджат — отказ отмыт в нормальную работу. Три вещи гниют под этим. Первое: реальная доля отказов зависимости теперь невидима, поэтому маленькая проблема невыявленно растёт в большую. Второе: сам фолбэк-путь теперь несущий, но редко рассматриваемый; в день, когда кэш тоже падает, ты обнаруживаешь, что в фолбэке был баг, на который никто не натыкался месяцами. Третье: ты потерял сигнал, отличающий «деградировано» от «здорово», а это сигнал, на котором держатся решения дежурного. Дисциплина — относиться к фолбэкам как к исключению и инструментировать их частоту: фолбэк, срабатывающий 0,1% времени во время сбоя, — это система, работающая как задумано, тогда как тот же фолбэк, срабатывающий 40% времени, — инцидент в маске здоровья. Поэтому зрелые команды алертят на частоту деградации, не только на жёсткие ошибки — отсутствие ошибок не то же, что здоровье, когда фолбэк молча поглощает отказы.
| Слой | Триггер | Что возвращает | Когда применять |
|---|---|---|---|
| Таймаут | Вызов сверх бюджета (~1 с) | Отказ, скормленный брейкеру | Всегда, на каждом сетевом вызове |
| Деградированный ответ | Брейкер open | Страница минус сломанная фича | Некритичная фича |
| Устаревший кэш | Брейкер open | Последнее-известное-хорошее значение | Чтение, терпимое к устареванию |
| Статический дефолт | Брейкер open | Универсальное безопасное значение | Нет более свежей опции |
| Быстрый 503 | Брейкер open, фолбэка нет | Чистая ошибка + Retry-After | Критичный вызов (например платёж) |
| Сброс нагрузки | Весь сервис перегружен | 503 для доли | Последний рубеж, защитить остальное |
Брейкер добавлен к флакающему сервису, но никогда не срабатывает во время сбоя. Зависимость не ошибается — она берёт 30 с на вызов. Чего не хватает?
Почему опытные команды алертят, когда фолбэк грациозной деградации срабатывает слишком часто, вместо того чтобы считать это просто работой системы?
- 01Почему таймаут существенен для работы circuit breaker, и какие опции фолбэка есть у open-брейкера?
- 02Почему грациозная деградация должна быть редкой, и что такое сброс нагрузки?
Брейкер хорош ровно настолько, насколько хороши два куска вокруг него. Таймаут — триггер: поскольку брейкер считает отказы, а опасная медленная зависимость их не производит, зависание без таймаута невидимо, и брейкер сидит closed сквозь сбой — дефолтный таймаут Hystrix в 1 секунду превращает зависание в считаемый отказ, а брейкер поверх зависимости без таймаута молча ничего не делает. Фолбэк — ответ: open-брейкер отклоняет мгновенно, и ценность — это то, что вызывающий возвращает взамен: деградированная-но-корректная страница, последний-известный-хороший кэш, статический дефолт, поставленная в очередь запись через outbox или чистый 503 с Retry-After — выбираемые на каждом месте вызова, ведь отсутствующая карусель не должна ронять страницу, а отсутствующая авторизация платежа обязана провалить заказ. Грациозная деградация должна быть редкой, потому что постоянно срабатывающий фолбэк скрывает истинную долю отказов и оставляет нетестируемый путь несущим, поэтому алерти на его частоту. А когда весь сервис перегружен, сброс нагрузки с очередями, осведомлёнными о дедлайне, — это пол. Всё до сих пор предполагало один процесс — финальный урок масштабирует на много инстансов, где брейкеры флапают, стадятся на half-open и сталкиваются с ретраями.