面试爽文 :没想到吧,一个 MySQL 锁能问出20多个问题
首先分享之前的所有文章 , 欢迎点赞收藏转发三连下次一定 >>>> 😜😜😜 文章合集 : 🎁 juejin.cn/post/694164… Github : 👉 github.com/black-ant CASE 备份 : 👉 gitee.com/antblack/ca…
一. 前言
👉👉👉 本篇确实不易,花了不少心思,感谢点赞收藏
!!!
实践和问题可以帮助我们更深入的学习,这篇文章算是广度问题,对于一些细节点不会太深入
。
首先要有个宏观概念,整个锁囊括了大量的内容 ,首先我尝试用简单的几句话概括整个过程 :
- 我们可以从2个角度看锁 : 锁的类型(行锁 ,表锁 ,间隙锁) ,锁的模式 (独占锁,共享锁)。基本上 90% 的功能(包括 死锁 ,隔离级别的部分原理)都是基于上述2个角度进行分析。
- 日常使用中会涉及到的核心点主要是 : 间隙锁 ,死锁 。 再往深入理解可以涉及锁的结构,事务隔离级别的实现原理。
二. 从头认识锁
2.1 锁有哪些范围 ? 不同的存储引擎都有哪些锁 ?
- 行锁 (记录锁) :锁定数据特定行,控制单个数据的读写
- 间隙锁 : 用于锁定某个范围的间隙 (防止事务插入数据,出现幻读)
- 下一键锁 (Next-Key Locks):和间隙锁类似,间隙锁不会锁自己,下一键会锁自己
- 页级锁 :对数据页进行锁定 (当大批量修改数据或者全表扫描时)
- 表锁 :锁定数据库表,对表的操作都会阻塞(通常用于表修改和索引调整,全量导出或者备份)
- 全局锁 :用于锁定整个数据库(用于数据备份)
❓ 那么存储引擎里面支持哪些锁的范围呢?
通常我们谈存储引擎主要是 MyISAM 和 InnerDB 。 MyISAM 功能较少,支持的是表锁,不支持比表锁粒度更低的锁类型。
而 InnerDB 支持上述说的的所有类型,所以其中需要注意的就是不同锁的触发场景,粒度越细的锁触发的越容易。如果要理解得更加透彻,就需要理解锁得内存结构
和原理
,然后明白不同锁带来得性能和损耗
(下文详述)
2.2 锁有哪些模式?有什么特性呢?
- 共享锁 (读锁、S锁):多个事务
可以同时持有同一数据的共享锁
- 独占锁 (写锁、排他锁、X锁):一个数据的排他锁
只能被一个事务持有
,其他事务不能再持有该数据的任何锁- 这里的任何锁包括读锁,也就是说如果一个数据已经有了排他锁,那么其他事务的共享锁再上锁了
- 共享意向锁(IS锁) : 本质上是一种标记,
表示某个事务正准备获取共享锁
- 事务A向数据库系统发起请求,请求获取共享锁
- 事务B此时向数据获取排他锁时,如果数据库查询到存在共享意向锁,则事务B无法获取到排他锁
- 排他意向锁 (IX): 同样是一种标记
- 事务A向数据库系统发起请求,请求获取排他锁
- 事务B获取共享锁或者排他锁的时候,如果数据库判断存在排他意向锁,则事务B不能上任何锁
- 隐式锁 : 一种非实体的锁结构
一句话解释 :读锁(S锁)只和读锁兼容,但凡有一个写锁(X锁),读锁就不能和他兼容
2.3 乐观锁和悲观锁是什么?
乐观锁和悲观锁单独拿出来说,这两种锁并不是一种物理概念,而是一种业务用法
。
- 乐观锁 : 乐观的认定锁的竞争场景较少 , 不需要特定的锁对象
- 思路 :通过
版本号字段
(自行添加),少部分场景可以通过时间戳
- 实现 :读取时拿到当前对象的版本号,当操作数据时会通过
判断版本号从而判断当前数据是否被修改过
- 优点 : 性能更优秀,不需要额外的锁定,
不会阻塞
,也不会影响到其他的并发请求 - 缺点 : 需要自定义版本号字段,且当版本号不一致时,当前事务会失败
- 思路 :通过
- 悲观锁 : 认为大部分场景都会出现锁竞争,在事务一开始的时候就去获取锁,保证整个过程的锁定
- 思路 :读取和操作数据时直接加锁,
上文说的物理层面的锁都是悲观锁
- 实现 :直接加锁,略
- 优点 : 数据一致性更高 , 不会轻易的出现异常回滚
- 缺点 : 会锁定或者阻塞数据,
并发性能低,可能触发死锁
- 思路 :读取和操作数据时直接加锁,
2.4 通常说的隐式锁是什么 ?
- 隐式锁是 InnerDB 引擎的一种加锁模式 ,通常 Insert 语句都是隐式锁。
- 隐式锁是一种
延迟加锁机制
,当判断不会发生锁冲突的时候,实际上会跳过加锁环节
- 隐式锁有较小的几率转换为显示锁,常见的例如
事务1插入数据未提交
,事务2尝试对事务2加锁
时
❓那么隐式锁的原理是什么?
在后文中就可以了解到,每条记录都会有个 trx_id
字段存放在聚集索引
中 ,用于判断当前处理该数据的事务 :
❓那么隐式锁又是什么场景转换为显示锁的?
PS :这一段有点超前了, 可以把下面的锁结构看了再回头来看
- 主键索引 : 通过聚簇索引记录的 trx_id 隐藏列实现
- S1 : 当前事务A插入一条聚簇索引记录,该记录的
trx_id
为当前事务A - S2 : 其他事务B想要对记录进行操作 (读 / 写),判断当前的 trx_id 对应的事务是否为
活跃事务
- S3 : 如果是活跃用户,则访问事务B会帮助
持有该对象的 事务A
创建一个is_waiting 为 false
的 X 锁 - S4 : 同时
为自己(B)
创建一个is_waiting
为true
的锁结构 ,标识自己等待锁释放
- S1 : 当前事务A插入一条聚簇索引记录,该记录的
- 二级索引 : 当发生插入时,会更新所在page的max_trx_id
- S1 : 当触发二级索引的时候,会在二级索引页面得 page Header 部分设置
PAGE_MAX_TRX_ID
属性- 该属性表示对页面
做改动
得最大的事务ID
- 该属性表示对页面
- S2 : 如果 PAGE_MAX_TRX_ID 的值
小于当前最小的活跃事务ID
,说明事务已提交
- S3 : 如果
不是
,则进行回表后
,进行上述主键索引
的逻辑
- S1 : 当触发二级索引的时候,会在二级索引页面得 page Header 部分设置
简单点说,二级索引也是在索引当前的处理情况,如果还在处理,同样要回表加锁
具体更细节的涉及到源码逻辑,这里不深入,可以参考这篇文章 : MySQL InnoDB隐式锁功能解析
2.5 意向锁的作用
- 插入意向锁是一种间隙锁,
在 Insert 时触发
- 此锁表明,插入同一索引间隙的多个事务如果没有插入间隙内的同一位置,则无需互相等待
- 案例一 : 如果2个事务在数据 4-7 之间插入 5和6 ,此时2个事务都用插入意向锁锁定4-7,则不会发生阻塞
- 案例二 : 如果此时一个事务获取 4-7 之间的排他锁,在获取插入意向锁的同时,
还是会等待排他锁释放
三. 从原理的角度深入了解锁
3.1 锁的内存结构是什么样的 ?
抽象结构:
- trx:代表这个锁结构是
哪个事务生成
的。 - is_waiting:代表当前事务
是否在等待
。
行锁核心结构:
struct lock_rec_struct{
ulint space; // 所在表空间
ulint page_no; // 当前所处页
ulint n_bits; // 位图 , 位图中的索引与记录的 head_no 一一对应
}
关键点 :
- 锁结构是按照
页
进行区分的 - 行锁会记录 SpaceID(记录所在表空间),Page Number(记录所在页号) ,n_bits(比特集合)
- n_bits : 通过比特位来区分哪些记录加了锁,每一个比特位
3.2 和锁有关的表有哪些 ?
- INNODB_TRX : 存储有关正在运行或曾经运行的事务的信息
- trx_id : 事务ID
- trx_requested_lock_id :请求的锁定标识符
- trx_wait_started : 等待锁定的开始时间(如果事务正在等待锁)
- trx_mysql_thread_id : 与事务相关联的 MySQL 线程标识符
- trx_state (事务状态) / trx_started (事务启动时间) / trx_query (事务SQL查询)
- trx_tables_in_use (正在时使用的表数量)/ trx_tables_locked (已锁定的表的数量)
- trx_isolation_level : 事务的隔离级别
- INNODB_LOCKS :存储有关当前正在等待或持有的锁定的信息
- lock_id : 锁定的唯一标识符
- lock_trx_id :持有或等待该锁的事务的标识符
- lock_mode : 锁定的模式 (共享锁/排他锁)
- lock_type : 锁定的类型 (表锁 / 记录锁)
- lock_table / lock_index : 锁定的表和索引
- lock_space 和 lock_page:受锁定的页的标识符(仅适用于页锁)
- lock_data : 附加数据 (键值,主键)
- INNODB_LOCK_WAITS : 存储正在等待锁的事务的信息
- requesting_trx_id : 正在请求锁的事务的标识符
- requested_lock_id :所请求的锁的唯一标识符
- blocking_trx_id : 导致锁等待的事务的标识符
PS :这里提一下 ,在不同的版本中表是不同的,在 8.0 里面这两张表叫 data_locks 和 data_lock_waits。
官方文档
3.3 当多个事务到来的时候,加锁流程是怎样的呢?
一个简单的操作 :
- S1 : 当事务改动一条记录时,会生成一个锁结构与记录相连,此时 is_Waiting 属性为
false
- S2 : 当第二个事务发起改动求得时候,会首先判断锁结构是否存在(即对象是否上锁),如果已经上锁,则生成
第二个锁结构关联该条记录
(此时第二个锁结构得 is_waiting 为true
) - S3 : 当第一个事务处理完成后,会释放自己的锁结构,同时判断是否有其他的事务等待锁
- S3-1 : 如果有对象等待锁,则唤醒对应事务的线程,同时修改对应事务的锁结构的 is_waiting 属性
如果涉及到隐式锁,可以看上文2.4之隐式锁的处理
👉👉内存结构层面的锁 (可了解):
行锁的具体实现主体是bitmap,每条记录一个bit存储。
维护一个锁的全局hash表,key值由(space_*id,* page *_* no
)计算得到,value为一个链表,存储该页锁信息。用于事务上锁时判断相应页是否存在锁冲突。
同时各个事务都会维护一个锁链表,存储该事务的锁结构。不同事务即使是对同一条记录上同样模式的锁,也需要分别创建一个锁对象。用于事务结束时释放锁
👉👉当新的事务来临时 :
- S1 : 首先查询
Hash 链表
,判断某个页面上是否存在锁 - S2 : 如果不存在,则直接生成锁(或者隐式锁),生成的锁加入
Hash链表
和事务的锁链
- S3 : 若存在,则判断是否可重用,如果有冲突,则创建等待锁,并挂起等待(
全局维护一个等待对象数组
) - S4 : 当拿到锁时,设置对应记录的 bit_map 位,用于后续的锁冲突判断
3.4 锁的算法说的是什么 ?
InnerDB 引擎里面有3种锁的算法 :
- Record Lock : 单个行记录的锁
- Gap Lock : 间隙锁,锁定一个范围,但是不包含记录本身
- Next-Key Lock : (Gap Lock + Record Lock) , 锁定一个范围的同时锁定记录本身
- Insert Intention Locks (插入意向锁) :当一个事务想向 Gap Lock (间隙锁)插入数据时,会生成该锁
Insert Intention Locks 场景主要是当一个间隙锁产生的时候,如果另外一个事务想往间隙插入数据,就会产生插入意向锁 , 表示有事务想在某个间隙
中插入新记录,但是现在在等待
3.5 归根结底锁的原理是什么 ?
锁的本质其实是对索引加上锁结构,以下是几种常见的场景 :
锁会和事务绑定
,一个锁对应一个内存中的锁结构- 通常说的对索引加锁不是说把索引数据改了,而是
锁结构中会绑定这些信息
- 每个行/页/表都会有对应的
全局变量
,记录当前数据/范围是否存在锁,但是最终判断还是要走锁结构
等值查询场景 :
- 主键等值查询 : 对聚簇索引中对应的主键记录进行加锁
- 正常查询 / LOCK IN SHARE MODE :会加上共享锁
- FOR UPDATE : 会加上独占锁
- 主键等值更新 : 会加入独占锁
- 如果更新了二级索引列,则会在对应的二级索引上加上独占锁
- 主键删除 : 加锁步骤类似于 update ,先删除聚簇索引记录,再删除对应的二级索引
范围查询场景 :
- 主键范围查询 : 会基于聚簇索引加间隙锁
最终总结 :
- 通过
主键
进行加锁的语句,仅对聚集索引记录进行加锁
- 通过
辅助索引记录
进行加锁的语句,首先要对辅助索引记录加锁,再对聚集索引记录加锁
- 通过辅助索引记录加锁的语句,
可能会涉及到下一记录锁和间隙锁
- 当加锁时,会在内存中生成各种锁对象
- 同时这些锁对象会根据
space + page_no
映射到对应的哈希桶
中 (用于逆向的行查锁)
四. 常用的业务场景和问题 (重点)
4.1 那么不同的操作又是怎么加锁的 ?
前置要点回顾 :共享和排他锁的竞争原则 👉👉
- 同一个事务里面,数据如果已有了排他锁,还是可以被当前事务获取到共享锁 (一个事务里面不冲突)
- 不同事务里面,在没有排他锁的场景下,可以任意获取共享锁 (读锁不冲突)
- 不同事务里面,一个事务获取了排他锁,其他的事务还是不能获取数据的共享锁 (不同事务读写冲突)
❓操作是怎么加锁的呢?
- 读取加锁 , 可以加S锁和 X锁
- SELECT ... FROM ; == 如果不是 SERIALIZABLE(串行化)则会进行快照读,不会加锁
- SELECT ... LOCK IN SHARE MODE; == 对数据加 S 锁,此时读请求可以进来,X锁操作不能进来
- SELECT ... FOR UPDATE; == 对数据加 X 锁 , 此时任何其他事务的操作都会被阻塞
- 写操作加锁 (唯一索引):
- DELETE : 先在 B+ 树获取记录 , 然后获取这条记录的 X锁(排他) , 后面再执行 delete mark 操作
- UPDATE : 分为多种不同的情况
- 未修改键值 + 更新的列存储空间无变化 : 只获取 X锁
- 未修改键值 + 更新的列储存空间变化 : 先获取 X 锁 ,然后删除记录 , 再插入新的数据(隐式锁)
- 修改了键值 : 在原记录上做
DELETE
操作之后再来一次INSERT
操作
- INSERT : 新插入一条记录的操作并不加 , 通过隐式锁镜像控制
- 间隙操作 (其他搜索条件或非唯一索引)SELECT使用FOR UPDATE或FOR SHARE 或 UPDATE和 DELETE:
- InnoDB锁定扫描的索引范围 ,使用间隙锁或 下一个键锁(Next-Key Locks) 来阻止其他会话插入该范围所覆盖的间隙
👉容易误解的点 :
- 如果有3个事务对同一个数据进行请求的时候,会产生几个锁结构呢 ?
- 答 : 这种场景下会生成3种锁结构
- 第一个锁 :获取到资源的事务 ,会是生产一个 is_waiting = false 的锁
- 第二个锁 / 第三个锁 : 会生成一个 is_waiting = true 的锁 , 标识等待该数据
MySQL :: MySQL 8.0 Reference Manual :: 15.7.1 InnoDB Locking
4.2 什么是锁重用
在 MySQL 的处理逻辑中,为了减少锁的开销,InnerDB 引擎会重用已经创建好的 lock_t 对象。
锁重用有2个前提 :
- 同一个事务锁住同一个页面中的记录
- 锁的模式相同
当符合这2个条件后,就可以复用内存锁结构
4.3 间隙锁到底是什么 ?
- 幻读 : 一个事务在第二次查询时看到了不同的记录数量或不一致的数据
- 目的 :保证某个范围内的记录不存在或不被插入新的数据,主要是为了防止幻读
- 解释 :假设一个事务里面对同一个数据查询了2次,要保证2次查询的结果一致
- 误导点 : 间隙锁通常不是修改时产生,大多数情况下是在查询时产生
👉间隙锁和下一键锁有什么区别 :
- 间隙锁 : 锁定范围,防止幻读
- 下一键锁 :不仅锁定指定键的范围,还锁定该范围内的下一条记录 , 主要目的是为了一致性和隔离性
- 不仅防止幻读,还阻止其他事务在锁定范围内插入新行,
同时也阻止其他事务更新范围内的下一行
- 不仅防止幻读,还阻止其他事务在锁定范围内插入新行,
👉间隙锁的加锁流程:
SELECT * FROM sso_user WHERE id 互斥条件 + 不可剥夺条件 - 当此时互相请求时,就触发了死锁 //> 请求与保持条件 + 循环等待条件