Сервис остановился посередине операции. Клиент получил таймаут и повторил запрос. Если операция не готова к повторному вызову — деньги спишутся дважды, сообщение обработается два раза, запись продублируется в базе. Это и есть проблема, которую решает идемпотентность.
Что такое идемпотентность и зачем она нужна
Операция называется идемпотентной, если её можно вызвать несколько раз с одинаковыми параметрами и получить тот же результат, что при первом вызове.
Простой пример: 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);
}
Что происходит при остановке:
- Клиент отправил запрос, сервис начал списание.
- SIGTERM — сервис остановлен на полпути, ответ не отправлен.
- Клиент получил таймаут и повторил запрос на новый экземпляр.
- Новый экземпляр списал деньги ещё раз.
Как правильно — требовать от клиента уникальный ключ операции:
@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));
}
Сценарий:
- Первый вызов отправил запрос к платёжному провайдеру — тайм-аут сети.
@Retryсделал второй вызов — тоже с тайм-аутом.- На самом деле оба запроса дошли до провайдера (тайм-аут не значит «не получили»).
- Без идемпотентного ключа провайдер обработал оба — деньги списались дважды.
Правильно — передавать ключ, который генерируется один раз на операцию и остаётся одинаковым во всех повторных попытках:
@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 — как оркестратор управляет жизненным циклом пода.