前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >仔细思考之后,发现只需要赔6w。

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

作者头像
why技术
发布2021-09-24 15:26:40
4870
发布2021-09-24 15:26:40
举报
文章被收录于专栏:why技术why技术

你好呀,我是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

这波,不需要我分析原因了吧?

最后说一句(求关注)

好了,看到了这里安排个“一键三连” (转发、在看、点赞) 吧,写文章很累的,需要一点正反馈。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-08-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 why技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前情提要
  • 为什么是 20 个?
  • 看一眼行锁
  • 注解上的超时时间
  • 最后说一句(求关注)
相关产品与服务
云数据库 SQL Server
腾讯云数据库 SQL Server (TencentDB for SQL Server)是业界最常用的商用数据库之一,对基于 Windows 架构的应用程序具有完美的支持。TencentDB for SQL Server 拥有微软正版授权,可持续为用户提供最新的功能,避免未授权使用软件的风险。具有即开即用、稳定可靠、安全运行、弹性扩缩等特点。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档