Модуль: Распределённые системы · Уровень: Senior+
TL;DR#
- Идемпотентность — повторное выполнение операции с теми же параметрами даёт тот же результат и те же побочные эффекты, что и однократное.
f(f(x)) == f(x)на уровне состояния системы. - Нужна потому, что сеть ненадёжна: клиент не знает, дошёл ли запрос. Любая система с ретраями и доставкой at-least-once обязана быть идемпотентной, иначе дубли (двойные списания, двойные заказы).
- HTTP:
GET,PUT,DELETE,HEAD,OPTIONSидемпотентны по семантике спецификации;POSTиPATCH— нет. Спецификация не гарантирует реализацию. - Практика для не-идемпотентных операций: idempotency key — клиент генерирует уникальный ключ, сервер хранит
(key → результат, статус)и при повторе отдаёт сохранённый ответ вместо повторного выполнения. - Самое сложное — конкурентные запросы с одним ключом (in-flight). Решается через unique constraint или distributed lock + конечный автомат статусов
processing → completed/failed. - На уровне БД идемпотентность достигается через
UPSERT,INSERT ... ON CONFLICT, unique constraints,INSERT ... WHERE NOT EXISTS.
Теория#
Зачем нужна идемпотентность#
В распределённой системе невозможно отличить «запрос не дошёл» от «ответ не дошёл». Это фундаментальная проблема two generals: клиент отправил POST /payments, получил таймаут. Что произошло?
Клиент Сервер
| POST /payments ---------> | списал $100
| | отправил 200 OK
| <-- X (ответ потерян) |
| таймаут, ретрай |
| POST /payments ---------> | списал ещё $100 ← ДУБЛЬБез идемпотентности безопасный ретрай невозможен. А ретраи неизбежны:
- At-least-once delivery — основная гарантия большинства брокеров (Kafka, SQS, RabbitMQ с ack). Брокер скорее доставит дважды, чем потеряет.
- Ретраи на уровне HTTP-клиентов, service mesh (Envoy/Istio), gRPC.
- Перезапуск воркеров после краша до коммита оффсета.
Exactly-once в общем случае недостижимо на транспортном уровне. Практический exactly-once = at-least-once delivery + идемпотентная обработка (дедупликация).
Идемпотентность vs безопасность (safety)#
Не путать два свойства HTTP:
- Safe — не меняет состояние (read-only):
GET,HEAD,OPTIONS. - Idempotent — повтор не меняет результат относительно однократного выполнения.
Все safe-методы идемпотентны, но не наоборот.
| Метод | Safe | Idempotent | Комментарий |
|---|---|---|---|
| GET | да | да | только чтение |
| HEAD | да | да | как GET без тела |
| OPTIONS | да | да | метаданные |
| PUT | нет | да | полная замена ресурса: PUT /users/1 {name:"A"} сколько угодно раз → один результат |
| DELETE | нет | да | удаление уже удалённого — то же состояние (хотя статус может отличаться 200 vs 404) |
| POST | нет | нет | создание ресурса: каждый вызов создаёт новый |
| PATCH | нет | нет | частичное изменение, не обязано быть идемпотентным (напр. {balance: balance+10}) |
Тонкость с DELETE: операция идемпотентна по состоянию (ресурса нет), но HTTP-статусы повторов могут различаться (первый — 204, второй — 404). Это нормально: идемпотентность — про состояние системы, а не про байт-в-байт идентичность ответа.
Тонкость с PUT: идемпотентен, только если payload не содержит относительных операций. PUT {counter: 5} — идемпотентен, а PUT с серверным updated_at = now() строго говоря меняет ответ, но семантически считается идемпотентным.
Idempotency keys: паттерн для POST/PATCH#
Поскольку POST неидемпотентен по природе, индустрия (Stripe, PayPal, GitHub) ввела idempotency key — заголовок, который клиент генерирует и переиспользует при ретраях:
POST /v1/charges
Idempotency-Key: 7f8a9b3c-1d2e-4f5a-8b6c-9d0e1f2a3b4cКонтракт:
- Клиент генерирует ключ один раз на логическую операцию (UUIDv4) и шлёт его при всех ретраях этого запроса.
- Сервер: если ключ виден впервые — выполняет операцию, сохраняет результат под ключом.
- Если ключ уже виден — возвращает сохранённый результат без повторного выполнения.
Что хранить:
idempotency_key(PK / unique)status(processing/completed/failed)response_code,response_body(снапшот ответа)request_fingerprint(хэш тела запроса — защита от reuse ключа с другим payload)created_at,expires_at(TTL)
┌──────────────────────────────────────────────────────────┐
│ idempotency_keys │
├──────────────┬──────────┬──────────┬──────────┬──────────┤
│ key (UNIQUE) │ status │ resp_code│ resp_body│ fp │
├──────────────┼──────────┼──────────┼──────────┼──────────┤
│ 7f8a... │ completed│ 200 │ {...} │ a1b2... │
│ c4d5... │ processing│ NULL │ NULL │ e6f7... │
└──────────────┴──────────┴──────────┴──────────┴──────────┘TTL для ключей#
Ключи нельзя хранить вечно. TTL = окно, в течение которого клиент может ретраить (Stripe — 24 часа). После TTL ключ можно переиспользовать. Реализация:
- Redis:
SET key ... EX 86400— автоматическая эвикция. - Postgres: колонка
expires_at+ периодический cleanup-джоб (DELETE WHERE expires_at < now()) или partitioning по времени.
Компромисс: слишком короткий TTL → ретрай после TTL создаст дубль; слишком длинный → разрастание хранилища.
Race condition: конкурентные запросы с одним ключом (главная проблема)#
Самый частый баг наивной реализации:
// ПЛОХО: проверка-затем-действие (check-then-act) — TOCTOU
existing, _ := store.Get(key)
if existing != nil {
return existing.Response // отдаём сохранённое
}
result := doExpensiveOperation() // <-- два запроса попадут сюда одновременно
store.Save(key, result)Два параллельных запроса с одним ключом (клиент агрессивно ретраит, или балансировщик задублировал) оба проходят Get == nil → оба выполняют операцию. Дубль.
Решение 1 — unique constraint + статус-машина (рекомендуемое).
Атомарно вставляем строку со статусом processing. Кто проиграл гонку на insert — получает конфликт и ждёт/опрашивает результат победителя.
Запрос A: INSERT key, status=processing → OK, выполняет операцию
Запрос B: INSERT key, status=processing → UNIQUE VIOLATION
→ читает строку, видит processing
→ возвращает 409 Conflict / ждёт completed
Запрос A: UPDATE key SET status=completed, response=...Решение 2 — distributed lock (Redis SETNX / SET NX PX, Redlock). Захватываем лок на ключ перед выполнением. Минус: лок может протухнуть/зависнуть, нужен fencing token; сложнее, чем unique constraint.
Как отвечать на in-flight дубль:
- 409 Conflict «запрос с этим ключом ещё обрабатывается, повторите позже» — простой и честный вариант.
- Или дождаться завершения первого (polling статуса с backoff) и вернуть его результат — лучше для UX, но дольше держит соединение.
Защита от reuse ключа с другим телом: при конфликте сравнить request_fingerprint. Если совпадает — дедуп. Если различается — 422 Unprocessable Entity «ключ уже использован с другими параметрами» (защита от ошибок клиента).
Идемпотентность на уровне БД#
Часто идемпотентность дешевле обеспечить в самой записи, без отдельной таблицы ключей.
UPSERT — повторная вставка не создаёт дубль:
INSERT INTO orders (id, user_id, amount)
VALUES ('order-123', 42, 100)
ON CONFLICT (id) DO NOTHING; -- идемпотентное создание
-- или DO UPDATE SET amount = EXCLUDED.amount; -- идемпотентная заменаUnique constraint как дедупликатор. Если у бизнес-сущности есть естественный ключ (payment_external_id, message_id из брокера) — повесить unique-индекс. Повторная вставка падает с ошибкой → ловим её как «уже обработано».
CREATE UNIQUE INDEX idx_dedup ON processed_messages (message_id);
-- обработчик Kafka:
INSERT INTO processed_messages (message_id, ...) VALUES (...)
ON CONFLICT (message_id) DO NOTHING; -- если 0 строк затронуто → дубль, скипаем бизнес-логикуУсловный UPDATE для переходов состояний (защита от повтора и от гонок):
UPDATE orders SET status = 'paid'
WHERE id = 'order-123' AND status = 'pending';
-- RowsAffected == 0 → уже оплачен (или нет такого) → повтор безопасенТранзакционность. Бизнес-операция и запись ключа/дедуп-маркера должны коммититься в одной транзакции. Иначе: операция выполнилась, а маркер не записался (или наоборот) → следующий ретрай создаст дубль. Для брокеров это паттерн inbox (дедуп входящих) и outbox (надёжная публикация исходящих).
Пример: idempotency middleware на Go#
Вариант с Postgres (надёжное хранилище + транзакционность с бизнес-логикой). Ключевая идея — атомарный INSERT ... ON CONFLICT для захвата ключа.
package idempotency
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type record struct {
Status string // "processing" | "completed"
RespCode int
RespBody []byte
Fingerprint string
}
type Store struct{ db *sql.DB }
// tryAcquire атомарно пытается застолбить ключ в статусе processing.
// Возвращает (rec=nil, acquired=true) — мы первые, выполняем операцию.
// Возвращает (rec!=nil, acquired=false) — ключ уже есть, отдаём/ждём.
func (s *Store) tryAcquire(ctx context.Context, key, fp string, ttl time.Duration) (*record, bool, error) {
res, err := s.db.ExecContext(ctx, `
INSERT INTO idempotency_keys (key, status, fingerprint, expires_at)
VALUES ($1, 'processing', $2, $3)
ON CONFLICT (key) DO NOTHING`,
key, fp, time.Now().Add(ttl))
if err != nil {
return nil, false, err
}
if n, _ := res.RowsAffected(); n == 1 {
return nil, true, nil // мы захватили ключ
}
// конфликт — читаем существующую запись
var r record
err = s.db.QueryRowContext(ctx, `
SELECT status, COALESCE(resp_code,0), COALESCE(resp_body,''::bytea), fingerprint
FROM idempotency_keys WHERE key = $1`, key).
Scan(&r.Status, &r.RespCode, &r.RespBody, &r.Fingerprint)
if err != nil {
return nil, false, err
}
return &r, false, nil
}
func (s *Store) complete(ctx context.Context, key string, code int, body []byte) error {
_, err := s.db.ExecContext(ctx, `
UPDATE idempotency_keys
SET status='completed', resp_code=$2, resp_body=$3
WHERE key=$1`, key, code, body)
return err
}
// Middleware применяет идемпотентность только к мутирующим методам.
func (s *Store) Middleware(ttl time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" || (r.Method != http.MethodPost && r.Method != http.MethodPatch) {
next.ServeHTTP(w, r) // ключ не требуется
return
}
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(body))
fp := fingerprint(r.Method, r.URL.Path, body)
rec, acquired, err := s.tryAcquire(r.Context(), key, fp, ttl)
if err != nil {
http.Error(w, "idempotency store error", http.StatusInternalServerError)
return
}
if !acquired {
// ключ уже существует
if rec.Fingerprint != fp {
http.Error(w, "key reused with different payload", http.StatusUnprocessableEntity)
return
}
switch rec.Status {
case "completed":
replay(w, rec) // отдаём сохранённый ответ
case "processing":
// in-flight: первый запрос ещё работает
w.Header().Set("Retry-After", "1")
http.Error(w, "request in progress", http.StatusConflict)
}
return
}
// Мы захватили ключ — выполняем хендлер, перехватывая ответ.
rw := &capture{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rw, r)
// Сохраняем результат. В проде: бизнес-логика + complete()
// должны быть в ОДНОЙ транзакции, чтобы избежать рассинхрона.
if err := s.complete(r.Context(), key, rw.code, rw.buf.Bytes()); err != nil {
// логируем; ключ останется processing до TTL — следующий ретрай получит 409
return
}
})
}
}
func fingerprint(method, path string, body []byte) string {
h := sha256.New()
h.Write([]byte(method + "\n" + path + "\n"))
h.Write(body)
return hex.EncodeToString(h.Sum(nil))
}
func replay(w http.ResponseWriter, rec *record) {
w.Header().Set("Idempotent-Replayed", "true")
w.WriteHeader(rec.RespCode)
_, _ = w.Write(rec.RespBody)
}
// capture перехватывает код и тело ответа для сохранения.
type capture struct {
http.ResponseWriter
code int
buf bytes.Buffer
}
func (c *capture) WriteHeader(code int) { c.code = code; c.ResponseWriter.WriteHeader(code) }
func (c *capture) Write(b []byte) (int, error) {
c.buf.Write(b)
return c.ResponseWriter.Write(b)
}
var _ = json.Marshal
var _ = errors.IsВариант с Redis (быстрее, но без транзакции с бизнес-БД — годится, когда сама операция уже идемпотентна на уровне записи):
// Атомарный захват через SET NX. Значение — маркер processing.
ok, err := rdb.SetNX(ctx, "idem:"+key, "processing", ttl).Result()
if err != nil { /* ... */ }
if !ok {
// ключ уже есть → читаем сохранённый ответ или отдаём 409 если processing
val, _ := rdb.Get(ctx, "idem:"+key).Result()
// ...распарсить и отдать...
return
}
// мы первые — выполняем, потом перезаписываем маркер на сериализованный ответ
defer rdb.Set(ctx, "idem:"+key, serializedResponse, ttl)Redis-вариант менее надёжен: если воркер упал между
SetNXи записью ответа, ключ застрянет вprocessingдо TTL. Для денежных операций предпочтительнее Postgres с транзакцией бизнес-логика+ключ.
Подводные камни / gotchas#
- Check-then-act вместо атомарного insert. Самый частый баг —
Get(); if nil { do() }. Гонка → дубли. Всегда атомарныйINSERT ON CONFLICT/SETNX. - Ключ не транзакционен с бизнес-логикой. Если операция закоммитилась, а ключ — нет (или наоборот), система рассинхронизируется. Один коммит на оба изменения.
- Застрявший
processing. Воркер упал после захвата ключа, не дойдя доcompleted. Без TTL/таймаута ключ навсегда блокирует ретраи. Нужен TTL + механизм перевода протухшегоprocessingв доступное состояние. - Сервер генерирует ключ. Тогда при ретрае ключ другой → дедуп не работает. Ключ обязан генерировать клиент (или прокси на входе, но стабильно для ретраев).
- Reuse ключа с другим телом. Без проверки fingerprint клиент по ошибке отправит другой payload под старым ключом и получит чужой ответ. Сравнивать хэш запроса.
- Идемпотентность только на API, но не в обработчике сообщений. HTTP защитили, а consumer Kafka при ребалансе обрабатывает сообщение дважды. Дедуп нужен на каждой at-least-once границе.
- TTL короче окна ретраев клиента. Клиент ретраит через 25 часов, ключ протух за 24 → дубль. TTL ≥ максимального горизонта ретраев.
- «DELETE идемпотентен, значит ответ одинаков». Нет: состояние одинаково, а статусы (204 → 404) могут различаться. Не завязывайте логику на идентичность ответа.
- Неидемпотентный PATCH/POST приняли за идемпотентный и включили агрессивные ретраи в mesh без ключей → дубли заказов.
- Идемпотентность ≠ конкурентная корректность. Два разных запроса (разные ключи) на один ресурс всё равно требуют оптимистичных/пессимистичных блокировок (версии,
SELECT FOR UPDATE).
Вопросы на собеседовании#
В: В чём разница между safe и idempotent методами HTTP? О: Safe — не меняет состояние сервера (read-only: GET, HEAD, OPTIONS). Idempotent — повтор не меняет результат относительно однократного выполнения. Все safe идемпотентны, но не наоборот: PUT и DELETE меняют состояние, но идемпотентны.
В: Почему POST не идемпотентен, а PUT — да?
О: POST создаёт новый ресурс при каждом вызове (N вызовов → N ресурсов). PUT — полная замена ресурса по известному URI: N одинаковых PUT приводят к тому же состоянию, что и один. PATCH тоже неидемпотентен, если содержит относительные операции (balance += 10).
В: Зачем вообще нужна идемпотентность, если можно просто не ретраить? О: Сеть ненадёжна, и при таймауте клиент не знает, дошёл ли запрос. Большинство брокеров и mesh дают at-least-once доставку — дубли неизбежны. Exactly-once на транспорте недостижим; практически он строится как at-least-once delivery + идемпотентная обработка.
В: Как реализовать idempotency key и что хранить?
О: Клиент генерирует уникальный ключ (UUID) и шлёт его при всех ретраях операции. Сервер хранит key → (status, response_code, response_body, request_fingerprint, expires_at). Первый запрос выполняет операцию и сохраняет ответ; повторы получают сохранённый ответ без повторного выполнения.
В: Что произойдёт при двух одновременных запросах с одним ключом и как это решить?
О: При наивной check-then-act реализации оба пройдут проверку «ключа нет» и оба выполнят операцию — дубль. Решение: атомарный INSERT ... ON CONFLICT DO NOTHING со статусом processing. Победитель выполняет операцию, проигравший получает unique violation и возвращает 409 (или ждёт результат). Альтернатива — distributed lock (SETNX/Redlock с fencing token).
В: Как обеспечить идемпотентность на уровне БД без отдельной таблицы ключей?
О: UPSERT (INSERT ... ON CONFLICT DO NOTHING/UPDATE), unique constraint на естественном ключе (повторная вставка падает → ловим как «уже обработано»), условный UPDATE с проверкой текущего статуса (WHERE status='pending', смотрим RowsAffected).
В: Почему запись idempotency-ключа должна быть в одной транзакции с бизнес-логикой? О: Иначе возможен рассинхрон: операция закоммитилась, а ключ нет (ретрай повторит операцию → дубль) или ключ записан, а операция откатилась (ретрай вернёт «успех» по несуществующей операции). Атомарность обоих изменений гарантирует консистентность.
В: Что такое inbox/outbox и как они связаны с идемпотентностью?
О: Outbox — запись исходящего события в ту же транзакцию, что и бизнес-изменение, с последующей надёжной публикацией (решает «опубликовали, но не закоммитили»). Inbox — дедупликация входящих сообщений по message_id через unique constraint (идемпотентная обработка at-least-once потока).
В: Как выбрать TTL для ключей?
О: TTL должен покрывать максимальный горизонт ретраев клиента. Слишком короткий → ретрай после истечения создаст дубль; слишком длинный → разрастание хранилища. Stripe использует 24 часа. Реализация: Redis EXPIRE или Postgres expires_at + cleanup-джоб/партиционирование.
На что копают на senior+#
- Глубина с race conditions. Senior должен сразу назвать check-then-act как баг и предложить атомарный insert/SETNX, а не «проверю в начале хендлера». Бонус — обсуждение fencing token при Redis-локах и проблем Redlock.
- Транзакционные границы. Понимание, что ключ и бизнес-операция должны коммититься вместе, и знание паттернов inbox/outbox для брокеров.
- Exactly-once как миф. Умение объяснить, что exactly-once delivery невозможен, и что реально достигается дедупликацией поверх at-least-once.
- In-flight обработка. Что делать с конкурентным дублем: 409 vs ожидание результата, trade-offs по UX и удержанию соединений; восстановление застрявшего
processing. - Идемпотентность на всех границах. Не только HTTP API, но и consumer’ы брокеров, retry в gRPC/mesh, перезапуски воркеров.
- Связь с конкурентным доступом. Чёткое разделение: идемпотентность защищает от повтора одного запроса, но не заменяет оптимистичные блокировки/версионирование для конкурентных разных запросов.
- Выбор хранилища. Когда Redis (скорость, TTL из коробки, но нет транзакции с бизнес-БД), когда Postgres (надёжность, транзакционность, дороже). Для денег — почти всегда транзакционная БД.