Как бы вы обеспечили идемпотентность операций в распределенной системе? Приведите пример.

Ответ

Идемпотентность — это свойство операции, при котором повторный вызов с теми же входными данными даёт тот же результат, что и первичный. Это критически важно в бэкенд-системах, чтобы, например, не списать деньги с клиента дважды из-за сетевого сбоя и повторного запроса (retry).

Классический подход — использование ключа идемпотентности (Idempotency-Key).

Алгоритм выглядит так:

  1. Клиент генерирует уникальный ключ (например, UUID) для каждой операции и передает его в заголовке запроса (Idempotency-Key).
  2. Сервер перед выполнением операции проверяет этот ключ в хранилище (например, Redis или БД).
  3. Возможные сценарии:
    • Ключа нет: Это новый запрос. Сервер начинает транзакцию, выполняет бизнес-логику, сохраняет результат и ключ идемпотентности в БД атомарно. После этого возвращает результат клиенту.
    • Ключ есть: Это повторный запрос. Сервер не выполняет бизнес-логику заново, а сразу возвращает сохраненный ранее результат.

Пример реализации с 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 необходим, чтобы не хранить ключи вечно.