Модуль: Распределённые системы · Уровень: 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-методы идемпотентны, но не наоборот.

МетодSafeIdempotentКомментарий
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

Контракт:

  1. Клиент генерирует ключ один раз на логическую операцию (UUIDv4) и шлёт его при всех ретраях этого запроса.
  2. Сервер: если ключ виден впервые — выполняет операцию, сохраняет результат под ключом.
  3. Если ключ уже виден — возвращает сохранённый результат без повторного выполнения.

Что хранить:

  • 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 (надёжность, транзакционность, дороже). Для денег — почти всегда транзакционная БД.