余额是够的 — 只是两个柜台读到了同一个数
一句话总结
如果余额确认和余额扣减分成了两条语句,别的请求就会插在这中间,以同一个余额为依据被批准两次。修复的方法是,把确认塞进更新语句里,并且用“实际改变的行数”,而不是“我读到的值”来判定是否批准。
为什么需要它
故障报告里最常出现的句子之一是“余额变成了负数”。打开代码一看,通常是这样的。先用 SELECT balance 读出余额,在 Python 或 Java 这一侧用 if balance >= amount 做判断,然后执行 UPDATE account SET balance = balance - amount。在一次只进来一个请求的测试环境里,它永远正常。
问题出在同一个账户上请求重叠的那一刻。发薪日的自动转账和客户在 App 上的取款,如果在同一个 100 毫秒之内到达,两者都会先执行 SELECT,并且都判断余额充足。随后两条 UPDATE 依次发出。两个都被批准了,两个都记入了账本,余额变成负数。有意思的是,这时账本和余额并不会互相对不上。因为扣减两次都被正确地反映了。所以,只确认“账本合计 == 余额”的结账检查,会悄悄放过这种事故。
这类缺陷,因为难以复现,所以会长期留存。经常有人说“加压测试看看”,但加压是概率。运气不好,跑一个小时也不出现;就算运气好出现了一次,也不能保证下一次运行还会出现。如果复现是概率性的,那么是否修好了,也只能用概率来说。
工作原理
首先是让复现变成确定性的方法。不是加大压力,而是把重叠的点对准。如果设置一个屏障,让两个请求按“两个都读完之后才两个都写”的顺序进行,那么每一轮都必然读到同一个余额。四十个请求用不了 1 秒,每次运行得到的结果都一样。复现是确定性的,“修好了”这句话才有依据。
修复这一侧只需要一条语句。
UPDATE account SET balance = balance - :amt
WHERE acct_id = :acct AND balance >= :amt;
这条语句改变的行数如果是 1,就是批准,如果是 0,就是拒绝。改变的行数,在 SQLite 里用 changes() 读取,在 Python 的 sqlite3 模块 里用游标的 rowcount 读取。这里重要的是,我们之前读出的余额,不用于判定。读到的值只用于记录和说明,是否批准由数据库来决定。
每日累计额度也以同样的形式加上去。如果把累计额单独放在一列里,那么从这一列与账本对不上的那一刻起,额度就会撒谎,所以要把从账本里统计当天取款的子查询放进条件里。
UPDATE account SET balance = balance - :amt
WHERE acct_id = :acct AND balance >= :amt
AND (SELECT COALESCE(-SUM(amount),0) FROM ledger
WHERE acct_id = :acct AND biz_date = :d AND amount < 0) + :amt <= daily_limit;
接下来是事务和锁。SQLite 的 BEGIN 文档 明确写道,读事务可以同时开多个,而写事务一次只能有一个。并且指出了一个陷阱。以默认值 BEGIN DEFERRED 开始,先执行 SELECT,就会开启读事务,而后面的写语句会尝试把读升级为写,如果别的连接已经在修改数据库,升级就无法进行,会以 SQLITE_BUSY 失败。BEGIN IMMEDIATE 从一开始就开启写事务,把这种升级本身消除了。所以“先读后写”的事务,从一开始就用 IMMEDIATE 来开启更安全。
等待由 PRAGMA busy_timeout 负责。锁被占用时,不是立刻失败,而是睡眠指定的毫秒数之后再次尝试。默认值是 0,所以什么都不等。锁是怎样分阶段升降的,在 文件锁与并发文档 中分阶段作了记载,哪个错误码出现在什么情况下,则由 结果码文档 整理。
最后是重试。重试只用于可以再试一次的失败。锁争用随着时间推移会解除,所以可以再试;而余额不足或超出额度,无论再试多少次,答案都一样,所以不能再试。没有上限的重试会扩大故障。要把上限、等待间隔,以及针对哪些失败重试,都写成策略,并把尝试次数留在记录里。
在现场相遇的样子
第一,是“用事务包起来了,所以没问题”的误解。事务给的是原子性,并不会阻止别的请求在这期间修改同一行。起这个作用的是隔离级别和锁,而把条件放进更新语句,是用最低成本达到同样效果的方法。
第二,是重试扩大事故的情形。某个团队对所有数据库错误都设置了无限重试,结果某一个锁一长,等待中的请求就堆积起来,连接数撞到了上限。重试只是推迟失败,并不会减少失败。如果策略里没有上限和间隔,那样的重试就是炸弹。
第三,是只设一个检查项。只看账本合计与余额是否一致的结账检查,会放过这次事故。必须把负余额和超出额度另外立项,才能抓到。检查要并排设置多个,并且把查看了什么与观测值一并留下。
实际工作中真正重要的事
- 判定由更新语句来做,而不是应用程序。读出来的值只用于说明和记录。
- 复现靠重叠来制造,而不是靠压力。概率性的复现,不能成为“修好了”的证据。
- “先读后写”的事务用
BEGIN IMMEDIATE开启。升级是不会等待的。 - 重试只针对锁争用,并且要带上限。绝不能用于业务判定。
- 检查要并排设置多个不变式。只看一个,就会出现能通过的事故。
下一项实验要做什么
亲手做出六个账户的快照,用读—改—写的路径,把负余额做成可以复现的样子。然后用条件更新堵住同样的重叠,亲身体验写锁和 busy_timeout。把每日累计额度放进同一条语句里,加上带上限的重试之后,用三个不变式检查六个账户,以证据报告收尾。