Сбои, ретраи, очереди, консенсус. Что делать, когда запрос завис и неизвестно, выполнился ли он; как не положить сервис ретраями; как доставить событие ровно один раз «по смыслу» и как узлы договариваются, кто главный.
Четвёртая часть серии «Системный дизайн». Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
Список Питера Дойча (Sun, 1994). Каждый пункт — источник реальных инцидентов: код, написанный «как для одной машины», ломается именно на них.
Главное следствие: у удалённого вызова не два исхода, а три — «успех», «ошибка» и «неизвестно». Про сам TCP и сокеты — в презентации «Сеть: от пакета до сокета».
Клиент отправил «списать 1000 ₽» и через 2 секунды получил таймаут. Это может значить три разные вещи: запрос не дошёл, сервер его выполнил, но ответ потерялся, или сервер ещё работает и выполнит его позже.
Отправляйте запросы в разных сценариях. Клиент во всех неудачных случаях видит одно и то же — таймаут, а на сервере деньги то списаны, то нет.
Двигайте таймаут и включите деградацию зависимости.
Сервер упал на несколько секунд, и у 1000 клиентов запросы сломались одновременно. Если все повторяют по одному расписанию, они приходят волнами: сервер, который только поднялся, получает тысячу запросов в одно мгновение, берёт в очередь сотню, отбивает остальные и потом простаивает до следующей волны.
Экспонента (0,5 → 1 → 2 → 4 → 8 с, дальше потолок) снижает нагрузку, но волны остаются. Случайная задержка (full jitter: от 0 до экспоненты) размазывает их. Выберите политику и запустите.
Ретраи умножаются по слоям. Gateway → сервис → сервис → база, каждый делает 3 попытки: в худшем случае одна операция пользователя превращается в 3 × 3 × 3 = 27 запросов к базе — ровно тогда, когда база и так перегружена.
Retry-After). Повторяйте сетевые ошибки и таймауты — только для идемпотентных операций.
// успех: +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). Ключи хранят сутки и больше, а при совпадении ключа сверяют хеш тела запроса.
Сервис переводит деньги через внешний платёжный шлюз. Шлюз не поддерживает ключи идемпотентности. Вызов упал по таймауту. Что правильно сделать?
Если зависимость стабильно падает, нет смысла каждый раз ждать её таймаута. Предохранитель считает ошибки в окне последних вызовов и при превышении порога размыкается: вызовы сразу получают ошибку или запасной ответ.
Окно — 20 вызовов, порог 50%, пауза 5 с, 3 пробных вызова. Сломайте зависимость, а потом почините.
Сервис ходит в B и C через общий пул из 20 потоков. C начал отвечать за 2 секунды вместо 50 мс. По закону Литтла вызовам C теперь нужно 50 rps × 2 с = 100 потоков: они занимают весь пул, и запросы к здоровому B тоже отбиваются.
Так отказ одной зависимости роняет весь сервис, а за ним — тех, кто зовёт его. Переборка (bulkhead) — отдельный пул или лимит одновременных вызовов на каждую зависимость: C тонет в своём отсеке. Включите тормоза C, затем переборки.
Каждое сообщение — «начислить 100 ₽». Обрабатывайте, роняйте потребителя посередине и сравнивайте баланс с ожидаемым.
order_id.enable.idempotence, обработка одной партиции в нескольких потоках, перебалансировка (сообщения после последнего коммита оффсета придут снова).Когда брать Kafka, а когда RabbitMQ — в презентации «Серверные технологии».
hash(A) mod 3 = 0, hash(B) = 1, hash(C) = 2. A1 → A2 → A3 всегда по порядку; между A и B порядка нет и не нужно.
Сервис сохраняет заказ в базу и публикует событие в брокер. Это две разные системы, общей транзакции нет: процесс может упасть между ними, и одна запись случится без другой.
Outbox: событие пишется в таблицу outbox в той же транзакции, что и заказ. Отдельный relay читает таблицу, публикует и помечает отправленным. Упал relay после публикации — событие уйдёт повторно, поэтому потребитель всё равно идемпотентный. Выберите способ и место сбоя.
Change Data Capture читает журнал репликации базы (WAL в Postgres, binlog в MySQL) и превращает каждое изменение строки в событие. Debezium + Kafka Connect — стандартный набор.
outbox и публикует их — задержка в миллисекунды, нагрузки на базу почти нет.
-- 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 и отправляет письмо. Что гарантировано?
time.Since, System.nanoTime), не now().Сценарий: запись x=1 на A реплицируется на B, клиент на B видит её и пишет x=2. Реплика C применяет «побеждает большая метка». Сдвиньте часы B назад и сравните метки.
Время замедлено примерно в 10 раз. Пишите, роняйте лидера, нажимайте на узлы.
Распределённая блокировка — это аренда (lease) на время: если владелец пропал, через TTL её получит другой. Но владелец мог не пропасть, а замереть: пауза GC, своп, остановка ВМ. Проснувшись, он не знает, что аренда истекла, и пишет.
Защита — монотонный fencing-токен: сервис блокировок выдаёт номер с каждой арендой, хранилище помнит максимальный и отвергает меньшие. В etcd это revision ключа, в ZooKeeper — zxid. Пройдите сценарий без токена и с ним.
Кластер etcd из 3 узлов решили расширить до 4, «чтобы было надёжнее». Сколько узлов теперь может упасть без потери записи?
# задержка 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
Распределённая система — это когда отказ компьютера, о существовании которого вы не знали, ломает ваш.Лесли Лэмпорт