4. Idempotent handlers

«В распределенных системах нет вопроса "случится ли дублирование?". Вопрос лишь в том, спишет ли ваш обработчик деньги у клиента дважды, когда дубликат неизбежно прилетит.»

Иллюзия Exactly-Once и неизбежность дубликатов

В фундаментальной лекции 4. Модели доставки. At most once, at least once, exactly once мы сформулировали суровую правду компьютерных сетей: идеальная физическая доставка Exactly-Once (ровно один раз) в распределенной среде невозможна из-за проблемы двух генералов и неизбежности сетевых сбоев.

Практически все современные брокеры сообщений (Apache Kafka, RabbitMQ, NATS JetStream) работают с семантикой At-Least-Once (как минимум один раз).

Это означает, что появление дубликатов в очереди — не редкий баг, а штатное повседневное состояние системы:

  • Сервер-консьюмер успешно выполнил списание средств, но процесс был вытеснен Kubernetes прямо перед отправкой подтверждения Ack (как мы разбирали в 2. Консьюмеры и graceful shutdown).
  • Сетевой шлюз словил микропаузу, TCP-таймаут брокера истек, и брокер решил, что консьюмер погиб, отправив то же самое сообщение другому воркеру.
  • Продюсер переотправил пачку из-за таймаута репликации в Kafka.

Если ваш обработчик (Handler) не обладает свойством идемпотентности, система мгновенно деградирует: с клиента спишутся лишние средства, на складе заблокируется двойной объем товара, а пользователю придет десяток одинаковых SMS-уведомлений.

sequenceDiagram
    autonumber
    participant Broker as Брокер (Kafka / RabbitMQ)
    participant Worker as Go Consumer
    participant DB as PostgreSQL

    Broker->>Worker: Сообщение (Заказ #991, списать $50)
    Worker->>DB: UPDATE accounts SET balance = balance - 50
    DB-->>Worker: OK (Баланс уменьшен)
    Note over Worker: Сетевой сбой или OOM до отправки Ack!
    Worker--xBroker: Соединение разорвано (Ack потерян)

    Note over Broker: Ределивери (Requeue) по At-Least-Once
    Broker->>Worker: ПОВТОР: Сообщение (Заказ #991, списать $50)
    Note over Worker: Без идемпотентности: повторное списание!
    Worker->>DB: UPDATE accounts SET balance = balance - 50 (ОШИБКА ДУБЛЯ!)
  

Идемпотентность — математическое свойство операции:
$$f(f(x)) = f(x)$$
В контексте асинхронного бэкенда это означает: если обработчик получит сообщение с message_id=123 десять раз подряд, финальное состояние системы изменится ровно так же, как если бы оно было получено строго один раз, а все последующие ответы будут идентичны первому.


Откуда берется ключ: Producer vs Broker

Чтобы отбросить повторное сообщение, консьюмер обязан однозначно опознать его. Откуда взять надежный уникальный идентификатор?

  1. Внутренние идентификаторы брокера:
    У брокеров всегда есть внутренние метки (например, DeliveryTag в RabbitMQ или связка Topic-Partition-Offset в Kafka). Использовать их в качестве ключа идемпотентности категорически запрещено! При пересылке сообщения в Dead Letter Exchange (DLQ), при повторной публикации продюсером или при переносе очередей эти технические идентификаторы меняются.
  2. Идентификатор продюсера (Idempotency Key — единственный верный подход):
    Продюсер обязан сгенерировать детерминированный или уникальный Idempotency Key на своей стороне в момент зарождения намерения (например, UUIDv7 или хэш бизнес-параметров order_id + user_id) и внедрить его в заголовки (Headers) либо в тело сообщения. Этот ключ сопровождает полезную нагрузку на протяжении всей ее жизни.

Паттерны идемпотентности в Go

В промышленной разработке применяются три фундаментальных инженерных паттерна обеспечения идемпотентности.


1. Optimistic Locking через БД (Естественная идемпотентность)

Наиболее надежный и транзакционно строгий метод — делегировать проверку уникальности персистентному движку базы данных с помощью ограничения уникальности (UNIQUE CONSTRAINT).

SQL
-- Таблица транзакций в PostgreSQL
CREATE TABLE transactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    idempotency_key VARCHAR(255) NOT NULL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(19, 4) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_transactions_idempotency_key UNIQUE (idempotency_key)
);

В Go-коде мы выполняем операцию INSERT. Если сообщение дублируется, PostgreSQL откатит вставку и вернет ошибку нарушения ограничения уникальности (код 23505 в драйвере pgx — pgerrcode.UniqueViolation).

Go
package handler

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log/slog"

    "github.com/jackc/pgerrcode"
    "github.com/jackc/pgx/v5/pgconn"
    "github.com/jackc/pgx/v5/pgxpool"
)

type TopUpEvent struct {
    IdempotencyKey string  `json:"idempotency_key"`
    UserID         int64   `json:"user_id"`
    Amount         float64 `json:"amount"`
}

type PaymentHandler struct {
    db     *pgxpool.Pool
    logger *slog.Logger
}

func (h *PaymentHandler) HandleTopUp(ctx context.Context, body []byte) error {
    var event TopUpEvent
    if err := json.Unmarshal(body, &event); err != nil {
        return fmt.Errorf("unmarshal event: %w", err)
    }

    _, err := h.db.Exec(ctx, `
        INSERT INTO transactions (idempotency_key, user_id, amount)
        VALUES ($1, $2, $3)
    `, event.IdempotencyKey, event.UserID, event.Amount)

    if err != nil {
        var pgErr *pgconn.PgError
        // Проверяем, является ли ошибка нарушением уникального ключа (код 23505)
        if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
            // ДУБЛИКАТ: Транзакция уже была успешно применена ранее!
            // КРИТИЧЕСКИ ВАЖНО: возвращаем nil, чтобы консьюмер отправил брокеру Ack
            h.logger.Warn("Обнаружен дубликат сообщения, пропускаем", 
                "idempotency_key", event.IdempotencyKey)
            return nil
        }

        // Реальная ошибка базы данных (разрыв сети, таймаут диска)
        return fmt.Errorf("db insert failed: %w", err)
    }

    // Успешная первая обработка
    h.logger.Info("Транзакция успешно зафиксирована", "idempotency_key", event.IdempotencyKey)
    return nil
}

2. Отдельное хранилище идемпотентности (Redis / Memcached)

Метод с UNIQUE CONSTRAINT безупречен, если целевым действием является запись в реляционную базу данных. Но что, если обработка сообщения — это внешний побочный эффект: вызов API платежного шлюза Stripe, генерация фискального чека или отправка SMS?

В этом случае мы не можем повесить UNIQUE CONSTRAINT на чужой HTTP-сервер. Нам необходимо атомарно захватить распределенную блокировку до вызова внешнего API.

Для этого используется распределенный кэш (Redis) с атомарной операцией:

TEXT
SET key value NX EX 3600
  • Флаг NX (Not Exists) гарантирует, что ключ запишется только в том случае, если его еще нет в памяти.
  • Флаг EX (Expire) задает время жизни (TTL), защищая от вечной блокировки.
Go
package handler

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

type SMSHandler struct {
    redisClient *redis.Client
    smsGateway  SMSGatewayClient
}

func (h *SMSHandler) HandleSendSMS(ctx context.Context, idempKey string, phone, text string) error {
    redisKey := "idemp:" + idempKey

    // 1. Атомарная попытка захвата ключа
    set, err := h.redisClient.SetNX(ctx, redisKey, "processing", 1*time.Hour).Result()
    if err != nil {
        return fmt.Errorf("redis setnx error: %w", err) // Сетевой сбой Redis -> Nack
    }
    if !set {
        // Ключ уже существует: операция либо выполняется параллельно, либо уже завершена
        return nil // Пропускаем дубликат
    }

    // 2. Вызов внешнего стороннего API
    err = h.smsGateway.Send(ctx, phone, text)
    if err != nil {
        // ВНИМАНИЕ: Если внешний вызов упал, мы ОБЯЗАНЫ удалить ключ из Redis!
        // Иначе при повторной доставке система сочтет сообщение дубликатом,
        // и пользователь никогда не получит важное SMS.
        _ = h.redisClient.Del(ctx, redisKey).Err()
        return fmt.Errorf("sms gateway failed: %w", err)
    }

    // 3. Помечаем как успешно завершенное
    _ = h.redisClient.Set(ctx, redisKey, "done", 24*time.Hour).Err()
    return nil
}

3. Конечные автоматы (State Machines)

Если бизнес-сущность уже сохранена в базе данных и имеет жизненный цикл (например, сущность Order со статусами Created $\to$ Paid $\to$ Shipped), мы можем превратить сам запрос UPDATE в естественный атомарный фильтр дубликатов:

SQL
UPDATE orders 
SET status = 'PAID', paid_at = NOW() 
WHERE id = $1 AND status = 'CREATED';
stateDiagram-v2
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    [*] --> CREATED: Инициализация заказа
    CREATED --> PAID: Событие оплаты (Первый прогон)
    PAID --> SHIPPED: Отгрузка со склада

    note right of PAID
      Повторный UPDATE:
      WHERE status = 'CREATED'
      вернет 0 строк! Дубликат отфильтрован.
    end note
  

В Go мы анализируем количество модифицированных строк с помощью метода RowsAffected():

Go
func (h *OrderHandler) HandleOrderPaid(ctx context.Context, orderID string) error {
    tag, err := h.db.Exec(ctx, `
        UPDATE orders 
        SET status = 'PAID', paid_at = NOW() 
        WHERE id = $1 AND status = 'CREATED'
    `, orderID)
    if err != nil {
        return fmt.Errorf("update order error: %w", err)
    }

    if tag.RowsAffected() == 0 {
        // Ни одной строки не изменено: заказ либо не существует,
        // либо УЖЕ находится в статусе PAID или SHIPPED (дубликат!).
        h.logger.Warn("Повторный статус оплаты или невалидное состояние, пропускаем", 
            "order_id", orderID)
        return nil // Отправляем Ack брокеру
    }

    // Успешный первичный переход конечного автомата
    return nil
}

Mechanical Sympathy: Цена идемпотентности

Закон сохранения сложности в распределенных системах напоминает: за надежность и защиту от дублей приходится платить аппаратными ресурсами (I/O и задержками).

  1. Дисковые накладные расходы индексов:
    Каждое ограничение UNIQUE CONSTRAINT — это дополнительное дерево B-Tree на диске. На каждый INSERT СУБД обязана выполнить обход дерева и удерживать страницы индекса в буферном пуле памяти (Shared Buffers). Использование невыровненных ключей приводит к постоянным промахам кэша.
  2. Сетевые задержки Redis:
    Проверка SetNX в Redis добавляет сетевой round-trip (1–3 миллисекунды) на каждое сообщение. При потоке в 50 000 msg/s это создаст 50 000 дополнительных сетевых вызовов в секунду, утилизируя сетевой стек ядра и сокеты Linux.
  3. Отсутствие распределенных транзакций:
    Если вы обновляете баланс в PostgreSQL, а флаг идемпотентности проверяете в Redis, между ними всегда существует зазор для аварии.

Наиболее совершенное решение для высоконагруженных систем — Inbox Pattern (7. Inbox pattern), где таблица входящих ключей идемпотентности (inbox) находится в той же базе данных PostgreSQL, что и бизнес-данные, и обновляется строго в рамках единой локальной ACID-транзакции.


Итог

  1. Дубликаты неизбежны: Семантика At-Least-Once обязывает все консьюмеры быть строго идемпотентными.
  2. Ключ генерирует продюсер: Внутренние метки брокеров (DeliveryTag, Offset) нестабильны. Используйте монотонно возрастающие бизнес-идентификаторы или UUIDv7.
  3. UNIQUE CONSTRAINT в СУБД — золотой стандарт надежности. При перехвате UniqueViolation обязательно возвращайте nil, подтверждая сообщение брокеру.
  4. Внешние API защищаются через Redis (SET NX): но помните про обязательное удаление ключа в блоке обработки ошибок при авариях вызова.
  5. Конечные автоматы (State Machines) позволяют реализовывать идемпотентность естественным образом через UPDATE ... WHERE status = ... и проверку RowsAffected().

Идемпотентность защитила нас от дублей при повторных попытках (9. Retry стратегии и exponential backoff). Но что делать, если обработка единичных сообщений перегружает базу данных тысячами мелких транзакций в секунду? Как научить консьюмер обрабатывать данные пачками? Разберем в следующей лекции: 5. Batch processing сообщений.