11.4Распределённые системы: теория
Исходи из того, что сеть задерживает ответы, часы расходятся, а узлы падают между шагами. Применяй идемпотентность, deadline, jitter, bulkhead и fencing token, затем различай CAP выбор при partition и компромисс latency/consistency в обычной работе.
Сетевое разделение неотличимо от падения
Практический вопрос тут один: что делать, когда ответ не пришёл, а узнать причину невозможно? Из него вырастают CAP, кворумы, консенсус и векторные часы.
Главное: изнутри сетевое разделение неотличимо от падения. Узел A послал запрос узлу B и не получил ответа. Что случилось: B умер? B жив, но ответ потерялся по дороге? B жив, получил запрос, выполнил его и просто отвечает медленно? Различить эти три случая нельзя в принципе - не «инструмент пока не придумали», а математически невозможно: все три выглядят для A одинаково, как тишина. Всё дальнейшее сводится к тому, как с этой тишиной жить.
CAP: что теорема на самом деле утверждает
Когда сеть порвалась, узел на одной стороне разрыва обязан выбрать одно из двух - ответить клиенту возможно устаревшими данными или не ответить вовсе. Третьего варианта нет физически: свежие данные лежат по ту сторону разрыва, дотянуться до них нельзя, а притвориться, что дотянулся, значит соврать. Всё прочее в CAP лишь аккуратно формулирует этот выбор и доказывает, что он неизбежен.
Точная формулировка (Gilbert & Lynch в 2002 году доказали гипотезу Брюера): распределённая система не может одновременно гарантировать consistency, availability и partition tolerance. Дальше начинается место, где ошибаются почти все: каждое из трёх слов означает не то, что кажется.
- Consistency здесь означает линеаризуемость, а не букву C из ACID. Требование: система ведёт себя так, будто существует одна копия данных, и любое чтение возвращает результат последней завершённой записи по реальному времени. Свойство очень сильное - сильнее, чем «данные не противоречат ограничениям».
- Availability: каждый запрос к любому живому узлу получает непустой корректный ответ за конечное время. Не «99.99 % аптайма», а именно «ни один живой узел не имеет права ответить ошибкой или молчать».
- С partition tolerance система продолжает работать, когда сеть произвольно теряет сообщения между группами узлов.
CAP не говорит «выбери два из трёх на всё время жизни системы». Это условное утверждение: если случилось сетевое разделение, то придётся отказаться либо от C, либо от A. Пока сеть цела, система спокойно даёт и консистентность, и доступность - выбора делать не нужно и не из чего. Именно поэтому вопрос «ваша система CP или AP?» осмыслен только как «что вы делаете в момент разрыва».
QUORUM против ONE).Почему «CA-система» - некорректная категория
Отказаться от P означает утверждать, что сетевого разделения не бывает. В любой системе, где узлы общаются по сети, разделение не гипотеза, а наблюдаемый факт: кабель, свитч, перегруженный линк, GC-пауза на 30 секунд (снаружи неотличима от разрыва), неправильно применённая сетевая политика. Ты не выбираешь, случится ли partition; ты выбираешь только, как система себя поведёт.
Поэтому P не опция, а условие задачи, и реальный выбор всегда «C или A при P». Честной CA-системой остаётся только одноузловая: там нет сети между узлами, а значит, нет и разделения (но есть отказ, при котором система просто недоступна). Одиночный Postgres формально CA - и именно поэтому его аптайм ограничен аптаймом одной машины. Как только к нему добавили синхронную реплику, появился вопрос «что делать, если реплика недоступна: ждать (CP, встанет запись) или коммитить без неё (AP, риск потерять данные при failover)».
Плохой ответ: «выбираем два из трёх». Хороший: «P выбрать нельзя, он дан. Вопрос в том, что делает узел, который не может подтвердить свою актуальность: отвечает ошибкой (CP) или отвечает возможно устаревшими данными (AP). Для денег я выберу CP и объясню клиенту, что операция временно недоступна; для ленты рекомендаций возьму AP, потому что устаревшая лента лучше пустого экрана». Отдельный балл дадут за то, что ты заметишь: CAP описывает крайности и почти ничего не говорит о нормальной работе. Для этого есть PACELC.
PACELC: то, чего не хватает в CAP
Формулировка Дэниела Абади: if P then A or C, else L or C. Читается так: «при разделении (P) выбирай между доступностью (A) и консистентностью (C), а в остальное время (Else) выбирай между латентностью (L) и консистентностью (C)».
Вторая половина важнее первой, потому что описывает 99.99 % времени жизни системы. Разделения случаются редко; а вот выбор «ждать подтверждения от кворума реплик (медленнее, но консистентно) или ответить сразу с локальной реплики (быстро, но возможно устарело)» делается на каждом запросе. Синхронная репликация через дата-центры стоит десятки миллисекунд на каждый коммит, всегда, независимо от аварий.
| Система | При partition | В обычном режиме | Класс |
|---|---|---|---|
| Cassandra / DynamoDB (дефолт) | отвечает всегда | жертвует консистентностью ради скорости | PA/EL |
| etcd / ZooKeeper / Consul | меньшинство отказывает | ждёт кворум, латентность выше | PC/EC |
| Google Spanner | меньшинство отказывает | ждёт TrueTime-неопределённость | PC/EC |
| MongoDB (writeConcern majority) | меньшинство отказывает | ждёт большинство | PC/EC |
| MySQL/Postgres с асинхронной репликой | реплика отдаёт старое | чтение с реплики быстрое и устаревшее | PA/EL |
Практический смысл PACELC для собеса: он даёт язык, чтобы обсуждать реплики для чтения. За «читаем с асинхронной реплики» стоит осознанный выбор L над C в нормальном режиме, со всеми последствиями вроде «пользователь сохранил профиль и не увидел изменений».
Strict и eventual consistency, и что между ними
Между «всегда свежие данные» и «когда-нибудь сойдётся» лежит целая шкала моделей, и на практике почти всегда нужна её середина.
| Модель | Гарантия | Цена |
|---|---|---|
| Linearizability (strict/strong) | система выглядит как одна копия; чтение видит последнюю завершённую запись по реальному времени | кворумы или лидер + синхронная репликация; латентность и недоступность меньшинства |
| Sequential | все видят один и тот же порядок операций, но он может отставать от реального времени | дешевле линеаризуемости, но всё ещё требует согласования порядка |
| Causal | причинно-связанные операции видны всем в одном порядке; независимые - в любом | метаданные о причинности (векторные часы) - но без глобального согласования |
| Read-your-writes | ты видишь свои собственные записи | липкая сессия или чтение с лидера/по версии |
| Monotonic reads | прочитав новое значение, ты не увидишь старое при следующем чтении | привязка сессии к реплике или отслеживание версии |
| Eventual | если записи прекратятся, все реплики когда-нибудь сойдутся | почти ничего - и почти никаких обещаний пользователю |
Три «клиентские» гарантии, за которые платят пользователи
Eventual consistency без дополнительных гарантий выдаёт аномалии, которые пользователь замечает. У каждой есть имя и лечение.
- Read-your-writes (read-after-write). Пользователь сменил аватарку, обновил
страницу и видит старую - запись ушла на лидера, чтение попало на отстающую реплику.
Лечения: (1) читать с лидера в течение N секунд после записи этого пользователя;
(2) липкая сессия, когда все запросы сессии идут на одну реплику; (3) клиент запоминает
версию/LSN своей записи и требует реплику не старее (
read your writesчерез токен консистентности - так работаетconsistent readв некоторых БД). - Monotonic reads. Пользователь видит комментарий, обновляет страницу - комментарий
исчез: два чтения попали на реплики с разным лагом. Лечение: закрепить сессию
за репликой (по
user_idв consistent hashing, а не случайно), либо передавать наблюдённую версию и не читать более старую. - Monotonic writes / causal. Классика: ответ на комментарий появился раньше самого комментария, потому что записи распространялись независимо. Лечение: причинная согласованность, то есть вместе с операцией передают её зависимости (версии тех записей, которые операция «видела») и не применяют операцию, пока не применены её причины.
«Eventual consistency - это когда данные сойдутся за пару секунд». Нет: eventual не даёт никакой границы по времени, только обещание «сойдутся, если записи прекратятся». Граница по времени уже другое свойство: его надо мерить (лаг репликации как метрика с алертом) и обеспечивать. Во второй капкан попадают те, кто считает, что «сильная консистентность» решает всё: линеаризуемость не даёт атомарности нескольких объектов, это отдельное свойство (транзакции), и в распределённой системе оно ещё дороже.
Идемпотентность: главный инструмент выживания в ненадёжной сети
Определение: операция идемпотентна, если её повторное применение с теми же
аргументами не меняет состояние системы по сравнению с однократным применением.
Формально f(f(x)) = f(x). Важно, что речь про состояние, а не про
ответ: повторный DELETE может вернуть 404 вместо 204 - состояние всё равно
одинаковое, операция идемпотентна.
Смысл ровно в одном: в сети нельзя отличить «запрос не дошёл» от «ответ не вернулся», поэтому на неизвестность остаётся один безопасный ответ: повторить, а повторять можно только идемпотентное. Ретраи, at-least-once доставка, outbox, саги: всё это держится на идемпотентности.
| Идемпотентно | Неидемпотентно |
|---|---|
SET balance = 100 (присвоение) | balance = balance + 100 (инкремент) |
GET, PUT, DELETE, HEAD, OPTIONS | POST, PATCH (в общем случае) |
INSERT ... ON CONFLICT DO NOTHING | INSERT без уникального ключа |
UPDATE ... SET status='paid' WHERE id=$1 | «отправить письмо», «списать деньги», «напечатать чек» |
Redis SET k v, SADD | Redis INCR, LPUSH, APPEND |
| Загрузка файла по фиксированному ключу в S3 | Добавление строки в лог, публикация события |
Три способа сделать неидемпотентную операцию идемпотентной
-
Ключ идемпотентности. Клиент генерирует уникальный
идентификатор намерения (не запроса!) и присылает его в заголовке. Сервер
сохраняет ключ вместе с результатом; повторный запрос с тем же ключом возвращает
сохранённый результат, не выполняя работу заново. Так устроены Stripe, платёжные шлюзы
и любой нормальный финансовый API.
CREATE TABLE idempotency ( key text PRIMARY KEY, -- Idempotency-Key от клиента request_hash text NOT NULL, -- защита от «тот же ключ, другое тело» state text NOT NULL, -- IN_PROGRESS | DONE response jsonb, -- сохранённый ответ created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL -- ключи не хранят вечно: 24ч–7 суток );func (s *Service) Charge(ctx context.Context, key string, req ChargeReq) (Resp, error) { h := hashOf(req) // 1. Столбим ключ: тут и происходит сериализация. claimed, prev, err := s.claim(ctx, key, h) // INSERT ... ON CONFLICT DO NOTHING + SELECT if err != nil { return Resp{}, err } if !claimed { if prev.RequestHash != h { return Resp{}, ErrKeyReused // 422: тот же ключ с другим телом } if prev.State == "IN_PROGRESS" { return Resp{}, ErrInFlight // 409: клиент должен повторить позже } return prev.Response, nil // 200 с сохранённым ответом } // 2. Делаем работу и сохраняем результат в одной транзакции с ключом. return s.doChargeAndStore(ctx, key, req) }Три детали, которые отличают знающего: (а) ключ придумывает клиент, и он обязан переиспользовать его при ретрае - иначе смысла нет; (б) хранится хеш запроса, чтобы поймать «тот же ключ, другое тело» и вернуть ошибку вместо чужого ответа; (в) есть состояние
IN_PROGRESS, иначе два параллельных ретрая начнут работу одновременно. -
Уникальное ограничение в БД (естественная дедупликация). Если у операции есть
естественный уникальный ключ, СУБД сделает работу за тебя:
UNIQUE (order_id, operation_type), и второйINSERTпросто провалится или ничего не сделает. Самый дешёвый и самый надёжный вариант, потому что гарантию держит само хранилище, а не дисциплина кода. Работает и на потребителе брокера:INSERT INTO processed(message_id) ON CONFLICT DO NOTHINGв одной транзакции с эффектом. -
Условное изменение состояния (CAS / переход конечного автомата). Вместо «списать»
писать «перевести из
PENDINGвPAID»:UPDATE orders SET status='PAID' WHERE id=$1 AND status='PENDING'. Повтор увидит 0 затронутых строк и поймёт, что работа уже сделана. Тем же приёмом работает оптимистическая блокировка по версии:... WHERE id=$1 AND version=$2. Это превращает любое изменение в идемпотентное на уровне семантики домена и заодно защищает от конкурентных обновлений.
Сделать идемпотентной отправку письма или SMS внутри своей БД можно (запомнить, что отправляли), а вот у внешнего провайдера гарантию придётся получать его средствами - и обычно он тоже предлагает ключ идемпотентности. Если не предлагает, остаётся своя таблица «отправлено» с записью до вызова и подтверждением после, плюс осознанный выбор: «лучше не отправить, чем отправить дважды» или наоборот. Это доменное решение, и на собесе полезно сказать вслух, что оно доменное.
Таймауты, ретраи, jitter - и как не устроить retry storm
Таймаут: обязателен всегда
Запрос без таймаута оставляет утечку, которая ждёт своего часа: горутина, соединение из пула и память живут ровно столько, сколько решит удалённая сторона. Правила:
- Таймаут ставится на каждый внешний вызов и выбирается по перцентилю, а не наугад: обычно p99 нормального ответа × 1.5–2. Таймаут 30 секунд при p99 = 80 мс бесполезен - он не защищает, а только продлевает агонию.
- Бюджет таймаута. Дедлайн задаёт входящий запрос,
и он уменьшается по цепочке: если клиенту обещано 2 секунды, а на первый хоп
ушло 700 мс, второму хопу остаётся 1.3 с, а не снова 2. В Go это буквально
context.WithTimeout, который пробрасывают вниз, а в gRPC он передаётся по сети автоматически. Без этого суммарное время ответа = сумма таймаутов, и клиент отваливается раньше, чем система успевает ему ответить. - Отменять работу по дедлайну. Если клиент уже ушёл, считать дальше значит
жечь ресурсы впустую.
ctx.Done()должен реально доходить до запроса в БД.
Ретраи: только на то, что можно повторять
- Повторять можно таймауты, ошибки соединения, 502/503/504, gRPC
UNAVAILABLE/DEADLINE_EXCEEDED- и только для идемпотентных операций. - Повторять нельзя 400/401/403/404/422 (отказ детерминированный - повтор даст то же самое) и любые неидемпотентные операции без ключа идемпотентности.
- Уважать
Retry-After, если сервер его прислал: он знает лучше. - Ограничивать число попыток: 2–3 на уровень, не больше. И помнить про умножение по цепочке: 3 уровня по 3 попытки = 27 запросов на один клиентский, а по 3 повтора сверх первой попытки — уже 43 = 64.
Экспоненциальная задержка и jitter
Просто «повторить через секунду» плохо тем, что тысяча клиентов, получивших ошибку одновременно, повторят тоже одновременно - и добьют сервис ровно в момент, когда он пытается встать. Экспонента разносит попытки во времени, jitter разносит клиентов между собой.
// Full jitter: задержка равномерно из [0, cap), где cap растёт экспоненциально.
// В замерах AWS он даёт наименьшую нагрузку и наименьшее время до успеха,
// лучше, чем «экспонента ± небольшой шум».
func backoff(attempt int) time.Duration {
const base, max = 100 * time.Millisecond, 10 * time.Second
exp := base << attempt // 100ms, 200ms, 400ms, 800ms...
if exp > max {
exp = max
}
return time.Duration(rand.Int63n(int64(exp))) // [0, exp)
}
func doWithRetry(ctx context.Context, f func(context.Context) error) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-time.After(backoff(attempt)):
case <-ctx.Done(): // дедлайн важнее ретраев
return ctx.Err()
}
}
if err = f(ctx); err == nil || !retriable(err) {
return err
}
}
return err
}
Сценарий: сервис D деградировал и отвечает медленно. C делает 3 попытки к D, B — 3 попытки к C, A — 3 попытки к B, и на один пользовательский запрос приходит 27 запросов к D. Нагрузка на упавший сервис выросла на порядок ровно тогда, когда ему нужно было разгрузиться, и сам он больше не встанет: это metastable failure. Четыре средства: (1) ретраи только на одном уровне - обычно на самом внешнем или в mesh, остальные выключены; (2) retry budget: не более ~10 % ретраев от общего числа запросов, сверх бюджета ретраи запрещены; (3) circuit breaker: перестать долбить того, кто явно лежит; (4) jitter, чтобы попытки не выстраивались в синхронную волну.
Тот же механизм синхронизации проявляется и без ретраев: одновременно истёкший TTL
у горячего ключа кэша отправляет тысячу запросов в БД (cache stampede), перезапуск
кластера отправляет все поды в БД за прогревом одновременно, cron в
0 * * * * у ста инстансов бьёт в один момент. Лечения те же по духу:
jitter в TTL и в расписании, single-flight (в Go это golang.org/x/sync/singleflight), вероятностное раннее
обновление кэша.
Circuit breaker, bulkhead, backpressure, load shedding
Четыре разных ответа на вопрос «что делать, когда мощности не хватает». Их часто перечисляют списком, не различая, хотя различие простое: breaker защищает от чужого отказа, bulkhead ограничивает распространение своего, backpressure тормозит источник, load shedding выбрасывает лишнее.
Circuit breaker - не долби мёртвого
Конечный автомат из трёх состояний вокруг вызова к внешней зависимости.
- Closed: запросы идут, считаем долю ошибок и медленных ответов в скользящем окне.
- В состоянии Open порог уже превышен, и все вызовы мгновенно падают с ошибкой, не трогая зависимость. Главный выигрыш тут в том, что клиент получает быстрый отказ вместо таймаута в 3 секунды, освобождаются горутины и соединения, а упавший сервис получает шанс встать.
- Half-open пропускает несколько пробных запросов, когда пауза вышла. Успех возвращает в closed, неуспех отправляет обратно в open с увеличенной паузой.
Тонкости: считать надо долю ошибок при достаточном объёме (минимум N запросов в окне, иначе один неудачный запрос при низком трафике откроет breaker), считать таймауты как ошибки, а бизнес-ошибки (404, 422) не считать, и обязательно иметь осмысленный fallback: закэшированное значение, значение по умолчанию, деградированный ответ. Breaker без fallback просто меняет медленный отказ на быстрый - это полезно, но только половина ценности.
Bulkhead - переборка
Название из судостроения: корпус делят на отсеки, чтобы пробоина не затопила весь корабль. В сервисе это отдельные пулы ресурсов на каждую зависимость или класс трафика: свой пул соединений, свой семафор на число одновременных вызовов, отдельный пул воркеров. Без переборок один медленный внешний API съедает все горутины и соединения, и падает весь сервис, включая эндпоинты, которые к этому API вообще не обращаются.
// Простейший bulkhead в Go: буферизованный канал как семафор на зависимость.
type Bulkhead struct{ sem chan struct{} }
func NewBulkhead(n int) *Bulkhead { return &Bulkhead{sem: make(chan struct{}, n)} }
func (b *Bulkhead) Do(ctx context.Context, f func() error) error {
select {
case b.sem <- struct{}{}: // взяли слот
defer func() { <-b.sem }()
return f()
case <-ctx.Done(): // слотов нет, бесконечно не ждём
return ErrBulkheadFull // быстрый отказ, а не медленная очередь
}
}
Backpressure - обратное давление
Сигнал «мне плохо, притормози», который идёт вверх по потоку к источнику нагрузки.
Это не отказ, а замедление: TCP-окно, небуферизованный канал в Go, ограниченная очередь,
ответ 429 с Retry-After, остановка чтения из брокера, пока не разобрана
текущая пачка. Очередь без границы не буфер, а отложенный
отказ: неограниченный канал или неограниченная очередь превращают перегрузку
в рост латентности и OOM вместо честного «не могу».
Load shedding - сбрасывание нагрузки
Когда притормозить источник невозможно (это внешние пользователи), остаётся отказывать части запросов, чтобы обслужить остальные. Отличие от rate limiting: rate limit задаёт политику («этому клиенту не больше 100 rps»), а shedding реагирует на состояние («у меня очередь длиннее 200, отвечаю 503»). Правильно делать это по приоритету: сначала отбрасывать фоновые задачи и повторные запросы, потом аналитику, в последнюю очередь платежи. И отбрасывать как можно раньше и дешевле: проверка на входе, до похода в БД.
Если запрос пролежал в очереди дольше, чем клиентский таймаут, обрабатывать его
бессмысленно - ответ уже никому не нужен, а ресурсы уйдут впустую. Проверка
«дедлайн истёк, сразу выбрасываем» на входе в обработчик (ctx.Err() != nil)
даёт системе шанс выбраться из перегрузки самостоятельно, вместо того чтобы
бесконечно работать над устаревшими запросами. В SRE это один
из описанных способов выйти из метастабильного отказа.
| Приём | От чего защищает | Когда применять |
|---|---|---|
| Circuit breaker | от чужой деградации, от собственных зависаний на таймаутах | любой внешний вызов с ненулевой вероятностью отказа |
| Bulkhead | от того, что одна зависимость съест все ресурсы | несколько зависимостей или классов трафика в одном процессе |
| Backpressure | от неограниченного роста очередей и OOM | есть управляемый источник: брокер, пайплайн, свои клиенты |
| Load shedding | от полного коллапса при перегрузке | источник неуправляем: публичное API, пик трафика |
Консистентное хеширование
«Консистентное» здесь не имеет отношения к консистентности данных из CAP. Английское consistent hashing в этом контексте значит «устойчивое»: такое распределение ключей по узлам, которое почти не меняется, когда узлов становится больше или меньше. Речь всё время идёт про шардирование: ключи раскладывают по нескольким узлам так, чтобы любой участник системы, зная только ключ, вычислил один и тот же адрес.
Какую проблему решает
Ключи по N узлам обычно размазывают так: node = hash(key) % N.
Работает ровно до момента, когда N меняется. Пусть в кэше 100 узлов и 100 млн ключей.
Добавили один узел: % 100 превратилось в % 101, и почти
каждый ключ теперь отображается на другой узел - переезжает около
99 % ключей (точнее, доля N/(N+1) при добавлении). Для кэша это
означает почти полный промах на всём кластере одновременно: кэш пустой, весь трафик
ушёл в базу, база легла. Не «деградация», а инцидент - вызванный не аварией,
а плановым добавлением мощности.
Консистентное хеширование делает так, что при изменении числа узлов переезжает только
1/N ключей. Те же 100 узлов, добавили 101-й: переехало примерно 1 %
ключей, остальные 99 % остались на своих местах и продолжают попадать в кэш.
Кольцо
Пространство хешей (скажем, 0…232−1) замыкается в кольцо. На кольцо отображаются и узлы (по хешу их имени или адреса), и ключи (по хешу ключа). Правило владения одно: ключ принадлежит первому узлу, встреченному при движении по кольцу по часовой стрелке. Ищут бинарным поиском по отсортированному массиву позиций узлов, O(log N).
Виртуальные узлы: зачем они обязательны
У наивного кольца с N физическими точками две болезни. Первая в неравномерности: случайные позиции трёх узлов почти никогда не делят кольцо на три равные дуги, и разброс нагрузки в 2–3 раза считается нормой. Вторая: при удалении узла вся его нагрузка падает на одного соседа, который тут же ложится следом (каскад).
Решение: каждый физический узел кладут на кольцо V раз под разными метками
(hash("nodeA#0"), hash("nodeA#1"), …). При V = 100–200 самый нагруженный узел обгоняет среднее уже не в разы, а на 10–25 %; до единиц процентов разброс падает ближе к V = 1000. При удалении узла его дуги расходятся
между всеми остальными, а не сваливаются на одного. Платишь памятью под V×N точек
(для 100 узлов по 150 реплик это 15 000 записей, копейки) и чуть более долгим поиском.
Бонус: V можно делать пропорциональным мощности узла - тогда получается взвешенное
распределение для разнородного железа.
type Ring struct {
points []uint32 // отсортированные позиции виртуальных узлов
owner map[uint32]string // позиция -> имя физического узла
vnodes int
}
// crc32 не годится: он линеен, и метки, отличающиеся одним байтом, дают позиции
// со сдвигом на одну и ту же константу — узлы ложатся параллельно, и разброс
// не падает даже при V = 1000. Обрезанный sha256 раскидывает ровно.
func hash(s string) uint32 {
sum := sha256.Sum256([]byte(s))
return binary.BigEndian.Uint32(sum[:4])
}
func (r *Ring) Add(node string) {
for i := 0; i < r.vnodes; i++ {
p := hash(node + "#" + strconv.Itoa(i))
r.points = append(r.points, p)
r.owner[p] = node
}
sort.Slice(r.points, func(i, j int) bool { return r.points[i] < r.points[j] })
}
func (r *Ring) Get(key string) string {
h := hash(key)
// первый узел по часовой стрелке; ушли за конец, замыкаем на начало
i := sort.Search(len(r.points), func(i int) bool { return r.points[i] >= h })
if i == len(r.points) {
i = 0
}
return r.owner[r.points[i]]
}
Memcached-клиенты (ketama), Cassandra и DynamoDB (партиционирование + репликация
на следующие R узлов по кольцу), шардирование Redis Cluster (там 16 384 слота,
по сути фиксированные виртуальные узлы), балансировка sticky-сессий, шардирование
очередей. Полезно знать, что существуют альтернативы: rendezvous hashing
(HRW): берём узел с максимальным hash(key, node), получаем то же свойство
минимального переноса без кольца и без виртуальных узлов, но поиск O(N).
И jump consistent hash с O(1) памяти и идеальной равномерностью, но узлы можно
только добавлять и убирать с конца, что не подходит для произвольного набора адресов.
Распределённые блокировки и fencing token
Задача: гарантировать, что критическую секцию (обработку файла, запуск джобы, изменение ресурса) в кластере выполняет ровно один процесс. Наивный вариант на Redis:
SET lock:job42 <random-uuid> NX PX 30000 # взять, только если свободен, TTL 30 c
# ... работа ...
# снять только свою блокировку: сравнить значение и удалить атомарно (Lua):
# if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) end
Уже здесь две обязательные детали: TTL (иначе упавший держатель заблокирует ресурс навсегда) и уникальное значение с проверкой при снятии (иначе процесс, чей TTL истёк, снимет чужую блокировку). Но главная проблема остаётся.
Фундаментальная проблема: TTL не останавливает процесс
Сценарий, который надо уметь рассказать: процесс P1 взял блокировку с TTL 30 секунд, начал работу и застрял - stop-the-world пауза GC, своп, вытеснение планировщиком, сетевая задержка. Прошло 40 секунд. Блокировка истекла, P2 её взял и начал работу. Тут P1 просыпается: с его точки зрения ничего не произошло, он уверен, что владеет блокировкой, и делает запись в хранилище. Два процесса одновременно в критической секции, и никакой TTL, никакой алгоритм согласования этого не предотвратит - потому что проблема не в блокировке, а в том, что нельзя остановить процесс, который считает себя владельцем.
Fencing token лечит эту болезнь не со стороны блокировки, а со стороны ресурса. Аналогия простая: талон электронной очереди. На табло горит номер, и окно обслуживает только того, чей талон не меньше текущего. Пришёл с талоном 33, когда на табло уже 34, и тебя развернут: неважно, насколько ты был уверен, что твоя очередь ещё не прошла.
Механика: сервис блокировок выдаёт вместе с блокировкой монотонно возрастающий номер (token). Клиент передаёт этот номер в защищаемый ресурс при каждой операции, а ресурс запоминает максимальный виденный номер и отвергает всё, что меньше. P1 получил токен 33, P2 получил 34. P2 успел записать с 34; когда проснувшийся P1 приходит с 33, хранилище отвечает отказом. Проверяют обычно ровно это: корректность обеспечивает не блокировка, а ресурс. Если ресурс не умеет проверять токен (обычный S3-бакет, внешнее API), полной защиты нет в принципе - и это надо честно сказать.
// Токен ложится в условие записи: ресурс сам отсекает устаревшего владельца.
// UPDATE resource SET data = $1, fence = $2 WHERE id = $3 AND fence < $2
res, err := db.ExecContext(ctx,
`UPDATE jobs SET result = $1, fence = $2 WHERE id = $3 AND fence < $2`,
data, token, jobID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return ErrStaleLockHolder // нас уже вытеснили, тихо выходим без паники
}
Redlock и почему вокруг него спор
Redlock работает на N независимых Redis-мастерах: берём блокировку на большинстве за время, много меньшее TTL. Критика Мартина Клеппманна: алгоритм опирается на ограниченность задержек и на синхронность часов, а при паузе GC или скачке часов ломается ровно так же, как одиночный Redis - то есть даёт иллюзию строгости без фактической гарантии. Ответ на собесе, который закрывает вопрос: «Для эффективности (не запускать одну и ту же работу дважды без большой беды) хватит блокировки на одном Redis с TTL и уникальным значением. Для корректности (двойной запуск недопустим) нужен либо fencing token, либо перенос гарантии в сам ресурс - уникальный индекс, CAS по версии, условная запись».
В большинстве реальных задач блокировка не нужна вовсе. Уникальное ограничение в БД
(«эту джобу уже взяли») делает работу надёжнее и дешевле. SELECT ... FOR UPDATE
SKIP LOCKED раздаёт задачи воркерам без внешнего сервиса. Партиционирование
по ключу (все операции по счёту X идут в один воркер через ключ партиции Kafka)
убирает конкуренцию физически, а не логически. Оптимистичная блокировка по версии
решает конфликт в момент записи. Хороший ответ звучит так: «сначала я попробую сделать
так, чтобы блокировка не понадобилась; распределённый лок оставлю на крайний случай,
потому что он даёт меньше гарантий, чем от него ждут».
Лидер, кворум, консенсус
Зачем вообще лидер
Лидер сводит распределённую задачу к локальной: если все записи проходят через один узел, порядок операций определён тривиально, конфликтов нет, а остальные узлы просто повторяют журнал. Лидер нужен далеко не одним БД: единственный запускающий cron, единственный владелец шарда, единственный компактор, единственный ребалансировщик. Цена известна: нового лидера при отказе надо надёжно выбрать и гарантировать, что он ровно один.
Кворум и почему N/2+1
Кворумом называют минимальное число узлов, которое должно согласиться, чтобы решение
считалось принятым. Большинство (N/2+1) выбрано не из эстетики: любые два
большинства из одного и того же множества обязательно пересекаются минимум в одном узле.
Из этого следует главное свойство: два разных решения (два лидера, две записи одного
индекса лога) невозможны, потому что узел из пересечения не проголосует дважды в одном
терме. Если бы кворумом была половина, при разрыве 2/2 обе половины набрали бы кворум
и выбрали бы разных лидеров - классический split brain.
| N узлов | Кворум | Переживает отказов | Комментарий |
|---|---|---|---|
| 1 | 1 | 0 | не отказоустойчиво |
| 2 | 2 | 0 | хуже одного: отказ любого ломает кворум |
| 3 | 2 | 1 | рабочий минимум |
| 4 | 3 | 1 | толку как от 3, а латентность выше |
| 5 | 3 | 2 | стандарт для важных кластеров |
| 7 | 4 | 3 | редко: каждый лишний узел замедляет запись |
Отсюда правило «нечётное число узлов»: чётность не добавляет живучести, но добавляет латентности и стоимости. И отсюда же понятно, почему кластер из 3 узлов, потерявший 2, перестаёт обслуживать запись, хотя один узел жив: он не может доказать, что он не в меньшинстве, а значит, не имеет права принимать решения. Это буквально CP-выбор в действии.
В системах с настраиваемым кворумом (Cassandra, Dynamo-подобные) то же самое выражается
через W + R > N: если число узлов для подтверждения записи плюс число
узлов для чтения больше общего числа реплик, множества гарантированно пересекаются
и чтение увидит последнюю запись. Типовой выбор N=3, W=2, R=2;
а W=1, R=1 даёт максимальную скорость и никаких гарантий.
Консенсус: договориться об одном значении и больше его не менять
В задаче о консенсусе группа узлов должна выбрать одно значение (кто теперь лидер; какая запись идёт под номером 42) так, чтобы выполнились три условия: выбрано ровно одно значение, выбранное больше никогда не меняется, и все живые узлы в итоге узнают одно и то же. Это умеют Paxos и Raft; на Raft стоят etcd, Consul и Kafka в режиме KRaft (Raft выбирает там контроллер, а лидеров партиций назначает уже он). ZooKeeper держится на своём протоколе ZAB, родственнике Paxos.
Разницу с кворумом полезно уметь проговорить: кворум задаёт правило подсчёта («больше половины»), а консенсус описывает протокол, который поверх этого правила решает, что делать с отказами, повторными голосованиями и вернувшимися после разрыва «зомби»-узлами. Кворум считает, консенсус договаривается.
Raft: термы, выборы, репликация лога
Raft придумали как «понятную альтернативу Paxos», и держится он на трёх идеях: сильный лидер, разделение времени на термы и репликация журнала.
- Терм задаёт номер эпохи, монотонно растущее целое. В каждом терме не более одного лидера. Он же служит логическими часами: узел, увидевший больший терм, немедленно становится follower. Так система сама вытесняет «зомби-лидера», вернувшегося после разрыва.
- Три роли: follower (пассивно принимает записи и heartbeat), candidate (пытается стать лидером), leader (единственный, кто принимает клиентские записи).
- Выборы. У каждого follower идёт случайный таймаут выборов (в статье авторов Raft для примера 150–300 мс, в etcd по умолчанию от 1 до 2 с).
Heartbeat не пришёл - follower увеличивает терм, становится candidate, голосует
за себя и рассылает
RequestVote. Узел отдаёт голос, если ещё не голосовал в этом терме и лог кандидата не отстаёт от его собственного (это условие гарантирует, что лидером не станет узел, потерявший закоммиченные записи). Набрал большинство - стал лидером и сразу начинает слать heartbeat. - Split vote случается, когда несколько кандидатов стартовали одновременно: голоса делятся, и никто не набирает большинство. Терм проходит впустую, таймауты истекают, начинается новый терм. Именно поэтому таймаут случайный: рандомизация делает одновременный старт маловероятным, и обычно достаточно одного лишнего раунда.
- Репликация лога. Клиентская запись попадает в лог лидера, лидер рассылает
AppendEntries. Реплицировав запись своего терма на большинство, лидер считает её committed, применяет к машине состояний и сообщает индекс коммита followers. Записи прошлых термов так коммитить нельзя, даже если они лежат на большинстве: их новый лидер ещё может затереть, и они становятся закоммиченными заодно с первой записью текущего терма. Клиенту, соответственно, нельзя отвечать «ок» до коммита. - Свойство безопасности: если запись закоммичена в терме T, она присутствует в логах всех будущих лидеров. Это обеспечивают правило голосования «не голосуй за отстающий лог» и пересечение кворумов.
term 3 в первом же ответе, поймёт,
что отстал, и станет follower, не успев ничего испортить.
Свой Raft писать не надо - надо уметь пользоваться чужим. Практический рецепт
выбора лидера для «одного cron на кластер»: аренда в etcd/Consul/Kubernetes
(coordination.k8s.io/Lease, тот же механизм, что у контроллеров k8s)
или строка в Postgres с SELECT ... FOR UPDATE и TTL. Обязательные детали:
аренда продлевается в фоне, при неудачном продлении процесс сам прекращает
работу лидера (иначе будет два), и вся защищаемая работа всё равно должна быть
идемпотентной, потому что перекрытие лидеров возможно по причинам из раздела
про блокировки.
Дедупликация и «exactly-once end-to-end»
- At-most-once. Отправили и забыли: потери возможны, дублей нет. Годится для метрик и телеметрии, где потеря одного значения безразлична.
- At-least-once. Повторяем до подтверждения: потерь нет, дубли гарантированы. Так и работает любая надёжная система доставки.
- Exactly-once, ровно один раз. Как свойство доставки по сети недостижимо: потерю запроса нельзя отличить от потери ответа (в теории это задача о двух генералах).
«Exactly-once доставки не существует. Существует exactly-once обработка (effectively-once): at-least-once доставка плюс дедупликация или идемпотентность на стороне получателя. Kafka-транзакции дают exactly-once внутри Kafka для схемы consume–process–produce, но как только эффект уходит наружу (в твою БД, в письмо, во внешнее API), гарантия заканчивается и восстанавливать её приходится самому.»
Как делают дедупликацию на практике
- Ключ дедупликации в сообщении. Продюсер кладёт
event_id(детерминированный, а неuuid.New()на каждой попытке отправки), потребитель хранит виденные идентификаторы. Под хранилище идёт таблица inbox (транзакционно с эффектом) или Redis-множество с TTL, если допустимо «почти точно». - Окно дедупликации конечно. Хранить все идентификаторы вечно нельзя. Окно выбирают (час, сутки, неделя) по максимально возможной задержке повтора; за пределами окна дубль пройдёт. Компромисс придётся назвать вслух: «у нас дедупликация на 24 часа, сообщение старше этого срока считается новым».
- Экономия памяти. Для больших потоков ставят Bloom-фильтр предфильтром: «точно не видел» он отвечает бесплатно, «возможно видел» отправляет в БД. Ложноположительные срабатывания не страшны, за ними идёт точная проверка.
- Лучше не дедуплицировать, а быть идемпотентным.
UPSERTпо естественному ключу, переход состояния с условием, запись в S3 по детерминированному имени. Тогда дубли безвредны by design и никакого хранилища идентификаторов не нужно.
Отдельно про то, в каком порядке потребитель брокера коммитит смещение: commit offset после обработки даёт at-least-once (упали между обработкой и коммитом - будет повтор), commit до обработки даёт at-most-once (упали после коммита - сообщение потеряно). Почти всегда выбирают первое плюс идемпотентную обработку.
Часы и порядок событий
Физические часы врут - и это архитектурная проблема
- Дрейф. Кварцевый генератор уходит на 10–100 ppm, то есть на секунды в сутки. NTP подтягивает часы обратно, и время в этот момент может прыгнуть назад. Два события, случившиеся друг за другом, получат метки в обратном порядке.
- Точность синхронизации в дата-центре измеряется единицами миллисекунд, между дата-центрами десятками. Это больше, чем длительность многих операций, поэтому сравнивать метки с разных машин с точностью до миллисекунды нельзя.
- Практические правила Go: чтобы померить длительность, берут
монотонные часы (в Go
time.Now()хранит монотонную составляющую, и разность двухtime.Timeкорректна даже при переводе часов), а метки времени в данных читают как «примерно» и корректность на них не строят. - Опасный антипаттерн: last-write-wins по wall clock. Часы одного узла спешат на 200 мс - и его записи всегда «выигрывают», а данные, записанные позже, молча теряются. Так теряют данные в Cassandra при кривой синхронизации времени.
Google Spanner использует TrueTime: атомные часы и GPS дают не точку, а интервал
[earliest, latest] с гарантированной границей ошибки (единицы миллисекунд).
Чтобы транзакции были линеаризуемыми, коммит ждёт, пока этот интервал пройдёт
(«commit wait»). Система буквально платит латентностью за то, чтобы порядок по времени
был настоящим. Отсюда общее правило: сильные гарантии времени покупаются либо
специальным железом, либо ожиданием.
Happened-before и логические часы
Лэмпорт заметил: чтобы упорядочивать события, физическое время не нужно, хватает отношения
«произошло раньше» (→), которое задают три правила:
(1) в пределах одного процесса события упорядочены; (2) отправка сообщения предшествует
его получению; (3) отношение транзитивно. События, не связанные этим отношением,
называются конкурентными. Про них нельзя сказать, какое было раньше,
и это не незнание, а объективное свойство.
Оба вида логических часов удобно представить как бумажную переписку. Часы Лэмпорта нумеруют письма подряд: по номерам видно, что письмо №7 написано после №5, но если два человека писали одновременно, номера всё равно выстроятся в один ряд и создадут ложное впечатление очерёдности. Векторные часы добавляют приписку «твои письма прочитал по 4-е включительно, своих написал 6»: по ней сразу видно, знал ли автор о твоём последнем письме, когда писал своё. Если он не знал о твоём, а ты о его, письма разошлись в пути; события конкурентны, и это видно явно, а не угадывается по времени на штемпеле.
- Часы Лэмпорта. У каждого процесса счётчик. Перед локальным событием
C++. При отправке сообщения к нему прикладываетсяC. При полученииC = max(C, C_msg) + 1. Свойство: еслиa → b, тоC(a) < C(b). Обратное неверно: изC(a) < C(b)не следует, чтоaбыло раньше, события вполне могут оказаться конкурентными. То есть часы Лэмпорта дают полный порядок (ничьи разрешают по id процесса), но обнаружить конкурентность не дают. - Векторные часы. Вектор из N счётчиков, по одному на процесс; процесс
инкрементирует свою позицию, при получении берёт поэлементный максимум.
Теперь
a → bтогда и только тогда, когда векторaпокомпонентно меньше или равен векторуbи где-то строго меньше; если ни один не меньше другого, события конкурентны, и видно это сразу. Цена: размер метаданных растёт с числом узлов, а узлы бывают эфемерными. Именно так Dynamo и Riak находили конфликтующие версии и отдавали их разрешать приложению (siblings).
| Часы | Что дают | Чего не дают | Где встречается |
|---|---|---|---|
| Физические (wall clock) | человекочитаемые метки, сравнение с внешним миром | корректный порядок между узлами | логи, аудит, TTL |
| Монотонные | корректные длительности внутри процесса | смысл между процессами | таймауты, метрики латентности |
| Лэмпорта | полный порядок, согласованный с причинностью | обнаружение конкурентности | термы Raft, версии в БД |
| Векторные | обнаружение конкурентных версий | компактность при большом N | Dynamo, Riak, CRDT |
| Гибридные (HLC) | близость к реальному времени + причинность | магии - ошибка часов всё равно учитывается | CockroachDB, YugabyteDB |
Вопрос-детектор: «у тебя два сервиса пишут в общий лог с метками времени; можешь ли
ты по этим меткам восстановить порядок событий?» Правильный ответ: нет, не можешь,
если разница меньше погрешности синхронизации. Чтобы восстановить порядок, нужен
trace_id с причинными связями (span parent), логическая версия или
последовательный номер от общего источника. Второй вопрос-детектор: «как ты
разрешаешь конфликт двух одновременных записей?» Ответ «по времени» плохой;
«по версии/вектору», «CRDT», «отдать конфликт домену на разрешение» хорошие.
Вопросы
10Скелет ответа за минуту
- Дать точные определения: C значит линеаризуемость (система выглядит как одна копия); A требует, чтобы каждый живой узел отвечал непустым корректным ответом за конечное время; P описывает работу при потере сообщений между группами узлов.
- Сказать, что теорема условная. Пока сеть цела, есть и C, и A. Выбор возникает ровно в момент разрыва.
- Разобрать сценарий на двух узлах: клиент записал
x=2в A, сеть порвалась, другой клиент читает у B. У B два варианта: ответить ошибкой (CP) или ответитьx=1(AP). Третьего нет. - Привести примеры: etcd/ZooKeeper/Consul/Spanner работают как CP (меньшинство отключает себя), Cassandra/DynamoDB/Riak как AP (отвечают всегда, конфликты чинят потом).
- Добавить, что это настройка, а не природа: в Cassandra
QUORUMпротивONEбуквально переключает CP/AP на уровне отдельного запроса.
Почему CA - некорректная категория
Чтобы отказаться от P, надо утверждать, что сетевого разделения не бывает. Но разрыв не сводится к перерубленному кабелю: 30-секундная пауза GC, перегруженный линк, кривая сетевая политика, перезагрузка свитча выглядят снаружи идентично разрыву. Ты не выбираешь, случится ли partition. Выбираешь только реакцию. Формально под CA подходит одиночный узел (сети между узлами нет, разделения не бывает), но у него нет и отказоустойчивости: он просто недоступен, когда падает. Как только появляется вторая реплика, сразу встаёт вопрос «коммитить, если реплика не ответила?» - и это уже C против A.
PACELC
If P then A or C, else L or C. Вторая половина описывает 99.99 % времени: ждать, пока подтвердит кворум (медленнее, консистентно), или ответить с локальной реплики (быстро, возможно устарело). Синхронная кросс-ДЦ репликация стоит десятки миллисекунд на каждый коммит всегда, а не в одной лишь аварии. Классификация: Cassandra/DynamoDB идут как PA/EL, etcd/ZooKeeper/Spanner/MongoDB(majority) как PC/EC, Postgres с асинхронной репликой как PA/EL по пути чтения с реплики.
Хороший ход: сказать самому, что CAP переоценён как инструмент проектирования.
Он говорит о крайностях (полная линеаризуемость против полной доступности)
и молчит про промежуточные модели, а на практике нужны именно они: причинная
согласованность, read-your-writes, кворумы W+R>N.
И ещё, что выбор делается не для системы целиком, а для конкретной операции:
деньги списывают по CP, ленту показывают по AP, и внутри одного продукта это нормально.
За «выбираем два из трёх» без оговорки про условность сразу минус. Фраза
«MongoDB это CP, а MySQL это CA» вешает ярлык на СУБД целиком, тогда как поведение
зависит от настроек (writeConcern, синхронная или асинхронная реплика,
readPreference). И третье: путать C из CAP с C из ACID. Вещи разные:
первое про линеаризуемость, второе про то, что инварианты схемы соблюдены.
Крайности
Strict (линеаризуемость): система ведёт себя как одна копия, любое чтение видит последнюю завершённую запись по реальному времени. Добиваются этого лидером плюс синхронной репликацией на кворум или консенсусом. Цена: латентность каждой операции и отказ меньшинства при разделении.
Eventual: если записи прекратятся, реплики когда-нибудь сойдутся. Никакой границы по времени, никаких гарантий на порядок. Дёшево, масштабируемо и очень неприятно для пользователя, если не добавить клиентских гарантий сверху.
Три аномалии, у которых есть имена
| Что видит пользователь | Название | Как чинят |
|---|---|---|
| Сменил аватар, обновил страницу - старый аватар | нет read-your-writes | читать с лидера N секунд после записи этого пользователя; липкая сессия; чтение по версии/LSN своей записи |
| Видел комментарий, обновил - исчез | нет monotonic reads | закрепить сессию за конкретной репликой (хеш от user_id, а не round-robin); передавать наблюдённую версию и не читать реплику старее |
| Ответ на комментарий появился раньше комментария | нет causal consistency | передавать причинные зависимости (версии прочитанного) и не применять операцию раньше её причин; в брокере - один ключ партиционирования на причинно связанные события |
Практический рецепт «read-your-writes» для веб-сервиса
// После записи кладём в сессию/куку момент (или LSN) и N секунд читаем с мастера.
const stickyWindow = 3 * time.Second
func (r *Repo) pickConn(ctx context.Context) *sql.DB {
if t, ok := LastWriteAt(ctx); ok && time.Since(t) < stickyWindow {
return r.primary // свои свежие данные только с мастера
}
return r.replica // всё остальное с реплики
}
Точнее работает вариант не по времени, а по позиции: после записи запомнить LSN
(pg_current_wal_lsn()), при чтении выбрать реплику, у которой
pg_last_wal_replay_lsn() >= запомненного, иначе идти на мастер.
В других системах это называют «токен консистентности» или
ReadYourWrites-сессия.
Во-первых, причинная согласованность остаётся сильнейшей моделью, совместимой с доступностью при разделении (доказано): всё, что сильнее, требует отказа от A. Во-вторых, линеаризуемость относится к одному объекту; атомарность нескольких объектов даёт уже транзакция, свойство отдельное и более дорогое. В-третьих, у eventual consistency обязана быть метрика: лаг репликации с алертом, иначе «когда-нибудь» превращается в «через сорок минут» и никто не узнает.
f(f(x)) = f(x). Это про состояние, а не про ответ.
Нужна потому, что в сети «запрос не дошёл» неотличимо от «ответ не вернулся»,
и единственная реакция на неизвестность - повтор.Примеры
- Идемпотентно:
SET balance = 100;PUT /users/42;DELETE(второй раз просто нечего удалять);INSERT ... ON CONFLICT DO NOTHING;UPDATE ... SET status='paid' WHERE id=$1; загрузка объекта в S3 по фиксированному ключу; RedisSET,SADD. - Неидемпотентно:
balance = balance + 100;POST /ordersбез ключа;INSERTбез уникального ограничения; RedisINCR,LPUSH; отправка письма, SMS, push; печать чека; вызов внешнего платёжного API без idempotency key.
Уточнение, которое любят слышать: идемпотентность и безопасность (safety)
различаются. GET и безопасен, и идемпотентен;
DELETE идемпотентен, но не безопасен; POST ни то, ни другое.
И ещё: ответ может отличаться (204 в первый раз, 404 во второй),
а идемпотентность от этого не ломается.
Три способа сделать неидемпотентную идемпотентной
- Ключ идемпотентности. Клиент генерирует уникальный id намерения
и присылает в заголовке
Idempotency-Key, переиспользуя его при всех ретраях. Сервер хранитkey → (хеш запроса, состояние, сохранённый ответ)и на повтор возвращает сохранённый результат, не выполняя работу. Три детали: хеш тела (поймать «тот же ключ, другое тело» → 422), состояниеIN_PROGRESS(два параллельных ретрая не должны стартовать оба → 409), TTL ключей (сутки-неделя, не вечно). - Уникальное ограничение в БД. Естественный ключ операции
(
UNIQUE (order_id, op_type),message_id PRIMARY KEY) иON CONFLICT DO NOTHING. Самый надёжный вариант, потому что гарантию даёт хранилище, а не дисциплина кода. - Условный переход состояния (CAS). Не «списать», а «перевести из PENDING
в PAID»:
UPDATE ... WHERE id=$1 AND status='PENDING'. Ноль затронутых строк = работа уже сделана. То же самое с версией:WHERE id=$1 AND version=$2. Заодно решает конкурентные обновления.
«Сгенерируем UUID на сервере» ключом идемпотентности не работает: при ретрае клиент придёт с новым UUID, и будет два заказа. Ключ придумывает клиент и обязан его переиспользовать. Второй капкан ловит тех, кто хранит ключ в Redis, а эффект пишет в Postgres: между двумя хранилищами нет атомарности, и на падении между операциями получишь либо потерянный ключ, либо потерянный эффект. Ключ и эффект пишутся одной транзакцией. Третий капкан прячется на границе системы: письмо, отправленное внешним провайдером, твоей таблицей не отменяется.
Таймауты
- Ставятся всегда: вызов без таймаута превращается в утечку горутин, соединений и памяти, которой управляет чужая сторона.
- Значение выбирают из наблюдаемого p99, а не из головы. Таймаут 30 секунд при p99 = 80 мс не защищает ни от чего.
- Бюджет убывает. Клиенту обещано 2 с, первый хоп съел 700 мс - второму
остаётся 1.3 с. В Go это проброшенный
context, в gRPC дедлайн передаётся по сети автоматически. Без бюджета суммарный таймаут = сумма таймаутов, и клиент отваливается раньше, чем система отвечает. - Дедлайн должен реально доходить до запроса в БД и отменять работу, иначе процесс продолжает считать ответ, который уже никому не нужен.
Ретраи
| Ретраить | Не ретраить |
|---|---|
| таймаут, обрыв соединения, 502/503/504 | 400, 401, 403, 404, 422 - детерминированный отказ |
gRPC UNAVAILABLE, RESOURCE_EXHAUSTED (с паузой, которую просит сервер: в gRPC вместо Retry-After для этого есть grpc-retry-pushback-ms) | gRPC INVALID_ARGUMENT, PERMISSION_DENIED |
| идемпотентные операции | неидемпотентные без ключа идемпотентности |
Jitter: почему без него плохо
Тысяча клиентов получила ошибку в один и тот же момент. Без jitter все повторят через ровно 1 секунду - и синхронная волна ударит в сервис, который как раз пытается встать. Full jitter в замерах даёт наименьшую нагрузку, а по времени до успеха идёт вровень с decorrelated jitter, чуть уступая ему. «Экспонента плюс небольшой шум» проигрывает обоим и по работе, и по времени. Тот же приём нужен в TTL кэша и в расписании cron, иначе получишь thundering herd без всяких ретраев.
Retry storm
Сервис D деградировал. C делает 3 попытки к D, B — 3 попытки к C, A — 3 попытки к B, и до D доходит 27 запросов на один пользовательский. Нагрузка выросла на порядок ровно в момент, когда нужна была разгрузка, и система входит в метастабильный отказ: она не восстанавливается, даже когда исходную причину убрали, потому что причина теперь в самих ретраях.
- Ретраи на одном уровне. Обычно на самом внешнем или в service mesh; на промежуточных выключены. Ретраит mesh - приложение не ретраит.
- Retry budget: ретраи не более ~10 % от объёма основных запросов; превысили порог - ретраи временно запрещены.
- Circuit breaker поверх: перестать долбить того, кто явно лежит.
- Jitter обязательно.
- Отбрасывать просроченное: если запрос пролежал дольше клиентского дедлайна, не обрабатывать его вообще - мощность освободится для актуальных.
Спросят: «а как понять, что таймаут выставлен правильно?» Смотреть надо на долю
запросов, отваливающихся по таймауту, и на сравнение с p99.9: если таймаут
режет заметную долю успешных ответов, он мал; если ошибки по таймауту приходят
через 30 секунд - он велик, и ты просто дольше держишь ресурсы. И второй:
«а ретраить запросы в базу можно?» Можно на ошибках соединения и deadlock/serialization
failure (в Postgres на 40001/40P01 повторять транзакцию
прямо предписано), нельзя на нарушениях ограничений.
Circuit breaker
Автомат из трёх состояний вокруг внешнего вызова: closed (считаем долю ошибок в скользящем окне), open (мгновенный отказ без обращения к зависимости), half-open (несколько пробных запросов; успех закрывает автомат, отказ снова открывает с увеличенной паузой). Ценен он быстрым отказом вместо медленного: освобождаются горутины и соединения, а упавшая зависимость получает шанс встать.
Тонкости, которые отличают знающего: считать долю, а не абсолютное число, и только при минимальном объёме в окне (иначе на низком трафике один сбой откроет breaker); таймауты считать ошибками, а бизнес-ошибки (404, 422) не считать; иметь fallback (кэш, дефолт, деградированный ответ), иначе выигрыш только в скорости отказа; отдельный breaker на каждую зависимость, а не один на сервис.
Bulkhead
Переборки из судостроения. На каждую зависимость или класс трафика заводят
отдельный пул ресурсов: свой пул соединений, свой семафор на число одновременных
вызовов, свой набор воркеров. Без них один медленный внешний API съедает все горутины - и падает
весь сервис, включая ручки, которые к этому API не обращаются. В Go это чаще
всего буферизованный канал как семафор плюс select с
ctx.Done(), чтобы не ждать слот бесконечно.
Backpressure
Сигнал «притормози» вверх по потоку к источнику: TCP-окно, небуферизованный канал,
ограниченная очередь, 429 с Retry-After, пауза в чтении из брокера.
Очередь без границы работает не буфером, а отложенным отказом.
Неограниченный канал превращает перегрузку в рост латентности и OOM вместо
честного «не могу прямо сейчас».
Load shedding
Когда источник неуправляем (внешние пользователи), остаётся отбрасывать часть запросов, чтобы обслужить остальные. Отличие от rate limiting: rate limit задаёт политику по клиенту, shedding реагирует на своё состояние (длина очереди, использование CPU, возраст запросов в очереди). Отбрасывать правильно по приоритету (сначала фон и аналитика, платежи в последнюю очередь) и как можно раньше и дешевле, до похода в БД.
Один вопрос: кто виноват в перегрузке? Деградировала внешняя зависимость, значит breaker и таймауты. Одна часть системы мешает другим, значит bulkhead. Потребляешь из управляемого источника (брокер, пайплайн, свои клиенты), тут поможет backpressure. Источник неуправляем и топит тебя - остаётся load shedding. В реальном сервисе обычно нужны все четыре, и они не заменяют друг друга.
«Поставим circuit breaker» - а зависимость отвечает 200 OK за 5 секунд, breaker закрыт, сервис лежит. Медленные ответы должны считаться отказами, иначе breaker бесполезен ровно в самом частом сценарии. Во втором капкане один breaker стоит на все зависимости: отказ второстепенного сервиса отрубает вызовы к главному. Третий: увеличить очередь, чтобы «не терять запросы». Время ожидания уходит за клиентский таймаут, и ты работаешь над ответами, которых уже никто не ждёт.
hash(key) % N при изменении N переносит почти
все ключи - для кэша это мгновенный полный промах и падение базы. Кольцо переносит
только 1/N. Виртуальные узлы нужны, чтобы нагрузка была ровной и чтобы
при отказе узла его ключи не свалились целиком на одного соседа.Проблема с числами
100 узлов кэша, 100 млн ключей. Добавляем 101-й: % 100 стало
% 101, и на новое место переезжает доля N/(N+1) -
около 99 % ключей. Весь кластер одновременно промахивается, трафик уходит
в базу - база ложится. Инцидент устроила не авария, а плановое добавление мощности.
С консистентным хешированием переезжает 1/(N+1), примерно 1 %,
остальные 99 % ключей остаются на своих узлах.
Кольцо
Пространство хешей (0…232−1) замкнуто в кольцо. На него отображаются и узлы (по хешу имени), и ключи. Правило: ключ принадлежит первому узлу по часовой стрелке. Ищут бинарным поиском по отсортированному массиву позиций, O(log N).
- Добавление узла: он вклинивается между двумя существующими и забирает ключи только у одного соседа, той дуги, которая теперь заканчивается на нём.
- Удаление узла: вся его дуга целиком достаётся следующему по часовой стрелке. Именно здесь наивное кольцо опасно: сосед получает удвоенную нагрузку, падает следом - начинается каскад.
Виртуальные узлы
Каждый физический узел сажают на кольцо V раз под разными метками
(hash("A#0"), hash("A#1"), …). Что это даёт:
- Равномерность. Три случайные точки почти никогда не делят кольцо на три равные дуги, разброс в 2–3 раза тут обычное дело. При V = 100–200 самый нагруженный узел обгоняет среднее уже на 10–25 %, а не в разы; единицы процентов получаются ближе к V = 1000.
- Каскада не будет. При отказе узла его 150 дуг распределяются между всеми оставшимися, а не сваливаются на одного.
- Взвешенность. Узлу с вдвое большей памятью даём вдвое больше виртуальных точек - разнородное железо загружается пропорционально.
Цена: V×N записей в памяти (100 узлов × 150 = 15 000, копейки) и чуть более долгий поиск.
«А если распределение всё равно перекошено, горячий ключ?» Консистентное
хеширование распределяет ключи, а не нагрузку: один суперпопулярный
ключ живёт на одном узле, и никакие виртуальные узлы не помогут. Лечится репликацией
горячего ключа на несколько узлов с суффиксом (key#1..key#K), локальным
кэшем перед распределённым или отдельной обработкой известных горячих ключей.
«А альтернативы?» Rendezvous hashing (HRW): берём узел с максимальным
hash(key, node). Перенос такой же минимальный, кольца
и виртуальных узлов нет, но поиск O(N). Jump consistent hash: O(1) памяти, идеальная
равномерность, но узлы можно менять только «с конца». Redis Cluster идёт третьим
путём: 16 384 фиксированных слота, которые вручную переназначают между узлами.
Те же виртуальные узлы, только управляют ими явно.
Забыть, что при перебалансировке кэша данные не переезжают физически: ключ просто начинает искаться на новом узле - а там его нет. Даже 1 % переезда даёт 1 % промахов, и об этом надо предупредить. Для хранилища (не кэша) всё серьёзнее: там переезд означает реальное копирование данных, и его надо делать фоново и с ограничением скорости, иначе ребаланс сам станет инцидентом. И ещё: консистентное хеширование не даёт репликации, за отказоустойчивость отвечает отдельный механизм (например, «ключ живёт на следующих R узлах по кольцу»).
Базовая реализация и её обязательные детали
SET lock:job42 <uuid> NX PX 30000 # NX = только если свободен, PX = TTL
# снимаем только свою блокировку, атомарно через Lua:
# if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) end
- TTL обязателен, иначе упавший держатель заблокирует ресурс навсегда.
- Уникальное значение обязательно, иначе процесс, чей TTL уже истёк, снимет блокировку, взятую другим.
- Продление (watchdog). Если работа может занять дольше TTL, фоновая горутина продлевает аренду; при неудачном продлении процесс обязан сам прекратить работу.
Фундаментальная проблема
P1 взял блокировку с TTL 30 с и застрял на 40 с - stop-the-world GC, своп, вытеснение, сетевая задержка. Блокировка истекла, P2 её взял и работает. P1 просыпается: с его точки зрения ничего не произошло, он «владеет» блокировкой и пишет в хранилище. Два процесса в критической секции. Никакой алгоритм блокировки этого не предотвратит, потому что нельзя остановить процесс, который считает себя владельцем, и нельзя узнать снаружи, что он застрял, а не умер.
Fencing token
Сервис блокировок выдаёт вместе с блокировкой монотонно возрастающий номер. Клиент передаёт его в защищаемый ресурс при каждой операции; ресурс помнит максимальный виденный номер и отвергает всё меньшее. P1 получил 33, P2 получил 34; после записи P2 запись P1 с номером 33 отвергается.
// UPDATE ... WHERE fence < $token: ресурс сам отсекает устаревшего владельца
res, _ := db.Exec(ctx,
`UPDATE jobs SET result = $1, fence = $2 WHERE id = $3 AND fence < $2`,
data, token, jobID)
if n, _ := res.RowsAffected(); n == 0 {
return ErrStaleLockHolder
}
Отсюда вывод: если ресурс не умеет проверять токен (обычный S3-бакет, сторонний API, отправка письма), полной защиты не существует, и это надо честно признать, а не делать вид, что «Redlock всё решает».
Redlock берёт блокировку на большинстве из N независимых Redis-мастеров. Критика Клеппманна: алгоритм опирается на ограниченность задержек и корректность часов, поэтому при паузе GC или скачке времени ломается так же, как одиночный Redis, давая ложное ощущение строгости. Формулировка, закрывающая вопрос: «Для эффективности (не делать одну работу дважды без большой беды) хватит одного Redis с TTL и уникальным значением. Для корректности (двойной запуск недопустим) нужен fencing token или перенос гарантии в сам ресурс: уникальный индекс, CAS по версии».
В большинстве задач она не нужна: уникальное ограничение в БД («эту джобу уже взяли»),
SELECT ... FOR UPDATE SKIP LOCKED для раздачи задач воркерам,
партиционирование по ключу (все операции по счёту X идут в один воркер через ключ
партиции), оптимистичная блокировка по версии. Фраза «сначала я попробую
спроектировать так, чтобы лок не понадобился» работает сильно: видно,
что кандидат понимает, насколько мало гарантий даёт распределённая блокировка.
Почему N/2+1
Два любых подмножества размера N/2+1 из одного множества обязательно
имеют общий элемент. Этот общий узел не проголосует дважды в одном терме и не примет
две разные записи на один индекс лога, поэтому split brain невозможен. Если бы
кворумом была ровно половина, при разрыве 2/2 обе половины «набрали бы кворум»
и выбрали разных лидеров.
| N | Кворум | Переживает отказов |
|---|---|---|
| 3 | 2 | 1 |
| 4 | 3 | 1 - толку как от трёх, латентность выше |
| 5 | 3 | 2 |
Отсюда правило нечётного числа узлов. И отсюда понятно, почему кластер из трёх,
потерявший два, перестаёт принимать запись, хотя один узел жив: он не может
доказать, что он не в меньшинстве. Это и есть CP-выбор на практике. В Dynamo-подобных
системах ту же идею записывают как W + R > N.
Raft по частям
- Терм нумерует эпохи монотонно; в терме не более одного лидера. Узел, увидевший больший терм, немедленно становится follower - так система вытесняет «зомби-лидера», вернувшегося после разрыва, без всякой ручной работы.
- Роли: follower, candidate, leader. Клиентские записи принимает только лидер.
- Выборы: у follower истёк случайный таймаут без heartbeat (в статье про Raft для примера 150–300 мс, в etcd по умолчанию 1–2 с) →
term++, стал candidate, голосует за себя, рассылаетRequestVote. Узел отдаёт голос, если ещё не голосовал в этом терме и лог кандидата не отстаёт от его собственного. Второе условие гарантирует, что лидером не станет тот, кто потерял закоммиченные записи. - Split vote: несколько кандидатов стартовали одновременно, голоса разделились, большинства ни у кого нет. Терм проходит впустую, таймауты истекают, начинается следующий. Именно из-за этого таймаут случайный: рандомизация делает повторное совпадение маловероятным.
- Репликация лога: запись попадает в лог лидера →
AppendEntriesна followers → реплицирована на большинство → committed → применена к машине состояний → ответ клиенту. Отвечать клиенту до коммита нельзя: незакоммиченные записи при смене лидера могут быть отброшены. - Свойство безопасности: закоммиченная запись присутствует в логах всех будущих лидеров. Следует это из правила голосования плюс пересечения кворумов.
Что из этого нужно прикладному разработчику
Не писать свой Raft, а уметь пользоваться готовым. Лидера для «одного cron
на кластер» выбирают так: аренда в etcd/Consul, Lease в Kubernetes (тот же механизм,
что у контроллеров), или строка в Postgres с TTL и FOR UPDATE.
Обязательные детали: аренда продлевается в фоне; при неудачном продлении процесс
сам складывает полномочия; вся защищаемая работа всё равно идемпотентна, потому что
кратковременное перекрытие двух лидеров возможно по тем же причинам, что и нарушение
распределённой блокировки.
«Возьмём кластер из двух узлов для надёжности» - два узла хуже одного: кворум 2, отказ любого останавливает всё. «Лидер выбран, значит всё консистентно»: нет, нужен ещё запрет отвечать на чтение с устаревшего лидера (в Raft это lease read или подтверждение лидерства кворумом, иначе старый лидер отдаст устаревшие данные). И третье: путать кворум записи с кворумом выборов. Это разные кворумы одного и того же множества, и безопасность даёт именно их пересечение.
Три семантики
- At-most-once: отправил и забыл, потери есть, дублей нет. Метрики, телеметрия.
- At-least-once: повторяем до подтверждения, потерь нет, дубли гарантированы. Это и дают на практике надёжные системы.
- Exactly-once как свойство сетевой доставки недостижимо (задача о двух генералах). Достижима effectively-once обработка.
Как делают на практике
- Идемпотентный эффект бьёт всё остальное.
UPSERTпо естественному ключу, переход состояния с условием, запись в S3 по детерминированному имени. Дубли безвредны by design, хранить идентификаторы не нужно вообще. - Таблица inbox / processed.
message_id PRIMARY KEY,INSERT ... ON CONFLICT DO NOTHINGв одной транзакции с эффектом. Если отметку писать в Redis, а эффект в Postgres - вернулся dual write, и гарантия исчезла. - Окно дедупликации. Хранить идентификаторы вечно нельзя: окно выбирают (сутки, неделя) по максимально возможной задержке повтора; за его пределами дубль пройдёт. Это ограничение надо называть вслух.
- Bloom-фильтр работает дешёвым предфильтром для больших потоков: «точно не видел» он отвечает мгновенно, «возможно видел» отправляет на точную проверку в БД. Ложноположительные срабатывания безопасны, ложноотрицательных не бывает.
Детали, которые проверяют
event_idдолжен быть детерминированным. Если продюсер при каждой попытке отправки генерирует новыйuuid.New(), дедупликация у потребителя не сработает никогда. Идентификатор рождается вместе с событием и не меняется при ретраях (в outbox это первичный ключ строки).- Порядок коммита смещения. Коммит после обработки = at-least-once (упали до коммита - будет повтор); коммит до обработки = at-most-once (упали после - будет потеря). Правильный выбор почти всегда первый + идемпотентная обработка.
- Kafka EOS (
enable.idempotence, транзакции,read_committed) закрывает схему consume–process–produce внутри Kafka: нет дублей при ретраях продюсера и атомарность «прочитал и записал». Но как только эффект уходит в твою БД, в письмо или во внешнее API, гарантия заканчивается и восстанавливается своими средствами.
«У нас exactly-once, мы включили транзакции в Kafka» - уточняющий вопрос «а письмо клиенту отправляется внутри транзакции Kafka?» ставит всё на места. Второй капкан ждёт тех, кто дедуплицирует в памяти процесса: при рестарте и при нескольких репликах это не работает. Третий: «мы дедуплицируем по содержимому». Два легитимных одинаковых события (два одинаковых платежа по 100 рублей) неотличимы от дубля, поэтому нужен явный идентификатор, а не хеш тела.
Почему физическому времени нельзя верить
- Дрейф. Кварц уходит на 10–100 ppm, то есть на секунды в сутки.
- Скачки при коррекции. NTP может перевести часы назад - и два последовательных события получат метки в обратном порядке. Отдельно стоит високосная секунда - она исторически роняла целые кластеры.
- Погрешность синхронизации держится в единицах миллисекунд внутри ДЦ и в десятках между ДЦ. Это больше длительности многих операций, поэтому разрешение «до миллисекунды» между узлами остаётся иллюзией.
- В Go: для длительностей использовать монотонную составляющую
(разность двух
time.Now()корректна даже при переводе часов), а метки в данных читать как «примерно» и корректность на них не строить.
Часы одного узла спешат на 200 мс, и его записи всегда «побеждают», а данные, записанные позже, молча пропадают. Это не гипотетика: так теряют данные в Cassandra при разъехавшемся NTP. Если конфликт разрешается по времени, корректность системы завязана на то, как администрируют время, а это плохое место для корректности.
Happened-before и логические часы
Отношение → задают три правила: события внутри процесса
упорядочены; отправка предшествует получению; отношение транзитивно. События,
не связанные им, конкурентны, и это объективное свойство, а не незнание.
- Часы Лэмпорта. Счётчик на процесс; перед событием
C++, при полученииC = max(C, C_msg) + 1. Свойство:a → bвлечётC(a) < C(b). Обратное неверно: по номерам нельзя понять, были ли события конкурентными. Даёт полный порядок (ничьи разрешаются по id узла), но не обнаруживает конфликты. - Векторные часы. Вектор из N счётчиков; при получении берут поэлементный максимум. Теперь конкурентность обнаруживается явно: если ни один вектор не меньше другого, это конфликт, и его можно отдать приложению (siblings в Dynamo/Riak) или разрешить структурой данных (CRDT). Платить приходится метаданными, они растут с числом узлов.
- Гибридные (HLC) комбинируют физическое время с логическим счётчиком: метки близки к реальному времени и при этом согласованы с причинностью. Используются в CockroachDB, YugabyteDB.
- В TrueTime у Spanner часы возвращают интервал с гарантированной границей ошибки, и коммит ждёт, пока интервал пройдёт. Настоящий порядок по времени покупается либо специальным железом, либо ожиданием.
Вопрос-детектор: «два сервиса пишут в общий лог с метками времени, восстановишь
порядок?» Правильный ответ: нет, если разница меньше погрешности синхронизации;
для порядка нужен trace_id со связями span→parent, логическая версия
или последовательный номер от общего источника (например, bigserial
одной БД или смещение в партиции Kafka). Второй: «как разрешаешь конфликт двух
одновременных записей?» Тут «по времени» плохо, а «по версии/векторным часам»,
«CRDT», «отдать конфликт в домен» хорошо. Третий, любимый в финтехе:
«клиент прислал операцию с меткой времени, верить?» Нет: время клиента говорит
не о порядке событий, а о том, что показывали часы клиента.
Что стоит запомнить
- Отсутствие ответа не позволяет отличить падение узла от задержки сети или потерянного ответа.
- Ретраи нужны только для повторяемой операции с budget/deadline; jitter уменьшает синхронный retry storm.
- Fencing token защищает от устаревшего владельца, когда downstream ресурс сравнивает токен.
Частые ошибки
- Не повторяй неидемпотентный side effect после timeout, не выяснив исход предыдущей попытки.
- Не трактуй CAP как постоянный выбор только между consistency и availability вне сетевого разделения.
- Не считай распределённый lock корректным, если операция продолжится после истечения lease без fencing.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай backoff функцию
Какой диапазон задержки даёт функция при attempt=0 и attempt=4? Почему случайное значение не может быть равно верхней границе?
func backoff(attempt int) time.Duration {
const base, max = 100 * time.Millisecond, 10 * time.Second
exp := base << attempt
if exp > max { exp = max }
return time.Duration(rand.Int63n(int64(exp)))
}
Ответ и разбор
При attempt=0 диапазон [0,100ms), при attempt=4 [0,1.6s), поскольку 100ms удваивается четыре раза. Int63n(n) выбирает целое от 0 включительно до n не включая верхнюю границу. Это full jitter; при очень большом attempt нужно ограничить сдвиг, чтобы не переполнить duration до сравнения с max.
Код-ревью
2. Защити запись fencing token
Код получил token=17, но UPDATE не проверяет ранее записанный fence. Как устаревший worker может затереть результат нового и как исправить SQL?
UPDATE jobs SET result = $1, fence = $2 WHERE id = $3;
Ответ и разбор
Worker с token 17 может завершиться позже нового владельца с token 18 и перезаписать его результат. Добавь условие AND fence < $2, увеличивай/выдавай токен монотонно при захвате, проверяй RowsAffected: ноль значит устаревший владелец. Сам downstream ресурс обязан применять это условие.
Применение
3. Выбери поведение при partition
Сервис профиля должен всегда отвечать при потере связи между двумя репликами, но для изменения email нельзя допустить две конфликтующие записи. Что выберешь при partition и почему?
Ответ и разбор
Для email write выбери отказ/ожидание без кворума, чтобы сохранить линейризуемую единственную запись; сервис может отвечать на чтение из локальной копии с пометкой/ограничением свежести, если это допустимо. CAP выбор относится к периоду partition и заданным гарантиям. Не обещай одновременно свежую запись и ответ на обеих разделённых сторонах.
Применение
4. Защити внешний вызов
Checkout вызывает провайдера оплаты, запрос истекает через 500 мс. Составь бюджет таймаутов, поведение ретрая и способ проверить исход, если ответ потерялся.
Ответ и разбор
Установи outbound deadline меньше оставшихся 500 мс с запасом на обработку ответа и запись результата. Ретрай допустим только с тем же idempotency key, ограниченным числом попыток и full jitter; если запрос мог выполниться, запроси статус по ключу/merchant reference вместо создания новой операции. На истечении дедлайна сохрани состояние pending/unknown и сверяй позже.