MySQL
收录于 工程治理体系
MySQL 线上死锁处理:从 kill 进程到系统化治理
线上 MySQL 死锁最容易被回答成“先 kill 掉卡住事务”。真正更重要的,是先分清单次死锁和系统性阻塞,再按诊断、止血、根因分析和并发治理一路往下做。
线上 MySQL 出了死锁怎么办?大多数回答的第一句都是:把卡住的事务 kill 掉。
这句话不算错,但如果处理只停在这里,思路就还在反应式操作阶段——看到故障先做动作,至于为什么会发生、做完会不会再来一次,没有一套完整的方法。
我对这个问题的理解,就是在一次线上故障里被重新塑造的。
那次故障的表象是:数据入库流程和后台扫描任务同时运行,数据库开始出现锁冲突,随后接口 RT 上升、任务堆积、环境整体卡顿。
一开始,团队里最自然的声音就是:先把那几个长事务 kill 掉,让业务恢复再说。
但复盘下来我才意识到,kill 只是止血动作。线上口语里常说“kill 进程”,其实就是 kill 对应的数据库会话,让事务回滚并释放锁。更有价值的问题是:有没有一套从诊断、止血到根治的完整思路。
下面我结合这次场景,把处理 MySQL 线上死锁的经验整理一遍。
一、事故背景:数据入库与扫描任务同时命中同一批数据
先讲一下当时的业务场景。
系统里有一张核心业务表,记录外部数据的入库结果和处理状态。为了方便讨论,这里假设表名叫 import_result。表里有几个关键字段:
biz_id:业务唯一标识status:当前处理状态,比如INIT、SUCCESS、RETRYupdate_time:最后更新时间version:版本号或乐观锁字段
线上同时存在两类流程。
1. 数据入库流程
上游系统持续推送数据,服务端收到后会批量入库。入库逻辑更接近一种 upsert:
- 如果记录不存在,就插入新数据
- 如果记录已存在,就根据业务主键更新状态、时间戳、结果字段
- 一批数据会放在一个事务里提交
这条流程的特点是:写入频繁、事务批量、对时效敏感。
2. 后台扫描任务
系统里还有一个定时扫描任务,每隔一段时间就去捞取 INIT、RETRY 这类状态的数据,做补偿、重试或者归档。
扫描任务的大致逻辑是:
- 查询满足条件的记录
- 把选中的记录改成处理中状态
- 执行后续补偿逻辑
- 再更新处理结果
这种任务的特点是:批量处理、带范围查询、容易扫到热点数据。
问题就出在这里:入库流程和扫描任务,命中了同一批记录。
更糟糕的是,它们对这些记录的访问方式并不完全一致:
- 入库流程按业务主键逐条更新
- 扫描任务按状态和时间范围批量扫描
- 两边事务都不算小
- 应用层对失败事务还有快速重试
于是,一个本来只是偶发冲突的问题,迅速演化成了环境级卡顿。
二、先把一个事说清楚:死锁不等于环境卡顿
我后来再回头看这次事故,最想先讲清楚的一点是:别把“死锁”和“环境卡顿”混成一件事。
对于 InnoDB 来说,单次死锁一般会被数据库自动检测并回滚其中一个事务,所以它本身不一定会把系统拖死。更危险的是死锁背后的并发冲突没有管好,进一步演化成:
- 长事务持续占锁
- 锁等待链越来越长
- 应用层快速重试放大冲突
- 连接池和工作线程被逐步占满
直接 kill 会话,经常只能缓一口气,很难从根上解决问题。
三、后来我再看这类问题,第一步都是先诊断
后来我再处理这类问题,或者再复盘类似现场时,第一步都会先做一个短平快的诊断,而不是急着动手 kill。
这里有个前提:线上高危操作必须基于证据,不能基于情绪。
1. 用 SHOW ENGINE INNODB STATUS 看死锁现场
我一般会先执行:
SHOW ENGINE INNODB STATUS;
这个命令最关键的部分,是 LATEST DETECTED DEADLOCK。
这里能看到:
- 最近一次死锁发生的时间
- 参与死锁的事务有哪些
- 每个事务当前在等待什么锁
- 每个事务已经持有哪些锁
- 涉及哪张表、哪个索引、哪种锁
- InnoDB 最终选择回滚哪个事务
这一步的目的是搞清楚一个问题:到底是哪两类业务在冲突?它们争抢的是哪一批数据?冲突发生在主键更新、范围更新,还是二级索引扫描?
在我们这次事故里,通过死锁日志很快就能看到:一边是入库事务按 biz_id 更新记录,另一边是扫描任务按 status + update_time 条件扫描并更新状态。
这就把问题从“数据库有点卡”,迅速收敛成了两个具体业务流程在争抢同一批数据。
查到这儿,方向就有了。
2. 再看当前阻塞链,而不只是最近一次死锁
只看死锁日志还不够。线上直接影响业务的,往往是此刻有多少事务在等锁。
所以我会继续看:
SHOW PROCESSLIST- 当前活跃事务
- 谁在持锁
- 谁在等待
- 是否已经形成阻塞链
关键在于判断现场属于哪一类。
场景 A:单次死锁,数据库已经自动处理
这种情况下,数据库一般已经回滚了一个事务,现场不一定还有大面积阻塞。这时候该做的是:
- 记录死锁日志
- 识别冲突 SQL
- 检查业务是否需要重试
- 进入后续根因分析
场景 B:长事务 + 锁等待堆积,业务已经明显卡顿
这种情况下,情况已经从偶发死锁升级成了系统性阻塞:
- 一批会话在等待锁
- 前台接口开始超时
- 后台任务持续堆积
- 连接池接近打满
这时候,才进入止血决策。
四、止血时可以 kill,但必须是有依据的 kill
有人会理解成 kill 完全不能做。
也不是。
kill 可以做,但不能盲目做。
如果我已经通过诊断确认:现场存在长事务持续占锁,并且已经影响核心业务流程,那么我会做人工干预。但这个干预一定是一个有依据、有取舍的决策。
1. 什么情况下可以考虑 kill
比如下面这些场景:
- 某个后台扫描任务事务跑得太大,持锁时间过长
- 某个非核心批处理占住热点记录,导致前台写请求都在排队
- 某些会话已经形成明显的等待链,继续放着只会把环境拖得更慢
2. kill 谁,不是随机选择
我的原则是优先牺牲业务影响小、回滚成本低、可以重跑的事务。
例如在这次事故里,如果冲突双方分别是:
- 前台实时入库流程
- 后台定时扫描补偿任务
那么很明显,我会优先中断后台扫描任务,而不是中断前台核心入库。
因为后台任务一般有几个特征:
- 可延后执行
- 可重新调度
- 对用户实时体验影响小
- 即使中断,代价也相对可控
这个 kill 是在业务优先级之上做出的止血决策。
3. 止血的目标不是把数据库清空,而是恢复主链路
这个地方特别重要。
处理线上阻塞时,容易陷入一种误区:不断 kill、不断清理会话,仿佛把数据库里的卡住事务都杀掉,就等于问题解决了。
其实不是。
止血阶段的目标是:用最小代价恢复核心业务主流程,避免非核心任务继续放大阻塞。
4. 业务不允许下线时,重点是在线止血
这还推到另一个更实际的问题:业务不允许下线,在线上还能做什么?
我的经验是:不允许下线,不等于什么都做不了。更现实的做法是在不停服的前提下完成在线止血、局部降级和风险收敛——先保住核心业务,再推动根治。
先保核心流程,再收缩非核心流量。
拿这次事故来说,冲突双方分别是前台实时入库和后台扫描补偿任务。
如果业务不允许下线,我的第一原则一定是:优先保护入库主流程,优先收缩后台扫描、补偿、归档这类非核心写流量。
也就是说,我会先评估这些动作能不能立即执行:
- 暂停扫描任务
- 降低扫描频率
- 缩小单批处理条数
- 关闭部分非必要补偿写操作
这些动作都是在线降级,不需要把系统整体停掉,但可以迅速减少数据库层面的锁竞争。
必要时定点 kill,但只 kill 非核心阻塞源。
如果我已经确认现场已经从偶发死锁演变成持续阻塞,比如:
- 大量会话在等锁
- 前台接口 RT 明显上升
- 连接池快被占满
- 扫描任务长事务持续持锁
那我会进行有依据的人工干预。
这里的关键是三个判断:
- kill 谁
- 为什么 kill 它
- kill 完以后是否还会再次冲突
我的原则一般是:
- 优先 kill 后台批处理或补偿任务
- 优先 kill 可重跑、可回放、业务影响较小的事务
- 尽量不要动前台核心流程
这是外科手术式地拿掉最主要的阻塞源,尽快恢复主流程的吞吐能力。
通过运行时手段做临时缓解。
线上很多时候不只是不能下线,还意味着:
- 暂时不能发版
- 不能立刻改代码
- 业务窗口非常紧
这时候就更需要依赖运行时可调整的手段,例如:
- 把扫描任务从高并发改成低并发
- 把每批处理 1000 条改成每批处理 100 条
- 把立即重试改成退避重试
- 拉长补偿任务的调度间隔
- 临时关闭部分非必要写操作
这些手段的价值在于:不用下线,也不用马上发版,就能先把冲突面缩小。
在线处理的目标是先稳住现场。线上不能下线时,最忌讳因为问题没法一次性修好就什么都不做。只要做到下面几点,就已经在止血:
- 先让核心业务恢复
- 先让阻塞面收缩
- 先让数据库负载回落
- 先避免问题继续放大
根治当然重要,但那是止血之后的下一阶段工作。
五、问题不在一条 SQL,在背后的并发模型
止血之后,更重要的是能不能往下走到根因分析。
在我们这个场景里,问题最终不是一个单点,而是几个因素叠加。
1. 加锁顺序不一致
这是最典型、也是最容易被忽视的死锁来源。
入库流程是按 biz_id 命中单条记录,更新主记录后再处理扩展状态。
扫描任务则是先按 status 条件批量扫描,再逐步更新命中的记录状态。
看起来两边都只是更新同一张表,但从数据库视角看,它们的访问顺序其实并不一致。
一旦两个事务在同一批数据上相向而行,就很容易形成循环等待。
就这么卡住的。
线上死锁的常见根源就是:两条业务路径以不同顺序获取同一组资源。SQL 本身不一定多复杂。
这类问题最根本的解法,就是统一加锁顺序。只要资源获取顺序被统一,死锁概率通常会明显下降。
2. 扫描任务范围太大
扫描任务本身就容易放大锁冲突,尤其是满足下面这些条件时:
- 按状态字段做范围扫描
- 一次捞取的数据量大
- 扫描后直接批量更新
- 条件命中热点记录
如果 SQL 和索引设计不够精准,数据库为了完成这些扫描,可能会锁住比你预期更多的数据范围。
这会带来两个直接后果:
- 冲突面变大
- 锁持有时间变长
原本只应该是两三行记录之间的竞争,可能迅速演变成一片范围内的数据互相影响。
3. 事务边界过大
这是线上事故里非常常见的问题。
很多时候,问题不是 SQL 慢,而是事务太大。
比如一次入库处理一大批数据,整个批次放进一个事务里;扫描任务也是一次捞很多,再统一更新提交。这样做的结果是:
- 单个事务持锁时间长
- 任意冲突都会持续更久
- 死锁概率升高
- 锁等待时间变长
如果事务里还夹杂了额外操作,比如:
- RPC 调用
- 复杂计算
- 日志落库
- 结果回调
那问题会更严重。因为这些与数据库无关的耗时,也在无形中延长锁的持有时间。
4. 应用层快速重试,放大冲突
这次事故里,把问题推向环境级卡顿的还有一个关键因素:失败重试策略过于激进。
数据库检测到死锁并回滚一个事务后,应用层立刻重试;而扫描任务本身又在持续扫相同数据。
于是就形成了一个很糟糕的循环:
- 死锁发生
- 事务回滚
- 应用立即重试
- 再次命中同一批热点数据
- 继续发生冲突
如果没有退避机制,这种重试几乎等同于继续冲撞现场。
从系统视角看,线上锁冲突大多是数据库冲突、应用重试策略和后台任务调度方式一起把问题放大的。
也正因为这样,后面的改进重点要回到并发模型本身,不能只停在“把哪条 SQL 再修一下”。前面这些原因怎么把问题放大,后面基本就得反过来一项项拆。
5. 统一加锁顺序
这是最值得优先推动的事情。
如果多个业务流程会操作同一批资源,就要在应用层明确约定:任何时候都按同样的顺序获取资源。
比如:
- 永远先更新主记录,再更新状态记录
- 永远按主键升序处理一批数据
- 永远先锁定任务主表,再处理扩展表
统一顺序之后,即便还有锁竞争,通常也更容易退化成等待,而不是形成死锁环。
6. 缩短事务边界
如果事务天然大,就要想办法把它切小。
具体可以做的包括:
- 批量入库拆成更小批次提交
- 扫描任务分页处理,不要一把捞太多
- 事务里只保留必要的数据库操作
- 把 RPC、日志、计算等耗时操作移到事务外
一条简单但有效的原则:让事务尽可能短,让锁尽可能早释放。
7. 优化索引和 SQL,让扫描更精准
如果扫描任务是按 status + update_time 条件查数据,就应该检查:
- 有没有合适的联合索引
- 查询条件和索引顺序是否匹配
- 是否能避免不必要的全表或大范围扫描
- 是否能缩小每次命中的数据集合
很多时候,死锁和锁等待看起来像并发问题,其实是扫描太粗。
一旦扫描范围缩小,锁的覆盖范围也会跟着缩小,冲突自然就会下降。
8. 改造扫描任务的运行方式
后台扫描任务特别容易成为锁冲突制造者,所以这里往往需要单独调整。
例如可以考虑:
- 分页扫描,而不是一次性扫全量
- 分批提交,而不是整个批次一个大事务
- 限流执行,避免与高峰写流量正面碰撞
- 避开业务高峰期调度
- 对热点数据做拆分或分桶处理
如果扫描任务只是为了补偿或兜底,那它的系统设计目标应该是:宁可慢一点,也不要影响主流程。
9. 应用层加入幂等和退避重试
这一步经常被低估,但实际上非常关键。
因为死锁这种事情,在高并发系统里很难做到绝对为零。你不能指望数据库永远不出现竞争,更现实的做法是:
- 应用层识别死锁 / 锁等待异常
- 做有限次数的重试
- 重试之间加入退避
- 保证写操作具备幂等性
这样即使偶发死锁发生,也不会直接把异常暴露给用户,更不会因为秒级重试把冲突放大成风暴。
六、如果短期还改不了代码,还能做什么
实际线上环境里,并不是所有问题都能马上通过改代码解决。
有时候业务窗口很紧,发布成本很高,这时候就需要考虑一些过渡性方案。
其中一个经常被提到的思路,是评估隔离级别。但这块我不想展开成一段数据库教材,只保留和这次问题直接相关的部分。
如果系统跑在可重复读(RR)下,而扫描任务又带着范围查询去捞数据,那么 InnoDB 在某些场景里会把锁范围放得比你直觉里更大。除了锁住某一行,还可能把一段索引范围一起带进去。
这类范围锁一旦和扫描任务叠在一起,就很容易把原本局部的冲突放大。所以有些团队会想到:要不要把 RR 调成 RC,先把冲突面压下去。
这里一定要说清楚:
RR 改 RC 不是通用解法,更不是万金油。
它主要适用于那些与 Gap Lock / Next-Key Lock 强相关的冲突场景,比如:
- 范围查询后再更新
- 按状态区间捞取记录
- 热点范围内反复插入或更新
如果死锁的原因是加锁顺序相反或者事务太大,那就算改成 RC,也不一定能从根上解决。
如果经过分析,我确认某类扫描任务确实是被 RR 下的范围锁放大了冲突,而业务又能接受更弱一点的隔离保证,那么我会考虑:
- 只针对这类特定事务做隔离级别调整
- 明确数据一致性上的 trade-off
- 做灰度验证,而不是全局直接改
这个方案的意思是:在一致性和并发性之间,基于具体业务算一笔清楚的工程账。
七、从 kill 到治理,我后来真正复盘的是什么
这次故障对我的触动,是让我重新理解了线上故障处理的层次。
一个普通的处理方式,往往是这样的:
- 出现卡顿
- 找到几个慢事务
- kill 掉
- 业务恢复
- 事情结束
而一个更成熟的处理方式,应该是这样的:
- 先判断是单次死锁,还是系统性锁等待
- 基于死锁日志和阻塞链做诊断
- 在业务优先级之上做止血
- 复盘加锁顺序、事务边界、索引和扫描方式
- 在应用层补齐重试和幂等
- 必要时再评估隔离级别等策略性优化
画成决策树,大致就是这个路径:
flowchart TD
A["发现死锁/卡顿"] --> B["诊断: 死锁日志 + 阻塞链"]
B --> C{"单次死锁<br>还是系统性阻塞?"}
C -->|"InnoDB 已自动回滚"| D["记录日志<br>识别冲突 SQL"]
C -->|"长事务持锁<br>锁等待堆积"| E{"业务能否下线?"}
E -->|"不能"| F["在线降级<br>暂停/降频/缩批次"]
E -->|"能"| G["定点 kill<br>优先牺牲非核心事务"]
F --> G
G --> H["恢复核心主流程"]
D --> I["根因分析"]
H --> I
I --> J["统一加锁顺序"]
I --> K["缩短事务边界"]
I --> L["优化索引与扫描"]
I --> M["退避重试 + 幂等"]
J & K & L & M --> N["系统化治理"]
两者的差距在于:你是在处理一个现象,还是在拆解一套并发系统的问题。
后来做其他项目也一样——处理完故障只是第一步,让同类问题不再以同样方式重来,才算把事情做完。
结语:别把 kill 当成答案,把它当成最后一道止血手段
回到最开始那个问题:线上 MySQL 出现死锁怎么办?
我的答案现在会是:
- 第一,不要跳过诊断直接操作
- 第二,先分清楚是单次死锁,还是已经演化成锁等待堆积
- 第三,必要时可以 kill,但一定是基于业务影响和阻塞链分析后的决策
- 第四,止血之后一定要推到根治
如果只把问题理解成找个事务杀掉,那你的上限大概率就是故障来了会灭火。
但如果从诊断、止血、根因分析、并发优化和系统设计去拆这件事,处理的就是一整套线上稳定性问题。
从 kill 进程到系统化治理,这就是我这次故障之后最大的收获。