【硬核】万字梳理Mysql隔离级别和MVCC相关知识!看完打通任督二脉!(面试必看)
作者:互联网
带着问题上路
开局还是先抛几个问题,今天主菜就是它们。
- 什么是事务,什么是事务的隔离级别,隔离级别有多少种,它们的关系是什么
- 什么是当前读、什么是快照读?
- MVCC是什么?版本链是什么?ReadView又是什么?
- RC和RR有什么区别?RC如何解决了脏读,又为什么会产生不可重复读?RR如何解决了不可重复读?
- 什么是幻读?RR到底有没有解决幻读?
人狠话不多,让我们带着问题开始今天的探索之旅把。
什么是事务?
事务的基本概念。
事务(Transaction):是访问和更新数据库的程序执行单元,只有访问的是只读事务,有更新的就是读写事务。它们是存储引擎来控制的,mysql中只有innodb支持事务。
当我们对数据库发生读或者写的时候,数据库的处理逻辑是非常复杂的,涉及到一系列底层的操作。
比如连接处理器啊、查询器啊、优化器啊,还有和磁盘的访问策略,中间会出现一系列的问题,为了方便我们普通操作人员的理解和使用,同时避免一系列的问题,把对应问题抽象了出来,总结成了四种特性,那就是原子性、隔离性、一致性和持久性(ACID)。只要设计上能满足这四大要点,可以避免我们在复杂场景中出错。
为了知识的完整性,先快速回顾这四个特性的含义。
ACID
原子性:
从名字就可以看出来,原子就是最小不可以再分割的单位了。在mysql中,一批sql的操作要么全部成功执行成功,要么全部执行失败,不能有其中一条单独执行了而其他的没有执行,满足了这一点,就可以说满足了原子性。
一般就说到这里就要来个转账的例子意思意思。对,你设计了一个钱包系统,然后有用户A向用户B转了1000块钱
用户A执行了
Update wallet_table set money = money - 1000
用户B执行了
Update wallet_table set money = money + 1000
如果没有原子性的保证,这两条语句有任意一条执行成功,一条执行失败,那就会导致整个钱包系统中的流转金额多了1000或者少了1000,这不变坏帐了么。
所以如果A扣钱或者B加钱任意一个操作出现了异常情况,那整个操作就要被取消,才符合原子性,系统资金才能保证稳定的运作。
mysql通过undo log日志保证原子性。这可以说是事务最基础也是最重要的特性之一。
隔离性。
不同事务之间的操作不能相互干扰,要有各自相互独立的状态。在并发状态下,他们不能互相影响。他们具体会有什么影响?哪些的程度是什么?这里就设计到了事务的隔离级别了,一会儿咱们就会重点盘它。
一致性:
数据的可见性,中间状态的数据对外部不可见,只有最初状态和最终状态的数据对外可见。这个概念比较抽象,不是很重要。
持久性:
mysql作为一个关系型数据库,最重要还是保存数据,一条insert语句执行完成后,这个数据必须永久的被保存下来。
它是通过redo log日志保证的。
mysql中一共有三种类型的操作日志,分别是binlog、redolog、undolog,后面会专门对它们的用处及场景进行介绍,本文的隔离级别中只会涉及到undoLog日志的一些概念。
隔离级别
上面说到事务的特性,其中有一个是隔离性,也是我们今天要聊的重点。
刚刚我们说到在并发场景下如果没有隔离性可能会出现的一系列问题,首先我们先来看下会有哪些问题:
【问题和影响】
脏读:读取到未提交的数据,这个时候的数据还未持久化,可能会被自动或手动的回滚,如果这些数据被来操作的话就可能会出现和预期不符合的结果。
不可重复读:读取到的是已经提交的数据,但是其他事务如果更新了这条数据,那每次读取出来的都是不一样的。它主要针对的是对同一条记录的反复读取结果是否一样。
例子:
T1事务查询:
T1:
begin:
select * from user where age = 18
得到结果:
name | age |
---|---|
饭饭 | 18 |
T2事务修改:
T2
begin:
update user set class = 娜美 where age = 18
commit;
T1事务再查询,结果为:
name | age |
---|---|
娜美 | 18 |
因为T1两次查询的结果不一样,所以不可重复读。
幻读:读取到的是已经提交的数据,且其他的事务更新也不会影响到多次的读取结果,但是如果新插入了一条数据,那你一些sql的执行结果可能和预期不符合.
例子:
T1事务查询年龄为18的数据记录:
T1:
begin:
select * from user where age = 18
结果有三条:
name | age |
---|---|
饭饭 | 18 |
娜美 | 18 |
路飞 | 18 |
T2执行一条insert语句:
T2:
begin:
insert into user value('索隆',18)
commit;
然后T1准备修改:
begin:
select * from user where age = 18
update user set class = 3 where age = 18
最后T1修改结果为:
Query OK,4 row affected(0.23 sec)
明明刚刚查询只有三条,为什么更新的时候更新了四条,条件没变啊,就像出现了幻觉一样。(其实没幻觉,要相信科学)。
【解决方式】
从这些可能存在的问题描述中可以看出它们的严重程度,依次是脏读->不可重复度->幻读,那针对这些问题的严重程度,mysql提供了四种隔离级别供使用者选择。
- 读未提交: READ UNCOMMITTED可能发生脏读、不可重复读和幻读问题。
- 读已提交((RC): READ COMMITTED 可能发生不可重复读和幻读问题,但是不会发生脏读问题。
- 可重复读(RR): REPEATABLE READ,可能发生幻读问题,但是不会发生脏读和不可重复读的问题。
- 串行化: SERIALIZABLE 上面所说的问题都不会发生。
快照读和当前读
要解决上诉的脏读、重复读、幻读的问题,mysql有两种方案,一种是当前读、一种是快照读。
当前读:
语法如下
- select … lock in share mode 加的是共享锁,不阻塞其他事务的读取,但是会阻塞它们的写入操作
- select … for update 加的是排他锁,其他事务的读取和写入都会被阻塞。
- update。。。
- insert。。。
- delete。。。
简单来说,当有事务执行上面这些sql的时候,其他事务的sql如果命中了执行的条件,就会被阻塞。
只有你一个人能用,能不是最新的么。
这种通过锁住记录、阻塞其他事务操作的方式来正确的读取到最新的数据的方式,就是当前读。
在SERIALIZABLE隔离级别中,所有的读、写操作都会上锁,我们知道大部分情况下数据库都是读多写少,所以加锁的方式效率太差了,所以SERIALIZABLE没人会去用它,而RC、RR提供了更加好的方式去避免这种问题。
select … lock in share mode、select … for update为啥从来没用过呢?
首先这种写法主要是在RC、RR下才会显式的添加,串行化隔离级别下的select隐式就是for-
update的,不需要手动这样写。但是选择RC和RR有更好的机制去避免(大部分场景),那我为什么要这种方式。
所以这种写法基本用不到。
快照读
主要针对读取场景,它的核心目的就是,读取的时候即不阻塞其他事务的读写,也要保证没有脏读、幻读的问题,既然是快照,那就可能不是最新的数据。
MVCC
感觉平时接触不到它,因为是mysql 存储引擎(准确的说是innodb) 提供的机制,只要隔离级别是RC或者RR,就会开启。
一句话简介:MVCC名为多版本并发控制,旨在通过非阻塞的方式避免不可重复读和幻读的问题,以此提升mysql事务的并行度,也是一种实现机制,Oracle、SqlServer都有,而且实现方式不一样,mysql是依赖于undoLog版本链和ReadView来实现的。
接触不到的东西就会陌生,陌生就会恐惧。。我不允许你有陌生的东西,下面就用一套图把它扒个底儿掉。且看:
当我们在insert一条数据后,你以为是这样:
其实还有几个你看不见的值,是这样的。。。
然后还会往undolog里面记录一条数据。。。它里面也有一个 roll_pointer属性。
解释一下这几个字段:
row_id:上一篇再讲存储引擎的时候提到过innodb如果未指定主键会隐式的生成一个row_id,不去关注。
trx_id:事务id,每次开启一个事务进行操作的时候,就会把事务id记录到这个字段里面。
roll_pointer:undolog指针,当我们修改的同时,会把修改之前的数据记录到undolog中,然后把指向这个日志的地址指针放入roll_pointer字段,也就是说通过这个这个字段你可以找到修改的前一个版本的信息。当事务执行回滚的时候,就是通过它来找到修改之前的数据的。
了解了这些字段后,再看下整个写入的流程。
先弄一张表,里面有三个字段
name | age | height |
---|---|---|
现在我们开启一个事务,往表里面插入一条数据,然后commit。
T1:trd_id = 10
begin:
insert into user(饭饭,18,183)
commit;
这个事务T1被分配了一个事务号10。
而roll_pointer在我们第一次insert的时候肯定是没有历史数据的,它针对的是行数据。所以为空。
然后再开启一个事务T2进行update。
T1:trd_id = 20
begin:
update user SET name = "娜美" WHERE age= 18
现在是这个样子,历史数据被记入了undolog,同时将它的地址保存到了最新记录行的roll_pointer中
以此类推,接下来再开启三个事务去更新它
T4事务,事务号30
T1:trd_id = 30
begin:
update user SET name = "路飞" WHERE age= 18
T4事务,事务号40
T4:trd_id = 40
begin:
update user SET name = "索隆" WHERE age= 18
T5事务,事务号50
T5:trd_id = 50
begin:
update user SET name = "山治" WHERE age= 18
经过多个事务更新后,现在变成这样了(注意后面4个update的事务都是begin后未commit的)。
可以发现这些数据都被roll_pointer串起来了,通过一个事务id就可以根据roll_pointer一直往下找历史的版本,这个链表被称为版本链。
这里还跟隔离级别没关系,不管什么隔离级别写入数据都会被这样保存。
咱们继续往下看。
ReadView
这又是什么玩意儿?兄弟姐妹们,重点来了哦~
它只有RC、RR隔离级别下才有,它是在事务进行select操作的时候生成的,它是MVCC机制的关键!
上面我们不是已经生成了版本链了嘛,这个ReadView就是用来定义数据可见性的,哪些数据能被看到,哪些不能,在不同隔离级别下,它的生成规则不同,它就是决定了为什么RC是不可重读,RR是可重读的关键!。
ReadView一共有这么几个概念:
m_ids: 活跃事务列表,何为活跃事务,就是begin了,未commit的。
min_trx_id:最小的活跃事务id。就是最开始提交的事务。
max_trx_id: 生成ReadView时分配的下一个事务id。
creator_trx_id:生成ReadView的事务id,只有在对表中的记录做改动时(执行insert、delete、update这些语句时)才会为事务分配事务id,按照上面的演示,如果是在T5事务中查询,就是50。如果单独执行一个SELECT,那就是creator_trx_id就是0。
按照我们上面的更新步骤,ReadView大致就长下面这样:
我们刚刚做了四笔更新操作,都未提交,所以m_ids中一共有4条活跃记录。
其中事务号为50的事务最后一次执行,下一次分配的事务就是为60。
事务20最早执行,所以它是最小的活跃事务。
有了这个ReadView,结合版本链,那就可以根据以下的规则来判定版本链的数据对某个事务来说是否应该被访问。
可以被访问到的情况:
- 查询的trx_id比活跃事务最小的tax_id小,说明说明这个事务再ReadView生成前已经提交。
- 查询的trx_id在最大tax_id和最小tax_id之间,但是不存在,说明事务已经提交了。
- 查询的trx_id等于creator_trx_id,说明是在访问自己的事务,也可以被查到。
不可以被访问的情况:
- 查询的trx_id比max_trx_id大,说明本次访问的事务在readView生成之后才开启的,不能访问。
- trx_id在最大tax_id和最小tax_id之间且存在,不能访问。
- 如果不能访问的话,就会顺着我们上面画的undolog版本链往下找,每次拿到trx_id后继续和上面的规则做匹配,直到访问到数据为止。
一句话总结,如果被查询版本链上的事务ID在m_ids列表中存在且不是它自身的事务ID,那这条链上的数据就不能被访问,就要顺着版本链继续往下找。反之则可以被查到。
这样说可能还是有点迷茫,为了更加形象一点,接下来我们结合RC和RR两种隔离级别,来模拟他们的查询路径,看下到底为什么RC会不可重复读,RR可以避免不可重复读。
现在库里有一条初始数据饭饭,先开启两个事务,分别执行
T1 trx_id=20
begin:
update user set name = "娜美" where age = 18
T2 trx_id=30
begin:
update user set name = "路飞" where age = 18
生成的版本链如下:
现在再开启一个只读进行查询
BEGIN:
select * from user where age = 18
请问它查到的数据应该是什么呢?
生成ReadView如下:
有了版本链和ReadView两大神器,就根据上面的查询规则,我们可以得到以下的查询路径。
- 首先去版本链中获取最新的数据,发现是路飞,拿到路飞的trx_id 为30。
- 拿到30后去ReadView里面匹配,它在我们的m_ids列表中存在,说明该事务未提交无法查看(这里已经解决脏读问题了哈,脏读在步骤一就直接返回了,没有ReadView)。那就重新回到版本链中拿到行记录对应的roll_pointer查到下一级信息。
- 下一级信息是娜美,娜美的trx_id是20,它也在m_ids中,说明这个事务也未提交,所以继续往下找。
- 饭饭的txr_id是10,不在m_ids列表中,说明事务已经提交,返回这条数据!
最终的查询结果是【name=饭饭】的记录。
现在把T1、T2的事务提交
T1 trx_id=20
begin:
update user set name = "娜美" where age = 18
commit;
T2 trx_id=30
begin:
update user set name = "路飞" where age = 18
commit;
然后再开启两个事务,写入两条信息,不提交:
T3 trx_id=40
begin
update user set name = "索隆" where age = 18
T4 trx_id=50
begin
update user set name = "山治" where age = 18
这时候版本链变成如下的样子:
继续用刚刚的只读事务查询,还是查询age=18的数据
BEGIN:
select * from user where age = 18
重点来了(圈起来)
这个时候,RC隔离级别下会重新生成一份ReadView,现在的ReadView变成这样了。
查询路径如下:
- 首先去版本链中获取最新的数据,发现是山治,拿到山治的trx_id 为50。
- 拿到50后去ReadView里面匹配,它在我们的m_ids列表中存在,说明该事务未提交无法查看。重新回到版本链中拿到行记录对应的roll_pointer查到下一级信息。
- 下一级信息是索隆,索隆的trx_id是40,它也在m_ids中,说明这个事务也未提交,所以继续往下找。
- 路飞的txr_id是30,不在m_ids列表中,说明事务已经提交,返回这条数据!
最终查到的是路飞。
可以看到两次查询到的结果不一样,因为RC下事务每次查询的时候都会生成一个新的ReadView,而ReadView的m_ids会随着活跃事务的提交而改变的,最终导致了RC下读取的数据随着ReadView的变化而变化,产生了不可重复读。
而在RR隔离级别下,ReadView是不会变化的,如果第二次查询是RR隔离级别,我们再看下情况。
还是执行刚刚的只读事务:
BEGIN:
select * from user where age = 18
由于ReadView不会变化,所以还是第一次的ReadView。
RR的查询路径如下:
- 首先去版本链中获取最新的数据,发现是山治,拿到山治的trx_id 为50。
- 拿到50后去ReadView里面匹配,发现它比max_trx_id大,根据前面的访问规则,说明该事务再本次ReadView开启之后生成的,无法访问。重新回到版本链中拿到行记录对应的roll_pointer查到下一级信息。
- 下一级信息是索隆,索隆的trx_id是40,它在m_ids中,认为这个事务未提交(虽然它确实已经提交了),所以继续往下找。
- 同理路飞和娜美对应的trd_id都在活跃事务列表中,所以无法访问。
- 最终查询到的是饭饭的记录,两次结果一致,解决了不可重复读的问题。
最终结果是饭饭。
刚刚那两个是只读事务,也就是事务里面只做了读取操作。
趁着T3事务和T4事务还没提交的功夫,如果我在T3事务(索隆那条记录)中查询会是什么情况。
现在这样做查询:
T3 trx_id=40
begin:
update user set name = "索隆" where age = 18
/**再提示一下,执行这条查询的时候T5(山治)的update已经执行了未提交**/
select * from user where age = 18
生成的ReadView如下:
可以看到,creator_trx_id变成40了,因为执行了增删改操作的事务再创建ReadView的时候creator_txd_id是会跟着自己的事务号走的。
接下来的查询路径大家应该都很清楚了。
先从版本链获取到最新的数据山治,然后根据ReadView的规则进行一层层匹配,
当发现creator_trx_id和需要查询的事务号一致的时候,就认为是自己的事务在读取这条记录,也会返回。这就是为什么事务内部的修改可以被访问到。
这里还要再补充一点哦,为什么MVCC可以这么顺利的控制多版本下的读取,还有很重要的一点就是锁。我们可以发现只有事务在保证它们的先后顺序的情况下,才可以保证按照版本链的顺序读取是不会出现异常。
上面的例子如果T5事务先提交T4事务后提交(脏写),那按照版本链的顺序读取就会出现读取到T5的数据这种情况。这里顺带提一句,正因为保证了事务提交的先后顺序,版本链才可以正常的被读取(当然脏写的情况比这个例子要恶劣的多,它会导致T4回滚后回到T4之前版本,直接把T5的修改操作覆盖)。
再谈幻读
刚刚上面的例子我们看到解决了脏读和不可重复读,但是没有提到幻读,到底有没有被解决。为什么呢?
因为它有点特殊。
要想讨论它有没有被解决,首先要先搞清楚幻读的定义什么,虽然一开始就已经列过了幻读的场景,但是还是不够详细。下面举两种例子:
第一个列子是查询结果:
开启一个只读事务:
BEGIN:
select * from user where age = 18;
结果是:
name | age | height |
---|---|---|
饭饭 | 18 | 180 |
再写入一条数据:
T1
begin:
insert into user values ('娜美',22 ,167);
commit;
如果是RC,这个时候再去查询。发现多了一条数据,多的数据是幻行,出现了幻读。
name | age | height |
---|---|---|
饭饭 | 18 | 180 |
娜美 | 22 | 167 |
如果再RR下,查询会发现还是原来那一条。
name | age | height |
---|---|---|
饭饭 | 18 | 180 |
有人会觉得,RC下查询两次结果不一样,这不是不可重复读吗,怎么说这种情况是幻读。
其实它们主要的一个区别就是产生作用的结果集。
不可重复读是针对同一批数据的,结果集数量不变的,刚刚上面的例子我都是用的update,从头到尾修改的都是一条数据。不可重复也是针对同一条数据每次读取字段的值不一样。
而幻读是结果集的数量发生了变更,第一次查出来一条,第二次查出来两条。
所以,从现象来讲,虽然都是两次查询的结果不一样,但是不一样的点上面是有区别的。
那RR解决了insert导致的结果集不一样的这种情况吗 ?
解决了。还是通过上面说的ReadView的生成规则的不同,可以说RR下通过ReadView的机制同时解决了不可重复读和这种情况下的幻读。
例子二:
隔离级别直接用RR,前两步动作和例子1一样,都是先查询再插入,这里不再画了。
区别再第三步上,上面是查询,这里我们做更新操作。
T1:
BEGIN:
select * from user where age = 18;
result= | 饭饭 |18 |180 |
update user set name = "娜美" where age = 18
结果有两条记录生效。这种情况最开始在解释幻读定义的时候举得例子。
Query OK,2 row affected(0.23 sec)
再来,如果你这个时候再去查询一下:
T1:
BEGIN:
select * from user where age = 18;
result= | 饭饭 |18 |180 |
update user set name = "娜美" where age = 18
Query OK,2 row affected(0.23 sec)
select * from user where age = 18;
会得到结果:
name | age | height |
---|---|---|
饭饭 | 18 | 180 |
娜美 | 22 | 167 |
what?不是说RR的ReadView不会每次都更新吗?怎么两次查询的结果不一样?
那这样来看RR级别下也没有彻底杜绝掉幻读。为什么?
1:针对更新后生效的明细行,我们刚刚提到,select是快照读,update是当前读,也就是说update它会把当前已经提交的最新数据返回给你(这里有一些更详细的关于锁的知识,下篇会详细讲。)。一个查的快照信息(旧的),一个查的当前信息(新的),这信息不对称啊!那必然出现幻读。
2:针对第二次查询结果被刷新了,那是因为ReadView只有在只读事务下是会复用的,当这个事务中执行了其他的insert、update、delete操作,会被重新分配事务号,这样的话在下次select的时候,ReadView又会重新生成了,导致了查询了最新的数据,就是RC下的情况一样。
那如果改一下写法这样写呢?
T1:
BEGIN:
select * from user where age = 18 for update
那刚刚的T2再insert的时候就直接阻塞了,插不进去,因为for update 操作会让age=18的数据区间被加上了锁,除非这个T1事务执行了commit,在这种情况下你再去T1里面update,就能保证更新的数据就是你查询到的数据了,但我们说了这种加锁的方式和mvcc已经没关系了。
但是为什么不能避免我们平时也没遇到问题
1:这种实际场景本来就比较少,一般一个事务中本来就要避免做太多的操作,尽量避免 长事务。
2:第二如果说真的有数据依赖的关系,比如扣减库存,你会先select 然后判断一下库存是否大于0然后再执行update吗?基本不可能,依赖数据库层面来控制,我们都再上层就拦截掉了,最多写个原子加减操作。
最后再汇总一下开局提的几个问题
1. 什么是事务,什么是事务的隔离级别,隔离级别有多少种,它们的关系是什么?
- 事务就是数据库最小的操作单元,它满足了ADIC的特性保证我们的数据能被正常的读取和写入,隔离级别就是四大特性中的隔离性的详细实现。一共有4种隔离级别。
2. 什么是当前读、什么是快照读。
- 当前读就是加锁的读取,保证同一时间内同一个的数据只有一个事务可以访问,它可能对读读和读写的操作进行阻塞,从而保证读取的数据是最新的。
- 快照读是在RC和RR级别下通过MVCC结合ReadView的一种保证多事务读写不阻塞的一种解决方案,提升了数据库的并发度。
3.RC和RR有什么区别?RC如何解决了脏读,又为什么会产生不可重复读?RR如何解决了不可重复读?
- RC和RR在ReadView的生成机制上有差别(在锁的机制上也有差别,本文没有提到)。
- RC通过ReadView的查询路径杜绝了脏读,保证不会读到未提交的事务,但是由于每次都会生成新的ReadView所以一个事务内重复读取会读到不一样的值。
- RR只有在第一次查询会生成ReadView,后期再怎么查都是复用第一次的,所以解决了不可重复读。
4. 什么是幻读?RR到底有没有解决幻读?
- 幻读主要针对的insert操作。
- 查询一批数据,两次结果的值不一样,就是不可重复读。
- 查询一批数据,两次结果数量不一样,这就叫幻读。
- RR只解决了快照读(只读事务)下的幻读,如果混合了当前读(查询后更新),就无法避免了,因为更新会读取当前最新的数据,并且会更新事务Id,导致ReadView也会重新生成。
5. MVCC是什么?版本链是什么?ReadView又是什么?
- MVCC是多版本并发控制,在不加锁的情况下可以保证读取的数据不会因为的事务的操作干扰。
- 版本链是Mysql为了保证原子性使用undolog提供的一套更新数据的模型,只要有数据更新就会生成版本链条,和隔离级别无关。
- ReadView存放着活跃事务ID列表,定义了版本链的数据哪些可见,哪些不可见。
总结
本文主要探索的是Mysql的隔离级别,以此来引出它们存在的问题,并阐述了它们产生的原因,解决的原理,以及最后的总结。
这些内容小饭觉得第一次接触是比较难理解的,而难理解的原因主要是因为我们平常几乎感知不到这些机制的存在。
说到索引、锁都比较直观,因为它会影响到一系列的优化原则,死锁的问题,会真正切切的影响我们
的查询、写入效率和线上故障。而隔离级别,RR和RC以我们目前的使用场景来看几乎关心不到(不绝对),它们在可见性上的概念太过相似,而幻读造成的影响的原因基本没人会这么写,因为我们本来就不会把一些可见性和复杂的数据依赖逻辑交给数据库来做。在前面几层基本就已经处理掉了,DB只负责数据的存储。
小饭习惯每次学习完后思考一些它最终解决了什么问题,这样有助于我们从更多的角度来思考。
知道了这些概念,对于一些极端情况下的问题排查和处理还是有帮助的,也让我们碰到问题时有个参考思路,不至于钻牛角尖。
本文还涉及到几个概念,比如三大Mysql日志,Mysql的锁机制,本文引一下是为了先建立起关系,让我们的思维更加体系化,小饭马上就会再深入的探索一下它们的用处。希望大家看完以后可以真正的吃透这些知识点,在工作和面试的时候更加有安全感。
我是小饭,如果大家这篇文章带来了新的思考和认知,或者还有存在的疑问,欢迎留言和我互动啦~
上一篇文章:MySql索引和结构深度解析!(多动图详细版)
关注小饭,持续输出干货,让你更有安全感!
标签:事务,必看,18,update,查询,ReadView,id,硬核,通任督 来源: https://blog.csdn.net/wangqifan807/article/details/115535147