Ответ
Идемпотентность — это свойство операции, при котором повторный вызов с теми же входными данными даёт тот же результат, что и первичный. Это критически важно в бэкенд-системах, чтобы, например, не списать деньги с клиента дважды из-за сетевого сбоя и повторного запроса (retry).
Классический подход — использование ключа идемпотентности (Idempotency-Key
).
Алгоритм выглядит так:
- Клиент генерирует уникальный ключ (например,
UUID
) для каждой операции и передает его в заголовке запроса (Idempotency-Key
). - Сервер перед выполнением операции проверяет этот ключ в хранилище (например, Redis или БД).
- Возможные сценарии:
- Ключа нет: Это новый запрос. Сервер начинает транзакцию, выполняет бизнес-логику, сохраняет результат и ключ идемпотентности в БД атомарно. После этого возвращает результат клиенту.
- Ключ есть: Это повторный запрос. Сервер не выполняет бизнес-логику заново, а сразу возвращает сохраненный ранее результат.
Пример реализации с Redis и PostgreSQL:
// PaymentProcessor обрабатывает платежи
type PaymentProcessor struct {
cache *redis.Client // Для быстрой проверки ключа
db *sql.DB // Для атомарного выполнения операции
}
func (p *PaymentProcessor) HandlePayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) {
idempotencyKey := req.Header.Get("Idempotency-Key")
if idempotencyKey == "" {
return nil, errors.New("idempotency key is missing")
}
// 1. Быстрая проверка в кеше (Redis)
if status := p.cache.Get(ctx, idempotencyKey).Val(); status == "completed" {
// Можно вернуть сохраненный ответ или просто подтверждение
return &PaymentResponse{Status: "OK"}, nil
}
// 2. Атомарная операция в БД
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback() // Откатится, если не будет Commit
// Проверяем ключ в таблице идемпотентности внутри транзакции, чтобы избежать race condition
var count int
tx.QueryRowContext(ctx, "SELECT count(*) FROM idempotency_keys WHERE key = $1", idempotencyKey).Scan(&count)
if count > 0 {
return &PaymentResponse{Status: "OK"}, nil // Другой запрос уже обработал
}
// 3. Выполнение бизнес-логики
if err := processPaymentInDB(tx, req); err != nil {
return nil, err
}
// 4. Сохраняем ключ идемпотентности
_, err = tx.ExecContext(ctx, "INSERT INTO idempotency_keys (key) VALUES ($1)", idempotencyKey)
if err != nil {
return nil, err
}
// 5. Фиксируем транзакцию и кешируем результат
if err := tx.Commit(); err != nil {
return nil, err
}
p.cache.Set(ctx, idempotencyKey, "completed", 24*time.Hour) // TTL для очистки старых ключей
return &PaymentResponse{Status: "OK"}, nil
}
Ключевые моменты этого подхода:
- Redis используется как быстрый фильтр для уже обработанных запросов.
- Транзакция в PostgreSQL гарантирует атомарность: либо операция и сохранение ключа выполняются вместе, либо ничего не происходит.
- TTL (Time To Live) в Redis необходим, чтобы не хранить ключи вечно.