你好呀,我是why。
前几天在某平台看到一个技术问题,很有意思啊。
涉及到的两个技术点,大家平时开发使用的也比较多,但是属于一个小细节,深挖下去,还是有点意思的。
来,先带你看一下问题是什么,同时给你解读一下这个问题:
首先,这位同学给出了一个代码片段:
他说他有一个 func 方法,这个方法里面干了两件事:
- 1.先查询数据库里面的商品库存。
- 2.如果还有库存,那么对库存进行减一操作,模拟商品卖出。
对于第二件事,提问的同学其实写了两个操作在里面,所以我再细分一下:
- 2.1 对库存进行减一操作。
- 2.2 在订单表插入订单数据。
很显然,这两个操作都会对数据库进行操作,且应该是应该原子性的操作。
所以,在方法上加了一个 @Transactional
注解。
接着,为了解决并发访问的问题,他用 lock 把整个代码包裹了起来,保证在单体结构下,同一时刻只有一个请求能去执行减少库存,生成订单的操作。
非常的完美。
首先,先把大前提申明一下:MySQL 数据库的隔离机制使用的是可重复读级别。
这个时候,问题就来了。
如果是高并发的情况下,假设真的就有多个线程同时调用 func 方法。
要保证一定不能出现超卖的情况,那么就需要事务的开启与提交能完整的包裹在 lock 与 unlock之间。
显然事务的开启一定是在 lock 之后的。
故关键在于事务的提交是否一定在 unlock 之前?
如果事务的提交在 unlock 之前,没有问题。
因为事务已经提交了,代表库存一定减下来了,而这个时候锁还没释放,所以,其他线程也进不来。
画个简单的示意图如下:
等 unlock 之后,再进来一个线程,执行查询数据库的操作,那么查询到的值一定是减去库存之后的值。
但是,如果事务的提交是在 unlock 之后,那么有意思的事情就出现了,你很有可能发生超卖的情况。
上面的图就变成了这样的了,注意最后两个步骤调换了:
举个例子。
假设现在库存就只有一个了。
这个时候 A,B 两个线程来请求下单。
A 请求先拿到锁,然后查询出库存为一,可以下单,走了下单流程,把库存减为 0 了。
但是由于 A 先执行了 unlock 操作,释放了锁。
B 线程看到后马上就冲过来拿到了锁,并执行了查询库存的操作。
注意了,这个时候 A 线程还没来得及提交事务,所以 B 读取到的库存还是 1,如果程序没有做好控制,也走了下单流程。
哦豁,超卖了。
所以,再次重申问题:
在上面的示例代码的情况下,如果事务的提交在 unlock 之前,是没有问题的。但是如果在 unlock 之后是会有问题的。
那么事务的提交到底是在 unlock 之前还是之后呢?
这个事情,先把问题听懂了,接着我们先按下不表。你可以简单的思考一下。
我想先聊聊这句被我轻描淡写,一笔带过,你大概率没有注意到的话:
显然事务的开启一定是在 lock 之后的。
这句话,不是我说的,是提问的同学说的:
你有没有一丝丝疑问?
怎么就显然了?哪里就显然了?为什么不是一进入方法就开启事务了?
请给我证据。
来吧,瞅一眼证据。
事务开启时机
证据,我们需要去源码里面找。
另外,我不得不多说一句 Spring 在事务这块的源码写的非常的清晰易懂,看起来基本上没有什么障碍。
所以如果你不知道怎么去啃源码,那么事务这块源码,也许是你撕开源码的一个口子。
好了,不多说了,去找答案。
答案就藏在这个方法里面的:
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
先看我下面框起来的那一行日志:
Switching JDBC Connection [HikariProxyConnection@946359486 wrapping com.mysql.jdbc.JDBC4Connection@7a24806] to manual commit
你知道的,我是个技术博主,偶尔教点单词。
Switching,转换。
Connection,链接。
manual commit,手动提交。
Switching ... to ...,把什么转换为什么。
没想到吧,这次学技术的同时不仅学了几个单词,还会了一个语法。
所以,上面那句话翻译过来就非常简单了:
把数据库连接切换为手动提交。
然后,我们看一下打印这行日志的代码逻辑,也就是被框起来的代码部分。
我单独拿出来:
逻辑非常清晰,就是把连接的 AutoCommit 参数从 ture 修改为 false。
那么现在问题就来了,这个时候,事务启动了吗?
我觉得没启动,只是就绪了而已。
启动和就绪还是有一点点差异的,就绪是启动之前的步骤。
那么事务的启动有哪些方式呢?
- 第一种:使用启动事务的语句,这种是显式的启动事务。比如 begin 或 start transaction 语句。与之配套的提交语句是 commit,回滚语句是 rollback。
- 第二种:autocommit 的值默认是 1,含义是事务的自动提交是开启的。如果我们执行 set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。
很显然,在 Spring 里面采用的是第二种方式。
而上面的代码 con.setAutoCommit(false)
只是把这个链接的自动提交关掉。
事务真正启动的时机是什么时候呢?
前面说的 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才算是真正启动。
如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。需要注意的是这个命令在读已提交的隔离级别(RC)下是没意义的,和直接使用 start transaction 一个效果。
回到在前面的问题:什么时候才会执行第一个 SQL 语句?
就是在 lock 代码之后。
所以,显然事务的开启一定是在 lock 之后的。
这一个简单的“显然”,先给大家铺垫一下。
接下来,给大家上给动图看一眼,更加直观。
首先说一下这个 SQL:
select * from information_schema.innodb_trx;
不多解释,你只要知道这是查询当前数据库有哪些事务正在执行的语句就行。
你就注意看下面的动图,是不是第 27 行查询语句执行完成之后,查询事务的语句才能查出数据,说明事务这才真正的开启:
最后,我们把目光转移到这个方法的注释上:
写这么长一段注释,意思就是给你说,这个参数我们默认是 ture,原因就是在某些 JDBC 的驱动中,切换为自动提交是一个很重的操作。
那么在哪设置的为 true 呢?
没看到代码,我一般是不死心的。
所以,一起去看一眼。
setAutoCommit 这个方法有好几个实现类,我也不知道具体会走哪一个:
所以,我们可以在下面这个接口打上一个断点:
java.sql.Connection#setAutoCommit
可以看到,默认确实是 true。
等等,你不会真的以为我是想让你看这个 true 吧?
我是想让你知道这个调试技巧啊。
不知道有多少个小伙伴曾经问过我:这个接口实现类好多啊,我怎么知道在哪打断点啊?
我说:很简单啊,就在每个实现类的第一行代码打上断点就好了。
然后他说:别闹,我经常给你的文章一键三联。
我当时就被感动了,既然是这样的好读者,我当然把可以直接在接口上打断点的这个小技巧教给他啦。
好了,不扯远了。
再说一个小细节,这一小节就收尾。
你再去看这小节的开头,我直接说答案藏在这个方法里面:
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
直接把答案告诉你了,隐去了探索的过程。
但是这个东西,就像是数学公式推导一样,省略了一步,就会让人看起来一脸懵逼。
就像下面这个小耗子一样:
所以,我是怎么知道在这个地方打断点的呢?
答案就是调用栈。
先给大家看一下我的代码:
啥也先不管,上来就先在 26 行,方法入口处打上断点,跑起来: