28. Idempotency

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

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

Философия идемпотентности в распределенных системах

В распределенных сетях надежность передается не протоколами доставки, а архитектурой обработки. Закон Мерфи в действии: сетевые пакеты дублируются, TCP-таймауты перезапускают запросы, брокеры гарантируют доставку как минимум один раз, а мобильные клиенты повторно нажимают кнопку «Оплатить» при плохом сигнале. Без идемпотентности любая транзакция превращается в лотерею, где двойное списание или повторное создание заказа становится нормой.

Идемпотентность — это свойство операции, при котором повторное выполнение с теми же входными данными дает идентичный результат и не изменяет состояние системы сверх первого успешного вызова. В Go это не магическая аннотация, а явный контракт, реализуемый через детерминированные проверки и атомарные операции.

1. HTTP-семантика и границы применения

RFC 9110 четко определяет идемпотентные методы: GET, PUT, DELETE и HEAD. POST и PATCH по умолчанию не идемпотентны, так как предполагают создание новых ресурсов или частичное обновление, зависимое от текущего состояния.

В практике бэкенда PUT идемпотентен только если содержит полную замену ресурса. POST с платежами, созданием сущностей или отправкой сообщений требует явного механизма идемпотентности. Клиент генерирует уникальный ключ (обычно UUIDv4), сервер использует его для дедупликации.

2. Паттерн реализации: Ключи и атомарный захват

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

flowchart TD
    A["Клиент шлет POST с Idempotency-Key"] --> B{"Запрос в хранилище дедупликации"}
    B -->|"Ключ найден"| C["Возврат сохраненного ответа"]
    B -->|"Ключ в статусе PROCESSING"| D["Возврат 409 Conflict"]
    B -->|"Ключ отсутствует"| E["Атомарная установка статуса PROCESSING"]
    E --> F["Выполнение бизнес-логики"]
    F -->|"Успех"| G["Сохранение ответа с ключом"]
    F -->|"Ошибка"| H["Сброс статуса для повторной попытки"]
    G --> I["Возврат ответа клиенту"]
    H --> J["Клиент повторяет запрос"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
    classDef success fill:#0f172a,stroke:#10b981,color:#f8fafc;
    classDef conflict fill:#4c0519,stroke:#f43f5e,color:#ffffff;
    class A,E,F,G,H,I,J normal;
    class B decision;
    class C,G success;
    class D conflict;
  
Go
package service

import (
    "context"
    "errors"
    "fmt"
    "time"
)

var ErrOperationInProgress = errors.New("operation already in progress")

// IdempotencyStore определяет контракт для хранения состояний идемпотентности
type IdempotencyStore interface {
    TryAcquire(ctx context.Context, key string, ttl time.Duration) (bool, error)
    GetResult(ctx context.Context, key string) ([]byte, error)
    StoreResult(ctx context.Context, key string, data []byte) error
    Release(ctx context.Context, key string) error
}

type ChargeRequest struct {
    IdempotencyKey string
    Amount         int64
    Currency       string
}

type ChargeResponse struct {
    TransactionID string
    Status        string
}

type PaymentProcessor struct {
    store IdempotencyStore
}

func (p *PaymentProcessor) Charge(ctx context.Context, req *ChargeRequest) (*ChargeResponse, error) {
    if req.IdempotencyKey == "" {
        return nil, errors.New("idempotency key is required for POST operations")
    }

    // 1. Атомарная попытка захвата
    isNew, err := p.store.TryAcquire(ctx, req.IdempotencyKey, 24*time.Hour)
    if err != nil {
        return nil, fmt.Errorf("idempotency check: %w", err)
    }
    if !isNew {
        // Ключ уже обрабатывается или результат готов
        result, err := p.store.GetResult(ctx, req.IdempotencyKey)
        if err != nil {
            // Если ошибка при чтении, считаем, что операция в процессе
            return nil, ErrOperationInProgress
        }
        return deserializeResponse(result)
    }

    // Гарантируем освобождение ключа при панике или отмене контекста
    defer func() {
        if r := recover(); r != nil || ctx.Err() != nil {
            _ = p.store.Release(ctx, req.IdempotencyKey)
        }
    }()

    // 2. Выполнение бизнес-логики
    resp, err := p.executePayment(ctx, req)
    if err != nil {
        // При бизнес-ошибке освобождаем ключ, чтобы клиент мог повторить
        _ = p.store.Release(ctx, req.IdempotencyKey)
        return nil, fmt.Errorf("execute payment: %w", err)
    }

    // 3. Сохранение результата
    serialized, err := serializeResponse(resp)
    if err != nil {
        return nil, fmt.Errorf("serialize response: %w", err)
    }

    if err := p.store.StoreResult(ctx, req.IdempotencyKey, serialized); err != nil {
        // Логирование критично, но не блокируем ответ
        fmt.Printf("failed to store idempotency result: %v\n", err)
    }

    return resp, nil
}

func (p *PaymentProcessor) executePayment(ctx context.Context, req *ChargeRequest) (*ChargeResponse, error) {
    return &ChargeResponse{TransactionID: "tx_12345", Status: "success"}, nil
}

func serializeResponse(resp *ChargeResponse) ([]byte, error) {
    return []byte(resp.TransactionID + ":" + resp.Status), nil
}

func deserializeResponse(data []byte) (*ChargeResponse, error) {
    return &ChargeResponse{TransactionID: "tx_12345", Status: "success"}, nil
}

3. Ловушки и антипаттерны

4. Производительность и Mechanical Sympathy

Каждая проверка идемпотентности добавляет как минимум 1 RTT к хранилищу (БД/Redis) и 2 аллокации на сериализацию. На highload-сервисах это требует оптимизации:

  1. Локальный кэш + Redis: Использовать sync.Map или sharded map для быстрого поиска недавно обработанных ключей. Если ключ отсутствует локально, идти в Redis. Это снижает сетевой overhead на 70-90% при кластерной обработке.
  2. Сжатие ответов: Хранение []byte JSON-ответов в Redis потребляет память. Для ответов >1 КБ применяйте snappy или zstd. Компрессия выполняется в User Space за 1-5 тактов на байт, экономия места достигает 3-5х.
  3. Атомарные операции без Lua: В Redis SET NX атомарен сам по себе. Использование сложных Lua-скриптов для идемпотентности излишне блокирует event loop. Простые команды предпочтительнее.
  4. Избегание строк в хот-пути: map[string] использует хэширование строк, которое проходит по всем байтам. Для ключей идемпотентности (128-битные UUID) хэширование занимает ~50-100 тактов. Если использовать []byte или кастомные структуры с фиксированным размером, компилятор может оптимизировать сравнение через memcmp.

5. Сравнение подходов

Аспект PHP / Laravel Java / Spring Python / FastAPI Go
Реализация Middleware с кэшем в Redis/DB AOP-интерцепторы @Idempotent Декораторы + кэш Явные сервисные методы + интерфейсы
Транзакционность Зависит от драйвера и конфига Управляется прокси транзакций Ручная или async context context.Context + defer + явные ошибки
Оверхед Значительный из-за интерпретатора и reflection Высокий (прокси, bytecode weaving) Средний (GIL, async switching) Минимальный (компиляция, встраивание, атомики)
Тестируемость Требует мока глобального состояния Сложно из-за скрытых зависимостей Легко, но требует event loop Тривиально через инъекцию IdempotencyStore

6. Итог

  1. Идемпотентность — обязательный атрибут любого POST/PATCH, меняющего состояние в распределенной системе.
  2. Реализуется через клиентские Idempotency-Key и атомарные операции захвата на сервере (SET NX, ON CONFLICT).
  3. Всегда определяйте TTL для ключей дедупликации, иначе хранилище станет причиной OOM.
  4. Обрабатывайте состояние PROCESSING явно: возвращайте 409 или 202, не блокируйте горутины бесконечно.
  5. Оптимизируйте хранение: локальный кэш для горячих ключей, сжатие ответов, избегание строк в хот-пути.
  6. Идемпотентность не заменяет валидацию входных данных, а дополняет её, делая сервис устойчивым к сетевым аномалиям и клиентским ретраям.

Следующая статья: 29. Retry и backoff