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

银行现场的语言

两个柜台读到同一个余额:并发取款与限额

在 TT Lab 中继续学习

目标

把同一个账户上重叠到来的取款所造成的负余额,做成可以复现的样子,然后把确认和更新合并成一条语句,消除这个空隙。用同样的方式加上每日累计额度,只对锁争用加上带上限的重试,最后以不变式检查和证据报告收尾。

为什么重要

先读出余额确认、再另行扣减的结构,在请求一个一个进来的期间,永远是正常的。同一个账户上请求重叠的那一刻,两者以同一个余额为依据被批准,扣减两次都被反映,余额就变成了负数。这时账本和余额并不会互相对不上,所以只看“账本合计 == 余额”的结账检查,会放过这种事故。 如果想用加压来复现,就要依赖概率。本实验不靠压力,而是用对准重叠点的方法做出确定性的复现。四十个请求用不了 1 秒,每次运行得到的数字都一样。 评分器不会相信你写的语句。它会把你留下的记录文件中的值,逐个与实物 DB 对照。评分器不会再次引发竞争——因为如果复现是概率性的,评分也会摇摆。

步骤

  1. 创建并运行 /root/limit/make_bank.py,在 /root/limit/bank.db 中放入 6 个账户和 6 行开户账本。
  2. 用 /root/limit/race_naive.py 做出读—改—写的路径,在 A-RACE 账户上以 2 人 × 20 轮的方式重叠运行,并把结果留在 /root/limit/race_naive.json 中。
  3. 在 /root/limit/transfer.py 中做出条件更新的路径,在 A-SAFE 账户上以同样的条件运行,并把结果留在 /root/limit/race_safe.json 中。
  4. 用 A-FEE 账户亲自体验写锁,把 busy_timeout 为 0 与宽裕的值之间的差别留在 /root/limit/lock_probe.json 中。
  5. 在 A-DAILY 账户上,把每日累计额度 300000 韩元放在更新语句内部来判定,并把结果留在 /root/limit/daily_limit.json 中。
  6. 向 A-RETRY 账户以 3 人 × 10 轮的方式放入请求,把只针对锁争用的、带上限的重试策略及其结果,留在 /root/limit/retry_policy.json 中。
  7. 对 6 个账户设置三个不变式,把违反列表留在 /root/limit/invariant.json 中。
  8. 把到这里为止的数字从账本里提取出来,整理到 /root/limit/limit_report.md 的四个小节中。

参考

生成账户快照

创建并运行 /root/limit/make_bank.py,在 /root/limit/bank.db 中放入 account、ledger、attempt 三张表,以及 6 个账户和 6 行开户账本。

账户是 A-RACE 150000/100000、A-SAFE 150000/200000、A-DAILY 1000000/300000、A-RETRY 300000/500000、A-FEE 80000/50000、A-POOL 1950000/5000000(余额/每日额度)。开户行的 path 设为 'OPEN',biz_date 对齐为 2026-09-17。后面的步骤不能动这一行,这样重新评分时才会得到同样的答案。

用两个重叠的请求复现负余额

创建 /root/limit/race_naive.py,在 A-RACE 账户上以 2 个工作者 × 20 轮 × 10000 韩元的方式重叠运行,并把结果留在 /root/limit/race_naive.json 中。

保持“读出余额,用读出的值判断,然后用 balance = balance - amount 更新”的顺序不变。让每一轮都等到两个工作者都读完,它们就会看到同一个余额。记录里要放入批准和拒绝的次数、批准的合计、最后的余额,并且每个请求在 attempt 表里也留下一行。

把确认和更新放进一条语句

创建 /root/limit/transfer.py,在 A-SAFE 账户上以与第 2 步相同的条件运行,并把结果留在 /root/limit/race_safe.json 中。

把余额条件加到 UPDATE 的 WHERE 里,用改变的行数来判定是否批准。读出的余额只用于记录(seen_balance)。读的时候看起来够用、却被拒绝的请求有多少笔,就是这一步的证据。

亲自体验写锁与 busy_timeout

在 A-FEE 账户上,当一方用 BEGIN IMMEDIATE 持有锁的期间,分别以 busy_timeout 为 0 和宽裕的值尝试同样的更新,并把结果留在 /root/limit/lock_probe.json 中。

持锁的一方扣 1000 韩元,等待的一方扣 2000 韩元,path 记为 'lock'。busy_timeout 为 0 时会立刻失败,宽裕时会睡眠到锁释放再成功。被阻塞的尝试,也要以 decision='busy' 留在 attempt 表里——没有记录,就无法知道发生过什么。

把每日累计额度放进同一条语句

向 A-DAILY 账户放入 2 人 × 10 轮 × 50000 韩元,把每日额度 300000 韩元放在更新语句内部判定,并把结果留在 /root/limit/daily_limit.json 中。

如果把累计额单独放在一列里,从这一列与账本对不上的那一刻起,额度就会撒谎。要把从账本里统计当天取款合计的子查询,放进 UPDATE 的条件里。拒绝的原因,要区分余额不足和超出额度并留下。

把重试写成策略并遵守上限

向 A-RETRY 账户放入 3 人 × 10 轮 × 20000 韩元,把只针对锁争用的、带上限的重试策略及其结果,留在 /root/limit/retry_policy.json 中。

把 busy_timeout 设为 0,争用会以异常的形式冒上来,只抓这个异常再重试。余额不足无论再试多少次,答案都一样,所以不能重试。每个请求尝试了几次,要留在 attempt.attempts 里,并且不能有超过策略上限的尝试。

用三个不变式检查六个账户

对 6 个账户设置 balance_matches_ledger、no_negative_balance 和 daily_limit_respected 三个不变式,并把违反列表留在 /root/limit/invariant.json 中。

只看第一个不变式,第 2 步的事故会通过——因为批准了多少,账本里也就都记下了多少。每个违反项都要写上账户、是哪个不变式、观测值(observed)和基准值(limit)。评分器会在 DB 里重新做同样的计算,来对照列表。

用证据报告收尾

在 /root/limit/limit_report.md 中设置“## 发生了什么”“## 如何复现的”“## 如何堵住的”“## 剩余的风险与运维规则”四个小节,并连同从账本里提取的数字一起写下来。

如果用手抄数字,这一次是对的,下一次运行就会错。请用读取 DB 来生成报告的脚本。“发生了什么”一节要有最后的余额和超额划出的金额,复现一节要有重叠运行的请求数,堵住一节要有通过条件更新被批准的笔数,最后一节要有当天划出到额度为止的金额,以及包含重试在内的尝试合计。