02 深入专题:事务与锁机制
这一篇聚焦 MySQL 的事务隔离级别、MVCC、锁体系(记录锁/间隙锁/Next-Key 锁/意向锁)、死锁分析与排查,以及不同语句和索引条件下的加锁行为。内容整理自爱可生开源社区《大智小技》系列(2019/2020 两册)技术文章精选。
Insert 语句遇到唯一键冲突时 InnoDB 是如何加锁的,为什么会演变成死锁?
前提:RC 隔离级别、字段建有唯一索引。session1 先 delete c2=15,在唯一索引 c2=15 上加 X Lock but not gap;session2、session3 执行 insert 遇唯一冲突,加 S Next-Key Lock 含记录与前间隙,被 session1 的 X 锁阻塞。session1 commit 释放后,session2、session3 都获 S Next-Key Lock;随后真正插入时生成 INSERT INTENTION LOCK,该锁被 gap lock 阻塞,于是 session2 与 session3 互相等待对方 gap 锁,形成死锁,其中之一回滚。结论:RC 唯一冲突仍加 S Next-Key Lock,插入意向锁间不互斥但都阻塞于对方 gap 锁。
READ-COMMITTED 隔离级别下为什么仍会出现 gap lock?唯一约束冲突时加的是什么锁?
通常认为 gap lock 只存在于 RR 以解决幻读,但 RC 隔离级别在两类场景仍会出现 gap lock:(1) 唯一约束检查遇到唯一冲突时,会加 S Next-Key Lock(记录+前间隙的共享锁),即使直接报错 Duplicate key,该 S Next-Key Lock 也不会立即释放,后续会话仍会被阻塞;(2) 外键约束检查。换句话说,RC 的 gap lock 仅用于“唯一性/外键检查”,不用于普通范围防幻读。因此 RC 下普通 UPDATE/DELETE 仅加 Record Lock(X but not gap),并发插入间隙自由,冲突面远小于 RR。文中建议:绝大多数业务可用 RC;业务能控制唯一性的前提下尽量减少唯一索引数量,以降低此类隐性 gap 锁带来的死锁与阻塞。
什么是插入意向锁(Insert Intention Lock),它与普通 gap lock 有何不同,为什么容易被忽略?
插入意向锁(Insert Intention Lock)是 InnoDB 在真正插入行、获取记录 X 锁之前设置的一种特殊 gap lock(官方手册原文:prior to inserting the row, a type of gap lock called an insert intention gap lock is set)。它只锁定插入位置的间隙(如已有值 4、7,插入 5、6 会在 (4,7) 上加 gap lock)。其特性是:①它不会阻塞任何其他锁;②它自身仅会被 gap lock 阻塞——当插入位置的下一条记录上存在 gap 属性的锁时,插入意向锁与之冲突而进入等待(插入意向锁之间并不互斥)。通常仅在被阻塞时才能从 show engine innodb status 或死锁日志中观察到它,所以它常被忽略。理解它有助于解释“明明插入不同位置却互相等待”的死锁。
并发执行某 SELECT 时 MySQL 的 CPU %sys 飙高、perf 显示 _spin_lock 占用高,根因是什么?如何解决?
案例:MySQL 5.6.29,某 SELECT 单条秒级,并发执行却 CPU 长期飙高、超 1 小时。诊断:mpstat 显示 CPU 主要消耗在 %sys;perf top 热点在 _spin_lock;pt-pmp/pstack 追踪到 mem_heap_alloc/mem_heap_free 与 row_vers_build_for_consistent_read 反复调用。根因:该表更新频繁导致 undo history list 很长,快照读需遍历 undo 找版本、每次循环都申请/释放堆内存;Linux 默认 ptmalloc 在大量 malloc/free 下易产生锁热点。解决:将内存库替换为 tcmalloc,在 my.cnf 的 [mysqld_safe] 加 malloc-lib=tcmalloc 重启。上线后 72 小时未再飙高。
执行 FTWRL 后 show processlist 看到大量 wait global read lock,如何快速定位持有全局锁的会话?
FTWRL 全局锁在 processlist 看不到持锁会话(state 可能为空),但有快速定位法。方法1(5.7+):先开 metadata lock 探针,查 performance_schema.metadata_locks,OBJECT_TYPE=‘GLOBAL’ 且 LOCK_TYPE=‘SHARED’ 即全局锁,join threads 拿 processlist_id 直接 kill。方法2:events_statements_history 按 digest_text like ‘FLUSH TABLES%’ 找线程转 processlist_id。方法3:极端情况用 gdb 脚本遍历线程,打印持有 GRL_ACQUIRED_AND_BLOCKS_COMMIT 的 thread_id(5.6/5.7 字段名不同),须用 batch 模式。
用 metadata_locks 视图定位全局锁的具体 SQL 是什么?5.6/5.7 版本有何差异?
5.7+ 可用 metadata_locks 视图定位全局锁。先开探针:UPDATE performance_schema.setup_instruments SET ENABLED=‘YES’ WHERE NAME=‘wait/lock/metadata/sql/mdl’; 再 flush tables with read lock; 查 metadata_locks,OBJECT_TYPE=‘GLOBAL’、LOCK_TYPE=‘SHARED’、LOCK_STATUS=‘GRANTED’ 即持有全局读锁。关联会话:用 owner_thread_id join performance_schema.threads 得到 processlist_id 后 kill。注意探针须上锁前已启用;5.6 无该视图,须用 gdb 或 events_statements_history 替代。
综合来看,INSERT 语句在不同前提下的加锁规律是什么?
综合文中分析,INSERT 的加锁分两种情况。无唯一索引时:仅对插入的记录加 X Lock but not gap。有唯一索引时:①唯一性冲突检测时加 S Lock(带 gap 属性,锁住该记录以及它与上一条记录之间的间隙,即 S Next-Key Lock),即便最终报 Duplicate key 也不立刻释放;②若插入位置已有带 gap 属性的 S/X Lock,则插入意向锁(LOCK_INSERT_INTENTION)被阻塞进入等待;③新数据顺利插入后,最终对该记录加 X Lock but not gap。要点:INSERT 加锁比 select/delete 复杂,唯一冲突期间先加共享 Next-Key Lock 做约束校验,再在插入点加插入意向锁,最后升级为记录排他锁;二阶段锁下这些锁要等到事务结束才释放。
InnoDB 行锁包含哪三类?Record Lock、Gap Lock、Next-Key Lock 各自锁什么?
InnoDB 行锁由三类组成。Record Lock:索引记录锁,锁定具体索引记录;即使表无索引,InnoDB 也会生成隐藏聚簇索引(GEN_CLUST_INDEX)并据其加记录锁。Gap Lock:索引记录之间、第一条之前或最后一条之后的间隙锁,是性能与并发的权衡;对“已被完全填充、无间隙”的数据范围不需要 gap lock。Next-Key Lock:某索引记录上的 Record Lock 加上该记录之前间隙的 Gap Lock 的组合,例如索引含 10、11、13、20,可能的 next-key 区间为 (负无穷,10]、(10,11]、(11,13]、(13,20]、(20,正无穷);最后一段锁定最大值之后的“上确界”间隙。RR 默认用 Next-Key Lock 防幻读;RC 下仅在外键/唯一键检查才用 gap 锁。
RR 与 RC 隔离级别下,InnoDB 加锁行为的关键差异是什么?为什么 RC 下并发更友好?
关键差异在 gap/next-key 锁。RR(默认):为保证可重复读,除数据本身加 Record Lock,还对间隙加 Gap Lock(即 Next-Key Lock),事务开始即加锁、提交才释放,能防幻读但并发插入易被阻塞。RC:对锁定读、update、delete,InnoDB 仅锁索引记录,不匹配行的锁在评估 where 条件后立即释放,gap locking 仅用于外键约束和重复键(唯一)检查,不存在普通间隙锁,因此并发更友好、死锁概率更低。另外 RC 下 update 会做 semi-consistent read(返回最新已提交版本给 Server 层判断 where 是否匹配),可提前释放不冲突的行锁。实践建议:多数业务可设为 RC,仅在确需防幻读时保留 RR。
如何利用加锁明细日志分析死锁(如 LOCK_X|LOCK_NOT_GAP、is blocked 等含义)?
作者用改造版 Percona(innodb_gaopeng_row_lock_detail=ON)把每次加锁记录到 errlog,典型输出如:某表某索引上 row lock mode:LOCK_X|LOCK_NOT_GAP,以及阻塞行 “Trx(…) is blocked!!!!!”。分析步骤:①按 TRX_ID 对每个事务加锁顺序排序;②观察 lock mode:LOCK_X 表排他、LOCK_NOT_GAP 表仅为记录锁无间隙;③出现 “is blocked” 说明该事务等待某记录锁,对照另一事务已 GRANTED 的同类锁即可画等待图;④等待形成环即死锁。push_token 案例据此确认:s1 释放后 s2、s3 各得其一记录锁,却互相阻塞对方需要的另一把记录锁,环闭合。
文中 push_token 案例里,UPDATE 与 DELETE 并发是如何形成死锁的?
表 push_token(id PK, uk_token_appid UK),初始仅 1 行。s1 执行 UPDATE … WHERE token=‘token1’ AND app_id=‘1’,对唯一键和主键都加记录锁(LOCK_X|LOCK_NOT_GAP)。s2 执行 DELETE WHERE id IN(1),请求主键记录锁被 s1 阻塞。s3 同 s1 的 UPDATE 请求唯一键记录锁被 s1 阻塞。s1 commit 释放后,s2 拿主键锁、s3 拿唯一键锁;随后 s3 请求主键记录锁(被 s2 持有)阻塞,s2 请求唯一键记录锁(被 s3 持有)阻塞,双方互持对方所需,死锁形成。要点:UPDATE 在唯一索引和主键都加记录锁,DELETE 走主键也加记录锁,三者交错等待链构成死锁。
无索引的 UPDATE 在 RR 与 RC 隔离级别下分别如何加锁?为什么差异巨大?
当 UPDATE 的 where 未走索引,只能全表扫描,InnoDB 会对扫描到的每行加锁,但 RR 与 RC 差异巨大。RC:对所有命中的聚簇索引记录加 Record Lock(X but not gap),无间隙锁,所以未命中行的插入/更新可正常进行(场景13、14 中 a=8 的更新和插入均成功)。RR:同是对命中行加 Record Lock,但还会在所有聚簇索引相邻的间隙加 Next-Key Lock(含 Gap Lock)。因此即使 where 实际只影响少量行,也会锁住大量间隙,其他会话对该表任意位置的插入、甚至更新未被 where 命中的行都会被阻塞(场景11、12 中 a=8 的更新与插入均等待)。结论:无索引的写操作在 RR 下接近“全表间隙锁”,危害远大于 RC,应避免。
为什么 RC 下 UPDATE 能利用半一致性读提前释放行锁,而带 FOR UPDATE 的当前读却被阻塞?
案例两处对照。Session1 执行 select * from t where id>3 and id<6 for update:无索引全表扫描,但 Server 层用 where 过滤后只保留 id=4、5,仅这两行加锁(RC 下不匹配行锁评估后即释放)。Session2 执行 select * from t where id=7 for update:从 id=1 起逐条加 X 锁,读到 id=4 撞上 Session1 持有的 id=4 记录锁被阻塞直到锁等待超时——因 FOR UPDATE 当前读不会像 UPDATE 那样提前释放锁。Session3 执行 update … where id=7:走半一致性读优化,判断与 id=4/5 不冲突、提前释放锁而执行。差异:UPDATE 有 semi-consistent 优化,FOR UPDATE 当前读没有,故前者并发友好。
什么是半一致性读(semi-consistent read)?它在 RC 隔离级别下如何减少锁冲突?
半一致性读(semi-consistent read)是 RC 隔离级别下 InnoDB 对 UPDATE 的一种优化。UPDATE 做当前读时需对扫描到的记录加 X 锁,但 RC 下 InnoDB 会先把记录的最新已提交版本返回给 Server 层,由其判断该记录是否满足 update 的 where 条件:若不满足,则提前释放该行 InnoDB 锁(违背二阶段锁,但减少冲突、提升并发);若满足才保留锁。结合案例:Session1 用 select … for update(当前读)只锁了 id=4、5;Session3 的 update id=7 因与 id=4/5 行锁不冲突,借助半一致性读提前释放了 id=7 的行锁,故不被阻塞。注意:半一致性读仅作用于 UPDATE(及 DELETE)的当前读,普通 SELECT FOR UPDATE 的当前读不参与该优化。
MySQL 5.7 如何开启 metadata lock 的 instrument 探针?
MySQL 5.7 的 metadata_locks 表默认未开启对应 instrument,需先启用才能采集 MDL 信息:CALL sys.ps_setup_enable_instrument(‘wait/lock/metadata/sql/mdl%’); 该存储过程会开启 performance_schema 中 wait/lock/metadata/sql/mdl 相关 instruments。启用后,DDL、表级锁、全局锁(FTWRL)产生的 metadata lock 都会记录到 performance_schema.metadata_locks,可结合 threads、sys.processlist 关联出上锁会话与 SQL。注意:8.0+ 默认已支持无需此步;5.6 无该视图,须用 gdb 或 events_statements_history 替代。
如何便捷地查看实例上的 Metadata Lock 情况(含上锁会话、SQL、锁类型)?
MySQL Shell 插件库 mysqlshell-plugins 的 ext.check.get_locks() 可一键查看实例锁情况(需 8.0+,内部用 CTE),原理是把 sys.processlist 与 performance_schema.metadata_locks 关联,聚合出每个线程持有的锁摘要(含上锁会话、SQL、锁类型)。等价的手工 SQL 是用 owner_thread_id 把 metadata_locks 按线程 GROUP_CONCAT 聚合锁摘要,再 join sys.processlist 的 thd_id 输出。DDL/表锁/全局锁产生的 MDL 在 processlist 不可见,此法可快速定位“罪魁”。
show processlist 看不到加锁的 SQL 时,如何通过 performance_schema 追溯是哪个事务、哪条 SQL 持锁?
当 show processlist 只看到被阻塞的 update,而持锁会话 Sleep 且 trx_query=NULL 时,需借 performance_schema 追溯(前提 performance_schema=on)。步骤:①从 INNODB_TRX 找阻塞方 trx_mysql_thread_id,其 trx_query=NULL;②用 performance_schema.threads 按 PROCESSLIST_ID 定位 THREAD_ID(注意 processlist 的 id 与 p_s 的 THREAD_ID 不同);③查 events_statements_current 按 THREAD_ID 取 SQL_TEXT,即可看到持锁的那条 delete from action1 where id=3,进而决定 kill 或处理。
如何利用 performance_schema 的 history_long 表排查行锁等待超时(无需登录服务器)?它有什么局限?
不想登录服务器时,可用 P_S 的 events_transactions_history_long 与 events_statements_history_long。先开监控项:UPDATE setup_instruments SET ENABLED=‘YES’,TIMED=‘YES’ WHERE name=‘transaction’; 并开 %events_transactions% 与 %events_statements% 两类 consumer。排查:①查回滚事务的 THREAD_ID/EVENT_ID,JOIN 两表筛 STATE=‘ROLLED BACK’;②以该线程/事件 ID 为时间锚,筛同段 STATE=‘COMMITTED’ 且 TIMER_WAIT>5s 的可疑事务 SQL。缺点:相对时间不好换算、容量有限可能被刷、不主动记锁等待,靠时间范围反推。
如何用手动复现场景的行锁等待脚本 + general_log 定位行锁等待源头?
手动复现场景:一边模拟页面操作触发超时,一边在上锁超时前跑行锁等待脚本(join information_schema.innodb_lock_waits、innodb_trx 与 performance_schema.threads、events_statements_current),能看到阻塞方线程 ID 及最后一条 SQL(如某 SELECT),据此优化 SQL 或移出事务。若阻塞方为 Sleep(事务挂起),需用 general_log 还原完整事务:SET GLOBAL general_log=1 临时开启,按线程 ID 在 general_log 中过滤该事务全部 SQL 交开发定位代码中的交互操作,排查后务必 SET GLOBAL general_log=0 关闭(开销大且易暴涨)。
行锁等待超时的常见根因有哪些?如何区分“事务中慢 SQL”与“事务挂起”?
innodb_lock_wait_timeout(建议设 5s,官方默认 50s)超时即抛行锁等待错误。常见根因:①事务中嵌入非数据库交互(接口调用/文件上传)导致事务挂起;②事务含慢查询,DML 长时间不释放行锁;③单个事务含大量 SQL(如 for 循环),事务整体变慢;④级联更新(update A … where in (select B))既占 A 也占 B 行锁,执行久易引发 B 表等待;⑤磁盘故障致事务卡在内核 IO 无法提交。定位难点:MySQL 不主动记录等待信息,事后难复现。区分“事务中慢 SQL”与“事务挂起”看阻塞方 processlist_command:若为 Sleep(无 SQL 在跑)多半是代码嵌入交互操作夯住;若在跑 SELECT,则多为慢 SQL。