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 Tabelle idempotency_keys ist 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:

  1. Der Schlüssel existiert noch nicht: Schreiben erfolgreich, fahre mit der Verarbeitung fort.
  2. Der Schlüssel ist bereits COMPLETED: gib die zuvor gespeicherte Antwort zurück, ohne das PSP erneut aufzurufen.
  3. Der Schlüssel ist gerade IN_PROGRESS: Eine andere Anfrage mit dem gleichen Schlüssel wird gerade ausgeführt. Gib 409 Conflict zurü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 SETNX allein 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:

  1. Lade die Settlement-Datei des PSP und lade sie in die temporäre Tabelle psp_settlements(psp_reference, amount, type).
  2. Gleiche mit internen Daten ab, indem du FULL OUTER JOIN verwendest.
  3. 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 keinen CAPTURED Eintrag. Dies ist normalerweise eine Transaktion mit Status UNKNOWN oder 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 + EXPIRE mit 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 503 mit Retry-After zurück, anstatt den Thread Pool zu erschöpfen. Client-Wiederholungen mit dem gleichen Idempotency-Key sind 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 und INSERT-Operationen verlangsamt.

10. Häufige Fehler

  • Verwende double/float für Geld. Verwende long in der kleinsten Einheit oder BigDecimal wenn Wechselkurse nötig sind.
  • Behandle Timeout als Fehler. Timeout bedeutet unbekannt, also musst du UNKNOWN setzen 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 @Transactional und 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.

system-designpaymentidempotencyspring-bootpostgresql