一个看起来非常普通的订单插入接口,在并发压测时居然死锁了。第一反应是"我又没改同一行,怎么会死锁",查了半天才搞明白 MySQL 在可重复读隔离级别下,INSERT 也会加间隙锁,而且加锁范围比想象中大很多。这篇把这次死锁的完整调查过程、间隙锁的真实加锁范围、四种修法的取舍,以及生产里真正能避免类似事故的几条铁律,完整写一遍。看完之后,你大概率会回去重新审视自己项目里所有的并发插入场景。
故障现场
背景是一个订单系统的下单接口,逻辑很简单,先查用户的待支付订单数,小于五就插入新订单,否则返回错误。表结构里 user_id 有普通索引,事务隔离级别是 MySQL 默认的可重复读。压测时把并发开到两百,死锁率高达百分之三,日志里全是死锁回滚的报错。当时整个团队都很意外,因为代码里完全没有 UPDATE,只有 SELECT 和 INSERT,谁能想到两个 INSERT 会死锁呢。
| 时刻 | 事件 |
|---|---|
| 压测开始 | QPS 五百,死锁率零 |
| 并发上到一百 | 偶发死锁,百分之零点三 |
| 并发到两百 | 死锁率三百分之三,日志爆炸 |
| 查 show engine innodb status | 看到两个 INSERT 互相等对方的锁 |
| 查代码 | SELECT FOR UPDATE 加在 user_id 上 |
| 定位 | 间隙锁覆盖了多个用户的范围 |
问题代码
-- 出事的事务
BEGIN;
SELECT COUNT(*) FROM orders
WHERE user_id = 123 AND status = 'pending'
FOR UPDATE;
-- 业务逻辑判断: count < 5
INSERT INTO orders(user_id, amount, status)
VALUES (123, 99, 'pending');
COMMIT;
这段代码看起来天经地义,先查再判断再插入,加 FOR UPDATE 避免并发下重复插入。但在可重复读隔离级别下,FOR UPDATE 加的不只是行锁,还会在索引上加间隙锁,而间隙锁的范围由查询条件和索引结构共同决定,经常覆盖不止当前用户的数据。
间隙锁的真实加锁范围
很多人以为 SELECT WHERE user_id = 123 FOR UPDATE 只会锁住 user_id 等于 123 的行,实际上不是。MySQL 在可重复读下会加临键锁,包括行锁和它前面的间隙。如果 user_id 索引上 123 这个值不存在,锁的范围就是 123 前后两个相邻值之间的整个间隙。即使存在,也会锁住 123 这一行加上前后的间隙,防止并发插入造成幻读。
举个例子,如果索引上现有的 user_id 是 100、120、150,你的 SELECT user_id = 123 FOR UPDATE 会锁住 120 到 150 这整个区间,这意味着任何 user_id 在 121 到 149 之间的插入都会被阻塞。两个事务,一个查 123 一个查 130,它们的间隙锁有重叠,如果再各自插入,就可能形成循环等待,触发死锁。
修法一:把隔离级别降到读已提交
最简单的修法是把事务隔离级别从可重复读改成读已提交。读已提交不加间隙锁,只加行锁,几乎不会出现这种死锁。但这个修法的代价不小,需要应用层自己处理可能的幻读、不可重复读问题,并且团队里很多遗留代码可能依赖可重复读的快照特性,改动需要全面评估。
另外读已提交下主从复制有要求,binlog 格式必须是 row 模式,因为 statement 模式下读已提交会导致主从数据不一致。大多数云数据库默认都是 row 模式,不是问题,但自建数据库要确认一下。我们项目最终没选这条路,因为评估下来改造成本太高,但它确实是治本的方案,新项目可以考虑直接用读已提交。
修法二:用唯一索引把判断推给数据库
既然业务逻辑是"每个用户最多五个待支付订单",可以加一个组合唯一索引,把限制下沉到数据库层。但 MySQL 的唯一索引只支持精确去重,不支持"最多 N 个"这种语义。变通的做法是给订单加一个序号字段,user_id 加序号做唯一索引,业务层先查最大序号,加一后插入。如果两个事务拿到同一个序号,后插入的那个会因为唯一索引冲突失败,业务层重试即可。
-- 添加序号字段和唯一索引
ALTER TABLE orders ADD COLUMN seq_no INT NOT NULL DEFAULT 0;
ALTER TABLE orders ADD UNIQUE KEY uk_user_seq (user_id, seq_no);
-- 应用层伪代码
BEGIN;
SELECT IFNULL(MAX(seq_no), 0) + 1 INTO @next FROM orders
WHERE user_id = 123 AND status = 'pending';
-- 如果 @next > 5 则报错
INSERT INTO orders(user_id, seq_no, amount, status)
VALUES (123, @next, 99, 'pending');
COMMIT;
-- 唯一索引冲突时, 业务层捕获 1062 错误码并重试
这种做法把并发竞争从间隙锁推给了唯一索引冲突,后者只阻塞同一个 user_id 的并发,不会跨用户。但它要求业务层正确处理重试逻辑,而且重试次数过多时仍然会有性能问题,适合并发不太高的场景。
修法三:用应用层的分布式锁
这是最常用也最容易理解的方案。在数据库事务外面加一个 Redis 锁,锁的粒度就是 user_id,同一个用户同时只能有一个下单请求在执行。Redis 锁的获取释放都是原子操作,不会有间隙锁的问题。
# 伪代码: Redis 分布式锁包裹事务
def create_order(user_id, amount):
lock_key = f'order_lock:{user_id}'
if not redis.set(lock_key, '1', nx=True, ex=5):
raise BusyError('try later')
try:
with db.transaction():
count = db.execute(
'SELECT COUNT(*) FROM orders '
'WHERE user_id=%s AND status=%s',
(user_id, 'pending')
)
if count >= 5:
raise LimitError('too many pending')
db.execute(
'INSERT INTO orders(user_id, amount, status) '
'VALUES (%s, %s, %s)',
(user_id, amount, 'pending')
)
finally:
redis.delete(lock_key)
这种写法的好处是简单直观,坏处是引入了 Redis 依赖,Redis 挂了下单就不能用。生产环境通常会加降级,Redis 挂了走数据库的悲观锁兜底,这是个常见的工程模式。
修法四:重写 SQL,避免间隙锁
更优雅的做法是改写查询条件,让间隙锁的范围最小化。比如把 SELECT COUNT FOR UPDATE 改成 INSERT ON DUPLICATE KEY UPDATE,或者用乐观锁的方式,先 INSERT 再用 WHERE 子句做条件判断。这种修法不需要改隔离级别,也不需要外部依赖,但要求对 InnoDB 的锁机制非常熟悉,改写时容易踩新坑。
一个比较稳的写法是把"先查后插"改成"先插再校验"。INSERT 成功之后立刻 SELECT COUNT,如果超过限制就 ROLLBACK。这种写法依赖事务的回滚能力,理论上正确,但实际上会浪费一些插入资源,适合超限是边缘情况的场景。
-- 先插再校验, 避免 SELECT FOR UPDATE 的间隙锁
BEGIN;
INSERT INTO orders(user_id, amount, status)
VALUES (123, 99, 'pending');
-- 立刻校验, 这里只锁当前行不锁间隙
SELECT COUNT(*) INTO @cnt FROM orders
WHERE user_id = 123 AND status = 'pending';
-- 如果 @cnt > 5 则 ROLLBACK
COMMIT;
这种写法的优势是 INSERT 只会加插入意向锁和行锁,不会触发主动的间隙锁扫描,锁的范围最小化。但它的代价是即使最终要回滚,也已经分配了主键自增、产生了 binlog 事件,在超限是常态的场景下会浪费很多资源。所以更适合"超限是异常情况"的业务,正常路径下几乎不会触发回滚。
四种修法对比
| 方案 | 性能 | 改造成本 | 依赖 | 适用场景 |
|---|---|---|---|---|
| 读已提交 | 最好 | 高(可能引入新 bug) | 无 | 新项目 |
| 唯一索引 | 中等 | 中 | 无 | 低并发 |
| Redis 锁 | 好 | 低 | Redis | 常规生产 |
| SQL 重写 | 好 | 高(易踩坑) | 无 | 追求极致 |
定位死锁的标准流程
遇到死锁,第一步是抓最近一次死锁的详细信息。MySQL 提供了 SHOW ENGINE INNODB STATUS 命令,输出里有 LATEST DETECTED DEADLOCK 一节,记录了两个事务的 SQL、加锁状态、等待对象。读懂这段输出是 DBA 的基本功,也是开发同学应该掌握的技能。
# 抓最近一次死锁详情
mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" \
| sed -n '/LATEST DETECTED DEADLOCK/,/WE ROLL BACK/p'
# 开启死锁日志持久化(写入 error log)
mysql -e "SET GLOBAL innodb_print_all_deadlocks = ON;"
# 实时观察锁等待
mysql -e "SELECT * FROM information_schema.innodb_lock_waits\G"
# 查看当前持有的所有锁
mysql -e "SELECT * FROM performance_schema.data_locks\G"
输出里关键看几样:每个事务正在等的锁是什么类型,持有的锁又是什么。锁类型主要看是 record lock、gap lock、next-key lock 还是 insert intention lock。其中插入意向锁经常被忽略,它是 INSERT 操作专用的,本身不与其他插入意向锁冲突,但会被间隙锁阻塞。这次死锁的本质就是两个事务的间隙锁互相阻塞了对方的插入意向锁。
定位之后,下一步是用最小复现确认根因。我们当时写了一个十几行的 Python 脚本,起两个线程模拟两个事务,稳定复现死锁,然后逐步改 SQL 观察哪一步消除死锁。这种最小复现的能力是排查复杂并发问题的关键,光看日志和代码很难想清楚,必须能够动手验证。
团队最终选择和后续改造
我们最终选了 Redis 锁加唯一索引兜底的组合方案。Redis 锁负责处理高并发下的快速判断,唯一索引作为兜底保证数据正确性。这种"双保险"是生产环境常见的工程实践,任何一层失效另一层还能扛住。改造完上线之后,死锁率从百分之三降到零,QPS 还提高了百分之十,因为不需要等待间隙锁的释放。
除了修这一处,我们还做了一次全项目的死锁审计。把所有用了 FOR UPDATE 的 SQL 都翻了一遍,确认它们的间隙锁范围是否合理。审计中发现了另外四处类似的潜在死锁,提前修掉了。这种事故后的全面排查投入产出比非常高,几乎每次都能挖出比当前事故更多的隐患。
排查过程中走过的几个弯路
事故发生时团队的第一反应是怀疑代码有 bug,大家把 SELECT 和 INSERT 那一段反复读了好几遍,确认逻辑上没有问题。第二个怀疑方向是数据库连接池配置不对,以为是连接复用引起的事务嵌套,翻了应用层的连接池源码,确认事务边界清晰。第三个怀疑是中间件层面的代理把 SQL 改写了,跑到中间件那边看了一遍执行计划,也没发现异常。这三个弯路加起来花了将近两个小时,期间死锁还在持续发生,客服那边的投诉单也在累积,压力非常大。
真正的突破口是一位资深 DBA 提了一句:你们打开 SHOW ENGINE INNODB STATUS 看看 LATEST DETECTED DEADLOCK 段。我们打开之后,瞬间看到两个事务的等待锁里都有 lock_mode X locks gap before rec insert intention 这几个关键字,死锁的本质一下就清晰了,是间隙锁挡住了插入意向锁的获取。如果当时知道有这个命令,定位时间能从两小时缩到十分钟。这也是事故复盘里给团队补的第一课,凡是涉及到 InnoDB 锁的问题,先看 INNODB STATUS 再做其他推测。
这次的经历让我特别想强调一点,排查问题时要相信工具而不是直觉。直觉来自经验,经验来自旧场景,新场景的根因经常跟旧场景不同。工具给出的是当下真实发生的事实,事实不会骗人,但需要你有能力读懂它。读懂 MySQL 的锁日志、读懂 Linux 的 perf 输出、读懂火焰图、读懂网络抓包,这些都是工程师的硬核能力,平时不显眼,出事时是救命稻草。
间隙锁的几个反直觉细节
第一个反直觉细节是间隙锁的范围由索引上已有的相邻值决定,而不是由你的查询条件决定。比如 WHERE user_id = 123,如果索引上没有 123 这个值,锁范围就是 123 前后两个相邻值之间的间隙,可能横跨几十个 user_id。这就解释了为什么两个查不同 user_id 的事务会互相阻塞,因为它们的间隙锁落在了同一个区间。新人写代码时经常以为锁是按查询条件粒度的,实际不是,这种误解是死锁的最大源头。
第二个反直觉细节是间隙锁是共享的,不是排他的。多个事务可以同时持有同一个间隙的间隙锁,但任何一个事务想在这个间隙里插入数据,都会被其他事务的间隙锁阻塞。这就是死锁的形成机制,两个事务都先拿到了重叠的间隙锁,然后都想插入,互相等待对方释放,最终形成死锁。理解这一点之后,你就明白为什么并发量上去之后死锁概率会陡增,因为重叠的间隙锁数量是平方级增长的。
第三个反直觉细节是 INSERT 也需要先拿锁。很多人以为 INSERT 是无锁操作,实际上 INSERT 会先尝试获取插入意向锁,这是一种特殊的间隙锁。插入意向锁本身不与其他插入意向锁冲突,但会被普通间隙锁阻塞。这意味着只要事务里有任何一条 SELECT FOR UPDATE 或者 UPDATE 加了间隙锁,后续的 INSERT 就可能被同事务以外的间隙锁挡住,触发死锁。
第四个反直觉细节是隔离级别影响巨大。同样的 SQL,在可重复读下加间隙锁,在读已提交下只加行锁。这就是为什么很多新项目直接选读已提交,可以省掉大量类似事故。但读已提交也有自己的代价,可能产生不可重复读和幻读,需要业务层自己处理。技术选型本来就是不断权衡的过程,没有银弹。
关于 MySQL 锁机制的一些常见误解
第一个误解是"FOR UPDATE 只锁当前行"。实际上在可重复读隔离级别下,FOR UPDATE 会加临键锁,范围远超你查询的那一行。这条误解导致了大量的死锁事故,新人写代码时几乎必踩一次。第二个误解是"不加 FOR UPDATE 就不会有锁"。普通的 INSERT 也会加插入意向锁,UPDATE 和 DELETE 会加行锁加间隙锁,只要在事务里,就一定有锁,只是粒度不同。
第三个误解是"间隙锁只在 INSERT 时起作用"。其实间隙锁会阻塞任何想在该间隙内插入或修改数据的操作,包括 UPDATE 改主键值进入该间隙、DELETE 已经存在但被覆盖的行。这种间接影响经常导致排查方向跑偏。第四个误解是"死锁就是 bug,要彻底消除"。在高并发场景下,死锁是 MySQL 的正常自保机制,完全消除几乎不可能,目标应该是控制在可接受的范围内,加上自动重试逻辑。
跨数据库对比
| 数据库 | 默认隔离级别 | 是否有间隙锁 |
|---|---|---|
| MySQL InnoDB | 可重复读 | 有,默认开启 |
| PostgreSQL | 读已提交 | 无,用 MVCC |
| Oracle | 读已提交 | 无 |
| SQL Server | 读已提交 | 有,可选 |
| TiDB | 乐观锁默认 | 悲观锁模式才有 |
有意思的是 MySQL 是少数默认隔离级别是可重复读、还默认开间隙锁的数据库。这跟它早期主从复制依赖语句级 binlog 有关,可重复读加间隙锁能保证主从一致。PostgreSQL 走的是 MVCC 多版本并发控制,不需要间隙锁就能避免幻读,因此并发场景下死锁少很多。如果是新项目,选 PostgreSQL 在锁机制上确实省心。
我们立的几条铁律
- 任何事务里出现 FOR UPDATE 必须经过 review,review 时必须画出锁的范围。
- 高并发下单类接口必须有 Redis 锁或类似的应用层保护,不能裸跑数据库事务。
- 所有用户输入的范围查询必须考虑间隙锁,WHERE 条件越窄越好。
- 事务尽量短,SELECT 和 INSERT 之间不要做耗时操作,锁持有时间和死锁概率成正比。
- 应用层必须捕获死锁错误码 1213,自动重试两次,超过则报错。
- 压测必须包含死锁场景,QA 阶段就要把死锁率作为验收指标之一。
事故复盘的反思
这次事故让我深刻意识到,数据库的并发模型远比我们以为的复杂。我们写 SQL 时只关注业务逻辑,很少考虑底层的锁机制,结果在高并发场景下踩各种坑。要避免这种问题,光靠经验是不够的,必须主动学习数据库的内部机制,把锁模型、隔离级别、索引结构这些基础打牢。这些知识不是写应用层代码用得到的,但出事时一定救你。
另一个反思是关于压测的覆盖度。我们之前的压测只关心 QPS 和延迟,从来没把死锁率作为指标。这次事故之后,我们的压测脚本增加了死锁、超时、锁等待等异常指标的统计,任何一个超过阈值都不能上线。这种"异常驱动"的压测能挖出大量隐藏的并发问题,比单纯的吞吐量测试更有价值。压测不是为了证明系统能跑多快,而是为了暴露系统在压力下的脆弱点。
给读者的建议
如果你的项目用 MySQL 并且事务里有 FOR UPDATE,强烈建议立刻去检查一遍,看看间隙锁的范围是不是符合预期。检查方法很简单,起两个事务模拟并发,观察是否会互相阻塞。如果发现问题,按这篇里的四种修法选一个适合自己场景的尝试。不要等到生产环境出事再改,死锁导致的事故往往伴随大量交易失败,用户投诉、资金损失、值班同事熬夜,代价不小。
另一条建议是不要害怕换数据库。如果你的业务场景对并发写入非常敏感,PostgreSQL 的 MVCC 模型可能比 MySQL 的间隙锁省心很多。换数据库的成本看起来高,但相比一次又一次的死锁事故,可能反而是更划算的选择。技术选型本来就是不断权衡的过程,没有银弹,只有更适合的工具。希望这篇能帮你少踩一些数据库相关的坑,把精力用在更有价值的事情上。
团队推广这些规矩时的真实阻力
事故复盘开完之后,我们想把那六条铁律推广到整个团队,实际执行时遇到了不小的阻力。第一个阻力来自资深开发,他们觉得 review 时画锁范围太繁琐,影响开发效率,希望可以简化。我们最终的折中是只对涉及 FOR UPDATE 的代码强制画图,其他 SELECT 不强制,这样既保留了关键场景的安全,又不至于让 review 变成纯粹的负担。第二个阻力来自新人,他们看不懂间隙锁的图怎么画,我们专门开了一节内训课讲怎么画,把流程标准化。
第三个阻力是已有代码不符合规矩怎么办。全量改造工作量太大,业务也不允许停下来重构。我们采取的策略是新代码必须符合,旧代码出问题再改,同时给所有 FOR UPDATE 的旧代码加了监控,任何死锁告警都会自动关联到对应的代码位置。这种渐进式改造比一刀切更现实,虽然进度慢,但不影响业务节奏。半年下来,大约百分之七十的旧代码都被替换成了符合规矩的写法,死锁告警数量下降了百分之九十。
这种推广过程让我意识到,技术规矩的落地难度往往不在技术本身,而在人和习惯。再好的规矩,如果团队不接受、不执行,就是一纸空文。要让规矩落地,需要配套培训、配套工具、配套监控,缺一不可。同时还要给团队留出消化的时间,不要指望一次会议就能改变所有人的习惯。这是工程管理的艺术,跟技术能力是两码事,但同样重要。
给读者的几条实操建议
第一条建议是马上去检查你的项目里有多少 FOR UPDATE。打开代码搜索一下,统计数量,看看每一处的锁范围是不是合理。这个动作很简单,但能让你对项目的死锁风险有个量化的认识。第二条建议是在测试环境上跑一次并发压测,把死锁率作为指标之一,看看实际数字是多少。如果死锁率超过千分之一,就值得花时间优化,千分之一在生产环境会被流量放大成不可接受的水平。
第三条建议是给应用层加上死锁自动重试。MySQL 死锁的错误码是 1213,捕获这个错误码自动重试两到三次,可以把绝大部分死锁对用户的影响隐藏掉。注意重试要有上限,不能无限重试,否则可能引发雪崩。第四条建议是定期看慢查询日志和死锁日志,把死锁率作为系统健康度的指标之一,纳入日常运维。这些数字平时看起来无关紧要,出事时就是救命的预警。
第五条建议是不要害怕换数据库。如果你的业务场景对并发写入非常敏感,而且团队对 MySQL 的锁机制不熟悉,换一个 MVCC 模型的数据库可能是更省心的选择。换数据库的成本看起来很高,但相比一次又一次的死锁事故,可能反而是更划算的投资。技术选型应该面向团队能力和业务特点,而不是面向技术潮流或个人喜好。
跟其他锁问题的对比和联系
MySQL 间隙锁导致的死锁,只是数据库并发问题的一类典型场景。其他类似的场景还有很多,比如 PostgreSQL 的 SELECT FOR UPDATE 也可能引发死锁,虽然机制跟 MySQL 不同;Oracle 的行级锁在某些情况下会升级为表级锁,导致大范围阻塞;Redis 的分布式锁如果获取释放时机不对,也会形成类似死锁的等待。这些问题的本质都是多个事务竞争资源的顺序不一致,只要这种竞争存在,死锁就有可能发生。
解决思路也是相通的,要么消除竞争,要么统一竞争顺序,要么允许死锁但自动恢复。消除竞争通常意味着引入串行化,代价是吞吐量降低;统一竞争顺序需要全局的协调机制,实现复杂;允许死锁加自动恢复是最实用的方案,大部分数据库都内置了死锁检测和回滚机制,业务层只需要处理重试。理解这套通用思路之后,你会发现不只是 MySQL,任何并发系统的死锁问题都可以用同样的框架分析和解决。
这种思维方式的迁移能力,是工程师价值的核心来源之一。学会一个具体技术之后,不要停在技术细节里,而要思考它背后的通用原理。原理通了,以后遇到新技术、新场景,都能快速上手,不需要从零开始。这种"底层逻辑通用,上层细节多样"的认知模式,适用于几乎所有的工程领域,值得长期投入和打磨。
总结
MySQL 在可重复读下的间隙锁是一个高频踩坑点,新人几乎必中,老手也会偶尔翻车。理解它的关键是搞清楚"锁的范围由索引结构决定,而不是由你的 WHERE 条件直接决定"。一旦理解这一点,写 SQL 时会自然地避开危险写法,死锁率会大幅下降。这篇里讲的四种修法各有适用场景,Redis 锁是大多数生产环境的稳妥选择,读已提交是新项目的优秀方案,唯一索引适合简单场景,SQL 重写需要深厚的数据库功底。
最后想说的是,数据库的复杂度远超大多数开发者的认知,我们用 SQL 的简单接口隐藏了大量底层细节,这是个双刃剑。简单意味着易用,但也意味着出问题时难以理解。要真正用好数据库,必须花时间学习它的内部机制,这种投资在职业生涯里收益巨大。希望读完这篇能让你对 MySQL 的锁机制多一份敬畏,少一份盲目自信。
—— 别看了 · 2026