Skip to content

kmprograms/idempotency-key-pattern

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Idempotency Key - demo (Spring Boot 4 + MySQL)

Język: Polski · English

Produkcyjne demo wzorca Idempotency Key: bezpiecznie wątkowo, bez blokowania wątków, bez wywołań zewnętrznego API wewnątrz transakcji bazodanowej, na wątkach wirtualnych (Project Loom). Cały stos startuje jedną komendą docker compose up.

Więcej teorii: TEORIA.md.

Teoria: Idempotency Key

Problem

Klient wysyła żądanie „zaksięguj płatność 199,99 PLN". Sieć się zawiesza - klient nie dostaje odpowiedzi. Co robi? Ponawia to samo żądanie.

Bez zabezpieczenia serwer potraktuje drugie żądanie jako nową operację - druga płatność, podwójne obciążenie.

Rozwiązanie

Klient generuje unikalny identyfikator operacji (Idempotency Key) i wysyła go w nagłówku przy każdym żądaniu:

POST /api/v1/payments
Idempotency-Key: 7f3a9c2e-4b1d-4e8a-9f6c-1a2b3c4d5e6f
Content-Type: application/json

{"amount": 199.99, "currency": "PLN", "recipient": "ACME"}

Serwer zapamiętuje: „ten klucz = ten wynik". Kolejne żądanie z tym samym kluczem i tą samą treścią dostaje ten sam wynik, bez ponownego wykonania operacji.

Ważne: klucz generuje klient (np. UUID), nie serwer. Dzięki temu klient może bezpiecznie ponowić request po timeoucie, używając tego samego klucza.

Trzy reguły, które musisz znać

Reguła Co to znaczy
Klucz = operacja Jeden klucz na jedną logiczną operację biznesową (np. jedna płatność w koszyku).
Ten sam klucz + ta sama treść Idempotentny replay - zwracamy zapisany wynik (201, Idempotent-Replayed: true).
Ten sam klucz + inna treść Nadużycie klucza - odrzucamy (422). Klient nie może „podmienić" kwoty pod istniejącym kluczem.

Dlaczego nie wystarczy sam cache w pamięci?

  • Wiele instancji serwisu - każda ma własną pamięć; synchronizacja musi być w bazie danych.
  • Wyścig równoległy - dwa żądania z tym samym kluczem mogą trafić w ten sam moment; tylko jedno może „wygrać".
  • Trwałość - po restarcie serwisu replay nadal musi działać.

W tym projekcie synchronizacją zajmuje się PRIMARY KEY na kolumnie idempotency_key - baza gwarantuje, że tylko jeden INSERT się powiedzie.

Kluczowa zasada produkcyjna

Nigdy nie wołaj zewnętrznego API (operator płatności) wewnątrz transakcji bazodanowej.

Wolne HTTP (setki ms-sekundy) trzyma otwarte połączenie do bazy, wyczerpuje pulę HikariCP i blokuje inne żądania. Dlatego aplikacja dzieli pracę na 3 fazy o rozdzielonych transakcjach (opisane poniżej).

Architektura

Projekt składa się z dwóch niezależnych aplikacji Spring Boot (osobne pom.xml, osobne Dockerfile) orkiestrowanych przez Docker Compose:

idempotency-key-pattern/
├── payment-service/        # główny serwis (port 8080)
│   ├── src/main/java/com/app/
│   ├── src/main/resources/   schema.sql, data.sql, application.yaml
│   ├── pom.xml
│   └── Dockerfile
├── payment-gateway/        # atrapa operatora płatności (port 8081)
│   ├── src/main/java/com/app/
│   ├── pom.xml
│   └── Dockerfile
├── docker-compose.yml      # MySQL + oba serwisy
├── README.md
├── README.en.md
└── TEORIA.md

Serwisy

Serwis Port Rola
payment-service 8080 API płatności, idempotencja, JPA + MySQL
payment-gateway 8081 Wolny stub operatora (~800 ms), idempotentny per klucz
mysql 3306 Baza idempotency

Przepływ żądania

Klient
  │  POST + Idempotency-Key
  ▼
PaymentController                    ← presentation/
  │  ProcessPaymentCommand
  ▼
ProcessPaymentUseCaseImpl            ← infrastructure/usecase/  ← SEDNO (3 fazy)
  │
  ├── IdempotencyTransactionBoundary ← infrastructure/tx/
  │     └── IdempotencyPaymentService ← application/service/
  │
  └── PaymentGatewayPort             ← application/port/output/
        └── PaymentGatewayAdapter    ← infrastructure/gateway/  (RestClient → :8081)

Struktura pakietów (payment-service)

com.app
├── domain/                         # rdzeń - model i repozytoria
│   ├── model/                      Payment, IdempotencyLookup
│   ├── repository/                 PaymentRepository, IdempotencyRecordRepository
│   └── exception/                  wyjątki domenowe (409 / 422 / 502)
│
├── application/                    # logika biznesowa
│   ├── service/                    IdempotencyPaymentService
│   └── port/
│       ├── input/                  ProcessPaymentUseCase, Command, Result
│       └── output/                 PaymentGatewayPort
│
├── infrastructure/                 # świat zewnętrzny
│   ├── usecase/                    ProcessPaymentUseCaseImpl (3 fazy)
│   ├── tx/                         IdempotencyTransactionBoundary
│   ├── configuration/              BeanConfiguration, GatewayProperties
│   ├── gateway/                    adapter + DTO, RestClient do operatora
│   └── persistence/                entity, repository, adapter, mapper
│
└── presentation/                   REST API
    ├── controller/                 PaymentController
    ├── dto/                        request/response DTO
    └── exception/                  GlobalExceptionHandler

Drugi serwis - payment-gateway (port 8081) - to atrapa wolnego operatora (~800 ms), sama też idempotentna (pamięta wynik per Idempotency-Key w pamięci). Dzięki temu system jest idempotentny end-to-end.

Sedno: 3 fazy o rozdzielonych transakcjach

Implementacja w ProcessPaymentUseCaseImpl:

Faza Co robi Transakcja? I/O sieciowe?
1. REZERWACJA INSERT rekordu IN_PROGRESS TAK (krótka #1) nie
2. WYKONANIE wywołanie wolnego operatora NIE TAK (tu i tylko tu)
3. ZATWIERDZENIE zapis płatności + COMPLETED TAK (krótka #2) nie

Dlaczego tak:

  1. Brak API w transakcji. Wolne wywołanie operatora (faza 2) nigdy nie trzyma otwartej transakcji ani połączenia do bazy.
  2. Bezpieczeństwo wątkowe = baza. Synchronizację daje klucz główny na idempotency_key. Tylko jedno żądanie wygra INSERT; przegrani dostają błąd unikalności i są klasyfikowani. Działa też przy wielu instancjach.
  3. Nie blokujemy. Równoległe żądanie z tym samym kluczem dostaje natychmiast 409.
  4. Bezpieczne ponawianie. Gdy operator zawiedzie, zwalniamy rezerwację, więc klient może ponowić z tym samym kluczem.

Odpowiedzi HTTP

Sytuacja Status Nagłówek / uwaga
Pierwsze żądanie 201 Idempotent-Replayed: false
Powtórzenie (zakończone) 201 Idempotent-Replayed: true
Równoległe, w trakcie 409 nie blokujemy wątku
Ten sam klucz, inna treść 422 nadużycie klucza
Błąd operatora 502 rezerwacja zwolniona
Brak nagłówka / walidacja 400 obsługa Spring Boot

Uruchomienie

Wymagania: Docker + Docker Compose (do pełnego stosu). Do lokalnego buildu bez Dockera: JDK 25, Maven 3.9+.

docker compose up -d --build

Zatrzymanie i usunięcie wolumenu z danymi:

docker compose down -v

payment-service czeka na mysql z condition: service_healthy. Jeśli przy starcie pojawi się błąd połączenia z bazą (Connection refused), poczekaj chwilę i uruchom ponownie:

docker compose restart payment-service

Szybki test (seed z bazy)

W data.sql jest gotowy rekord seed-key-demo:

curl -i -X POST http://localhost:8080/api/v1/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: seed-key-demo" \
  -d '{"amount": 49.99, "currency": "PLN", "recipient": "Sklep Demo"}'

Oczekiwany wynik: 201, Idempotent-Replayed: true, ten sam paymentId przy każdym wywołaniu.

Scenariusze w Postmanie

Endpoint: POST http://localhost:8080/api/v1/payments

Nagłówki:

  • Content-Type: application/json
  • Idempotency-Key: <unikalny klucz - generuje klient>

Body:

{
  "amount": 199.99,
  "currency": "PLN",
  "recipient": "ACME Corp"
}
Krok Idempotency-Key Body Oczekiwany wynik
1. Pierwsza płatność test-001 jak wyżej 201, Idempotent-Replayed: false
2. Replay test-001 to samo 201, Idempotent-Replayed: true, ten sam paymentId
3. Nadużycie klucza test-001 inna kwota 422
4. Wyścig test-002 wyślij 2× szybko 201 + 409
5. Po 409 test-002 to samo, po ~2 s 201, replay z pierwszego paymentId

Używaj nowego klucza na każdą nową płatność. W produkcji zamiast test-001 stosuj UUID (crypto.randomUUID()).

Gdzie szukać w kodzie

Temat Plik
3 fazy idempotencji payment-service/.../ProcessPaymentUseCaseImpl.java
Granice transakcji payment-service/.../infrastructure/tx/IdempotencyTransactionBoundary.java
Synchronizacja wyścigu (PK) payment-service/src/main/resources/schema.sql
Rezerwacja klucza (INSERT) payment-service/.../IdempotencyRecordRepositoryAdapter.java
Persistable - wymuszenie INSERT payment-service/.../entity/IdempotencyRecordEntity.java
UUID jako VARCHAR(36) payment-service/.../entity/PaymentEntity.java
Hash treści żądania ProcessPaymentCommand.canonical() + DigestUtils.sha256Hex
Mapowanie błędów HTTP payment-service/.../GlobalExceptionHandler.java
Wolny operator payment-gateway/.../ChargeController.java
Virtual threads + RestClient payment-service/.../BeanConfiguration.java
Orkiestracja Docker docker-compose.yml

Stos technologiczny

Warstwa Technologie
Runtime Java 25, Spring Boot 4.1.0, wątki wirtualne (Project Loom)
Web spring-boot-starter-webmvc, spring-boot-starter-restclient, actuator
Persystencja MySQL 9.7, JPA/Hibernate 7, schema.sql + data.sql (bez Flyway)
Narzędzia Lombok, commons-codec, Docker / Docker Compose
Obrazy Docker maven:3.9-eclipse-temurin-25, eclipse-temurin:25-jre

About

Production Idempotency Key demo in Spring Boot 4 + Java 25. 3-phase flow, MySQL PK race handling, no external API inside DB transactions. Docker-ready.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors