一次多线程批处理漏数据问题排查:我如何从日志反推出 SQL 并定位根因

0 阅读15分钟

摘要

这篇文章复盘了一次真实的多线程批处理漏数据问题排查过程。问题表象是:批处理任务执行后,部分对象已经产生结果数据,但在任务维度却查不到对应明细,导致整条链路呈现出“前半段成功、后半段缺失”的异常状态。

这类问题最大的难点在于,它不是直接报错,也不是全量失败,而是只漏掉了一部分数据,特别容易让人误判方向。本文重点分享了我如何从异常现象入手,借助日志提取关键主键,逐步反推出数据库查询路径,并最终结合 SQL、表数据和代码链路定位到根因。

适合有批处理、多线程、事务排障经验或需求的后端开发同学阅读。

前言

后端开发里最难排查的问题,往往不是那种一眼就能看到报错的异常,而是这种:

看起来流程已经跑完了,但结果却少了一部分数据。

前段时间我在项目里碰到了一次典型的多线程批处理问题。某个批处理任务执行完成后,业务侧反馈说有一批对象的处理结果不一致:

  • 一部分结果数据已经生成
  • 过程记录也能查到
  • 但在任务维度却查不到对应明细

这种问题最麻烦的地方在于,它不是“全部失败”,而是“部分成功、部分缺失”。

从排查角度看,这比直接报错更难,因为它会制造很多误导信息。

这篇文章我想复盘一下这次排查过程,重点分享以下几点:

  1. 遇到这类问题时,我第一轮是怎么收缩怀疑范围的
  2. 多线程日志很乱时,应该怎么找突破口
  3. 怎么从日志里的主键反推出 SQL
  4. 最后又是如何把根因真正抠出来的

一、问题背景

项目中有一段批量处理逻辑,用来对一批对象执行统一处理。由于一次处理的数据量比较大,系统在实现上采用了多线程并发处理,目的是缩短整体执行时间。

正常情况下,一次完整处理结束后,会在数据库中形成以下几类数据:

  • 批次主记录
  • 批次明细记录
  • 过程记录
  • 过程明细记录
  • 状态更新记录

也就是说,这不是单表写入,而是一条相对完整的处理链路。

某次执行后,业务侧在核查结果时发现,有一批对象出现异常。它们具备以下共同特征:

  • 前面的结果数据已经存在
  • 某些过程记录已经落库
  • 但在批次维度中查不到对应对象的明细
  • 导致后续核对和追溯都出现困难

从表面上看,这像是“任务没有完全成功”;

但从数据库现象上看,又不是“全部失败”。

这种状态说明:

处理链路并没有完全中断,而是只在某个阶段出现了断裂。

二、问题现象

为了避免一上来就陷进代码,我先把现象理清楚。

这批异常数据大致表现如下:

数据项状态
批处理主流程已触发
结果数据已生成
过程记录表存在数据
过程明细表数据完整
批次维度可追溯

如果把这个问题换成更直白的话来说,就是:

对象似乎处理过了,但在批次里又像没处理完整。

这类问题的危险点在于,它会让人一开始误以为是:

  • 查询条件有问题
  • 页面展示有延迟
  • 任务号关联错误
  • 明细只是暂时没刷出来

但如果真按这个方向一直查下去,很容易越查越偏。

三、第一轮排查:我先怀疑了什么

遇到这种“部分成功、部分缺失”的问题,我一般不会先看具体代码实现,而是先列出可能性。

因为如果不先收敛方向,直接进代码只会把问题越看越大。

1)多线程是否存在部分任务执行失败

既然这是批量并发处理,那第一反应一定要放在并发执行本身:

  • 是否有部分线程执行异常
  • 是否存在任务提交成功但结果未收集
  • 是否主线程过早返回,导致后续任务未完整结束

如果是这里出了问题,就很容易出现“同一批次中,有的对象成功,有的对象丢失”。

2)事务边界是否不一致

第二个怀疑点是事务。

因为这种链路通常会涉及多张表,如果这些写库动作不在同一个事务边界内,或者中间异常没有被统一处理,就可能导致:

  • 前面的表写成功了
  • 后面的表没写进去
  • 最后表现成“数据只落了一半”

3)后置逻辑是否影响主链路

很多系统里,主流程执行完之后,都会追加一些附加动作,比如:

  • 状态派生
  • 补充记录生成
  • 通知同步
  • 额外校验逻辑

如果这些后置动作和主流程耦合太紧,一旦后置逻辑抛异常,就可能把主链路后半段打断。

4)是否只是查询口径问题

最后一个怀疑点也是最容易被忽略的:

有没有可能并不是数据没写,而只是“查法不对”。

比如:

  • 任务号关联有偏差
  • 状态字段未更新
  • 查询过滤条件过严
  • 页面口径和数据库口径不一致

所以这类问题不能凭感觉,必须回到数据库做验证。

四、第二轮排查:日志很多而且是多线程输出,我是怎么找突破口的

第一轮列完怀疑点之后,接下来就要找证据。

但多线程问题有个经典难点:

日志非常多,而且线程交叉输出,按时间顺序硬看几乎没有意义。

所以我没有选择从头到尾扫日志,而是换了一个思路:

按异常对象倒查日志。

也就是说,不从线程开始,也不从方法开始,而是从异常对象本身开始,反向去找它在日志里的完整处理轨迹。

我重点关注的日志信息

在排查过程中,我主要盯了这些字段:

  • 业务主键
  • 对象编号
  • 任务号
  • 流水号
  • 线程名
  • 关键方法标识
  • 异常堆栈
  • 写库前后日志

只要日志里这些主键没有丢,就算输出顺序是乱的,也依然可以把一条对象处理链路拼起来。

这一步最重要的经验

不是“看更多日志”,而是“围绕主键查日志”。

我后来采用的做法很简单:

  1. 先拿到异常对象列表
  2. 逐个对象去日志中搜索
  3. 把每个对象相关的关键主键记录下来
  4. 再用这些主键去反推数据库查询路径

这一轮下来,问题虽然还没有直接落到根因上,但至少已经从“全局异常”变成了“具体对象链路异常”。

五、第三轮排查:从日志反推出 SQL

这一步是整个排查过程中最关键的一步。

因为日志最多只能告诉你:

  • 这个对象处理过
  • 这个方法执行过
  • 某个地方可能报了异常

但日志不能直接告诉你:

  • 数据究竟写到了哪张表
  • 写到哪一步停住了
  • 哪些表有,哪些表没有
  • 是没写进去,还是没关联出来

所以最后还是要落到数据库验证。

而问题在于,日志一般不会打印完整 SQL,更多时候只会打印一些业务主键或者上下文字段。

因此这一步本质上就是:

从日志提取关键主键,再反推出应该查哪些表、怎么查。

我的处理思路

第一步:从日志中提取可串联的关键字段

例如:

  • biz_id
  • object_id
  • task_no
  • trace_no
  • record_no

这些字段未必同一条日志里全都有,但只要能在几条相关日志里拼起来,就足够了。

第二步:确定涉及的核心表

这里我用统一的通用表名表示:

  • batch_task
  • batch_task_detail
  • process_record
  • process_record_detail
  • task_state_log

这里的核心思路不是“全表扫”,而是先抓主链路上的关键表。

第三步:按链路逆向验证

我最后采用的是这样一条验证路径:

异常对象

日志中的 task_no / object_id / trace_no

查 batch_task

查 batch_task_detail

查 process_record

查 process_record_detail

查 task_state_log

对比正常对象与异常对象

第四步:必须找一条正常样本做对照

这一点非常关键。

如果只看异常数据,很容易看半天也不知道到底差在哪里。

所以我特意找了一条同批次里处理成功的对象,和异常对象做一一对照。

这一对照很快就能看出来:

  • 正常对象应该经过哪些表
  • 每张表应该留下什么痕迹
  • 异常对象到底断在哪一步

六、数据验证:到底是哪一步丢了

有了查询路径之后,接下来就是真正落库验证。

我这里特别建议,排查这类问题时不要一上来写一个超大的联表 SQL,而是按“表维度”逐步确认。

1)先查批次主表

先确认这批对象关联的批次主记录是否存在,任务号是否一致。

SELECT id, task_no, task_status, created_time
FROM batch_task
WHERE task_no = '123';

这一步的目的是先判断:

  • 是不是连批次主记录都没生成
  • 还是主记录存在,但后面的数据没跟上

2)再查批次明细表

确认异常对象是否真正挂到了批次明细中。

SELECT id, task_no, object_id, detail_status, created_time
FROM batch_task_detail
WHERE task_no = '123' AND object_id = '456';

如果这里查不到数据,而批次主表又存在,就说明问题大概率出现在“批次展开”这一段或者之后的逻辑里。

3)查过程记录表

接着看该对象是否已经形成了过程记录。

SELECT id, biz_id, object_id, record_no, record_status, created_time
FROM process_record WHERE object_id = '456' ORDER BY created_time DESC;

如果这一步已经查到数据,说明主流程前半段其实是走过的。

4)查过程明细表

这一层通常最能暴露问题,因为很多“主表有、明细没”的问题都会在这里浮出来。

SELECT id, record_no, step_code, step_status, created_time
FROM process_record_detail WHERE record_no = '789' ORDER BY created_time ASC;

如果主记录存在但过程明细缺失,或者步骤中断,就能进一步说明链路并未走完。

5)查状态更新记录

最后看状态更新逻辑有没有执行。

SELECT id, object_id, task_no, state_code, state_value, created_time
FROM task_state_log WHERE object_id = '456' ORDER BY created_time DESC;

这一层主要用来判断:

  • 是不是数据已经生成了,但状态没更新
  • 还是状态更新也只执行了一半

经过这一轮逐表验证,我最终确认了一件事:

这不是查询口径问题,而是链路中确实有一段写库逻辑被打断了。

七、日志示例:怎么从日志里拿到反推 SQL 的主键

日志片段 1:对象开始处理

2026-04-05 10:12:31.128 INFO  [batch-worker-3] c.demo.batch.BatchExecutor

  • start process, bizId=BIZ_1024, objectId=456, taskNo=123, traceNo=TRACE_A001

这条日志说明:

  • 对象确实进入了处理流程
  • 能拿到 bizId
  • 能拿到 objectId
  • 能拿到 taskNo
  • 能拿到 traceNo

这些字段足够反推出第一批 SQL。

日志片段 2:后置逻辑执行前后

2026-04-05 10:12:31.452 INFO  [batch-worker-3] c.demo.batch.PostActionService

  • execute post action, objectId=456, taskNo=123, recordNo=789

2026-04-05 10:12:31.467 ERROR [batch-worker-3] c.demo.batch.PostActionService

  • post action failed, objectId=456, taskNo=123, recordNo=789, message=unexpected null state

这组日志的作用非常大,因为它能说明:

  • 主流程前半段已经跑到了后置逻辑
  • 后置逻辑开始执行时,已经带出了 recordNo
  • 异常点出现在链路中后段

这就能把排查方向从“是不是没处理”收缩成“处理中后段被异常打断”。

八、代码示例:伪代码

public void executeBatch(List<String> objectIds, String taskNo) {
    List<Future<Void>> futures = new ArrayList<>();

    for (String objectId : objectIds) {
        futures.add(executorService.submit(() -> {
            handleSingleObject(objectId, taskNo);
            return null;
        }));
    }

    for (Future<Void> future : futures) {
        future.get();
    }
}

private void handleSingleObject(String objectId, String taskNo) {
    // 1. 生成主处理记录
    saveProcessRecord(objectId, taskNo);

    // 2. 生成过程明细
    saveProcessDetail(objectId, taskNo);

    // 3. 执行后置逻辑
    doPostAction(objectId, taskNo);

    // 4. 更新状态
    updateTaskState(objectId, taskNo);
}

这段代码的意义不在于展示真实实现,而在于向读者说明:

  • 这是一个并发批处理入口
  • 对单个对象的处理是链式的
  • 后置逻辑位于主链路中后段
  • 一旦这里异常,就可能导致链路不完整

九、最终定位:根因出在链路中后段的附加动作

把日志、SQL 验证结果和代码链路串起来之后,问题终于开始收口了。

最终定位结果可以总结成一句话:

主流程前半段已执行成功,但链路中后段的附加动作发生异常,导致后续部分数据没有完整落库。

也就是说,真实情况并不是:

  • 整个批处理都失败了
  • 多线程任务没有执行
  • 数据库根本没写

而是:

  1. 前面的结果数据已经写入
  2. 中后段附加动作执行时报错
  3. 后续状态或明细写入被打断
  4. 最终形成“前面有结果、批次里却查不到对应明细”的表象

这也是为什么这个问题初看特别像“查询没查到”,但实际上并不是查询问题。

十、这类问题为什么特别难查

回头复盘,这类问题难查主要有三个原因。

1)它不是彻底失败,而是“半成功”

全量失败通常很容易判断,因为现象统一。

但这种“前面成功、后面断掉”的问题会制造大量误导信息。

2)多线程让日志阅读成本陡增

单线程日志按时间顺序还能串起来,多线程日志一旦交叉输出,单纯通读基本没有意义。

3)数据链路长,表又多

当一个处理流程涉及主表、明细表、过程记录、状态更新和附加动作时,如果没有主键串联意识,很容易越查越散。

十一、修复思路

定位清楚根因之后,修复通常分三层。

1)补数据

针对已经漏掉的对象,按如下顺序处理:

  • 确认异常对象列表
  • 核查主表、明细表、过程表、状态表现状
  • 生成补数据 SQL
  • 先在测试环境验证
  • 确认无误后再处理正式环境

2)改代码

代码层面至少要做两件事。

第一,重新审视后置逻辑与主流程的耦合关系。

如果附加动作本质上属于“补充处理”,那它不应该轻易影响主链路的完整性。

第二,补关键日志。

建议至少补上:

  • 对象开始处理日志
  • 核心表写入前后日志
  • 后置逻辑前后日志
  • 异常对象统一汇总日志

3)做防御性优化

后续优化建议包括:

  • 多线程结果统一收集,不只负责提交任务
  • 主流程和附加动作尽量解耦
  • 关键链路增加校验或对账
  • 对异常对象进行集中汇总和告警

十二、这次排查给我的几个经验

1)多线程问题先看数据,再看代码

代码只能告诉你“理论应该怎么跑”,

数据才能告诉你“实际上发生了什么”。

2)日志排查的关键不是数量,而是主键

面对大量日志,最有效的方法不是全文通读,而是围绕主键串链路。

3)异常样本一定要和正常样本对照

单看异常对象,很多时候只能看到“有问题”;

但和正常对象一对比,问题往往会非常明显。

4)后置动作不要轻易污染主流程

所有附加动作,只要可能抛异常,就应该尽量避免直接影响主链路结果。

总结

这次问题排查最难的地方,不在于 SQL 本身有多复杂,也不在于代码量有多大,而在于它制造了一个非常迷惑的表象:

结果好像已经出来了,但链路其实没有完整走完。

最终真正帮我把问题定位出来的,不是继续猜,而是按下面这条路径一步步缩小范围:

现象确认 → 怀疑点收敛 → 日志定点排查 → 提取主键 → 反推 SQL → 对比正常与异常数据 → 结合代码链路定位根因

对我来说,这次排查最大的收获不是单纯修了一个问题,而是再次验证了一件事:

复杂批处理问题,最终一定要回到数据本身。