深入 MySQL 的锁:从行锁、间隙锁到度量驱动的排障
Posted on 四 30 7月 2026 in Tech
| Abstract | 深入 MySQL 的锁:从行锁、间隙锁到度量驱动的排障 |
|---|---|
| Authors | Walter Fan |
| Category | tech note |
| Status | v1.0 |
| Updated | 2026-08-03 |
| License | CC-BY-NC-ND 4.0 |
深入 MySQL 的锁:从行锁、间隙锁到度量驱动的排障
先讲个我见过好几遍的场景:一个秒杀活动,库存明明只有 100 件,最后卖出去 106 件。运营找过来,程序员一脸委屈——"我明明查了库存够才扣的啊"。
问题就出在这个“查了才扣”上。两个请求同时读到库存是 1,都觉得“够”,于是都扣了一次,库存变成了 -1。这不是代码写错了,而是代码把一个本来应该原子完成的动作拆成了两步。并发这只看不见的手,正好从两步之间钻了进去。
MySQL 里的锁可以挡住这只手,但锁不是万能创可贴。锁用多了,业务会排队;索引没走对,几行锁会变成大面积阻塞;事务拖得太长,连一条 ALTER TABLE 都可能把整张表的请求拦在门外。
所以这次不只讲“行锁、间隙锁、Next-Key Lock 分别是什么”。我还想把我一直倡导的 Metrics-Driven Development(MDD,度量驱动开发)放进这个主题里:先把“系统应该变好”写成可以验证的指标,再讨论该加什么锁、换什么索引、缩短哪段事务。
一句话先立住:锁不是用来“锁数据”的,是用来“约定顺序”的;而度量是用来检查这个顺序有没有让用户付出不必要的等待。
- 本文默认存储引擎是 InnoDB(MyISAM 只有表锁,早该退休了)。
- 例子基于 MySQL 8.0,默认隔离级别 REPEATABLE READ(RR)。
- 文中的压测数字是“示意数据”,用于说明 MDD 的验收写法,不代表某个真实生产系统的统计结果。
一、先别急着选锁:把问题写成一个 MDD 假设
很多锁问题的排查,一上来就是一句:“数据库是不是锁住了?”这句话太宽了。数据库里有很多等待:锁等待、磁盘 I/O、线程池等待、连接池等待,甚至是应用自己在等一个 RPC。只看一张 CPU 图,很容易把所有病都诊断成“CPU 不够”。
MDD 的第一步,是先写清楚我们要验证的假设。
1.1 一个可验证的假设
以库存扣减为例,假设不要写成“优化锁性能”,而应该写成:
假设 H1:把“先查询库存、再更新库存”改成带条件的原子更新,并把事务里的非数据库操作移出去,可以在不发生超卖的前提下,降低锁等待和订单接口 P99 延迟。
这个假设里至少有三层验收标准:
- 业务正确性:超卖数必须为 0,库存不能变成负数。
- 用户体验:订单接口成功率和 P99 延迟不能变差。
- 资源健康度:锁等待、死锁、连接池排队不能因为优化一个指标而恶化。
如果只盯着“死锁少了”,却发现订单接口 P99 从 300ms 变成 3s,这不叫改进,只是把问题搬了家。
1.2 锁问题应该看哪些指标
我通常把指标分成四层。越靠上越接近用户,越靠下越接近原因;四层要串起来看,不能只挑一层顺眼的。
| 层次 | 指标例子 | 它回答的问题 |
|---|---|---|
| 业务结果 | inventory_oversell_total、订单成功率、售罄判断正确率 |
用户有没有拿到错误结果? |
| 服务表现 | 请求速率、错误率、接口 P50/P95/P99、重试率 | 用户有没有变慢、失败或反复提交? |
| 数据库资源 | 当前锁等待数、锁等待次数、锁等待时间、死锁次数、事务持续时间、连接池排队 | 数据库有没有在排队?排队是否已经饱和? |
| 排障证据 | 阻塞事务、等待事务、表名、索引、SQL digest、事务开始时间 | 谁挡住了谁,具体挡在哪里? |
应用侧至少建议有下面这些指标。名称可以按团队规范调整,关键是语义稳定、标签数量可控:
db_transaction_duration_seconds{service, operation}
db_lock_wait_seconds{service, operation, table}
db_deadlock_total{service, operation}
db_lock_wait_timeout_total{service, operation}
db_pool_wait_seconds{service, pool}
inventory_update_conflict_total{product_class}
inventory_oversell_total{product_class}
这些指标有三个注意点:
- 延迟用 Histogram,关注 P95/P99,不要只看平均值。平均值很擅长把一次 10 秒的等待藏进一堆 10 毫秒里。
- 不要把
order_id、用户 ID、完整 SQL、锁持有者 token 直接放进 metric label。它们会制造高基数,甚至把敏感数据带进监控系统。用操作名、表名、归一化后的 query fingerprint 做有限维度。 - 数据库状态指标是总量或瞬时状态,最好和应用请求指标、部署事件、流量变化一起看。单独看
Innodb_row_lock_time的累计值,无法判断这一分钟是不是突然恶化。
1.3 给指标一个“可以行动”的阈值
没有阈值的指标,最后往往只是仪表盘上的装饰。下面是一组示意性的起始目标,真实值要根据基线、业务 SLO 和容量测试校准:
| 指标 | 示例目标 | 示例告警条件 | 告警后先做什么 |
|---|---|---|---|
| 超卖数 | 0 | > 0 立即告警 |
保护业务、冻结继续扣减,再查写路径 |
| 库存更新冲突率 | 低于 0.1% | 5 分钟持续高于基线 3 倍 | 判断热点商品还是查询逻辑退化 |
| 锁等待 P95 | < 20ms |
> 50ms 持续 5 分钟 |
查等待表、阻塞事务和事务时长 |
| 死锁 / 事务数 | < 0.1% |
高于基线 3 倍 | 对照死锁样本检查加锁顺序 |
| 锁等待超时 | 0 或接近 0 | 出现即告警 | 查长事务、MDL 和连接池堆积 |
| 订单接口 P99 | 例如 < 300ms |
超过业务 SLO | 先保护用户体验,再区分锁和非锁等待 |
“50ms”不是宇宙真理。一个后台批处理可能接受几秒,一个同步下单接口可能连 50ms 都嫌长。阈值的正确来源,是上线前压测和一段稳定期的基线,而不是从博客里抄一个数字。
例如,应用已经把锁等待记录成 Histogram,就可以把“锁等待 P95 连续超过 50ms”变成一个可执行的告警。下面只是 Prometheus 告警规则的示意,metric name、label 和阈值要按实际埋点校准:
groups:
- name: mysql-locks
rules:
- alert: InventoryLockWaitP95High
expr: |
histogram_quantile(
0.95,
sum by (le) (
rate(db_lock_wait_seconds_bucket{
service="order",
operation="inventory_decrement"
}[5m])
)
) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "库存扣减锁等待 P95 超过 50ms"
runbook: "mysql-locks"
告警只是入口,不是结论。收到告警后,先看业务 P99 和错误率,再用 sys.innodb_lock_waits 找阻塞者,最后把 SQL、索引和事务边界的改动回填到下一轮验收。
1.4 MDD 的闭环
把上面的步骤画出来,就是一条比“出问题—拍脑袋—改 SQL”稍微可靠的链路:
flowchart LR
H[业务假设] --> M[定义业务 / 服务 / DB 指标]
M --> B[建立基线与告警阈值]
B --> O[上线或压测]
O --> D{指标是否偏离?}
D -->|否| K[固化看板与验收结果]
D -->|是| L[锁等待 / SQL / 事务定位]
L --> F[改索引、缩短事务、统一加锁顺序]
F --> V[同场景回归验证]
V -->|达标| K
V -->|未达标| H
这也是我在《微服务之道:度量驱动开发》里反复强调的 Metrics-Driven Development:度量不是上线以后才补的监控,而是设计阶段就写进“什么算做对”的验收条件。
二、为什么需要锁:并发下的四种“翻车”
在讲锁之前,得先知道锁是来解决什么麻烦的。多个事务同时读写同一份数据,可能出现四类问题:
| 问题 | 通俗解释 | 举例 |
|---|---|---|
| 脏读(Dirty Read) | 读到了别人还没提交的数据 | A 改了余额还没提交,B 读到了改后的值,结果 A 回滚了 |
| 不可重复读(Non-repeatable Read) | 同一行,两次读结果不一样 | B 在同一个事务里读两次某人余额,中间 A 改了并提交 |
| 幻读(Phantom Read) | 同样的查询条件,两次读出的"行数"变了 | B 查"余额>100 的用户"有 3 个,A 插入一个新用户,B 再查变 4 个 |
| 丢失更新(Lost Update) | 两个人同时改,后写的把先写的覆盖了 | 开头那个超卖,就是典型的丢失更新 |
SQL 标准用隔离级别来划定"你能容忍哪几种问题",从松到紧是:读未提交(RU)、读已提交(RC)、可重复读(RR)、串行化(Serializable)。级别越高越安全,但并发越差——这本身就是一组 trade-off。
而 InnoDB 实现这些隔离级别,靠的是两样东西:MVCC(多版本并发控制) 和 锁。
- MVCC 解决的是"读不阻塞写、写不阻塞读"。普通的
SELECT(快照读)读的是某个时间点的历史版本,不加锁,靠 undo log 里的多版本快照。 - 锁 解决的是"写与写"、以及"当前读"之间的互斥。所谓当前读,就是
UPDATE、DELETE、INSERT,以及显式加锁的SELECT ... FOR UPDATE/SELECT ... FOR SHARE——它们必须读最新版本,还得把它锁住。
快照读靠 MVCC,当前读靠锁。 你平时写的
SELECT大多是快照读,感觉不到锁;一旦带上FOR UPDATE或者做增删改,锁就登场了。
这篇文章聊的,主要就是"当前读"背后那套锁机制。
三、按粒度看:全局锁、表级锁、行锁
MySQL 的锁先按"锁多大范围"分成三层。范围越大,并发越差,但管理越省事。
flowchart TD
A["MySQL 锁 · 按粒度"] --> B["全局锁<br/>整个实例"]
A --> C["表级锁<br/>一张表"]
A --> D["行级锁<br/>InnoDB 特有"]
C --> C1["表锁 LOCK TABLES"]
C --> C2["元数据锁 MDL"]
C --> C3["意向锁 IS / IX"]
D --> D1["记录锁 Record Lock"]
D --> D2["间隙锁 Gap Lock"]
D --> D3["临键锁 Next-Key Lock"]
D --> D4["插入意向锁 Insert Intention"]
3.1 全局锁:给整个库拍张一致的照片
全局锁的命令是 FLUSH TABLES WITH READ LOCK(简称 FTWRL)。执行后整个实例进入只读状态,所有增删改和建表改表都会被堵住。
它的主要用途是做逻辑备份——你要给全库拍一张时间点一致的快照,不能备着备着数据还在变。不过对 InnoDB 来说,mysqldump --single-transaction 用一个可重复读的事务就能拿到一致快照,不需要全局锁,业务照常读写。所以真正需要 FTWRL 的,通常是引擎不支持事务、或者要做全库级别的一致性动作时。
记住一点:全局锁一上,整个库只读,是个"核弹级"操作,生产上别乱按。
3.2 表级锁:显式表锁与你可能没意识到的 MDL
表级锁有两类,一类你主动加,一类系统偷偷帮你加。
显式表锁 LOCK TABLES ... READ/WRITE。这是 MyISAM 时代的遗产,InnoDB 有了行锁之后基本不用它,除非有特殊需求。
元数据锁(MDL, Metadata Lock) 才是真正天天在起作用、又最容易被忽略的那个。它不用你手动加:
- 你对表做增删改查(DML)时,自动加 MDL 读锁(共享);
- 你对表做改结构(DDL,比如
ALTER TABLE)时,自动加 MDL 写锁(排他)。
读锁之间兼容,读写锁互斥。这就埋了一个经典的线上坑:
一个长事务在慢慢查表(持有 MDL 读锁不放),这时你去
ALTER TABLE加个字段(要 MDL 写锁),DDL 被卡住;更要命的是,DDL 在等待时会排在后面所有新查询的前面,于是这张表后续所有SELECT全被堵死——一次"简单的加字段"能把业务打挂。
这个坑我提醒同事无数次:改表结构前,先去 information_schema 或 performance_schema 查一下有没有针对这张表的长事务,确认没有再动手,或者用支持 online DDL / 低峰期操作。
意向锁(Intention Lock) 是表级锁里最"程序员向"的一个。InnoDB 在给某几行加行锁之前,会先在表级别打一个意向标记:
- 打算加行级共享锁 → 先加表级 意向共享锁 IS;
- 打算加行级排他锁 → 先加表级 意向排他锁 IX。
它的作用是提高效率:当有人想加表级锁时,不用一行行去检查"是不是已经有行被锁了",只要看一眼表上有没有 IS/IX 就知道。意向锁之间互相兼容,你基本不用管它,知道有这么个东西就行。
3.3 行锁:InnoDB 的看家本领
行锁只锁涉及的那几行,并发最好,是 InnoDB 相比 MyISAM 最大的优势。但行锁有个极其关键、又极其容易踩的前提:
InnoDB 的行锁是加在索引上的,不是加在数据行上的。
这句话的含义是:如果你的 UPDATE ... WHERE 条件用不上索引,InnoDB 就只能扫描大量索引记录,并对扫描到的记录加锁。它在内部不一定真的变成一把“表锁”,但对业务的效果可能已经像整张表被锁住:其他请求全部卡死排队,监控上就是数据库连接数瞬间打满。我见过一次故障就是这么来的。
所以第一条实操铁律:凡是当前读(UPDATE / DELETE / SELECT ... FOR UPDATE),WHERE 条件一定要走索引,而且最好命中唯一索引或主键。
这条铁律要用 EXPLAIN 验证,不能靠“我记得建过索引”:
EXPLAIN
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 100 AND stock > 0;
重点看访问类型、实际选择的索引和估算扫描行数。MySQL 8.0 还可以在测试环境用 EXPLAIN ANALYZE 对照实际执行情况,但不要在生产高峰期为了看计划随意重复执行有副作用的 DML。
四、行锁的模式:共享锁 S 与排他锁 X
行锁按"能不能共存"分两种模式:
- 共享锁(S, Shared):读锁。多个事务可以同时持有同一行的 S 锁——大家一起读,互不干扰。加法:
SELECT ... FOR SHARE(老语法LOCK IN SHARE MODE)。 - 排他锁(X, Exclusive):写锁。同一行只能有一个事务持有 X 锁,且与 S 锁互斥。
UPDATE/DELETE自动加 X 锁,或显式SELECT ... FOR UPDATE。
兼容性矩阵,一张表说清楚(Y=兼容,N=冲突):
| 持有 \ 请求 | S | X |
|---|---|---|
| S | Y | N |
| X | N | N |
一句话:读读兼容,读写、写写都互斥。
-- 事务 A:给这一行加排他锁,别人改不了也别想 FOR UPDATE
BEGIN;
SELECT * FROM account WHERE id = 1001 FOR UPDATE;
-- ... 基于查出来的余额做业务判断 ...
UPDATE account SET balance = balance - 100 WHERE id = 1001;
COMMIT; -- 锁在这里释放
注意锁是在事务提交(或回滚)时才释放的,不是语句执行完就放。所以事务要尽量短——持锁时间越长,别人排队越久。
五、间隙锁与 Next-Key Lock:InnoDB 怎么防幻读
这是 MySQL 锁里最绕、也最能体现 InnoDB 功力的部分。很多人以为 RR 隔离级别下还会有幻读,其实 InnoDB 在 RR 下用 MVCC + 锁把幻读基本消灭了,靠的就是间隙锁。
InnoDB 的行锁其实有三种"形态":
- 记录锁(Record Lock):锁住某一条已存在的索引记录。比如
WHERE id = 5且 id=5 存在,就锁这一行。 - 间隙锁(Gap Lock):锁住一段开区间,即两条记录之间的"空隙",不含记录本身。作用是不让别人往这个空隙里插入新行——这正是防幻读的关键。
- 临键锁(Next-Key Lock):= 记录锁 + 它前面的间隙锁,是一个左开右闭的区间。这是 InnoDB 在 RR 下加锁的默认形态。
举个具体的。假设 t 表 id 列上有值 5, 10, 20,那么这些 Next-Key Lock 区间大致是:
(-∞, 5] (5, 10] (10, 20] (20, +∞)
当你执行 SELECT * FROM t WHERE id BETWEEN 8 AND 15 FOR UPDATE,InnoDB 会锁住覆盖到 8~15 的那些区间,于是别人没法在这个范围里插入 id=12 的新行——再查一次,行数不会变,幻读消失。
一个非常容易忽略的坑:等值查询"不存在的值"
-- id 上只有 5, 10, 20,下面这条查的是不存在的 7
SELECT * FROM t WHERE id = 7 FOR UPDATE;
你以为什么都没锁到(因为 7 不存在)?错。InnoDB 会加一个 (5, 10) 的间隙锁,把 5 和 10 之间的空隙锁住。结果就是别人想插入 id=6、id=8 都会被阻塞。这是很多"莫名其妙的插入被卡住"问题的根源。
RR 和 RC 在这里的关键区别
- RR(可重复读):默认用 Next-Key Lock,有间隙锁,防幻读,但并发相对差,且更容易死锁。
- RC(读已提交):基本关闭了间隙锁(只在外键检查、唯一键冲突检查等少数场景保留),只加记录锁。并发更好、死锁更少,但需要业务自己承担可能的幻读。
有些系统会选择 RC 来减少间隙锁带来的冲突,代价是把更多一致性责任交给业务代码,比如用唯一索引和状态机兜底。这是一个典型的“用一致性语义换并发空间”的工程决策,没有绝对对错,必须看业务能不能接受,并用指标验证死锁、幻读和接口延迟是否真的改善。
六、悲观锁 vs 乐观锁:两种并发思路
上面讲的都是数据库层面的机制,到了业务层,控制并发通常归为两种思路。名字听着玄,其实就是两种人生态度。
悲观锁(Pessimistic Lock)——"我先假设一定会有人跟我抢,那我先把门锁上再干活"。对应到 MySQL,就是 SELECT ... FOR UPDATE:
BEGIN;
-- 先锁住这行,别人这时想 FOR UPDATE 只能排队等
SELECT stock FROM product WHERE id = 100 FOR UPDATE;
-- 拿到锁了,安心判断和扣减
UPDATE product SET stock = stock - 1 WHERE id = 100 AND stock > 0;
COMMIT;
悲观锁的好处是逻辑直观、强一致;坏处是持锁期间别人干等着,并发上不去,还容易死锁。适合冲突频繁、且业务处理较快的场景。
乐观锁(Optimistic Lock)——"我赌大概率没人跟我抢,先干,提交时再检查有没有被人动过;被动过了我就重来"。它不依赖数据库的锁,而是在表里加一个 version(版本号)或用 CAS(Compare-And-Swap)思路:
-- 1. 先读出当前版本
SELECT stock, version FROM product WHERE id = 100;
-- 假设读到 stock=10, version=7
-- 2. 更新时把 version 当条件,并让它 +1
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 7;
-- 3. 看影响行数:affected_rows = 1 成功;= 0 说明期间被人改过,需要重试
乐观锁的好处是不加锁、并发高;坏处是冲突高时会疯狂重试,反而更慢。适合冲突较少、读多写少的场景。
一句话对比:
悲观锁是"先排队再办事",乐观锁是"先办事出错再重来"。 冲突多用悲观,冲突少用乐观。别把秒杀这种高冲突场景交给乐观锁去反复重试。
七、死锁:怎么产生、怎么排查、怎么避免
死锁就是两个事务互相等对方手里的锁,谁都不肯放,僵住了。经典剧本:
事务 A: 锁住行 1 → 想要行 2(等待 B 释放)
事务 B: 锁住行 2 → 想要行 1(等待 A 释放)
→ 互相等待,成环,死锁
好在 InnoDB 有死锁自动检测(innodb_deadlock_detect 默认开启)。它一旦发现等待成环,会挑一个"代价较小"的事务直接回滚,让另一个继续。所以你在应用里经常看到的是 Deadlock found when trying to get lock; try restarting transaction 这个错误——这意味着你的业务代码必须能捕获它并重试,而不是当成致命错误。
排查死锁的第一手工具:
SHOW ENGINE INNODB STATUS\G
-- 看 "LATEST DETECTED DEADLOCK" 段落,
-- 它会告诉你两个事务分别持有什么锁、在等什么锁、卡在哪条 SQL
还有一个容易和死锁混淆的错误:Lock wait timeout exceeded。这不是死锁,是等锁等太久超时了(由 innodb_lock_wait_timeout 控制,默认值通常是 50 秒,但当前配置可能已经被调整)。死锁是“成环、很快回滚一方”;锁等待超时是“没成环、但前面的事务迟迟不放锁”。区分这两个,排查方向完全不同。
避免死锁的几条实战经验:
- 按固定顺序访问资源。多个事务如果都按"先动小 id 再动大 id"的顺序加锁,就不会成环。死锁多半是加锁顺序不一致造成的。
- 事务尽量短小。一进事务就干活、干完立刻提交,别在事务里穿插 RPC 调用、发消息、等用户输入这类慢操作。
- 让 WHERE 走索引、走唯一索引。索引不精确会锁更多行、加更多间隙锁,冲突面变大。
- 必要时降到 RC 隔离级别,减少间隙锁。
- 一次锁定所需的全部行,别"锁一点、算一点、再锁一点"。
八、用指标把“数据库卡住了”定位成证据链
这一节是全文的重点。指标不是为了让仪表盘更热闹,而是为了缩短从“用户说慢”到“知道该改哪一行代码”的距离。
8.1 先看业务,再看服务,最后钻数据库
一个实用的排查顺序是:
业务正确性 / 成功率
↓
接口 P99、错误率、重试率
↓
事务耗时、锁等待、连接池排队
↓
等待者、阻塞者、表、索引、SQL digest
↓
改代码 / 改索引 / 改事务边界 / 改加锁顺序
为什么不一上来就看 MySQL?因为用户感受到的是“下单失败”或“页面转圈”,不是 Innodb_row_lock_time。如果业务指标正常、锁等待也正常,就不要为了证明自己懂数据库而给一条无辜的 SQL 加 FOR UPDATE。
8.2 MySQL 全局锁等待指标:先看趋势
MySQL 提供了一组 InnoDB 行锁状态变量,可以先用下面的命令观察当前实例的累计值和瞬时等待数:
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';
常见字段包括:
| 状态变量 | 含义 | 适合怎么用 |
|---|---|---|
Innodb_row_lock_current_waits |
当前正在等待行锁的数量 | 看瞬时饱和,适合做 Gauge |
Innodb_row_lock_waits |
发生过等待的次数 | 采集为 Counter,看变化率 |
Innodb_row_lock_time |
累计行锁等待时间 | 看一段时间内的增量,不看绝对值 |
Innodb_row_lock_time_max |
观测到的最大行锁等待时间 | 辅助发现尖刺,需结合采集周期 |
不同 exporter 对这些变量的命名和单位可能不同。接入 Prometheus 后,先在测试环境确认 metric name、单位和 reset 行为,再写告警表达式。不要把一个“看起来像秒”的字段直接当秒用。
8.3 找出“谁在等谁”:sys schema
如果安装了 MySQL sys schema,可以先看等待时间最长的 InnoDB 锁请求:
SELECT
wait_started,
wait_age_secs,
locked_table_schema,
locked_table_name,
waiting_pid,
waiting_query,
blocking_pid,
blocking_query
FROM sys.innodb_lock_waits
ORDER BY wait_age_secs DESC;
这张视图把等待者、阻塞者、表和 SQL 摆在一起,比只看连接数更接近问题本身。生产环境执行前要确认账号权限,并对查询结果里的 SQL、业务参数和连接信息做脱敏。
长事务是锁问题的常见放大器,可以先查事务年龄:
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_age_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
如果怀疑是 MDL,而不是 InnoDB 行锁,可以在 sys schema 中检查表级元数据锁等待:
SELECT *
FROM sys.schema_table_lock_waits
ORDER BY waiting_query_secs DESC;
MySQL 8.0 还可以通过 performance_schema.data_locks 和 performance_schema.data_lock_waits 查看数据锁的等待关系。它们表达的是“请求中的锁被哪一把已持有的锁阻塞”,比旧版本的 INFORMATION_SCHEMA.INNODB_LOCK_WAITS 更适合 8.0 的排查路径。
8.4 用症状缩小范围
下面这张表是我更愿意放进值班手册的东西。它不直接给出答案,但能避免大家凭感觉乱改:
| 看到的组合 | 更可能的解释 | 下一步 |
|---|---|---|
| 接口 P99 上升、当前锁等待上升、事务时长上升 | 热点行或事务持锁时间过长 | 查 sys.innodb_lock_waits 和事务开始时间 |
| 接口 P99 上升、锁等待正常、CPU 或 I/O 上升 | 根因可能不是锁 | 查执行计划、慢查询、资源饱和 |
| 死锁率上升、锁等待不一定很高 | 多事务加锁顺序不一致 | 对照死锁样本,画等待关系 |
| 锁等待上升、阻塞者事务年龄很长 | 长事务或忘记提交 | 查事务边界、连接复用和异常路径 |
| DDL 等待、同表新查询也排队 | MDL 写锁排队 | 查长查询和 schema_table_lock_waits |
| 连接池等待上升、MySQL 当前锁等待低 | 连接池容量或慢查询问题 | 先看连接池和 SQL 延迟,不要先加锁 |
真正有价值的告警,不只告诉你“锁等待 > 50ms”,还应该带上 runbook:先看哪个 dashboard,执行哪条只读 SQL,什么情况下升级,以及哪些操作需要变更审批。
九、一个完整例子:用指标验证库存扣减优化
前面提到的超卖,下面把它展开成一组可以压测和验收的例子。
9.1 先看错误实现
库存表:
CREATE TABLE inventory (
product_id BIGINT PRIMARY KEY,
stock INT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
CHECK (stock >= 0)
) ENGINE = InnoDB;
错误实现把判断拆成了两个不受保护的步骤:
事务 A:SELECT stock,读到 1
事务 B:SELECT stock,读到 1
事务 A:UPDATE stock = stock - 1,变成 0
事务 B:UPDATE stock = stock - 1,变成 -1
如果两个请求都在普通快照读里读到库存足够,再用 stock = stock - 1 更新,数据库会串行执行更新,但它不会替业务重新判断“更新前是否还有库存”。结果可能是库存负数,或者订单已经创建但库存扣减状态不一致。
9.2 先做最小、可验证的改动
如果业务允许“扣减成功或售罄”这两个结果,最小改动通常是把判断放进同一条更新语句:
UPDATE inventory
SET stock = stock - 1
WHERE product_id = ?
AND stock > 0;
应用只需要检查 affected_rows:
1:扣减成功;0:商品不存在或已经售罄,需要根据业务区分处理。
这条语句把“判断库存”和“扣减库存”放进了同一个原子更新里,且按主键定位。它不代表所有订单流程都结束了:如果还要创建订单、写 outbox 事件,仍然需要设计好事务边界和失败补偿。
如果一次订单包含多个商品,还要按固定顺序更新多个 product_id,例如先按数值升序排序,避免请求 A 按 1、2 加锁,请求 B 按 2、1 加锁。
9.3 改动前后到底看什么
不要只跑一遍 SQL、看它返回成功,就宣布“优化完成”。至少在同样的流量模型和数据分布下记录这些结果:
| 指标 | 优化前(示意) | 优化后(示意) | 是否达成假设 |
|---|---|---|---|
inventory_oversell_total |
6 | 0 | 是,正确性守住 |
| 扣减接口 P99 | 2.8s | 360ms | 是,用户体验改善 |
| 锁等待 P95 | 180ms | 18ms | 是,等待下降 |
| 死锁 / 事务数 | 0.7% | 0.08% | 是,低于示例目标 |
| 事务平均时长 | 420ms | 75ms | 是,事务边界更短 |
| 连接池排队 P95 | 600ms | 20ms | 是,数据库拥塞缓解 |
这张表里的数字不是重点,重点是“同一组指标、同一个场景、前后可比较”。如果改成原子更新后超卖归零,但热点商品的锁等待仍然很高,就说明正确性问题解决了,容量问题还没有解决。下一轮可以评估按商品分片库存、排队串行化热点写入、预扣减或削峰,但每个方案都要重新写假设和护栏指标。
9.4 一个指标异常的反例
假设改完以后死锁从 0.7% 降到 0.05%,但 db_transaction_retry_total 仍然很高,接口 P99 没降反升。可能发生了什么?
一种可能是:死锁没有了,但每个请求仍然在事务里调用库存服务、支付服务和消息系统,锁虽然只在最后一段竞争,事务却一直持有到所有远程调用结束。此时应该看 trx_age_seconds 和 db_transaction_duration_seconds,把事务边界缩短,而不是继续调大 innodb_lock_wait_timeout。
这就是指标之间要互相解释的原因:单指标变好,只能说明一个局部现象;多个层次的指标同时朝目标变化,才更像真正的改进。
十、分布式应用怎么用 MySQL 的锁
前面全是"单库内部"的事。到了分布式系统——多个服务实例、多个进程,甚至跨机房——它们抢的是同一份逻辑资源(比如同一个订单、同一个库存),进程内的锁(Java 的 synchronized、Go 的 Mutex)就管不着了,因为它们各在各的进程里。这时候需要一把所有实例都认的锁,也就是分布式锁。
MySQL 本身就是所有实例共享的,所以它天然可以当这把"公共的锁"。常见有四种玩法,从简单到复杂。
10.1 唯一索引:最朴素的分布式锁 / 幂等
利用唯一索引不允许重复这个特性:谁能成功插入这行,谁就抢到了锁。
CREATE TABLE distributed_lock (
lock_key VARCHAR(128) NOT NULL,
holder VARCHAR(128) NOT NULL, -- 一次获取生成的不可猜测 token
expire_at DATETIME NOT NULL, -- 租约过期时间
PRIMARY KEY (lock_key)
);
-- 抢锁:插入成功 = 抢到;报主键冲突 = 没抢到
INSERT INTO distributed_lock (lock_key, holder, expire_at)
VALUES (?, ?, UTC_TIMESTAMP() + INTERVAL 30 SECOND);
-- 释放锁:务必校验 holder,别删了别人的锁
DELETE FROM distributed_lock WHERE lock_key = ? AND holder = ?;
这套最大的价值其实是幂等:同一个订单号只能插入一次,天然防重复下单。它的缺点也很明确:过期接管、续期、清理、时钟偏差和持有者失联都要自己设计。不能简单地发现过期就无条件删除,因为旧持有者可能只是网络抖动,恢复后仍然会继续执行临界区。
关键操作应该是带条件的更新,并且要考虑 fencing token(栅栏令牌):每次成功获取锁都产生递增令牌,下游写入时拒绝旧令牌。这样即使旧客户端的租约已经过期,它的迟到写入也不能覆盖新持有者的结果。
10.2 SELECT ... FOR UPDATE:悲观分布式锁
在一个事务里 FOR UPDATE 住某一行,其他实例的同样操作就会阻塞,直到你提交事务。
BEGIN;
-- 所有实例都来抢这一行,只有一个能拿到,其余排队
SELECT * FROM distributed_lock WHERE lock_key = 'order:1001' FOR UPDATE;
-- 临界区:这里的逻辑同一时刻只有一个实例在跑
-- ... 处理订单 ...
COMMIT; -- 释放锁,下一个实例进来
好处是靠数据库的事务机制,锁会随事务结束自动释放,进程崩了连接断了事务回滚,锁也就没了,不会像 10.1 那样锁死。缺点是要占着一个数据库连接和事务,持锁期间别人真的在数据库层排队,高并发下对 DB 压力大。适合并发不高、但要求强一致的场景。
10.3 乐观锁 version:把重试交给业务
就是第六节讲的乐观锁,跨进程一样成立——因为 version 存在共享的 MySQL 里。它不“占住”任何锁,靠 WHERE version = ? + 检查影响行数来判断有没有冲突,适合冲突不激烈的分布式更新。
但它必须配套观察 conflict_total、重试次数和最终失败率。如果冲突率已经很高,继续增加重试次数只是把数据库压力乘上去。
10.4 GET_LOCK:MySQL 自带的命名锁(很多人不知道)
MySQL 有一对内置函数 GET_LOCK(name, timeout) / RELEASE_LOCK(name),专门用来做用户级命名锁,天生适合当分布式锁:
-- 尝试获取名为 'order:1001' 的锁,最多等 10 秒
SELECT GET_LOCK('order:1001', 10);
-- 返回 1 = 拿到锁;0 = 超时没拿到;NULL = 出错
-- ... 干活 ...
SELECT RELEASE_LOCK('order:1001'); -- 主动释放
它有几个很香的特性:
- 锁和会话(连接)绑定:连接断开时,MySQL 会自动释放这个会话持有的所有命名锁。这直接解决了 10.1 里“进程崩了锁不释放”的痛点。
- 不占业务表、不开显式事务,比
FOR UPDATE轻。
代价是:它是单实例语义。如果你的 MySQL 是主从架构、锁只在主库上,主库一挂就有问题;分库分表后不同库之间的 GET_LOCK 也互不相识。所以它适合中小规模、单一主库的场景,规模再大就得上专门的方案。
10.5 分布式锁本身也要度量
分布式锁的看板不能只有“获取成功次数”,至少还要看:
| 指标 | 用途 |
|---|---|
distributed_lock_acquire_total{result} |
获取成功、冲突、超时的比例 |
distributed_lock_wait_seconds |
竞争者等锁多久 |
distributed_lock_held_seconds |
临界区是否越来越长 |
distributed_lock_expired_total |
租约是否频繁自然过期 |
distributed_lock_renew_failed_total |
续期链路是否不稳定 |
distributed_lock_owner_mismatch_total |
是否出现错误释放或旧持有者操作 |
| 业务重复执行数、幂等冲突数 | 锁失效后业务有没有兜底 |
不要记录完整 holder token 到普通日志;记录哈希、短 ID 或 trace ID 即可。锁的监控也要遵守最小数据暴露原则。
10.6 MySQL 分布式锁 vs Redis vs ZooKeeper
说到这里,得诚实地讲一句:MySQL 做分布式锁,能用,但通常不是首选。
flowchart LR
subgraph MySQL
M1["优点: 现成、强一致、有事务兜底"]
M2["缺点: 性能一般、连接是瓶颈、主从有单点"]
end
subgraph Redis
R1["优点: 快、SETNX+租约简单"]
R2["缺点: 主从切换和租约语义需谨慎"]
end
subgraph ZooKeeper
Z1["优点: 临时节点+Watch, 天然处理宕机"]
Z2["缺点: 运维重、写性能一般"]
end
简单选型建议:
- 已经有 MySQL、并发不高、又不想引入新组件 → 用 MySQL(
GET_LOCK或FOR UPDATE),够用且省事。 - 高并发、锁竞争激烈 → 可以评估 Redis,但要把租约、续期、主从切换、fencing 和锁外幂等一起设计,不能只写一句
SET NX。 - 需要租约、临时节点、watch 或共识型协调能力 → 评估 ZooKeeper / etcd,接受更重的运维和更高的理解成本。
不管用哪个,分布式锁有几条通用的命门,务必记牢:
- 一定要有过期时间或自动释放,否则持锁者一挂就可能死锁。
- 释放锁要校验持有者身份(谁加的谁才能解),否则你可能解掉别人刚拿到的锁。
- 租约要覆盖业务执行时间,并有续期和失效处理。租约过期后,旧持有者必须停止写入。
- 考虑 fencing token,避免网络延迟导致旧客户端“死而复生”。
- 想清楚锁丢了会怎样。关键业务要在锁之外加唯一索引、状态机、幂等键或版本校验,而不是把全部身家压在锁上。
分布式锁不是保险箱,是限流闸。 它降低冲突概率,但不替你保证正确性。真正的正确性,来自幂等设计和数据库约束。
十一、要点回收与实操清单
一路讲下来,把最该记住的收一收:
- 快照读靠 MVCC(不加锁),当前读靠锁。你平时的
SELECT大多不加锁,UPDATE/DELETE/FOR UPDATE才加。 - 锁按粒度分:全局锁(备份,核弹级慎用)、表锁 / MDL(改表结构小心长事务把表查死)、行锁(InnoDB 看家本领,但锁在索引记录上,WHERE 不走索引会造成大面积锁竞争)。
- 行锁模式:S 读锁共享,X 写锁排他,读写互斥。
- 间隙锁 / Next-Key Lock 是 RR 下防范围幻读的重要机制,代价是范围操作可能有更多冲突;RC 通常减少间隙锁,但必须重新验证业务一致性。
- 悲观锁先排队、乐观锁先干活出错重来;高冲突用悲观,低冲突用乐观。
- 死锁会自动检测并回滚一方,业务代码必须能捕获并重试;
SHOW ENGINE INNODB STATUS看现场。 - 分布式锁:MySQL 能做(唯一索引 /
FOR UPDATE/ 乐观锁 /GET_LOCK),但高并发场景要评估专用协调组件;租约、校验持有者、fencing、锁外幂等一个都不能少。 - MDD 的闭环是:提出假设 → 定义指标 → 建立基线 → 发现偏差 → 用证据定位 → 做最小改动 → 用同一组指标验证。
上线前自查清单(可直接抄):
- [ ] 所有
UPDATE/DELETE/FOR UPDATE的 WHERE 都走索引了吗?最好命中唯一索引/主键。 - [ ] 事务里有没有夹带 RPC、发消息、等用户输入这类慢操作?挪出去。
- [ ] 多事务访问多行时,加锁顺序统一了吗?(防死锁)
- [ ] 捕获了
Deadlock found并做了重试吗?重试有上限和退避吗? - [ ] 改表结构前,确认过没有针对该表的长事务吗?(防 MDL 把表查死)
- [ ] 用了分布式锁的话:有租约、持有者校验、fencing 或等价的旧写保护吗?锁之外有幂等兜底吗?
- [ ] 隔离级别是什么?如果是 RR,评估过间隙锁带来的死锁风险吗?如果是 RC,业务自己防了幻读吗?
最后一句
写了这么多年后端,我越来越觉得,锁的本质不是“技术”,是“协作”——一群并发的事务,怎么在不打架的前提下把事办完。
但协作不能只靠感觉。以前我们说“这条 SQL 优化后应该会快”,现在我更愿意追问一句:快多少?哪个用户指标会变好?锁等待是否下降?改完以后能不能用同一组数据证明?
理解了这一层,你写的每一条 SQL、设计的每一把分布式锁,才不只是“能跑”,而是知道它在什么条件下可靠、什么时候开始排队,以及下一步该拿什么证据继续改。
全文思维导图
@startmindmap
<style>
mindmapDiagram {
node {
BackgroundColor #F8F9FA
RoundCorner 10
Padding 10
FontSize 13
}
:depth(0) {
BackgroundColor #1E3A5F
FontColor white
FontSize 18
FontStyle bold
}
:depth(1) {
FontSize 15
FontStyle bold
}
:depth(2) {
FontSize 13
}
}
</style>
* MySQL 的锁
** 为什么要锁
*** 脏读 / 不可重复读 / 幻读 / 丢失更新
*** 隔离级别: RU/RC/RR/Serializable
*** 快照读靠 MVCC, 当前读靠锁
** MDD 度量驱动排障
*** 业务正确性: 超卖 / 重复订单
*** 服务表现: 错误率 / P99 / 重试率
*** 数据库健康: 锁等待 / 死锁 / 事务时长 / 连接池
*** 假设 → 基线 → 发现 → 定位 → 改进 → 验证
** 按粒度
*** 全局锁 FTWRL (备份, 慎用)
*** 表级锁
**** MDL 元数据锁 (DDL 卡表)
**** 意向锁 IS/IX
*** 行锁 (加在索引上!)
** 行锁模式
*** S 共享锁 (读)
*** X 排他锁 (写)
*** 读读兼容, 读写/写写互斥
** 间隙 & 幻读
*** Record Lock 记录锁
*** Gap Lock 间隙锁
*** Next-Key Lock 临键锁
*** RR 有间隙锁, RC 基本关闭
** 悲观 vs 乐观
*** 悲观: FOR UPDATE, 先排队
*** 乐观: version/CAS, 先干活重试
** 死锁
*** 自动检测 + 回滚一方
*** 业务必须捕获并重试整个事务
*** SHOW ENGINE INNODB STATUS
*** 固定加锁顺序 + 短事务
** 度量排查
*** SHOW GLOBAL STATUS
*** sys.innodb_lock_waits
*** performance_schema.data_lock_waits
*** 症状 → 证据 → 改动
** 库存案例
*** 原子条件更新
*** 事务边界
*** 前后指标验证
** 分布式锁
*** 唯一索引 (幂等)
*** SELECT FOR UPDATE (悲观)
*** version 乐观锁
*** GET_LOCK (连接断开自动释放)
*** 分布式锁自身也要度量
*** 对比: MySQL / Redis / ZooKeeper / etcd
*** 租约 / 校验持有者 / fencing / 锁外幂等
@endmindmap
延伸阅读
- MySQL 8.0 Reference Manual:
data_lock_waits - MySQL 8.0 Reference Manual:
sys.innodb_lock_waits - MySQL 8.0 Reference Manual:InnoDB Deadlock Detection
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。