关于 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_mode=2,不再持有表级自增锁,从而降低对并发插入的影响。
- 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`) ) ENGINE=InnoDB;为什么要有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 锁住了 id=1,B 更新 id=2 不受影响,说明行锁生效。如果 B 更新 id=1,则进入锁等待,直到 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 的加锁过程只有一步:在主键索引上定位到 id=1 的记录,加 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);为什么 id=3 会被挡住?因为age BETWEEN 20 AND 50在扫描二级索引 age 时,会锁定 20 和 50 之间的所有间隙,防止其他事务插入符合条件的新记录。这里没有 age 索引,实际会退化为全表扫描锁,但逻辑同理。
实际业务中,最典型的间隙锁问题是“插入卡死”和“插入间歇性超时”。排查时先看data_lock_waits,一旦发现等待记录类型为GAP,基本可以确定是间隙锁。
4.3 临键锁
临键锁是记录锁和间隙锁的组合。InnoDB 在 REPEATABLE READ 下,默认对索引扫描使用的就是临键锁,它锁住一个左开右闭区间。
例如表 t_user 现有主键 id:1、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;id=4 的记录不存在,但 InnoDB 会对区间 (2, 5] 加上临键锁,因此其他事务无法插入 id=3 和 id=4 的记录。这个行为经常让开发者困惑:“明明查不到记录,为什么别人也插不进来?”
4.4 插入意向锁
插入意向锁是间隙锁与插入操作碰撞时产生的一种锁。它不是真正的锁,而是一种“插入意图”的标记,多个事务可以在不同的间隙上同时持有插入意向锁,只要它们插入的位置不冲突。
演示场景:
- 会话 A 锁住
id=8,范围锁覆盖(5,8]。 - 会话 B 想插入
id=6,需要获取插入意向锁。 - 由于
id=6落在(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 READ
InnoDB 默认级别,也是间隙锁和临键锁的“主战场”。这个级别下,事务内多次读取同一范围的数据结果一致,通过快照读实现,同时通过间隙锁防止幻读。
这也是 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 锁住 id=1 后准备锁 id=2;事务 2 锁住 id=2 后准备锁 id=1,互相等待,死锁产生。
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_WRITE,DDL 需要获取 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_TYPE=RECORD且LOCK_MODE中含GAP的行就是间隙锁。
9. MySQL 锁在分布式场景的边界
关于分布式锁,一个常见误区是把数据库的唯一索引当作分布式锁主方案。MySQL 唯一索引的确可以实现简单互斥:插入成功即为获取锁,删除即可释放。但它在高并发下有几个明显问题:
- 数据库连接池耗尽风险,每个分布式锁请求都占用一个连接。
- 锁无自动过期机制,事务异常时锁记录残留,需要额外守护任务清理。
- 数据库压力增长后成为系统瓶颈,扩展成本远高于 Redis 或 etcd。
恰当的边界划分是:纯内部系统、低并发控制场景,可以直接用 Redis 分布式锁;对可靠性要求更高的场景用 etcd 的租约机制;MySQL 唯一索引更适合作为“兜底防重”而非常规分布式锁。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 更新某行一直卡住 | 该行已被其他事务持有 X 锁 | 查 innodb_trx 和 innodb_lock_waits | 定位持锁线程,kill 或等其提交 |
Waiting for table metadata lock | DDL 被长事务阻塞 | 查看 PROCESSLIST 中的 Sleep 长连接 | 终止长事务,再执行 DDL |
| 批量插入偶发超时 | 间隙锁与插入意向锁冲突 | data_locks 查看 GAP 锁 | 降低隔离级别为 READ COMMITTED 或不使用批量范围插入 |
| 死锁报错 ERROR 1213 | 多事务加锁顺序不一致 | SHOW ENGINE INNODB STATUS | 统一加锁顺序,增加重试机制 |
| 无索引条件更新 | 行锁退化为表锁 | EXPLAIN 看执行计划是否走全表扫描 | 加索引,或改写 SQL |
| ALTER 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 的ALGORITHM=INSTANT或ONLINE DDL参数,并且避开业务高峰时段执行。
应用层必须处理死锁。死锁在 InnoDB 里是正常现象,不是 bug。应用层捕获ER_LOCK_DEADLOCK (1213)后重试,是必须写入代码的兜底逻辑。
12. 总结与下一步
MySQL 锁机制并不复杂,关键在于把加锁规则、隔离级别、索引使用三者结合起来理解。记录锁解决行冲突,间隙锁解决幻读,插入意向锁协调并发插入,元数据锁维护表结构的一致性,死锁检测和锁等待超时是数据库的自我保护机制。
建议优先验证以下三个方向:
- 通过 EXPLAIN 分析现有 SQL 是否走索引,排除行锁退化为表锁的风险。
- 在三会话环境下模拟间隙锁和死锁场景,观察
data_lock_waits的锁等待关系。 - 检查所有写事务的长度,确保事务内没有外部调用、批量大事务和跨行乱序更新。
这套方法学完,常见的锁表和死锁问题基本都能在 5 分钟内定位到根因,再配合应用层重试和监控告警,锁机制就不会再是线上事故的定时炸弹。