深入 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 延迟。

这个假设里至少有三层验收标准:

  1. 业务正确性:超卖数必须为 0,库存不能变成负数。
  2. 用户体验:订单接口成功率和 P99 延迟不能变差。
  3. 资源健康度:锁等待、死锁、连接池排队不能因为优化一个指标而恶化。

如果只盯着“死锁少了”,却发现订单接口 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 里的多版本快照。
  • 解决的是"写与写"、以及"当前读"之间的互斥。所谓当前读,就是 UPDATEDELETEINSERT,以及显式加锁的 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_schemaperformance_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 的行锁其实有三种"形态":

  1. 记录锁(Record Lock):锁住某一条已存在的索引记录。比如 WHERE id = 5 且 id=5 存在,就锁这一行。
  2. 间隙锁(Gap Lock):锁住一段开区间,即两条记录之间的"空隙",不含记录本身。作用是不让别人往这个空隙里插入新行——这正是防幻读的关键。
  3. 临键锁(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 秒,但当前配置可能已经被调整)。死锁是“成环、很快回滚一方”;锁等待超时是“没成环、但前面的事务迟迟不放锁”。区分这两个,排查方向完全不同。

避免死锁的几条实战经验:

  1. 按固定顺序访问资源。多个事务如果都按"先动小 id 再动大 id"的顺序加锁,就不会成环。死锁多半是加锁顺序不一致造成的。
  2. 事务尽量短小。一进事务就干活、干完立刻提交,别在事务里穿插 RPC 调用、发消息、等用户输入这类慢操作。
  3. 让 WHERE 走索引、走唯一索引。索引不精确会锁更多行、加更多间隙锁,冲突面变大。
  4. 必要时降到 RC 隔离级别,减少间隙锁。
  5. 一次锁定所需的全部行,别"锁一点、算一点、再锁一点"。

八、用指标把“数据库卡住了”定位成证据链

这一节是全文的重点。指标不是为了让仪表盘更热闹,而是为了缩短从“用户说慢”到“知道该改哪一行代码”的距离。

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_locksperformance_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_secondsdb_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_LOCKFOR UPDATE),够用且省事。
  • 高并发、锁竞争激烈 → 可以评估 Redis,但要把租约、续期、主从切换、fencing 和锁外幂等一起设计,不能只写一句 SET NX
  • 需要租约、临时节点、watch 或共识型协调能力 → 评估 ZooKeeper / etcd,接受更重的运维和更高的理解成本。

不管用哪个,分布式锁有几条通用的命门,务必记牢:

  1. 一定要有过期时间或自动释放,否则持锁者一挂就可能死锁。
  2. 释放锁要校验持有者身份(谁加的谁才能解),否则你可能解掉别人刚拿到的锁。
  3. 租约要覆盖业务执行时间,并有续期和失效处理。租约过期后,旧持有者必须停止写入。
  4. 考虑 fencing token,避免网络延迟导致旧客户端“死而复生”。
  5. 想清楚锁丢了会怎样。关键业务要在锁之外加唯一索引、状态机、幂等键或版本校验,而不是把全部身家压在锁上。

分布式锁不是保险箱,是限流闸。 它降低冲突概率,但不替你保证正确性。真正的正确性,来自幂等设计和数据库约束。


十一、要点回收与实操清单

一路讲下来,把最该记住的收一收:

  • 快照读靠 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

延伸阅读


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。