Идемпотентность и retry: чтение кода и запросов
Читай реальные сниппеты idempotency, retry и inbox, предсказывай их поведение под конкурентностью или сбоем и выбирай самый рычажный фикс.
Баги идемпотентности прячутся в зазоре между двумя 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
}
Два запроса с одним ключом приходят в этот хендлер в пределах миллисекунды. Что идёт не так и какой единственный самый рычажный фикс?
Сниппет 2 — цикл retry
async function callWithRetry(fn, maxAttempts = 6) {
let delay = 200;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxAttempts - 1) throw err;
await sleep(delay); // фиксированное удвоение, без случайности
delay = Math.min(30000, delay * 2);
}
}
}
Этот цикл делает retry на каждую брошенную ошибку с удвоением задержки. Назови две разные production-опасности.
Сниппет 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;
Retry гонится с исходным запросом: исходный ещё 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;
Relay переотправляет событие после сбоя, поэтому этот блок выполняется дважды для одного event_id. Что реально происходит и какой фикс?
Четыре рефлекса ревью для этого юнита: проверка 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.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.