MySQL innodb锁机制原理
1.锁的类型
1.1 占有模式
- 共享锁 Share Lock/S锁
- 排他锁 Exclusive Lock/X锁
S | X | |
---|---|---|
S | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 |
- 对行记录加X锁
insert、update、delete以及显式的 for update 语句都会对行记录加X锁
1 |
|
- S锁
1
select * from table_name where id = 1 lock in share mode;
1.2 锁的类型
- 记录锁 Record Lock: 锁定具体的行,依赖于索引,分为独占和共享两种
- 依附于索引,因此查询时命中所以尤为关键
- 间隙锁 Gap Lock: 对记录行之间的空隙加锁
- 间隙锁不存在共享或排他的概念,它的目标就是拦截间隙范围内即将到来的insert行为,因此间隙锁生效的前提依赖于insert行为
- 间隙锁也是依附于索引而存在,具体载体是间隙所在范围右边界遇到的第一条记录对应的索引,倘若右边界不存在则使用无穷大进行补齐
- 临键锁 Next-Key Lock: 行锁和间隙锁的组合,本质上为间隙锁加右边界形成的组合
- Gap Lock + 右边界首条记录的行锁(Record Lock)形成的组合锁,锁定的是左开右闭的区间
- 表级意向锁 Intention Lock: 在具体操作行为之前进行意向声明,用于判断是否可以进行操作
- 为了提高粗粒度所性能而设置的一种预判机制,所有申请行锁的操作都要申请到相同占有模式下的表级意向锁
- 表级意向共享锁(IS Lock)
- 表级意向排他锁(IX Lock)
- 插入意向锁 Insert Intention Lock: 插入前置校验步骤,配合间隙锁使用
- 死锁
- 超时回滚:产生死锁的直接导火索是等锁行为,因此当等待时间超过一定阈值之后就超时回滚可避免
- 等待图wait-for
graph机制:这是innodb主动探测死锁的一种机制,针对事务的等锁依赖关系构筑一条链表,当链表成环时表示存在死锁。此时innodb会选择事务进行回滚以破坏死锁,这里选择事务时会选择最小权重的,即事务涉及修改和锁住的行记录数最小
2.隔离级别与锁之间的关系
2.1 一致性非锁定读
innodb针对commited-read 和 repeatable-read 两种隔离级采用MVCC机制作为应对策略
MVCC实现上可以拆分为版本连结构与版本策略选择两个部分
- 版本链结构:每当事务修改一行数据时,会基于写时复制机制生成一个副本,并通过指针指向上一个版本,如此形成一个链表状的数据结构,即版本链
- 版本选择策略:针对普通非锁定select操作本质上是便利版本连选择合适的版本进行读取,以保证查询视角的一致性
因此不同的隔离级别其实是在版本连中的选择策略不同
2.1.1 版本链
- row_id: 非必须,innodb采用非聚簇索引,必须存在主键作为数据的存储载体,如果未现实的声明主键,则会使用此隐藏字段row_id作为主键索引
- transaction_id: 事务id,表示一个版本是哪个事务生成的,是全局递增ID
- roll_pointer: 回滚指针,指向上一个版本,用于版本链的构建
innodb中为了支持事务的回滚启用了undo-log机制,这也天然支持了版本链而不需额外的成本开销。只不过为了保证MVCC中数据视图的一致性,针对undo-log中老版本日志回收需要适当延后,保证知道不存在更小的活跃事务id存在时才能回收
2.2.2 选择策略
ReadView机制:在事务启动时会生成一个ReadView,用于描述当前事务的可见性范围,在事务执行过程中会根据ReadView中的信息来选择合适的版本进行读取,ReadView包含以下几部分信息
- m_ids: 当前处于活跃状态的事务ID列表
- 所谓活跃即是未提交,用于遍历版本链时判断是正式数据还是草稿数据
- up_limit_id: m_ids中最小活跃事务ID
- undo-log版本链中,对于id < up_limit_id的老版本数据可以及进行回收
- low_limit_id: 分配给下一个事务的ID,全局唯一递增
- creator_trx_id: 指的是创建该 Read View 的事务的事务 id
在RC与RR隔离级别下,非锁定读(即普通select)都会获取ReadView,并遍历版本链选择合适的版本进行读取
- RC下每次select都会生成一个新的ReadView,因此每次select读取的都是当前时刻的最新数据
- RR下只在事务开启时获取一次ReadView,并在整个生命周期内进行复用。同时还需保证所选的事务id要小于low_limit_id,即选择的事务id要在事务开启之前,否则会读取到未来的事务数据,这就保证了事务的可重复读
2.2.3 RC与RR隔离级别下的一致性非锁定读
2.2.3.1 RC隔离级别
假设某一时刻A、B两个事务开启后生成的ReadView如下
事务A先于事务B提交,此时如果事务B再执行普通select操作,在当前隔离级别下事务B生成新的的ReadView如下所示
此时事务A的事务Id不在当前事务B的ReadView中的活跃事务id列表中,因此事务A的版本链中的数据对事务B可见,因此事务B可以读取到事务A的修改数据
也就是读取已提交
2.2.3.2 RR隔离级别
假设某一时刻A、B两个事务开启后生成的ReadView如下
事务A先于事务B提交,此时如果事务B再执行普通select操作,并不会生成新的ReadView
因此事务A的Id=101还在事务B的m_ids中,处于活跃的事务id列表中,因此事务A的版本链中的数据对事务B不可见,因此事务B无法读取到事务A的提交数据
这就保证了事务B在整个事务生命周期内读取到的都是同一份数据,即可重复读
2.2 一致性锁定读(RR)
- 插入阻塞:插入之前定位到该记录在B+树的位置,如果该记录的下一条记录上存在间隙锁,则此记录插入会阻塞
- 幻读:在同一个事务中,两次查询的结果集不同
- 行级锁:记录所、间隙锁、临键锁
根据上述三个条件即可分析在各种事务中的锁的使用情况:
注意事项:所有的select均在事务中(begin/commit/rollback)执行
2.2.1 唯一索引
测试的user表如下
id主键索引 | 1 | 5 | 10 | 15 | 20 |
---|---|---|---|---|---|
name列 | a | b | c | d | e |
age列 | 19 | 21 | 22 | 20 | 39 |
- 等值查询记录存在
- sql:
select * from user where id = 1 for update;
- 锁
- id=1的X锁(记录锁),仅需对这个记录上锁即可避免幻读(如被其他事务删除)
- sql:
- 等值查询记录不存在
- sql:
select * from user where id = 2 for update;
- 锁
- (1,5)->间隙锁,加在了id=5上,避免其他事务插入id=2的记录,其他id=3、4的记录插入时同样会被阻塞
- sql:
- 范围查询
- 大于
- sql:
select * from user where id > 15 for update;
- 锁
- (15,20]-> 临键锁,加在了20上. (20,+∞]-> 临键锁
- 大于时,比较值存不存在其实是不影响的,如果这里id取了17,加锁情况还是不变的,可以仔细想一下
- sql:
- 大于等于
- sql:
select * from user where id >= 15 for update;
- 锁
- id=15 -> 记录锁, (15,20]-> 临键锁,加在了20上. (20,+∞]-> 临键锁
- 大于等于时如果比较值不存在时,如id取17,则会加(15,20]-> 临键锁,加在了20上. (20,+∞]-> 临键锁
- sql:
- 小于
- sql:
select * from user where id < 7 for update;
- 锁
- (-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁, (5,10)->间隙锁,加在了10上
- 如果比较值(id=7)存在的话,加锁情况就变成了:(-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁, (5,7)->间隙锁, 7即使被删除也不会导致当前事务产生幻读,因此5-7是间隙锁
- sql:
- 小于等于
- sql:
select * from user where id <= 5 for update;
- 锁
- (-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁,加在5上
- 如果比较值不存在,假设比较值为7,则加锁情况为:(-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁,加在5上. (5,10)->间隙锁,加在了10上
- sql:
- 小于
- sql:
select * from user where id < 5 for update;
- 锁
- (-∞,1]-> 临键锁,加在了1上. (1,5)-> 临键锁,加在5上
- 如果比较值不存在,假设比较值为7,则加锁情况为:(-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁,加在5上. (5,10)->间隙锁,加在了10上
- sql:
- 大于
总结
至此,唯一键索引在RR隔离级别下的一致性锁定读的锁情况就分析完了,有几点需要注意的:
- 唯一索引如果是二级索引,那么加锁时不仅锁住二级索引,而且会对对应的主键索引值也进行加锁
- 唯一索引如果是主键索引,那么加锁时只会对主键索引进行加锁
- 分析事务的加锁情况要带着问题去分析,即如何通过行级锁解决幻读的问题,需要怎么设计才可以避免幻读
2.2.2 非唯一索引
user表测试数据如下
age非唯一索引 | 19 | 20 | 21 | 22 | 39 |
---|---|---|---|---|---|
id主键索引 | 1 | 15 | 5 | 10 | 20 |
name列 | a | b | c | d | e |
- 等值查询值不存在
- sql:
select * from user where age = 25 for update;
- 锁
- age索引: (22,39)-> 间隙锁,加在了39上
- 这会导致age为22和39的新记录在某些情况被阻塞下不可插入,不能插入的情况就是当前记录的下一个记录上存在间隙锁
- age=22,id=5 -> 下一条记录为age=22,id=10,不存在间隙锁,可以插入
- age=22,id=12 -> 下一条记录为age=39,id=20,存在间隙锁,不能插入
- age=39,id=15 -> 下一条记录为age=39,id=20,存在间隙锁,不能插入
- age=39,id=22 -> 下一条记录不存在,不存在间隙锁,能插入
- sql:
- 等值查询值存在
- sql:
select * from user where age = 22 for update;
- 锁
- age索引: (21,22]-> 临键锁,加在了22上. (22,39)-> 间隙锁,加在了39上, id=10->记录锁,加在了id=10上
- 同样对于age=21和age=39也是存在一些可以插入和不能插入的情况,同上
- sql:
- 范围查询
- 大于等于
- sql:
select * from user where age >= 22 for update;
- 锁
- (21,22]-> 临键锁,加在了22上. (22,39]-> 临键锁,加在了39上,(39,+∞]临键锁, id=10和id=20->记录锁,加在了id=10和id=20上
- 同样对于age=21和age=39也是存在一些可以插入和不能插入的情况,同上
- 如果比较值不存在,假设比较值为23,则加锁情况为:(22,39]-> 临键锁,加在了39上,(39,+∞]临键锁, id=20->记录锁,加在了id=20上
- sql:
- 小于等于
- sql:
select * from user where age <= 20 for update;
- 锁
- (-∞,19]-> 临键锁,加在了19上. (19,20]-> 临键锁,加在20上,(20,21)->间隙锁,加在了21上,id=1,id=15->记录锁,加在了id=1,id=15上
- sql:
- 其他情况
- 按照上述方式分析即可,不做过多描述
- 大于等于
3.MySQL的RR级别完全解决了幻读问题吗?
答案是没有,只是在很大程度上避免了幻读
考虑下面情况:
事务A | 事务B | |
---|---|---|
时刻1 | begin; select * from user where id > 5;// 无结果 |
|
时刻2 | begin; insert into user (5,19,’a’); commit; |
|
时刻3 | update user set name = ‘b’ where id=5; | |
时刻4 | select * from user where id > 5; // 输出id=5的记录 |
- 时刻3事务A更新id=5的记录,那这条记录的隐藏列trx_id将会变成事务A的事务Id,这样这条记录在事务A的ReadView中就变得可见,因此时刻4事务A的查询就可以查得到
- 此中情况也就导致了幻读
如何解决这种问题?
答案是在事务A里尽可能早的加锁处理,如在时刻1:select * from user where id > 5 for update;
这样事务B的插入就会被阻塞,直到事务A提交或回滚,这样就避免了幻读的问题
4.死锁构建
order表测试数据如下
id | order_id | desc |
---|---|---|
1 | 1001 | aaa |
2 | 1002 | bbb |
3 | 1003 | ccc |
4 | 1004 | ddd |
5 | 1005 | eee |
6 | 1006 | fff |
4.1 死锁构建
事务A | 事务B | |
---|---|---|
时刻1 | begin; | begin; |
时刻2 | select id from order where order_id = 1007 for update; | |
时刻3 | select id from order where order_id = 1008 for update; | |
时刻4 | insert into order value(7,1007,’ggg’); | |
时刻5 | insert into order value(8,1008,’ggg’); |
- 上述情况下时刻2与时刻3都进行当前读,会获取到(1006,+∞]的临键锁,因为临键锁是相互兼容的,因此时刻2与时刻3都可以获取到(1006,+∞]的临键锁
- 时刻4事务A进行插入,发现存在(1006,+∞]的临键锁,因此事务A会被阻塞,等待事务B释放锁
- 时刻5事务B进行插入,发现存在(1006,+∞]的临键锁,因此事务B也会被阻塞,等待事务A释放锁
- 此时就会出现死锁
4.2 同一事务锁冲突问题
继续4.1事务执行情况,仅考虑事务A
- 时刻2进行当前读,获取到(1006,+∞]的临键锁
- 时刻4插入,获取插入意向锁,发现待插入位置存在临键锁,会发生锁冲突,但是因为两个操作是同一事务的,所以时刻4在A事务中是能正常执行的,即MySQL允许同一事务内的操作获取冲突的锁。
4.3 死锁避免
- 等待超时
- 主动死锁检测,通过wait-for图检测死锁,发现死锁后选择一个事务进行回滚,释放锁,避免死锁
- 业务上避免死锁,进行预防
5.insert加锁(RR隔离级别)
插入是会检查待插入记录的下一条记录是否存在间隙锁,如果存在就生成插入意向锁,锁的状态设置为等待,对外表现即为插入阻塞。
获取表级IX锁 → 获取插入意向锁 → 检查唯一约束 → 根据情况加X锁或S锁
5.1 唯一键冲突加锁机制
唯一键冲突时会加上S锁,而不是简单的返回错误
5.2 唯一键冲突加S锁原因
5.2.1 防止幻读
防止在当前事务第一次插入失败,记录被其他事务删除后本事务重试插入成功导致幻读问题
5.2.2 确保唯一性约束的正确性
唯一键冲突时加上S锁让其他事务按顺序处理,避免两者都成功
5.2.3 与间隙锁协同工作
与间隙锁协同工作增强隔离性
在非主键的唯一索引发生冲突时除了对记录加S锁还会加上间隙锁,即S类型临键锁,对与主键导致的唯一键冲突会对此记录加上记录锁