Conception de portefeuille électronique : transferts sécurisés avec grand livre double

1. Vue d’ensemble

Transférer de l’argent entre deux portefeuilles paraît simple : débiter d’un côté, créditer de l’autre. Avec des milliers de requêtes simultanées, des retentatives client, un réseau instable et des exigences d’audit, la méthode « mettre à jour la colonne balance » créera rapidement des écarts.

Cet article explore la conception d’un portefeuille électronique basé sur le grand livre double (double-entry ledger). La conception couvre les exigences, le modèle de données, l’API, le flux de traitement, puis les points difficiles : les contentions de verrous, la cohérence avec d’autres systèmes et les pics de charge. À la fin, vous pourrez construire un service de transfert d’argent avec Spring Boot 3 et PostgreSQL. Ce service ne perd jamais d’argent, ne double jamais les débits et permet la réconciliation.

2. Exigences du problème

2.1. Fonctionnalités

  • Chaque utilisateur possède un ou plusieurs portefeuilles par type de devise (VND).
  • Recharge, retrait, transfert d’argent entre deux portefeuilles.
  • Consultation du solde et de l’historique des transactions.
  • Une transaction ne doit jamais être enregistrée deux fois, même si le client envoie à nouveau la requête.

2.2. Échelle et caractéristiques non-fonctionnelles

  • 10 millions de portefeuilles, moyenne 500 transactions/seconde. Pic de 5 000 transactions/seconde lors de soldes ou jours de paie.
  • Latence p99 inférieure à 300 ms pour l’API de transfert.
  • Le solde doit être fortement cohérent. Lire un ancien solde pour décider d’autoriser un transfert est inacceptable.

2.3. Contraintes

  • Le solde ne doit jamais être négatif, sauf pour les comptes système autorisés.
  • Tout changement de solde doit être traçable à une écriture comptable. Pas de modification, pas de suppression d’écritures. Pour corriger une erreur, enregistrez une écriture de contrepassation.
  • Les montants sont stockés en nombres entiers selon l’unité la plus petite (đồng). Pas de double.

3. Qu’est-ce que le grand livre double

Chaque transaction comprend au minimum deux écritures comptables (entries). La somme des écritures d’une transaction est toujours égale à zéro. Un transfert de 100 000đ d’An à Bình génère deux écritures :

transaction T1
  compte An    : -100000
  compte Binh  : +100000
  ----------------------
  somme        :       0

Une recharge suit aussi cette règle. L’argent vient « d’ailleurs », qui est un compte système, par exemple BANK_CLEARING, et ce compte est autorisé à être négatif. Cela nous donne un invariant très puissant : la somme de toutes les écritures du système est toujours égale à zéro. Si la somme est différente de zéro, il y a sûrement un bug.

4. Modèle de données

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

Les tables ci-dessus ont les rôles suivants :

  • ledger_entry est la source de vérité. La table est append-only. amount négatif signifie un débit, positif un crédit.
  • account.balance est un cache de la somme des écritures. Nous conservons cette colonne pour vérifier rapidement le solde sans SUM de millions de lignes.
  • La contrainte CHECK est la dernière ligne de défense. Si le code a un bug, la base de données refuse toujours un solde négatif.
  • idempotency_key UNIQUE prévient la duplication d’écritures de transactions.
  • outbox_event est utilisé pour envoyer des événements à Kafka (section 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" }

Le client génère lui-même l’Idempotency-Key pour chaque intention de transfert et réutilise exactement la même clé lors de chaque retentative. Le serveur doit retourner le même résultat pour la même clé.

Le contrôleur est très mince :

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. Flux de traitement du transfert

Nous utilisons spring-boot-starter-jdbc pour voir clairement chaque requête SQL :

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

L’ensemble du flux s’exécute dans une seule transaction database :

@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("Impossible de transférer vers soi-même");
        }
        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("Solde insuffisant : " + accountId); }
}

Les points clés du code :

  • (1) Idempotence. ON CONFLICT DO NOTHING retourne 0 lignes si la clé existe déjà. Supposons que deux requêtes avec la même clé s’exécutent en parallèle. PostgreSQL fait attendre la deuxième requête à l’index unique jusqu’à ce que la première soit validée. Ensuite, la deuxième requête reçoit 0 lignes et retourne l’ancienne transaction. Pas de deuxième débit.
  • (2) Verrouillage par ordre d’id. Si le flux A transfère 1→2 et le flux B transfère 2→1. Si chaque flux verrouille son compte source en premier, les deux flux tiendront un verrou et attendront l’autre, c’est-à-dire un deadlock. En verrouillant toujours l’id plus petit en premier, tous les flux demandent les verrous dans le même ordre, donc pas de cycle d’attente.
  • (3) Vérification du solde après verrouillage. Vérifier avant le verrouillage est une erreur classique check-then-act. Deux requêtes lisent « solde suffisant » et débitent toutes les deux.
  • (4) Deux écritures et outbox sont dans la même transaction. Si une étape échoue, tout est annulé, y compris la ligne journal_transaction. Le client peut donc réessayer s’il reçoit un message de solde insuffisant.

Note : Ne mettez pas @Transactional sur une méthode private ou ne l’appelez pas depuis la même classe (self-invocation). Le proxy de Spring ne wrappera pas la transaction et tout le mécanisme de verrouillage sera cassé.

7. Points difficiles

7.1. Contention sur un compte chaud (hot account)

Le portefeuille d’un grand marchand peut recevoir des milliers de paiements par seconde. Chaque transaction attend en file d’attente sur FOR UPDATE d’une seule ligne, ce qui limite le débit à environ quelques centaines de transactions/seconde.

La solution courante est de diviser le compte chaud en N sous-comptes (sub-accounts). Lors du crédit, choisissez un sous-compte au hasard. Le solde réel du marchand est la somme des sous-comptes :

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);
}

Avec 16 fragments, la contention diminue d’environ 16 fois. Cette approche fonctionne bien pour le crédit, car ajouter de l’argent ne nécessite pas de vérification de solde. Le débit, par exemple un retrait de marchand, est beaucoup moins fréquent. Vous pouvez consolider l’argent sur le compte principal via un job périodique, et ce job est aussi une transaction double.

Ne fragmentez pas les portefeuilles personnels ordinaires. Les portefeuilles personnels ont rarement de contention, tandis que le fragmentage complique beaucoup la vérification du solde.

7.2. Cohérence avec d’autres systèmes : Transactional Outbox

Après un transfert d’argent, les services de notification, de fraude et de points de fidélité doivent être informés. Si vous appelez kafkaTemplate.send() directement dans transfer(), vous pourriez rencontrer l’un de ces deux problèmes :

  • Kafka reçoit le message mais la DB est annulée, l’utilisateur reçoit une notification pour une transaction inexistante.
  • La DB est validée mais l’envoi à Kafka échoue, l’événement est perdu.

À la section 6, nous avons déjà enregistré l’événement dans outbox_event dans la même transaction. Un relais séparé lira et poussera cette table vers 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"));
        }
    }
}

N’oubliez pas d’activer @EnableScheduling sur une classe de configuration pour que @Scheduled s’exécute. SKIP LOCKED permet à plusieurs instances de relais de s’exécuter en parallèle sans sélectionner les mêmes lignes.

Ce mécanisme garantit at-least-once. Si le relais s’arrête après l’envoi mais avant la validation, le message sera renvoyé. Par conséquent, le consommateur doit dédupliquer par transactionId.

7.3. Pics de charge

Quand le volume de requêtes augmente de 10 fois, le premier goulot est le pool de connexions à la base de données, pas le CPU. Vous avez trois couches de défense :

  1. Rate limiting par utilisateur à la limite, avec Redis.
  2. Limitation du pool de connexions (HikariCP) pour que la DB ne soit pas surchargée, avec un timeout court pour échouer rapidement.
  3. Retarder les tâches non urgentes comme les notifications et les points de fidélité vers Kafka. Cela est déjà en place grâce à l’outbox.

Exemple de rate limiting avec fenêtre fixe, 10 requêtes/seconde par utilisateur, avec 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

Quand allow() retourne false, retournez 429 Too Many Requests. Quand vous ne pouvez pas obtenir une connexion en 500 ms, retournez 503. Le client ayant l’Idempotency-Key peut réessayer en toute sécurité.

Note : N’utilisez pas Redis pour stocker les soldes ou « débiter d’abord puis synchroniser avec la DB après ». Redis est très rapide mais ne vous donne pas de transactions ACID avec le grand livre comptable. Perdre un failover, c’est perdre de l’argent.

8. Test du comportement concurrent

Le test le plus important est de tirer plusieurs requêtes en parallèle vers le même portefeuille. Un portefeuille A avec 50 000đ reçoit 100 ordres de transfert de 1 000đ. Le résultat correct est exactement 50 transferts réussis, un solde de 0 et une somme d’écritures égale à 0. Le test ci-dessous nécessite un vrai PostgreSQL, par exemple via Testcontainers, et des données d’initialisation dans @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);
    }
}

Si vous supprimez FOR UPDATE dans lockAndGet, le premier test échouera de manière aléatoire. Parfois success sera supérieur à 50, parfois la contrainte CHECK lancera une erreur. Si vous n’avez pas ON CONFLICT, le deuxième test débite 2 000đ.

Au-delà des tests, exécutez un job de rapprochement chaque nuit. Ce job vérifie deux choses : la somme des écritures est zéro, et account.balance égale la somme de ledger_entry pour chaque compte :

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

La requête retournant 0 ligne signifie que les registres correspondent.

9. Erreurs courantes et quand ne pas utiliser cette approche

  • Utiliser double/float pour l’argent. Le calcul 0.1 + 0.2 ne fait pas 0.3. Utilisez long selon l’unité la plus petite, ou BigDecimal si vous avez besoin de taux de change.
  • Mettre à jour directement balance sans enregistrer une écriture. Cette approche est rapide au début, mais quand un client se plaint, vous ne pouvez pas répondre « où est allé l’argent ».
  • Modifier ou supprimer une écriture incorrecte. Enregistrez une écriture de contrepassation (reversal) comme une nouvelle transaction.
  • Verrouillage sans ordre entraînant des deadlocks sous charge. PostgreSQL brisera le deadlock en annulant une transaction, et l’utilisateur verra une erreur aléatoire.
  • Appeler une API externe en tenant un verrou, par exemple appeler une passerelle de paiement bancaire. Le verrou est tenu pendant des secondes et le débit s’effondre. Séparez les flux bancaires en une machine d’états (PENDING → COMPLETED/FAILED) avec un compte de retenue temporaire.
  • Ne pas utiliser cette conception pour les données qui ne sont pas de l’argent ou des actifs, par exemple les compteurs de vues. Là, les petits écarts sont acceptables et le grand livre double est juste un coût supplémentaire.

10. Conclusion

Le cœur d’un portefeuille électronique sûr ne réside pas dans une technologie nouvelle mais dans quelques principes strictes :

  • Grand livre double append-only, où la somme des écritures est toujours zéro.
  • Clé d’idempotence pour chaque commande d’écriture.
  • Verrouillage pessimiste dans un ordre fixe, et vérification du solde après verrouillage.
  • Contrainte CHECK comme dernière ligne de défense.

Sur cette base, gérez les comptes chauds en utilisant des sous-comptes, synchronisez avec d’autres systèmes en utilisant Transactional Outbox et Kafka, et défendez-vous contre les pics de charge avec Redis rate limiting et un pool de connexions limité.

Lors de l’expansion, vous pouvez séparer les flux bancaires en une machine d’états avec un compte de retenue temporaire. Vous pouvez aussi partitionner ledger_entry par temps quand la table atteint des milliards de lignes. Peu importe comment vous vous développez, maintenez toujours des tests concurrents et exécutez des jobs de rapprochement.

system-designspring-bootpostgresqlkafkafintech