MySQL锁机制深度解析:从行锁、间隙锁到死锁排查
发布时间:2026/9/9 14:59:41来源:尧图网络
关于 MySQL 锁机制很多开发者是在“线上出事故”后才开始认真补课的。程序跑得好好的突然某个更新语句卡住不动两个事务互相等待日志里出现 deadlock一个热更新任务把整张表的写操作全堵住。这些问题背后基本都是锁的作用域、加锁顺序或隔离级别没有理解透。这篇文章会从 MySQL 锁的分类开始依次讲清全局锁、表级锁、行级锁的底层逻辑重点拆解 InnoDB 行锁的加锁规则、间隙锁与临键锁的触发条件、死锁的产生与排查流程最后给出一套可以直接用于线上的锁问题定位方法。需要提前说明的是本文所有 SQL 和结论基于 InnoDB 存储引擎MySQL 版本以 8.0 为主5.7 大部分结论同样适用。1. MySQL 锁机制核心速览项目说明存储引擎InnoDB 支持行锁与表锁MyISAM 仅支持表锁锁粒度全局锁 表级锁 行级锁粒度越小并发能力越强行锁实现基于索引实现锁的是索引记录而非数据本身行锁类型记录锁、间隙锁、临键锁、插入意向锁表级锁类型表锁、元数据锁、意向锁、自增锁默认隔离级别REPEATABLE READ可重复读锁等待默认超时innodb_lock_wait_timeout默认 50 秒死锁检测innodb_deadlock_detect默认开启适合场景高并发写、短事务、精确行更新不适合场景长事务、无索引条件更新、大范围批量更新这张表解决的核心问题是你首先得知道锁是有层级的不同的锁影响范围完全不同。很多线上事故的本质就是使用了表级锁范围的语句却以为它只锁了目标行。2. MySQL 锁的分类与演进逻辑2.1 为什么 MySQL 需要锁锁是数据库保证数据一致性的核心机制。当多个事务同时对同一份数据进行读写时如果不加锁就会出现脏读、不可重复读、幻读等问题。锁的粒度越细允许并发执行的事务越多系统吞吐量越高但锁管理和死锁检测的开销也越大。从 MySQL 的存储引擎发展看MyISAM 时代只有表锁任何写操作都会锁住整张表读操作也只能读快照。这也是 MyISAM 并发写入能力弱、容易锁表的根本原因。InnoDB 引入行级锁后才真正支持高并发事务处理。2.2 锁的三种口径MySQL 官方文档中锁绕不开以下三类全局锁锁定整个数据库实例。典型命令是FLUSH TABLES WITH READ LOCK在备份场景中用于保证一致性快照。全局锁一旦加上所有涉及写操作的事务都会被阻塞。表级锁锁住整张表包括表锁、元数据锁、意向锁、自增锁。表锁的获取开销小但并发粒度粗。行级锁InnoDB 独有锁住索引记录支持高并发。行锁的加锁过程复杂但并发能力最强。一个容易忽略的细节是行的锁是基于索引加的并且锁兼容关系是官方定义好的。比如共享锁S和互斥锁X之间S 与 S 兼容S 与 X 互斥X 与 X 互斥。表级读锁与写锁同理写锁优先级更高。2.3 当前版本需要考虑的锁变化MySQL 8.0 在锁机制上有几处重要调整元数据锁MDL在 8.0 中完全由 Server 层接管任何 DDL 操作都需要获取 MDL 写锁。自增锁在 8.0 中默认使用 innodb_autoinc_lock_mode2不再持有表级自增锁从而降低对并发插入的影响。8.0 取消查询缓存也就不存在缓存锁等待的问题。8.0 对performance_schema.data_locks和data_lock_waits表结构做了调整排查锁等待的方式与 5.7 稍有不同。如果是 8.0 用户查询锁信息时优先使用performance_schema.data_locks不要依赖 5.7 时代的information_schema.INNODB_LOCKS。3. 环境准备与测试表设计本文涉及的所有演示建议在下述环境中验证# 查看 MySQL 版本 mysql --version # 查看当前隔离级别 SELECT transaction_isolation;线上 MySQL 普遍存在多个隔离级别但 InnoDB 默认是可重复读。为了更好地演示行锁建议单独创建一张测试表不影响业务库。CREATE DATABASE IF NOT EXISTS lock_demo DEFAULT CHARSET utf8mb4; USE lock_demo; CREATE TABLE t_user ( id int NOT NULL AUTO_INCREMENT, user_no varchar(32) NOT NULL, name varchar(64) NOT NULL, age int DEFAULT NULL, status int DEFAULT 0, PRIMARY KEY (id), KEY idx_user_no (user_no) ) ENGINEInnoDB;为什么要有user_no的二级索引因为 InnoDB 的行锁是基于索引的如果只有主键就无法演示二级索引加锁的行为。测试数据如下INSERT INTO t_user (id, user_no, name, age, status) VALUES (1, U1001, 张三, 18, 0), (2, U1002, 李四, 25, 0), (5, U1005, 王五, 30, 0), (8, U1008, 赵六, 40, 0), (11, U1011, 钱七, 55, 0);开两个终端会话 A、B分别开启事务后续的加锁实验都在两个会话中进行。-- 会话 A START TRANSACTION; SELECT * FROM t_user WHERE id 1 FOR UPDATE; -- 会话 B START TRANSACTION; SELECT * FROM t_user WHERE id 2 FOR UPDATE;如果 A 锁住了 id1B 更新 id2 不受影响说明行锁生效。如果 B 更新 id1则进入锁等待直到 A 提交或回滚或等待超时。4. InnoDB 行锁的加锁规则详解4.1 记录锁记录锁是最基础的行锁锁住的是索引记录本身。SELECT 语句后面加FOR UPDATE或LOCK IN SHARE MODE或者 UPDATE、DELETE 语句执行时InnoDB 都会对命中的索引记录加锁。-- 会话 A START TRANSACTION; SELECT * FROM t_user WHERE id 1 FOR UPDATE; -- 会话 B会阻塞 UPDATE t_user SET age 20 WHERE id 1;如果 id 是主键这条 SQL 的加锁过程只有一步在主键索引上定位到 id1 的记录加 X 锁。执行计划中如果命中索引不存在全表扫描导致的额外加锁。观察锁等待的方式SELECT * FROM performance_schema.data_locks\G SELECT * FROM performance_schema.data_lock_waits\G当 B 会话 UPDATE 卡住后在第三个会话执行上述查询可以看到 B 会话正在等待 A 会话持有的锁。4.2 间隙锁间隙锁锁的是“记录与记录之间”的间隙它的目的是防止其他事务在这个间隙内插入新的记录从而避免幻读。间隙锁只在 REPEATABLE READ 隔离级别下生效在 READ COMMITTED 隔离级别下间隙锁会被禁用。这也是为什么 READ COMMITTED 下并发插入能力更强但可能出现幻读。演示-- 会话 A START TRANSACTION; SELECT * FROM t_user WHERE age BETWEEN 20 AND 50 FOR UPDATE; -- 会话 B以下插入都会被阻塞 INSERT INTO t_user (id, user_no, name, age, status) VALUES (3, U1003, 测试, 22, 0);为什么 id3 会被挡住因为age BETWEEN 20 AND 50在扫描二级索引 age 时会锁定 20 和 50 之间的所有间隙防止其他事务插入符合条件的新记录。这里没有 age 索引实际会退化为全表扫描锁但逻辑同理。实际业务中最典型的间隙锁问题是“插入卡死”和“插入间歇性超时”。排查时先看data_lock_waits一旦发现等待记录类型为GAP基本可以确定是间隙锁。4.3 临键锁临键锁是记录锁和间隙锁的组合。InnoDB 在 REPEATABLE READ 下默认对索引扫描使用的就是临键锁它锁住一个左开右闭区间。例如表 t_user 现有主键 id1、2、5、8、11。那么 InnoDB 的临键锁区间为(-∞, 1] (1, 2] (2, 5] (5, 8] (8, 11] (11, ∞)这条规则包含两个关键点当查询条件命中某条记录时它会锁住该记录本身同时锁住前面的间隙。当查询条件没有命中任何记录时它会锁住整个扫描范围内的间隙。比如执行START TRANSACTION; SELECT * FROM t_user WHERE id 4 FOR UPDATE;id4 的记录不存在但 InnoDB 会对区间 (2, 5] 加上临键锁因此其他事务无法插入 id3 和 id4 的记录。这个行为经常让开发者困惑“明明查不到记录为什么别人也插不进来”4.4 插入意向锁插入意向锁是间隙锁与插入操作碰撞时产生的一种锁。它不是真正的锁而是一种“插入意图”的标记多个事务可以在不同的间隙上同时持有插入意向锁只要它们插入的位置不冲突。演示场景会话 A 锁住id8范围锁覆盖(5,8]。会话 B 想插入id6需要获取插入意向锁。由于id6落在(5,8]区间内插入意向锁与间隙锁冲突B 必须等待。-- 会话 A START TRANSACTION; SELECT * FROM t_user WHERE id 8 FOR UPDATE; -- 会话 B会阻塞 INSERT INTO t_user (id, user_no, name, age, status) VALUES (6, U1006, 测试, 28, 0);插入意向锁的存在让 InnoDB 能在高并发插入时尽量并行同时仍然保证间隙锁的防幻读语义。4.5 加锁规则总结基于 InnoDB 当前版本加锁规则可以归纳为以下几条查询条件使用主键或唯一索引等值查询且命中记录只加记录锁不加间隙锁。查询条件使用普通二级索引等值查询会对二级索引加临键锁同时回表对主键索引加记录锁。查询条件使用范围查询会对扫描到的所有索引区间加临键锁而在不满足等值条件的部分退化为间隙锁。查询条件没有命中索引InnoDB 只能全表扫描逐条加锁相当于锁表写入并发直接归零。唯一索引等值查询未命中记录时会退化为临键锁锁住目标值所在区间。这五条规则基本覆盖了日常 SQL 的加锁范围。理解它们才能解释为什么有些语句会“误伤”相邻记录。5. 不同隔离级别下的锁表现5.1 READ UNCOMMITTED读不加锁写操作正常加锁存在脏读。这个级别基本只有在纯大数据统计场景才会使用不推荐作为业务库的隔离级别。5.2 READ COMMITTED读使用快照读写使用当前读。这个级别下 InnoDB 会关闭间隙锁只保留记录锁。因此并发插入能力更强但不可重复读问题明显。从锁角度理解-- 会话 A START TRANSACTION; SELECT * FROM t_user WHERE id 1 LOCK IN SHARE MODE; -- 会话 B在 READ COMMITTED 下 UPDATE t_user SET age 19 WHERE id 1;B 的 UPDATE 需要获取 X 锁但是 A 持有 S 锁因此 B 仍会阻塞。这说明 READ COMMITTED 只是关闭间隙锁不是没有锁。5.3 REPEATABLE READInnoDB 默认级别也是间隙锁和临键锁的“主战场”。这个级别下事务内多次读取同一范围的数据结果一致通过快照读实现同时通过间隙锁防止幻读。这也是 MySQL 5.7 和 8.0 默认情况下容易出现锁等待和死锁的原因。间隙锁对并发插入的限制在批量插入、批量更新、报表统计等场景中会非常明显。5.4 SERIALIZABLE所有普通 SELECT 都会被隐式转换为LOCK IN SHARE MODE相当于把读也变成当前读。并发能力最低基本只能用于对一致性要求极高、写并发极少的环境。对多数业务系统来说REPEATABLE READ 是默认选择但如果在确认为核心 OLTP 场景、并且代码已经控制好一致性问题的情况下主动切换到 READ COMMITTED 能明显减少间隙锁冲突。6. 死锁的产生与排查6.1 死锁为什么会发生死锁的本质是两个或多个事务互相持有对方需要的资源并且都不主动释放。InnoDB 默认开启死锁检测一旦检测到会立即回滚其中一个事务释放它持有的锁让另一个事务继续执行。最常见的死锁场景是“两条 SQL 加锁顺序不一致”-- 事务 1 START TRANSACTION; UPDATE t_user SET age 20 WHERE id 1; UPDATE t_user SET age 25 WHERE id 2; COMMIT; -- 事务 2 START TRANSACTION; UPDATE t_user SET age 30 WHERE id 2; UPDATE t_user SET age 18 WHERE id 1; COMMIT;两者并发执行事务 1 锁住 id1 后准备锁 id2事务 2 锁住 id2 后准备锁 id1互相等待死锁产生。6.2 死锁日志怎么看MySQL 死锁发生后默认记录到错误日志。查看方式SHOW ENGINE INNODB STATUS\G重点关注 LATEST DETECTED DEADLOCK 部分。日志中会明确给出两个事务各自的 SQL、持有哪些锁、等待哪些锁。定位死锁关键信息事务 1 持有的锁LOCK HELD事务 1 等待的锁LOCK WAIT事务 2 持有的锁事务 2 等待的锁只要出现循环等待死锁就是必然结果。解决办法的核心是让事务获取锁的顺序全局一致。6.3 死锁的规避策略死锁无法完全禁止但可以通过规范大幅降低发生率多个事务更新多张表或有多条 SQL 时统一按主键/业务编号排序后执行。尽量缩短事务时长业务操作不要放在一个长事务里。对热点行更新尽量隔离到独立事务避免多行更新的交错等待。等值更新命中唯一索引避免范围条件和全表扫描带来的额外锁区间。高并发插入场景考虑将随机主键改为趋势递增主键减少间隙锁碰撞。死锁发生后应用层必须做重试逻辑。比较通用的方案是捕获 MySQL 死锁异常码1213随即 sleep 100ms 到 300ms 重试一次。7. 表级锁与元数据锁7.1 表锁与自增锁表锁由 server 层控制InnoDB 场景下真正用到表锁的地方不多典型场景是LOCK TABLES t_user WRITE显式锁表。这会阻塞所有读写操作建议只在数据迁移、表结构变更时使用。自增锁在 8.0 默认模式下插入时不再锁表使用轻量级的互斥量保证 auto_increment 有序性。但需要注意INSERT ... SELECT、LOAD DATA等大批量插入场景自增锁仍会升级为表级锁。7.2 元数据锁所有 DML 操作都会自动获取元数据锁。元数据锁分为 SHARED_READ 和 SHARED_WRITEDDL 需要获取 EXCLUSIVE 锁。这就是为什么一个大事务在跑期间ALTER TABLE会卡住同样ALTER TABLE在等待期间其他所有查询也会被阻塞。常见问题形态Waiting for table metadata lock出现这个提示基本是某个长事务、慢查询或未提交事务持有了元数据锁DDL 排队等待进而在后续阻塞更多查询。排查步骤是查information_schema.PROCESSLIST找出Sleep状态时间很长的连接确认后 kill 对应线程。8. 锁等待与行锁冲突排查8.1 锁等待超时设置锁等待超时由innodb_lock_wait_timeout控制默认 50 秒。这个配置的单位是秒且是动态参数。如果希望快速报错避免堆积太多阻塞会话建议线上设置为 5~10 秒。SET GLOBAL innodb_lock_wait_timeout 10;需要注意的是锁等待超时只作用于 InnoDB 行锁和表锁不包含元数据锁。元数据锁没有超时机制。8.2 定位锁冲突的 SQL推荐使用以下组合查询-- 查看所有当前事务 SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx; -- 查看锁等待关系 SELECT r.trx_mysql_thread_id AS waiting_thread, r.trx_query AS waiting_query, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx r ON w.requesting_trx_id r.trx_id JOIN information_schema.innodb_trx b ON w.blocking_trx_id b.trx_id;这个查询会直接告诉你谁在等锁、谁在持锁、每个事务的起始时间和具体 SQL。拿到阻塞线程 ID 后可以进一步确定是否 kill-- 查看线程基本信息 SELECT * FROM performance_schema.threads WHERE PROCESSLIST_ID 阻塞线程ID; -- 确认后终止阻塞线程 KILL 阻塞线程ID;8.3 使用 performance_schema 分析历史锁冲突8.0 中查看最近锁等待和死锁的信息可以使用SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM performance_schema.data_locks;区别于innodb_trx这类事务视图data_locks会展示锁的具体类型和模式包括RECORD、GAP、AUTO_INC等。定位间隙锁冲突时LOCK_TYPERECORD且LOCK_MODE中含GAP的行就是间隙锁。9. MySQL 锁在分布式场景的边界关于分布式锁一个常见误区是把数据库的唯一索引当作分布式锁主方案。MySQL 唯一索引的确可以实现简单互斥插入成功即为获取锁删除即可释放。但它在高并发下有几个明显问题数据库连接池耗尽风险每个分布式锁请求都占用一个连接。锁无自动过期机制事务异常时锁记录残留需要额外守护任务清理。数据库压力增长后成为系统瓶颈扩展成本远高于 Redis 或 etcd。恰当的边界划分是纯内部系统、低并发控制场景可以直接用 Redis 分布式锁对可靠性要求更高的场景用 etcd 的租约机制MySQL 唯一索引更适合作为“兜底防重”而非常规分布式锁。10. 常见问题与排查方法问题现象可能原因排查方式解决方案更新某行一直卡住该行已被其他事务持有 X 锁查 innodb_trx 和 innodb_lock_waits定位持锁线程kill 或等其提交Waiting for table metadata lockDDL 被长事务阻塞查看 PROCESSLIST 中的 Sleep 长连接终止长事务再执行 DDL批量插入偶发超时间隙锁与插入意向锁冲突data_locks 查看 GAP 锁降低隔离级别为 READ COMMITTED 或不使用批量范围插入死锁报错 ERROR 1213多事务加锁顺序不一致SHOW ENGINE INNODB STATUS统一加锁顺序增加重试机制无索引条件更新行锁退化为表锁EXPLAIN 看执行计划是否走全表扫描加索引或改写 SQLALTER TABLE 卡住元数据锁等待排空查看 PROCESSLIST 状态等待或 kill 阻塞线程自增锁导致插入串行化大批量插入触发自增锁升级SHOW ENGINE INNODB STATUS拆分插入批次保持单条插入量锁等待时间太长innodb_lock_wait_timeout 过大SHOW VARIABLES LIKE %lock_wait%调整超时时间到合理范围11. 锁机制最佳实践以下来自实际运维和开发中的经验总结建议直接纳入团队的开发规范索引是行锁的根基。任何 UPDATE、DELETE 以及FOR UPDATE查询都要确认执行计划走的是索引。没有索引全表扫描行锁自动退化为表锁这是“为什么更新一行却锁了全表”的根本原因。事务保持短小。锁的持有时间由事务决定而不是单条 SQL。事务越长锁持有越久阻塞面越大。一个事务内尽量只放必要的写操作外部服务调用不要包进数据库事务中。统一加锁顺序。涉及多行、多表更新时先对主键或唯一键排序再执行更新。如果不排序并发场景下必现死锁。监控锁等待。建立监控任务定时查询innodb_trx和innodb_lock_waits超过阈值告警。提前发现锁等待堆积能有效避免“锁表雪崩”。避免大范围更新。批量更新尽量切片成小批次执行每次限制几百行避免一次占住大量临键锁区间。在线 DDL 要低调。高流量表做 DDL 时优先使用 MySQL 8.0 的ALGORITHMINSTANT或ONLINE DDL参数并且避开业务高峰时段执行。应用层必须处理死锁。死锁在 InnoDB 里是正常现象不是 bug。应用层捕获ER_LOCK_DEADLOCK (1213)后重试是必须写入代码的兜底逻辑。12. 总结与下一步MySQL 锁机制并不复杂关键在于把加锁规则、隔离级别、索引使用三者结合起来理解。记录锁解决行冲突间隙锁解决幻读插入意向锁协调并发插入元数据锁维护表结构的一致性死锁检测和锁等待超时是数据库的自我保护机制。建议优先验证以下三个方向通过 EXPLAIN 分析现有 SQL 是否走索引排除行锁退化为表锁的风险。在三会话环境下模拟间隙锁和死锁场景观察data_lock_waits的锁等待关系。检查所有写事务的长度确保事务内没有外部调用、批量大事务和跨行乱序更新。这套方法学完常见的锁表和死锁问题基本都能在 5 分钟内定位到根因再配合应用层重试和监控告警锁机制就不会再是线上事故的定时炸弹。
网站建设高端定制企业官网