Опирается на правила:
R-DIST-IDEM-1…R-DIST-IDEM-5иR-DIST-IDEM-X1…R-DIST-IDEM-X3из Distributed Patterns Style Guide → раздел 3. Idempotency.
Важно знать
- Распределённая система всегда at-least-once — сообщения могут дублироваться. Receiver обязан быть идемпотентным.
- Каждое cross-service сообщение имеет уникальный ID: Kafka —
eventId UUID v7, HTTP money —Idempotency-Keyheader, 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 event | eventId UUID v7 | producer генерирует |
| HTTP money command | Idempotency-Key header | client генерирует |
| Saga step | sagaId + stepName | orchestrator знает |
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));
}
}
tryMarkProcessed — INSERT ... 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 для money | R-DIST-IDEM-X1 | processed_event + Idempotency-Key |
| Только receiver-side dedup | R-DIST-IDEM-X2 | producer enable.idempotence: true + receiver dedup |
| Новый UUID при каждом retry | R-DIST-IDEM-X3 | один ключ на бизнес-операцию |
| TTL idempotency < 24h | R-DIST-IDEM-5 | 24-72 часа |
| TTL idempotency > 72h без причины | R-DIST-IDEM-5 | 24-72 часа + cleanup-job |
| Один слой защиты для money | R-DIST-IDEM-4 | client key + БД unique constraint |
| eventId без UUID v7 | R-DIST-IDEM-1 | UUID 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-эндпоинтов.