Распределённые системы

Сбои, ретраи, очереди, консенсус. Что делать, когда запрос завис и неизвестно, выполнился ли он; как не положить сервис ретраями; как доставить событие ровно один раз «по смыслу» и как узлы договариваются, кто главный.

Четвёртая часть серии «Системный дизайн». Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Восемь заблуждений о сети

Список Питера Дойча (Sun, 1994). Каждый пункт — источник реальных инцидентов: код, написанный «как для одной машины», ломается именно на них.

Сеть надёжнаПакеты теряются, соединения рвутся посреди запроса. Отсюда таймауты, ретраи и идемпотентность.
Задержка нулеваяВнутри ДЦ ~0,5 мс, между регионами 60–150 мс. 100 последовательных вызовов — уже секунды.
Пропускная способность бесконечнаОтвет на 5 МБ × 1000 rps — это 40 Гбит/с. Пагинация и сжатие.
Сеть безопаснаmTLS между сервисами, а не доверие «потому что внутри VPC».
Топология не меняетсяПоды переезжают, IP меняются, DNS кэшируется дольше, чем живёт адрес.
Администратор одинЧужой балансировщик, чужой файрвол и чужое окно обслуживания.
Передача бесплатнаСериализация стоит CPU, межзонный трафик в облаке — денег.
Сеть однороднаРазные версии протоколов, MTU, таймауты прокси по пути.

Главное следствие: у удалённого вызова не два исхода, а три — «успех», «ошибка» и «неизвестно». Про сам TCP и сокеты — в презентации «Сеть: от пакета до сокета».

Частичный отказ: таймаут — а запрос выполнился?

Клиент отправил «списать 1000 ₽» и через 2 секунды получил таймаут. Это может значить три разные вещи: запрос не дошёл, сервер его выполнил, но ответ потерялся, или сервер ещё работает и выполнит его позже.

Отправляйте запросы в разных сценариях. Клиент во всех неудачных случаях видит одно и то же — таймаут, а на сервере деньги то списаны, то нет.

Таймауты: как выбрать

  • Таймаут нужен всегда. Без него медленная зависимость держит потоки и соединения, и по закону Литтла занятых потоков становится rps × задержка.
  • Слишком короткий таймаут режет нормальные медленные ответы и провоцирует ретраи. Ориентир — p99,9 здоровой зависимости с запасом.
  • Таймаут вызова должен укладываться в дедлайн всего запроса. Передавайте оставшийся бюджет вниз по цепочке (gRPC deadline, заголовок).

Двигайте таймаут и включите деградацию зависимости.

Ретраи: экспонента и джиттер

Сервер упал на несколько секунд, и у 1000 клиентов запросы сломались одновременно. Если все повторяют по одному расписанию, они приходят волнами: сервер, который только поднялся, получает тысячу запросов в одно мгновение, берёт в очередь сотню, отбивает остальные и потом простаивает до следующей волны.

Экспонента (0,5 → 1 → 2 → 4 → 8 с, дальше потолок) снижает нагрузку, но волны остаются. Случайная задержка (full jitter: от 0 до экспоненты) размазывает их. Выберите политику и запустите.

Бюджет ретраев

Ретраи умножаются по слоям. Gateway → сервис → сервис → база, каждый делает 3 попытки: в худшем случае одна операция пользователя превращается в 3 × 3 × 3 = 27 запросов к базе — ровно тогда, когда база и так перегружена.

  • Повторяйте только в одном слое, обычно ближе к клиенту.
  • Бюджет: ретраев не больше 10% от успешных запросов (так делают Envoy, Finagle, gRPC retry throttling). При массовом сбое ретраи сами выключаются.
  • Не повторяйте 4xx и явный отказ от перегрузки (429, 503 с Retry-After). Повторяйте сетевые ошибки и таймауты — только для идемпотентных операций.
  • Лимит попыток (3–5) и общий дедлайн.

// успех: +0,1 токена, ретрай: −1 → ретраев ≤ 10%
type RetryBudget struct {
	mu     sync.Mutex
	tokens float64 // не больше max
	max    float64
}

func (b *RetryBudget) OnSuccess() {
	b.mu.Lock()
	b.tokens = min(b.max, b.tokens+0.1)
	b.mu.Unlock()
}

func (b *RetryBudget) CanRetry() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.tokens < 1 {
		return false // бюджет исчерпан: ошибка сразу
	}
	b.tokens--
	return true
}

Ключ идемпотентности

Клиент генерирует уникальный ключ на операцию и шлёт его со всеми повторами. Сервер в той же транзакции, что и списание, сохраняет ключ и ответ. Повтор с тем же ключом получает сохранённый ответ, а не второе списание.


BEGIN;
INSERT INTO idem_keys (key) VALUES ($1)
  ON CONFLICT DO NOTHING;  -- 0 строк → повтор
INSERT INTO payments ...;
UPDATE idem_keys SET response = $2
  WHERE key = $1;
COMMIT;

Так работают Stripe (Idempotency-Key), PayPal, AWS (ClientToken). Ключи хранят сутки и больше, а при совпадении ключа сверяют хеш тела запроса.

Проверьте себя

Сервис переводит деньги через внешний платёжный шлюз. Шлюз не поддерживает ключи идемпотентности. Вызов упал по таймауту. Что правильно сделать?

Таймаут — это «неизвестно», а не «ошибка». Повтор может списать деньги дважды, а ответ «не выполнено» может оказаться ложью. Операцию сохраняют в статусе «неизвестно» и выясняют, что случилось: запросом статуса по своему ID, который передали в шлюз, или сверкой (reconciliation) с выпиской. Этот вопрос любят на собеседованиях.

Circuit breaker

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

  • Closed — вызовы идут, ошибки считаются.
  • Open — вызовов нет, ошибка за микросекунды. Зависимость получает передышку.
  • Half-open — через паузу пропускаем несколько пробных вызовов. Успешны — замыкаемся, нет — снова open.

Окно — 20 вызовов, порог 50%, пауза 5 с, 3 пробных вызова. Сломайте зависимость, а потом почините.

Переборки и каскадные отказы

Сервис ходит в B и C через общий пул из 20 потоков. C начал отвечать за 2 секунды вместо 50 мс. По закону Литтла вызовам C теперь нужно 50 rps × 2 с = 100 потоков: они занимают весь пул, и запросы к здоровому B тоже отбиваются.

Так отказ одной зависимости роняет весь сервис, а за ним — тех, кто зовёт его. Переборка (bulkhead) — отдельный пул или лимит одновременных вызовов на каждую зависимость: C тонет в своём отсеке. Включите тормоза C, затем переборки.

Очереди: семантика доставки

  • At-most-once — подтвердить (ack), потом обработать. Упали посередине — сообщение потеряно.
  • At-least-once — обработать, потом ack. Упали посередине — брокер доставит снова, и эффект применится дважды.
  • «Exactly-once» на практике = at-least-once + идемпотентный потребитель: ID сообщения записывается в той же транзакции, что и эффект, и повтор пропускается.

Каждое сообщение — «начислить 100 ₽». Обрабатывайте, роняйте потребителя посередине и сравнивайте баланс с ожидаемым.

Порядок и партиции

  • Kafka гарантирует порядок только внутри партиции. Партиция выбирается по хешу ключа, поэтому все события одного заказа — по порядку, если ключ = order_id.
  • В группе потребителей партицию читает ровно один потребитель. Параллельность ≤ числа партиций.
  • Глобальный порядок = одна партиция = один потребитель. Обычно он не нужен — нужен порядок по сущности.
  • Порядок ломают: ретраи продюсера без enable.idempotence, обработка одной партиции в нескольких потоках, перебалансировка (сообщения после последнего коммита оффсета придут снова).

Когда брать Kafka, а когда RabbitMQ — в презентации «Серверные технологии».

продюсер, ключ = order_id
A1B1A2C1B2A3
P0
A1A2A3
→ потребитель 1
P1
B1B2
→ потребитель 2
P2
C1
→ потребитель 2

hash(A) mod 3 = 0, hash(B) = 1, hash(C) = 2. A1 → A2 → A3 всегда по порядку; между A и B порядка нет и не нужно.

Двойная запись и transactional outbox

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

Outbox: событие пишется в таблицу outbox в той же транзакции, что и заказ. Отдельный relay читает таблицу, публикует и помечает отправленным. Упал relay после публикации — событие уйдёт повторно, поэтому потребитель всё равно идемпотентный. Выберите способ и место сбоя.

CDC: события из журнала базы

Change Data Capture читает журнал репликации базы (WAL в Postgres, binlog в MySQL) и превращает каждое изменение строки в событие. Debezium + Kafka Connect — стандартный набор.

  • Relay для outbox без опроса таблицы: Debezium читает вставки в outbox и публикует их — задержка в миллисекунды, нагрузки на базу почти нет.
  • CDC сырых таблиц удобен для поиска, кэшей и аналитики, но схема таблицы становится публичным контрактом. Для событий между сервисами лучше outbox.
  • Слот логической репликации держит WAL, пока потребитель не прочитал. Упал Debezium — диск базы растёт. Мониторьте слоты (подробнее — в «PostgreSQL изнутри»).
  • Гарантия — at-least-once: после рестарта коннектор перечитывает с последнего сохранённого места.

-- Postgres: включить логическую репликацию
ALTER SYSTEM SET wal_level = logical;

CREATE TABLE outbox (
  id      uuid PRIMARY KEY,
  key     text NOT NULL,   -- order_id
  type    text NOT NULL,   -- OrderCreated
  payload jsonb NOT NULL,
  created timestamptz DEFAULT now()
);

-- в той же транзакции, что и заказ
INSERT INTO outbox (id, key, type, payload)
VALUES (gen_random_uuid(), '42',
        'OrderCreated', '{"sum": 1000}');

-- сколько WAL держит каждый слот
SELECT slot_name, active,
  pg_wal_lsn_diff(pg_current_wal_lsn(),
                  restart_lsn) AS bytes
FROM pg_replication_slots;

Саги: транзакция из шагов и компенсаций

Заказ затрагивает четыре сервиса со своими базами. Распределённая транзакция (2PC) блокирует всех на время сбоя координатора, поэтому делают сагу: цепочку локальных транзакций. Если шаг не удался, уже сделанные шаги отменяются компенсациями в обратном порядке.

  • Оркестрация — один координатор знает весь сценарий и вызывает шаги. Логику видно в одном месте.
  • Хореография — сервисы реагируют на события друг друга. Нет центра, но сценарий размазан по сервисам.

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

Проверьте себя

В Kafka включены транзакции и processing.guarantee=exactly_once_v2. Потребитель читает событие, пишет строку в Postgres и отправляет письмо. Что гарантировано?

Транзакции Kafka атомарно связывают чтение, запись в топики и коммит оффсетов — внутри Kafka. Postgres и почтовый сервер в эту транзакцию не входят: потребитель может упасть после INSERT и до коммита оффсета и обработать событие снова. Для строки — ID события в той же транзакции Postgres (как на слайде про доставку), для письма — таблица отправленных или ключ идемпотентности у почтового провайдера.

Часы врут: время и порядок событий

  • Часы серверов расходятся: NTP держит единицы миллисекунд в ДЦ, но после сбоя синхронизации или на ВМ расхождение бывает сотни миллисекунд. Часы могут прыгнуть назад.
  • Поэтому «последняя запись побеждает» по времени сервера молча теряет данные. Длительности меряйте монотонными часами (time.Since, System.nanoTime), не now().
  • Часы Лэмпорта: счётчик +1 на каждом событии, в сообщение кладём счётчик, получатель берёт max(свой, пришедший) + 1. Если A причина B, то L(A) < L(B).

Сценарий: запись x=1 на A реплицируется на B, клиент на B видит её и пишет x=2. Реплика C применяет «побеждает большая метка». Сдвиньте часы B назад и сравните метки.

Консенсус: Raft

  • Время поделено на сроки (term). В каждом сроке не больше одного лидера: чтобы им стать, нужны голоса большинства, а голосуют за одного кандидата за срок.
  • Лидер принимает записи и рассылает их в журналы ведомых. Запись подтверждена, когда она есть у большинства.
  • Лидер молчит дольше таймаута выборов (случайного) — ведомый начинает выборы со сроком +1.
  • Кворум — большинство: 2 из 3, 3 из 5. Кластер из 4 терпит один отказ, как и из 3, поэтому узлов ставят нечётное число. etcd, Consul, Kafka KRaft, CockroachDB построены на Raft.

Время замедлено примерно в 10 раз. Пишите, роняйте лидера, нажимайте на узлы.

Аренда и fencing-токены

Распределённая блокировка — это аренда (lease) на время: если владелец пропал, через TTL её получит другой. Но владелец мог не пропасть, а замереть: пауза GC, своп, остановка ВМ. Проснувшись, он не знает, что аренда истекла, и пишет.

Защита — монотонный fencing-токен: сервис блокировок выдаёт номер с каждой арендой, хранилище помнит максимальный и отвергает меньшие. В etcd это revision ключа, в ZooKeeper — zxid. Пройдите сценарий без токена и с ним.

Проверьте себя

Кластер etcd из 3 узлов решили расширить до 4, «чтобы было надёжнее». Сколько узлов теперь может упасть без потери записи?

Кворум из 4 — это 3 узла, значит, переживаем 1 отказ — столько же, сколько с тремя. Зато каждая запись теперь ждёт подтверждения от 3 узлов, а не от 2, и вероятность, что откажут два из четырёх, выше, чем два из трёх. Нужна устойчивость к двум отказам — ставят 5. Больше 7 почти не бывает: запись становится медленнее.

На собеседовании и в проде

  • На каждой стрелке схемы скажите: что будет при таймауте, кто повторяет, где ключ идемпотентности.
  • Называйте семантику доставки явно. «Exactly-once» — только с оговоркой про идемпотентного потребителя.
  • Событие после записи в базу — outbox или CDC, а не «запишем и отправим».
  • Многошаговый процесс через сервисы — сага с компенсациями, а не 2PC.
  • Лидер и блокировки — готовый консенсус (etcd, ZooKeeper) плюс fencing-токены. Свой Raft не пишут.
  • Проверяйте отказы заранее: задержки и потери в сети, падение зависимости, пауза процесса.

# задержка 200±50 мс и 2% потерь
tc qdisc add dev eth0 root \
   netem delay 200ms 50ms loss 2%
tc qdisc del dev eth0 root

# toxiproxy: «медленный Redis» в тестах
toxiproxy-cli toxic add \
   -t latency -a latency=2000 redis

# «пауза GC» на 15 секунд
kill -STOP $PID; sleep 15; kill -CONT $PID

# что смотреть в метриках
retries/sec, retry budget exhausted,
circuit breaker state, consumer lag,
outbox: самая старая неотправленная запись,
raft: leader changes, proposals failed

Главное

  • У удалённого вызова три исхода: успех, ошибка и «неизвестно». Таймаут — это «неизвестно».
  • Таймаут на каждом вызове; ретраи — с экспонентой, джиттером и бюджетом, в одном слое.
  • Повторяемое — идемпотентно: ключ у API, ID сообщения у потребителя.
  • Circuit breaker и переборки не дают одной зависимости уронить всё.
  • Доставка — at-least-once, «ровно один раз» делает потребитель. Событие после записи — через outbox.
  • Порядок — не по часам серверов. Лидер — по кворуму, его права — по fencing-токену.

Распределённая система — это когда отказ компьютера, о существовании которого вы не знали, ломает ваш.Лесли Лэмпорт