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

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

Разница между «повтор безопасен» и «списали дважды» решается в одном месте: оказались списание и отметка о нём в одной транзакции или в разных.

Одно сообщение orders.confirmed · offset 4218 · списание 1 200 ₽Ошибка: отметка вне транзакцииВерно: отметка в транзакции chargeтакт 1Kafka доставил offset 4218, event_id 7f3a такт 2charge 1 200 ₽ — COMMITINSERT 7f3a — следом, вторым COMMITcharge 1 200 ₽ + INSERT 7f3aодин COMMIT на оба действия такт 3SIGTERM: Spring прервал listener до ack.acknowledge()второй COMMIT не случилсяcharge и отметка — в базе вместе такт 4Рестарт: Kafka снова шлёт offset 4218 такт 5отметки нет — charge сновасписано 2 400 ₽ вместо 1 200 ₽INSERT 7f3a отбит uniqueskip + ack — списано 1 200 ₽ Дубль отсекает не запас времени, а общая транзакцияОдна транзакция: либо и списание, и отметка, либо ни того, ни другого

Разрыв между списанием и отметкой — это окно, в которое попадает SIGTERM: один платёж превращается в два по 1 200 ₽. Общий COMMIT такого окна не оставляет.

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

Дважды нажали «удалить заказ» — DELETE /orders/42 ушёл два раза, а итог один: заказа нет. Дважды ушёл POST /payments — деньги списались дважды. Первая операция идемпотентна: её можно вызвать несколько раз подряд с одинаковыми параметрами, и состояние системы будет таким же, как после первого вызова. Ответы при этом могут отличаться — на первый вызов сервис отдаст 204, на второй 404, — и это не ломает идемпотентность: она про состояние после вызова, а не про букву ответа.

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

При graceful shutdown сервис получает сигнал SIGTERM и начинает завершение работы. Времени на это ровно столько, сколько задано в манифесте: по умолчанию Kubernetes даёт 30 секунд, а в этом разделе мы рекомендуем поднять бюджет до 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) и использует его во всех попытках одной операции.

Что именно сохранять и что возвращать на повтор

«Записывает результат в базу» — самая важная и самая недосказанная часть. Вот что лежит в таблице на самом деле:

CREATE TABLE idempotency_keys (
    key          text        PRIMARY KEY,
    request_hash text        NOT NULL,     -- отпечаток тела запроса
    status       text        NOT NULL,     -- IN_PROGRESS | DONE
    http_status  int,                      -- код ответа первой попытки
    response     jsonb,                    -- тело ответа первой попытки
    created_at   timestamptz NOT NULL DEFAULT now(),
    completed_at timestamptz
);

Четыре поля здесь нужны по делу.

response и http_status — то, что вернут повтору. Без них повтор нечем обслужить: операция уже выполнена, выполнять снова нельзя, а ответить надо тем же, что получил бы клиент с первой попытки. Возвращают именно сохранённый ответ, а не «пересчитанный»: за время между попытками объект мог измениться, и пересчёт вернёт другое.

request_hash — отпечаток тела (скажем, sha256 от нормализованного JSON). Он отвечает на вопрос, который обязательно возникнет: что делать, если пришёл тот же ключ, но другое тело? Это ошибка клиента, и правильный ответ — отказ, а не выполнение и не выдача старого результата:

var saved = keys.find(key);
if (saved != null) {
    if (!saved.requestHash().equals(hash(req))) {
        throw new IdempotencyKeyReuseException();   // 422, иногда 409
    }
    if (saved.status() == IN_PROGRESS) {
        throw new RequestInProgressException();     // 409, «повторите позже»
    }
    return saved.response();                        // тот же ответ, что и в первый раз
}

Код ответа тут дело соглашения: платёжные провайдеры обычно отдают 422 Unprocessable Entity с явным объяснением «этот ключ уже использован с другим телом», реже 409 Conflict. Главное — не 200: молча вернуть результат другой операции хуже, чем отказать.

status нужен для случая из следующего раздела.

Два запроса с одним ключом одновременно

Ситуация не редкая: клиент не дождался ответа, повторил, а первый запрос ещё выполняется — и попал на другую реплику. Два обработчика с одним ключом работают параллельно. Проверка «есть ли запись» тут не помогает: на момент проверки записи ещё нет ни у одного.

Разрешает это уникальный индекс плюс отметка о начале работы в отдельной транзакции:

  1. Первым делом, до всякой бизнес-логики, вставляем строку со статусом IN_PROGRESS и фиксируем эту вставку.
  2. Если вставка упала на нарушении уникальности — значит кто-то уже начал. Читаем строку: статус DONE — возвращаем сохранённый ответ; статус IN_PROGRESS — отвечаем 409 с просьбой повторить позже.
  3. Выполняем операцию и в её же транзакции переводим строку в DONE с ответом.
@Transactional(propagation = REQUIRES_NEW)
public boolean tryStart(String key, String hash) {
    try {
        keys.insertInProgress(key, hash);
        return true;
    } catch (DuplicateKeyException e) {
        return false;
    }
}

Почему отдельная транзакция (REQUIRES_NEW): отметка о начале должна стать видимой остальным сразу, а не после завершения основной работы. Внутри одной транзакции соседняя реплика ничего не увидит и начнёт делать то же самое.

И оговорка, которая относится к теме этой фазы: если под умер в состоянии IN_PROGRESS, строка останется в нём навсегда, и все повторы будут получать 409. Поэтому у такого состояния есть срок: запись старше, чем разумное время выполнения операции (минуты), считается брошенной, и повтор либо начинает работу заново (если операция идемпотентна на уровне данных), либо попадает в разбор человеку. Это тот же приём «опрос застрявших», что и во внешних операциях.

Откуда на самом деле берётся ключ

«Клиент генерирует UUID» — верно для случая, когда клиент один и он ваш. На практике ключ появляется в одном из четырёх мест, и от этого зависит, работает ли защита.

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

Мобильное приложение делает то же самое, но ему важнее переживать перезапуск: ключ вместе с намерением сохраняют в локальном хранилище, чтобы после потери сети и перезапуска повтор шёл с тем же ключом.

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

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

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

Иногда ключ не нужен: PUT с идентификатором от клиента

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

POST /orders                 → сервер придумал id, повтор создаст второй заказ
PUT  /orders/{clientOrderId} → id придумал клиент, повтор перезапишет тот же заказ

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

Где это работает: создание сущности, у которой клиент и так знает естественный идентификатор (номер документа, идентификатор во внешней системе, идентификатор корзины). Где нет: операции, у которых нет объекта («списать деньги» — это не создание сущности, это действие), и случаи, когда клиенту нельзя доверять выбор идентификатора.

Промежуточный вариант, который часто оказывается лучшим: естественный ключ идемпотентности вместо случайного. Не заголовок от клиента, а уникальный индекс на том, что по смыслу уникально: (order_id, 'refund') для возврата по заказу, (user_id, event_id) для обработки события. Тогда таблица ключей не нужна вовсе — уникальность обеспечивает та же таблица, где лежат данные.

Сколько живёт ключ на стороне провайдера

Обратная сторона той же темы: вы сами вызываете чужой сервис с ключом идемпотентности. Срок жизни ключа там не бесконечен, и это влияет на ваши повторы.

Типичные значения у платёжных провайдеров — 24 часа; встречаются и сутки, и несколько дней, и пятнадцать минут для отдельных операций. Что происходит после истечения: повтор с тем же ключом считается новой операцией и выполняется заново. То есть отложенный повтор через неделю списывает деньги второй раз, хотя формально всё сделано правильно.

Практические следствия три.

Повторы должны укладываться в срок. Фоновая задача, которая добивает незавершённые операции, повторяет их часами, а не неделями; после того как срок ключа у провайдера истёк, операцию не повторяют, а отправляют в разбор.

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

Статус спрашивают, а не угадывают. У всех серьёзных провайдеров есть запрос состояния операции по вашему идентификатору — и это надёжнее повтора: он не создаёт ничего нового и отвечает, чем закончилась первая попытка. Схема «сначала спросить, потом при необходимости повторить» безопасна при любом сроке жизни ключа.

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.recordCharge(event.orderId(), event.totalAmount());
    outboxRepository.append(new ChargePaymentRequested(
        event.orderId(),
        event.totalAmount(),
        event.eventId().toString()
    ));
    ack.acknowledge();
}

Заметьте, чего в обработчике нет: похода в платёжный сервис. Внутри listener'а — только своя база: проводка и строка в outbox, всё одной короткой локальной транзакцией. Запрос к провайдеру делает отдельный процессор, который читает outbox. Почему listener обязан быть быстрым, разобрано в Kafka shutdown.

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

Как именно вставлять — не мелочь. Наивный вариант «попробовали INSERT, поймали исключение уникальности, вернули false» в PostgreSQL не работает: нарушение ограничения обрывает всю транзакцию, и после него в ней уже ничего не выполнить — ни проводку, ни ack. Конфликт не должен быть ошибкой:

INSERT INTO processed_event (event_id, consumer_group)
VALUES (:eventId, :consumerGroup)
ON CONFLICT DO NOTHING

Метод возвращает true, если вставилась одна строка, и false, если ноль.

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

Идемпотентный ключ едет дальше — в строку outbox, а оттуда в заголовок запроса к провайдеру: тот тоже должен уметь отличить повтор от новой операции.

И отдельно про порядок, который смущает при первом чтении: ack.acknowledge() стоит внутри транзакционного метода, то есть вызывается до коммита. Это не опечатка. В режиме MANUAL подтверждение не фиксирует offset сразу — оно встаёт в очередь и уезжает к брокеру, когда контейнер добьёт пачку, а к тому моменту транзакция в базе уже закрыта. А вот с MANUAL_IMMEDIATE так писать нельзя: там коммит уходит прямо в момент вызова и может обогнать транзакцию.

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 для повторной попытки.

PENDING PUBLISHING PUBLISHED взяли пачку отправили вернули по сроку

Строка outbox идёт по трём статусам: relay захватывает пачку и переводит её в PUBLISHING, после успешной отправки ставит PUBLISHED, а то, что зависло в PUBLISHING, фоновый процесс возвращает обратно в PENDING.

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

Вариант без второй фазы — оставить relay простым, а дубли гасить у потребителей через processed_event. Цена — повторы в Kafka, то есть небольшие накладные расходы; выигрыш — relay без состояния PUBLISHING и фонового возврата. Для большинства сервисов этого достаточно.

Таблицы растут: срок жизни и уборка

Две таблицы, которые появились выше — ключи идемпотентности и обработанные события, — растут с каждой операцией и никогда не уменьшаются сами. Через год это самые большие таблицы в базе, и первым это заметит не диск, а замедлившаяся вставка.

Срок жизни определяется тем, как долго возможен повтор. Считают по самому долгому источнику: пользователь нажимает второй раз в пределах минут, библиотека повторяет минуты, брокер может доставить сообщение повторно через часы простоя потребителя, фоновая сверка приходит через сутки, а ключ у платёжного провайдера живёт сутки. Отсюда практические ориентиры: для обработанных событий — от нескольких дней до недели; для ключей идемпотентности платёжных операций — не меньше, чем у провайдера, то есть обычно те же сутки с запасом до недели; для внутренних вызовов между своими сервисами — часы.

Чистят по времени, пачками. Один большой DELETE создаст длинную транзакцию и кучу мёртвых строк:

DELETE FROM processed_events
WHERE id IN (
    SELECT id FROM processed_events
    WHERE processed_at < now() - interval '7 days'
    LIMIT 10000
);

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

После удаления защиты нет. Это главное, что нужно понимать про срок: повтор, пришедший позже, обработается как новая операция. Поэтому срок выбирают не «чтобы таблица не росла», а «чтобы накрыть все возможные повторы», и уже потом смотрят на размер. Если и то и другое не сходится, это признак, что ключ выбран неудачно: естественный ключ (уникальный индекс на самих данных) не требует срока вовсе, потому что уникальность гарантирует сама модель.

Индекс важнее размера. Даже большая таблица работает быстро, если проверка идёт по первичному ключу; медленно она начинает работать, когда проверяют по колонке без индекса или когда ключ — длинная строка без ограничения длины. И отдельно: для уборки нужен индекс по времени, иначе сама уборка станет самым тяжёлым запросом в системе.

Проверить, что всё в порядке, можно двумя запросами: размер таблиц (pg_total_relation_size) и возраст самой старой записи. Если самая старая запись старше заявленного срока, уборка не работает, и это стоит узнать до того, как таблица займёт полдиска.

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

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

// Опасно
@Retry(name = "payment")
public Receipt charge(Long orderId, Money amount) {
    return paymentClient.post(
        "/charge",
        new ChargeRequest(orderId, amount),
        Map.of(),                       // заголовков нет — повтор неотличим от новой операции
        Receipt.class
    );
}

Сценарий:

  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 от клиента; сохранять результат в базе при первом вызове, возвращать сохранённый при повторе. В таблице ключей хранят не только ключ: отпечаток тела (тот же ключ с другим телом — отказ 422, а не выполнение), код и тело ответа первой попытки, статус и время.
  • Kafka-обработчик: таблица processed_event с уникальным ограничением по (event_id, consumer_group) — вставка в той же транзакции, что и основное действие. Откат транзакции = откат обоих. Таблицы ключей и обработанных событий чистят по сроку, равному максимально возможному времени повтора, пачками или удалением секций; после удаления защиты нет.
  • Outbox-relay: двух-фазная публикация (PENDING → PUBLISHING → PUBLISHED) или защита на стороне потребителя через processed_event.
  • Ретраи + деньги: идемпотентный ключ создаётся один раз на бизнес-операцию и передаётся во все повторные попытки — иначе каждая попытка может создать новое списание.
  • Graceful shutdown даёт время на завершение, но не гарантирует его. Идемпотентность — защита на случай, когда завершить не успели.
  • Одновременный дубль разрешают вставкой IN_PROGRESS в отдельной транзакции: нарушение уникальности означает «кто-то уже начал», и второй получает 409; брошенные записи закрывают по сроку.
  • Ключ принадлежит попытке операции, а не запросу: браузер и мобильное приложение создают его при нажатии кнопки, а сервис для соседа выводит из идентификатора заказа или события.
  • Иногда ключ не нужен: PUT с идентификатором от клиента или уникальный индекс на самих данных (order_id плюс тип операции) делают повтор безопасным без отдельной таблицы.
  • У чужого ключа идемпотентности есть срок (обычно сутки): повторы укладывают в него, свой срок хранения делают не меньше, а надёжнее сначала спросить статус операции.

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