事务的隔离性和行锁
# 事务的隔离性和行锁
两个视图的概念:
- 一个是view,用查询语句定义的视图表。重复利用SQL语句,简化查询。
- 另一个是MVCC的一致性视图,即consistent read view。用于在事务期间定义数据的可见性。
下面基于第二个视图的逻辑概念展开讨论。
# 1.快照在MVCC中如何工作
一行记录被多个事务连续更新后的状态图如下所示,具体来说包含以下几个点:
- 每个事务ID向事务系统申请,得到的id都是递增的。
- 每一行数据存在多个版本号,每行数据的版本号row trx_id为修改该行数据事务的transaction id。
- 虚线箭头代表的是语句更新产生的回滚日志undo log。
- 每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。换而言之是先有事务id,然后才有数据的版本号。
实际上版本V3,V4并不是物理上真实存在的,而是每次需要的时候,依次通过回滚日志U2,U3计算得到的。
另外每行记录都有一个当前版本号的概念,记录当前数据所更新的最新状态。

InnoDB为每个事务构造了一个视图数组:用来保存当前事务启动瞬间,当前正在“活跃”的所有事务ID。这里活跃指的是所有启动了但还没有提交的事务,也就是下图中的黄色部分数组(但并不完全)。某个版本号数据是否可见,遵循如下规则:
- 低水位指的是数组中事务ID的最小值。高水位指的是当前时刻,系统已创建但未提交事务ID最大值+1。
- 如果该行数据的版本row trx_id落在绿色部分,则表示该版本是已提交过的或者是当前事务自己生成的,因此是可见的。
- 如果row trx_id落在红色部分,则表示该版本的数据是在当前事务之后创建修改的,因此是不可见的。
- 如果row trx_id落在橙色部分(仅代表那一时刻未提交,后续在当前事务提交之前,可能会提交),该部分存疑❓

InnoDB利用了每行数据都有多个版本号这个特性,实现了秒级创建快照的能力。通过回滚日志和版本号可以回溯到任意一个版本的快照。
数据的可见性问题:通过比对row trx_id版本号和当前视图数组来得到。
# 2.场景分析
有如下场景:
事务A开始前系统只有一个活跃事务ID为99
三个事务的版本号如下图所示
三个事务开始前,(1,1)这一行数据的row trx_id为90(也就是A事务启动前的当前版本号—最新数据)
下面分别分析A事务读,以及B事务写两种情况。

# 2.1 A事务读
首先A、B、C三个事务依次启动,每个事务的活跃数组如上图所示。C事务首先拿到数据,修改为(1,2),同时将该行数据的版本号记为102;接着B事务拿到102版
本的数据,修改为(1,3),同时将最新记录的版本号记为101。最后轮到A读取这行数据,遵循前面的活跃数组规则:
- 发现数据(1,3)版本号为101,因为101不在当前事务A的活跃数组中,超过了高水位属于”红色区域“,不可见。此时根据undo log回滚到版本1
- 数据(1,2)版本号为102,也不在活跃数组范围内,不可见。根据回滚日志回滚到版本2
- 此时版本2的版本号为90,虽然不在活跃数组范围内,但是处于低水位之下,属于”绿色区域“中已提交的历史版本数据,是可见的,所以最终事务A查到的数据为(1,1)
总结一下对于一个事务,什么情况下数据是可见(指的是读操作可见性)的:
①在当前事务启动并创建视图数组之前,所有的版本提交都是可见的。
②视图数组的所有事务都是不可见的。不论是否提交。
③在当前事务启动并创建视图数组之后,所有新创建事务的版本提交都是不可见的。
④无论是否提交,自己的更新总是可见的。
解释一下规则2,在InnoDB中🌟事务启动前所有还没提交的事务,都是不可见的。因此活跃数组存在的意义在于,在小于当前事务ID的所有事务版本中,划分已提交的老版本数据和未提交的版本数据,其中视图数组存放的就是未提交的版本数据。
# 2.2 B事务写(写操作的当前读)
B事务写之前,该行数据当前(最新)版本号为102,虽然102超过了B事务视图数组的高水位,如果是读请求那么自然读不到(1,2)这行数据。而如果是写操作,在InnoDB中需要遵循如下规则:
- 🌟更新操作每次读取并修改的数据,一定是这行记录最新版本的数据,也就是当前读。所以事务B会修改最新的数据。
- 一般情况下,其它事务只要没有提交的数据都是不可见的,读操作如此,而对于写操作来说虽然不可见,但要修改数据还需要受到行锁的限制。因此如上述C事务更改记录后,并没有马上提交(autocommit=0),那么B事务修改语句会被阻塞。直到C事务的写锁释放。
数据库更新操作的执行时机和结果,会同时受到行锁+事务这两个机制的共同制约。
# 2.3 读操作的当前读
🌟注意:如果select加了共享锁lock in share mode 或者是 排他锁for update,那么当前的读操作也能读到最新的数据。
# 3.可重复读和读已提交
# 3.1可重复读
start transaction with consistent snapshot;创建一个持续整个事务的一致性快照
通过视图数组,确保了每次读操作读到的数据与事务启动时是一致的;然而“可重复读"隔离级别并不会确保"可重复写",也就是说在可重复读隔离级别下,每次更新操作使用的都是当前读——最新版本的数据。
# 3.2读已提交
在该隔离级别下,每个语句执行之前,都会重新计算构建出一个新的视图数组。
注意:此处构建的视图中,其它事务已提交的数据版本是可见的,同时还有当前自己事务的更新也总是可见的。
# 3.3解决其它事务修改值导致当前事务修改操作失败
可重复读隔离级别下,因为操作读取的是当前读,因此当前update语句的where条件会因为其它事务在这之前修改,导致where条件失效不匹配。类似于乐观锁机制中版本号被修改过,更新失败。解决的方案是cas失败后,重起一个事务重新进行查询或者更新。
# 4.行锁+事务读+事务写案例分析
Update更新语句的核心原理分为以下两个步骤:
- MySQL的server层会先进行判断,如果该行记录没有必要执行更新操作,那么直接返回。此处“没有必要”是根据SQL语句进行判断。
- 如果需要进行更新操作,那么InnoDB引擎会认真完成更新,包括加行锁、更新行记录、更新事务版本号。

在案例一中,InnoDB引擎会执行整个更新操作:
- 事务B修改时,先给id=1这行数据添加行锁,接着把数据改为(1,3),最后把这行记录的最新版本号修改为“B的事务ID”。然后释放行锁。
- 接着事务A执行更新语句,虽然更新时读到的是最新的数据,InnoDB更新时读取到并修改的是(1,3)这条记录,但是server层仅根据SQL语句并不能知道id=1这条记录a的值为3,看到的只是要把id=1的记录a的值更新为3。因此InnoDB引擎会把(1,3)这条记录更新为(1,3),并将记录的最新版本号修改为“A的事务ID”。
- 事务A最后的select查询操作中,发现记录的最新版本号为自己的事务ID(自己做过的所有操作均可见),最后将可见的(1,3)这条记录返回。

案例二很好的证明了update的解析过程:
- A事务在进行更新时,server层解析SQL语句时,发现将a=3这条记录a的值改为3是多余的,因此InnoDB引擎并不会进行更新操作。
- A事务最后进行查询时,对于(1,3)这行记录来说,A事务并没有进行过修改,最新的版本号仍然是B的事务ID,处于低水位之上,是不可见的,因此会根据回滚日志回退到上一个数据版本(1,2),并进行返回。