一次线上锁风暴复盘:号源表大范围 DELETE 的隐患与分批删除改造

场景:医院号源刷新业务,每次刷新完成后要"清掉上次刷新遗留的旧号源"。

现象:连续两天早高峰,全系统几十张表集体 Lock wait timeout,业务雪崩。

本文讲清楚三件事:原来这段删除代码有什么问题、为什么"全是行锁"大 DELETE 照样危险、我们最后怎么改的。


一、先搞清楚:这个删除是干什么的

号源刷新的写入模式是"新老共存,刷完再删旧":

  1. 刷新线程把从 HIS 拿到的号源写进 ihm_doctor_schedule_detail 表,每条记录带一个 trace_id(本次刷新的流水号);

  2. 写完之后,把上一次刷新 留下的、trace_id 不等于本次的旧数据删掉。

所以删除条件长这样:

sql 复制代码
DELETE FROM ihm_doctor_schedule_detail
WHERE dept_id = ?          -- 科室
  AND doctor_id = ?        -- 医生(可选)
  AND trace_id != '本次流水号'   -- 排除刚写入的新数据

一个科室含有多个医生的号源,跨 14~30 天,轻松就是几千上万行。这个 DELETE 不带 LIMIT,一条语句从头删到尾。

老代码里还留着一行注释:

bash 复制代码
// 死锁问题, 如果数据存在才执行删除, 避免表锁

写这行注释的前辈,显然已经在删除上踩过坑。可惜的是,他为这个方法配的"防护措施"其实一直没生效------这就是问题一。


二、问题一:@Transactional(timeout = 10) 是个"假防护"

原代码大致是这样的:

java 复制代码
// 类内部调用
this.deleteOldDeptSchedule(deptId, doctorId);   // ← 问题就出在这
​
@Transactional(timeout = 10)
public void deleteOldDeptSchedule(Long deptId, Long doctorId) {
    doctorScheduleDetailService.lambdaUpdate()...remove();
}

作者的意图很明确:这条 DELETE 可能很慢,最多给它 10 秒,超了就整体回滚,不许长事务拖累别人。

但这个注解从来没生效过。

为什么?Spring AOP 的"自己调自己"问题

Spring 的事务不是靠注解本身实现的,而是靠代理对象。外部调用进来时,请求先落到代理对象上,代理在方法前后织入"开启事务 / 提交事务"的逻辑,再转调真正的对象:

TypeScript 复制代码
外部调用 → Spring代理(开事务)→ 真正的ServiceImpl(跑业务)→ 提交/回滚

而 this. 调用是在对象内部直接调方法,压根不经过代理,代理的"开事务"逻辑根本没机会执行:

TypeScript 复制代码
同类内部 this. 调用 → 直接进真正的对象 → 什么事务都没有 → 每条 SQL 自动提交

验证方法也很简单:日志里根本看不到这条 DELETE 的事务编号,它每次都是单条语句自动提交。

所以线上真实的执行方式是:一条大 DELETE,自动提交,删完整个提交,没有任何超时保护。

顺便说一个有意思的事:这个注解没生效,某种意义上是"因祸得福"。如果它真的生效了------整个方法包一个 10 秒超时的事务------那 10 秒删不完就整体回滚,白删;而且事务期间锁全部攒着不放,反而更糟。后面改造时我们会讲到,正确答案不是"修好这个事务",而是干脆不要这个整体事务。


三、问题二:大 DELETE 到底危险在哪?

先区分两个概念,很多同学会混:

Lock wait timeout(锁等待超时) Deadlock(死锁)
含义 我要的锁被别人占着,等了 50 秒还没等到,放弃 两方互相等对方的锁,谁也走不了,InnoDB 立即砍掉一个
日志表现 Lock wait timeout exceeded; try restarting transaction,恰好 50001ms Deadlock found when trying to get lock,瞬间报错
我们线上日志 ✅ 就是这个,50 秒整 未直接出现,但风险真实存在

我们线上的雪崩日志里,这条 DELETE 反复出现 Lock wait timeout------它等不到锁,而它自己占着的锁又让别人(写入的 INSERT)也动不了,它是风暴的参与者之一。死锁没在日志里直接出现,不代表可以高枕无忧,下面解释为什么。

3.1 InnoDB 的 DELETE 到底锁了什么

很多人以为"DELETE 也就是行锁嘛,我删我的行,跟你删你的行不冲突"。现实要复杂一些:

  1. 删除是"边扫边锁"的 。DELETE 执行时,每扫描到一行就锁一行(RR 隔离级别下还会锁"间隙",也就是行与行之间的空档),锁要到事务提交才释放;

  2. 不带 LIMIT 的大删除,一口气锁几千上万行 。哪怕这些锁全是行锁、粒度很"细",架不住数量多------这段时间内,凡是碰到这个范围的写操作全部排队;

  3. 命中不命中索引,差别巨大 。命中索引时只锁匹配范围;一旦走全表扫描,锁面直接铺满整张表。而我们这个条件里的 trace_id != ? 恰恰是"排除型"条件,很难独走索引。

一句话:细粒度 × 大数量 = 事实上的大范围阻塞。这就是注释里说的"避免表锁"想防的那个效果------虽然 InnoDB 没有真正的表锁升级,但一条锁了几万行的 DELETE,体验上和表锁差不多:别人哪儿都动不了。

3.2 即使"全是行锁",为什么还会死锁?

死锁的本质不是"锁大锁小",而是两个事务拿锁的顺序不一致。看我们生产上的真实组合:

参与方 A:刷新线程,正在执行大 DELETE(条件 dept_id + trace_id != )

参与方 B :同一个科室并发的写入------saveBatchh 的 INSERT ... ON DUPLICATE KEY UPDATE,以及停诊的 UPDATE suspend_flag

经典的交叉等待长这样:

TypeScript 复制代码
时间线        刷新线程A(大DELETE)                 写入线程B(INSERT ON DUPLICATE)
──────       ─────────────────────                ───────────────────────────
 t1          扫描,锁住第 1~500 行
 t2                                          往唯一键位置插入新行
                                             → 需要在 A 已锁住的间隙里"打洞"
                                             → 等 A 的间隙锁
 t3          继续扫描第 501 行以后
              → 这行上有 B 持有的锁?
              → 等 B 释放
 t4          等 B ...............              等 A ...............
              ↓                                ↓
                    双方互相等待 → 死锁(InnoDB 选一个牺牲掉)

关键点有两个:

  1. 拿锁顺序不一致 。A 按扫描顺序从前往后锁,B 按唯一键定位,两者的"前进方向"不同,就存在交叉等待的可能。行锁再细,只要两个事务覆盖的行集合有交集且顺序不一致,死锁就有土壤;

  2. DELETE 和 INSERT 的锁类型天然容易打架 。DELETE 扫过间隙时会加间隙锁,而 INSERT 想往间隙里插数据要先拿"插入意向锁"------插入意向锁和间隙锁是互斥的 。于是一个拼命往里插、一个拼命往外删,双方又都可能反向需要对方已持有的锁,循环等待就形成了。

所以结论是:"全是行锁"绝不等于"没有死锁" 。死锁看的是锁的类型(行锁/间隙锁/意向锁)+ 获取顺序,而不是锁的粒度大小。MySQL 遇到死锁会立即选一个事务回滚(通常选代价小的那个),报 Deadlock found------业务直接失败。

3.3 大 DELETE 的另外两笔账

除了锁,大删除还有两个隐性成本:

  • 回滚成本高:事务越长,回滚段(undo log)越大。真出了事要回滚时,几万行的 DELETE 回滚本身又是一场小型灾难,期间照样锁着;

  • 拖垮主从延迟:一条删几万行的语句进 binlog,从库照着单线程回放,主库几秒干完的活从库要追半天。


四、我们的改造方案

改动就三处,核心思想一句话:把"一颗大雷"拆成"一串小雷",每颗响完立刻松手。

4.1 调用点:绕开 this.,走代理

TypeScript 复制代码
// 改前
this.deleteOldDeptSchedule(deptId, doctorId);
​
// 改后:类里本来就有 @Resource 自注入的接口字段,直接用它
doctorScheduleDetailService.deleteOldDeptSchedule(deptId, doctorId);

同时把方法声明补进了 DoctorScheduleDetailService 接口------原方法压根不在接口上,这也是当初只能 this. 调用的原因。

4.2 删除逻辑:LIMIT 500 分批循环

java 复制代码
@Override
public void deleteOldDeptSchedule(Long deptId, Long doctorId) {
    log.info("删除历史号源,{},{}", deptId, doctorId);
    // 不加整体事务: 每批独立提交, 单条 DELETE 持锁时间可控
    String traceId = MDC.get(Constants.TRACE_ID);
    final int batchSize = 500;
    final int maxBatch = 200;          // 上限 10 万行,防御性兜底
    int total = 0;
    for (int i = 0; i < maxBatch; i++) {
        int deleted = baseMapper.deleteOldDeptScheduleBatch(deptId, doctorId, traceId, batchSize);
        total += deleted;
        if (deleted < batchSize) {     // 不满一批说明删完了
            break;
        }
        log.info("删除历史号源分批进行中,{},{} 已删{}行", deptId, doctorId, total);
    }
    if (total > 0) {
        log.info("删除历史号源完成,{},{} 共删{}行", deptId, doctorId, total);
    }
}

对应的 SQL(Mapper XML):

XML 复制代码
<delete id="deleteOldDeptScheduleBatch">
    delete from ihm_doctor_schedule_detail
    where dept_id = #{deptId}
    <if test="doctorId != null">
        and doctor_id = #{doctorId}
    </if>
    and trace_id != #{traceId}
    limit #{limit}
</delete>

为什么 500 这个数:一批 500 行,正常情况下毫秒级完成、几十个锁,删完立刻提交释放。锁的"持有时间"从原来的一整条长语句,压缩成一个个小片段,别人有缝可钻,死锁窗口也跟着大幅缩小。

为什么设 200 批上限:防止条件异常时(比如数据量暴涨)循环删到天荒地老。到上限就停,剩下的下一轮刷新接着删------反正每 8~10 分钟就有一轮。

4.3 关键取舍:为什么是"去掉事务",而不是"修好事务"

这是整个改造最容易纠结的点,把三个选项摆在一起看就清楚了:

方案 效果 结论
A. 维持现状(this. 调用,注解失效) 单条大 DELETE 自动提交 就是这次要解决的问题 ❌
B. 修好调用 + 方法上加整体事务 循环 200 批共用一个事务,所有锁攒到最后一次 commit 才释放------每批删的行都锁着,等于比原来锁得更久;而且真超过 timeout,前面删的全部回滚,白干 ❌ 看似修好了,实际更糟
C. 走代理 + 不加整体事务(我们选的) 每批独立提交、锁随批释放,行为和线上一直跑着的(自动提交)完全一致,只是把"一次长锁"拆成"多次短锁" ✅

方案 B 恰恰是很多人的第一反应------"注解失效了,那就修好它"。但这里的正确答案是:那个 @Transactional(timeout = 10) 的设计本身就不适合分批删除。事务的存在意义是"全做或全不做",而分批删除恰恰不需要这个语义(见下一条)。

4.4 删一半中断了怎么办?------ 幂等兜底

分批删除牺牲了"原子性":极端情况下可能删了一半进程挂了,表里残留一部分旧行。这不是问题,因为这套流程天生自带兜底:

  1. 删除条件是幂等的 :trace_id != 本次流水号,下一轮刷新完成后会再次执行同样的删除,残留的旧行(trace_id 依然不是"最新")照样被清掉;

  2. 每 8~10 分钟一轮刷新:中断的数据最多"多活"一轮;

  3. 残留旧行不产生业务错误:查询侧按最新数据展示,旧数据只是多余,不是脏数据------这也是为什么这个场景可以放心不原子。

一句话:用"最终删干净"换"每次只锁一小会儿",这笔账是划算的。


五、改完之后的行为对照

维度 改前 改后
执行方式 一条大 DELETE,扫多少锁多少 每批 500 行,删完立即提交
锁持有时间 整条语句执行期间(秒级甚至更久) 每批毫秒级
超时保护 @Transactional(timeout=10) 失效,形同虚设 不需要整体事务;单批天然短,超时风险趋近于零
死锁窗口 大(长时间持锁 + 覆盖范围大) 小(锁片段化,且每批之间有间隙让别人过)
中断后 --- 残留下轮刷新按 trace_id 幂等清理
日志可观测 只有一条"开始删除" 每批进度 + 最终总行数,线上问题一眼看清删了多少

六、写在最后

复盘下来,这个案例其实就三层:

  1. 表面问题 :一条不带 LIMIT 的大 DELETE,在高峰期反复 Lock wait timeout,成为全系统锁风暴的参与者之一;

  2. 隐藏问题: 之前的同事预判到了大删除的风险,加了 @Transactional(timeout = 10) 想兜底,但 this. 调用让注解默默失效了很久都没人发现------防护措施本身也需要验证,"写了"和"生效了"是两回事;

  3. 解法的本质 :没有引入任何新依赖、新组件,就是把长锁拆短、让出资源、配合幂等做兜底。分批 + 不要整体事务 + 幂等重入,这套组合在任何"删一批历史数据"的场景里都通用。

最后留两个自查建议,遇到类似的删除/更新慢 SQL 可以先问自己:

  • 这条 SQL 是谁调的?同类里 this. 调的吗? 注解生效了没有?

  • 这条语句一次锁多少行、持锁多久?能不能加 LIMIT 分批? 中断了有没有下一轮兜底?

能把这两问答上来,一半的慢 SQL 锁问题在代码评审阶段就能拦住。


(本文基于某互联网医院平台一次真实线上故障的复盘整理,日志细节已脱敏。)

相关推荐
爱喝雪碧的可乐1 小时前
OpenAI Codex完成率
大数据·人工智能·1024程序员节
Solange__1 小时前
文旅景区怎么用AI生成3D导览?从航拍采集、地标重建到网页漫游的完整方案
人工智能·3d·语言模型·自然语言处理·材质·贴图·1024程序员节
夏幻灵1 小时前
Web 渲染模式:SSR、CSR、SEO 与前后端分离
1024程序员节
m0_622023531 小时前
产业资本运作:新金融博弈下的产业链安全底盘搭建
大数据·深度学习·重构·1024程序员节
geats人山人海1 小时前
洛谷好题推荐 2026年10月24日
1024程序员节
扶尔魔ocy1 小时前
【C++开发】从裸指针到智能指针
c++·智能指针·1024程序员节
xUxIAOrUIII1 小时前
CS329a-推理时计算扩展
1024程序员节
KryHan1 小时前
【JavaScript】第三章数组
1024程序员节
小南家的青蛙1 小时前
Redis的资料
redis·缓存·1024程序员节