TT Lab
开始
学习 学习路径 课程

幂等性 — 点两次也只扣一次款

转账中途失败时仍保持余额守恒

在 TT Lab 中继续学习

目标

把扣减、增加和转账记录以原子方式绑在一起,并重现并发的余额竞争。

为什么重要

转出账户已经扣了钱,还没来得及给转入账户加上,进程就崩溃了。重试时因为没有转账 id,又扣了一次钱。反过来,如果先提交转账记录,重试就会认为已经完成,入账便永远丢失。即使金额计算用整数做得很精确,只要事务边界不对,结果也是错的。

步骤

  1. 在 /root/work/idem-ledger-transfer-lab/service.py 中,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)。

先做一次准备。已有的文件不会被覆盖。

mkdir -p /root/work/idem-ledger-transfer-lab
test -e /root/work/idem-ledger-transfer-lab/service.py || cp /opt/fixtures/ten_labs/idem-ledger-transfer-lab/service.py /root/work/idem-ledger-transfer-lab/service.py
cd /root/work/idem-ledger-transfer-lab
  1. 在 /root/work/idem-ledger-transfer-lab/service.py 中,add_account(path, account_id, amount) 只允许(不含 bool 的)大于等于 0 的 int,并用 INSERT 创建账户。已存在的账户以 sqlite3.IntegrityError 拒绝,并保留其余额。

  2. 在 /root/work/idem-ledger-transfer-lab/service.py 中,balance(path, account_id) 返回账户余额,账户不存在时抛出 KeyError。

  3. 在 /root/work/idem-ledger-transfer-lab/service.py 中,validate_transfer(source,target,amount) 在 source!=target 且 amount 是(不含 bool 的)正 int 时返回 None,否则抛出 ValueError。

  4. 在 /root/work/idem-ledger-transfer-lab/service.py 中,transfer(path,tx_id,source,target,amount,fault=lambda:None) 在 id 和内容都相同时返回 False;id 冲突、账户不存在、余额不足则抛出 ValueError。新的转账以原子方式依次执行扣减 → fault() → 入账 → 插入 transfers,并返回 True。

  5. 在 /root/work/idem-ledger-transfer-lab/service.py 中,history(path) 把 transfers 按 id 升序,以 (id,source,target,amount) 元组列表的形式返回。

  6. 在 /root/work/idem-ledger-transfer-lab/service.py 中,total_balance(path) 是 accounts.balance 的合计。没有账户时为 0。

  7. 在 /root/work/idem-ledger-transfer-lab/service.py 中,compete(path,source,target,amount) 用两个不同的 id(race-a 和 race-b),在两个线程中执行金额相同的 transfer。返回的列表中,只把 ValueError 转换为 False。如果余额只够一次转账,成功的就只有一笔。

参考

创建账本表

在 /root/work/idem-ledger-transfer-lab/service.py 中,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)。

先做一次准备。已有的文件不会被覆盖。

mkdir -p /root/work/idem-ledger-transfer-lab
test -e /root/work/idem-ledger-transfer-lab/service.py || cp /opt/fixtures/ten_labs/idem-ledger-transfer-lab/service.py /root/work/idem-ledger-transfer-lab/service.py
cd /root/work/idem-ledger-transfer-lab

把唯一性放在 DB 里,以避免同一个转账 id 被提交两次。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/01-contract.sh 确认。

账户只创建一次

在 /root/work/idem-ledger-transfer-lab/service.py 中,add_account(path, account_id, amount) 只允许(不含 bool 的)大于等于 0 的 int,并用 INSERT 创建账户。已存在的账户以 sqlite3.IntegrityError 拒绝,并保留其余额。

避免初始化时的 UPSERT 把实际余额重置回初始值。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/02-contract.sh 确认。

区分不存在的账户与余额为 0

在 /root/work/idem-ledger-transfer-lab/service.py 中,balance(path, account_id) 返回账户余额,账户不存在时抛出 KeyError。

如果把“不存在”换成 0,转账就可能对一个错误的账户继续执行。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/03-contract.sh 确认。

校验转账输入

在 /root/work/idem-ledger-transfer-lab/service.py 中,validate_transfer(source,target,amount) 在 source!=target 且 amount 是(不含 bool 的)正 int 时返回 None,否则抛出 ValueError。

提前拒绝同一账户之间的转账和负数转账。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/04-contract.sh 确认。

把三次写入绑进一个事务

在 /root/work/idem-ledger-transfer-lab/service.py 中,transfer(path,tx_id,source,target,amount,fault=lambda:None) 在 id 和内容都相同时返回 False;id 冲突、账户不存在、余额不足则抛出 ValueError。新的转账以原子方式依次执行扣减 → fault() → 入账 → 插入 transfers,并返回 True。

扣减之后强制抛出异常,检查余额和转账记录是否都恢复如初。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/05-contract.sh 确认。

以确定的顺序读取转账历史

在 /root/work/idem-ledger-transfer-lab/service.py 中,history(path) 把 transfers 按 id 升序,以 (id,source,target,amount) 元组列表的形式返回。

明确写出 ORDER BY,避免把输入顺序与查询顺序混为一谈。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/06-contract.sh 确认。

确认守恒量

在 /root/work/idem-ledger-transfer-lab/service.py 中,total_balance(path) 是 accounts.balance 的合计。没有账户时为 0。

单个请求的返回值,与总金额是否守恒,要分别验证。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/07-contract.sh 确认。

并发转账不会超出余额

在 /root/work/idem-ledger-transfer-lab/service.py 中,compete(path,source,target,amount) 用两个不同的 id(race-a 和 race-b),在两个线程中执行金额相同的 transfer。返回的列表中,只把 ValueError 转换为 False。如果余额只够一次转账,成功的就只有一笔。

如果在事务之外预先检查余额,两个请求就可能看到同一份余额而都成功。

保存后用 bash /opt/lab/checks/idem-ledger-transfer-lab/08-contract.sh 确认。