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
Чтобы отбросить повторное сообщение, консьюмер обязан однозначно опознать его. Откуда взять надежный уникальный идентификатор?
- Внутренние идентификаторы брокера:
У брокеров всегда есть внутренние метки (например,DeliveryTagв RabbitMQ или связкаTopic-Partition-Offsetв Kafka). Использовать их в качестве ключа идемпотентности категорически запрещено! При пересылке сообщения в Dead Letter Exchange (DLQ), при повторной публикации продюсером или при переносе очередей эти технические идентификаторы меняются. - Идентификатор продюсера (
Idempotency Key— единственный верный подход):
Продюсер обязан сгенерировать детерминированный или уникальныйIdempotency Keyна своей стороне в момент зарождения намерения (например, UUIDv7 или хэш бизнес-параметровorder_id + user_id) и внедрить его в заголовки (Headers) либо в тело сообщения. Этот ключ сопровождает полезную нагрузку на протяжении всей ее жизни.
Паттерны идемпотентности в Go
В промышленной разработке применяются три фундаментальных инженерных паттерна обеспечения идемпотентности.
1. Optimistic Locking через БД (Естественная идемпотентность)
Наиболее надежный и транзакционно строгий метод — делегировать проверку уникальности персистентному движку базы данных с помощью ограничения уникальности (UNIQUE CONSTRAINT).
-- Таблица транзакций в 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).
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) с атомарной операцией:
SET key value NX EX 3600
- Флаг
NX(Not Exists) гарантирует, что ключ запишется только в том случае, если его еще нет в памяти. - Флаг
EX(Expire) задает время жизни (TTL), защищая от вечной блокировки.
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 в естественный атомарный фильтр дубликатов:
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():
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 и задержками).
- Дисковые накладные расходы индексов:
Каждое ограничениеUNIQUE CONSTRAINT— это дополнительное дерево B-Tree на диске. На каждыйINSERTСУБД обязана выполнить обход дерева и удерживать страницы индекса в буферном пуле памяти (Shared Buffers). Использование невыровненных ключей приводит к постоянным промахам кэша. - Сетевые задержки Redis:
ПроверкаSetNXв Redis добавляет сетевой round-trip (1–3 миллисекунды) на каждое сообщение. При потоке в 50 000 msg/s это создаст 50 000 дополнительных сетевых вызовов в секунду, утилизируя сетевой стек ядра и сокеты Linux. - Отсутствие распределенных транзакций:
Если вы обновляете баланс в PostgreSQL, а флаг идемпотентности проверяете в Redis, между ними всегда существует зазор для аварии.
Наиболее совершенное решение для высоконагруженных систем — Inbox Pattern (7. Inbox pattern), где таблица входящих ключей идемпотентности (inbox) находится в той же базе данных PostgreSQL, что и бизнес-данные, и обновляется строго в рамках единой локальной ACID-транзакции.
Итог
- Дубликаты неизбежны: Семантика
At-Least-Onceобязывает все консьюмеры быть строго идемпотентными. - Ключ генерирует продюсер: Внутренние метки брокеров (
DeliveryTag,Offset) нестабильны. Используйте монотонно возрастающие бизнес-идентификаторы илиUUIDv7. UNIQUE CONSTRAINTв СУБД — золотой стандарт надежности. При перехватеUniqueViolationобязательно возвращайтеnil, подтверждая сообщение брокеру.- Внешние API защищаются через Redis (
SET NX): но помните про обязательное удаление ключа в блоке обработки ошибок при авариях вызова. - Конечные автоматы (State Machines) позволяют реализовывать идемпотентность естественным образом через
UPDATE ... WHERE status = ...и проверкуRowsAffected().
Идемпотентность защитила нас от дублей при повторных попытках (9. Retry стратегии и exponential backoff). Но что делать, если обработка единичных сообщений перегружает базу данных тысячами мелких транзакций в секунду? Как научить консьюмер обрабатывать данные пачками? Разберем в следующей лекции: 5. Batch processing сообщений.