工作小记——莫名其妙的超卖

前两天,生产环境发生了超卖。排查过程极其纠结复杂。在这里尽可能详尽地记录下来。不仅是结论本身,思考的过程也是有价值。但是写得超乱,有空整理下

从未有过的,发生超卖了!!!毫无疑问,是因为最近新上的代码:把修改库存从悲观锁改成了乐观锁。但是理论上说也不应该存在问题。

最后超卖136000,查询投资流水表,只有一笔投资失败了,且投资金额为136000。可以肯定,和这笔投资有关。回滚了两次?会不会失败了马上回滚,但是流水没更新,定时作业检查的时候再回滚了一次?但是回库存和改流水状态真的是绑在一个事务里的,如果存在回滚两次的问题,以前也会有

去查了下这笔136000失败的原因为,资金不足。账户里只有10W。但是如果一开始就不足,在检查阶段就会报错,根本不会走到修改库存。查询发现当天该用户没有其他投资,账户一直是10W。不应该会在修改库存后发现资金不足。继续查数据库发现,用户初始投资意图为10W,之后被系统改成了136000才导致资金不足

遍历代码setAmount的地方只有两处,初始赋金额,以及在修改库存失败后,修改金额(最后一笔,资产额度不够的情况)。通过对之前检查红包钱包余额以及修改库存后对红包钱包扣款的两次调用的日志看,确实是这边金额被该写了。
这样就归为两个核心问题:
1.初始投资10W,产品额度不够,重新计算,竟然计算出更大的136000???
2.就算计算错误,导致资金不够失败,也不该导致超卖啊。
 
来好好看一下代码
原代码:
select …… for update           //悲观锁
if(购买 > 总量-已售)
  购买 = 总量-已售          //因为使用悲观锁,最后一份可以在修改之前处理,所以计算出来的肯定对。
update …… set 已售=已售+购买 ……     //update不会出错
插入流水记录等
调用红包、钱包等
失败则回滚库存
 
现代码:
update …… where …… and 总量>已售+购买
if (update 成功)
  插入流水记录等
else
  select 总量,已售 ……
  购买 = 总量-已售         //事实证明   这里金额已经被从10W变成了136000
  update …… set 已售=已售+购买 and 总量>已售+购买       //原本已经份额不足了,整个过程就一笔失败,不存在库存回滚的情况,所以这句也肯定失败
插入流水记录等           //没有对update结果进行判断,默认是成功的
调用红包、钱包等
失败则回滚库存                  //明明update失败,没有减库存,却依然在失败的时候进行了回滚
 
第2个问题找到原因了,超卖的场景是复现了。但是为什么会越算越大呢?会不会是别人update还没commit,他就select到了旧值。再重新看一遍代码
update …… where …… and 总量>已售+购买         //改写库存在这里。前者没有commit后者肯定会卡在这里。
if (update 成功)
  插入流水记录等                
else
  select 总量,已售 ……      //查询最后一笔余额在这里。能到这里,说明之前的update都已经commit了,不可能update时产品额度<10000,更新失败,这边却取到旧值
  购买 = 总量-已售         
  update …… set 已售=已售+购买 and 总量>已售+购买       
插入流水记录等        
调用红包、钱包等
失败则回滚库存
 
查询数据库发现,在这笔投资之前,产品额度剩余231000。与这笔投资几乎差不多时间来的投资有95000、136000(原100000)、39000等其他。可以发现,136000就是231000-95000来的。说明95000先来。然后39000和136000(原100000)同时来。资产额度<100000,重新查询产品,查出136000。就是说,39000明明commit了并阻止了100000更新,select出的却是39000commit前。
 
百思不得其解啊!!!怎么也想不到合适的解释,用数据库两个终端来模拟,也是只要update不在等待,select肯定能拿到新值
表test1
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产生快照

终于!终于!终于!所有的谜题,都解开了!!!

将悲观锁改成了乐观锁——》失败之后需要额外处理最后一份投资的份额——》因为拦截器,在事务一开始的时候就产生了快照,重新获得的还是事务开始时的旧值——》计算出的份额反而比原始投资的大——》用户余额不足投资失败——》默认最后修改库存肯定成功,没有做检查,直接回滚了库存——》超卖

posted @ 2017-03-29 10:52  架构之美,智慧之光  阅读(270)  评论(0编辑  收藏  举报