Суть Читай реальные сниппеты idempotency, retry и inbox, предсказывай их поведение под конкурентностью или сбоем и выбирай самый рычажный фикс.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги идемпотентности прячутся в зазоре между двумя statement, в отсутствующем вызове jitter и в retry-условии, которое срабатывает на неверном статусе. Читай каждый сниппет как на ревью — предскажи сбой под конкурентностью или нагрузкой, затем выбери фикс, который senior делает первым.
Цель
Потренируй цикл ревью для этого юнита: замечай окно гонки, retry, усиливающий herd, потерянный переход и dedup, который молча ничего не делает — затем назови одно изменение, закрывающее каждый.
Сниппет 1 — хендлер dedup
func handleCharge(ctx context.Context, db *sql.DB, key string, body []byte) (*Resp, error) { var existing Row err := db.QueryRowContext(ctx, `SELECT response_body, status FROM idempotency_keys WHERE key = $1`, key, ).Scan(&existing.Body, &existing.Status) if err == sql.ErrNoRows { // строки ещё нет -> трактуем как new db.ExecContext(ctx, `INSERT INTO idempotency_keys (key, fingerprint, status) VALUES ($1, $2, 'in_progress')`, key, fingerprint(body)) return process(ctx, db, key, body) // выполняет списание } return replay(existing), nil}
Викторина
Completed
Два запроса с одним ключом приходят в этот хендлер в пределах миллисекунды. Что идёт не так и какой единственный самый рычажный фикс?
Heads-up Таймаут — хорошая гигиена, но не связан с двойным списанием. Дефект — неатомарное окно гонки SELECT-потом-INSERT между двумя запросами.
Heads-up Код игнорирует ошибку INSERT и безусловно вызывает process(). Даже если PK отклонит второй INSERT, обе горутины уже прошли ветку ErrNoRows и обе списывают.
Heads-up fingerprint — это микросекунды, к гонке отношения не имеет. Гонка — это зазор между чтением «строки нет» и её вставкой, закрываемый только атомарной insert-or-noop.
Этот цикл делает retry на каждую брошенную ошибку с удвоением задержки. Назови две разные production-опасности.
Heads-up Эти значения в нормальных диапазонах (base 100–500мс, cap 30–60с). Опасности — отсутствующий jitter и безусловный retry неретраибельных ошибок, а не константы.
Heads-up Шесть попыток память не исчерпают. Реальная проблема масштаба — на уровне флота — без retry budget много клиентов по 6 попыток усиливают нагрузку — но локальные баги сниппета это отсутствие jitter и retry на 4xx.
Heads-up await sleep уступает; он не блокирует event loop. Дефекты — синхронизированная (без jitter) задержка и retry ошибок, которые никогда не успешны.
Сниппет 3 — переход состояния
-- запрос пришёл; строка для этого ключа уже естьBEGIN; SELECT status FROM idempotency_keys WHERE key = $1; -- читает 'in_progress' -- приложение видит in_progress, но всё равно вызывает платёжный провайдер... -- провайдер успешен, затем: UPDATE idempotency_keys SET status = 'completed', response_body = $2 WHERE key = $1;COMMIT;
Викторина
Completed
Retry гонится с исходным запросом: исходный ещё in_progress, когда выполняется этот блок. В чём дефект использования машины из четырёх состояний?
Heads-up Строка уже есть (status=in_progress), поэтому INSERT неверен. Дефект — вообще обрабатывать на чтении in_progress вместо возврата 409.
Heads-up Изоляция не чинит логическую ошибку: код намеренно вызывает провайдера после in_progress. Сама машина состояний требует 409 на in_progress, независимо от изоляции.
Heads-up Внешнее списание уже случилось у провайдера и не откатывается базой. Фикс — никогда не вызывать провайдера на чтении in_progress.
Сниппет 4 — inbox-консьюмер
BEGIN; INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING; -- применяется всегда, независимо от того, сделала ли вставка выше что-либо: UPDATE inventory SET reserved = reserved + $qty WHERE sku = $sku;COMMIT;
Викторина
Completed
Relay переотправляет событие после сбоя, поэтому этот блок выполняется дважды для одного event_id. Что реально происходит и какой фикс?
Heads-up ON CONFLICT DO NOTHING подавляет ошибку — броска нет. Блок коммитится, и безусловный UPDATE дважды резервирует.
Heads-up Таблица inbox только записывает event_id; она ничего не делает с UPDATE, если код не ветвится по тому, удалась ли вставка. Здесь не ветвится, поэтому UPDATE выполняется всегда.
Heads-up Направление +/- зависит от домена и не является дефектом. Дефект — бизнес-запись выполняется на дубликате доставки, потому что не завязана на dedup-вставку.
Итог
Четыре рефлекса ревью для этого юнита: проверка idempotency через SELECT-потом-INSERT — это гонка, сделай создание атомарным через ON CONFLICT DO NOTHING RETURNING и обрабатывай только победителя; цикл retry без jitter и без retry-условия — это herd, который долбит постоянные ошибки — используй full jitter и retry только на 5xx/таймауты; чтение in_progress должно коротко замкнуть на 409, никогда не вызывая провайдера снова; а dedup в inbox, который не завязывает бизнес-запись на dedup-вставку, декоративен — дубликат всё равно применяет эффект. Каждый фикс — одна-две строки, и каждая отсутствующая строка — это дублированный side effect в production.