Payment-Systementwicklung: Idempotenz, Abgleich und Rückerstattungen
1. Überblick
Das Zahlungsproblem unterscheidet sich von typischen CRUD-Aufgaben in einem entscheidenden Punkt: Ein Fehler bedeutet echtes Geld verloren. Das Retry eines Clients kann dazu führen, dass einem Kunden das Geld zweimal abgezogen wird. Ein Payment Service Provider (PSP) kann mit Timeout antworten, obwohl die Transaktion bereits erfolgreich war. Zwei parallel laufende Rückerstattungsanfragen können einen Gesamtrückerstattungsbetrag führen, der über dem eingezogenen Betrag liegt.
Dieser Artikel durchläuft ein konkretes Design bestehend aus Anforderungen, Datenmodell, API und Verarbeitungsfluss. Anschließend werden drei schwierige Punkte gelöst: Idempotenz, Abgleich (Reconciliation) und Rückerstattung (Refund). Nach dem Lesen kannst du den Kern mit Spring Boot 3 und PostgreSQL selbst aufbauen, zusammen mit Tests, die beweisen, dass das System bei gleichzeitigen Anfragen nie über den Limit hinaus erstattet.
2. Anforderungen des Problems
2.1. Funktionale Anforderungen
- Ein Verkäufer erstellt eine Zahlung für eine
Order. Das System ruft das PSP auf, um Geld von einer Karte oder einem Wallet abzubuchen. - Der Verkäufer erstattet den gesamten Betrag oder teilweise. Die Gesamtrückerstattung darf den eingezogenen Betrag nicht überschreiten.
- Täglich gleicht das System interne Daten mit der Settlement-Datei des PSP ab.
- Das System gibt Ereignisse (
PaymentCaptured,RefundSucceeded…) an andere Services wie Order und Accounting aus.
2.2. Skalierung und Einschränkungen
- Im Durchschnitt 200 Transaktionen pro Sekunde. Spitzenwert beim Flash Sale etwa 5.000 Transaktionen pro Sekunde für einige Minuten.
- Geld darf nie zweimal für die gleiche Zahlungsabsicht abgezogen werden. Dies ist eine strikte Einschränkung.
- Konsistenz ist wichtiger als Verfügbarkeit: lieber einen Fehler an den Client zurückgeben, als die Geldsumme falsch zu erfassen.
- Das PSP ist ein externes System. Es kann langsam sein, Timeouts verursachen oder Webhooks verzögert oder dupliziert senden.
3. Datenmodell
Geld wird immer in der kleinsten Einheit gespeichert (bei VND ist das der Dong, bei USD der Cent) als BIGINT Typ. Verwende nicht double.
CREATE TABLE payments (
id UUID PRIMARY KEY,
merchant_id TEXT NOT NULL,
order_id TEXT NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
status TEXT NOT NULL, -- CREATED, PROCESSING, CAPTURED, FAILED, UNKNOWN
refunded_amount BIGINT NOT NULL DEFAULT 0,
psp_reference TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (refunded_amount <= amount)
);
CREATE TABLE refunds (
id UUID PRIMARY KEY,
payment_id UUID NOT NULL REFERENCES payments(id),
amount BIGINT NOT NULL CHECK (amount > 0),
status TEXT NOT NULL, -- PENDING, SUCCEEDED, FAILED
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE idempotency_keys (
merchant_id TEXT NOT NULL,
idem_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- IN_PROGRESS, COMPLETED
response_code INT,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (merchant_id, idem_key)
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
sent_at TIMESTAMPTZ
);
Einige wichtige Punkte:
CHECK (refunded_amount <= amount)ist das letzte Sicherheitsnetz auf Datenbankebene. Falls der Code einen Bug hat, blockiert die Datenbank ihn trotzdem.- Der Primärschlüssel
(merchant_id, idem_key)der Tabelleidempotency_keysist das Herzstück des Mechanismus zur Verhütung von Duplikaten. - Die
outbox-Tabelle hilft dabei, Ereignisse an Kafka zu senden, ohne dass eine verteilte Transaktion nötig ist (Abschnitt 6).
4. API-Design
POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body: { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201 { "id": "...", "status": "CAPTURED" }
POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body: { "amount": 100000 }
→ 202 { "refundId": "...", "status": "PENDING" }
GET /v1/payments/{id}
Jede API, die einen Status ändert, erfordert einen vom Client generierten Idempotency-Key. Der Client sendet denselben Schlüssel erneut bei Wiederholungen. Rückerstattungen geben 202 Accepted zurück, da das PSP Rückerstattungen asynchron verarbeitet.
5. Idempotenz: Schutz vor Doppelabzügen
5.1. Idee
Wenn eine Anfrage empfangen wird, schreiben wir den Schlüssel in die Datenbank, bevor wir etwas anderes tun. Es gibt drei Möglichkeiten:
- Der Schlüssel existiert noch nicht: Schreiben erfolgreich, fahre mit der Verarbeitung fort.
- Der Schlüssel ist bereits
COMPLETED: gib die zuvor gespeicherte Antwort zurück, ohne das PSP erneut aufzurufen. - Der Schlüssel ist gerade
IN_PROGRESS: Eine andere Anfrage mit dem gleichen Schlüssel wird gerade ausgeführt. Gib409 Conflictzurück, damit der Client später erneut versucht.
Zusätzlich speichern wir einen Hash des Request-Body. Falls der Client den Schlüssel für einen anderen Body wiederverwendet (z.B. einen anderen Betrag), ist das ein Client-Fehler, und wir geben 422 zurück.
5.2. Implementierung
Abhängigkeiten, die benötigt werden:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
@Service
public class IdempotencyService {
private final JdbcTemplate jdbc;
public IdempotencyService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public record StoredResponse(int code, String body) {}
public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
int inserted = jdbc.update("""
INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
VALUES (?, ?, ?, 'IN_PROGRESS')
ON CONFLICT (merchant_id, idem_key) DO NOTHING
""", merchantId, key, requestHash);
if (inserted == 1) {
return Optional.empty(); // neue Anfrage, kann verarbeitet werden
}
Map<String, Object> row = jdbc.queryForMap("""
SELECT request_hash, status, response_code, response_body
FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
""", merchantId, key);
if (!requestHash.equals(row.get("request_hash"))) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Schlüssel mit anderem Body wiederverwendet");
}
if ("IN_PROGRESS".equals(row.get("status"))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Anfrage wird gerade verarbeitet");
}
return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
}
public void complete(String merchantId, String key, int code, String body) {
jdbc.update("""
UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
WHERE merchant_id = ? AND idem_key = ?
""", code, body, merchantId, key);
}
}
ON CONFLICT DO NOTHING macht die Aktion “prüfen dann schreiben” zu einer atomaren Anweisung. Wenn zwei Anfragen mit dem gleichen Schlüssel gleichzeitig ankommen, hat nur eine Anfrage inserted == 1. Die andere Anfrage fällt in den Lesezweig und erhält 409. Es gibt keine Race Condition der Art “beide sehen gleichzeitig, dass der Schlüssel noch nicht existiert”.
Hinweis: Verwende nicht Redis
SETNXallein als Quelle der Wahrheit für die Idempotenz von Zahlungen. Bei Redis-Failover oder wenn ein Key evicted wird, verlierst du die Aufzeichnung, und das Geld wird zweimal abgezogen. Redis sollte nur als vordere Cache-Schicht dienen, während die Datenbank der Schiedsrichter ist.
5.3. Idempotenz auch auf PSP-Seite
Deine Anfrage ist geschützt, aber der Aufruf von dir zum PSP kann auch wiederholt werden. Die meisten PSPs unterstützen ihren eigenen Idempotency-Key. Übergib die payments.id selbst als Schlüssel:
PspResult result = pspClient.charge(new ChargeRequest(
payment.id().toString(), // Idempotency-Key zum PSP senden
payment.amount(),
payment.currency(),
sourceToken));
Wenn der Aufruf zum PSP einen Timeout hat, weißt du nicht, ob Geld bereits abgezogen wurde oder nicht. Markiere es nicht als FAILED. Setze den Status auf UNKNOWN, und lass dann einen Job das PSP mit payments.id erneut abfragen. Die Markierung als FAILED und dem Client einen erneuten Versuch mit einem neuen Schlüssel zu geben, ist der klassische Weg zu Doppelabzügen.
6. Konsistenz zwischen Datenbank und Kafka: Transactional Outbox
Nach dem Einzug des Geldes müssen wir payments aktualisieren und das Ereignis PaymentCaptured ausgeben. Wenn die Datenbankschreibvorgänge erfolgreich sind, aber das Senden an Kafka fehlschlägt (oder umgekehrt), gerät der Order-Service aus der Synchronisation. Die Lösung besteht darin, das Ereignis in die outbox-Tabelle innerhalb derselben Transaktion zu schreiben:
@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
jdbc.update("UPDATE payments SET status = 'CAPTURED', psp_reference = ? WHERE id = ? AND status IN ('PROCESSING','UNKNOWN')",
pspReference, paymentId);
jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
"""
{"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}
Ein Relay (periodischer Job oder Debezium CDC) liest die Zeilen sent_at IS NULL, sendet sie an Kafka und aktualisiert dann sent_at. Das Relay kann Duplikate senden, daher muss der Consumer idempotent sein, z.B. durch Speicherung der bereits verarbeiteten paymentId. Die status IN (...) Bedingung in der UPDATE-Anweisung verhindert, dass duplizierte Webhooks vom PSP einen anderen Endzustand überschreiben.
7. Rückerstattungen und gleichzeitige Anfragen
7.1. Problem
Zahlung 100.000đ. Ein Kundendienstmitarbeiter und ein automatischer Job senden gleichzeitig eine Rückerstattungsanfrage von 60.000đ. Wenn der Code “lese refunded_amount, überprüfe dann schreibe” folgt, sehen beide 0 + 60.000 <= 100.000 und werden beide genehmigt. Das Ergebnis ist, dass du 120.000đ erstattest.
7.2. Lösung: Conditional Update
Kombiniere die Überprüfung und das Update in eine einzelne UPDATE-Anweisung mit Bedingung:
@Service
public class RefundService {
private final JdbcTemplate jdbc;
public RefundService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public UUID requestRefund(UUID paymentId, long amount) {
int reserved = jdbc.update("""
UPDATE payments SET refunded_amount = refunded_amount + ?
WHERE id = ? AND status = 'CAPTURED' AND refunded_amount + ? <= amount
""", amount, paymentId, amount);
if (reserved == 0) {
throw new IllegalStateException("Rückerstattung übersteigt erstattungsfähigen Betrag oder Zahlung nicht erfasst");
}
UUID refundId = UUID.randomUUID();
jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDING')",
refundId, paymentId, amount);
jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
"""
{"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
return refundId;
}
}
PostgreSQL sperrt die Zeile payments während des UPDATE. Die zweite Transaktion muss auf die erste warten, die committed. Danach wertet es die WHERE-Bedingung mit den neuen Daten erneut aus, sieht 60.000 + 60.000 > 100.000 und erhält reserved == 0. Dies ist das Standardverhalten auf READ COMMITTED-Ebene und erfordert nicht SERIALIZABLE.
Wir “reservieren” den Betrag zuerst, dann rufen wir das PSP asynchron über die Outbox auf. Wenn das PSP die Rückerstattung ablehnt, gibt der Consumer den reservierten Teil zurück:
UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds SET status = 'FAILED' WHERE id = :refundId AND status = 'PENDING';
Diese beiden Anweisungen müssen in derselben Transaktion sein. Die status = 'PENDING' Bedingung stellt sicher, dass ein duplizierter fehlgeschlagener Webhook nicht den reservierten Betrag zweimal zurückgibt. Um noch sicherer zu sein, führst du die erste Anweisung nur aus, wenn die zweite Anweisung genau eine Zeile aktualisiert hat.
7.3. Test für gleichzeitige Anfragen
Test-Abhängigkeiten:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired RefundService refundService;
@Autowired JdbcTemplate jdbc;
@Test
void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
UUID paymentId = UUID.randomUUID();
jdbc.update("""
INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURED')
""", paymentId);
Callable<Boolean> refund60k = () -> {
try {
refundService.requestRefund(paymentId, 60_000);
return true;
} catch (IllegalStateException e) {
return false;
}
};
List<Boolean> results;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
results = executor.invokeAll(List.of(refund60k, refund60k))
.stream().map(Future::resultNow).toList();
}
assertThat(results).containsExactlyInAnyOrder(true, false);
Long refunded = jdbc.queryForObject(
"SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
assertThat(refunded).isEqualTo(60_000L);
}
}
Der Test geht davon aus, dass das Schema aus Abschnitt 3 über src/test/resources/schema.sql (zusammen mit spring.sql.init.mode=always) oder über Flyway geladen wird. Zwei Virtual Threads senden gleichzeitig eine Rückerstattung. Das erwartete Ergebnis ist immer konsistent:
results = [true, false] (Reihenfolge beliebig)
refunded_amount = 60000
Wenn du die UPDATE-Anweisung durch Lesen und Vergleichen in Java ersetzt, wirst du sehen, dass der Test manchmal fehlschlägt. Das ist genau die Race Condition, die dieses Design beseitigt.
8. Abgleich (Reconciliation)
Egal wie vorsichtig du bist, interne Daten und das PSP können sich unterscheiden. Webhooks können verloren gehen, es können noch Transaktionen mit dem Status UNKNOWN ausstehen, oder das PSP kann aufgrund von Chargebacks Geld zurückgeben. Abgleich ist die letzte Verteidigungslinie.
Täglicher Ablauf:
- Lade die Settlement-Datei des PSP und lade sie in die temporäre Tabelle
psp_settlements(psp_reference, amount, type). - Gleiche mit internen Daten ab, indem du
FULL OUTER JOINverwendest. - Schreibe Unterschiede in die Tabelle
recon_breaks, um sie automatisch oder manuell zu bearbeiten.
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
p.amount,
s.amount,
CASE
WHEN p.psp_reference IS NULL THEN 'MISSING_INTERNAL'
WHEN s.psp_reference IS NULL THEN 'MISSING_AT_PSP'
ELSE 'AMOUNT_MISMATCH'
END
FROM (SELECT * FROM payments
WHERE status = 'CAPTURED' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
OR s.psp_reference IS NULL
OR p.amount <> s.amount;
Bedeutung der verschiedenen Unterschiede:
MISSING_INTERNAL: Das PSP hat Geld eingezogen, aber du hast keinenCAPTUREDEintrag. Dies ist normalerweise eine Transaktion mit StatusUNKNOWNoder ein verlorenes Webhook. Behebe dies durch Aktualisierung des Status oder durch Rückerstattung, wenn die Bestellung storniert wurde.MISSING_AT_PSP: Du hast registriert, dass Geld eingezogen wurde, aber das PSP hat keinen Eintrag. Dies ist ein kritischer Fall, da du möglicherweise Waren geliefert hast, ohne das Geld zu erhalten.AMOUNT_MISMATCH: Normalerweise aufgrund von Gebühren, Wechselkurs oder Rundungsfehlern.
Hinweis: Achte auf das Zeitfenster. Eine Transaktion um 23:59 Uhr könnte in der Settlement-Datei des nächsten Tages enthalten sein. Führe den Abgleich mit einer Verzögerung von T+1 oder T+2 durch, und pushe einen Unterschied nur an einen Menschen, wenn er über zwei aufeinanderfolgende Läufe hinweg bestehen bleibt.
9. Spitzenlast
Bei einem Flash Sale steigt die Last um das 25-Fache. Einige Techniken helfen dem System, dies zu ertragen:
- Rate Limiting pro Verkäufer mit Redis (
INCR+EXPIREmit einem Ein-Sekunden-Fenster). Dies schützt das System vor einem “lauten” Verkäufer. Hier wird Redis nur zur Begrenzung verwendet, nicht zur Entscheidung über die Richtigkeit von Transaktionen. - Trennung der synchronen und asynchronen Teile. Der synchrone Pfad besteht nur aus dem Schreiben des Idempotency-Keys, dem Erstellen der Zahlung und dem Aufrufen der PSP-Autorisierung. Rückerstattungen, E-Mail-Versand und Accountingaktualisierungen laufen alle über Outbox → Kafka. Partitioniere Kafka nach
paymentId, um die Ereignisreihenfolge pro Zahlung zu bewahren. - Circuit Breaker und kurze Timeouts beim Aufrufen des PSP (z.B. Resilience4j). Wenn das PSP langsam ist, schlagen wir schnell fehl und geben
503mitRetry-Afterzurück, anstatt den Thread Pool zu erschöpfen. Client-Wiederholungen mit dem gleichenIdempotency-Keysind sicher dank Abschnitt 5. - Regelmäßige Bereinigung der
idempotency_keys-Tabelle (behalte 24–72 Stunden) und partitioniere nach Tag, um zu verhindern, dass die Tabelle aufquillt undINSERT-Operationen verlangsamt.
10. Häufige Fehler
- Verwende
double/floatfür Geld. Verwendelongin der kleinsten Einheit oderBigDecimalwenn Wechselkurse nötig sind. - Behandle Timeout als Fehler. Timeout bedeutet unbekannt, also musst du
UNKNOWNsetzen und das PSP später erneut abfragen. - Generiere Idempotency-Keys auf der Serverseite. Der Schlüssel muss vom Client generiert werden und über Wiederholungen hinweg erhalten bleiben.
- Überprüfe das Rückerstattungsguthaben in Java und dann
UPDATE. Lass die Datenbank dies in einer einzigen Anweisung tun. - Sende Kafka innerhalb von
@Transactionalund erwartest, dass es zusammen mit der Datenbank rollback wird. Das wird es nicht. Verwende die Outbox. - Ignoriere Abgleich weil “der Code ist korrekt”. Externe Systeme liegen außerhalb deiner Kontrolle.
11. Fazit
Dieser Artikel hat den Kern eines Zahlungssystems basierend auf drei Prinzipien gestaltet. Erstens wird die Idempotenz durch eindeutige Constraints in der Datenbank gewährleistet, sowohl in deiner API als auch beim Aufrufen des PSP. Zweitens werden Konflikte bei Rückerstattungen durch bedingte Updates zusammen mit CHECK-Constraints gelöst, mit Tests, die dies gleichzeitig demonstrieren. Drittens wird die Konsistenz mit der Außenwelt durch transaktionale Outbox und tägliche Abstimmung erreicht.
Der gemeinsame Punkt aller drei ist, wichtige Invarianten auf die Datenbankebene zu verlagern, wo sie atomar erzwungen werden. Gleichzeitig solltest du immer davon ausgehen, dass Netzwerk, PSP und dein eigener Code fehlerhaft sein können, und stelle Mechanismen zur Erkennung und Behebung von Fehlern bereit.