查看原文
其他

仔细思考之后,发现只需要赔6w。

why技术 why技术 2022-09-10

你好呀,我是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,你也可以叫我小歪,一个主要写代码,经常写文章,偶尔拍视频的程序猿。

欢迎关注我呀。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存