Содержание

С вас вопросы, с нас ответы. Часть 7

Отвечаем на популярные вопросы по паттернам отказоустойчивости
Консультирует:
Даниил Булыкин, Senior в Ozon tech

1. Почему в микросервисной архитектуре сбои случаются чаще, чем в монолитной?

Это не столько про то, что багов становится больше — скорее меняется их характер. В микросервисной архитектуре проблемы становятся разнообразнее и менее предсказуемыми. К привычным ошибкам в коде добавляется целый пласт сетевых сбоев и сложностей во взаимодействии сервисов — того, чего в монолите просто не существует. По сути, мы обмениваем риск одного большого падения на постоянный поток мелких, но регулярных инцидентов.

Монолит можно представить как один мощный компьютер. Если он падает — падает все сразу. Но пока он работает, внутри все происходит быстро и стабильно: вызовы — это просто обращения к памяти в рамках одного процесса.

Микросервисы же больше похожи на сеть отдельных машин, которые вынуждены общаться друг с другом по сети. И вот тут появляется гораздо больше точек отказа.

Во-первых, сама сеть. Она асинхронна и по своей природе ненадежна. В монолите вызов метода либо успешно выполняется, либо завершается исключением — все предсказуемо. В распределенной системе отсутствие ответа — это всегда загадка: запрос мог потеряться, сервис может быть недоступен, а может просто отвечать слишком долго. Отсюда и вечная проблема с таймаутами: сделаешь их короткими — начнутся ложные срабатывания, увеличишь — начнешь терять ресурсы и ухудшать отклик для пользователя. Универсального решения здесь нет.

Во-вторых, время. У каждого сервера свои часы, и они неизбежно расходятся. Даже синхронизация не дает идеальной точности. В монолите это почти незаметно — там одна машина и одно представление о времени. А в микросервисах время может «прыгать» вперед и назад из-за коррекций. Это приводит к странным эффектам: события могут обрабатываться в неправильном порядке, а решения, основанные на временных метках, — оказываться некорректными. При этом у любого времени есть погрешность, о которой часто просто забывают.

И, наконец, паузы в выполнении. Любой процесс может внезапно «зависнуть» на какое-то время — из-за сборки мусора, работы виртуализации или операций ввода-вывода. В монолите это выглядит как явная остановка — ее легко заметить. В микросервисах все сложнее: сервис может временно перестать отвечать на проверки здоровья, хотя фактически он не упал. Остальная система воспринимает это как отказ, перераспределяет нагрузку, а когда сервис «просыпается», он продолжает работать, не учитывая, что за это время все вокруг уже изменилось. В результате появляются дубликаты операций, рассинхронизация данных и другие неприятные эффекты.

В итоге главное отличие в том, что в распределенной системе нельзя полагаться на те же допущения, что и в монолите. Сеть может подвести, время — оказаться неточным, а выполнение — прерываться в самый неподходящий момент. Это создает фундаментальную неопределенность, с которой приходится жить.

Поэтому отказоустойчивость здесь — это не просто «обработать ошибку», а целый набор адаптационных механизмов: аккуратно настроенные таймауты, повторные попытки с учетом идемпотентности, предохранители вроде circuit breaker и изоляция ресурсов, чтобы сбой в одном месте не тянул за собой всю систему.

2. Что такое CB?

Circuit Breaker (автоматический выключатель) — это способ защитить систему от цепной реакции сбоев. Его задача проста: если какой-то сервис уже явно работает плохо или не работает вовсе, нет смысла продолжать к нему стучаться и усугублять ситуацию. Лучше вовремя остановиться и дать ему восстановиться.

Проще всего это понять через аналогию с электричеством. В обычной сети автомат выбивает, если ток становится слишком большим — чтобы провода не перегрелись и ничего не загорелось. Пока цепь разомкнута, ток не идет. Через какое-то время можно попробовать включить обратно: если проблема исчезла, все снова работает нормально.

В софте идея та же:
  • Closed (замкнут): запросы к удаленному сервису выполняются в обычном режиме, при этом отслеживается количество ошибок (таймауты, отказы, 5xx).
  • Open (разомкнут): при превышении порога ошибок выключатель размыкается, все последующие запросы немедленно завершаются ошибкой без попытки вызова. Это дает сервису время восстановиться и предотвращает исчерпание ресурсов в вызывающем сервисе.
  • Half-Open (полуоткрыт): через заданный таймаут пропускается ограниченное количество пробных запросов. Если они успешны — состояние возвращается в Closed, если нет — снова в Open.

В микросервисах это особенно критично. Один упавший сервис легко может потянуть за собой остальные. Без какой-либо защиты начинается неприятный сценарий: сервисы продолжают ждать ответы, занимают потоки и соединения, ресурсы заканчиваются, и в итоге даже те части системы, которые были здоровы, перестают нормально работать.

Circuit Breaker ломает этот сценарий. Он позволяет «падать быстро» — не зависать в ожидании, а сразу отдавать ошибку. За счет этого экономятся ресурсы, а проблемный сервис получает передышку. И при этом все происходит автоматически: система сама проверяет, когда можно вернуться к нормальной работе.

Конечно, в реальности все чуть тоньше. Нужно аккуратно настроить, сколько ошибок считать критичными и за какой промежуток времени, как долго держать выключатель в «разомкнутом» состоянии и сколько пробных запросов пускать при проверке. Важно и то, как это сочетается с таймаутами и повторными попытками: обычно сначала срабатывает таймаут, потом возможен retry, и уже при накоплении ошибок включается Circuit Breaker.

В итоге это не просто способ «подавить ошибки», а полноценный механизм защиты. Он помогает системе не развалиться из-за одного проблемного звена и переводит взаимодействие с нестабильными сервисами в более безопасный режим, где есть место и для ожидания, и для восстановления.

3. Что значит «деградировать, а не падать»?

Это ключевой принцип отказоустойчивых систем: при возникновении сбоев система продолжает работать в урезанном режиме, вместо того чтобы полностью прекратить функционирование. Вместо тотального отказа пользователь получает частичный сервис, возможно с задержками или упрощенным функционалом, но система остается живой и способной восстановиться.

Суть подхода в том, что в распределенных архитектурах, таких как микросервисы, отказы неизбежны. Поэтому систему проектируют не как «идеально работает или не работает вообще», а с учетом худших сценариев.

В одном случае сервис просто перестает отвечать: интерфейс показывает ошибку вроде 500 Internal Server Error, пользователь не может выполнить действия. Это классический сценарий падения. В другом случае система ведет себя иначе - она обнаруживает проблему, отключает зависимые или второстепенные функции, но сохраняет базовый функционал. Пользователь либо почти не замечает сбоя, либо получает понятное уведомление, например о том, что рекомендации временно недоступны, но корзина при этом работает.

Примеры такой деградации довольно наглядны. Если ломается база данных, в худшем случае падает весь backend с ошибкой подключения. Но при более устойчивом подходе система может переключиться на кешированные данные, даже если они устарели, отключить операции записи, но сохранить возможность чтения.

С рекомендательным сервисом ситуация похожая: без деградации страница может просто не загрузиться, ожидая данные. С деградацией блок рекомендаций скрывается, а остальная часть страницы продолжает работать. В сервисе погоды вместо падения приложения при запуске можно показать последние доступные данные и уведомить пользователя об отсутствии обновления. При проблемах с загрузкой изображений вместо пустоты отображается заглушка или версия более низкого разрешения из CDN.

Технически это достигается набором дополняющих друг друга механизмов. Например, Circuit Breaker в открытом состоянии не позволяет выполнять вызовы к проблемному сервису и возвращает заранее подготовленный ответ, такой как список популярных товаров из кеша. Таймауты и лимиты не дают бесконечно ждать ответа: если сервис не уложился во время, возвращается заглушка. Изоляция ресурсов позволяет сделать так, чтобы проблемы в одном модуле не блокировали все приложение. Feature flags дают возможность отключать тяжелые или нестабильные функции прямо в реальном времени, без передеплоя. Кеширование и использование статических данных позволяют опираться на последнюю успешную версию при отказе backend. Также в коде обычно предусмотрены fallback-сценарии - например, использование предыдущих данных при сбое.

Почему это критически важно, становится понятно, если посмотреть на последствия. С точки зрения пользовательского опыта лучше частично работающий или даже немного медленный сервис, чем полный отказ - вроде белого экрана или 502 Bad Gateway. Такой подход помогает предотвратить каскадные отказы: проблемы остаются локальными и не обрушивают всю систему. Кроме того, это дает время на восстановление - система продолжает работать в ограниченном режиме, пока инженеры устраняют проблему. И, наконец, это напрямую влияет на бизнес-непрерывность: ключевые функции, такие как оформление заказа или вход в аккаунт, остаются доступными даже при сбоях второстепенных сервисов.

Итог простой: деградировать, а не падать - это философия устойчивых систем, которая исходит из того, что сбои неизбежны. Вместо хрупкой модели «все или ничего» строится более гибкая архитектура, способная жертвовать второстепенными функциями ради сохранения ключевых.

4. Как выбирать значения таймаутов?

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

При выборе таймаутов в первую очередь смотрят на SLA зависимых сервисов. Если сервис Б гарантирует ответ за 200 мс в 99% случаев, нет смысла ставить таймаут меньше этого значения, но и сильно завышать его тоже не нужно - обычно добавляют небольшой запас, например до 300-500 мс.

Важно учитывать и распределение задержек. Ориентироваться на среднее время ответа опасно - редкие, но длинные задержки могут ломать систему. Поэтому чаще смотрят на p95, p99 или даже p999 и добавляют небольшой буфер, например умножают p99 на 1.5.
Не менее важны бизнес-требования. Если речь идет о пользовательской операции, вроде оформления заказа, общее время ответа обычно ограничено несколькими секундами. Все внутренние таймауты должны укладываться в этот бюджет с учетом того, что вызовы могут быть вложенными.

Также имеет значение тип операции. Запись обычно требует больше времени, чем чтение. Фоновые задачи, в отличие от пользовательских, могут позволить себе ждать дольше.
На практике лучше всего работает эмпирический подход. Сначала собираются реальные метрики - время ответа внешних вызовов, гистограммы, перцентили. Затем выставляется начальный таймаут чуть выше p99, например с запасом 20-30%. После этого важно наблюдать за системой: если таймауты начинают срабатывать даже при нормально работающем downstream-сервисе, значит значение выбрано слишком маленьким. Дальше его корректируют с учетом пиков нагрузки, сезонности и сетевых задержек. При этом нужно помнить, что latency со временем меняется, поэтому таймауты нельзя один раз настроить и забыть - их нужно периодически пересматривать.

Отдельная сложность возникает из-за цепочек вызовов. В микросервисной архитектуре один запрос часто проходит через несколько сервисов. Если сервис А вызывает Б, а Б вызывает В, таймауты должны уменьшаться по цепочке. Таймаут на вызов Б должен быть меньше, чем таймаут клиента к А, а таймаут на вызов В - еще меньше. Иначе можно получить ситуацию, когда клиент уже сдался и закрыл запрос, а сервис А все еще ждет ответ от Б, зря удерживая ресурсы.

На практике это означает, что общий таймаут пользовательского запроса, например 2 секунды, распределяется между всеми внутренними вызовами с учетом времени на маршрутизацию и обработку.

Важно различать и сами типы таймаутов. Есть время на установку соединения, время ожидания ответа после отправки запроса и время на отправку данных. Для каждой из этих фаз можно задавать отдельные значения, что дает более точный контроль над поведением системы.

Иногда используют адаптивные таймауты. Например, система может подстраивать их на основе текущих значений p99 или опираться на историю предыдущих запросов. Но такой подход усложняет отладку, поэтому чаще начинают с фиксированных значений и уже потом добавляют динамику, если это действительно нужно.

Есть и типичные ошибки. Слишком маленькие таймауты приводят к ложным отказам и ухудшают доступность даже здоровых сервисов. Слишком большие - к истощению ресурсов и замедлению всей системы при сбоях. Универсальный таймаут для всех вызовов игнорирует различия между операциями. Неправильный учет цепочки вызовов приводит к накоплению ожиданий. А статичные значения без мониторинга со временем просто перестают соответствовать реальности.

В итоге выбор таймаутов - это не про «угадать правильное число», а про процесс. Нужно измерять, закладывать разумный запас, следить за ошибками и регулярно корректировать значения. Таймаут должен быть достаточно большим, чтобы не мешать нормальной работе, и достаточно маленьким, чтобы быстро отсекать действительно проблемные или перегруженные узлы.

5. Что такое Fallback-стратегии и деградация функциональности?

Fallback-стратегия - это заранее продуманный способ реагирования на сбой, когда основной путь обработки запроса становится недоступен. Ее задача - не просто «поймать ошибку», а сохранить работоспособность системы и по возможности минимизировать влияние на пользователя.

Деградация функциональности - это осознанное упрощение поведения сервиса: он делает меньше, но продолжает работать. Особенно это важно в микросервисах, где сбой одного компонента легко тянет за собой всю цепочку.

На практике fallback может выглядеть по-разному.

Самый простой вариант - вернуть какое-то значение по умолчанию. Например, если сервис погоды не отвечает, можно показать «нет данных» или усредненную температуру за последние дни. Это резко снижает задержки и не дает системе «залипать» в ожидании.

Чуть более продвинутый подход - использовать кеш. Если API с товарами недоступен, можно отдать последнюю сохраненную версию каталога. Это снижает нагрузку на зависимые сервисы и не разгоняет лавину запросов, но есть очевидный компромисс - данные могут быть устаревшими.

Часто fallback комбинируют с повторными попытками. Система несколько раз пробует выполнить запрос с backoff и jitter, и только если это не помогает, переключается на запасной сценарий.

Иногда есть возможность пойти другим путем и вызвать альтернативный сервис. Например, если один платежный провайдер недоступен, система переключается на другой. Но здесь важно, чтобы такая замена была согласована с бизнес-логикой, иначе можно получить неожиданные эффекты.

Еще один распространенный вариант - просто упростить функциональность. Например, не показывать рекомендации, комментарии или другие второстепенные части, оставив основной контент. Так система сохраняет «ядро» и не перегружается.

Часто это работает в связке с circuit breaker: как только становится понятно, что внешний сервис нестабилен, запросы к нему прекращаются, и сразу применяется fallback - например, отдаются заранее подготовленные данные вместо «живых».

Есть и более управляемый способ - через feature flags. Если новая функциональность начинает вести себя нестабильно, ее можно быстро отключить и вернуться к старой, не трогая сам код и не делая откат.

Иногда используют заранее подготовленные срезы данных - так называемые проекции или materialized view. Если внешний источник недоступен, система берет последний сохраненный snapshot. Это позволяет продолжать работу, но требует отдельной логики обновления и, опять же, означает, что данные могут быть не самыми свежими.

В некоторых случаях fallback - это просто переход на более простой алгоритм. Например, если сложная рекомендательная система перегружена, можно временно использовать упрощенную логику, которая требует меньше ресурсов, но все еще дает приемлемый результат.

Есть несколько важных моментов, о которых обычно стоит помнить. Fallback - это не ошибка, а управляемое поведение системы. Он должен быть безопасным и идемпотентным, чтобы повторные вызовы не приводили к проблемам. И его основная задача - не только скрыть сбой, но и не дать нагрузке лавинообразно расти, когда что-то ломается.

В итоге fallback-стратегии и деградация - это инструменты контроля отказов. Они позволяют системе не «падать», а адаптироваться к проблемам и продолжать работать даже тогда, когда отдельные ее части недоступны или перегружены.

6. Почему shared database — риск для отказоустойчивости?

Shared database - это ситуация, когда несколько сервисов работают с одной и той же базой данных. На первый взгляд это удобно, но с точки зрения отказоустойчивости это одно из самых уязвимых мест архитектуры.

Первая и самая очевидная проблема - единая точка отказа. Если база падает, вместе с ней останавливаются все сервисы, которые от нее зависят. Причем даже полного падения не нужно: достаточно роста latency или деградации производительности, и это сразу начинает тормозить всю систему.

Типичный пример - когда заказы, платежи и уведомления завязаны на одну базу. Любая проблема в ней блокирует сразу всю цепочку.

Дальше начинаются проблемы с общими ресурсами. У базы один CPU, одна память, один пул соединений, общий I/O. Если один сервис начинает активно потреблять ресурсы, остальные неизбежно страдают. Это классический эффект «шумного соседа».

Например, сервис оплаты запускает тяжелую транзакцию, нагружает CPU и держит блокировки. В это время сервис уведомлений пытается записать события, но упирается в те же ресурсы и начинает тормозить. В итоге пользователи видят замедление сразу во всей системе, хотя проблема была локальной.

Еще одна важная история - связность сервисов. Когда несколько микросервисов используют одну базу, они перестают быть по-настоящему независимыми. Любое изменение схемы, сделанное под один сервис, может неожиданно повлиять на другие. Приходится постоянно думать о прямой и обратной совместимости, особенно если сервисы работают с одними и теми же таблицами.

Плюс к этому размывается зона ответственности. Когда начинаются проблемы с производительностью или доступностью базы, команды уже не могут четко разделить, «кто виноват». Даже если проблема в одном сервисе, разбираться и «тушить пожар» в итоге приходится всем вместе.

Ошибки и задержки в одном сервисе напрямую отражаются на других. Изолировать сбой становится сложно, а про независимую деградацию можно забыть.

Хороший пример - сервис заказов и сервис уведомлений, которые используют одни и те же таблицы. Добавили новое поле под заказы - старый код уведомлений может начать работать некорректно. Появились задержки в обработке заказов - автоматически замедляется и поток уведомлений.

Все это снижает автономность микросервисов, усложняет внедрение изменений и повышает риск каскадных отказов.

Отдельная проблема - масштабирование. Можно сколько угодно масштабировать сами сервисы, но если база остается одной, именно она становится узким местом. Ограничения по I/O, блокировкам и количеству соединений начинают упираться в физические возможности базы.

В реальности это проявляется довольно типично: растут очереди на получение соединений, долгие транзакции блокируют другие операции, база перегружается запросами от большого количества клиентов.

Рисков у shared database на самом деле еще больше, и их можно разбирать очень долго. Но основной смысл простой: общая база создает узкое место, усиливает взаимозависимость сервисов и в итоге снижает отказоустойчивость всей системы.

задай вопрос, а мы ответим

другие статьи