行级锁的种类
先复习下 mysql 的数据读取问题:
- 脏读:事务A读取了其他事务未提交的数据,一旦其他事务回滚,事务A就尴尬了
- 不可重复读:一个事务内前后多次读取同一个数据的内容有差异。比如事务A前后两次查询,但是中间数据已经被其他事务修改了,就会导致前后读取的差异。主要针对 update 操作
- 幻读:一个事务内前后查询的数据量级发生变化。原因与不可重复读类似,其他事务在中间过程新增/删除一些数据。主要针对 insert/delete
MySql 中事务隔离级别如下表所示。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| Read Uncommitted | √ | √ | √ |
| Read Committed | × | √ | √ |
| Repeatable Read | × | × | √ |
| Serializable | × | × | × |
InnoDB 支持行级锁,当出现以下语句时会对记录增加行级锁.
- S: 共享锁,允许读读共享
- X: 排他锁,不允许共享
//对读取的记录加共享锁(S型锁)
select ... lock in share mode;
//对读取的记录加独占锁(X型锁)
select ... for update;
//对操作的记录加独占锁(X型锁)
updaet table .... where id = 1;
//对操作的记录加独占锁(X型锁)
delete from table where id = 1;
行级锁的种类主要有三种:
- 记录锁:锁定单条记录。且区分 S 锁和 X 锁
- 间隙锁:锁定一个范围,但不包含记录本身。前开后开
- 只存在于可重复读的隔离级别,防止插入记录出现幻读现象
- 间隙锁之间是兼容的,两个事务可以同时持有包含共同范围的间隙锁
- 底层存储中,间隙锁锁住是右边界,左边界为右边界的上一条记录。比如间隙锁(1,5)实际上锁定的记录是5
- Next-Key Lock: 记录锁+间隙锁,锁定一个范围,并且锁定记录本身。前开后闭
- 又被称为临键锁,既不允许在中间插入数据导致幻读,也不允许修改锁定范围中的数据导致不可重复读
- next-key lock 之间是存在阻塞关系的
各个隔离级别对应的行级锁的关系如下表所示。
| 隔离级别 | 行级锁 |
|---|---|
| 读未提交(READ UNCOMMITTED) | 记录锁 |
| 读已提交(READ COMMITTED) | 记录锁 |
| 可重复读(REPEATABLE READ) | 记录锁、间隙锁和 Next-Key |
执行 Sql 加的都是什么行级锁?
加锁的对象是索引,基本单位是 next-key lock,是由记录锁和间隙锁组合而成的。在能使用记录锁或者间隙锁就能避免幻读的场景下,next-key lock 就会退化为记录锁或者间隙锁。
在下表中,id 是唯一索引,age 是普通索引,对应的隔离级别为「可重复读」
唯一索引等值查询
查询记录存在时,next-key lock 会退化为 「记录锁」。如下图中,执行 “select * from user where id = 1 for update” 后,会对 id=1 的记录增加 X 类型的记录锁。其他针对该 id 的操作都会被阻塞。这里需要额外关注一点:加锁的对象是索引。
为什么唯一索引在等值查询且记录存在的情况下,next-key lock 会退化为记录锁?
原因:仅靠记录锁也能避免幻读
- 主键索引的唯一性天然避免了插入 id 相同的记录
- X 型记录锁可以避免 delete 等删除语句
当记录不存在时,会找到第一个大于该查询记录的记录后,索引退化为 间隙锁。如下图中,执行“select * from user where id = 2 for update”之后会降级为间隙锁,此时会锁住 2,3,4 这个索引范围,但是并不会锁定1,5. 边界的确认方式:右边界-第一个大于id的记录,左边界-第一个小于id的记录
为什么唯一索引在等值查询且记录不存在的情况下,next-key lock 会退化为间隙锁?
原因:仅靠间隙锁也能避免幻读
- id=1/5 的更新/删除 并不会影响 id=2 的查询结果,因此没必要 next-key
- 锁是加在索引上的,查询记录不存在时,无法单独针对 id=2 加记录锁
- 疑惑:那么id=1被删除了,这条记录不复存在,间隙锁会随着更新吗?
唯一索引范围查询
范围查询会默认对扫描到的索引都增加 next-key lock,但是部分情况下会退化为记录锁或者间隙锁。
场景1: > 或 >=
>= 范围查询时,如果=的等值查询记录存在于表中,该记录对应索引会降级为 记录锁
执行 "select * from user where id > 15 for update" 后,增加的锁如下图。这里之所以会有两个锁,原因是 20 是最后一个记录。
注意,正无穷也是可以被锁住的,对应的 lock_data 是 ( supremum pseudo-record)
当执行 "select * from user where id >= 15 for update" 时,与上图相比会多增加一个 id=15 的记录锁,之所以能够降级和前面的等值查询是一致的。
场景2: < 或 <=
条件值的记录不存在,扫描到终止范围查询的记录时,该记录索引会退化为间隙锁,其他扫描记录仍然是 next-lock 锁
执行"select * from user where id < 6 for update"后,增加的锁如下图所示,id=6 不存在,这时无论是< 还是 <= 加锁的方式都是一致的。针对小于或者小于等于的范围查询,如果条件值记录不在表中,扫描到终止范围查询的记录(第一个>条件的记录)时,该记录的锁会退化为间隙锁,其他记录的锁不变,仍然是 next-key lock.
条件记录存在时。如果是<,则该的索引会退化为间隙锁。如果是<=,扫描的记录都不降级
执行 sql "select * from user where id <= 5 for update" 后,对应的锁会变为
当 sql 变为 "select * from user where id < 5 for update" 去掉=的条件后,对应的行级锁变为:
非唯一索引等值查询
非唯一索引查询时,会同时对主键索引,二级索引加锁。主键索引只会针对满足查询条件的记录加锁
场景1: 等值查询的记录不存在
查询记录不存在时,会对相邻的二级索引记录增加间隙锁,不对主键索引加锁
执行 sql "select * from user where age = 25 for update",行级锁的示意图如下:
二级索引 B+ 树的存储顺序是先按照二级索引顺序存储,如果 age 相等,再按照「 id 主键索引」顺序存储。
增加了(22,39)的间隙锁,锁定的右边界是(39, id=20),左边界是(22, id=10)。
此时,如果新增了 (age=39, id = 21) 的记录,不影响现在的锁定记录(39, 20),因此可以正常插入。如果新增了 (age=22, id=11)的记录,由于该记录处于当前的间隙锁的范围内,因此会被阻塞。这种现象可以简单归结为:间隙锁实际上锁定的是两条记录之间的存储范围,而不是具体的 id 或者 index,只要范围内有数据变化就会被阻塞。
场景2: 等值查询的记录存在
当查询记录存在时,可能有多个索引值相同的记录,因此二级索引的查找是一个扫描查询的过程。整体的加锁类似于唯一索引,会对扫描出的二级索引记录加 next-key,相邻不符合条件的记录加间隙锁。符合条件的记录的主键索引会加上记录锁。
执行 sql “select * from user where age = 22 for update”,对应加锁的情况如下图。
- 对二级索引增加间隙锁的原因:为了解决幻读。二级索引可能存在多条记录,需要把相邻的区间也锁住,避免新增 age 相同的数据。这也会导致无法新增 age 在间隙区间的记录,age=21/35 是否可以增加取决于 id 的范围
非唯一索引范围查询
非唯一索引的范围查询,不存在 next-key 降级的情况。
执行 “select * from user where age >= 22 for update” 后,记录的锁状态如下图所示:
age=22 不会退化为记录锁的原因:不具备唯一性,不能避免幻读
不走索引的查询
结论:锁全表