上周公司支付系统出现了故障,在故障处理后的善后排查中发现了某账户某段时间内资金和流水对不上,最终发现了一个并发BUG。由于该BUG具有一定普通适性,故整理成文到内部知识库,简书也顺便update了。
数据定义
能够说明问题的最简数据如下。
CREATE TABLE `purse` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`money` DECIMAL(13,2) NOT NULL DEFAULT 0.0 COMMENT '账户金额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='钱包' ;
CREATE TABLE `user_order` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`money` DECIMAL(10,2) NOT NULL DEFAULT 0.0 COMMENT '订单金额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='订单';
INSERT purse VALUE (1,1);
INSERT user_order VALUE (1,10);
INSERT user_order VALUE (2,20);
伪代码
能构成该BUG的最简伪代码如下
1.sql('start transaction');
2.orderMoney=sql('select money from user_order where id =:id ')
3.sql('select money from user_purse where id =:id for update')
4.nowMoney=sql('select * from orders where id =:order_id ')+orderMoney
5.sql('user_purse set avail_money=:nowMoney where id =:id')
6.sql('commit')
故障原理
定义两个客户端/事务为A/B,其中一种能够触发BUG的执行流程如下。
//事务隔离级别为默认的REPEATABLE READ
A1 sql('start transaction');
B1 sql('start transaction');
A2 orderMoney=sql('select money from user_order where id =1' )//10
B2 orderMoney=sql('select money from user_order where id =2 ')//20
B3.sql('select money from user_purse where id = 1 for update')//1
B4.nowMoney=sql('select money from user_purse where id =1 ' )+orderMoney//21
B5.sql('user_purse set avail_money=:nowMoney where id =3')//purse=21
B6.sql('commit')//purse =21
A3.sql('select money from user_purse where id =:id for update')//purse=21
A4.nowMoney=sql('select money from user_purse where id =1 ')+orderMoney //问题触发点 purse=1+10
A5.sql('user_purse set avail_money=:nowMoney where id =1')//purse =11
A6.sql('commit')//purse =11
示例代码中,通过语句3的for update
对账户表的某一条记录来施加行锁以达到并发控制。
语句3~6间的流程即语句4,5都处于临界区,案例中由于事务B率先拿到了行x锁,所以事务A的语句3一直阻塞到事务B语句6执行完成。
临界区的处理本身没有任何问题。问题在于开发者在这里没有考虑MVCC和隔离级别,导致语句4在临界区引入了快照读。
SELECT FOR UPDATE
和SELECT LOCK IN SHARE MODE
这两个操作在Innodb中被成为一致性锁定读
这两者的性质是通过加锁来控制并发访问,在读取到的是记录的(已提交)实时值。
普通的SELECT
称为一致性非锁定读
其特性是通过MVCC(多版本)控制的方式读取数据。SELECT
并不会去等待锁的释放,而是直接获取数据的一个快照副本,在RR的隔离级别下,这个快照即事务开始时的数据快照。
因此A4读到了事务B修改前的数据。最终导致事务B在purse表的同一行记录被覆盖了。
解决方案
这个场景的修复方式有3种:
- 将隔离级别降低为RC(Read committed):RC的快照读获取的是数据已提交的最后一个版本,本案例中A4拿到的将会是B6提交后更新21,但这种办法本质是为事务引入’不可重复读‘。
2.将语句3和语句4合并,如果代码或框架上下文不允许合并。可以将语句4改成SELECT FOR UPDATE
/SELECT LOCK IN SHARE MODE
,避免快照读。
3.使用update set money+XX
这种自带X锁的表达式语法,这种是最建议的解决方案,除了避免了此处的快照读,还避免了应用层到mysql数据转换的潜在坑坑以及避免了加锁和事务处理不当的种种低级问题。在应用层计算数据更新后的值再写入数据库,是一种非常不健壮的方案,特别是在PHP这种弱类型语言中,服务异常导致的null随时会让你把某些数据初始化。
尾注:上文提到的“事务开启时的快照”是业内包括《高性能Mysql》《Mysql技术内幕》对RR下MVCC策略的一般描述。严格来说并不完全准确。
all consistent reads within the same transaction read the snapshot established by the first such read in that transaction.
RR隔离级别下,实际上快照读使用的是事务中第一条查询执行时的数据快照,所以如果想要复现,该问题例子中看似无关的语句2不能删除。如果语句2不存在,除非事务B的提交(B6)在在事务A的for update查询(A3)之前,才能复现该问题。