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

Ответ

Идемпотентность — это свойство операции, при котором повторный вызов с теми же входными данными даёт тот же результат, что и первичный. Это критически важно в бэкенд-системах, чтобы, например, не списать деньги с клиента дважды из-за сетевого сбоя и повторного запроса (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 необходим, чтобы не хранить ключи вечно.

Ответ 18+ 🔞

Да ты посмотри, какая хуйня, блядь! Идемпотентность, ёпта! Это ж когда ты десять раз нажимаешь на кнопку, а у тебя только один раз деньги списывают, а не десять, понимаешь? А то бывает — клик-клик, а потом смотришь, а с карты всё, пиздец, вынесли, как будто ты не покупку делал, а на войну снаряды закупал.

Вот чтобы эта мартышлюшка не происходила, умные дядьки придумали ключ идемпотентности. Суть проще пареной репы, блядь:

  1. Перед тем как просить сервер что-то сделать, клиент выдумывает уникальную хуйню — Idempotency-Key. Типа паспорт для запроса.
  2. Сервер этот паспорт проверяет: "А этот мудак уже тут был?"
  3. Если не был — всё делает, результат запоминает и паспорт в альбомчик вклеивает.
  4. Если был — даже не парится, а сразу из альбомчика достаёт старый ответ и отдаёт. И всё, блядь!

А теперь смотри, как это в коде выглядит, только не засыпай, а то хуй с горы на тебя упадёт.

// 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) — это наш швейцар, быстрый такой. Он сразу видит, если этот паспорт уже в базе был, и говорит: "Иди нахуй, всё уже сделано, не мешай работать".
  • Транзакция в Постгресе — это чтобы не было вот этой ебалы, когда два запроса одновременно прилетели и оба думают, что они первые. Транзакция всё скрепляет, как бульдожья хватка: либо операция и сохранение ключа прошли вместе, либо нихуя не прошло. Никаких промежуточных состояний, блядь!
  • TTL — это срок годности паспорта. А то накопится этих ключей овердохуища, и швейцар с ума сойдёт. Через сутки — нахуй, в мусорку.

Вот и вся магия, ёпта. Сделал так — и спи спокойно, не бойся, что из-за кривой сети или нервного пользователя с карты сотку снимут десять раз.