做后端开发的这十几年,MySQL 的“锁”大概是我见过引发线上事故最多的隐形杀手。单条 SQL 原本只需要跑几十毫秒,一旦卡在锁等待上,整个接口就超时;数据量也没多大,可死锁日志里全是信息量极大的行锁字段,不仔细比对根本看不出谁在等谁。更麻烦的是,锁机制和事务一旦绑在一起,隔离级别、索引命中情况、并发量、甚至 UPDATE 语句 WHERE 条件的写法,都能让同一套代码在不同环境里跑出完全不同的结果。
这篇文章我准备把这块彻底拆开。先讲 InnoDB 在四种隔离级别下的加锁差异,再掰扯行锁、间隙锁、Next-Key Lock、意向锁这些锁类型到底什么时候加、加了锁什么范围,然后直接用线上能跑的 SQL 带大家定位锁等待、读懂死锁日志,最后分享我这些年积累的事务与锁设计经验,把能提前避的坑都填上。适合刚开始学 MySQL 事务的开发者,也适合那些被锁等待问题折腾过无数个通宵的后端同学,想系统补齐 InnoDB 锁知识的 DBA 同样能从实战部分直接抄作业。
下面进入正文。内容不算少,但理解锁机制本来就需要一个全局视野。
1. 事务隔离级别与锁:先建立全局坐标
1.1 四个隔离级别到底隔离了什么
先绕不开的是事务隔离级别,因为锁的加锁范围、加锁时长、释放时机,全都和隔离级别强绑定。SQL 标准定义了四种隔离级别:READ UNCOMMITTED(读未提交)、READ COMMITTED(读已提交)、REPEATABLE READ(可重复读)和 SERIALIZABLE(串行化)。InnoDB 的默认隔离级别是可重复读,这一点和 Oracle、PostgreSQL 默认的读已提交完全不同,很多人换数据库踩坑,本质就是隔离级别的语义差异。
从锁的视角来看这四种级别:
- READ UNCOMMITTED:读的时候基本不加锁,事务可以看到其他事务尚未提交的数据,也就是脏读。这个级别在实际业务里几乎没人用,虽然它并发性能确实好一点,但代价是数据语义完全不可控。
- READ COMMITTED:每次读都取最新的已提交版本,解决了脏读,但同一事务内两次读同一行可能得到不同结果,也就是不可重复读。在这个级别下 InnoDB 会把大部分间隙锁退化为记录锁,锁的范围明显收窄。
- REPEATABLE READ:事务第一次读取时建立快照,事务内所有普通 SELECT 都基于这个快照,天然避免了不可重复读。但仅仅靠快照还挡不住幻读,所以 InnoDB 额外引入了间隙锁和 Next-Key Lock。
- SERIALIZABLE:把读也变成锁定读,普通 SELECT 隐式加共享锁,并发度最低,数据一致性最强。生产环境除非业务对一致性要求极其苛刻,否则我一般不建议直接用这个级别。
1.2 快照读和当前读的区别
很多新手会有个疑惑:InnoDB 不是有 MVCC 吗,怎么还会加锁?关键在于 MVCC 只保护快照读,也就是不带 FOR UPDATE、LOCK IN SHARE MODE 的普通 SELECT。快照读通过隐藏字段和 undo log 构造历史版本,读的时候不加任何锁。而 UPDATE、DELETE、INSERT,以及 SELECT ... FOR UPDATE 这类的当前读,必须读取行记录的最新版本并对记录加锁,因为写操作不能基于一个老版本去修改,否则会覆盖其他事务已提交的数据。
理解这两类读的区别特别重要,线上很多诡异的锁等待都源于应用代码里混合使用了快照读和当前读。比如一个事务先普通 SELECT 查出来一堆数据判断状态,然后再按条件 UPDATE,如果这个 UPDATE 的 WHERE 条件和前面 SELECT 的条件不完全一致,实际加锁的范围和开发者的预期就会偏差,锁冲突自然跟着来。我的判断标准很简单:业务里如果同一个事务内先查后改,尽量让 SELECT 和 UPDATE 的过滤条件完全一致,要么都走同一个索引范围,要么明确指定主键。
1.3 隔离级别与锁规则的对应关系
隔离级别对锁的影响,可以归纳成一张对照表:
| 隔离级别 | 普通 SELECT | 当前读加锁范围 | 幻读防护 | 典型使用场景 |
|---|---|---|---|---|
| READ UNCOMMITTED | 不加锁,读未提交版本 | 写加行锁 | 无 | 几乎不用 |
| READ COMMITTED | 每次读最新已提交快照 | 记录锁为主 | 无 | 对一致性要求不高的报表查询 |
| REPEATABLE READ | 事务首读建立快照 | 记录锁 + 间隙锁/Next-Key Lock | 由间隙锁保证 | 电商交易、账务系统等主流业务 |
| SERIALIZABLE | 普通读也加共享锁 | 记录锁 + 间隙锁 | 强一致 | 非常极端的强一致场景 |
需要注意的是,MySQL 5.7 及之前有个参数叫 innodb_locks_unsafe_for_binlog,早期文档描述它是关闭间隙锁用的,但这个参数已经废弃,千万别指望靠它调锁行为。真正想让间隙锁失效,就是把隔离级别调整为 READ COMMITTED,因为间隙锁本来就是为了在 REPEATABLE READ 下防止幻读才引入的,读已提交级别下当前读只加记录锁。
2. InnoDB 锁的类型:加锁范围与兼容性
2.1 共享锁与排他锁:最基础的行锁
InnoDB 的行锁分两种:共享锁(S Lock)和排他锁(X Lock)。共享锁之间兼容,也就是说多个事务可以同时持有同一行的共享锁;共享锁和排他锁互斥,排他锁之间更互斥。对应到 SQL,SELECT ... LOCK IN SHARE MODE 加共享锁,UPDATE、DELETE 以及 SELECT ... FOR UPDATE 加排他锁。
这里我总结一个简化的兼容性矩阵:
| 锁类型 | S 锁 | X 锁 |
|---|---|---|
| S 锁 | 兼容 | 冲突 |
| X 锁 | 冲突 | 冲突 |
这个矩阵看起来简单,但放到真实场景里很容易被忽略。比如一个报表任务用 LOCK IN SHARE MODE 读数据,另一个业务事务同时要更新同一行,表面上看两个操作没有写重叠,实际 S 锁和 X 锁是冲突的,报表那边如果事务迟迟不提交,更新的业务就会被一直阻塞。线上遇到过不少这类问题:加了共享锁的事务里还做了统计计算、接口调用,把锁握在手里十几秒,其他写入全部堵死。
2.2 间隙锁与 Next-Key Lock:防幻读的关键
间隙锁锁定的是索引记录之间的“空隙”,而不是具体某一行。它存在的意义就是阻止其他事务在某个间隙插入新记录,从而在 REPEATABLE READ 级别下把幻读堵死。Next-Key Lock 则是记录锁和间隙锁的组合:锁住记录本身,同时锁住记录前面的间隙,范围是左开右闭。
加锁规则有几种常见情况,我用等值查询和范围查询分开说明。
等值查询命中唯一索引记录时,Next-Key Lock 会退化为记录锁,只锁目标行。比如主键 id=5 的记录存在,事务 A 执行 UPDATE t SET ... WHERE id=5,事务 B 再去更新 id=3、id=7 都不会受影响。如果等值查询命中普通索引,那么就不仅锁目标记录,还会锁住该索引项附近的间隙,防止其他事务在间隙里插入新值。更极端的情况是等值查询没匹配到任何记录,比如查询 id=10,但表里只有 5、15 两条,数据库会锁住 5 到 15 之间的整个间隙,因为没有记录锁可加,只能用纯间隙锁挡插入。
范围查询的规则更复杂。使用 RR 级别执行 SELECT ... WHERE id > 3 FOR UPDATE,加锁范围通常是从第一个匹配记录开始,一直到索引最大值,所有扫描到的索引记录的 Next-Key Lock 都会加上。这意味着即使有些行不满足最终返回条件,只要处在扫描范围内,也会被锁住。所以范围查询的 WHERE 条件越宽,锁范围就越大,性能隐患越明显。
2.3 意向锁:行锁与表锁的协调器
意向锁是表级锁,但它本身不锁住任何行,只是表示“这个事务已经在某些行上加了锁”。InnoDB 在意向锁的设计上做了两个级别:意向共享锁(IS Lock)和意向排他锁(IX Lock)。执行 SELECT ... LOCK IN SHARE MODE 前,先给表加意向共享锁;执行 UPDATE、DELETE 或 SELECT ... FOR UPDATE 前,先给表加意向排他锁。
为什么需要意向锁?如果没有这个表级标记,当某个事务想对整张表加表级锁时,数据库必须扫描这张表的所有行,看有没有行级锁冲突,效率太低了。有了意向锁之后,表级锁只需判断意向锁是否兼容:排他表锁和任何意向锁都冲突,共享表锁和意向排他锁冲突。这个机制让表锁判断从 O(N) 变成了 O(1),代价非常小,却是 InnoDB 能同时支持行锁与表锁协调的关键设计。
2.4 插入意向锁与自增锁:容易被忽略的特例
插入意向锁不是要“意向”锁某一行,而是一种特殊的间隙锁:多个事务可以在同一个间隙里插入位置不冲突的多条记录,互不阻塞。比如事务 A 持有间隙锁挡住了 10 到 20 的插入,事务 B 想在 id=15 插入一行,它会先等待 A 提交;一旦 A 释放,B 就会获得插入意向锁完成插入。这个机制在批量插入场景下能显著提高并发,也是设计 INSERT 并发性能时必须理解的一环。
自增锁则是专门保护自增主键的锁。在 MySQL 5.7 中,参数 innodb_autoinc_lock_mode 决定自增锁的持有粒度:值为 0 时是传统表锁模式,每次插入都持锁到语句结束;值为 1 时是轻量级模式,普通插入语句只持有很短时间,但批量插入仍会持锁到语句结束;值为 2 时是交叉模式,允许批量插入的多个语句交错分配自增值,这个模式下主键可能不连续,但并发性能最好。生产环境我一般保持默认的 1,既保证自增值连续性又不会让锁粒度过于粗。
3. 实战排查:定位锁等待与死锁根源
3.1 一把梭定位锁等待的 SQL
先说在 MySQL 5.7 里最常用的定位方法。锁等待发生时,information_schema 下有三张核心表:INNODB_TRX(所有在跑的事务)、INNODB_LOCKS(锁信息)、INNODB_LOCK_WAITS(锁等待关系)。MySQL 8.0 之后 INNODB_LOCKS 和 INNODB_LOCK_WAITS 被 performance_schema 的 data_locks、data_lock_waits 替代,但 sys 库的 innodb_lock_waits 视图在 5.7 和 8.0 都能用,最简单的方式是直接查它。
SELECT * FROM sys.innodb_lock_waits\G这个视图会直接告诉你等待中的事务 id、线程 id、锁类型、锁所在的表、等待时长、以及是谁阻塞了它。如果记录为空但线上明显有慢请求,先看哪些事务处于 RUNNING 状态,可能是长事务持有锁但还没形成等待关系。
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.INNODB_TRX;trx_state 为 LOCK WAIT 的就是在等锁的事务,trx_started 很早的事务往往是锁的持有者。定位到持有者之后,用 performance_schema 里的 events_statements_current 或者直接看 innodb_trx.trx_query 确认它在执行什么。这里有个细节:trx_query 拿到的有时是 NULL,因为事务当前没有正在执行的语句,需要结合连接线程的状态信息,用 mysql 库的 threads 表配合查。
在 MySQL 8.0 里,8.0.5 之后重启了改名风波,data_locks 表的使用稍显绕,但 sys.innodb_lock_waits 依然是稳定的入口。如果你被迫直接查 performance_schema,核心字段是 ENGINE_TRANSACTION_ID、ENGINE_LOCK_ID、OBJECT_SCHEMA、OBJECT_NAME、INDEX_NAME、LOCK_TYPE、LOCK_MODE、LOCK_STATUS。
3.2 死锁日志怎么读
死锁发生时,InnoDB 会检测到循环等待,然后自动回滚代价较小的事务,并记录日志。默认情况下错误日志只记录最后一次死锁,所以线上最好先打开全量死锁记录:
SET GLOBAL innodb_print_all_deadlocks = ON;之后在 MySQL 错误日志里能看到每次死锁的完整记录。死锁日志的核心结构包括两个事务快照,每个快照里会列出事务持有的锁(HOLDS THE LOCK(S))和正在等待的锁(WAITING FOR THIS LOCK TO BE GRANTED)。最下方的 “WE ROLL BACK TRANSACTION” 行会明确告诉你哪个事务被回滚了。
读日志的重点按顺序看三个地方:一是看事务在等哪张表、哪个索引、哪把锁;二是看持有锁的事务当前执行到什么语句;三是看两个事务形成环的起点在哪里。比如日志里事务 1 持有 id=10 的 X 锁,等待 id=20 的 X 锁;事务 2 持有 id=20 的 X 锁,等待 id=10 的 X 锁,这就构成一个非常典型的循环等待。
3.3 一次真实的死锁复盘
之前一个订单系统的线上死锁,场景是两个并发请求同时处理订单 A 和订单 B 的库存。表面上看,两个请求都先扣库存,再更新订单状态,顺序一致,不应该死锁。但死锁日志显示:事务 1 持有订单 A 对应库存行的 X 锁,等待订单 B 对应库存行;事务 2 持有订单 B 的库存行,以及订单 A 的订单行,等待订单 A 的库存行。
复盘之后发现问题是代码里把“更新订单状态”和“扣库存”的物理顺序在不同分支里写反了,一个分支先扣库存后更新订单,另一个分支先更新订单后扣库存。再加上两个订单的分布顺序还不一致,直接形成环形等待。根源还是更新顺序不一致,只不过表现得更隐蔽。修法很简单:业务内涉及的多个资源统一按固定顺序加锁,比如都按下单表 id 升序锁行。
4. 死锁的典型场景与通用避坑策略
4.1 更新顺序不一致导致死锁
这是最高频的死锁类型。两个事务同时更新同一组记录,但加锁顺序相反。比如记录主键 id 分别为 1 和 2,事务 A 按 1、2 的顺序更新,事务 B 按 2、1 的顺序更新,事务 A 锁住 id=1,事务 B 锁住 id=2,接着两边都想拿对方手里的锁,死锁立刻出现。
解决思路很朴素:业务涉及多个资源时,强制统一加锁顺序。所有事务都先按主键升序锁 id=1 再锁 id=2,就不会形成环。实现方式可以在代码里对要去更新的主键集合排序,也可以用统一的 SQL 语句结构让优化器决定顺序。排序成本通常远低于处理一次死锁重试带来的复杂度。
4.2 范围更新造成 Next-Key 锁交叉死锁
范围更新产生的间隙锁交叉也是重灾区。比如事务 A 执行 UPDATE t SET status=1 WHERE id BETWEEN 10 AND 20,事务 B 执行 UPDATE t SET status=2 WHERE id BETWEEN 15 AND 25。两者在 15 到 20 之间有重叠区间,A 的 Next-Key 锁和 B 的 Next-Key 锁在边界处互相等待,就容易死锁。
这种场景很难通过调整业务顺序解决,更现实的策略是缩小事务范围。把一次大范围 UPDATE 拆成按主键小批量的分批更新,每批只处理少量记录,提交后释放锁再处理下一批。至于要达到“拆分后避免死锁”的最终目标,关键是每批之间的停顿很短暂,锁冲突概率会成数量级下降。
4.3 大事务与锁等待超时的连锁反应
锁等待本身不可怕,可怕的是大事务把锁握得太久。一个事务里如果包含远程接口调用、文件上传、大批量计算,整个事务从开启到提交可能持续数秒甚至数十秒。其他事务在这段时间内全部排队,一旦超过 innodb_lock_wait_timeout 的默认 50 秒,就会出现锁等待超时错误,应用层接着重试,又引发新一轮排队。
这个紧接着触发的问题,常常是错误日志里事务状态全是 RUNNING,而不是 LOCK WAIT,如果你只盯着锁等待视图,根本找不到源头。排查方向要转向“谁持锁最久”。我用一个相对简单的经验:任何事务内部都不要包含外部 IO 操作,尤其是同步调用第三方接口;事务只负责数据库读写,外部操作放到事务外完成。能把这步做到,一半以上的锁问题都不会出现。
4.4 四条务实的避坑经验
- 事务要短,锁范围要窄。更新语句的 WHERE 尽量用主键或唯一索引精确匹配,避免普通索引和范围扫描带来的大范围锁。
- 开启 innodb_print_all_deadlocks,别等死锁发生后再去开日志,日志是事后分析的第一手资料。
- 应用层必须处理死锁重试。MySQL 死锁检测会回滚代价较小的事务,但应用不会自动重跑,需要在代码里捕获死锁异常并重试。
- 控制并发量。热点数据的高并发写,比如秒杀库存、领券,要考虑用 Redis 预扣减或者把更新分散到多个槽位,让单行锁竞争降到最低。
5. 锁与事务的参数调优:把坑填在前头
5.1 关键参数设置与含义
innodb_lock_wait_timeout 默认 50 秒,意思是事务等待锁超过这个时间就会报超时错误。这个值不能盲目调大,调太大意味着排队的事务长时间占着连接资源,调太小则高并发下很容易误伤。一般线上我建议配置在 5 到 20 秒之间,具体看业务接口的超时时间,锁等待超时要低于接口总超时时间,否则用户那侧早就超时了,数据库还在傻等。
innodb_rollback_on_timeout 默认 OFF,锁等待超时只回滚当前语句,不回滚整个事务。如果想在超时后把整个事务回滚干净,可以打开这个参数,但要注意这会让事务内的前置更新全部丢失,业务需要自己处理好补偿逻辑。
innodb_deadlock_detect 在 8.0 引入,默认 ON。绝大多数场景应该保持开启,但如果并发极高并且大量操作单一行,死锁检测的 CPU 开销可能很高,可以关掉检测改为依赖锁等待超时兜底。这属于极端优化,不是常规手段,普通业务别轻易关。
autocommit 这个参数更基础,但也更容易被忽略。显式开启事务后忘了 COMMIT 是最常见的持锁事故。我见过有人在存储过程里开启事务,中途抛了异常没回滚也没提交,连接被连接池一直复用,锁就永远挂在事务上。建议所有事务操作都做成“开启-处理-提交/回滚”的固定模板,最后放在 finally 块中兜底。
5.2 索引设计对锁范围的影响
索引设计对锁范围的影响,很多时候比事务本身还要大。原因很简单:加锁是加在索引记录上的,InnoDB 只有通过索引条件锁行,才能精准锁定目标行。如果 WHERE 条件没有可用的索引,数据库只能全表扫描,那每一个扫描过的行,按 RECORD LOCK 语义其实都会被加上锁。
很多 DBA 口里的“无索引更新导致锁表”,本质上就是全表扫描加锁导致的。真实案例里,开发用 UPDATE t SET status=1 WHERE user_code='xxx',但 user_code 没建索引,扫描全表,所有行加 X 锁,其他任何写操作全部阻塞。等这个语句跑完,整个表的写入窗口也过了。解决方式永远是给过滤条件建合适的索引,让优化器选择的执行路径尽量精准。
设计二级索引时还要注意区分度。区分度低的索引,比如性别字段,很容易过滤出大量行,锁范围仍然很大。如果业务必须用这种条件更新,可以换一种思路:先查出主键集合,再按主键分批更新,既能减少锁范围,也方便控制事务长度。
5.3 监控与预警的日常三步走
锁问题最好的处理方式是提前发现,我每次排查线上数据库都会执行三件事:
第一,查长事务。事务开启时间超过 5 秒的,先确认是否有合理原因,再用 kill 语句清掉明显失控的事务。查询可以用信息采集表里的 trx_started 字段配合 NOW() 计算时长。
第二,查锁等待关系。用 sys.innodb_lock_waits 周期性检查,把等待中的会话、阻塞的会话、等待时长记录到监控系统里。锁等待长时间存在的次数一旦超过阈值,就该自动告警。
第三,查死锁日志。把 innodb_print_all_deadlocks 打开后,错误日志里死锁记录出现频率就是重要的稳定性指标。连续出现多条相似死锁日志时,要回到代码层检查逻辑,别只满足于杀会话。
这几步做扎实,大部分锁和事务问题都能在影响用户之前暴露出来。后面再碰到类似问题,你手里已经有一整套排查路径,不用每次从零摸索。
我个人在实际操作里还有一个挺管用的习惯:事务代码上线前,写一个简单的并发压测脚本,同时起十几个线程去跑透这条链路,专门观察有没有死锁和锁等待。这种测试在开发环境就能拦截掉一大部分问题,比上了生产再折腾强得多。