电子钱包设计:双式记账法的安全转账
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。有三层防御:
- 在边界处按用户进行 rate limit,使用 Redis。
- 限制 connection pool(HikariCP)以防止 DB 过载,搭配短 timeout 实现快速失败。
- 将非关键任务推迟到 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 持续运行。