电子钱包设计:双式记账法的安全转账

1. 概述

两个钱包之间转账听起来很简单:从这边减,从那边加。但当有数千个并发请求、客户端重试、网络不稳定以及审计要求时,“直接更新 balance 列”这种做法很快就会导致余额错误。

本文介绍如何设计一个基于**双式记账法(double-entry ledger)**的电子钱包。设计涵盖需求、数据模型、API、处理流程,以及难点:锁竞争、与其他系统的一致性和突发流量。读完本文,你可以用 Spring Boot 3 和 PostgreSQL 构建一个转账服务。这个服务不会丢钱、不会重复扣款、账目能对得上。

2. 需求

2.1. 功能需求

  • 每个用户有一个或多个钱包,按货币类型(越南盾)分类。
  • 充值、提现、在两个钱包之间转账。
  • 查看余额和交易历史。
  • 一笔交易永远不会被记录两次,即使客户端重新发送请求。

2.2. 规模与非功能需求

  • 1000 万个钱包,平均 500 笔交易/秒。促销或发工资时峰值 5000 笔交易/秒。
  • 转账 API 的 p99 延迟低于 300 毫秒。
  • 余额必须强一致。基于陈旧余额决定是否允许转账是不可接受的。

2.3. 约束

  • 永远不允许余额为负,除了被允许透支的系统账户。
  • 每个余额变化都必须能追溯到一笔分录。分录不能修改,不能删除。要更正错误,记录一笔反向分录。
  • 用整数按最小单位(分)存储金额。不使用 double。

3. 什么是双式记账法

每笔交易由至少两条**分录(entry)**组成。一笔交易的所有分录之和总是 0。从 An 转 100000 越南盾到 Bình 产生两条分录:

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

充值也遵循同样的规则。钱“来自某处”是一个系统账户,比如 BANK_CLEARING,这个账户允许为负。因此我们有一个**不变量(invariant)**非常强大:系统中所有分录的总和总是 0。如果总和不是 0,肯定有 bug。

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 是分录总和的缓存。保留这列是为了快速检查余额,而不需要 SUM 百万行。
  • CHECK 约束是最后的安全网。即使代码有 bug,数据库也会拒绝负余额。
  • 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,并在重试时重用相同的 key。服务器必须为相同的 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>

整个流程在一个数据库事务中运行:

@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 在 key 已存在时返回 0 行。假设两个相同 key 的请求并行运行。PostgreSQL 会让第二个请求在 unique index 上等待,直到第一个请求 commit。之后第二个请求收到 0 行并返回旧交易。不会有第二次扣款。
  • (2) 按 id 顺序加锁。 线程 A 转 1→2,线程 B 转 2→1。如果每个线程先锁定源账户,两个线程都会持有一个锁并等待另一个,即死锁。当总是先锁定较小的 id 时,所有线程按相同顺序请求锁,就没有等待环。
  • (3) 加锁后检查余额。 在加锁前检查是经典的 check-then-act 错误。两个请求都读到“余额充足”并都进行扣款。
  • (4) 两条分录和 outbox 在同一事务中。如果任何步骤失败,所有内容都会回滚,包括 journal_transaction 行。因此当报告余额不足时,客户端可以安全重试。

注意: 不要在 private 方法上使用 @Transactional,也不要从同一类中调用它(self-invocation)。Spring 的代理将不会包装事务,整个锁定机制将失效。

7. 难点

7.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也是一笔双式记账交易。

不应该对普通个人钱包进行分片。个人钱包很少有竞争,而分片会使余额检查复杂得多。

7.2. 与其他系统的一致性:Transactional Outbox

转账后,通知、反欺诈和积分服务需要知道。如果在 transfer() 中直接调用 kafkaTemplate.send(),可能会遇到两个错误之一:

  • Kafka 收到消息但 DB 回滚,导致用户收到不存在的交易通知。
  • DB commit 但发送 Kafka 失败,导致事件丢失。

在第 6 节中,我们已经在同一事务中将事件写入 outbox_event。一个单独的 relay 会读取这个表并将其推送到 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"));
        }
    }
}

记得在配置类上启用 @EnableScheduling 以使 @Scheduled 运行。SKIP LOCKED 允许多个 relay 实例并行运行而不会取重复行。

这种机制保证至少一次。如果 relay 在发送后但 commit 前崩溃,消息会被重新发送。因此 consumer 必须按 transactionId 进行去重。

7.3. 突发流量

当请求量增加 10 倍时,首先会失败的是数据库的 connection pool,而不是 CPU。有三层防御:

  1. 在边界处按用户进行 rate limit,使用 Redis。
  2. 限制 connection pool(HikariCP)以防止 DB 过载,搭配短 timeout 实现快速失败。
  3. 将非关键任务推迟到 Kafka,如通知和积分。这已经通过 outbox 实现了。

例如使用 spring-boot-starter-data-redis 实现每个用户每秒 10 个请求的固定窗口 rate limit:

@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 毫秒内无法获得连接时,返回 503。由于客户端有 Idempotency-Key,所以重试是安全的。

注意: 不要使用 Redis 存储余额或“先扣钱然后同步到 DB”。Redis 很快,但不能提供与账本的 ACID 事务。一次 failover 就会丢钱。

8. 并发行为测试

最重要的测试是向同一钱包并发发送多个请求。钱包 A 有 50000 越南盾,收到 100 条转账 1000 越南盾的命令。正确的结果是恰好 50 条命令成功,余额为 0,分录总和为 0。下面的测试需要真实的 PostgreSQL,例如通过 Testcontainers,以及在 @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,第二个测试会扣 2000 越南盾。

除了测试,每晚运行对账 job。这个 job 检查两件事:分录总和为 0,以及 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 而不记录分录。 这样初期运行很快,但当客户投诉时,你无法解释“钱去哪了”。
  • 修改或删除错误的分录。 记录一笔反向分录(reversal)作为新交易。
  • 锁不按顺序加锁导致在负载下发生死锁。PostgreSQL 会通过取消一个事务来打破死锁,用户会看到随机错误。
  • 在持有锁时调用外部 API,例如调用银行支付网关。锁会被持有数秒,吞吐量崩溃。将与银行相关的流程分离为 state machine(PENDING → COMPLETED/FAILED),使用暂存账户(hold)。
  • 不应该对非金钱或资产的数据使用这种设计,例如浏览计数器。在那里,小的差异是可以接受的,双式记账法只是额外的成本。

10. 结论

一个安全电子钱包的核心不在于新技术,而在于几条严格的原则:

  • 双式记账法 append-only,分录总和总是 0。
  • 所有写入命令都有 Idempotency key。
  • 按固定顺序进行悲观锁定,并在加锁后检查余额。
  • CHECK 约束作为最后的安全网。

在此基础上,我们通过子账户处理热账户,通过 Transactional Outbox 和 Kafka 与其他系统同步,并通过 Redis rate limit 和限制的 connection pool 应对突发流量。

进一步扩展时,可以将银行相关的流程分离为带暂存账户的 state machine。当表增长到数十亿行时,也可以按时间对 ledger_entry 进行分区。无论如何扩展,都要保持并发测试和对账 job 持续运行。

system-designspring-bootpostgresqlkafkafintech