友链提交
请认真填写以下信息,谢谢!

博客信息

网站名称:楠笙

网站地址:https://blog.nanshengwx.cn/

Logo链接:https://blog.nanshengwx.cn/upload/logo.png

描述:记录生活与技术,空谈误国,实干兴邦

RSS地址:https://blog.nanshengwx.cn/rss.xml

(请填写完整的网址,例如:https://www.example.com)
(贵站展示本站链接的页面地址,一般是友链页面,填写后将自动验证友链关系有效性)
(用于抓取文章)
(用于接收通知)

Spring Boot 异步任务死锁排查:@Async 父子任务共用线程池导致的 N+1 性能雪崩与任务丢失

楠笙
2026-01-09
点 赞
0
热 度
13
评 论
0
  1. 首页
  2. 后端
  3. Spring Boot 异步任务死锁排查:@Async 父子任务共用线程池导致的 N+1 性能雪崩与任务丢失

文章摘要

我是笨蛋

作为一名后端开发,最怕的不是代码报错,而是代码不报错,但也不干活

最近我就遇到了这样一个棘手的生产问题:我们的日志拉取系统每天凌晨 3:10 准时启动,负责拉取 AD千川本地推 三种类型的广告日志。然而,最近几天客户反馈,本地推的日志死活出不来,而 AD 和 千川 却一切正常。

更诡异的是,只要我手动把 AD 和 千川 的任务停掉,本地推的日志就立刻正常出现了。这简直就像是本地推被另外两个“霸凌”了一样。


一、 案发现场:诡异的现象

系统每天凌晨 3:10 启动定时任务,按顺序提交三个类型的日志拉取任务。

// AdTask.java
public void pullQcLog(){
    // 1. 拉取 AD
    logReport.setAdPlanType("1");
    batchRecordService.pullLog(...);
    
    // 2. 拉取 千川
    logReport.setAdPlanType("2");
    batchRecordService.pullLog(...);
    
    // 3. 拉取 本地推 (就是它不见了!)
    logReport.setAdPlanType("3");
    batchRecordService.pullLog(...);
}

观察到的日志现象:

  1. 开始:3:10 分,日志显示三个任务都提交了。
  2. 过程:随后日志里疯狂刷新慢 SQL 警告,全是 SELECT COUNT(*) ...
  3. 结束:3:39 千川跑完,4:13 AD 跑完。
  4. 消失:4:13 之后,日志戛然而止。直到早上 7:55 服务器重启,本地推的任务一行日志都没打出来

二、 抽丝剥茧:寻找线索

面对这个现象,我整理了三个核心疑点:

  1. 为什么单独跑能行? -> 说明代码逻辑没 bug,问题出在并发资源竞争
  2. 为什么会有大量慢 SQL? -> 说明系统在做低效操作,可能堵塞了线程。
  3. 为什么 4 点多前两个跑完了,第三个还不跑? -> 这点最反直觉。按理说资源释放了,排队的任务该执行了。除非……发生了死锁

线索 1:性能杀手 N+1

顺着慢 SQL 的线索,我找到了千川日志的去重逻辑:

// QcLogServiceImpl.java (优化前)
for (QcLog log : logs) {
    // 每一条日志都查一次数据库,判断是否存在
    Long count = baseMapper.selectCount(queryWrapper); 
    if (count == 0) {
        filteredLogs.add(log);
    }
}

这就是典型的 N+1 问题。如果有 2000 条日志,就要查 2000 次数据库。千川的数据量本来就大,这直接导致处理线程长时间被占用,数据库连接池也被打满。

线索 2:隐蔽的线程池死锁

解决了慢 SQL 只是提升速度,但为什么本地推会“丢失”?我仔细检查了任务提交的逻辑,发现了一个致命的组合:

  1. 入口方法 LogReportServiceImpl.pullLog 加了 @Async 注解(异步执行)。
  2. 这个方法内部,又会拆分出成百上千个子任务,提交给同一个线程池
  3. 父任务会阻塞等待子任务完成(future.get())。

灾难发生了:

  • 3:10:00:AD 父任务抢占了线程 A。
  • 3:10:01:千川 父任务抢占了线程 B。
  • 3:10:02:本地推 父任务尝试提交,但线程池的核心线程可能已经被 A 和 B 占满了(或者它被排在了 A 和 B 产生的几千个子任务后面)。

这时候,线程 A 和 B 正在阻塞等待它们的子任务完成。但子任务可能因为线程池满了,正在队列里排队。
父任务占着坑等子任务,子任务在排队等坑位。 这就是线程池的资源耗尽死锁

而本地推的任务,作为最后提交的“倒霉蛋”,被死死压在队列的最后面。直到 7:55 服务器重启,它都没能拿到 CPU 时间片。


三、 雷霆手段:三管齐下

找到病灶后,修复方案就呼之欲出了。

1. 疏通河道:优化去重逻辑

首先,把那个笨重的 N+1 查询改掉。思路很简单:批量查询,内存比对

// QcLogServiceImpl.java (优化后)
// 1. 提取所有待入库日志的 logId
Set<String> logIds = logs.stream().map(QcLog::getLogId).collect(Collectors.toSet());

// 2. 一次性查询数据库中已存在的 logId (WHERE log_id IN (...))
List<String> existingLogIds = baseMapper.selectLogIds(logIds);

// 3. 在内存中过滤
List<QcLog> filteredLogs = logs.stream()
    .filter(log -> !existingLogIds.contains(log.getLogId()))
    .collect(Collectors.toList());

效果:数据库查询次数从 N 次降为 1 次,千川任务的处理速度提升了数十倍。

2. 补齐短板:数据库索引优化

光改代码还不够,我检查了数据库表结构,发现 zy_qc_log 表的 log_id 字段竟然没有索引!难怪之前的慢 SQL 那么严重,每次去重都是全表扫描。

执行 SQL 补救:

-- 针对 log_id 建立索引,让去重查询秒级返回
CREATE INDEX idx_qc_log_log_id ON zy_qc_log (log_id);

-- 顺手把错误日志表的查询也优化了
CREATE INDEX idx_log_err_adv_type ON zy_log_err (advertiser_id, ad_plan_type, del_flag);

效果:配合代码优化,数据库负载瞬间下降,彻底消灭了慢 SQL 报警。

3. 解除死锁:移除异步注解

为了彻底解决“本地推丢失”的问题,我做了一个违背祖宗(不是)的决定:去掉 @Async

// LogReportServiceImpl.java

// @Async  <-- 删掉它!
public void pullLog(...) {
    // ...
}

逻辑变化:

  • 以前(并行):AD、千川、本地推 同时提交,大家一起抢线程池,导致拥堵和死锁。
  • 现在(串行)
    1. 主线程先执行 AD,做完才往下走。
    2. AD 做完,主线程执行 千川。
    3. 千川 做完,主线程执行 本地推。

虽然变成了串行,但因为我们已经优化了去重性能,整体耗时并不会增加多少。最重要的是,这种方式绝对稳定。本地推再也不用担心被“插队”或“挤掉”了。


四、 总结

这次排查给我最大的教训是:

  1. 敬畏 N+1:在处理批量数据时,永远不要在循环里调 SQL,这是性能的第一杀手。
  2. 索引不能忘:业务字段(如 log_id)如果用于高频查询或去重,必须加索引。
  3. 慎用 @Async:父子任务共用同一个线程池是并发编程的大忌。在资源有限的情况下,串行往往比并行更可靠

用键盘敲击出的不只是字符,更是一段段生活的剪影、一个个心底的梦想。希望我的文字能像一束光,在您阅读的瞬间,照亮某个角落,带来一丝温暖与共鸣。

楠笙

enfj 主人公

站长

具有版权性

请您在转载、复制时注明本文 作者、链接及内容来源信息。 若涉及转载第三方内容,还需一同注明。

具有时效性

欢迎来到楠笙的站点,为您导航全站动态

38 文章数
7 分类数
74 评论数
10标签数

热门文章