场景:医院号源刷新业务,每次刷新完成后要"清掉上次刷新遗留的旧号源"。
现象:连续两天早高峰,全系统几十张表集体
Lock wait timeout,业务雪崩。本文讲清楚三件事:原来这段删除代码有什么问题、为什么"全是行锁"大 DELETE 照样危险、我们最后怎么改的。
一、先搞清楚:这个删除是干什么的
号源刷新的写入模式是"新老共存,刷完再删旧":
-
刷新线程把从 HIS 拿到的号源写进
ihm_doctor_schedule_detail表,每条记录带一个trace_id(本次刷新的流水号); -
写完之后,把上一次刷新 留下的、
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 也就是行锁嘛,我删我的行,跟你删你的行不冲突"。现实要复杂一些:
-
删除是"边扫边锁"的 。DELETE 执行时,每扫描到一行就锁一行(RR 隔离级别下还会锁"间隙",也就是行与行之间的空档),锁要到事务提交才释放;
-
不带 LIMIT 的大删除,一口气锁几千上万行 。哪怕这些锁全是行锁、粒度很"细",架不住数量多------这段时间内,凡是碰到这个范围的写操作全部排队;
-
命中不命中索引,差别巨大 。命中索引时只锁匹配范围;一旦走全表扫描,锁面直接铺满整张表。而我们这个条件里的
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 选一个牺牲掉)
关键点有两个:
-
拿锁顺序不一致 。A 按扫描顺序从前往后锁,B 按唯一键定位,两者的"前进方向"不同,就存在交叉等待的可能。行锁再细,只要两个事务覆盖的行集合有交集且顺序不一致,死锁就有土壤;
-
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 删一半中断了怎么办?------ 幂等兜底
分批删除牺牲了"原子性":极端情况下可能删了一半进程挂了,表里残留一部分旧行。这不是问题,因为这套流程天生自带兜底:
-
删除条件是幂等的 :
trace_id != 本次流水号,下一轮刷新完成后会再次执行同样的删除,残留的旧行(trace_id 依然不是"最新")照样被清掉; -
每 8~10 分钟一轮刷新:中断的数据最多"多活"一轮;
-
残留旧行不产生业务错误:查询侧按最新数据展示,旧数据只是多余,不是脏数据------这也是为什么这个场景可以放心不原子。
一句话:用"最终删干净"换"每次只锁一小会儿",这笔账是划算的。
五、改完之后的行为对照
| 维度 | 改前 | 改后 |
|---|---|---|
| 执行方式 | 一条大 DELETE,扫多少锁多少 | 每批 500 行,删完立即提交 |
| 锁持有时间 | 整条语句执行期间(秒级甚至更久) | 每批毫秒级 |
| 超时保护 | @Transactional(timeout=10) 失效,形同虚设 | 不需要整体事务;单批天然短,超时风险趋近于零 |
| 死锁窗口 | 大(长时间持锁 + 覆盖范围大) | 小(锁片段化,且每批之间有间隙让别人过) |
| 中断后 | --- | 残留下轮刷新按 trace_id 幂等清理 |
| 日志可观测 | 只有一条"开始删除" | 每批进度 + 最终总行数,线上问题一眼看清删了多少 |
六、写在最后
复盘下来,这个案例其实就三层:
-
表面问题 :一条不带 LIMIT 的大 DELETE,在高峰期反复
Lock wait timeout,成为全系统锁风暴的参与者之一; -
隐藏问题: 之前的同事预判到了大删除的风险,加了
@Transactional(timeout = 10)想兜底,但this.调用让注解默默失效了很久都没人发现------防护措施本身也需要验证,"写了"和"生效了"是两回事; -
解法的本质 :没有引入任何新依赖、新组件,就是把长锁拆短、让出资源、配合幂等做兜底。分批 + 不要整体事务 + 幂等重入,这套组合在任何"删一批历史数据"的场景里都通用。
最后留两个自查建议,遇到类似的删除/更新慢 SQL 可以先问自己:
-
这条 SQL 是谁调的?同类里
this.调的吗? 注解生效了没有? -
这条语句一次锁多少行、持锁多久?能不能加 LIMIT 分批? 中断了有没有下一轮兜底?
能把这两问答上来,一半的慢 SQL 锁问题在代码评审阶段就能拦住。
(本文基于某互联网医院平台一次真实线上故障的复盘整理,日志细节已脱敏。)