MySQL innodb锁机制原理

MySQL锁机制全景图

1.锁的类型

1.1 占有模式

  • 共享锁 Share Lock/S锁
  • 排他锁 Exclusive Lock/X锁
S X
S 兼容 不兼容
X 不兼容 不兼容
  • 对行记录加X锁
    insert、update、delete以及显式的 for update 语句都会对行记录加X锁
1
2
3
4
select * from table_name where id = 1 for update;
insert into table_name values (1, 'test');
update table_name set name = 'test' where id = 1;
delete from table_name where id = 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如下

RC隔离级别

事务A先于事务B提交,此时如果事务B再执行普通select操作,在当前隔离级别下事务B生成新的的ReadView如下所示

事务B ReadView

此时事务A的事务Id不在当前事务B的ReadView中的活跃事务id列表中,因此事务A的版本链中的数据对事务B可见,因此事务B可以读取到事务A的修改数据
也就是读取已提交

2.2.3.2 RR隔离级别

假设某一时刻A、B两个事务开启后生成的ReadView如下

RC隔离级别

事务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: select * from user where id = 2 for update;
      • (1,5)->间隙锁,加在了id=5上,避免其他事务插入id=2的记录,其他id=3、4的记录插入时同样会被阻塞
  • 范围查询
    • 大于
      • sql: select * from user where id > 15 for update;
        • (15,20]-> 临键锁,加在了20上. (20,+∞]-> 临键锁
      • 大于时,比较值存不存在其实是不影响的,如果这里id取了17,加锁情况还是不变的,可以仔细想一下
    • 大于等于
      • sql: select * from user where id >= 15 for update;
        • id=15 -> 记录锁, (15,20]-> 临键锁,加在了20上. (20,+∞]-> 临键锁
      • 大于等于时如果比较值不存在时,如id取17,则会加(15,20]-> 临键锁,加在了20上. (20,+∞]-> 临键锁
    • 小于
      • 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: select * from user where id <= 5 for update;
        • (-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁,加在5上
      • 如果比较值不存在,假设比较值为7,则加锁情况为:(-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁,加在5上. (5,10)->间隙锁,加在了10上
    • 小于
      • sql: select * from user where id < 5 for update;
        • (-∞,1]-> 临键锁,加在了1上. (1,5)-> 临键锁,加在5上
      • 如果比较值不存在,假设比较值为7,则加锁情况为:(-∞,1]-> 临键锁,加在了1上. (1,5]-> 临键锁,加在5上. (5,10)->间隙锁,加在了10上

总结
至此,唯一键索引在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: select * from user where age = 22 for update;
      • age索引: (21,22]-> 临键锁,加在了22上. (22,39)-> 间隙锁,加在了39上, id=10->记录锁,加在了id=10上
      • 同样对于age=21和age=39也是存在一些可以插入和不能插入的情况,同上
  • 范围查询
    • 大于等于
      • 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: select * from user where age <= 20 for update;
        • (-∞,19]-> 临键锁,加在了19上. (19,20]-> 临键锁,加在20上,(20,21)->间隙锁,加在了21上,id=1,id=15->记录锁,加在了id=1,id=15上
    • 其他情况
      • 按照上述方式分析即可,不做过多描述

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类型临键锁,对与主键导致的唯一键冲突会对此记录加上记录锁
与间隙锁协同工作

5.3 S锁的工作过程

S锁的工作过程

5.4 具体示例

具体示例