Опирается на правила: R-DIST-IDEM-1R-DIST-IDEM-5 и R-DIST-IDEM-X1R-DIST-IDEM-X3 из Distributed Patterns Style Guide → раздел 3. Idempotency.

Важно знать

  • Распределённая система всегда at-least-once — сообщения могут дублироваться. Receiver обязан быть идемпотентным.
  • Каждое cross-service сообщение имеет уникальный ID: Kafka — eventId UUID v7, HTTP money — Idempotency-Key header, saga step — sagaId + stepName.
  • Receiver хранит processed-events в таблице processed_event и проверяет перед обработкой в той же транзакции.
  • HTTP-команды — таблица idempotency_record (key, command_hash, response): повтор возвращает сохранённый response, конфликт ключа с другой командой → 409 Conflict.
  • Money — двойная защита: client Idempotency-Key + внутренний unique constraint (payment_provider_id, external_payment_id).
  • TTL 24-72 часа для idempotency-records: меньше — реальный retry клиента не пройдёт; больше — таблица растёт.
  • Producer тоже обязан иметь exactly-once гарантии (enable.idempotence: true для Kafka), receiver-side dedup один — недостаточно.

В распределённой системе нет «доставлено ровно один раз». Сеть теряет ACK, retry повторяет, broker дублирует при rebalance. Единственный способ выжить — каждый получатель проверяет, не обработал ли он это сообщение, и при повторе возвращает тот же результат, что в первый раз.

Уникальный ID на каждое сообщение

R-DIST-IDEM-1: каждое cross-service сообщение обязано иметь уникальный ID.

ТранспортIDИсточник
Kafka eventeventId UUID v7producer генерирует
HTTP money commandIdempotency-Key headerclient генерирует
Saga stepsagaId + stepNameorchestrator знает

UUID v7 включает timestamp в первых 48 битах, что даёт монотонно растущие ID — хороший индекс в PG (sequential inserts, низкая фрагментация B-tree), плюс легко сортировать по времени создания.

@Builder
public record OrderCreatedEvent(
    UUID eventId,           // UUID v7 — для dedup
    UUID sagaId,            // сквозной для саги
    String eventType,       // "OrderCreated.v1"
    Long orderId,
    Long customerId,
    BigDecimal amount,
    Instant occurredAt
) {}

Processed-events для Kafka consumer

R-DIST-IDEM-2: receiver хранит обработанные eventId в БД и проверяет перед обработкой. Это решает проблему «Kafka rebalance → consumer получает то же сообщение снова».

CREATE TABLE processed_event (
    event_id      uuid PRIMARY KEY,
    consumer_name text NOT NULL,
    processed_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_processed_event_processed_at ON processed_event(processed_at);
@Component
@RequiredArgsConstructor
public class OrderCreatedListener {

    private final ProcessedEventRepository processedEventRepository;
    private final OrderProjectionRepository projectionRepository;

    @KafkaListener(topics = "order.events", groupId = "order-projection")
    @Transactional
    public void onOrderCreated(OrderCreatedEvent event) {
        if (!processedEventRepository.tryMarkProcessed(event.eventId(), "order-projection")) {
            return;
        }
        projectionRepository.upsert(toProjection(event));
    }
}

tryMarkProcessedINSERT ... ON CONFLICT DO NOTHING → возвращает true, если вставка произошла, иначе false. Проверка и запись в одной транзакции с бизнес-обновлением: либо всё закоммитилось, либо ничего.

Idempotency-Key для HTTP-команд

R-DIST-IDEM-3: для HTTP-команд (особенно money) receiver хранит (idempotency_key, command_hash, response) тройку.

CREATE TABLE idempotency_record (
    idempotency_key  text PRIMARY KEY,
    command_hash     text NOT NULL,
    response         jsonb NOT NULL,
    http_status      int NOT NULL,
    created_at       timestamptz NOT NULL DEFAULT now()
);

Логика receiver-а:

@RestController
@RequiredArgsConstructor
public class PaymentController {

    private final IdempotencyRecordRepository idempotencyRepository;
    private final UseCaseDispatcher dispatcher;

    @PostMapping("/payments")
    public ResponseEntity<PaymentResponse> charge(
        @RequestHeader("Idempotency-Key") String key,
        @RequestBody @Valid ChargeRequest request
    ) {
        var commandHash = HashUtil.sha256(request);

        var existing = idempotencyRepository.findByKey(key);
        if (existing.isPresent()) {
            var record = existing.get();
            if (!record.commandHash().equals(commandHash)) {
                throw new IdempotencyConflictException(key);
            }
            return ResponseEntity.status(record.httpStatus())
                .body(record.response(PaymentResponse.class));
        }

        var result = dispatcher.dispatch(new ChargePaymentCommand(key, request));
        var response = PaymentResponse.from(result);
        idempotencyRepository.save(key, commandHash, response, 201);
        return ResponseEntity.status(201).body(response);
    }
}

Три случая:

  • Ключ не встречался — обрабатываем команду, сохраняем результат, возвращаем.
  • Ключ встречался + та же команда — возвращаем сохранённый response (тот же HTTP status). Клиент не повторно списан.
  • Ключ встречался + другая команда409 Conflict. Клиент пытается переиспользовать ключ для другого, это ошибка.

Двойная защита для money

R-DIST-IDEM-4: money-операции защищаются дважды: client-side Idempotency-Key + внутренний unique constraint в БД.

CREATE TABLE payment (
    id                    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_provider      text NOT NULL,
    external_payment_id   text NOT NULL,
    amount                numeric(19,4) NOT NULL,
    status                text NOT NULL,
    created_at            timestamptz NOT NULL DEFAULT now(),
    UNIQUE (payment_provider, external_payment_id)
);

Если Idempotency-Key пропустил retry (например, разные client retries использовали разные ключи) — unique constraint на (payment_provider, external_payment_id) ловит дубль на уровне БД и возвращает ConstraintViolationException, который handler конвертирует в существующий payment.

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

TTL для idempotency-records

R-DIST-IDEM-5: idempotency-records держим 24-72 часа.

  • Меньше 24 часов — реальный retry клиента (через час, через сутки после network outage) не проходит дедупликацию → списали дважды.
  • Больше 72 часов — таблица растёт без пользы, нагрузка на autovacuum, размер индекса.

Cleanup — отдельным @Scheduled job-ом:

@Component
@RequiredArgsConstructor
public class IdempotencyRecordCleanup {

    private final IdempotencyRecordRepository repository;

    @Scheduled(cron = "0 0 3 * * *", zone = "UTC")
    public void cleanup() {
        var cutoff = Instant.now().minus(72, ChronoUnit.HOURS);
        repository.deleteOlderThan(cutoff);
    }
}

Cleanup-job обычно гоняется ночью; держать TTL как DELETE triggered-by-write-job — лишний overhead на каждую запись.

Что запрещено

Receiver без dedup для money

R-DIST-IDEM-X1: «обычно дублируется редко» — рассуждение, после которого инцидент с дважды списанными деньгами расследуют неделями. Любая money-операция обязана иметь и idempotency-record, и внутренний unique constraint.

Полагаться только на receiver-side

R-DIST-IDEM-X2: receiver dedup решает retry, но producer тоже должен быть exactly-once на своей стороне:

spring:
  kafka:
    producer:
      properties:
        enable.idempotence: true        # R-KFK-PROD-1
        max.in.flight.requests.per.connection: 5
        retries: 2147483647
      acks: all

Без enable.idempotence Kafka producer может опубликовать одно и то же сообщение дважды при partial failure → у receiver-а два разных eventId для одной бизнес-операции → dedup по eventId не работает.

Idempotency-Key = новый UUID каждый раз

R-DIST-IDEM-X3: клиент генерирует ключ один раз на бизнес-операцию и retry-ит с тем же ключом. Если клиент при каждом retry генерирует новый UUID — receiver видит два разных ключа, обрабатывает дважды, списывает дважды.

// ПЛОХО — новый ключ при каждом retry
for (int i = 0; i < 3; i++) {
    var key = UUID.randomUUID().toString();
    try {
        client.charge(key, request);
        break;
    } catch (IOException e) { Thread.sleep(1000); }
}

// ХОРОШО — один ключ на бизнес-операцию
var key = UUID.randomUUID().toString();
for (int i = 0; i < 3; i++) {
    try {
        client.charge(key, request);
        break;
    } catch (IOException e) { Thread.sleep(1000); }
}

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Receiver без dedup для moneyR-DIST-IDEM-X1processed_event + Idempotency-Key
Только receiver-side dedupR-DIST-IDEM-X2producer enable.idempotence: true + receiver dedup
Новый UUID при каждом retryR-DIST-IDEM-X3один ключ на бизнес-операцию
TTL idempotency < 24hR-DIST-IDEM-524-72 часа
TTL idempotency > 72h без причиныR-DIST-IDEM-524-72 часа + cleanup-job
Один слой защиты для moneyR-DIST-IDEM-4client key + БД unique constraint
eventId без UUID v7R-DIST-IDEM-1UUID v7 для time-sortable insert

Куда дальше

  • Distributed Patterns → раздел 3. Idempotency — нормативные формулировки.
  • Saga — каждый шаг саги обязан быть идемпотентным.
  • Compensation — compensation тоже идемпотентен.
  • Outbox + Inbox — outbox решает producer-side гарантии.
  • Kafka → idempotent consumer — детали processed_event для Kafka.
  • Resilience → retry — retry только при идемпотентности.
  • Auth → AUTH-19 — Idempotency-Key для money-эндпоинтов.