工作小记——莫名其妙的超卖
前两天,生产环境发生了超卖。排查过程极其纠结复杂。在这里尽可能详尽地记录下来。不仅是结论本身,思考的过程也是有价值。但是写得超乱,有空整理下
从未有过的,发生超卖了!!!毫无疑问,是因为最近新上的代码:把修改库存从悲观锁改成了乐观锁。但是理论上说也不应该存在问题。
最后超卖136000,查询投资流水表,只有一笔投资失败了,且投资金额为136000。可以肯定,和这笔投资有关。回滚了两次?会不会失败了马上回滚,但是流水没更新,定时作业检查的时候再回滚了一次?但是回库存和改流水状态真的是绑在一个事务里的,如果存在回滚两次的问题,以前也会有
去查了下这笔136000失败的原因为,资金不足。账户里只有10W。但是如果一开始就不足,在检查阶段就会报错,根本不会走到修改库存。查询发现当天该用户没有其他投资,账户一直是10W。不应该会在修改库存后发现资金不足。继续查数据库发现,用户初始投资意图为10W,之后被系统改成了136000才导致资金不足
id | value |
1 | 10 |
表test2
id | value |
1 | 1 |
例子一:正常情况下,别的事务提交后,能够获取到更新后的值,与事务开始时间无关
start transaction; | |
start transaction; |
|
update test1 set value=5 where id=1 and value>=10; --返回影响1行,更新成功 |
|
update test1 set value=5 where id=1 and value>=10; --等待 |
|
commit; | |
--返回影响0行,更新失败 |
|
select value from test1 where id=1; --结果为5,取到了最新值,与事务开始的时间无关 |
让开发人员在上线代码上改,两个线程一起启动,执行投资交易。并在select之后打出select的结果和update的结果。震惊!!!这么写代码结果竟是……结果是取到了旧值。
开发人员觉得可以理解,这就是mysql的隔离级别,RR,可重复读,读不到最新值,就是这样的。可是我不能接受啊!!!第一,这和数据库演示出的结果不同。第二,这也不叫可重复读,之前根本没读过啊。如果所有在更新前开启的事务,更新后都读不到更新后的值,这个也太不合理了。
继续做实验,
例子二:在更新提交前先读一次,拿到了旧数据的快照,更新后仍读取到旧值。因为隔离级别为RR,可重复读
start transaction; | |
start transaction; |
|
update test1 set value=5 where id=1 and value>=10; --返回影响1行,更新成功 |
|
select value from test1 where id=1; --结果为10。拿到了旧值 |
|
update test1 set value=5 where id=1 and value>=10; --等待 |
|
commit; | |
--返回影响0行,更新失败。update感知到的是新值,不然update就成功了 |
|
select value from test1 where id=1; --结果为10,取到了旧值,从之前的快照中获得的
|
虽然已经有了快照,但是update感知到的是新值,也说得通,update应是更为严谨的操作,不会适用多版本读中的内容。但是代码里,update是这个方法里的第一句话,这个方法是一个REQUIRED_NEW的事务。
开发意外发现,在进入这个方法前,将之前的大段代码都注释掉,就能取到新的值。难道是因为REQUIRED_NEW没有生效,被注掉的代码里有select语句产生了快照?
即使select其他完全没关系的表,也同样会影响到这张表
例子三:在更新提交前先执行一句select语句,不管是不是相关数据,不管是不是同一张表,都拿到了旧数据的快照,更新后仍读取到旧值
start transaction; | |
start transaction; |
|
update test1 set value=5 where id=1 and value>=10; --返回影响1行,更新成功 |
|
select value from test2 where id=1; --结果为1。 |
|
update test1 set value=5 where id=1 and value>=10; --等待 |
|
commit; | |
--返回影响0行,更新失败。update感知到的是新值,不然update就成功了 |
|
select value from test1 where id=1; --结果为10,取到了旧值 |
但是仔细做实验后,确定REQUIRED_NEW生效了,确实新开了一个事务,之前方法里的select不可能影响到这个事务
之后数据架构师研究后发现,开启的事务分为两种,start transaction;和start transaction with consistent snapshot。前者是在执行第一句select时获得快照。后者是在事务一开始就获得快照。
例子四:使用start transaction with consistent snapshot,好比在事务一开始执行一句select语句,会产生快照
start transaction with consistent snapshot; | |
start transaction; |
|
update test1 set value=5 where id=1 and value>=10; --返回影响1行,更新成功 |
|
update test1 set value=5 where id=1 and value>=10; --等待 |
|
commit; | |
--返回影响0行,更新失败。 |
|
select value from test1 where id=1; --结果为10,取到了旧值,事务一开始就取到了快照 |
测试代码中是否使用的是start transaction with consistent snapshot。但是开启事务是spring与mybatis控制的,找了下资料,没有提到过相关方面东西,一般也不会用这么偏门冷门的方式。而且无法解释注释掉一大段代码就没问题的现象。受此启发,将mybatis日志打出来看了下。注释掉代码,2个线程共有4个crearte new sqlsession。符合代码中的事务数量。而放开代码,共有5个crearte new sqlsession。原来注释掉的代码中有一个方法,是为了获取流水表主键id。应用刚启动,第一个线程来,会查询数据库获取id放入内存待分配。第2个线程直接从内存中拿。所以2个线程就查了一次数据库。但是这次查询对新事务应该完全没影响啊???
一行行代码读完实在找不到原因,只能一行行读日志。发现在进入这个事务后,在update库存前,有一句select语句????哪来的?代码里update是这个方法的第一句啊。通过开发回忆与查找,终于找到原来在mybatis中设置了个拦截器,执行update前会执行一句select语句检查投资流水是否被篡改的。是开发环境的做的原型,也不应用在这个业务上,不小心上到生产去的。感觉离真相越来越近了!!!但是貌似能取到新值和取到旧值两种场景,都有这句select,为什么一个没有产生快照呢?
和注释掉的代码有关,不注释的话,会取到流水表主键id,select成功,产生快照。注释掉的话,id为null,select不成功,则不产生快照。用实验来验证
例子五:在非空列用null查询,数据库不执行该语句,不产生快照
start transaction; | |
start transaction; |
|
update test1 set value=5 where id=1 and value>=10; --返回影响1行,更新成功 |
|
select value from test2 where id=null; --Empty set |
|
update test1 set value=5 where id=1 and value>=10; --等待 |
|
commit; | |
--返回影响0行,更新失败 |
|
select value from test1 where id=1; --结果为5,取到了新值,第一句select并没有产生快照 |
原本以为所有谜题都解开了~~~开发总监提出了疑问,说即使取到了id,应该是流水表不存在的id,结果仍应该是Empty set。其实和null没有区别。猜测用null和用不存在的id是不同的,在非空列用null,可能数据库引擎压根不查询了,就没有产生快照。用不存在的id,虽然也没结果,但是执行过查询了,就产生了快照。所以在允许为空的列用Null查,也会产生快照。用数据库验证了猜想。
例子六:用不存在的id查询,数据库还是执行该查询,产生快照
start transaction; | |
start transaction; |
|
update test1 set value=5 where id=1 and value>=10; --返回影响1行,更新成功 |
|
select value from test2 where id=2; --Empty set。不存在的id |
|
update test1 set value=5 where id=1 and value>=10; --等待 |
|
commit; | |
--返回影响0行,更新失败 |
|
select value from test1 where id=1; --结果为10,取到了旧值,第一句select产生快照 |
例子七:在允许空值的列使用null查询,数据库还是执行该查询,产生快照
start transaction; | |
start transaction; |
|
update test1 set value=5 where id=1 and value>=10; --返回影响1行,更新成功 |
|
select value from test2 where value=null; --Empty set。允许空值的列使用null搜索 |
|
update test1 set value=5 where id=1 and value>=10; --等待 |
|
commit; | |
--返回影响0行,更新失败 |
|
select value from test1 where id=1; --结果为10,取到了旧值,第一句select产生快照 |
终于!终于!终于!所有的谜题,都解开了!!!
将悲观锁改成了乐观锁——》失败之后需要额外处理最后一份投资的份额——》因为拦截器,在事务一开始的时候就产生了快照,重新获得的还是事务开始时的旧值——》计算出的份额反而比原始投资的大——》用户余额不足投资失败——》默认最后修改库存肯定成功,没有做检查,直接回滚了库存——》超卖