← назад к разделу

Сервис остановился посередине операции. Клиент получил таймаут и повторил запрос. Если операция не готова к повторному вызову — деньги спишутся дважды, сообщение обработается два раза, запись продублируется в базе. Это и есть проблема, которую решает идемпотентность.

Что такое идемпотентность и зачем она нужна

Операция называется идемпотентной, если её можно вызвать несколько раз с одинаковыми параметрами и получить тот же результат, что при первом вызове.

Простой пример: DELETE /orders/42 — идемпотентна. Первый вызов удаляет заказ, второй видит «уже удалён» и возвращает тот же ответ. Никакого вреда.

Проблемный пример: POST /payments без дополнительной защиты — не идемпотентна. Первый вызов списывает деньги. Если сервис упал и клиент повторил — деньги списались снова.

При graceful shutdown сервис получает сигнал SIGTERM и начинает завершение работы. Обычно на это даётся 60 секунд. Операции, которые уже начались, пытаются завершиться. Но если операция длинная (например, цепочка HTTP-вызовов с повторными попытками), она может не успеть. Сервис принудительно останавливается посередине, клиент видит ошибку и повторяет запрос к новому экземпляру.

Идемпотентность — это защита от того, что происходит после повтора.

HTTP POST: Idempotency-Key

Обычный GET-запрос безопасен — он ничего не меняет. Но POST-запросы, которые создают что-то или списывают деньги, по умолчанию не идемпотентны.

Частая ошибка — написать такой обработчик:

@PostMapping("/payments")
public PaymentResponse charge(@RequestBody @Valid ChargeRequest req) {
    return paymentService.charge(req);
}

Что происходит при остановке:

  1. Клиент отправил запрос, сервис начал списание.
  2. SIGTERM — сервис остановлен на полпути, ответ не отправлен.
  3. Клиент получил таймаут и повторил запрос на новый экземпляр.
  4. Новый экземпляр списал деньги ещё раз.

Как правильно — требовать от клиента уникальный ключ операции:

@PostMapping("/payments")
public PaymentResponse charge(
    @RequestHeader("Idempotency-Key") String key,
    @RequestBody @Valid ChargeRequest req
) {
    return paymentService.charge(key, req);
}

Логика внутри paymentService.charge при первом вызове записывает результат в базу вместе с этим ключом. При повторном вызове с тем же ключом — возвращает сохранённый результат, не выполняя операцию снова.

Клиент генерирует ключ один раз (например, UUID) и использует его во всех попытках одной операции.

Kafka-обработчик: processed_event в той же транзакции

Kafka доставляет сообщения хотя бы один раз (at-least-once). Это значит, что одно сообщение может прийти повторно — например, если сервис перезапустился до того, как сохранил позицию (offset).

Частая ошибка — обработчик без защиты от повтора:

@KafkaListener(topics = "orders.confirmed")
@Transactional
public void onConfirmed(OrderConfirmedEvent event, Acknowledgment ack) {
    billingService.charge(event.orderId(), event.totalAmount());
    ack.acknowledge();
}

Если сервис остановился после charge, но до ack.acknowledge() — Kafka при следующем запуске доставит то же сообщение снова. Деньги спишутся дважды.

Как правильно — записывать факт обработки в базу в той же транзакции, что и основное действие:

@KafkaListener(topics = "orders.confirmed")
@Transactional
public void onConfirmed(OrderConfirmedEvent event, Acknowledgment ack) {
    if (!processedEventRepository.tryMarkProcessed(event.eventId(), "billing")) {
        ack.acknowledge(); // уже обработано — пропускаем
        return;
    }
    billingService.charge(
        event.orderId(),
        event.totalAmount(),
        Map.of("Idempotency-Key", event.eventId().toString())
    );
    ack.acknowledge();
}

tryMarkProcessed пытается вставить запись (event_id, consumer_group) в таблицу processed_event с уникальным ограничением. Если запись уже есть — метод возвращает false, обработчик пропускает сообщение.

Важный момент: эта вставка должна происходить в той же транзакции, что и само списание. Тогда при откате (из-за ошибки или SIGTERM) оба действия откатятся вместе — и следующий повтор снова увидит необработанное сообщение.

Идемпотентный ключ также передаётся дальше в вызов billingService — на случай, если платёжный провайдер получит два одинаковых запроса.

Outbox-relay: двух-фазная публикация

Паттерн outbox решает проблему «записал в базу, но не отправил в Kafka» или «отправил в Kafka, но не записал в базу». Специальный процесс-relay читает таблицу outbox_event и публикует сообщения.

Проблема — что если relay упал посередине:

relay: отправил сообщение в Kafka
relay: [SIGTERM — не успел обновить статус в базе]

следующий запуск: снова видит то же событие как неотправленное
                  отправляет его в Kafka второй раз

Решение — двух-фазная публикация через промежуточный статус:

-- Переводим события в статус "публикуется" (берём блокировку)
UPDATE outbox_event
SET status = 'PUBLISHING', locked_at = now()
WHERE id IN (
    SELECT id FROM outbox_event
    WHERE status = 'PENDING'
    LIMIT 50
    FOR UPDATE SKIP LOCKED
);

-- После успешной отправки — переводим в "опубликовано"
UPDATE outbox_event
SET status = 'PUBLISHED', published_at = now()
WHERE id = :id;

Если relay упал в статусе PUBLISHING — событие не трогают другие relay-экземпляры. Периодический фоновый процесс (например, раз в час) возвращает «зависшие» события обратно в PENDING для повторной попытки.

Но даже с такой защитой потребитель (consumer) на стороне получателя должен использовать processed_event — потому что в редких случаях дубль всё равно возможен.

Более простой вариант — вообще не усложнять relay, но сделать так, чтобы все потребители игнорировали дубли через processed_event. Дубли в Kafka — небольшой overhead, зато relay остаётся простым.

Ретраи без идемпотентного ключа — опасная комбинация

Отдельная ловушка — автоматические повторные попытки (@Retry) без идемпотентного ключа на денежных операциях:

// Опасно
@Retry(name = "payment")
public Receipt charge(Long orderId, Money amount) {
    return paymentClient.post("/charge", new ChargeRequest(orderId, amount));
}

Сценарий:

  1. Первый вызов отправил запрос к платёжному провайдеру — тайм-аут сети.
  2. @Retry сделал второй вызов — тоже с тайм-аутом.
  3. На самом деле оба запроса дошли до провайдера (тайм-аут не значит «не получили»).
  4. Без идемпотентного ключа провайдер обработал оба — деньги списались дважды.

Правильно — передавать ключ, который генерируется один раз на операцию и остаётся одинаковым во всех повторных попытках:

@Retry(name = "payment")
public Receipt charge(String idempotencyKey, Long orderId, Money amount) {
    return paymentClient.post(
        "/charge",
        new ChargeRequest(orderId, amount),
        Map.of("Idempotency-Key", idempotencyKey),
        Receipt.class
    );
}

Провайдер по ключу видит, что это повтор одной и той же операции, и возвращает прежний результат без повторного списания.

Коротко

  • Идемпотентность — операцию можно повторить с тем же результатом. Нужна везде, где возможен повтор из-за сбоя или остановки.
  • HTTP POST для денежных операций: требовать Idempotency-Key от клиента; сохранять результат в базе при первом вызове, возвращать сохранённый при повторе.
  • Kafka-обработчик: таблица processed_event с уникальным ограничением по (event_id, consumer_group) — вставка в той же транзакции, что и основное действие. Откат транзакции = откат обоих.
  • Outbox-relay: двух-фазная публикация (PENDING → PUBLISHING → PUBLISHED) или защита на стороне потребителя через processed_event.
  • Ретраи + деньги: идемпотентный ключ создаётся один раз на бизнес-операцию и передаётся во все повторные попытки — иначе каждая попытка может создать новое списание.
  • Graceful shutdown даёт время на завершение, но не гарантирует его. Идемпотентность — защита на случай, когда завершить не успели.

Что почитать дальше

  • Graceful shutdown в Java — как сервис принимает SIGTERM и завершает работу.
  • Outbox и scheduled задачи при shutdown — как останавливать фоновые процессы.
  • Kubernetes probes и rolling deploy — как оркестратор управляет жизненным циклом пода.