Модуль 2. Идемпотентность и Exactly-once Processing

Цель модуля: Научить студента проектировать API, гарантированно выполняющее операцию ровно один раз, даже при сетевых сбоях, ретраях клиента и конкурентных запросах.

Стадия роста проекта: MVP / первые юзеры. Проект пережил стадию идеи, ADR написаны, архитектура выбрана. Появились первые реальные пользователи. Сеть нестабильна, клиенты ретраят, и первое, что ломается — дубли операций. Один платёж — два списания. Один push — два коммита. Одно сообщение — два уведомления.

Перед началом: Модуль 1 (Trade-offs, CAP/PACELC) Инструменты: PostgreSQL, pgx v5, k6, Toxiproxy Трудоёмкость: ~6 ч (теория 1.5 ч · практика 4 ч · ADR 0.5 ч)

🔥 Инцидент: один платёж — два списания

В пятницу вечером нагрузка растёт, сеть «сыпется»: задержки, обрывы соединений. Клиент отправляет POST /api/v1/payments. Транзакция в БД уже зафиксирована — деньги списаны. Но HTTP-ответ ещё не ушёл: сеть рвётся ровно между COMMIT и 200 OK.

Мобильное приложение не получает ответ → считает, что платёж не прошёл → делает ретрай с теми же данными. Сервис видит «новый» запрос → снова запускает бизнес-логику → второе списание. Клиент звонит в поддержку, бухгалтерия в панике.

Дальше — хуже. Из-за багов в UI или параллельных ретраев два воркера одновременно пытаются обработать один запрос. Оба проверяют «уже делали?» — оба видят «нет» — оба списывают. Если проверка не атомарна, гонка неизбежна.

В распределённых системах нестабильность сети — это не исключение, а норма. Нельзя полагаться на «один вызов = одна операция». Ответ — в идемпотентности на уровне бизнес-логики: как гарантировать, что операция выполнится ровно один раз, даже если сеть обрывается, клиент ретраит, а запросы приходят параллельно.

В этом модуле мы разберём, как не допустить двойного списания — от Idempotency-Key до уникального индекса в PostgreSQL и тестов с Toxiproxy.

📖 Теория: Раскрытые термины

Глоссарий модуля:

  • Идемпотентность — повторное выполнение операции даёт тот же результат: f(f(x)) = f(x). SET x=5 идемпотентно; x=x+1 — нет.
  • Race Condition (гонка) — несколько параллельных процессов читают/пишут общие данные без синхронизации; результат зависит от порядка выполнения.
  • Dual-Write — запись в два разных хранилища (например, БД + кэш/брокер) без общей транзакции: одно может зафиксироваться, а второе — нет.
  • Clean Architecture — разделение слоёв Handler → Use Case → Repository, где бизнес-логика (Use Case) не зависит ни от фреймворка, ни от БД.
  • Nonce — одноразовое случайное значение, защищающее от повтора запроса (replay).
  • At-least-once Delivery — сеть всегда доставляет дубликаты: брокеры и retry не гарантируют однократную доставку.
  • Exactly-once Processing — «ровно один раз» достигается НЕ сетью, а идемпотентностью на стороне обработки.
  • 23505 Unique Violation — код ошибки PostgreSQL при нарушении уникального индекса; перехватывать явно, не как «любое исключение».

2.1. At-least-once Delivery vs Exactly-once Processing

At-least-once Delivery (Доставка минимум один раз): Сеть, балансировщики и брокеры сообщений (Kafka/RabbitMQ) всегда стремятся доставить сообщение. Если они не получили подтверждения (ACK), они отправят его снова. Это значит, ваш сервис всегда будет получать дубликаты запросов.

Exactly-once Processing (Обработка ровно один раз): Этого невозможно достичь на уровне сети. Это достигается на уровне бизнес-логики через Идемпотентность. Если сервис получил тот же запрос дважды, он должен выполнить бизнес-операцию один раз, а на второй запрос вернуть результат первой операции.

2.2. Ключ идемпотентности (Idempotency-Key)

Механизм, популяризированный Stripe. Клиент генерирует уникальный UUID для конкретной бизнес-операции и отправляет его в заголовке Idempotency-Key.

Если сервер видит ключ впервые — он выполняет операцию и сохраняет результат.

Если сервер видит ключ повторно — он возвращает сохраненный результат без выполнения логики.

2.3. Антипаттерн: Redis как источник истины

Redis можно использовать только как кэш для быстрого возврата результата, но не как источник истины для проверки существования ключа. Проверка «ключ уже есть» должна быть атомарной и происходить в той же транзакции БД, что и бизнес-операция. Иначе возникает гонка: два воркера одновременно видят «ключа нет», оба начинают обработку, и происходит двойное списание.

2.4. Защита от Replay-атак и сценарий "потерянного 200 OK"

Идемпотентность защищает не только от сбоев сети на сервере, но и от сценария, когда транзакция прошла успешно, но сеть оборвалась до отправки HTTP-ответа клиенту. Клиент делает ретрай с тем же ключом, и сервер должен вернуть тот же payment_id и статус, как будто ничего не изменилось. Для защиты от злоумышленников используется Nonce + Timestamp + HMAC (сервер отклоняет запросы старше 5 минут).

2.5. Этот паттерн в архетипах

Идемпотентность проявляется по-разному в четырёх архетипах проектов:

АрхетипМеханизм идемпотентностиКлючПочему критично
Финтех (P2P)Idempotency-Key + UNIQUE indexUUID клиентаДвойное списание = деньги потеряны
Git-хостингОптимистичная блокировка по SHA: before должен совпадать с текущим HEADSHA коммита (детерминированный)Push с тем же SHA = no-op; push с устаревшим before = reject
МессенджерДедупликация по message_idUUID сообщенияДубль = спам в чате
Лента / чтениеИдемпотентная публикация постаUUID постаДубль = мусор в ленте

Общий принцип: каждая бизнес-операция должна иметь уникальный ключ, по которому сервер отличает ретрай от нового запроса. Различается только источник ключа: клиент генерирует (финтех, мессенджер), или ключ детерминирован из данных (Git SHA).

2.6. Компромиссы (Trade-offs)

ПодходПлюсМинусКогда выбирать
Redis как источник истиныбыстрый путь (O(1) GET)гонка: check и write — разные транзакции; дубль при конкурентных запросахнельзя для критичных операций (платежи)
DB unique indexатомарная проверка в одной транзакции (INSERT → 23505)каждый INSERT занимает БД-ресурскритичные операции (деньги)
Nonce + HMACполная защита от replay-атакограничение window (±2 мин) + сложность реализациипубличные API с платёжами
Только Idempotency-Key (без HMAC)простой ключ спасает от случайных дублейне защищает от перехвата/повтора злоумышленникомвнутренние API между доверенными сервисами

Вывод для курса: для финтеха — DB unique index + HMAC; для внутренних вызовов — Idempotency-Key достаточно. См. ADR-2.


🛠 Практические задания

Сценарий: API приема платежа POST /api/v1/payments. Клиент отправляет сумму и ID получателя. Из-за нестабильной сети клиент может отправить этот запрос несколько раз параллельно или с задержкой.

Примерный план работы:

  1. Спроектировать таблицу idempotency_keys → проверить схему (unique index, TTL).
  2. Реализовать UseCase WithdrawIdempotent → persist(платёж) + persist(ключ) в одной транзакции.
  3. Обработать 23505 → SELECT + возврат существующего результата.
  4. Протестировать через Toxiproxy + k6 → SLO: 10 параллельных, 1 списание, 10 ответов 200.
  5. Написать ADR-2: «Redis vs DB unique index».

Задача 1: Проектирование БД и Clean Architecture

Студенту необходимо спроектировать таблицу idempotency_keys и реализовать логику в рамках Clean Architecture (Handler → Use Case → Repository).

Требования к таблице idempotency_keys:

  • Поля: key (UUID), payment_id (FK), status, created_at.
  • Важно: Не хранить полный JSON-ответ. Ответ формируется заново по payment_id.
  • Уникальный индекс по key.
  • TTL-политика: ключи старше 30 дней удаляются (через pg_cron или job). Индекс по created_at для эффективного удаления.

Логика слоев:

  • Handler: Проверяет наличие ключа в БД (через Repository). Если найден — сразу возвращает результат.
  • Use Case: Принимает флаг is_idempotent и idempotency_key. Если результат уже есть, возвращает его без запуска бизнес-логики.

✅ Готово, когда:

  • Таблица idempotency_keys: key UUID UNIQUE, payment_id FK, status, created_at + индекс по created_at для TTL-удаления
  • Полный JSON-ответ не хранится — результат формируется по payment_id
  • Слои Clean Architecture: Handler → UseCase → Repository; Handler проверяет ключ и возвращает результат без вызова бизнес-логики
  • ADR-2 «Redis vs DB unique index» написан

Задача 2: Защита от Race Condition (Конкурентных запросов)

Клиент отправил два одинаковых запроса одновременно. Оба воркера не нашли ключ в БД и начали выполнять транзакцию.

Решение: Уникальный индекс в PostgreSQL. Первый воркер успешно вставляет строку. Второй получает ошибку 23505 Unique Violation. Код должен явно перехватывать именно эту ошибку, делать SELECT по ключу и возвращать существующий результат.

go
// Go: ключ и платёж — в одной транзакции, атомарный захват ключа
func (h *PaymentHandler) Pay(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    
    tx, err := h.db.BeginTx(r.Context(), pgx.TxOptions{})
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    defer tx.Rollback(r.Context()) // no-op после Commit
    
    // Атомарно захватываем ключ; 0 затронутых строк = уже было обработано
    tag, err := tx.Exec(r.Context(),
        `INSERT INTO idempotency_keys (key) VALUES ($1)
         ON CONFLICT (key) DO NOTHING`, key)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    if tag.RowsAffected() == 0 {
        // Конкурент успел первым — возвращаем его результат
        tx.Rollback(r.Context())
        respondByKey(w, h.repo.FindByKey(r.Context(), key))
        return
    }
    
    var paymentID string
    err = tx.QueryRow(r.Context(),
        "INSERT INTO payments (...) VALUES (...) RETURNING id").Scan(&paymentID)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    
    if err := tx.Commit(r.Context()); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    writeJSON(w, map[string]string{"payment_id": paymentID})
}

✅ Готово, когда:

  • Используется INSERT ... ON CONFLICT DO NOTHING (pgx)
  • При ошибке 23505 код делает SELECT по ключу и возвращает уже существующий результат
  • Ошибка 23505 не протекает наружу как HTTP 500 — клиент получает корректный 200 OK

Задача 3: Тестирование через Toxiproxy и k6

Настроить Toxiproxy на пути между клиентом и API, добавив задержку и обрыв 50% соединений. Это заставит клиент k6 делать ретраи.

В скрипте k6 жестко задать один Idempotency-Key для 10 параллельных запросов.

Цель (SLO): В БД должна появиться ровно 1 операция списания. 10 запросов должны получить HTTP 200 OK с одинаковым payment_id.

✅ Готово, когда:

  • Toxiproxy на пути клиент → API: задержка + обрыв 50% соединений (клиент k6 делает ретраи)
  • Один Idempotency-Key для 10 параллельных запросов
  • В БД — ровно 1 списание
  • Все 10 ответов — 200 OK с одинаковым payment_id

⚠️ Антипаттерн для разбора

Студенту даётся код — нужно найти уязвимость (Dual-Write).

go
// Go HTTP-хендлер (Антипаттерн)
func (h *PaymentHandler) Pay(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    // 1. Проверяем в Redis
    if v, ok := h.cache.Get(r.Context(), key); ok {
        writeJSON(w, v)
        return
    }
    // 2. Выполняем платёж
    payment, err := h.svc.Withdraw(r.Context(), amountFrom(r))
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    // 3. Сохраняем в БД
    if _, err := h.db.Exec(r.Context(), "INSERT INTO payments (...) VALUES (...)", payment.ID); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    // 4. Кладём ключ в Redis на 24 часа
    h.cache.Set(r.Context(), key, payment.ID, 24*time.Hour)
    writeJSON(w, payment)
}

Ошибка: Dual-Write (Redis + БД). При конкурентных запросах оба пройдут проверку cache.Get (вернёт «нет»), оба спишут деньги, и только потом второй запишет ключ. Результат: двойное списание. Проверка в Redis не атомарна с записью платежа.

✅ Правильное решение

Идемпотентность проверяется в той же транзакции БД, что и бизнес-данные, через уникальный индекс по key. Конкурентный «проигравший» ловит нарушение уникальности (23505) и возвращает уже созданный результат.

go
// Go: ключ и платёж — в одной транзакции, атомарный захват ключа
func (h *PaymentHandler) Pay(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    
    tx, err := h.db.BeginTx(r.Context(), pgx.TxOptions{})
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    defer tx.Rollback(r.Context()) // no-op после Commit
    
    // Атомарно захватываем ключ; 0 затронутых строк = уже было обработано
    tag, err := tx.Exec(r.Context(),
        `INSERT INTO idempotency_keys (key) VALUES ($1)
         ON CONFLICT (key) DO NOTHING`, key)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    if tag.RowsAffected() == 0 {
        // Конкурент успел первым — возвращаем его результат
        tx.Rollback(r.Context())
        respondByKey(w, h.repo.FindByKey(r.Context(), key))
        return
    }
    
    var paymentID string
    err = tx.QueryRow(r.Context(),
        "INSERT INTO payments (...) VALUES (...) RETURNING id").Scan(&paymentID)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    
    if err := tx.Commit(r.Context()); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    writeJSON(w, map[string]string{"payment_id": paymentID})
}

📐 Задание по визуализации (Mermaid)

Студенту необходимо нарисовать Sequence Diagram, показывающий гонку двух воркеров и обработку ошибки БД.

Обязательные элементы:

  1. Два параллельных запроса от клиента (один Idempotency-Key).
  2. Оба воркера начинают транзакцию (BEGIN).
  3. Первый воркер делает INSERT INTO idempotency_keys.
  4. Второй воркер пытается сделать тот же INSERT → получает ошибку 23505 Unique Violation.
  5. Второй воркер ловит именно эту ошибку, делает SELECT по ключу, возвращает сохраненный payment_id.
  6. Клиент получает два 200 OK с одинаковым payment_id.

Шаблон для ориентира:

Race Condition: два запроса с одним Idempotency-Key

🧩 Проверь себя

1. At-least-once Delivery vs Exactly-once Processing — это…
2. Два конкурентных запроса с одним ключом. Второй воркер получит ошибку…
3. Где проверять Idempotency-Key, чтобы исключить двойное списание?
4. TTL-политика для idempotency_keys — зачем удалять ключи старше 30 дней?
5. Сценарий «потерянный 200 OK»: транзакция прошла, но клиент не получил ответ. Что делать?

🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 2

Привет! Я начинаю Модуль 2 курса «Архитектура распределённых систем».

Дай короткий ответ (2–3 предложения): почему проверки Idempotency-Key через Redis недостаточно, и почему это обязательно нужно делать через реляционную БД с уникальным индексом?

Предоставь мне требования к таблице idempotency_keys: поля, индексы, TTL-политика.

Покажи мне код Антипаттерна (с Redis) и попроси меня объяснить, где именно произойдёт двойное списание при конкурентных запросах и почему проверка в Redis не спасает.

Дай описание API приёма платежа: URL, заголовки, параметры, ожидаемые ответы.

НЕ ПИШИ за меня реализацию Middleware, Use Case и логику БД. Дождись моего ответа. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «ты не учёл случай, когда клиент не получил 200», «нет обработки именно 23505», «логика не встроена в Use Case»).


📝 Чек-лист ментора

  • Студент чётко разделяет At-least-once Delivery и Exactly-once Processing.

  • В таблице idempotency_keys есть уникальный индекс по key, и он используется как источник истины.

  • Ошибка 23505 обрабатывается явно, а не как «любое исключение».

  • Запись ключа и бизнес-операции происходят в одной транзакции.

  • Результат не хранится как JSON-ответ, а как payment_id + status.

  • Есть TTL-политика и план очистки старых ключей.

  • Идемпотентность реализована не только в инфраструктуре (Middleware), но и в бизнес-слое (Use Case).

  • Тест k6 + Toxiproxy реально показывает: 10 запросов → 1 списание → 10 ответов 200 с одинаковым payment_id.

  • Mermaid-диаграмма корректно показывает гонку и обработку Unique Violation.

  • Студент понимает сценарий «транзакция прошла, клиент не получил 200, ретраит» и как система на это реагирует.

  • Студент понимает, как идемпотентность проявляется в разных архетипах (финтех, Git, мессенджер, лента).

  • ADR-2: Student сформулировал ADR «Redis vs DB unique index» (контекст, альтернативы, решение, последствия).

🔗 Мостик к Модулю 3 (Outbox)

В конце ревью обязательно скажи студенту:

  • «Отлично, мы добились того, что платеж не задвоится. Но что если после успешного списания нам нужно отправить событие в Kafka (например, для уведомления пользователя)? Если мы просто пошлём HTTP/Kafka-запрос после коммита БД, и сеть упадёт — событие потеряется. В Модуле 3 мы решим проблему Dual-Write (БД + Брокер) с помощью паттерна Transactional Outbox, чтобы гарантировать доставку событий без потери согласованности.»

Если ты прошёл курс «Git Hosting: Serverless MVP», ты уже видел этот паттерн на упрощённом стеке: YDB Serverless с атомарным UPDATE вместо PostgreSQL с FOR UPDATE, Message Queue вместо Kafka. Паттерн тот же — инфраструктура разная. Здесь мы разбираем его на полном стеке, с глубиной и trade-offs.