高并发下的 MySQL 锁治理:从行锁、间隙锁、死锁到 MDL 雪崩的生产级实战
高并发下的 MySQL 锁治理:从行锁、间隙锁、死锁到 MDL 雪崩的生产级实战
文章定位:不是罗列锁名词,而是建立一套从“SQL 如何加锁”到“生产事故如何止损”的完整方法论。
导读
高并发系统中的数据库锁问题,通常不是“某一条 SQL 很慢”这么简单。
一次锁事故往往会沿着下面的路径扩散:
事务持锁时间变长 ↓ 后续事务进入锁等待 ↓ 数据库活跃连接快速堆积 ↓ 连接池耗尽、线程池阻塞、接口超时 ↓ 客户端和服务端触发重试 ↓ 数据库并发进一步升高 ↓ 锁等待雪崩更危险的是,InnoDB 行锁、间隙锁、插入意向锁、Server 层元数据锁并不是彼此孤立的。长事务、低选择性索引、不合理的事务边界、DDL 变更和无差别重试,会把多个锁维度串联起来,最终拖垮整个业务链路。
本文将围绕以下问题展开:
- MySQL 到底锁的是“行”、索引记录还是索引区间
- 普通
SELECT为什么通常不加行锁,却仍可能阻塞 DDL - RR 与 RC 隔离级别下,间隙锁行为有什么本质差异
- 锁等待、死锁、MDL 等待应该如何快速定位
- Spring Boot 服务如何缩短事务、正确重试并保证幂等
- 在线 DDL 为什么仍然可能造成生产阻塞
- 如何建立开发、发布、监控和应急处置的锁治理闭环
一、事故复盘:一条 DDL 如何拖垮订单中心
以下案例经过脱敏和场景化整理,用于说明典型故障链路。
凌晨 00:03,订单服务连续触发告警:
- API P99 延迟超过 5 秒
- 数据库活跃连接数持续上升
- HikariCP 等待连接线程增加
- 订单创建、支付状态更新同时超时
- MySQL 中大量会话显示
Waiting for table metadata lock
当时,DBA 正在执行:
ALTERTABLEordersADDCOLUMNpromo_idBIGINTNULL;团队最初认为,这只是一个“新增可空字段”的轻量变更,不会阻塞订单写入。但现场实际形成了如下等待链:
会话 A:开启事务后查询 orders,但迟迟未提交 ↓ 持有 orders 的共享 MDL 会话 B:执行 ALTER TABLE,申请排他 MDL,进入等待 ↓ 会话 C、D、E:后续访问 orders 的请求排队 ↓ 订单接口超时,应用自动重试 ↓ 连接池和数据库会话数快速增长 ↓ 订单中心雪崩这里有三个容易被忽略的事实:
- 普通
SELECT通常是 MVCC 一致性读,不会给数据行加排他锁,但语句仍需要获取表的元数据锁。 - 在显式事务中,语句获取的 MDL 通常要等事务提交或回滚后才释放。
- 即使 DDL 使用
ALGORITHM=INSTANT或LOCK=NONE,也不代表完全不需要 MDL。DDL 在准备或提交表定义时仍可能需要排他元数据锁。
所以,事故的根因不是“DDL 本身执行很慢”,而是:
长事务占有共享 MDL,DDL 排他 MDL 在队列中等待,后续请求继续排队,再叠加应用重试,形成级联拥塞。
重启数据库只能强制清空会话和锁状态,却没有消除长事务、发布流程和重试策略上的问题,因此故障很容易再次出现。
二、先建立正确心智模型:MySQL 锁的边界是索引
很多人把 InnoDB 行锁理解为“锁住这一整行数据”。这个说法便于入门,但不够准确。
更准确的理解是:
InnoDB 的行级锁主要加在索引记录和索引区间上。SQL 通过哪个索引扫描、扫描了多少索引记录,直接决定锁住什么。
这也是为什么两条业务含义相近的 SQL,锁范围可能完全不同。
例如:
UPDATEordersSETstatus='PAID'WHEREid=10001;如果id是主键,InnoDB 可以快速定位单条聚簇索引记录,锁范围通常很小。
但下面这条 SQL:
UPDATEordersSETstatus='PAID'WHEREexternal_no='PAY-20260803-001';如果external_no没有索引,InnoDB 可能需要扫描大量记录。对于UPDATE、DELETE和锁定读,InnoDB 会对扫描过程中需要锁定的索引记录设置锁。即使最终只修改一行,也可能产生近似“锁住大量行”的效果。
因此,分析锁问题不能只看WHERE条件,还必须看:
- 实际使用了哪个索引
- 是否命中唯一索引的完整列
- 扫描了多少记录
- 查询条件是等值还是范围
- 隔离级别是 RR 还是 RC
- 语句是普通一致性读还是锁定读
三、MySQL 锁体系:Server 层与 InnoDB 层
3.1 锁类型总览
| 锁类型 | 所属层次 | 典型粒度 | 主要作用 | 常见风险 |
|---|---|---|---|---|
| Metadata Lock,MDL | MySQL Server 层 | 对象级 | 保护表、视图、存储过程等元数据一致性 | 长事务阻塞 DDL,等待中的 DDL 进一步阻塞业务访问 |
| Table Lock | Server 层或引擎层 | 表级 | 显式表锁、引擎内部协调 | 并发度显著降低 |
| Intention Lock | InnoDB | 表级 | 表示事务准备或已经持有行级共享锁或排他锁 | 通常不是性能问题根因 |
| Record Lock | InnoDB | 索引记录 | 锁定已有索引记录 | 热点行更新冲突 |
| Gap Lock | InnoDB | 索引间隙 | 阻止其他事务向间隙插入 | RR 下范围锁扩大,插入阻塞 |
| Next-Key Lock | InnoDB | 记录与前方间隙 | Record Lock 与 Gap Lock 的组合 | 范围查询产生大范围锁 |
| Insert Intention Lock | InnoDB | 索引间隙 | 表示事务准备在间隙中插入 | 与已有间隙锁冲突时等待 |
| AUTO-INC Lock | InnoDB | 表级或轻量机制 | 协调自增值分配 | 特定批量插入及配置下影响并发 |
| Predicate Lock | InnoDB 空间索引 | 谓词范围 | 支持空间索引隔离语义 | GIS 场景下的特殊锁行为 |
3.2 MVCC 与行锁不是二选一
InnoDB 同时使用:
- MVCC 多版本并发控制
- 两阶段锁协议
- Undo Log
- Read View
- 行级锁和间隙锁
普通一致性读通常通过 MVCC 读取某个可见版本,不需要阻塞正在修改该记录的事务。
SELECT*FROMordersWHEREid=10001;但以下语句属于锁定读或写操作:
SELECT*FROMordersWHEREid=10001FORUPDATE;SELECT*FROMordersWHEREid=10001FORSHARE;UPDATEordersSETstatus='PAID'WHEREid=10001;DELETEFROMordersWHEREid=10001;这些语句会根据访问路径设置相应锁。
需要特别注意:
普通
SELECT不加 InnoDB 数据行排他锁,不代表它“不持有任何锁”。它仍需要 MDL 来保证执行期间表结构稳定。
四、InnoDB 行级锁:Record、Gap 与 Next-Key
4.1 Record Lock:锁定已有索引记录
记录锁锁定的是索引记录。
假设表结构如下:
CREATETABLEorders(idBIGINTPRIMARYKEY,user_idBIGINTNOTNULL,order_noVARCHAR(64)NOTNULL,statusVARCHAR(16)NOTNULL,amountDECIMAL(18,2)NOTNULL,created_atDATETIME(3)NOTNULL,UNIQUEKEYuk_order_no(order_no),KEYidx_user_status(user_id,status),KEYidx_created_at(created_at))ENGINE=InnoDB;执行:
SELECT*FROMordersWHEREid=10001FORUPDATE;若主键记录存在,通常锁定主键索引中id = 10001的记录。
如果通过二级索引执行更新,InnoDB 还需要访问对应的聚簇索引记录。例如:
UPDATEordersSETamount=amount+10WHEREorder_no='O202608030001';执行过程中会涉及唯一二级索引uk_order_no和对应的主键记录。
4.2 Gap Lock:锁住“还不存在的数据位置”
假设索引中已有值:
10, 20, 30间隙包括:
(-∞, 10) (10, 20) (20, 30) (30, +∞)Gap Lock 不锁定现有记录本身,而是阻止其他事务向指定间隙插入新记录。
它的核心目标是控制范围内的新插入,从而支持 RR 隔离级别下的幻读防护和锁定读语义。
一个重要细节是:
多个事务持有同一间隙的 Gap Lock 并不一定互斥,但插入意向锁会被不兼容的 Gap Lock 阻塞。
4.3 Next-Key Lock:记录锁与前方间隙锁的组合
Next-Key Lock 可以理解为:
Gap Lock + Record Lock如果索引值为:
10, 20, 30可能形成的临键区间为:
(-∞, 10] (10, 20] (20, 30] (30, +∞)在 RR 隔离级别下,范围锁定读、范围更新和范围删除通常会对扫描到的索引区间设置 Next-Key Lock。
4.4 Insert Intention Lock:插入前的“占位申请”
多个事务准备向同一间隙的不同位置插入时,不一定要互相阻塞。
例如,索引中已有10和20,两个事务分别插入12和18。如果没有其他 Gap Lock 阻止该间隙插入,两个插入操作可以并发推进。
但如果另一个事务已经通过范围锁定读锁住(10, 20),插入意向锁就会等待。
五、RR 与 RC:隔离级别如何改变锁范围
MySQL InnoDB 默认隔离级别通常为REPEATABLE READ。生产中是否切换到READ COMMITTED,不能只凭“RC 锁更少”做决定。
5.1 行为对比
| 维度 | REPEATABLE READ | READ COMMITTED |
|---|---|---|
| 普通一致性读 | 同一事务内通常复用首次一致性读建立的快照 | 每条一致性读通常建立新快照 |
| 范围锁定读 | 常使用 Gap Lock 或 Next-Key Lock | 通常减少 Gap Lock,主要锁定索引记录 |
| 幻读处理 | MVCC 加 Next-Key Lock 语义 | 每条语句读取最新已提交版本 |
| 非匹配记录锁释放 | 锁行为更保守 | 对不满足条件的记录可更早释放锁 |
| 复制与一致性考虑 | 需结合业务与复制模式评估 | 需评估业务是否依赖 RR 语义 |
| 适合场景 | 需要稳定事务快照、已有逻辑依赖 RR | 高并发写入、希望缩小范围锁,但业务能接受 RC 语义 |
RC 并不是完全没有 Gap Lock。外键约束检查和重复键检查等场景仍可能使用间隙相关锁。
5.2 唯一索引等值查询的关键前提
常见结论是:
唯一索引等值查询命中记录时,Next-Key Lock 可以退化为 Record Lock。
但必须满足:
- 使用的是唯一索引
- 查询使用了唯一索引的全部列
- 是精确等值匹配
假设有复合唯一索引:
UNIQUEKEYuk_tenant_order(tenant_id,order_no)下面的查询可以唯一定位:
SELECT*FROMordersWHEREtenant_id=10ANDorder_no='O001'FORUPDATE;但只使用前导列:
SELECT*FROMordersWHEREtenant_id=10FORUPDATE;并不能唯一定位一条记录,锁范围也不会简单退化为单记录锁。
5.3 不存在记录时为什么仍会锁住间隙
假设user_id是非唯一索引,现有值为:
1, 2, 3, 8事务 A 在 RR 下执行:
SELECT*FROMordersWHEREuser_id=5FORUPDATE;虽然user_id = 5不存在,但为了防止另一个事务在当前锁定范围中插入匹配记录,InnoDB 可能锁住包含5的索引间隙(3, 8)。
事务 B 执行:
INSERTINTOorders(id,user_id,order_no,status,amount,created_at)VALUES(100,6,'O100','INIT',100,NOW(3));因为user_id = 6也位于(3, 8),插入可能等待。
这就是“查询一个不存在的值,却阻塞了另一个不同值插入”的根本原因。
六、不同 SQL 到底如何加锁
6.1 普通 SELECT
SELECT*FROMordersWHEREid=10001;默认是非锁定一致性读:
- 通过 MVCC 读取可见版本
- 通常不设置 InnoDB 记录锁
- 会获取执行所需的 MDL
- 在显式事务中,MDL 可能持续到事务结束
6.2 SELECT … FOR UPDATE
SELECT*FROMordersWHEREid=10001FORUPDATE;用于读取后修改:
- 对扫描到的索引记录设置排他类锁
- 在 RR 下,范围扫描可能产生 Next-Key Lock
- 锁持续到事务提交或回滚
不要把它当成“防并发万能开关”。如果访问路径不准确,它可能扩大锁范围。
6.3 SELECT … FOR SHARE
SELECT*FROMordersWHEREid=10001FORSHARE;适用于需要保证记录在当前事务结束前不被不兼容修改的场景。它允许其他事务获取兼容共享锁,但会阻塞不兼容的更新或删除。
6.4 UPDATE 与 DELETE
UPDATEordersSETstatus='CLOSED'WHEREuser_id=100ANDstatus='INIT';InnoDB 根据实际索引访问路径加锁。关键不是最终修改几条,而是扫描和锁定了哪些索引记录。
因此必须执行:
EXPLAINUPDATEordersSETstatus='CLOSED'WHEREuser_id=100ANDstatus='INIT';并重点关注:
keykey_lenrowsfiltered- 是否出现全表扫描
- 联合索引是否覆盖主要过滤条件
6.5 INSERT
普通插入涉及:
- 新记录写入
- 唯一性检查
- 插入意向锁
- 可能的自增值分配
- 二级索引维护
- 外键检查
如果插入目标间隙被 Gap Lock 覆盖,INSERT 会等待。
6.6 INSERT … ON DUPLICATE KEY UPDATE
INSERTINTOinventory(product_id,available,version)VALUES(10001,100,0)ONDUPLICATEKEYUPDATEavailable=VALUES(available),version=version+1;它可以减少“先查询、再判断、再插入或更新”的往返,但并不意味着没有锁冲突。命中重复键后会进入更新路径,并对对应记录设置写锁。
是否使用它,应根据业务语义、唯一键设计和更新逻辑决定,而不是为了简单地“少写一条 SQL”。
6.7 NOWAIT 与 SKIP LOCKED
MySQL 8.x 支持锁定读选项:
SELECT*FROMtask_queueWHEREstatus='READY'ORDERBYidLIMIT10FORUPDATESKIP LOCKED;SKIP LOCKED不等待已被其他事务锁定的行,适合多消费者任务领取、批处理队列等场景。
SELECT*FROMordersWHEREid=10001FORUPDATENOWAIT;NOWAIT在无法立即获取锁时快速失败,适合由上层明确处理竞争的场景。
注意:
SKIP LOCKED返回的不是完整一致视图- 不适合依赖全量顺序和严格集合一致性的普通业务查询
- 必须有后续状态机、幂等和超时回收机制
七、MDL:最容易被低估的生产级风险
7.1 MDL 解决什么问题
假设一个查询正在读取orders:
SELECTid,statusFROMorders;与此同时,另一个会话删除status列。如果没有元数据锁,查询解析阶段和执行阶段看到的表结构可能不一致。
因此,MySQL 使用 MDL 保护数据库对象定义的一致性。
7.2 普通查询也持有 MDL
会话 A:
STARTTRANSACTION;SELECT*FROMordersWHEREid=10001;-- 故意不提交即使这是普通一致性读,会话 A 仍持有访问orders所需的共享 MDL,直到事务结束。
会话 B:
SETSESSIONlock_wait_timeout=10;ALTERTABLEordersADDCOLUMNremarkVARCHAR(255)NULL,ALGORITHM=INSTANT;会话 B 需要获取相应 MDL。如果会话 A 长时间不提交,DDL 会等待。
7.3 为什么等待中的 DDL 会放大事故
典型情况下,排他 MDL 请求进入队列后,后续对该表的访问可能为了锁优先级和避免排他请求长期饥饿而排队。
于是形成:
长事务 ↓ DDL 等待排他 MDL ↓ 后续 DML / SELECT 排队 ↓ 连接池耗尽 ↓ 服务超时与重试 ↓ 数据库雪崩这也是为什么生产 DDL 的首要目标不是“尽快跑完”,而是:
无法快速获取必要锁时立即失败,不要在业务高峰期排队等待。
7.4 Online DDL 不等于无锁 DDL
ALGORITHM=INSTANT:
- 主要修改数据字典元数据
- 不需要扫描或重建整张表的操作可以非常快
- 仍需要在执行阶段获取必要的 MDL
ALGORITHM=INPLACE:
- 很多操作不需要完整复制表
- 某些操作仍会重建表
- 可以允许并发 DML,也可能受到操作类型限制
- 开始和结束阶段通常仍需要 MDL
LOCK=NONE:
- 表示要求允许并发读写
- 不代表完全不获取任何锁
- 如果操作不支持该锁级别,语句应失败,而不是默默降级
生产建议显式声明能力边界:
SETSESSIONlock_wait_timeout=5;ALTERTABLEordersADDCOLUMNpromo_idBIGINTNULL,ALGORITHM=INSTANT;对于需要INPLACE的操作:
SETSESSIONlock_wait_timeout=5;ALTERTABLEordersADDINDEXidx_status_created_at(status,created_at),ALGORITHM=INPLACE,LOCK=NONE;显式指定算法和锁模式的价值是“能力不满足就失败”,避免变更工具选择比预期更重的执行路径。
八、死锁与锁等待超时:两类问题不能混为一谈
8.1 锁等待
事务 B 需要的锁由事务 A 持有,只要 A 提交或回滚,B 就可以继续。这是普通锁等待。
A 持有 id=10 B 等待 id=10如果等待超过innodb_lock_wait_timeout,B 收到:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction默认情况下,锁等待超时通常回滚当前语句,而不是自动回滚整个事务。应用必须明确执行回滚,避免继续使用处于不确定业务状态的事务。
8.2 死锁
事务 A 持有资源 1,等待资源 2;事务 B 持有资源 2,等待资源 1:
A:持有 id=10,等待 id=20 B:持有 id=20,等待 id=10形成环路后,不可能通过继续等待自行解除。
InnoDB 默认启用死锁检测,并选择一个事务作为牺牲者回滚:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction8.3 为什么“业务代码没错”也要处理死锁
死锁是并发事务系统的正常现象之一。即使每条 SQL 都正确,只要不同事务以不同顺序访问多个资源,就可能产生死锁。
正确策略是:
- 缩短事务
- 统一资源访问顺序
- 减少一次事务涉及的记录数量
- 使用准确索引缩小扫描范围
- 对可安全重试的事务实施有限次数重试
- 重试前保证业务幂等
- 使用随机抖动的退避策略,避免同时重试
8.4 不要无条件关闭死锁检测
innodb_deadlock_detect=ON是默认配置。
在极端热点行竞争下,死锁检测本身可能带来额外 CPU 开销。一些场景会考虑关闭检测并依赖innodb_lock_wait_timeout。
但这不是通用优化项。关闭前必须确认:
- 热点竞争模型确实导致检测开销显著
- 锁等待超时足够短
- 应用能够正确回滚和重试
- 监控能够区分等待超时与其他错误
- 已经过专项压测验证
否则,关闭死锁检测只会让死锁从“快速失败”变成“等待到超时”。
九、MySQL 8.4 锁诊断:生产排障 SQL 手册
9.1 第一步:确认是否存在长事务
SELECTtrx_id,trx_state,trx_started,TIMESTAMPDIFF(SECOND,trx_started,NOW())AStrx_age_seconds,trx_mysql_thread_id