Сбои неизбежны, цель — управляемая деградация
Частичный отказ — определяющее свойство распределённой системы: в одной машине всё либо работает, либо нет, а здесь одна зависимость отвечает медленно, другая половиной запросов, третья возвращает устаревшие данные. Надёжность поэтому измеряется не отсутствием сбоев, а тем, во что сбой превращается для пользователя.
Арифметика доступности объясняет, почему это важно. Пять последовательных синхронных зависимостей с доступностью 99,9 процента каждая дают около 99,5 процента на сценарий — примерно три с половиной часа недоступности в месяц, и ни одна команда при этом не виновата. Отсюда два вывода: сокращать число синхронных зависимостей на пользовательском пути и уметь работать, когда часть из них недоступна.
Каскадный отказ — типичный способ превратить локальную проблему в общую. Одна зависимость начинает отвечать медленно; потоки вызывающего сервиса залипают в ожидании; пул исчерпывается; сервис перестаёт отвечать даже на запросы, которые этой зависимости не касаются; его вызывающие ведут себя так же — и через минуту не работает вся система. Резильентность в первую очередь про то, чтобы разорвать эту цепочку.
Управляемая деградация означает заранее принятое решение, чем можно пожертвовать: отдать ответ без персонализации, показать данные из кэша с отметкой о возможной неактуальности, принять заказ в очередь и подтвердить позже, отключить рекомендации, но оставить оформление покупки. Это продуктовое решение, и согласовывать его нужно до инцидента, а не во время.
Таймауты и бюджет времени
У каждого обращения к внешней системе обязан быть таймаут — отдельно на установку соединения и на получение ответа. Значение берут не из воздуха, а из наблюдаемого распределения: верхний перцентиль времени ответа зависимости плюс запас. Таймаут в тридцать секунд у сервиса, который в норме отвечает за двадцать миллисекунд, эквивалентен отсутствию таймаута.
Без ограничения времени поток или соединение остаётся занятым ответом, который может не прийти никогда. Пул исчерпывается, и сервис перестаёт обслуживать в том числе запросы, никак не связанные с зависшей зависимостью. Именно так локальная проблема одного интеграционного вызова превращается в полную недоступность.
По цепочке вызовов таймауты согласовывают, а лучше — передают оставшееся время явно вместе с запросом. Иначе нижние сервисы продолжают работать над запросом, который вызывающий уже бросил по таймауту: это чистая потеря ресурсов ровно в тот момент, когда их не хватает. Общее правило — таймаут вызывающего должен превышать суммарное время попыток вызываемого, иначе повторы никогда не успевают отработать.
Порядок внедрения важен: сначала таймауты и ограничение размеров пулов, потом повторы, потом предохранитель. Таймаут важнее повтора, потому что повторять то, что может висеть вечно, бессмысленно, а ограничение пула — единственное, что физически не даёт одной зависимости съесть весь сервис.
Ретраи, которые не убивают
Повтор осмыслен только для временных сбоев — сетевых ошибок, таймаутов, ответов о перегрузке или временной недоступности. Ошибки валидации, отсутствия прав и несуществующего ресурса повторять бесполезно: результат не изменится, а ресурсы потратятся. И второе обязательное условие — операция должна быть идемпотентной, иначе повтор создаст второй заказ или спишет деньги дважды.
Повторы делают с экспоненциально растущей паузой и случайным разбросом. Разброс здесь не украшение: без него тысячи клиентов, получивших ошибку одновременно, повторят запрос тоже одновременно и добьют зависимость, которая как раз начала подниматься. Синхронный залп повторов — классический способ продлить инцидент.
Отдельная опасность — умножение повторов по цепочке. Если каждый из трёх уровней делает по три попытки, нижний сервис в худшем случае получает до двадцати семи запросов вместо одного, и деградация превращается в отказ. Поэтому повторяют на одном уровне — обычно на границе системы, ближайшей к клиенту, — а на остальных ограничиваются бюджетом: доля повторов не должна превышать небольшого процента от основного трафика.
Наконец, стоит помнить, что повтор при перегрузке добавляет нагрузку ровно тогда, когда её и так слишком много. Если зависимость отвечает кодом о превышении лимита и просит подождать, это указание нужно уважать. А на своей стороне при перегрузке правильнее не повторять, а сбрасывать часть запросов: быстрый отказ дешевле медленного успеха.
Идемпотентность
Идемпотентная операция при повторном выполнении даёт тот же эффект, что и при первом. В распределённой системе это не роскошь, а фундамент: сеть даёт доставку не менее одного раза, клиенты повторяют запросы по таймауту, брокеры пересылают сообщения после перебалансировки. Без идемпотентности всё это превращается в двойные списания и дубли заказов.
Стандартная реализация — ключ идемпотентности. Клиент генерирует его на попытку бизнес-операции (а не на каждый повтор) и передаёт в заголовке; сервер хранит ключ с уникальным ограничением в базе и записывает бизнес-эффект вместе с отметкой об обработке в одной транзакции. Атомарность здесь обязательна: если эффект и отметка пишутся раздельно, между ними можно упасть, и защита перестаёт работать ровно в том сценарии, ради которого создавалась.
Важны и детали поведения при повторе. Если операция уже выполнена, честнее вернуть сохранённый ответ, а не выполнять её заново; если она ещё в процессе, повтор получает признак конфликта или ждёт результата. Ключи хранят ограниченное время — обычно от суток до нескольких недель — и чистят фоново, иначе таблица растёт бесконечно.
Ключ идемпотентности — не единственный инструмент. Часто достаточно естественного уникального ключа операции (номер заказа, идентификатор платежа), вставки с обновлением по конфликту, проверки версии агрегата при обновлении или дедупликации по идентификатору сообщения на стороне потребителя. Выбор зависит от того, кто способен породить стабильный идентификатор операции.
И важное уточнение, которое любят проверять: идемпотентность — не то же самое, что доставка ровно один раз. Сеть по-прежнему доставляет не менее одного раза; идемпотентность превращает это в однократный эффект. Обещать exactly-once на уровне транспорта неверно, а обещать однократный эффект при повторной доставке — верно.
begin;
-- ключ и бизнес-эффект пишем одной транзакцией
insert into processed(idempotency_key) values (?)
on conflict do nothing returning 1;
-- если returning пуст — операцию уже выполняли: возвращаем сохранённый ответ
insert into payments(order_id, amount) values (?, ?);
commit;
Предохранитель, отсеки и сброс нагрузки
Предохранитель отслеживает долю ошибок и таймаутов в скользящем окне. В замкнутом состоянии он пропускает запросы и считает сбои; при превышении порога размыкается и начинает мгновенно отклонять вызовы, не трогая упавшую зависимость и не занимая на неё потоки; через паузу переходит в пробное состояние и пропускает несколько запросов, чтобы понять, восстановилась ли она. Ключевая польза — мгновенный отказ вместо десяти тысяч запросов, висящих на таймаутах, и передышка для зависимости. Настраивать его нужно по доле ошибок и минимальному числу вызовов в окне, иначе один неудачный запрос ночью размыкает цепь на пустом трафике.
Отсеки изолируют ресурсы: у каждой зависимости свой пул или своё ограничение одновременных вызовов. Тогда зависшая внешняя система исчерпывает только свой отсек, а остальная функциональность продолжает работать. На практике ограничение конкурентности работает лучше выделенных пулов потоков — оно дешевле и точнее отражает то, что мы действительно хотим ограничить.
Когда нагрузка превышает возможности, правильная реакция — отказать части запросов быстро, а не пытаться обслужить все медленно. Ограничение частоты защищает от отдельного шумного клиента, сброс нагрузки — от общего превышения ёмкости. Полезно ранжировать трафик: пользовательские запросы важнее фоновой аналитики и переиндексации, и при нехватке ёмкости жертвуют вторыми. Очереди при этом должны быть ограниченной длины и отбрасывать устаревшие запросы: элемент, пролежавший дольше таймаута клиента, обрабатывать уже незачем.
Всё перечисленное существует только в той мере, в какой проверено. Таймауты, предохранители и деградация, которые ни разу не срабатывали на учениях, почти всегда настроены неверно: пороги взяты наугад, запасной путь не работает, а алерт ведёт не туда. Регулярные учения с намеренным отключением зависимостей — единственный способ убедиться, что схема работает.
Transactional outbox вместо двухфазного коммита
Типичная задача: изменить данные в своей базе и сообщить об этом другим — отправить событие в брокер или вызвать соседний сервис. Между двумя действиями можно упасть, и тогда система расходится: заказ создан, а событие не ушло, либо наоборот. Это проблема двойной записи, и она возникает в любом сервисе, публикующем события.
Двухфазный коммит формально её решает, но в микросервисной среде от него отказываются: координатор становится единой точкой отказа, участники держат блокировки до его решения, доступность падает до произведения доступностей участников, а поддержка со стороны брокеров ограничена или отсутствует.
Практическое решение — исходящая таблица. В той же транзакции, что и бизнес-данные, записывается строка с событием: либо оба изменения зафиксированы, либо ни одного. Отдельный процесс читает эту таблицу и публикует события в брокер, помечая отправленные; альтернатива опросу — чтение журнала транзакций базы через захват изменений, что даёт меньшую задержку и не нагружает таблицу опросами.
Гарантию нужно называть честно: получается доставка не менее одного раза, потому что упасть можно между отправкой в брокер и отметкой об отправке. Значит, потребитель обязан быть идемпотентным — обычно через дедупликацию по идентификатору события на своей стороне. Симметричный приём на приёмной стороне называют входящей таблицей: идентификаторы обработанных сообщений хранятся вместе с результатом обработки в одной транзакции.
Порядок событий сохраняется только в пределах ключа: события одного агрегата отправляют в один раздел брокера, глобального порядка не будет. Это нужно учитывать в проектировании потребителя — он должен переживать как повтор, так и события, пришедшие не в том порядке относительно других агрегатов.
Как отвечать на собеседовании
Задайте рамку первым предложением: сбои — норма, цель не в их отсутствии, а в управляемой деградации вместо каскада. Дальше почти любой вопрос про надёжность становится частным случаем этой рамки, и ответ звучит связно, а не как перечисление паттернов.
Называйте приёмы в правильном порядке и с их назначением: таймаут на каждом внешнем вызове и бюджет времени по цепочке, ограничение конкурентности и отсеки, повторы только для временных ошибок с ростом паузы и разбросом, предохранитель как защита от каскада, сброс нагрузки при перегрузке. Порядок здесь и есть содержание ответа: сначала ограничить время и ресурсы, потом повторять.
Идемпотентность объясняйте механикой, а не определением: ключ от клиента, уникальное ограничение, эффект и отметка об обработке в одной транзакции, возврат сохранённого ответа при повторе. И отдельно проговорите, что это даёт однократный эффект, а не однократную доставку — это частая ловушка в вопросах.
Задачу «изменить данные и опубликовать событие» закрывайте исходящей таблицей с объяснением, почему не двухфазный коммит, и обязательно добавляйте, что доставка получается не менее одного раза, поэтому потребитель идемпотентен. Хорошим финалом будет фраза о том, что все эти механизмы проверяются учениями: ненастроенный предохранитель хуже отсутствующего, потому что создаёт ложное чувство защиты.