E-Wallet-Design: Sichere Geldtransfers mit doppelter Buchhaltung

1. Übersicht

Geld zwischen zwei Geldbörsen zu transferieren klingt einfach: hier abziehen, dort hinzufügen. Doch bei Tausenden gleichzeitigen Anfragen, Client-Retries, instabilen Netzwerken und Audit-Anforderungen wird die Ansatz „einfach die balance-Spalte updaten” schnell zu fehlerhaften Salden führen.

Dieser Artikel führt dich durch die Gestaltung einer elektronischen Geldbörse basierend auf einem Double-Entry-Ledger (doppelten Buchungssystem). Das Design umfasst Anforderungen, Datenmodell, API, Verarbeitungsfluss und dann die schwierigen Punkte: Lock-Konflikte, Konsistenz mit anderen Systemen und Burst-Loads. Nach dem Lesen kannst du einen Transfer-Service mit Spring Boot 3 und PostgreSQL implementieren. Dieser Service verliert kein Geld, belastet nie zweimal und ist abrechenbar.

2. Anforderungen

2.1. Funktionalität

  • Jeder Benutzer hat eine oder mehrere Geldbörsen nach Währungstyp (VND).
  • Geld aufladen, abheben und zwischen zwei Geldbörsen überweisen.
  • Saldo und Transaktionsverlauf anzeigen.
  • Eine Transaktion wird niemals zweimal gebucht, selbst wenn der Client die Anfrage erneut sendet.

2.2. Skalierbarkeit und nicht-funktionale Anforderungen

  • 10 Millionen Geldbörsen, durchschnittlich 500 Transaktionen/Sekunde. Peak 5.000 Transaktionen/Sekunde bei Flash-Sales oder am Gehalt-Tag.
  • P99-Latenz unter 300 ms für Transfer-API.
  • Saldo muss stark konsistent sein. Alten Saldo zu lesen um zu entscheiden, ob ein Transfer erlaubt ist, ist nicht akzeptabel.

2.3. Beschränkungen

  • Niemals einen negativen Saldo erlauben, außer System-Konten, die negative Salden haben dürfen.
  • Jede Saldenänderung muss auf einen Ledger-Eintrag zurückführbar sein. Keine Löschungen, keine Änderungen. Um Fehler zu korrigieren, buche eine Gegenbuchung.
  • Geld als Ganzzahl in der kleinsten Einheit speichern (Centimes). Nie double verwenden.

3. Was ist Double-Entry-Ledger?

Jede Transaktion besteht aus mindestens zwei Ledger-Einträgen (entries). Die Summe aller Einträge einer Transaktion ist immer gleich null. Die Überweisung von 100.000đ von An zu Binh erzeugt zwei Einträge:

transaction T1
  account An    : -100000
  account Binh  : +100000
  ----------------------
  sum           :       0

Aufladen folgt der gleichen Regel. Das Geld „kommt von irgendwoher” – ein System-Konto, z.B. BANK_CLEARING, das negative Salden haben darf. Dadurch haben wir eine sehr starke Invariante: die Summe aller Ledger-Einträge im System ist immer null. Wenn die Summe nicht null ist, gibt es definitiv einen Bug.

4. Datenmodell

CREATE TABLE account (
    id              BIGINT PRIMARY KEY,
    owner_id        BIGINT      NOT NULL,
    currency        CHAR(3)     NOT NULL,
    balance         BIGINT      NOT NULL DEFAULT 0,
    allow_negative  BOOLEAN     NOT NULL DEFAULT FALSE,
    CHECK (allow_negative OR balance >= 0)
);

CREATE TABLE journal_transaction (
    id               UUID PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    type             VARCHAR(32) NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entry (
    id              BIGSERIAL PRIMARY KEY,
    transaction_id  UUID   NOT NULL REFERENCES journal_transaction(id),
    account_id      BIGINT NOT NULL REFERENCES account(id),
    amount          BIGINT NOT NULL,
    balance_after   BIGINT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_entry_account ON ledger_entry(account_id, id);

CREATE TABLE outbox_event (
    id          BIGSERIAL PRIMARY KEY,
    topic       VARCHAR(64) NOT NULL,
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT FALSE
);

Die obigen Tabellen haben folgende Rollen:

  • ledger_entry ist die Single Source of Truth. Die Tabelle ist nur append-only. Negative amount bedeutet Belastung, positive bedeutet Gutschrift.
  • account.balance ist ein Cache der Summe der Ledger-Einträge. Wir halten diese Spalte, um Salden schnell zu prüfen, ohne Millionen von Zeilen zu summieren.
  • Die CHECK-Bedingung ist das letzte Sicherheitsnetz. Selbst wenn der Code einen Bug hat, lehnt die Datenbank negative Salden ab.
  • idempotency_key UNIQUE verhindert doppelte Transaktionsbuchungen.
  • outbox_event wird verwendet, um Events an Kafka zu senden (Abschnitt 7.2).

5. API

POST /v1/transfers
Idempotency-Key: 6f1c2a9e-...-client-generated
Content-Type: application/json

{ "fromAccountId": 1001, "toAccountId": 2002, "amount": 100000 }
201 Created
{ "transactionId": "a3f0...", "status": "COMPLETED" }

Der Client generiert selbst den Idempotency-Key für jede Transfer-Absicht und verwendet denselben Schlüssel bei Wiederholungen erneut. Der Server muss das gleiche Ergebnis für denselben Schlüssel zurückgeben.

Der Controller ist sehr dünn:

public record TransferRequest(@NotNull Long fromAccountId,
                              @NotNull Long toAccountId,
                              @Positive long amount) {}

public record TransferResult(UUID transactionId, String status) {}

@RestController
@RequestMapping("/v1/transfers")
class TransferController {
    private final TransferService service;

    TransferController(TransferService service) { this.service = service; }

    @PostMapping
    ResponseEntity<TransferResult> create(@RequestHeader("Idempotency-Key") String key,
                                          @Valid @RequestBody TransferRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(service.transfer(key, req));
    }
}

6. Verarbeitungsfluss für Geldtransfer

Wir verwenden spring-boot-starter-jdbc, um jedes SQL-Statement klar zu sehen:

<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>

Der gesamte Fluss läuft in einer Datenbanktransaktion ab:

@Service
public class TransferService {
    private final JdbcTemplate jdbc;

    public TransferService(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @Transactional
    public TransferResult transfer(String key, TransferRequest req) {
        if (req.fromAccountId().equals(req.toAccountId())) {
            throw new IllegalArgumentException("Kann nicht an sich selbst übertragen");
        }
        UUID txId = UUID.randomUUID();
        int inserted = jdbc.update("""
            INSERT INTO journal_transaction(id, idempotency_key, type)
            VALUES (?, ?, 'TRANSFER') ON CONFLICT (idempotency_key) DO NOTHING
            """, txId, key);
        if (inserted == 0) {                                        // (1)
            UUID existing = jdbc.queryForObject(
                "SELECT id FROM journal_transaction WHERE idempotency_key = ?", UUID.class, key);
            return new TransferResult(existing, "COMPLETED");
        }

        long fromBalance = lockAndGet(req.fromAccountId(), req.toAccountId()); // (2)
        if (fromBalance < req.amount()) {
            throw new InsufficientFundsException(req.fromAccountId());          // (3)
        }
        post(txId, req.fromAccountId(), -req.amount());                         // (4)
        post(txId, req.toAccountId(), req.amount());

        jdbc.update("INSERT INTO outbox_event(topic, payload) VALUES ('wallet.transfers', ?::jsonb)",
            """
            {"transactionId":"%s","from":%d,"to":%d,"amount":%d}
            """.formatted(txId, req.fromAccountId(), req.toAccountId(), req.amount()));
        return new TransferResult(txId, "COMPLETED");
    }

    private long lockAndGet(long fromId, long toId) {
        long fromBalance = 0;
        for (long id : new long[]{Math.min(fromId, toId), Math.max(fromId, toId)}) {
            long balance = jdbc.queryForObject(
                "SELECT balance FROM account WHERE id = ? FOR UPDATE", Long.class, id);
            if (id == fromId) fromBalance = balance;
        }
        return fromBalance;
    }

    private void post(UUID txId, long accountId, long amount) {
        Long after = jdbc.queryForObject(
            "UPDATE account SET balance = balance + ? WHERE id = ? RETURNING balance",
            Long.class, amount, accountId);
        jdbc.update("""
            INSERT INTO ledger_entry(transaction_id, account_id, amount, balance_after)
            VALUES (?, ?, ?, ?)""", txId, accountId, amount, after);
    }
}

class InsufficientFundsException extends RuntimeException {
    InsufficientFundsException(long accountId) { super("Saldo nicht ausreichend: " + accountId); }
}

Die wichtigsten Punkte im Code:

  • (1) Idempotenz. ON CONFLICT DO NOTHING gibt 0 Zeilen zurück, wenn der Schlüssel bereits existiert. Angenommen, zwei Anfragen mit demselben Schlüssel laufen parallel. PostgreSQL zwingt die zweite Anfrage, am Unique-Index zu warten, bis die erste Anfrage committed. Dann erhält die zweite Anfrage 0 Zeilen und gibt die alte Transaktion zurück. Keine zweite Belastung.
  • (2) Lock nach ID-Reihenfolge. Thread A überweist 1→2 und Thread B überweist 2→1. Wenn jeder Thread zuerst das Quellkonto sperrt, wird jeder Thread eine Sperre halten und auf die andere warten – ein Deadlock. Wenn wir immer die kleinere ID zuerst sperren, fordern alle Threads Sperren in derselben Reihenfolge an – keine Warteschleifen.
  • (3) Saldo-Check nach dem Sperren. Ein Check vor dem Sperren ist der klassische Check-Then-Act-Fehler. Zwei Anfragen lesen beide “Geld vorhanden” und ziehen beide ab.
  • (4) Zwei Ledger-Einträge und Outbox liegen in derselben Transaktion. Wenn ein Schritt fehlschlägt, wird alles zurückgerollt, einschließlich der journal_transaction-Zeile. Daher kann der Client wiederholen, wenn ihm Mittel gemeldet werden.

Hinweis: Verwende @Transactional nicht auf private-Methoden oder wenn sie von der gleichen Klasse aufgerufen werden (self-invocation). Der Spring-Proxy wird die Transaktion nicht wrappen und der gesamte Lock-Mechanismus verliert seine Wirkung.

7. Schwierige Punkte

7.1. Contention auf Hot Accounts

Die Geldbörse eines großen Merchants kann Tausende Zahlungen pro Sekunde erhalten. Jede Transaktion wartet in einer Warteschlange auf FOR UPDATE für eine einzelne Zeile, daher wird der Durchsatz auf einige hundert Transaktionen/Sekunde begrenzt.

Eine häufige Lösung ist, das Hot Account in N Sub-Konten zu teilen. Beim Gutschreiben wird zufällig ein Sub-Konto gewählt. Der tatsächliche Saldo des Merchants ist die Summe der Sub-Konten:

long creditTarget(long merchantAccountId, int shards) {
    int shard = ThreadLocalRandom.current().nextInt(shards);
    return jdbc.queryForObject(
        "SELECT id FROM account WHERE parent_id = ? AND shard_no = ?",
        Long.class, merchantAccountId, shard);
}

Mit 16 Shards wird die Contention um etwa das 16-fache reduziert. Dieser Ansatz funktioniert für die Gutschrift-Richtung, da Geld hinzufügen keinen Saldo-Check benötigt. Die Belastungs-Richtung, z.B. wenn ein Merchant Geld abheben möchte, passiert viel seltener. Du kannst Geld regelmäßig in das Haupt-Konto konsolidieren, und dieser Job ist auch eine Double-Entry-Transaktion.

Sharding sollte nicht für normale persönliche Geldbörsen verwendet werden. Persönliche Geldbörsen leiden selten unter Contention, aber Sharding macht Saldo-Checks kompliziert.

7.2. Konsistenz mit anderen Systemen: Transactional Outbox

Nach einem Transfer müssen Benachrichtigungs-, Fraud- und Loyalty-Services informiert werden. Wenn du kafkaTemplate.send() direkt in transfer() aufrufst, kannst du auf einen von zwei Fehlern treffen:

  • Kafka erhält die Nachricht, aber die DB rollback, sodass der Benutzer eine Benachrichtigung für eine nicht existierende Transaktion erhält.
  • Die DB committed, aber das Senden an Kafka schlägt fehl, sodass das Ereignis verloren geht.

In Abschnitt 6 haben wir das Ereignis in outbox_event in derselben Transaktion geschrieben. Ein separater Relay liest diese Tabelle und pushed sie zu Kafka:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
class OutboxRelay {
    private final JdbcTemplate jdbc;
    private final KafkaTemplate<String, String> kafka;

    OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 200)
    @Transactional
    public void publish() {
        var rows = jdbc.queryForList("""
            SELECT id, topic, payload::text AS payload FROM outbox_event
            WHERE published = FALSE ORDER BY id LIMIT 100
            FOR UPDATE SKIP LOCKED""");
        for (var row : rows) {
            kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
            jdbc.update("UPDATE outbox_event SET published = TRUE WHERE id = ?", row.get("id"));
        }
    }
}

Aktiviere @EnableScheduling in einer Config-Klasse, damit @Scheduled läuft. SKIP LOCKED erlaubt mehreren Relay-Instanzen parallel zu laufen, ohne Zeilen doppelt zu verarbeiten.

Dieser Mechanismus garantiert at-least-once. Wenn das Relay nach dem Senden, aber vor dem Commit ausfällt, wird die Nachricht erneut gesendet. Daher muss der Consumer duplizierte Einträge nach transactionId deduplizieren.

7.3. Burst-Load-Handling

Wenn der Request-Volumen um das 10-fache steigt, bricht zuerst der Connection Pool der Datenbank, nicht die CPU. Es gibt drei Schutzschichten:

  1. Rate-Limiting pro Benutzer am Rand mit Redis.
  2. Connection-Pool-Limit (HikariCP), um die DB nicht zu überlasten, mit kurzem Timeout zum schnellen Fehlschlag.
  3. Nicht-kritische Aufgaben wie Benachrichtigungen und Loyalty-Punkte auf Kafka verschieben. Das haben wir durch Outbox bereits.

Hier ein Beispiel für Fixed-Window-Rate-Limiting, 10 Requests/Sekunde pro Benutzer mit spring-boot-starter-data-redis:

@Component
class TransferRateLimiter {
    private final StringRedisTemplate redis;

    TransferRateLimiter(StringRedisTemplate redis) { this.redis = redis; }

    boolean allow(long userId) {
        String key = "rl:transfer:%d:%d".formatted(userId, Instant.now().getEpochSecond());
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(2));
        return count != null && count <= 10;
    }
}
spring.datasource.hikari.maximum-pool-size=40
spring.datasource.hikari.connection-timeout=500
spring.jpa.open-in-view=false

Wenn allow() false zurückgibt, gib 429 Too Many Requests zurück. Wenn du in 500 ms keine Connection bekommst, gib 503 zurück. Der Client hat Idempotency-Key, sodass Wiederholungen sicher sind.

Hinweis: Verwende Redis nicht, um Salden zu speichern oder „zuerst abziehen, dann später mit DB synchronisieren”. Redis ist sehr schnell, aber bietet keine ACID-Transaktionen zusammen mit dem Ledger. Ein Failover und du verlierst Geld.

8. Testing für gleichzeitiges Verhalten

Der wichtigste Test ist, viele Requests parallel auf ein Konto zu schießen. Konto A hat 50.000đ und empfängt 100 Transfer-Befehle á 1.000đ. Das richtige Ergebnis ist: genau 50 Befehle erfolgreich, Saldo auf 0 und Ledger-Summe null. Der folgende Test braucht eine echte PostgreSQL, z.B. über Testcontainers, und Initialisierung in @Sql.

@SpringBootTest
@Sql(statements = {
    "TRUNCATE ledger_entry, journal_transaction, outbox_event, account",
    "INSERT INTO account(id, owner_id, currency, balance) VALUES (1, 1, 'VND', 50000), (2, 2, 'VND', 0)"
})
class TransferServiceConcurrencyTest {

    @Autowired TransferService service;
    @Autowired JdbcTemplate jdbc;

    @Test
    void neverOverdraftsUnderConcurrency() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100; i++) {
                String key = "k-" + i;
                pool.submit(() -> {
                    try {
                        service.transfer(key, new TransferRequest(1L, 2L, 1_000));
                        success.incrementAndGet();
                    } catch (InsufficientFundsException ignored) { }
                });
            }
        }
        assertThat(success.get()).isEqualTo(50);
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class)).isZero();
        assertThat(jdbc.queryForObject("SELECT COALESCE(SUM(amount),0) FROM ledger_entry", Long.class)).isZero();
    }

    @Test
    void sameIdempotencyKeyReturnsSameTransaction() {
        var req = new TransferRequest(1L, 2L, 1_000);
        var first = service.transfer("dup", req);
        var second = service.transfer("dup", req);

        assertThat(second.transactionId()).isEqualTo(first.transactionId());
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class))
            .isEqualTo(49_000L);
    }
}

Wenn du FOR UPDATE aus lockAndGet entfernst, wird der erste Test zufällig fehlschlagen. Manchmal ist success größer als 50, manchmal wirft die CHECK-Bedingung einen Fehler. Ohne ON CONFLICT würde der zweite Test 2.000đ abziehen.

Neben den Tests solltest du einen täglichen Reconciliation-Job laufen lassen. Dieser Job prüft zwei Dinge: Ledger-Summe ist null, und account.balance entspricht der Summe der ledger_entry für jedes Konto:

SELECT a.id, a.balance, COALESCE(SUM(e.amount), 0) AS ledger_sum
FROM account a LEFT JOIN ledger_entry e ON e.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> COALESCE(SUM(e.amount), 0);

Die Abfrage gibt 0 Zeilen zurück, bedeutet die Bücher passen.

9. Häufige Fehler und wann nicht zu verwenden

  • double/float für Geld verwenden. 0.1 + 0.2 ist nicht 0.3. Verwende long in der kleinsten Einheit oder BigDecimal für Wechselkurse.
  • balance direkt updaten ohne Ledger-Eintrag. Das ist schnell anfangs, aber wenn ein Kunde sich beschwert, kannst du nicht sagen, wohin das Geld ging.
  • Falsche Ledger-Einträge ändern oder löschen. Buche stattdessen ein Reversal als neue Transaktion.
  • Locks nicht in sortierter Reihenfolge führen zu Deadlocks unter Last. PostgreSQL bricht den Deadlock durch Abbruch einer Transaktion, und der Benutzer sieht zufällige Fehler.
  • Externe APIs aufrufen, während Locks gehalten werden, z.B. Bank-Payment-Gateways. Locks werden Sekunden lang gehalten und der Durchsatz bricht zusammen. Trenne Bank-Flows in State Machines (PENDING → COMPLETED/FAILED) mit Hold-Konten auf.
  • Verwende dieses Design nicht für Nicht-Geld-Daten wie View-Counter. Dort sind kleine Unterschiede akzeptabel und Double-Entry ist reine Verschwendung.

10. Fazit

Der Kern eines sicheren E-Wallets liegt nicht in neuer Technologie, sondern in ein paar strengen Prinzipien:

  • Double-Entry append-only, die Ledger-Summe immer null.
  • Idempotency Key für alle Schreib-Befehle.
  • Pessimistic Locking in fester Reihenfolge und Saldo-Check nach dem Sperren.
  • CHECK-Bedingung als letztes Sicherheitsnetz.

Darauf aufbauend, handhaben wir Hot Accounts mit Sub-Accounts, synchronisieren mit anderen Systemen via Transactional Outbox und Kafka, und wehren Laststöße mit Redis-Rate-Limiting und limitiertem Connection Pool ab.

Bei weiterer Skalierung kannst du Bank-Flows in State Machines mit Hold-Konten teilen. Du kannst auch ledger_entry zeitbasiert partitionieren, wenn die Tabelle Milliarden Zeilen erreicht. Wie auch immer du skalierst, führe immer Concurrency-Tests und Reconciliation-Jobs aus.

system-designspring-bootpostgresqlkafkafintech