线上 MySQL 出了死锁怎么办?大多数回答的第一句都是:把卡住的事务 kill 掉。

这句话不算错,但如果处理只停在这里,思路就还在反应式操作阶段——看到故障先做动作,至于为什么会发生、做完会不会再来一次,没有一套完整的方法。

我对这个问题的理解,就是在一次线上故障里被重新塑造的。

那次故障的表象是:数据入库流程和后台扫描任务同时运行,数据库开始出现锁冲突,随后接口 RT 上升、任务堆积、环境整体卡顿。

一开始,团队里最自然的声音就是:先把那几个长事务 kill 掉,让业务恢复再说。

但复盘下来我才意识到,kill 只是止血动作。线上口语里常说“kill 进程”,其实就是 kill 对应的数据库会话,让事务回滚并释放锁。更有价值的问题是:有没有一套从诊断、止血到根治的完整思路。

下面我结合这次场景,把处理 MySQL 线上死锁的经验整理一遍。

一、事故背景:数据入库与扫描任务同时命中同一批数据

先讲一下当时的业务场景。

系统里有一张核心业务表,记录外部数据的入库结果和处理状态。为了方便讨论,这里假设表名叫 import_result。表里有几个关键字段:

  • biz_id:业务唯一标识
  • status:当前处理状态,比如 INITSUCCESSRETRY
  • update_time:最后更新时间
  • version:版本号或乐观锁字段

线上同时存在两类流程。

1. 数据入库流程

上游系统持续推送数据,服务端收到后会批量入库。入库逻辑更接近一种 upsert

  • 如果记录不存在,就插入新数据
  • 如果记录已存在,就根据业务主键更新状态、时间戳、结果字段
  • 一批数据会放在一个事务里提交

这条流程的特点是:写入频繁、事务批量、对时效敏感。

2. 后台扫描任务

系统里还有一个定时扫描任务,每隔一段时间就去捞取 INITRETRY 这类状态的数据,做补偿、重试或者归档。

扫描任务的大致逻辑是:

  • 查询满足条件的记录
  • 把选中的记录改成处理中状态
  • 执行后续补偿逻辑
  • 再更新处理结果

这种任务的特点是:批量处理、带范围查询、容易扫到热点数据。

问题就出在这里:入库流程和扫描任务,命中了同一批记录。

更糟糕的是,它们对这些记录的访问方式并不完全一致:

  • 入库流程按业务主键逐条更新
  • 扫描任务按状态和时间范围批量扫描
  • 两边事务都不算小
  • 应用层对失败事务还有快速重试

于是,一个本来只是偶发冲突的问题,迅速演化成了环境级卡顿。

二、先把一个事说清楚:死锁不等于环境卡顿

我后来再回头看这次事故,最想先讲清楚的一点是:别把“死锁”和“环境卡顿”混成一件事。

对于 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 进程到系统化治理,这就是我这次故障之后最大的收获。