这篇文章的选题我很早就想写了。起因是前阵子帮一个社区的朋友排查线上死锁,他跑了一个定时任务,每天凌晨批量更新订单状态,结果第二天一上班就收到一堆锁等待超时的告警。日志一拉,两条 UPDATE 语句堵在同一条索引记录上,偏偏两边还都加了别的事务,谁也等不到谁。我翻了翻他当时的建表语句和事务代码,发现问题根本不在于 SQL 写得不好,而是在于他完全没有意识到 MySQL 的锁机制在背后是怎么运作的。其实“锁”这个词在 MySQL 里听着抽象,但它几乎决定了你每一个并发事务的成败。很多人知道有“行锁”“表锁”这些名词,可真到了排查死锁的时候,却连SHOW ENGINE INNODB STATUS里的信息都看不懂。这篇就当是给所有被锁困扰过的开发者、运维和 DBA 一份从全局到行级、从原理到实战的完整梳理。你会看到:全局锁到底锁住了什么,表锁和 MDL 锁如何悄悄卡住你的查询,InnoDB 的行锁又是怎样在索引记录上跳舞,以及遇到死锁时,最快能落地的排查和改写手段。1. 从一条慢 SQL 引出的锁问题1.1 一次线上事故的复盘想理解锁机制,最好先回到事故现场。上面提到的那个定时任务,伪代码如下:BEGIN; UPDATE order_info SET status = 'PAID' WHERE order_no = 'A10001'; UPDATE order_info SET status = 'REFUNDING' WHERE order_no = 'B10002'; COMMIT;另一个事务则倒过来操作:BEGIN; UPDATE order_info SET status = 'REFUNDING' WHERE order_no = 'B10002'; UPDATE order_info SET status = 'PAID' WHERE order_no = 'A10001'; COMMIT;两个事务分别持有对方下一步要更新的行锁,于是互相等待,直到innodb_lock_wait_timeout默认的 50 秒耗尽。MySQL 只能把其中一个事务回滚掉,应用侧就会看到“Lock wait timeout exceeded; try restarting transaction”的报错。问题看起来是程序写得太“对称”,但我当时让他先跑了一遍SHOW ENGINE INNODB STATUS,发现一个更隐蔽的细节:虽然两条记录在逻辑上不同,但在 B+ 树里它们落在了同一个索引页的相邻位置。事务 A 第一条 UPDATE 除了锁住order_no = 'A10001'这条记录,还在记录之前和之后加上了间隙锁,这就直接把事务 B 要更新的B10002给堵在了更小范围的锁等待里。等你真正弄清楚这个因果链,就会明白:死锁不是运气差,而是加锁顺序、索引选择、隔离级别三者共同作用的结果。1.2 为什么每个 MySQL DBA 都要懂锁很多开发者觉得锁只是数据库内部的事,跟写业务没关系。但只要你写过一条带WHERE条件的 UPDATE 或 DELETE,你就已经在和锁打交道了。比如:DELETE FROM user_login_log WHERE create_time < '2025-01-01';这条 SQL 看起来只是想清理半年以上的日志,可如果没有合适的索引,MySQL 会扫描主键做全表判断,每扫到一行就加一次行锁。扫描过程中,别的会话想插入新的登录日志,如果被间隙锁挡住,就会出现“明明没碰同一条记录,却互相卡住”的诡异现象。理解锁机制的收益有三个层面:一是能快速读懂performance_schema.data_lock_waits这类视图,定位到底谁在等谁;二是能在设计表结构和 SQL 的时候主动避开全表更新、大范围扫描这些容易放大的锁范围;三是遇到死锁时,不靠重启数据库、不靠瞎改超时参数来碰运气,而是直接分析加锁顺序,用最小的代码改动解决问题。2. 锁的类型全景图:全局锁、表锁、行锁各自管什么2.1 全局锁:Flush tables with read lock 到底锁了什么全局锁是整个实例维度的锁,执行:FLUSH TABLES WITH READ LOCK;之后,整个实例进入只读状态。所有数据表的 DML、DDL 都会被阻塞,直到执行UNLOCK TABLES释放。这个操作最经典的用途是作为全库逻辑备份的锚点。比如用mysqldump做一致性快照时,需要保证备份期间没有任何写入,否则备份出来的数据可能是“半个事务”的状态,恢复回去一定会出问题。但全局锁的代价太粗暴了。线上业务一执行,等于所有写入全部停摆。于是 InnoDB 提供了更优雅的替代方案:利用 MVCC(多版本并发控制),在备份时开启一个可重复读的隔离级别事务,mysqldump --single-transaction就是靠这个原理,在备份期间不阻塞任何业务写入,依旧能看到一致的数据快照。我在实际运维中只会在两种场景下考虑全局锁:一是主从切换时为了保证主库和备库的数据点位完全一致,短暂加一下;二是某些历史遗留的表还是 MyISAM 引擎,--single-transaction对它无效,只能靠 FTWRL 保证一致性。如果你遇到的是 InnoDB 表,能用--single-transaction就别碰全局锁。2.2 表级锁:表锁、MDL 锁、意向锁的协同表锁是最容易被忽略的“隐藏杀手”。显式的表锁语句是:LOCK TABLES order_info WRITE;这个锁会把整张表锁住,其他会话不可以读也不可以写(WRITE 锁下,读也会被阻塞)。虽然 InnoDB 支持行锁,但它同样提供表级锁,只是默认不主动使用。真正的坑其实在 MDL 锁(元数据锁)。MDL 锁不需要你手动加,它是 MySQL 在 5.5 版本引入的,目的是保护表结构定义。当你执行一条 SELECT 查询时,会先拿一个 MDL 读锁;执行 ALTER TABLE 时,则要拿 MDL 写锁。MDL 读锁之间不互斥,写锁和读锁互斥。这就引出一个非常常见的线上事故:一个慢查询长期占着 MDL 读锁,后面一个 DDL 在排队等写锁,而 DDL 后面所有的查询、更新全部排队等待。最终表现为“数据库莫名不可用”,SHOW PROCESSLIST里能看到大量 “Waiting for table metadata lock” 的会话。意向锁则是 InnoDB 为了让表锁和行锁能共存而设计的“信号牌”。你执行SELECT ... FOR UPDATE加行级排他锁时,InnoDB 会先在表上加一个“意向排他锁”。另一个会话想对全表加表锁时,一看存在意向排他锁,就知道“有行数据被锁住了”,立即返回冲突。这样查表时不需要逐个扫描行记录,效率高很多。2.3 行锁:Record Lock、Gap Lock、Next-Key Lock 的三角关系行锁才是 InnoDB 的看家本领。按锁定的范围,它可以细分成三种:Record Lock(记录锁):只锁索引树上的某一条记录。比如主键id = 10,加锁后其他事务不能修改或删除这一行。Gap Lock(间隙锁):锁住记录之间的“空隙”。比如索引里有 10 和 20 两条记录,间隙锁会把(10, 20)这个区间锁住,作用是防止其他事务在这个区间插入新记录。Next-Key Lock(临键锁):记录锁 + 间隙锁的组合,锁住的范围是(10, 20],即左开右闭区间。它既保护了记录本身,又阻止了在这个区间插入新数据。为什么要分这么多类型?因为默认的隔离级别是REPEATABLE READ(可重复读),MySQL 必须防止“幻读”——也就是同一事务里,两次相同的查询返回了不同的行集合。如果不锁间隙,事务 A 第一次查id > 10没有记录,事务 B 插入一条id = 15并提交,事务 A 再查一次,就会多出一行,这就是幻读。Next-Key Lock 就是 InnoDB 在 RR 隔离级别下用来狙击幻读的武器。理解了这三种锁,你在看SHOW ENGINE INNODB STATUS的死锁信息时就能脑补出一幅 B+ 树索引页的图:哪个事务持有什么锁,等待的又是哪个锁,一目了然。这也是我把行锁放到一个单独章节慢慢讲的原因。3. 行级锁的底层实现与隔离级别3.1 InnoDB 为什么只认行锁MySQL 里有很多存储引擎,MyISAM 就只有表锁,InnoDB 才有行锁。要理解行锁的价值,得回到索引结构本身。InnoDB 的主索引是聚簇索引,数据行就存在 B+ 树的叶子节点上,每条记录都有一个主键值作为唯一标识。行锁本质上是“锁定索引记录”,而不是直接锁磁盘上的数据行。这个说法很关键,因为这意味着:如果你更新条件里没有索引,或没走索引,InnoDB 找不到确定的索引记录,就只能扫描聚簇索引,等于把全表所有索引记录都锁一遍。行锁的好处是并发度高:不同事务可以同时修改不同行,只要锁定的索引记录不重叠。坏处是拿锁的成本更高,因为要精确定位到叶子节点、检查锁对象是否冲突。这也是为什么 InnoDB 需要一套高效的锁系统来管理锁对象,而不是简单粗暴地在表头挂一把锁。3.2 RR 隔离级别下的 Gap 锁是怎么产生的Gap 锁的产生和事务隔离级别直接相关。在 READ COMMITTED 级别,InnoDB 只使用 Record Lock,不会加 Gap Lock,所以不会出现“两个事务之间因为区间插入互相阻塞”的情况,但它会有幻读问题。对于很多业务来说,RC 级别已经够用,而且并发能力更强,所以有不少团队会把隔离级别调成 RC。但默认的 RR 级别就不同了。我们看一个典型的场景:-- 事务 A BEGIN; SELECT * FROM orders WHERE amount > 100 FOR UPDATE;假设orders表里 amount 字段有二级索引,索引中已有 80、120、150 几条记录。事务 A 这条 SQL 不仅会把 120、150 两条记录加上 Record Lock,还会在 80 和 120 之间、120 和 150 之间、以及 150 之后到无穷大的区间加上 Gap Lock。目的就是保证:事务 A 再次执行同样的查询时,不会出现新的满足条件的行。但这也意味着,事务 B 如果尝试插入一条 amount = 130 的新订单,会被 Gap Lock 卡住。很多人在这个点上踩坑:他们以为只锁住了查出来的两行,结果另一个会话插一条与查询条件相关的新记录也被阻塞。不是 MySQL 锁多了,而是你选的隔离级别决定了它必须帮你防幻读。如果你业务上能接受提交后再读看到最新数据,把隔离级别调成 RC 会显著降低 Gap Lock 出现的概率。3.3 加锁语句与索引的三次握手我们可以把一条 UPDATE 的加锁过程简化三步:优化器选择索引:根据 WHERE 条件,选择成本最低的执行计划。如果选主键索引,锁直接落在主键行;如果选二级索引,InnoDB 会先在二级索引上加锁,再回表到聚簇索引,把主键对应的记录也锁上。遍历索引记录并加锁:每访问到一条索引记录,就按当前隔离级别生成 Record Lock 或 Next-Key Lock。注意,这里的“访问到”指的是扫描过程中碰到的每条记录,不是最终结果集里的每条记录。提交或回滚时释放:锁并不是立刻释放的,普通读(一致性读)走 MVCC 不加锁,但 UPDATE、DELETE、SELECT FOR UPDATE 这些加锁读都会持有锁直到事务结束。拿一个细节举例:假设执行:UPDATE users SET nickname = 'new_name' WHERE mobile = '13800000000';如果mobile建了唯一索引,InnoDB 只需要在唯一索引上找到对应记录加锁,再回表锁主键。整个过程锁范围极小。而如果mobile没有索引,优化器只能走主键索引的全表扫描,每扫一条主键记录都会加锁,效果等同于锁全表。这也是我下一章要强调的重点——行锁的高并发优势是建立在索引正确的前提之上的。4. 死锁排查与优化实战:从 show engine innodb status 到锁等待日志4.1 两个事务相互等待的完整现场当我们遇到死锁报错,第一反应应该是拿到死锁现场。MySQL 会把最近一次死锁的信息记录在SHOW ENGINE INNODB STATUS的输出里,重点看LATEST DETECTED DEADLOCK部分。下面是我实际分析过的一个简化例子:-- 事务 A UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 事务 B UPDATE account SET balance = balance - 100 WHERE id = 2; UPDATE account SET balance = balance + 100 WHERE id = 1;两个事务都是先更新 id=1 再更新 id=2,只不过 A 的顺序是 1→2,B 是 2→1。假设 A 先持有 id=1 的锁,B 先持有 id=2 的锁,然后 A 去申请 id=2 的锁时发现被 B 持有,B 去申请 id=1 的锁时发现被 A 持有,死锁形成。InnoDB 检测到死锁后,会选择一个事务回滚,一般是回滚代价较小的那个(按 UNDO 数量估算)。应用层收到的报错信息类似:Deadlock found when trying to get lock; try restarting transaction这在日志里不算罕见,正确做法是捕获这个错误码,然后重试整个事务。4.2 如何快速定位锁等待和源头事务死锁是“互相等”,而锁等待是“单方向等”。排查锁等待时,我建议按下面三步走:查当前锁等待关系:SELECT * FROM performance_schema.data_lock_waits;这个视图能看到哪个事务(REQUESTING_THREAD_ID)在等哪个事务(BLOCKING_THREAD_ID)持有的哪个锁。比早期的INNODB_LOCKS表信息更完整。查正在运行的事务和 SQL:SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;从这里可以找到长时间未提交的事务。记住,一个事务哪怕是只执行了一条 SELECT,也会开启事务并持有快照,后续对同表执行 UPDATE,就可能和别的正在写入的事务冲突。查系统进程:SHOW PROCESSLIST;找到被卡住会话的状态,如果看到Waiting for lock或Lock wait timeout exceeded,基本可以断定是锁等待。我在定位时还有一个值得养成的习惯:给事务里的 SQL 打上注释标记,比如/* batchJob:123 */ UPDATE ...,这样看trx_query时能立刻知道是哪个业务模块发起的事务。这个习惯在大规模集群里帮助极大,不然你只能看到一堆抽象 SQL。4.3 常见死锁场景与 SQL 改写思路我把这几年遇到的高频死锁场景整理成了三类,每一类都有对应的改写思路。交叉更新(上面说的 A 和 B 反向更新):核心解法是固定加锁顺序。比如业务上约定所有涉及账户余额的更新,都从小到大按账户 id 排序后再执行。项目里可以在 DAO 层统一封装一个批量更新方法,把入参按 id 排序。先查后改导致间隙锁叠加:BEGIN; SELECT COUNT(*) FROM orders WHERE status = 0 FOR UPDATE; -- 业务逻辑... UPDATE orders SET status = 1 WHERE order_id = 123; COMMIT;这种写法的问题在于SELECT FOR UPDATE的 WHERE 条件如果走了二级索引,都可能产生大量 Next-Key Lock。建议先确认能否避免FOR UPDATE,改用普通读 + 乐观锁版本号的方式,或者把查询范围缩小到具体的业务批次。批量更新范围重叠:UPDATE orders SET status = 1 WHERE create_time BETWEEN '2025-01-01' AND '2025-01-31';两个事务如果都执行这个更新,即使更新的是不同 create_time 的记录,间隙锁也可能互相覆盖。解决办法是按更细的粒度分批,例如每次只处理 100 条主键范围内的记录,并且批与批之间错开主键区间。改写 SQL 的原则是:尽量让锁的范围等于你真正要修改的数据范围,不要用宽泛条件把索引区间整个锁住。5. 锁与索引的关系:没有索引行锁会退化为表锁5.1 为什么无索引更新会锁全表这是一个很经典的问题:InnoDB 行锁是靠索引记录实现的,那 WHERE 条件不带索引会怎样?答案是,扫描了多少条索引记录,就会锁多少条。全表扫描等于锁全表,而且因为扫描过程中会持续上锁,其他事务的插入也被间隙锁大面积覆盖。举个实际数字:一张user_login_log表有 200 万行,某天运维同事执行:DELETE FROM user_login_log WHERE create_time < '2024-06-01';create_time没有索引,这条 SQL 会全表扫描,等于把所有记录的锁都过一遍。期间任何对这张表的 INSERT / UPDATE / DELETE 都会被阻塞,而且每条 DML 都要等前面的锁释放,越积越多,最后直接把数据库连接池打满。要避免这种事故,第一是必须给create_time建索引,第二是如果删除量确实很大,用“分段删除”的方式:每次用主键范围取一批 id,比如WHERE id BETWEEN ? AND ?,循环删除,单批事务的锁范围就只限制在这批 id 里。这也是很多 DBA 处理归档数据时的标准操作。5.2 唯一索引与主键索引的加锁差异很多人以为 WHERE 条件用了唯一索引,加锁就和主键一样。其实有区别。如果 WHERE 条件用唯一索引:UPDATE users SET email = 'new@example.com' WHERE user_id = 100;只要user_id是唯一索引,InnoDB 会先锁定user_id = 100这个二级索引记录,再去聚簇索引锁定主键id对应记录。两个锁都要持有,直到事务结束。但如果 WHERE 条件用普通二级索引:UPDATE users SET status = 1 WHERE age = 25;age不是唯一索引,那么所有age = 25的二级索引记录都会被加锁,同时回表对应的聚簇索引记录也都加锁。锁的数量等于匹配行数。这里有个容易被忽略的坑:如果age索引的区分度很低,比如表里一半人都 25 岁,那么锁的记录数量依然很大。所以在设计索引时,不能只看“有没有索引”,还要看索引列的区分度。区分度越低,一次扫描命中的记录越多,锁影响面越大。5.3 如何利用索引下推和覆盖索引减少锁范围想减少锁范围,一种很有效的思路是让 UPDATE 只回表修正真正要改的行,而不是让优化器大范围扫描。举例来说,查询条件里有多个列,可以在联合索引中尽量包含 WHERE 列。假设业务经常按status和create_time更新:UPDATE task SET exec_result = 'SUCCESS' WHERE status = 'RUNNING' AND create_time < '2025-06-01';如果只在status上建索引,MySQL 会先找到所有RUNNING的记录,再逐行判断create_time,涉及大量锁。如果在(status, create_time)上建联合索引,优化器可以精确命中那一小段符合条件的索引区间,锁的数量会大幅减少。这就是“索引条件下推”(Index Condition Pushdown)带来的间接好处:让存储引擎在索引层面过滤掉更多行,而不是把每一条都回表加锁。覆盖索引则更适合 SELECT 加锁场景。比如要锁定一批满足条件的订单号:SELECT order_id FROM orders WHERE merchant_id = 1000 AND status = 'PAID' FOR UPDATE;如果表有(merchant_id, status, order_id)联合索引,这个查询完全可以在索引中完成,不需要回表读整行数据。虽然FOR UPDATE还是会锁住命中的二级索引记录和聚簇索引记录,但回表扫描的开销显著降低。锁范围没有变小多少,但加锁的速度更快,事务持有锁的时间更短,从而减少锁冲突概率。6. 常见问题速查与经验总结6.1 锁等待超时与死锁的区别锁等待超时和死锁经常被混为一谈,但它们机制完全不同。锁等待超时:一个事务在等待另一个事务释放锁,等到了innodb_lock_wait_timeout(默认 50 秒)仍然没等到,就会报错。此时另一个事务还在正常运行,并没有被回滚。死锁:两个以上事务互相持有对方需要的锁,形成环形等待。InnoDB 的死锁检测线程会主动介入,把其中一个事务回滚掉,让另一个事务继续执行。注意,死锁检测默认是开启的,所以死锁不需要等超时,通常几百毫秒内就会触发。处理建议:锁等待超时先查data_lock_waits,找到阻塞源头,看看那个事务为什么长时间不提交。死锁处理则要抓住两点:一是应用层捕获ER_LOCK_DEADLOCK(错误码 1213),自动重启事务;二是从代码层面消除固定的加锁顺序冲突。6.2 实际生产环境中的锁监控命令清单给你一份我压箱底的排查命令组合,我每次遇到锁问题都会按顺序执行:看整体事务情况:SELECT trx_id, trx_state, trx_started, trx_query, trx_rows_locked FROM information_schema.innodb_trx;看锁等待明细:SELECT * FROM performance_schema.data_lock_waits;看当前持有的锁对象:SELECT * FROM performance_schema.data_locks;看最近死锁信息:SHOW ENGINE INNODB STATUS;如果发现某个事务长期不提交,查看它的连接信息:SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE id = 具体连接ID;这套组合覆盖了从“谁在等谁”到“等了多久”到“锁住了哪些对象”的完整链路。注意版本问题:MySQL 5.7 之后推荐用performance_schema.data_locks,老的information_schema.innodb_locks在新版本里已经废弃。如果你还在用 5.6,那就只能靠SHOW ENGINE INNODB STATUS手工解析了。6.3 几条独家避坑建议最后分享几条我在各种环境里踩过坑后沉淀下来的经验。第一条,别把隔离级别当摆设。如果业务数据对幻读不敏感,比如只有 insert 和 delete、很少做范围查询后再插入的场景,直接把隔离级别设置成READ COMMITTED,能减少大量间隙锁带来的并发阻塞。我见过某个订单系统只是改一个transaction_isolation参数,高峰期锁等待次数直接降了 70%。第二条,大事务是锁问题的万恶之源。一个事务里如果塞了太多查询、远程调用、甚至耗时算法,都会让锁的持有时间无限拉长。锁并不可怕,可怕的是锁被长时间握住不放。所以写事务代码时要遵守“事务内不做无关操作”的原则,把批量更新拆分到独立小事务里执行。第三条,监控要前置。不要等到死锁报错才去查。在数据库慢查询监控里,专门把 “Lock wait timeout” 和 “Deadlock found” 这两个错误码拉出来做告警,同时定期采集innodb_row_lock_current_waits和innodb_row_lock_time_avg这两个状态值。一旦平均值异常上升,即使没有报错,也能提前发现锁冲突恶化的趋势。第四条,也是我最想强调的一点——别动不动就清缓存、杀连接、重启 MySQL。锁问题是事务逻辑和索引设计的问题,不是数据库“坏了”。重启只会让正在运行的事务全部回滚,业务侧可能会补偿数据,反而扩大混乱。正确姿势是冷静下来,用上面的命令把锁等待链查清楚,从源头解决。说起来,我个人的习惯是在每个项目的数据库设计评审阶段,就把“这条业务 SQL 会加多少锁、加多久、会不会影响其他会话”当成必答题。一旦养成这种思维,很多并发问题在设计阶段就被掐灭了,根本轮不到上线后去死锁排查。这也是我和这篇文章最想传达给你的东西:锁机制并不神秘,它只是 InnoDB 为了保证数据一致性和并发效率,在索引之上画下的一条条边界。你理解了边界在哪里、为什么会存在,自然就知道怎么写 SQL 才能不越界、不踩坑。
这篇文章的选题我很早就想写了。起因是前阵子帮一个社区的朋友排查线上死锁,他跑了一个定时任务,每天凌晨批量更新订单状态,结果第二天一上班就收到一堆锁等待超时的告警。日志一拉,两条 UPDATE 语句堵在同一条索引记录上,偏偏两边还都加了别的事务,谁也等不到谁。我翻了翻他当时的建表语句和事务代码,发现问题根本不在于 SQL 写得不好,而是在于他完全没有意识到 MySQL 的锁机制在背后是怎么运作的。其实“锁”这个词在 MySQL 里听着抽象,但它几乎决定了你每一个并发事务的成败。很多人知道有“行锁”“表锁”这些名词,可真到了排查死锁的时候,却连SHOW ENGINE INNODB STATUS里的信息都看不懂。这篇就当是给所有被锁困扰过的开发者、运维和 DBA 一份从全局到行级、从原理到实战的完整梳理。你会看到:全局锁到底锁住了什么,表锁和 MDL 锁如何悄悄卡住你的查询,InnoDB 的行锁又是怎样在索引记录上跳舞,以及遇到死锁时,最快能落地的排查和改写手段。1. 从一条慢 SQL 引出的锁问题1.1 一次线上事故的复盘想理解锁机制,最好先回到事故现场。上面提到的那个定时任务,伪代码如下:BEGIN; UPDATE order_info SET status = 'PAID' WHERE order_no = 'A10001'; UPDATE order_info SET status = 'REFUNDING' WHERE order_no = 'B10002'; COMMIT;另一个事务则倒过来操作:BEGIN; UPDATE order_info SET status = 'REFUNDING' WHERE order_no = 'B10002'; UPDATE order_info SET status = 'PAID' WHERE order_no = 'A10001'; COMMIT;两个事务分别持有对方下一步要更新的行锁,于是互相等待,直到innodb_lock_wait_timeout默认的 50 秒耗尽。MySQL 只能把其中一个事务回滚掉,应用侧就会看到“Lock wait timeout exceeded; try restarting transaction”的报错。问题看起来是程序写得太“对称”,但我当时让他先跑了一遍SHOW ENGINE INNODB STATUS,发现一个更隐蔽的细节:虽然两条记录在逻辑上不同,但在 B+ 树里它们落在了同一个索引页的相邻位置。事务 A 第一条 UPDATE 除了锁住order_no = 'A10001'这条记录,还在记录之前和之后加上了间隙锁,这就直接把事务 B 要更新的B10002给堵在了更小范围的锁等待里。等你真正弄清楚这个因果链,就会明白:死锁不是运气差,而是加锁顺序、索引选择、隔离级别三者共同作用的结果。1.2 为什么每个 MySQL DBA 都要懂锁很多开发者觉得锁只是数据库内部的事,跟写业务没关系。但只要你写过一条带WHERE条件的 UPDATE 或 DELETE,你就已经在和锁打交道了。比如:DELETE FROM user_login_log WHERE create_time < '2025-01-01';这条 SQL 看起来只是想清理半年以上的日志,可如果没有合适的索引,MySQL 会扫描主键做全表判断,每扫到一行就加一次行锁。扫描过程中,别的会话想插入新的登录日志,如果被间隙锁挡住,就会出现“明明没碰同一条记录,却互相卡住”的诡异现象。理解锁机制的收益有三个层面:一是能快速读懂performance_schema.data_lock_waits这类视图,定位到底谁在等谁;二是能在设计表结构和 SQL 的时候主动避开全表更新、大范围扫描这些容易放大的锁范围;三是遇到死锁时,不靠重启数据库、不靠瞎改超时参数来碰运气,而是直接分析加锁顺序,用最小的代码改动解决问题。2. 锁的类型全景图:全局锁、表锁、行锁各自管什么2.1 全局锁:Flush tables with read lock 到底锁了什么全局锁是整个实例维度的锁,执行:FLUSH TABLES WITH READ LOCK;之后,整个实例进入只读状态。所有数据表的 DML、DDL 都会被阻塞,直到执行UNLOCK TABLES释放。这个操作最经典的用途是作为全库逻辑备份的锚点。比如用mysqldump做一致性快照时,需要保证备份期间没有任何写入,否则备份出来的数据可能是“半个事务”的状态,恢复回去一定会出问题。但全局锁的代价太粗暴了。线上业务一执行,等于所有写入全部停摆。于是 InnoDB 提供了更优雅的替代方案:利用 MVCC(多版本并发控制),在备份时开启一个可重复读的隔离级别事务,mysqldump --single-transaction就是靠这个原理,在备份期间不阻塞任何业务写入,依旧能看到一致的数据快照。我在实际运维中只会在两种场景下考虑全局锁:一是主从切换时为了保证主库和备库的数据点位完全一致,短暂加一下;二是某些历史遗留的表还是 MyISAM 引擎,--single-transaction对它无效,只能靠 FTWRL 保证一致性。如果你遇到的是 InnoDB 表,能用--single-transaction就别碰全局锁。2.2 表级锁:表锁、MDL 锁、意向锁的协同表锁是最容易被忽略的“隐藏杀手”。显式的表锁语句是:LOCK TABLES order_info WRITE;这个锁会把整张表锁住,其他会话不可以读也不可以写(WRITE 锁下,读也会被阻塞)。虽然 InnoDB 支持行锁,但它同样提供表级锁,只是默认不主动使用。真正的坑其实在 MDL 锁(元数据锁)。MDL 锁不需要你手动加,它是 MySQL 在 5.5 版本引入的,目的是保护表结构定义。当你执行一条 SELECT 查询时,会先拿一个 MDL 读锁;执行 ALTER TABLE 时,则要拿 MDL 写锁。MDL 读锁之间不互斥,写锁和读锁互斥。这就引出一个非常常见的线上事故:一个慢查询长期占着 MDL 读锁,后面一个 DDL 在排队等写锁,而 DDL 后面所有的查询、更新全部排队等待。最终表现为“数据库莫名不可用”,SHOW PROCESSLIST里能看到大量 “Waiting for table metadata lock” 的会话。意向锁则是 InnoDB 为了让表锁和行锁能共存而设计的“信号牌”。你执行SELECT ... FOR UPDATE加行级排他锁时,InnoDB 会先在表上加一个“意向排他锁”。另一个会话想对全表加表锁时,一看存在意向排他锁,就知道“有行数据被锁住了”,立即返回冲突。这样查表时不需要逐个扫描行记录,效率高很多。2.3 行锁:Record Lock、Gap Lock、Next-Key Lock 的三角关系行锁才是 InnoDB 的看家本领。按锁定的范围,它可以细分成三种:Record Lock(记录锁):只锁索引树上的某一条记录。比如主键id = 10,加锁后其他事务不能修改或删除这一行。Gap Lock(间隙锁):锁住记录之间的“空隙”。比如索引里有 10 和 20 两条记录,间隙锁会把(10, 20)这个区间锁住,作用是防止其他事务在这个区间插入新记录。Next-Key Lock(临键锁):记录锁 + 间隙锁的组合,锁住的范围是(10, 20],即左开右闭区间。它既保护了记录本身,又阻止了在这个区间插入新数据。为什么要分这么多类型?因为默认的隔离级别是REPEATABLE READ(可重复读),MySQL 必须防止“幻读”——也就是同一事务里,两次相同的查询返回了不同的行集合。如果不锁间隙,事务 A 第一次查id > 10没有记录,事务 B 插入一条id = 15并提交,事务 A 再查一次,就会多出一行,这就是幻读。Next-Key Lock 就是 InnoDB 在 RR 隔离级别下用来狙击幻读的武器。理解了这三种锁,你在看SHOW ENGINE INNODB STATUS的死锁信息时就能脑补出一幅 B+ 树索引页的图:哪个事务持有什么锁,等待的又是哪个锁,一目了然。这也是我把行锁放到一个单独章节慢慢讲的原因。3. 行级锁的底层实现与隔离级别3.1 InnoDB 为什么只认行锁MySQL 里有很多存储引擎,MyISAM 就只有表锁,InnoDB 才有行锁。要理解行锁的价值,得回到索引结构本身。InnoDB 的主索引是聚簇索引,数据行就存在 B+ 树的叶子节点上,每条记录都有一个主键值作为唯一标识。行锁本质上是“锁定索引记录”,而不是直接锁磁盘上的数据行。这个说法很关键,因为这意味着:如果你更新条件里没有索引,或没走索引,InnoDB 找不到确定的索引记录,就只能扫描聚簇索引,等于把全表所有索引记录都锁一遍。行锁的好处是并发度高:不同事务可以同时修改不同行,只要锁定的索引记录不重叠。坏处是拿锁的成本更高,因为要精确定位到叶子节点、检查锁对象是否冲突。这也是为什么 InnoDB 需要一套高效的锁系统来管理锁对象,而不是简单粗暴地在表头挂一把锁。3.2 RR 隔离级别下的 Gap 锁是怎么产生的Gap 锁的产生和事务隔离级别直接相关。在 READ COMMITTED 级别,InnoDB 只使用 Record Lock,不会加 Gap Lock,所以不会出现“两个事务之间因为区间插入互相阻塞”的情况,但它会有幻读问题。对于很多业务来说,RC 级别已经够用,而且并发能力更强,所以有不少团队会把隔离级别调成 RC。但默认的 RR 级别就不同了。我们看一个典型的场景:-- 事务 A BEGIN; SELECT * FROM orders WHERE amount > 100 FOR UPDATE;假设orders表里 amount 字段有二级索引,索引中已有 80、120、150 几条记录。事务 A 这条 SQL 不仅会把 120、150 两条记录加上 Record Lock,还会在 80 和 120 之间、120 和 150 之间、以及 150 之后到无穷大的区间加上 Gap Lock。目的就是保证:事务 A 再次执行同样的查询时,不会出现新的满足条件的行。但这也意味着,事务 B 如果尝试插入一条 amount = 130 的新订单,会被 Gap Lock 卡住。很多人在这个点上踩坑:他们以为只锁住了查出来的两行,结果另一个会话插一条与查询条件相关的新记录也被阻塞。不是 MySQL 锁多了,而是你选的隔离级别决定了它必须帮你防幻读。如果你业务上能接受提交后再读看到最新数据,把隔离级别调成 RC 会显著降低 Gap Lock 出现的概率。3.3 加锁语句与索引的三次握手我们可以把一条 UPDATE 的加锁过程简化三步:优化器选择索引:根据 WHERE 条件,选择成本最低的执行计划。如果选主键索引,锁直接落在主键行;如果选二级索引,InnoDB 会先在二级索引上加锁,再回表到聚簇索引,把主键对应的记录也锁上。遍历索引记录并加锁:每访问到一条索引记录,就按当前隔离级别生成 Record Lock 或 Next-Key Lock。注意,这里的“访问到”指的是扫描过程中碰到的每条记录,不是最终结果集里的每条记录。提交或回滚时释放:锁并不是立刻释放的,普通读(一致性读)走 MVCC 不加锁,但 UPDATE、DELETE、SELECT FOR UPDATE 这些加锁读都会持有锁直到事务结束。拿一个细节举例:假设执行:UPDATE users SET nickname = 'new_name' WHERE mobile = '13800000000';如果mobile建了唯一索引,InnoDB 只需要在唯一索引上找到对应记录加锁,再回表锁主键。整个过程锁范围极小。而如果mobile没有索引,优化器只能走主键索引的全表扫描,每扫一条主键记录都会加锁,效果等同于锁全表。这也是我下一章要强调的重点——行锁的高并发优势是建立在索引正确的前提之上的。4. 死锁排查与优化实战:从 show engine innodb status 到锁等待日志4.1 两个事务相互等待的完整现场当我们遇到死锁报错,第一反应应该是拿到死锁现场。MySQL 会把最近一次死锁的信息记录在SHOW ENGINE INNODB STATUS的输出里,重点看LATEST DETECTED DEADLOCK部分。下面是我实际分析过的一个简化例子:-- 事务 A UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 事务 B UPDATE account SET balance = balance - 100 WHERE id = 2; UPDATE account SET balance = balance + 100 WHERE id = 1;两个事务都是先更新 id=1 再更新 id=2,只不过 A 的顺序是 1→2,B 是 2→1。假设 A 先持有 id=1 的锁,B 先持有 id=2 的锁,然后 A 去申请 id=2 的锁时发现被 B 持有,B 去申请 id=1 的锁时发现被 A 持有,死锁形成。InnoDB 检测到死锁后,会选择一个事务回滚,一般是回滚代价较小的那个(按 UNDO 数量估算)。应用层收到的报错信息类似:Deadlock found when trying to get lock; try restarting transaction这在日志里不算罕见,正确做法是捕获这个错误码,然后重试整个事务。4.2 如何快速定位锁等待和源头事务死锁是“互相等”,而锁等待是“单方向等”。排查锁等待时,我建议按下面三步走:查当前锁等待关系:SELECT * FROM performance_schema.data_lock_waits;这个视图能看到哪个事务(REQUESTING_THREAD_ID)在等哪个事务(BLOCKING_THREAD_ID)持有的哪个锁。比早期的INNODB_LOCKS表信息更完整。查正在运行的事务和 SQL:SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;从这里可以找到长时间未提交的事务。记住,一个事务哪怕是只执行了一条 SELECT,也会开启事务并持有快照,后续对同表执行 UPDATE,就可能和别的正在写入的事务冲突。查系统进程:SHOW PROCESSLIST;找到被卡住会话的状态,如果看到Waiting for lock或Lock wait timeout exceeded,基本可以断定是锁等待。我在定位时还有一个值得养成的习惯:给事务里的 SQL 打上注释标记,比如/* batchJob:123 */ UPDATE ...,这样看trx_query时能立刻知道是哪个业务模块发起的事务。这个习惯在大规模集群里帮助极大,不然你只能看到一堆抽象 SQL。4.3 常见死锁场景与 SQL 改写思路我把这几年遇到的高频死锁场景整理成了三类,每一类都有对应的改写思路。交叉更新(上面说的 A 和 B 反向更新):核心解法是固定加锁顺序。比如业务上约定所有涉及账户余额的更新,都从小到大按账户 id 排序后再执行。项目里可以在 DAO 层统一封装一个批量更新方法,把入参按 id 排序。先查后改导致间隙锁叠加:BEGIN; SELECT COUNT(*) FROM orders WHERE status = 0 FOR UPDATE; -- 业务逻辑... UPDATE orders SET status = 1 WHERE order_id = 123; COMMIT;这种写法的问题在于SELECT FOR UPDATE的 WHERE 条件如果走了二级索引,都可能产生大量 Next-Key Lock。建议先确认能否避免FOR UPDATE,改用普通读 + 乐观锁版本号的方式,或者把查询范围缩小到具体的业务批次。批量更新范围重叠:UPDATE orders SET status = 1 WHERE create_time BETWEEN '2025-01-01' AND '2025-01-31';两个事务如果都执行这个更新,即使更新的是不同 create_time 的记录,间隙锁也可能互相覆盖。解决办法是按更细的粒度分批,例如每次只处理 100 条主键范围内的记录,并且批与批之间错开主键区间。改写 SQL 的原则是:尽量让锁的范围等于你真正要修改的数据范围,不要用宽泛条件把索引区间整个锁住。5. 锁与索引的关系:没有索引行锁会退化为表锁5.1 为什么无索引更新会锁全表这是一个很经典的问题:InnoDB 行锁是靠索引记录实现的,那 WHERE 条件不带索引会怎样?答案是,扫描了多少条索引记录,就会锁多少条。全表扫描等于锁全表,而且因为扫描过程中会持续上锁,其他事务的插入也被间隙锁大面积覆盖。举个实际数字:一张user_login_log表有 200 万行,某天运维同事执行:DELETE FROM user_login_log WHERE create_time < '2024-06-01';create_time没有索引,这条 SQL 会全表扫描,等于把所有记录的锁都过一遍。期间任何对这张表的 INSERT / UPDATE / DELETE 都会被阻塞,而且每条 DML 都要等前面的锁释放,越积越多,最后直接把数据库连接池打满。要避免这种事故,第一是必须给create_time建索引,第二是如果删除量确实很大,用“分段删除”的方式:每次用主键范围取一批 id,比如WHERE id BETWEEN ? AND ?,循环删除,单批事务的锁范围就只限制在这批 id 里。这也是很多 DBA 处理归档数据时的标准操作。5.2 唯一索引与主键索引的加锁差异很多人以为 WHERE 条件用了唯一索引,加锁就和主键一样。其实有区别。如果 WHERE 条件用唯一索引:UPDATE users SET email = 'new@example.com' WHERE user_id = 100;只要user_id是唯一索引,InnoDB 会先锁定user_id = 100这个二级索引记录,再去聚簇索引锁定主键id对应记录。两个锁都要持有,直到事务结束。但如果 WHERE 条件用普通二级索引:UPDATE users SET status = 1 WHERE age = 25;age不是唯一索引,那么所有age = 25的二级索引记录都会被加锁,同时回表对应的聚簇索引记录也都加锁。锁的数量等于匹配行数。这里有个容易被忽略的坑:如果age索引的区分度很低,比如表里一半人都 25 岁,那么锁的记录数量依然很大。所以在设计索引时,不能只看“有没有索引”,还要看索引列的区分度。区分度越低,一次扫描命中的记录越多,锁影响面越大。5.3 如何利用索引下推和覆盖索引减少锁范围想减少锁范围,一种很有效的思路是让 UPDATE 只回表修正真正要改的行,而不是让优化器大范围扫描。举例来说,查询条件里有多个列,可以在联合索引中尽量包含 WHERE 列。假设业务经常按status和create_time更新:UPDATE task SET exec_result = 'SUCCESS' WHERE status = 'RUNNING' AND create_time < '2025-06-01';如果只在status上建索引,MySQL 会先找到所有RUNNING的记录,再逐行判断create_time,涉及大量锁。如果在(status, create_time)上建联合索引,优化器可以精确命中那一小段符合条件的索引区间,锁的数量会大幅减少。这就是“索引条件下推”(Index Condition Pushdown)带来的间接好处:让存储引擎在索引层面过滤掉更多行,而不是把每一条都回表加锁。覆盖索引则更适合 SELECT 加锁场景。比如要锁定一批满足条件的订单号:SELECT order_id FROM orders WHERE merchant_id = 1000 AND status = 'PAID' FOR UPDATE;如果表有(merchant_id, status, order_id)联合索引,这个查询完全可以在索引中完成,不需要回表读整行数据。虽然FOR UPDATE还是会锁住命中的二级索引记录和聚簇索引记录,但回表扫描的开销显著降低。锁范围没有变小多少,但加锁的速度更快,事务持有锁的时间更短,从而减少锁冲突概率。6. 常见问题速查与经验总结6.1 锁等待超时与死锁的区别锁等待超时和死锁经常被混为一谈,但它们机制完全不同。锁等待超时:一个事务在等待另一个事务释放锁,等到了innodb_lock_wait_timeout(默认 50 秒)仍然没等到,就会报错。此时另一个事务还在正常运行,并没有被回滚。死锁:两个以上事务互相持有对方需要的锁,形成环形等待。InnoDB 的死锁检测线程会主动介入,把其中一个事务回滚掉,让另一个事务继续执行。注意,死锁检测默认是开启的,所以死锁不需要等超时,通常几百毫秒内就会触发。处理建议:锁等待超时先查data_lock_waits,找到阻塞源头,看看那个事务为什么长时间不提交。死锁处理则要抓住两点:一是应用层捕获ER_LOCK_DEADLOCK(错误码 1213),自动重启事务;二是从代码层面消除固定的加锁顺序冲突。6.2 实际生产环境中的锁监控命令清单给你一份我压箱底的排查命令组合,我每次遇到锁问题都会按顺序执行:看整体事务情况:SELECT trx_id, trx_state, trx_started, trx_query, trx_rows_locked FROM information_schema.innodb_trx;看锁等待明细:SELECT * FROM performance_schema.data_lock_waits;看当前持有的锁对象:SELECT * FROM performance_schema.data_locks;看最近死锁信息:SHOW ENGINE INNODB STATUS;如果发现某个事务长期不提交,查看它的连接信息:SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE id = 具体连接ID;这套组合覆盖了从“谁在等谁”到“等了多久”到“锁住了哪些对象”的完整链路。注意版本问题:MySQL 5.7 之后推荐用performance_schema.data_locks,老的information_schema.innodb_locks在新版本里已经废弃。如果你还在用 5.6,那就只能靠SHOW ENGINE INNODB STATUS手工解析了。6.3 几条独家避坑建议最后分享几条我在各种环境里踩过坑后沉淀下来的经验。第一条,别把隔离级别当摆设。如果业务数据对幻读不敏感,比如只有 insert 和 delete、很少做范围查询后再插入的场景,直接把隔离级别设置成READ COMMITTED,能减少大量间隙锁带来的并发阻塞。我见过某个订单系统只是改一个transaction_isolation参数,高峰期锁等待次数直接降了 70%。第二条,大事务是锁问题的万恶之源。一个事务里如果塞了太多查询、远程调用、甚至耗时算法,都会让锁的持有时间无限拉长。锁并不可怕,可怕的是锁被长时间握住不放。所以写事务代码时要遵守“事务内不做无关操作”的原则,把批量更新拆分到独立小事务里执行。第三条,监控要前置。不要等到死锁报错才去查。在数据库慢查询监控里,专门把 “Lock wait timeout” 和 “Deadlock found” 这两个错误码拉出来做告警,同时定期采集innodb_row_lock_current_waits和innodb_row_lock_time_avg这两个状态值。一旦平均值异常上升,即使没有报错,也能提前发现锁冲突恶化的趋势。第四条,也是我最想强调的一点——别动不动就清缓存、杀连接、重启 MySQL。锁问题是事务逻辑和索引设计的问题,不是数据库“坏了”。重启只会让正在运行的事务全部回滚,业务侧可能会补偿数据,反而扩大混乱。正确姿势是冷静下来,用上面的命令把锁等待链查清楚,从源头解决。说起来,我个人的习惯是在每个项目的数据库设计评审阶段,就把“这条业务 SQL 会加多少锁、加多久、会不会影响其他会话”当成必答题。一旦养成这种思维,很多并发问题在设计阶段就被掐灭了,根本轮不到上线后去死锁排查。这也是我和这篇文章最想传达给你的东西:锁机制并不神秘,它只是 InnoDB 为了保证数据一致性和并发效率,在索引之上画下的一条条边界。你理解了边界在哪里、为什么会存在,自然就知道怎么写 SQL 才能不越界、不踩坑。