作为一名后端开发,最怕的不是代码报错,而是代码不报错,但也不干活。
最近我就遇到了这样一个棘手的生产问题:我们的日志拉取系统每天凌晨 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(...);
}
观察到的日志现象:
- 开始:3:10 分,日志显示三个任务都提交了。
- 过程:随后日志里疯狂刷新慢 SQL 警告,全是
SELECT COUNT(*) ...。 - 结束:3:39 千川跑完,4:13 AD 跑完。
- 消失:4:13 之后,日志戛然而止。直到早上 7:55 服务器重启,本地推的任务一行日志都没打出来。
二、 抽丝剥茧:寻找线索
面对这个现象,我整理了三个核心疑点:
- 为什么单独跑能行? -> 说明代码逻辑没 bug,问题出在并发或资源竞争。
- 为什么会有大量慢 SQL? -> 说明系统在做低效操作,可能堵塞了线程。
- 为什么 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 只是提升速度,但为什么本地推会“丢失”?我仔细检查了任务提交的逻辑,发现了一个致命的组合:
- 入口方法
LogReportServiceImpl.pullLog加了@Async注解(异步执行)。 - 这个方法内部,又会拆分出成百上千个子任务,提交给同一个线程池。
- 父任务会阻塞等待子任务完成(
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、千川、本地推 同时提交,大家一起抢线程池,导致拥堵和死锁。
- 现在(串行):
- 主线程先执行 AD,做完才往下走。
- AD 做完,主线程执行 千川。
- 千川 做完,主线程执行 本地推。
虽然变成了串行,但因为我们已经优化了去重性能,整体耗时并不会增加多少。最重要的是,这种方式绝对稳定。本地推再也不用担心被“插队”或“挤掉”了。
四、 总结
这次排查给我最大的教训是:
- 敬畏 N+1:在处理批量数据时,永远不要在循环里调 SQL,这是性能的第一杀手。
- 索引不能忘:业务字段(如
log_id)如果用于高频查询或去重,必须加索引。 - 慎用 @Async:父子任务共用同一个线程池是并发编程的大忌。在资源有限的情况下,串行往往比并行更可靠。
默认评论
Halo系统提供的评论