转账中途失败时仍保持余额守恒:设计原理
一句话总结
把扣减、增加和转账记录以原子方式绑在一起,并重现并发的余额竞争。
为什么需要它
转出账户已经扣了钱,还没来得及给转入账户加上,进程就崩溃了。重试时因为没有转账 id,又扣了一次钱。反过来,如果先提交转账记录,重试就会认为已经完成,入账便永远丢失。即使金额计算用整数做得很精确,只要事务边界不对,结果也是错的。
工作原理
accounts 和 transfers 放在同一个 SQLite 文件里。先校验金额有效、两个账户不同,然后在 BEGIN IMMEDIATE 内确认是否为重新发送,并检查余额。扣减之后设置一个故障注入点,再把入账和转账记录一起一次性提交。相同的 id 加相同的内容属于重复,不做任何操作;内容不同则是冲突。最后同时执行两笔余额不足的转账,确认只有一笔成功。
BEGIN IMMEDIATE → 중복/잔액 검사 → 차감 → 장애 지점 → 입금+기록 → COMMIT
阅读契约并预测失败的工作表
下面并不是要求你把实现整个背下来的答案,而是逐步进行的代码评审。每个改动片段都有意破坏了契约。要注意,改动之后正常用例仍然可能通过。执行之前,先预测观测哪些输入、异常、状态能让差异显现出来;实现之后,再拿这个预测与实际结果对比。
1. 创建账本表
init_db(path) 以幂等方式创建 accounts(id TEXT PRIMARY KEY,balance INTEGER NOT NULL) 和 transfers(id TEXT PRIMARY KEY,source TEXT NOT NULL,target TEXT NOT NULL,amount INTEGER NOT NULL)。
判断依据:把唯一性放在 DB 里,以避免同一个转账 id 被提交两次。
需要评审的有问题的改动片段:
CREATE TABLE transfers
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
2. 账户只创建一次
add_account(path, account_id, amount) 只允许(不含 bool 的)大于等于 0 的 int,并用 INSERT 创建账户。已存在的账户以 sqlite3.IntegrityError 拒绝,并保留其余额。
判断依据:避免初始化时的 UPSERT 把实际余额重置回初始值。
需要评审的有问题的改动片段:
INSERT OR REPLACE INTO accounts VALUES
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
3. 区分不存在的账户与余额为 0
balance(path, account_id) 返回账户余额,账户不存在时抛出 KeyError。
判断依据:如果把“不存在”换成 0,转账就可能对一个错误的账户继续执行。
需要评审的有问题的改动片段:
return 0
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
4. 校验转账输入
validate_transfer(source,target,amount) 在 source!=target 且 amount 是(不含 bool 的)正 int 时返回 None,否则抛出 ValueError。
判断依据:提前拒绝同一账户之间的转账和负数转账。
需要评审的有问题的改动片段:
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
5. 把三次写入绑进一个事务
transfer(path,tx_id,source,target,amount,fault=lambda:None) 在 id 和内容都相同时返回 False;id 冲突、账户不存在、余额不足则抛出 ValueError。新的转账以原子方式依次执行扣减 → fault() → 入账 → 插入 transfers,并返回 True。
判断依据:扣减之后强制抛出异常,检查余额和转账记录是否都恢复如初。
需要评审的有问题的改动片段:
db.commit()
fault()
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
6. 以确定的顺序读取转账历史
history(path) 把 transfers 按 id 升序,以 (id,source,target,amount) 元组列表的形式返回。
判断依据:明确写出 ORDER BY,避免把输入顺序与查询顺序混为一谈。
需要评审的有问题的改动片段:
ORDER BY id DESC"
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
7. 确认守恒量
total_balance(path) 是 accounts.balance 的合计。没有账户时为 0。
判断依据:单个请求的返回值,与总金额是否守恒,要分别验证。
需要评审的有问题的改动片段:
MAX(balance)
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
8. 并发转账不会超出余额
compete(path,source,target,amount) 用两个不同的 id(race-a 和 race-b),在两个线程中执行金额相同的 transfer。返回的列表中,只把 ValueError 转换为 False。如果余额只够一次转账,成功的就只有一笔。
判断依据:如果在事务之外预先检查余额,两个请求就可能看到同一份余额而都成功。
需要评审的有问题的改动片段:
["race-a","race-a"]
将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。
在现场相遇的样子
它并不是能取代真实金融账本在会计、审计和法律方面要求的系统。这里不涉及币种、小数单位、手续费和多币种,金额是以最小单位表示的正整数。调用外部支付系统并不包含在这个 DB 事务之内,因此需要另行设计。
下一项实验要做什么
八个步骤会连成一个可运行的成果。创建账本表 → 账户只创建一次 → 区分不存在的账户与余额为 0 → 校验转账输入 → 把三次写入绑进一个事务 → 以确定的顺序读取转账历史 → 确认守恒量 → 并发转账不会超出余额。
每个步骤检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,故意改动边界比较或清理代码,确认哪些测试会失败。说明为什么前面的测试在后面的步骤中仍然成立,并写出一个本实验不保证的运维条件。