Опирается на правила:
R-SHUT-IDEM-1иR-SHUT-IDEM-X1из Graceful Shutdown Style Guide → раздел 7. Идемпотентность in-flight.
Важно знать
- Операции, которые SIGTERM может прервать, обязаны быть retry-safe.
- HTTP POST —
Idempotency-Keyобязателен (AUTH-19).- Kafka listener —
processed_event(event_id)в той же транзакции что side-effect.- Outbox-relay — либо двух-фаза
pending → publishing → published, либо processed_event-dedup на consumer.- Money с
@RetryбезIdempotency-Key— при SIGTERM в retry дважды списать.- Graceful shutdown даёт время на завершение, но не гарантирует отсутствие partial.
- Идемпотентность — последняя линия защиты, когда graceful не успел.
Graceful shutdown даёт операциям шанс завершиться корректно. Но shutdown — это deadline в 60 секунд. Долгий cascade (HTTP retry × 3 × 30s) может не уложиться, force-shutdown прерывает посередине. Если операция не идемпотентна — partial state → инцидент. UCP формулирует: каждая операция, которую graceful может прервать, обязана быть replay-safe.
Три типа in-flight операций
R-SHUT-IDEM-1: разные защиты для разных контекстов.
1. HTTP POST
Client → POST /payments (Idempotency-Key: abc)
Server: создаёт idempotency_record, обрабатывает
[SIGTERM посередине]
Server force-shutdown, response не отправлен
Client → retry: POST /payments (Idempotency-Key: abc)
Server (new pod): находит idempotency_record, возвращает сохранённый response
Дубля нет
Без Idempotency-Key — client retry создаёт второй платёж. Подробнее — Auth → idempotency.
2. Kafka listener
Listener: получил event_id=XYZ
Начал @Transactional:
- INSERT processed_event(event_id=XYZ)
- billing.charge(...) ← side-effect
- ack.acknowledge() (commit offset)
[SIGTERM посередине, transaction rollback]
Restart: Listener получил event_id=XYZ снова (offset не committed)
Начал @Transactional:
- INSERT processed_event(event_id=XYZ) — OK (rollback из предыдущего раза)
- billing.charge(...) ← возможен дубль если HTTP без Idempotency-Key
- ack
processed_event в той же транзакции — атомарно. Если что-то падает — обе записи откатываются, retry безопасен.
@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();
}
Двойная защита: processed_event + Idempotency-Key к payment-provider. Если processed_event пропустил (другой consumer-group), provider сам дедуплицирует.
3. Outbox-relay
Relay: SELECT * FROM outbox_event WHERE published_at IS NULL LIMIT 50
Для каждого:
kafkaTemplate.send(...).get() ← Kafka получил event
UPDATE outbox_event SET published_at=now() WHERE id=...
[SIGTERM посередине: send прошёл, UPDATE нет]
Restart: SELECT * FROM outbox_event WHERE published_at IS NULL LIMIT 50
Тот же event → второй send
Kafka получает дубль event_id=XYZ
Защита — двух-фаза:
ALTER TABLE outbox_event ADD COLUMN status text NOT NULL DEFAULT 'PENDING';
-- Phase 1: lock + mark PUBLISHING
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);
-- Phase 2: send
-- Phase 3: mark PUBLISHED
UPDATE outbox_event SET status = 'PUBLISHED', published_at = now() WHERE id = ...;
Если SIGTERM между Phase 1 и Phase 2 — PUBLISHING rows остаются locked. Cleanup job через час → возвращает в PENDING. Retry безопасен только если consumer dedupe-ит через processed_event на receiving side.
Альтернатива (проще) — receiver-side dedup всегда есть (см. Idempotent consumer). Тогда relay может публиковать дубли в Kafka, receiver игнорирует. Дубли в Kafka — небольшая overhead, но операционно проще.
Граничные случаи
R-SHUT-IDEM-1 (детали):
POST без Idempotency-Key
// КАТАСТРОФА — нет Idempotency-Key
@PostMapping("/payments")
public PaymentResponse charge(@RequestBody @Valid ChargeRequest req) {
return paymentService.charge(req);
}
При shutdown в момент charge:
- paymentService.charge() — connection to provider, deduct money.
- SIGTERM, force-shutdown.
- Client timeout → retry на новый pod.
- Charge снова → второй deduct.
Корректно — AUTH-19:
@PostMapping("/payments")
public PaymentResponse charge(
@RequestHeader("Idempotency-Key") String key,
@RequestBody @Valid ChargeRequest req
) {
return paymentService.charge(key, req);
}
Kafka listener offset committed, side-effect не завершён
При ack-mode: RECORD или MANUAL_IMMEDIATE offset commit может произойти до side-effect:
// Сценарий ошибки
@KafkaListener(...)
public void onEvent(SomeEvent event, Acknowledgment ack) {
sideEffect(event);
ack.acknowledge();
[SIGTERM здесь, ack committed, но side-effect внутри ещё не закончил async chain]
}
В UCP - использовать BATCH ack-mode + @Transactional с включённым processed_event. Если @Transactional не commit-нулась — offset тоже не commit-нулся.
Outbox-relay — Kafka send без УПДЕЙТ
См. секцию выше. Решение — receiver-side dedup или двух-фаза.
Что запрещено
Money без Idempotency-Key + @Retry
R-SHUT-IDEM-X1:
// КАТАСТРОФА
@CircuitBreaker(name = "payment")
@Retry(name = "payment")
public Receipt charge(Long orderId, Money amount) {
return paymentClient.post("/charge", new ChargeRequest(orderId, amount), Receipt.class);
}
Сценарий:
charge()вызван, первый attempt — отправил request, network timeout.- Retry — второй attempt, ещё один POST.
- На сервере: оба POST дошли (network timeout != не отправили).
- Без
Idempotency-Keypayment-provider обрабатывает оба → двойной deduct. - На pod SIGTERM посередине — то же самое, ещё хуже.
Корректно — Idempotency-Key обязателен:
@CircuitBreaker(name = "payment")
@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
);
}
idempotencyKey генерируется один раз на бизнес-операцию (например, event.eventId()), используется во всех retry.
Подробнее — Resilience → retry R-RES-RE-X1.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Money без Idempotency-Key с @Retry | R-SHUT-IDEM-X1 | header обязателен |
| Kafka listener offset commit до side-effect | R-SHUT-IDEM-1 | @Transactional с side-effect + processed_event |
| Outbox-relay без receiver dedup | R-SHUT-IDEM-1 | processed_event на consumer-side |
HTTP POST money без Idempotency-Key (AUTH-19) | R-SHUT-IDEM-1 | header обязателен |
| Non-transactional Kafka handler | R-SHUT-IDEM-1 | @Transactional обертка |
| Идемпотентность только на one сервисе | R-SHUT-IDEM-1 | end-to-end (client + server + downstream) |
Side-effect (HTTP) внутри @Transactional без try/catch | R-SHUT-IDEM-1 | outbox + worker |
Куда дальше
- Graceful Shutdown → раздел 7. Идемпотентность — нормативные формулировки.
- Auth → idempotency —
Idempotency-Keyдля money (AUTH-19). - Distributed → idempotency — общая теория at-least-once.
- Kafka → idempotent consumer —
processed_eventдетали. - Kafka → outbox publishing — двух-фаза, dedup.
- Resilience → retry — retry только при идемпотентности.
- Scheduled / Async / outbox — outbox-relay shutdown.