InnoDB的事务和锁如何处理并发问题?
MVCC
MVCC就是多版本并发控制机制,重要的几个概念如下:
- 每次插入/更新/删除记录时都会额外插入/更新一个唯一且递增的版本号,一般我们就称之为事务ID。每条记录的多个版本将会形成一条版本链,后续可用于回滚版本。
对于MySQL的每张表存放完整数据的B+树(聚簇索引)中每个叶子节点不仅仅保存记录的所有字段值,还将会额外保存两个字段——事务ID和回滚指针,回滚指针实际上指向的就是undo日志,而undo日志就是版本链的实现方式。
- 删除记录实际上并没有真正删除,而是在所有事务都不再使用该记录时,才会将这个数据真正删除。
- 在执行select语句时,将会分配一个read view,该视图用于为当前事务进行并发控制,也可以认为是数据可见性的控制。实现方式一般是通过划分版本号区间来限定可见范围。
- 对于读提交来说,每次select都会生成一个read view来决定此次查询的数据可见性
- 对于可重复读来说,只有第一次select才会生成read view,且后续的select都是用这个read view来决定查询的数据可见性。
- MVCC读取规则如下:
- read view一般会保存当前活跃/并行的最小事务ID、当前事务ID、下一个待分配的事务ID、当前活跃/并行的事务ID集合
- 如果当前记录的事务ID小于read view中的活跃/并行最小事务ID,则说明持有该read view的查询可见当前记录。
- 如果当前记录的事务ID大于等于read view中的下一个待分配的事务ID,则说明持有该read view的查询不可见当前记录。
- 如果当前记录的事务ID大于read view中的活跃/并行最小事务ID,且小于read view中的下一个待分配的事务ID
- 如果当前记录的事务ID不在read view的并行事务集合中,则说明创建持有read view的事务前,修改当前记录的事务已提交,故持有该read view的查询可见当前记录。
- 如果当前记录的事务ID在read view的并行事务集合中,则说明创建持有read view的事务后,当前记录被并行的事务修改过,故持有该read view的查询不可见当前记录。
锁
表锁
表锁见名知意,是一种在表上加锁,使得一个事务可以独占整整表数据的一种方式。
但是表锁的对于数据的保护粒度太粗了,因此需要一种更细粒度的锁——行锁。很容易想到一个问题,行锁和表锁的兼容性是如何解决的呢?
一般来说,我们会希望持有行锁的事务A不会被中止,且能继续持有这个锁住的临界资源。因此我们可能会希望当另一个事务B希望获取表锁时,事务A的锁能够升级为表锁,并且阻塞住事务B,从而保证事务A的数据一致性。
于是引入了一个特殊的表锁——意向锁,在事务A对记录插入行锁时,也需要在表上添加意向锁。当另一个事务B希望获取表锁时,事务A的意向锁将会锁住整个表。额外添加意向锁这种数据结构,也是为了加速确认行锁是否存在的一种解决方案——如果我们加表锁的时候都需要去表中一条一条记录的查看是否有添加行锁,那么效率十分堪忧。
实际上我们还漏掉了很重要的一点,即锁一般分为排他锁(X)和共享锁(S),那么在这种分类下,行锁、意向锁、表锁之间的兼容性到底是怎么样的呢?
- 行锁和意向锁:不管行锁添加的是排他锁还是共享锁,只要添加行锁,那么就会往表上添加排他/共享意向锁,也就是说排他意向锁(IX)和共享意向锁(IS)是兼容不互斥的关系。
- 表锁和意向锁:
- 对于存在IX的表来说,如果事务请求X/S表锁,都会被阻塞。
- 对于存在IS的表来说,如果事务请求S表锁,那么能够兼容;反之阻塞。
行锁
- 记录锁:十分经典的锁,可以保护临界区资源。记录锁分为S、X。
- 间隙锁:主要是为了防止幻读现象而出现的锁,锁住的是目标记录与上一条/下一条记录的范围,使其不允许拥有第二个事务在这个区间添加记录。从而可以防止幻读现象的产生。
- 间隙锁(Gap)在InnoDB中只有可重复读隔离级别下可用
- 间隙的范围是左开右闭
- 间隙锁互相兼容
- 临键锁:可以理解为记录锁+间隙锁。故临键锁可以分为S+Gap、X+Gap。
- 插入意向锁:标记一个事务准备在某个间隙内插入数据,但不阻止其他事务在不同间隙内的插入操作。
- 具体使用参见接下来的幻读现象分析
需要注意!插入意向锁并不属于表锁,而是一种特殊的间隙锁,属于行锁。
各隔离级别下需要用到哪些行锁?
首先要知道加行锁的三种情况——当前读、更新写、删除写。其中当前读又分为S锁和X锁(select ... lock in share mode、select ... for update)
当前读,顾名思义就是读取的数据是当前记录的最新版本(无论事务是否提交)。实际上更新/删除操作也是基于这个读取最新版本规则来执行修改操作的,更新/删除得数据一定基于最新版本。
- 读未提交:快照读不加锁,当前读加S/X记录锁,写加X记录锁。
- 读已提交:快照读不加锁,当前读加S/X记录锁,写加X记录锁
- 可重复读:快照读不加锁,当前读加S/X临键锁,写加X临键锁。
- 可串行化:读加S临键锁,写加X临键锁。
唯一索引等值查询:
- 当查询的记录存在,临键锁退化为记录锁。
- 当查询的记录不存在,临键锁退化为间隙锁
各隔离级别下行锁如何释放?
首先要知道无论是读操作还是写操作,都需要遍历B+树找到目标记录。行锁实际上就是对遍历到的记录进行上锁。
- 读未提交、读已提交:如果遍历过程中涉及到的记录并不是目标记录,那么就会立马释放锁。当事务提交时,目标记录上的锁将会释放。
- 可重复读、可串行化:如果遍历过程中涉及到的记录并不是目标记录,也不会立马释放锁,直到事务提交,才会将所有锁释放。
常见并发问题
有了以上MVCC和锁这两种核心的并发控制手段,我们可以来讨论更有趣的并发问题处理细节了。
读写并发问题
- 脏读:一个读事务会读到未提交事务的写操作。
- 此时我们可以认为并没有进行版本控制。
- 不可重复读:一个读事务能够读到已提交事务的写操作。
- 此时我们可以认为存在版本控制,但是每次读取都会生成一个新的read view。
- 幻读:一个读事务可以读到另一个事务插入的数据。接下来从隔离级别(read view)的角度分析一下:
- 读未提交:读取到的数据都是最新的版本,肯定会出现幻读
- 读已提交、可重复读:此时可能执行插入操作的事务ID可能会是两种情况——事务ID大于read view中的活跃事务ID集合的最小值;事务ID大于read view中下一个待分配的事务ID
- 当事务ID处于read view中的活跃事务ID集合中,不可见该记录,即select不会出现幻读
- 当事务ID不处于read view中的活跃事务ID集合中,可见该记录
- 对于读已提交来说,read view在每次查询时都会新建,所以会出现幻读现象
- 而对于可重复读来说,read view只会生成一次,那么插入的记录一定是早在事务开启时就已经提交,故不会出现幻读
- 当事务ID大于read view中下一个待分配的事务ID,select一定不会出现幻读
- 可串行化:由于加锁,将会阻塞读取,不会出现幻读
刚才从插入和查询的角度来分析了一下幻读现象,那么对于修改/删除操作又是如何呢(对于当前读同理)?
已知,修改/删除操作是基于最新版本执行,那么经过操作之后,事务ID也随之更改。此时再次执行查询操作,根据MVCC可见性规则可知,当前事务肯定可见这条被自己修改后的记录。
无论当插入记录的事务ID处于/不处于read view中的活跃事务ID集合中,还是插入记录的事务ID大于read view中下一个待分配的事务ID,只要当前事务基于这条插入记录进行修改,再次读取该记录时,是不违反可见性规则的。那么此时就会出现幻读现象。
虽然MVCC提供给我们只读操作下高并发效率以及尽可能安全的并发控制,但是还是可能会出现上述问题。此时我们就不得不通过加锁来避免这些问题了。间隙锁就是为了防止幻读现象而出现的一种特殊行锁,除此之外还需要与插入意向锁搭配使用才能避免幻读现象。
- 如果修改在先(会锁住间隙),插入在后(需要获取插入意向锁),此时插入事务会检测到间隙被锁住,故插入事务阻塞,即插入意向锁和间隙锁不兼容。
- 如果执行插入事务检测到主键冲突,那么插入事务获取到的插入意向锁将会升级为S记录锁,如果检测到的是唯一的二级索引,那么插入意向锁将会升级为S临键锁。
- 如果同时执行多个插入事务,那么第一个插入事务将会升级为X记录锁,其他插入事务阻塞,并试图获得S记录锁/S临键锁。
写写并发问题
脏写
脏写:对同一条记录写入数据时,一个未提交事务的写操作将会被另一个事务的写操作覆盖。
接下来从隔离级别(read view + 锁)的角度分析一下如何避免脏写问题
- 读未提交(不存在read view):事务A写入数据时,虽然会对记录上锁,但是修改完毕后将会立马释放锁,那么另一个事务B就可以继续对事务A写操作涉及到的记录进行修改,那么就会出现脏写问题。
- 读已提交:首先讨论锁方面——事务A的写操作完成后会释放锁,事务B可以修改事务A涉及到的记录,此时仍会出现脏写问题。其次讨论read view——可见性规则就直接告诉我们未提交的事务写入的数据对于持有read view的查询一定是不可见的。故虽然从物理事实上,会出现脏写,但是由于版本链的存在,我们可以找到正确的数据,从而避免脏写问题。
- 可重复读:锁——事务A写操作完成后,并不会立马释放锁,当另一个事务B想要修改事务A涉及到的记录时,将会阻塞,直到事务A提交后,事务B才能对记录进行修改。故从锁这一方面就杜绝了脏写的情况发生。read view方面和上述分析的读已提交隔离级别一致。
- 可串行化(不存在read view):锁方面和上述分析的可重复读隔离级别一致。
综上,我们可知,在读已提交及以上的隔离级别可以避免脏写问题。
更新丢失
更新丢失:对同一条记录执行读取-修改-写入的非原子操作时,就可能会出现更新丢失的问题。换句话说就是,基于失效数据执行写入操作。
就来说就是——事务A成功执行读取且修改,即将执行写入操作,但是事务B此时也成功执行读取事务A涉及到的数据,随后事务A提交了写入操作,最后事务B基于这份失效数据执行了自己的修改-写入操作。
可见脏写属于更新丢失的一种特殊情况
接下来从隔离级别(read view + 锁)的角度分析一下如何避免更新丢失问题:
- 读未提交(不存在read view):
- 如果事务A基于快照读执行写入操作,那么随时可能会由于另一个事务B修改了快照读涉及到的数据,导致事务A基于失效数据执行写入,故会出现更新丢失问题
- 如果事务A基于当前读执行写入操作,那么此时将会对读取的记录上锁,故不可能存在另一个事务提交新版本,故不会出现更新丢失问题。
- 读已提交:
- 如果事务A基于快照读执行写入操作,且在事务A执行写入之前,都不存在另一个事务B提交新版本,事务A还是基于事务A快照读到的最新版本数据执行写入操作,故不会出现更新丢失问题。
- 如果事务A基于快照读执行写入操作,且在事务A执行写入之前,存在另一个事务B提交新版本,事务A将会基于事务B修改后的最新版本数据,即失效数据执行写入操作,故将会出现更新丢失问题。
- 当前读的分析和读未提交一致。
- 可重复读:
- 如果事务A基于快照读执行写入操作,由于read view只会生成一次,无论是否有其他事务B提交了新版本,事务A始终基于事务A快照读到的最新版本数据执行写入操作,故不会出现更新丢失问题。
- 当前读的分析和读未提交一致。
- 可串行化:实际上当前读就是利用锁使得整个读取-修改-写入操作串行化。在该隔离级别下肯定不会出现更新丢失问题。
上述我们分析了快照读和当前读,其中当前读通过上锁,保证了整个读取-修改-写入操作并发安全。那么还有没有其他办法呢?
- 另一种方式就是让整个写入操作直接包含了读取-修改的逻辑,例如
UPDATE user SET value = value + 1 WHERE id = 1234;。 - 还有一种方式就是自动检测更新丢失,属于乐观锁的方式,这需要存储引擎的支持,而InnoDB无法自动检测出来。
写倾斜
写倾斜:对多条记录执行读取,然后判断-写入其中一部分记录,这样的非原子操作就可能出现写倾斜的问题。实际上还是基于失效判断数据执行写入操作。
例如事务A成功执行读取数据X且修改数据Z,即将执行写入数据Z,但是事务B此时修改了事务A读取的数据X,此时事务A就属于基于失效数据X(失效的判断)执行写入操作。
可见写倾斜属于更新丢失的一种特殊情况
对于MVCC和上锁的分析,基本上和更新丢失一致。但是对于一条原子语句来避免写倾斜并发问题就无法实现了,因为写倾斜涉及到的是多条记录,不得不使用select语句来得到判断条件。