仔细思考之后,发现只需要赔6w。
你好呀,我是why。
上周发了《几行烂代码,我赔了16万》 16w 这篇文章之后,有不下十个朋友来找我,问我一些文章中的问题。
因为至少有一大批人看了文章之后,在我没有提供源码的情况下,自己搭建了环境,然后把项目跑起来,并去验证了文章中的一些观点。
我没有提供源码,主要是因为我觉得都是非常简单的 Demo 级别的代码,但是我会把思路讲的非常清楚。
所以我觉得,你要是真心想深入了解一下,花个半小时就能按照文中的描述,把项目跑起来。
别懒,好嘛,自己多动动手,是很有收益的事情的。
什么,你问我为什么不愿意花半小时把代码放到 git 上去?
那不是因为我懒嘛。
另外,经过朋友提醒,我发现,其实我只需要亏 6w 啊:
最最后,我又发现,这特么不是我假设的场景吗。其实我一分钱都不需要赔呀。
好了,本文就顺着前面的文章接着往下说。
前情提要
为了让新老朋友快速入戏,先给大家简单的做一个前情提要。但是我还是强烈建议你去看前一章,以做到无缝衔接。
怎么样,有没有发条张内味,懂的都懂,不多解释。
首先,我们有这样的一份代码:
逻辑概述起来很简单:
0.加锁 1.查询库存。 2.判断是否还有库存。 3.有库存则执行减库存,创建订单的逻辑。 4.没有库存则返回。 5.释放锁
但是这个方法上还加了一个 @Transactional
注解,坏就坏在这个注解上。
假设我们模拟 1000 个人来抢库存:
在 MySQL 的默认隔离级别下,如果我们的库存是 10 个,那么程序执行完成后,我们的订单表必然是 20 个,一个不多一个不少。
就是天王老子来了,它也是 20 个,不会变。
为什么是 20 个?
先回答一个很多同学都关心的问题。
为什么订单数总是 20 ?
关于这个 20,就很微妙了。
通过下面我截取的一部分日志也能观察出来一个很奇怪的现象:
首先,我们看加锁和释放锁的过程,其实是没有问题的。
都踩在正确的节点上:加锁->释放->加锁->释放...
没有任何毛病。
问题出在库存上的,把上面的图画的细一点,就是这样的:
线程 Thread-11 虽然对库存进行了减一的操作,但是事务并没有提交。所以,Thread-107 能读库存为 2。
这一点在上一篇文章着重分析过,不再赘述。
因此连续两个库存为 2 ,就是这样的来的。
那么 Thread-46 为什么读不到库存为 2 呢?
这是一个关键的问题。
Thread-46 读到的一定是它前面的第两个线程,也就是 Thread-11 的事务提交之后的库存,也就是 1。
是的,我这里没有写错,就是前面第两个。
有的同学说不对啊,根据你上面的图,Thread-46 完全有可能在 Thread-107 释放锁之后,赶在 Thread-11、Thread-107 提交事务之前做数据库查询的操作呀?
比如在上面的图片中加入四个时刻:
由于是多线程的情况,它们之间的关系完全有可能是这样的:
首先,T2 时刻肯定是先于 T3 和 T4 的,因为只有释放了锁之后才能触发当前事务的提交操作,和其他线程的加锁操作。
T1 时刻和 T3 时刻之间是没有先后顺序的,因为这两个事务的提交说不准谁先谁后。
但是 T4 时刻完全有可能是先于 T3 和 T1 时刻的,在这两个事务提交之前,抢先执行了查询库存的操作。
也就是说,虽然前两个线程都扣减库存了,但是还没提交事务,这个时候 T3 时刻读取到的库存理论上还是为 2 吧?
对不对?
你别说,仔细一想还挺有道理的。
但是,朋友,我告诉你。
T2 时刻一定是晚于 T1 时刻的。 T3 时刻一定是晚于 T1 时刻的。 T4 时刻一定是晚于 T1 时刻的。 T3 时刻和 T4 时刻,推导不出必然的先后关系。但是一定都晚于 T2 时刻。
你别看这么轻飘飘的四行字,我硬是把自己给绕进去了,想了整整一个下午,然后又写了各种各样的代码去验证它们的正确性,生怕自己给搞错了。
接下来我们一个个的说:
T3 时刻一定是晚于 T1 时刻的。
能执行 T3 时刻的事务提交操作,那么必然已经完成了 T2 时刻的释放锁的操作。
按照前面画的线程图,如果完成了释放锁的操作,那么必然完成了扣减库存的操作。
这个没毛病,对吧?
关键节点就在于扣减库存的这个操作。
对应下面这个 sql:
UPDATE product set product_count=product_count-1 where id=1;
有没有悟出点什么。
如果你还没反应过来,我提个醒:
在数据库的 RR 隔离级别下,上面这个 sql 上的是什么锁?
是不是加的行锁?
而这个 sql 要成功执行的先决条件是什么?
是不是要前一个线程把行锁给释放了?
而前一个线程什么时候释放行锁?
是不是要等到事务提交的时候?
等等,前一个线程事务提交的时候,这不就是 T1 时刻吗?
由此可得,T3 时刻一定是晚于 T1 时刻的。
而关于 T4 时刻为什么一定晚于 T1 时刻,其实就很好理解了。
只有 Thread-107 线程释放锁之后,即 T2 时刻之后,Thread-46 线程才能获取到锁。
那么按照我们前面的推理,T2 时刻,一定是在 T1 时刻之后。
根据传导性,T4 时刻一定晚于 T1 时刻。
而根据多线程的特性,T2 释放锁之后,有可能执行提交事务的逻辑,即 T3。
也有可能被挂起,然后程序先执行到了 T4。
所以,T3 时刻和 T4 时刻,推导不出必然的先后关系。但是一定都晚于 T2 时刻。
而 T3 时刻,不管是提交之前还是之后,此时的库存一定已经是 1 了,因为 T1 已经提交了事务。
同理,T4 时刻,不管是在 T3 时刻之前还是之后执行,它读取到的库存,一定是 T1 时刻提交事务之后的库存。
所以,你再看我前面这句话,你就能理解了:
Thread-46 读到的一定是它前面的第两个线程,也就是 Thread-11 的事务提交之后的库存,也就是 1。
而纵观整个日志,你会发现日志中库存按照顺序打印是这样的:
我前面给你解释了 2->2->1 的这个流程,所以你应该能按照这个思路推断出整流程了。
也就能明白,为什么就算是天王老子来了,它也必须得是 20 单。
如果上面把你看懵了,没关系,你就记住一句话:
由于操作的是同一条数据库数据,因为行锁的存在,导致线程的阻塞,会出现排队现象。
接着,我再说一下,我写文章的时候把我绕了很久,甚至把我绕进去了的一个逻辑。
最开始,我在图上标记时刻的时候是这样的:
我就在想,T3 时刻会不会也读到库存为 2 呢?
为什么不能呢?
在极端情况下,T1、T2 都被阻塞了,都没有提交,T3 时刻完全有可能读到库存为 2 呀?
比如,我在程序里面使用编程式事务,让两个线程在提交事务之前,先睡眠一下。
然后我在数据库工具里面直接执行查询库存的操作,查出来的应该是 2 呀。
后来,我说服了自己,我觉得这是有可能的,极端的情况下会出现这样的情况。
而这样的情况一出现,订单就可能会大于 20 了,推翻我之前的观点。
我觉得理论是可行的,已经做好重写的准备了,只需要模拟一下这个极端场景就行了。
于是我写了一份代码出来,然后调试了 10 分钟,都没有看到想要的现象。
我心想,这现象还真特么奇怪又极端。
又调了 10 分钟,发现还是不行。
我就停下来,开始扣脑袋了。
才猛然的发现一个真理:能到 T2 时刻提交事务,那么 T1 时刻必然已经执行完成。因为 T1 和 T2 之间还有一个扣减库存的数据库操作,有行锁,所以必须要等待 T1 提交才能执行。
也就是说:不可能出现 T1,T2 都被阻塞的情况。
前面的假设不成立。
我写这个小片段的意思就是:
实践是检验真理的唯一标准。
看一眼行锁
前面说了这么多行锁,现在我就带你亲眼看看这个神奇的玩意。
首先,我们在 MySQL 工具里面执行这个 SQL,模拟事务开启,但是还没有提交的状态:
这时用这条 sql 可以查询到当前的锁信息如下:
select * from information_schema.innodb_trx;
需要说明的是,查询信息字段并没有截全。
这个时候,发起程序调用:
你会发现,程序就卡在更新语句的地方,走不动了。
为啥?
还能为啥,锁住了呗。
再看一下当前的锁的情况:
多了一条叫 LOCK_WAIT 状态的数据,在等待前一个正在跑的事务提交锁。
所以从程序的角度来看,就是阻塞在这里了。
而大家经常在各大秒杀相关的文章里面都能看到这这句 SQL:
UPDATE product set product_count=product_count-1 where id=1 and product_count>0;
同样的道理,用这条 SQL 来进行兜底,假设只有一个库存了,有 100 个线程都读到了这一个库存,然后来同时执行这个 SQL,也保证绝对不会出现超卖的情况。
因为到 MySQL 层面,它会帮我们保证,只会有一个线程执行成功返回更新的条数为 1,其他的都是 0。
程序里面控制,为 1 才能算秒杀成功。
MySQL 层面,说具体点就是锁。
再说具体一点,就是行锁。
当然了,表锁也能保证,但是这种情况,为什么不优化为行锁呢?
好了,我们接着说阻塞。
注解上的超时时间
说到程序阻塞,随之而来的另一个问题就出现了
这是什么意思:
@Transactional(timeout = 3)
意思很明显嘛,就是被这个注解修饰了的方法执行时间不能超过三秒嘛。
比如这样,在事务的方法里面,睡眠 4 秒,那么这个方法的总执行时间大于了 3 秒,所以事务就会被回滚:
上面这个方法会抛出事务超时的异常,对不对?
对个锤子对!
我给你跑一下:
并没有出任何问题吧。
足以证明, @Transactional 上的 timeout 参数并不是控制整个方法的。
那控制的是什么呢?
我在上面的代码中在加入一行,就会出现这样的异常:
这是为什么呢?
我们从源码中寻找答案。
根据异常堆栈,可以定位到这一个方法:
org.springframework.transaction.support.ResourceHolderSupport#checkTransactionTimeout
而该方法,判断如果超时了则设置 rollbackOnly 标志为 true,然后抛出异常:
怎么判断是否超时呢?
org.springframework.transaction.support.ResourceHolderSupport#getTimeToLiveInMillis
用 deadline 的时间和当前时间做减法,如果 deadline 小于当前时间了,则说明该事务已经超时了。
所以问题就变成了两个:
1.谁在调用判断的逻辑? 2.deadline 哪来的?
先看第一个问题。
Mybatis 里面有这样的一个方法:
org.apache.ibatis.executor.SimpleExecutor#prepareStatement
prepare,准备阶段。
就是在执行 SQL 之前获取当前连接的超时时间。
而获取超时时间的逻辑里面就包含了校验当前是否超时的方法。
如果超时就设置 rollbackOnly 标识,然后抛出异常。
如果不超时则返回配置的超时时间,表示这个 SQL 语句运行可以执行的最长时间。
再看看第二个问题:deadline 哪来的?
查看该字段被调用的地方,可以看到有一个 setTimeoutInMillis 方法:
再找到该方法被调用的地方,我们就熟悉了:
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
这不就是我之前文章里面着重分析的部分嘛:
所以,timeout 参数开始生效是什么时候呢?
是事务就绪的那一刻。
所以,回到这个代码中,为什么加入一行查询的 SQL 语句,事务方法就抛出了超时异常呢?
因为触发了超时时间检查的逻辑。
综上,关于超时的流程图应该是这样的:
最后,再演示一下 SQL 阻塞住之后,导致超时的效果。
首先,我们先在 MySQL 的工具中,开启事务,执行 SQL,对这条记录加上行锁:
然后,再次执行代码:
可以看到三秒之后,抛出了异常:
MySQLTimeoutException: Statement cancelled due to timeout or client request
这波,不需要我分析原因了吧?
最后说一句(求关注)
好了,看到了这里安排个“一键三连” (转发、在看、点赞) 吧,写文章很累的,需要一点正反馈。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
我是 why,你也可以叫我小歪,一个主要写代码,经常写文章,偶尔拍视频的程序猿。
欢迎关注我呀。