Распределенная система может сломаться так, что по отдельности все ее части будут выглядеть исправными.
Представим обычную оплату заказа: сервис отправил запрос платежному провайдеру, тот списал деньги и вернул успешный ответ. На обратном пути соединение оборвалось. Для платежной системы операция завершена, а сервис заказа получил тайм-аут и не знает, произошло списание или нет.
Самое очевидное решение — повторить запрос. И получить второе списание.
В монолите подобные ситуации встречаются реже: операция обычно проходит внутри одного процесса или одной транзакции, поэтому место сбоя проще определить. В распределенной системе между началом и концом одной бизнес-операции могут быть несколько сервисов, брокер сообщений, разные базы данных и внешний API (интерфейс программирования приложений). У каждого компонента при этом свое состояние и свое представление о том, что уже произошло.
Так, проверки «отправили запрос — получили ожидаемый ответ» здесь недостаточно. Интереснее проверить, что будет, если ответ задержится или потеряется, запрос придет повторно, события поменяются местами, а один из сервисов восстановится после нескольких минут простоя.
В таких ситуациях проявляются ошибки, которые сложно увидеть на happy path (позитивном сценарии). Ниже разберем, как их воспроизводить и что проверять, чтобы отказ одного компонента не превращался в некорректное состояние всей системы.
Почему распределенную систему нельзя тестировать как обычное приложение
Возьмем простой пример — оплату заказа в интернет-магазине.
Снаружи пользовательский сценарий выглядит так:
нажать «Оплатить» → дождаться ответа → увидеть успешную оплату
Внутри все по-другому:
Client
↓
API Gateway
↓
Order Service
↓
Payment Service
↓
Payment Provider
↓
Broker
↓
Inventory Service
Плюс несколько баз данных, кеш, фоновые процессы и внешние системы.
Теперь представим ситуацию.
Order Service отправляет запрос в Payment Service. Платеж успешно проходит у провайдера, но ответ теряется по дороге обратно.
Order Service ───────► Payment Service ───────► Payment Provider
payment = SUCCESS
Order Service ◄──X─── Payment Service
timeout
Для Payment Service операция завершена.
Для Order Service — нет.
После тайм-аута он решает повторить запрос. Если платежная операция не защищена от повторного выполнения, пользователь получает второе списание. Каждый отдельный компонент мог работать в соответствии со своей логикой. Ошибка возникла между ними.
Это особенность распределенной архитектуры: локальная корректность компонентов не гарантирует корректность системы в целом. И тестировать приходится не только результат, но и взаимодействие:
что будет при задержке ответа;
что произойдет при разрыве соединения во время операции;
можно ли безопасно повторить запрос;
что случится, если событие придет дважды;
как поведет себя система при нарушении порядка событий;
сможет ли она восстановиться после возвращения зависимости.
Частичный отказ опаснее полного
Если Payment Service полностью недоступен, ситуация хотя бы понятна:
Order Service ──X──► Payment Service
Есть ошибка соединения. Дальше система действует по заранее определенному сценарию: возвращает ошибку, переключается на резервный компонент или откладывает операцию. Но что, если сервис как будто работает.
Например, из десяти запросов:
1 → 200 OK
2 → 200 OK
3 → timeout
4 → 200 OK
5 → 4.8 sec → 200 OK
6 → timeout
...
Или один экземпляр отвечает нормально, а другой зависает. Такой частичный отказ способен запустить цепную реакцию. Клиенты начинают повторять запросы, растет количество соединений, заканчиваются рабочие потоки, увеличивается нагрузка на базу, а исходная локальная проблема постепенно затрагивает соседние компоненты.
При тестировании я бы не ограничивался двумя состояниями:
сервис работает
сервис не работает
Необходимо проверить промежуточные:
Что моделируем | Что проверяем |
Задержку ответа | тайм-ауты и рост времени ответа по цепочке |
Потерю ответа | безопасен ли повторный запрос |
Дубликат | выполнится ли бизнес-операция один раз |
Нарушение порядка событий | допустимы ли переходы между состояниями |
Потерю части узлов | перераспределение нагрузки |
Восстановление зависимости | вернется ли система в нормальное состояние |
Пять вещей, которые я проверил бы в первую очередь
1. Тайм-ауты: сколько система действительно готова ждать
Тайм-аут воспринимается как техническая настройка клиента, но в распределенной системе это часть архитектуры.
Допустим:
Client
|
| timeout = 3 sec
v
Service A
|
| timeout = 5 sec
v
Service B
Получается странная ситуация: клиент уже прекратил ждать результат, а Service A еще две секунды способен ждать Service B.
Если клиент после тайм-аута отправит запрос повторно, внутри системы одновременно могут выполняться уже две одинаковые операции. Поэтому тайм-ауты стоит проверять не по отдельности, а по всей цепочке вызовов.
Один из самых полезных тестов: искусственно увеличиваем задержку зависимого сервиса.
100 ms
500 ms
1 sec
3 sec
10 sec
И наблюдаем не только за тем, какой ответ получил клиент.
Нас интересуют:
latency
timeouts
active requests
connections
retries
error rate
В какой-то момент система должна перестать ждать и это нормально. Правильно обработанная ошибка через две секунды безопаснее успешного ответа через двадцать, если за эти двадцать секунд успела накопиться очередь из тысяч запросов.
2. Retry: повторный запрос может усилить проблему
Retry (повторная попытка) кажется естественным механизмом отказоустойчивости:
request
↓
timeout
↓
retry
При кратковременном сетевом сбое это помогает, но представим сервис, который обычно получает 1000 запросов в секунду. Из-за проблем с базой он начинает отвечать медленнее. Клиенты получают тайм-ауты и повторяют запросы.
Теперь сервис получает:
1000 исходных запросов
+ повторные попытки
+ повторные попытки повторных цепочек
То есть механизм восстановления увеличивает нагрузку именно тогда, когда системе уже плохо. Особенно опасно, когда retry настроен сразу на нескольких уровнях:
A → B → C → D
Если каждый компонент самостоятельно делает несколько попыток, один внешний запрос способен породить большое количество внутренних. Поэтому я бы проверял не только: После временного сбоя запрос все-таки выполнился? Но и: Сколько запросов для этого понадобилось?
3. Идемпотентность: два запроса — одна операция
Вернемся к платежу.
Первый запрос:
POST /payments
orderId = 10045
Платеж выполнен, но ответ потерялся. Клиент повторяет запрос и, если система воспринимает его как новую операцию, получаем:
Requests: 2
Payments: 2
Хотя ожидаем:
Requests: 2
Payments: 1
Для таких операций часто используют idempotency key (ключ идемпотентности), позволяющий системе распознать повторный запрос. Но проверить два последовательных запроса недостаточно. Интереснее отправить несколько одинаковых запросов почти одновременно:
┌── Request A
Client ─────┼── Request B
├── Request C
└── Request D
Если проверка ключа реализована неатомарно, два обработчика могут одновременно решить, что операция еще не выполнялась. Я бы смотрел не на количество 200 OK, а на реальное состояние:
payment records = 1
balance changed once
order = PAID
В распределенной системе лучше проверять бизнес-инвариант, а не только технический ответ компонента. HTTP 200 сам по себе еще не доказывает, что система осталась в корректном состоянии.
4. Circuit breaker: иногда лучше вообще не отправлять запрос
Допустим, Payment Service уже отвечает с ошибкой на большинство запросов.
Если продолжать отправлять ему новый трафик и повторные попытки, восстановиться ему будет труднее. Для таких ситуаций используется circuit breaker (предохранитель).
Упрощенно его работа выглядит так:
CLOSED
|
| слишком много ошибок
v
OPEN
|
| пауза
v
HALF-OPEN
|
| успешные пробные запросы
v
CLOSED
Проверить переход CLOSED → OPEN относительно несложно: заставляем зависимость возвращать ошибки и убеждаемся, что новые вызовы перестают уходить к ней. Но я бы уделил больше внимания обратному пути. Что происходит, когда зависимость снова доступна? Если весь накопившийся трафик мгновенно возвращается на только что восстановившийся сервис, можно получить цикл:
отказ
↓
восстановление
↓
скачок нагрузки
↓
новый отказ
Проверка механизма отказоустойчивости должна включать не только момент падения, но и выход из него.
5. Восстановление: сервис поднялся — система еще не восстановилась
После сбоя нужно проверить, как система возвращается к нормальной работе.
Например, сервис был недоступен минуту и за это время накопилась очередь. В 12:00 он снова стал healthy, но ошибки пришли в норму только через 18 секунд, задержки — через 43, а очередь разобралась к 12:03. Получается, сервис восстановился в 12:00, а система — на три минуты позже.
Для систем с брокером сообщений это особенно важно — после сбоя проблемы заканчиваются позже, чем показывает инфраструктурный мониторинг.
Асинхронность: дубликаты, порядок событий и согласованность данных
С синхронным взаимодействием хотя бы понятно, где искать проблему:
Service A → request → Service B
Service A ← response ← Service B
С очередями все становится менее очевидным:
Producer → Broker → Consumer
Producer (производитель) отправил сообщение и продолжил работу. Consumer (потребитель) обработает его позже.
Когда именно — зависит от нагрузки, состояния брокера и самого потребителя. При этом сообщение может прийти повторно, задержаться или оказаться обработанным не в том порядке, который мы ожидали. Успешная публикация события еще не означает успешную бизнес-операцию.
Допустим:
Order Service
|
| OrderCreated
v
Broker
|
+----> Payment Service
+----> Inventory Service
+----> Notification Service
Order Service успешно отправил OrderCreated.
Но в этот момент мы знаем только одно: сообщение принято брокером. Платеж мог еще не начаться, товар — не зарезервироваться, уведомление — не отправиться.
Снова возвращаемся к бизнес-инвариантам. Если заказ имеет статус PAID, платеж должен существовать независимо от того, сколько сообщений и через какие очереди понадобилось системе, чтобы к этому состоянию прийти.
Повторная доставка должна быть обычным тестовым сценарием
Consumer получил событие OrderPaid, изменил данные в базе, но не успел подтвердить обработку сообщения. Брокер отправляет событие снова:
OrderPaid → Consumer → DB
OrderPaid → Consumer → DB
Если повторная обработка меняет бизнес-состояние второй раз, получаем тот же класс проблем, что и с повторными HTTP-запросами: двойное начисление бонусов, повторную отправку товара или вторую комиссию. Следовательно полезный тест выглядит не так:
1 event → 1 operation
а так:
10 identical events → 1 business operation
Особенно если несколько одинаковых событий приходят практически одновременно.
Когда нельзя рассчитывать на порядок событий
Допустим, жизненный цикл заказа выглядит так:
OrderCreated
↓
OrderPaid
↓
OrderShipped
Нельзя заранее считать, что любой потребитель всегда обработает события именно в этой последовательности. Гарантии зависят от брокера, партиционирования и способа обработки сообщений.
Например, Kafka сохраняет порядок записей внутри одной partition (раздела), но не между разными partitions. Если события одного заказа отправляются с одним ключом и попадают в одну partition, на уровне Kafka их порядок сохраняется.
Но на уровне системы порядок все равно стоит учитывать там, где события приходят из разных источников, распределяются между разными partitions или дальнейшая обработка выполняется параллельно.
Поэтому полезный вопрос для теста звучит так:
Может ли в нашей архитектуре OrderShipped быть обработан раньше OrderPaid, и что произойдет, если это случится?
Здесь удобно проверять допустимые переходы состояний:
CREATED → PAID → SHIPPED → DELIVERED
и отдельно — недопустимые:
CREATED → SHIPPED
DELIVERED → PAID
PAID → CREATED
Если такой переход запрещен бизнес-правилами, система должна обработать его предсказуемо: отклонить событие, отложить обработку или применить другой предусмотренный архитектурой сценарий.
Eventual consistency: данные не обязаны совпадать прямо сейчас
Еще одна особенность распределенной архитектуры — eventual consistency (согласованность в конечном счете).
Payment Service → payment = SUCCESS
Order Service → order = WAITING_FOR_PAYMENT
На первый взгляд данные противоречат друг другу, но событие об успешном платеже могло еще не дойти до Order Service. Через две секунды состояние станет:
Payment Service → payment = SUCCESS
Order Service → order = PAID
Это нормальная ситуация, если архитектура допускает временную рассогласованность.
Проблема начинается с формулировки: Данные когда-нибудь должны сойтись. Для тестирования «когда-нибудь» бесполезно. Нужна измеримая граница.
В 99,9% случаев статус заказа должен обновиться не позднее чем через 10 секунд после подтверждения платежа. Теперь это можно проверить. В асинхронных тестах я бы избегал конструкций вроде:
createOrder()
sleep(10000)
assertOrderPaid()
Если система обработала событие за 300 миллисекунд, тест девять с лишним секунд ждет зря. Если ей понадобилось 11 секунд, тест упал, но мы мало знаем о причине.
Лучше ожидать конкретного состояния в пределах заданного времени:
wait until order.status == PAID
timeout = 10 sec
А при ошибке сохранять контекст: состояние платежа, последнее полученное событие, отставание потребителя и идентификатор трассировки. Так тест не только фиксирует проблему, но и помогает ее расследовать.
Распределенные транзакции: Saga нужно ломать посередине
Возьмем оформление заказа:
Создать заказ
↓
Списать деньги
↓
Зарезервировать товар
↓
Создать доставку
В монолите часть такого сценария можно закрыть одной транзакцией и при ошибке сделать ROLLBACK. В распределенной системе шаги выполняют разные сервисы, часто со своими базами данных. Поэтому ситуация «деньги списали, а товар зарезервировать не смогли» вполне реальна.
Один из способов работать с такими процессами — Saga (сага). Если очередной шаг не выполнился, система запускает компенсацию уже выполненных действий:
Create Order → OK
Charge Payment → OK
Reserve Inventory → FAIL
↓
Refund Payment
↓
Cancel Order
Для тестирования следует простое правило: ломать процесс стоит в каждой точке, где отказ способен оставить систему в промежуточном бизнес-состоянии. Сначала платеж не проходит. Затем платеж проходит, но падает резервирование. Потом ломается создание доставки. Для каждой такой точки команда должна заранее понимать, в каком состоянии в итоге останутся заказ, платеж и резерв.
Но есть сценарий интереснее:
Charge Payment → OK
Reserve Inventory → FAIL
Refund Payment → FAIL
Деньги уже списаны, товара нет, а автоматический возврат не сработал. Вот здесь Saga проходит настоящую проверку: будут ли повторные попытки возврата, что произойдет после их исчерпания, увидит ли проблему поддержка и можно ли безопасно завершить операцию вручную.
Если проверять только схему «шаг упал → компенсация успешно отработала», легко пропустить самый неприятный случай — отказ самой компенсации.
Fault injection: не ждать отказа, а создать его
Все предыдущие сценарии объединяет одна сложность: нужный сбой еще надо как-то воспроизвести. Можно ждать, когда сеть сама начнет терять пакеты или база внезапно станет отвечать пять секунд. Но для тестирования это не лучший план. Отказы имеет смысл создавать намеренно с помощью fault injection (внедрения отказов).
Например:
Service A → Service B
между ними можно искусственно добавить:
delay = 3 sec
или:
packet loss = 5%
или заставить зависимость возвращать:
HTTP 500
HTTP 429
timeout
connection reset
На более высоком уровне можно перезапустить контейнер, отключить экземпляр сервиса или сделать недоступным узел брокера, но начинать я бы советовал с минимального отказа, который позволяет проверить конкретную гипотезу.
Если Payment Service будет отвечать три секунды вместо 200 миллисекунд, сработает ли тайм-аут и не создадут ли повторные запросы дополнительную нагрузку?
Chaos engineering: сначала гипотеза, потом отказ
По этой же причине chaos engineering (хаос-инжиниринг) не стоит сводить к случайному отключению серверов. Эксперимент начинается с проверяемого предположения.
Если один экземпляр Payment Service станет недоступен, платежи продолжат проходить, а доля ошибок не превысит 1%.
Теперь есть три вещи:
условие
+
ожидаемое поведение
+
измеримый результат
После этого можно отключить экземпляр и посмотреть на error rate (долю ошибок), latency (задержку), количество повторных запросов и бизнес-операции. И начинать лучше с небольшого радиуса воздействия. Один экземпляр полезнее половины кластера, если этого достаточно для проверки гипотезы. Также важно заранее определить условия остановки эксперимента. Если влияние на систему становится больше допустимого, проверка должна завершиться.
Chaos engineering в этом смысле не столько способ тестирования «на прочность», сколько способ проверить, совпадают ли наши представления об отказоустойчивости с реальным поведением системы.
Отказ закончился. Тест — еще нет
Есть один момент, который я бы отдельно включал практически в каждый тест отказоустойчивости: восстановление.
Когда мы искусственно увеличили задержку базы данных — сервисы начали отвечать медленнее, появились тайм-ауты, выросла очередь. Через минуту убираем задержку и база снова работает нормально.
Эксперимент завершен?
Нет.
За время проблемы могла накопиться очередь. После восстановления все эти операции начинают выполняться одновременно с новыми.
Получается:
отказ
↓
накопление запросов
↓
восстановление зависимости
↓
резкий рост нагрузки
↓
новый отказ
Смотрим и на время, за которое вся система вернулась к нормальным показателям:
12:00:00 зависимость восстановилась
12:00:20 нормализовалась доля ошибок
12:00:45 нормализовалось время ответа
12:03:10 разобрана очередь
Технически компонент был исправен уже в 12:00, нактически система восстановилась только через три минуты.
Как понять, что система действительно пережила отказ
После нашего эксперимента мониторинг показывает:
CPU 42%
Memory 57%
success rate 99.9%
p95 latency 350 ms
Вроде все хорошо, а потом выясняется:
orders stuck = 1247
duplicate payments = 18
Система была технически доступна, но выполняла бизнес-операции неправильно. В тестах распределенных систем я бы смотрел сразу на два слоя.
Первый — технический:
latency
error rate
retry rate
queue lag
connections
Второй — бизнесовый:
orders created
payments completed
duplicate payments
orders stuck
failed refunds
Второй слой отвечает на главный вопрос: сохранила ли система корректность, пока переживала отказ?
Хороший пример инварианта:
one paid order = no duplicate successful charge
После нагрузочного теста или fault injection можно проверить:
paid_without_payment = 0
duplicate_payments = 0
payment_without_order = 0
Практический чек-лист: 10 вопросов к распределенной системе
Универсального набора тестов для распределенной архитектуры не существует. Слишком многое зависит от того, как взаимодействуют сервисы, где хранятся данные и какие операции критичны для бизнеса.
Но есть вопросы, которые я бы задал почти любой команде перед релизом.
1. Что произойдет, если зависимость не упадет, а станет медленной?
Полная недоступность — только один сценарий. Проверьте задержки в 500 мс, 2 секунды, 5 секунд. Посмотрите, где срабатывают тайм-ауты и не начинает ли один медленный компонент тормозить всю цепочку.
2. Что произойдет, если операция выполнится, но ответ потеряется?
Это один из самых важных сценариев для платежей, заказов и других операций, которые нельзя бездумно повторять.
Система должна уметь отличить:
операция не выполнена
от ситуации:
операция выполнена
ответ потерян
После потери ответа вызывающая сторона не всегда может понять, выполнилась операция или нет. Надо проверить, умеет ли система безопасно работать в этом неопределенном состоянии: запросить статус операции, использовать ключ идемпотентности или применить другой предусмотренный архитектурой механизм.
3. Что будет при повторном запросе?
Для критичных операций проверьте не только последовательный retry (повторную попытку), но и несколько параллельных запросов с одним идентификатором операции.
Итоговый бизнес-результат должен оставаться прежним.
4. Что будет, если сообщение придет дважды или не по порядку?
Для асинхронных процессов стоит проверить повторную доставку и запрещенные переходы состояний.
Если система получила:
OrderShipped
раньше:
OrderPaid
она должна вести себя предсказуемо и не надеяться на правильный порядок событий.
5. Сколько времени данные могут оставаться несогласованными?
Если используется eventual consistency (согласованность в конечном счете), у нее должна быть измеримая граница.
99,9% изменений отражаются в зависимом сервисе не позднее чем через 10 секунд. Тогда это свойство можно тестировать.
6. Что произойдет, если бизнес-процесс оборвется посередине?
Особенно это важно для цепочек, где уже произошло необратимое действие:
платеж выполнен
↓
резервирование товара FAILED
Для каждой точки отказа должен существовать понятный конечный сценарий.
7. А если не сработает компенсация?
Если архитектура использует Saga (сагу), недостаточно проверить успешный возврат после ошибки.
Нужно проверить и это:
основная операция → FAILED
компенсация → FAILED
И заранее понимать, кто и как будет разбирать такое состояние.
8. Что произойдет при восстановлении зависимости?
Сервис вернулся — еще не значит, что система восстановилась. Проверьте накопившиеся запросы, очереди, повторные попытки и нагрузку на вернувшийся компонент.
9. Сможем ли мы понять причину проблемы?
После искусственного отказа попробуйте расследовать его так, будто вы ничего о нем заранее не знаете. Есть ли trace ID (идентификатор трассировки)? Можно ли найти конкретную бизнес-операцию в логах? Видно ли, где появилась задержка? Есть ли метрика накопившейся очереди?
Если тест обнаруживает ошибку, которую потом невозможно объяснить, ценность такого теста заметно ниже.
10. Какие состояния системы недопустимы вообще?
Например:
order = PAID
payment = NOT_FOUND
или:
one order
two successful payments
или:
order = SHIPPED
inventory = NOT_RESERVED
Такие состояния стоит превратить в явно сформулированные бизнес-инварианты и проверять
после обычных интеграционных, нагрузочных и fault injection (внедрение отказов) тестов.
Не пытайтесь проверить все через end-to-end
Чем длиннее цепочка, тем меньше информации дает падение сквозного теста.
Сценарий проходит через шесть компонентов:
A → B → C → D → E → F
Тест упал. Теперь нужно выяснить, что именно произошло: ошибка в B, задержка в D, проблема с базой E, потерянное сообщение или старые данные на стенде. Сквозной тест хорошо показал, что бизнес-сценарий сломан, но почти ничего не сказал о причине.
Я бы оставлял end-to-end (сквозные) тесты для критичных пользовательских цепочек, а остальные проверки опускал ниже. Совместимость API и сообщений удобнее ловить контрактными тестами, работу с базой и брокером — интеграционными, тайм-ауты и повторные запросы — на уровне отдельных компонентов. Отказы инфраструктуры — отдельными экспериментами.
Иначе тест, которому для проверки одного свойства нужны шесть сервисов, три базы и брокер, очень быстро становится дорогим способом узнать: «что-то опять сломалось».
Проверяйте не сценарий, а то, что не должно сломаться
Для распределенной системы полезно заранее сформулировать несколько правил, которые должны выполняться при любых сбоях.
Например:
один заказ не может быть успешно оплачен дважды;
один заказ не может быть успешно оплачен дважды;
повторная доставка события не должна повторять бизнес-операцию;
недоступность сервиса уведомлений не должна останавливать оплату;
после восстановления брокера подтвержденные сообщения должны быть обработаны;
временно разошедшиеся данные должны сойтись за известное время.
Возьмем первое правило:
один заказ = один успешный платеж
Теперь вокруг него можно построить несколько тестов: потерять ответ платежного сервиса, повторить запрос, отправить несколько одинаковых запросов параллельно, добавить сетевую задержку, перезапустить сервис во время операции.
Сценарии разные, а проверка в конце одна: не появился ли второй платеж.
У такого подхода есть еще один плюс — он меньше зависит от реализации. Сегодня платежи ходят по HTTP, завтра часть взаимодействия переедет в Kafka. Тесты придется изменить, но правило «один заказ — один платеж» останется.
Вместо заключения
В распределенных системах подводит предположение о том, как будут вести себя несколько компонентов вместе.
Мы рассчитываем, что ответ придет вовремя, сообщение не задублируется, события сохранят порядок, а восстановившийся сервис сразу продолжит работать как раньше. В большинстве запросов так и происходит, поэтому обычные тесты проходят.
Проблемы начинаются в оставшихся случаях.
Запрос выполнился, но ответ потерялся. Сообщение пришло второй раз. Один сервис уже записал новое состояние, другой его еще не получил. После восстановления зависимости накопившаяся очередь создала новую волну нагрузки.
При тестировании распределенной системы я бы начинал с неприятного вопроса:
Что мы считаем гарантированным, хотя на самом деле это не гарантировано?
Дальше остается превратить эти предположения в проверки: задержать ответ, повторить запрос, переставить события местами, отключить зависимость, вернуть ее обратно и посмотреть не только на HTTP-код, но и на итоговое состояние системы.
Так гораздо проще найти слабое место до того, как тот же эксперимент поставит продакшен под удар.

