電子ウォレット設計:複式簿記による安全な送金

1. 概要

2つのウォレット間の送金は簡単そうに思えます。一方のウォレットから引き落とし、もう一方に振り込むだけです。しかし、数千個の同時リクエスト、クライアントの再試行、ネットワークの不安定性、監査要件が加わると、balance 列をただ更新するだけのやり方では、すぐに数え間違えが発生します。

このトピックでは、複式簿記(double-entry ledger) に基づいたウォレットの設計を実装していきます。この設計には、要件、データモデル、API、処理フロー、そして難しい点が含まれます:ロック競合、他のシステムとの一貫性、トラフィック急増。読み終わると、Spring Boot 3 と PostgreSQL を使ってお金をなくさない、二重引き落としがなく、照合可能な送金サービスを構築できるようになります。

2. 要件

2.1. 機能要件

  • 各ユーザーは通貨ごと(VND)に1つ以上のウォレットを持つ。
  • 入金、出金、2つのウォレット間の送金。
  • 残高と取引履歴の表示。
  • 1回の取引は、クライアントが再度リクエストを送信した場合でも、決して2回記録されてはいけない。

2.2. スケールと非機能要件

  • 1,000万ウォレット、平均500トランザクション/秒。ピーク時には5,000トランザクション/秒(セールまたは給与日)。
  • 送金APIのp99レイテンシは300ms未満。
  • 残高は強い一貫性 を必須とします。古い残高を読んで送金を許可するかを判断することは許容されません。

2.3. 制約

  • 残高を負にしてはいけません。ただし、システムアカウントは負の値を許可する場合があります。
  • 残高の変更はすべて1つの仕訳に遡ることができます。仕訳の修正や削除はできません。誤りを修正する場合は、逆向きの仕訳を記録します。
  • 金額は整数で最小単位(ドン)で保存します。double は使用しません。

3. 複式簿記とは

各取引は少なくとも2つの仕訳(entry) で構成されます。1つの取引のすべての仕訳の合計は常にゼロです。アンさんからビンさんへ100,000ドン送金すると、2つの仕訳が生成されます:

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

入金も同じルールに従います。お金は「どこかから」来ます。それはシステムアカウント、例えば BANK_CLEARING です。このアカウントは負の値を許可します。したがって、非常に強い不変量(invariant) が成り立ちます:システム内のすべての仕訳の合計は常にゼロです。合計がゼロでない場合は、バグがあります。

4. データモデル

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

上記のテーブルの役割は次のとおりです:

  • ledger_entry は 信実のソース です。テーブルは追記のみ(append-only)です。amount が負の場合は借方、正の場合は貸方です。
  • account.balance は仕訳の合計の キャッシュ です。この列を保持することで、何百万行もの合計を計算することなく、残高を素早くチェックできます。
  • CHECK 制約は最後の安全ネットです。コードにバグがある場合でも、データベースは負の残高を拒否します。
  • idempotency_key UNIQUE は取引の重複記録を防ぎます。
  • outbox_event は Kafka へのイベント送信(セクション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" }

クライアントは各送金の意図に対して Idempotency-Key を生成し、再試行時にまったく同じキーを再利用します。サーバーは同じキーに対して 同じ結果 を返す必要があります。

Controllerは非常に薄いです:

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. 送金処理フロー

spring-boot-starter-jdbc を使って各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>

すべての処理フローは 1つのデータベーストランザクション 内で実行されます:

@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("自分自身に送金することはできません");
        }
        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("残高不足:" + accountId); }
}

コード内の主要なポイント:

  • (1) 冪等性。 ON CONFLICT DO NOTHING は、キーが既に存在する場合は0行を返します。同じキーの2つのリクエストが並行して実行される場合を考えます。PostgreSQL は2番目のリクエストをユニークインデックスで待機させます。その後、2番目のリクエストは0行を受け取り、古い取引を返します。2回目の引き落としはありません。
  • (2) ロックをIDの順序で。 フローA は1→2を転送し、フローB は2→1を転送します。各フローがソースアカウントをロックした場合、両方のフローが1つのロックを保持して別のロックを待機します。つまり、デッドロックです。常にIDが小さい方をロックすることで、すべてのフローが同じ順序でロックをリクエストするため、待機サイクルはありません。
  • (3) ロック後に残高をチェック。 ロック前にチェックするのは古典的なTOCTOU(Time-Of-Check-Time-Of-Use)バグです。2つのリクエストが両方とも「残高あり」と読み取り、両方が引き落とします。
  • (4) 2つの仕訳とOutbox は同じトランザクションに含まれます。いずれかのステップが失敗すると、すべてがロールバックされます。journal_transaction の行も含みます。したがって、クライアントは残高不足エラーを受け取った場合に再試行できます。

注意: method の private に @Transactional を使用したり、同じクラスから呼び出さないでください。Spring のプロキシはトランザクションをラップせず、ロック機構全体が失われます。

7. 難しい点

7.1. ホットアカウントでのロック競合

大型マーチャントのウォレットは1秒あたり数千の支払いを受け取ることがあります。すべてのトランザクションは 1行のみ の FOR UPDATE で待機行列に並ぶため、スループットは数百トランザクション/秒に制限されます。

一般的なソリューションは、ホットアカウントを N 個の サブアカウント に分割することです。クレジット時に、ランダムにサブアカウントを選択します。マーチャントの実際の残高はサブアカウントの合計です:

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

16個のシャードを使用すると、ロック競合は約16倍削減されます。このアプローチは、残高チェック不要なため クレジット に適しています。デビット、例えばマーチャント出金は、ずっと少なく発生します。定期的なjobを使用して資金をメインアカウントに集約できます。このjobも複式簿記の1つの取引です。

通常のピアウォレットをシャードしないでください。個人用ウォレットはロック競合がほとんど発生しませんが、シャードは残高チェックをはるかに複雑にします。

7.2. 他のシステムとの一貫性:Transactional Outbox

送金後、通知、不正検知、ポイント付与サービスが知る必要があります。transfer() 内で直接 kafkaTemplate.send() を呼び出すと、2つのエラーのいずれかに直面する可能性があります:

  • Kafka がメッセージを受け取るが、DB がロールバックし、ユーザーは存在しない取引の通知を受け取ります。
  • DB がコミットするが、Kafka への送信が失敗し、イベントが失われます。

セクション6で示したように、同じトランザクション内で outbox_event にイベントを記録しておきます。別のリレーがこのテーブルを読み、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"));
        }
    }
}

@Scheduled を実行するには、設定クラスで @EnableScheduling を有効にすることを忘れずに。SKIP LOCKED は複数のリレーインスタンスが並行して実行され、同じ行を取得しないようにします。

このメカニズムは at-least-once を保証します。リレーが送信後だけどコミット前に失敗すると、メッセージは再送信されます。したがって、consumerは transactionId で重複排除する必要があります。

7.3. トラフィック急増

リクエスト量が10倍に増加したとき、最初に崩壊するのはデータベース接続プールです。CPU ではなく。3つの防御層があります:

  1. エッジでのユーザーごとのレート制限(Redis を使用)。
  2. 接続プール制限(HikariCP)でDBを過負荷から保護し、短いタイムアウトですばやく失敗します。
  3. 非緊急タスク を Kafka に遅延します。通知とポイント付与。これはOutboxのおかげで既に実装されています。

例として、spring-boot-starter-data-redis を使用した、ユーザーあたり10リクエスト/秒の固定ウィンドウレート制限:

@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

allow() が false を返すとき、429 Too Many Requests を返します。500 ms で接続を取得できないとき、503 を返します。クライアントは Idempotency-Key を持っているため、再試行は安全です。

注意: 残高を保存したり、「先に Redis でお金を引き落とし、後で DB に同期する」ことに Redis を使用しないでください。Redis は非常に高速ですが、複式簿記と共に ACID トランザクションを提供しません。1回のフェイルオーバーで資金が失われます。

8. 並行動作テスト

最も重要なテストは、複数のリクエストを同時に同じウォレットに送信することです。ウォレット A に 50,000 ドンあり、1,000 ドンの転送 100 コマンドを受け取ります。正しい結果は、正確に 50 コマンドが成功し、残高がゼロになり、仕訳の合計がゼロになることです。以下のテストは、Testcontainers を通じた PostgreSQL の実装と、@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);
    }
}

lockAndGet から FOR UPDATE を削除すると、最初のテストはランダムに失敗します。時には success が50以上になり、時には CHECK 制約が例外をスローします。ON CONFLICT がなければ、2番目のテストは 2,000 ドンを引き落とします。

テストに加えて、毎晩対照実行jobを実行します。このjobは2つをチェックします:仕訳の合計がゼロ、および account.balance が各アカウントの ledger_entry の合計と等しい:

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

クエリが0行を返すことは、帳簿が調和していることを意味します。

9. 一般的なエラーとこの設計を使用しないべき時

  • 金額に double/float を使用。 0.1 + 0.2 は 0.3 にはなりません。最小単位に従って long を使用するか、為替レートが必要な場合は BigDecimal を使用してください。
  • 仕訳を記録せずに直接 balance を更新。 このアプローチは最初は高速ですが、顧客が異議を唱えるとき、あなたは「お金はどこに行ったのか」に答えることができません。
  • 誤った仕訳を修正または削除。 逆向きの仕訳を新しい取引として記録してください。
  • 順序に従わないロック はロード下でデッドロックを招きます。PostgreSQL はトランザクションの1つをキャンセルしてデッドロックを破壊し、ユーザーはランダムなエラーを受け取ります。
  • ロック保有中の外部API呼び出し、例えば銀行ゲートウェイへの呼び出し。ロックは数秒間保持され、スループットは崩壊します。銀行関連の流れをステートマシン(PENDING → COMPLETED/FAILED)に分離し、一時保有(hold)アカウントを使用してください。
  • この設計を使用しないでください ビュー数カウンターなど、金銭や資産以外のデータの場合。そこでは小さな不一致が許容され、複式簿記は過剰なコストに過ぎません。

10. 結論

安全な電子ウォレットの本質は新しいテクノロジーではなく、いくつかの厳格な原則にあります:

  • 複式簿記append-only、仕訳の合計は常にゼロ。
  • すべての書き込みコマンドに冪等性キー。
  • 固定順序の悲観的ロック、ロック後に残高チェック。
  • CHECK 制約は最後の安全ネット。

それをベースに、ホットアカウントをサブアカウントで処理、Transactional Outbox と Kafka で他のシステムと同期、Redis レート制限と接続プール制限でトラフィック急増に対応します。

さらにスケーリングする際は、銀行関連の流れをステートマシンに分離し、一時保有アカウントを使用できます。テーブルが数十億行に達したときは、ledger_entry を時間でパーティション化することもできます。どの方法を選択しても、並行テストと対照jobを常に実行して保ってください。

system-designspring-bootpostgresqlkafkafintech