Skip to content

一个失败了两年的定时任务:从 Dubbo 超时到增量同步 ​

一、一个停留在两年前的页面 ​

当时,我入职还不到三个月。

某天,一位业务人员反馈:某个页面展示的数据一直停留在两年前,为什么没有更新?

接到这个问题时,我对相关功能并不熟悉,也不清楚这些数据由哪个服务负责维护。

于是,我开始检查相关服务。

排查过程中,我先发现了 OOM(OutOfMemoryError),随后沿着相关代码和任务执行过程,发现这些数据是由一个每天凌晨运行的 XXL-JOB 定时任务负责同步的。

进入 XXL-JOB Admin 查看执行记录后,我发现这个任务在能够查到的历史记录中,没有一次执行成功。

结合业务页面的数据情况,基本可以确认:这项数据同步功能已经约两年没有正常完成。

至于 XXL-JOB 当时保留了多少天的执行记录,我已经记不清了,可能是半年左右。但判断问题持续时间,并不完全依赖调度日志。

因为这个任务本身负责向目标系统写入数据,而业务端最新的数据恰好停留在两年前。

一个运行了多年的功能,就这样长期处于失效状态,直到业务人员发现数据异常,才重新进入排查流程。

二、技术问题:从 OOM 到无边界查询 ​

2.1 原来的同步方式 ​

继续阅读任务代码后,我发现原来的执行过程并不复杂。

A 系统先查询自己的数据库,再将查询结果通过 Dubbo 调用传递给 B 系统,由 B 完成后续数据处理和写入。

整个流程可以概括为:

text
XXL-JOB 每天凌晨触发
          ↓
A 系统查询自身数据库
          ↓
一次性获取全部符合程序查询条件的数据
          ↓
通过 Dubbo 调用 B 系统
          ↓
B 系统处理并写入数据

真正的问题出在 A 系统的数据库查询。

原有 SQL 没有使用 WHERE 条件限制数据范围。

也就是说,任务每天执行时,都会尝试读取相关表中的全部历史数据。

在业务刚开始运行时,数据量不大,这种写法可能不会立即暴露问题。

但数据会随着业务运行持续积累。

假如每天增加一些记录,那么一年、两年之后,查询结果可能已经与最初完全不是一个规模。

而原有程序并没有为这种增长设置边界。

2.2 Dubbo 超时与 OOM 是两个不同阶段的问题 ​

随着数据量增长,原来的处理方式逐渐无法正常完成。

一方面,A 系统会将大量数据一次性交给 B 系统处理。

B 需要较长时间完成这些数据的处理和写入,导致 Dubbo 调用超时。

另一方面,随着查询结果进一步增大,A 系统甚至可能在查询自身数据库时就发生 OOM。

这里需要区分两个问题。

Dubbo 超时,反映的是远程调用无法在规定时间内完成。

OOM,反映的是本地处理大量数据时,内存资源已经无法承受。

二者虽然表现不同,但都与原来缺乏数据规模限制的处理方式有关。

如果只是调大 Dubbo 超时时间,可能暂时缓解调用等待问题,却不能消除数据继续增长的风险。

如果只是增加 JVM 堆内存,也不能保证未来数据量更大时不会再次出现问题。

因此,我没有把调整超时配置或者扩大堆内存作为主要修复方向。

我更关注的是:

为什么一个每天执行的定时任务,需要在每次运行时重新读取全部历史数据?

三、技术修复:先恢复全量,再改为增量 ​

3.1 第一次调整:每批处理 50 条 ​

最初,我没有立即改变整个任务的数据同步范围,而是先解决一次性处理过多数据的问题。

修改后的处理方式是:

text
XXL-JOB
    ↓
A 系统分批查询
    ↓
每批 50 条
    ↓
调用 B 系统处理
    ↓
继续下一批
    ↓
直到完成本次同步

这样,单次查询和单次 Dubbo 调用的数据量都受到限制。

需要注意,分批处理并不代表总工作量减少了。

假设原来需要处理几万条记录,那么改为每批 50 条之后,仍然需要处理这些记录,只是将一次大规模操作拆成多次较小的操作。

这种调整的主要收益,是避免一次查询或一次 RPC 调用承担全部数据。

至于执行耗时,我印象中分批后的全量任务仍然需要较长时间,可能达到几十分钟,但现在已经没有原始日志能够核实,因此这里不使用具体数字作为性能结论。

这次修改首先解决的是任务能否完成的问题,而不是追求执行速度提高多少倍。

3.2 连续进行约一周的全量运行 ​

完成分批改造后,我并没有马上将任务切换成只处理最近几天的数据。

而是先让它连续运行了约一周的全量同步。

这是整个修复过程中的一个过渡阶段。

毕竟,这个任务此前已经长期失效,需要先恢复对历史数据的处理能力。

通过分批方式,全量任务不再需要一次性读取和传输全部记录。

随后,我才开始考虑长期运行策略。

因为即使全量同步能够完成,每天重新处理所有历史数据仍然没有必要。

而且,历史数据还会继续增长。

今天能够在可接受时间内完成的全量任务,并不意味着几年之后仍然如此。

3.3 第二次调整:只处理最近 7 天的数据 ​

最终,我将日常任务的数据获取范围调整为最近 7 天。

这里选择 7 天,而不是仅查询昨天或最近 1 天,并不是为了随意扩大查询范围。

主要考虑的是定时任务的失败补偿窗口。

例如,一个每天凌晨执行的任务,可能因为服务异常、网络问题或其他原因,某一天没有正常完成。

如果只查询最近 1 天的数据,后续恢复时就可能漏掉失败期间需要同步的记录。

而采用最近 7 天的重叠窗口,即使任务连续几天执行失败,恢复运行后仍然有机会重新处理这些数据。

当然,这个窗口并不意味着能够补偿任意长时间的任务中断。

超过窗口范围的数据,仍然需要另外考虑补偿方式。

3.4 重复同步如何处理? ​

采用最近 7 天的查询窗口,会产生一个新问题:

同一条业务数据可能被连续多天重复获取。

如果每次同步都直接插入新记录,就可能产生重复数据。

好在 B 系统的写入方式是根据业务 ID + 时间进行 Upsert。

当相同业务 ID 和时间对应的数据再次到达时,执行已有记录的更新或插入处理,而不是简单地每次新增一条。

这为重叠窗口提供了重复执行的基础。

因此,最终的方案形成了两个互相配合的约束:

  • 每批 50 条,控制单次处理规模。
  • 最近 7 天,限制日常处理范围,同时保留一定的失败补偿能力。

B 系统再通过业务 ID 与时间组成的业务标识处理重复数据。

至此,任务从原来的每日无边界全量同步,调整为有批次限制、带重叠补偿窗口的周期同步。

四、系统维护:为什么一个故障持续了两年? ​

技术问题解决后,我又重新思考了另一个问题。

原来的代码确实存在明显缺陷。

但对于一个已经运行多年的系统来说,出现代码缺陷并不罕见。

真正让我在意的是:

为什么这个定时任务长期执行失败,却一直没有得到有效处理?

4.1 有失败记录,不代表有人处理 ​

当时,系统使用 XXL-JOB 管理定时任务。

XXL-JOB 并不是完全没有记录任务执行情况。

我进入管理页面时,能够查到这个任务的失败记录。

但当时调度平台每天已经存在大量失败任务,失败记录数量可能达到上千条。

在这种情况下,单纯增加一条失败记录,很难再起到有效的警示作用。

问题并不是调度平台完全没有提供信息,而是这些信息没有形成有效的异常处理过程。

即使任务每天按时触发,也不能说明它每天都能正常完成。

更不能仅凭 Java 服务进程仍在运行,就认为其负责的业务功能一切正常。

任务被成功调度、任务执行成功、业务数据成功更新,是三个不同的判断。

这次故障最终是通过业务页面的数据异常暴露出来的。

这说明,对于数据同步任务,除了关注程序是否运行,还应该关注同步结果本身。

例如,目标数据最近一次成功更新时间,可能比调度执行次数更直接地反映业务功能是否正常。

4.2 系统维护不能只依赖熟悉代码的人 ​

我当时入职还不到三个月,对这个任务并不熟悉。

从排查过程来看,真正定位问题并没有想象中复杂。

检查服务异常、找到相关定时任务、查看执行记录,再阅读代码,最终发现没有 WHERE 条件的全量查询。

但此前,这项功能的用途和维护责任没有得到有效传承。

对于长期运行的业务系统,这是一个值得重视的问题。

一个系统可能包含很多不常被直接操作的后台功能。

例如定时同步、数据归集、周期对账和历史数据处理等。

这些功能没有明显的前端入口,平时也未必需要人工介入。

如果相关知识只存在于个别开发人员的记忆中,那么随着维护人员变化,就可能出现功能仍在部署运行、却没有人真正了解其职责的情况。

因此,我认为系统交接不应当只说明项目怎样启动、接口在哪里、数据库如何连接。

对于持续运行的后台任务,至少应该让接手者知道:

这个任务负责什么业务、什么时候执行、数据从哪里来、结果写到哪里,以及失败后会影响哪些功能。

这并不意味着需要为每个定时任务编写冗长的技术手册。

一份清晰的任务清单,加上必要的业务说明和代码入口,就已经能够帮助后来的人建立基本认识。

4.3 页面正常,不代表业务正常 ​

这次经历还让我意识到一个容易忽略的区别:

软件系统的可运行性,不等于业务功能的有效性。

当时,页面能够正常打开,Java 服务也并非整体不可用。

从表面上看,系统仍然在运行。

但页面依赖的数据已经约两年没有更新。

从业务角度看,这项功能实际上早已失效。

因此,系统维护不仅要关注服务是否启动、接口是否返回、定时任务是否触发,还要关注它们是否持续产生预期的业务结果。

对于这类同步任务,能够知道最后一次成功完成同步的时间,或目标数据最近一次更新的时间,往往很有帮助。

五、总结:修复代码与维护系统是两件事 ​

回顾这次经历,技术上的问题并不复杂。

原来的定时任务没有限制查询范围,每天都尝试读取全部历史数据,再通过 Dubbo 将大量数据交给另一个系统处理。

随着数据不断增长,逐渐出现调用超时,后来甚至在查询阶段发生 OOM。

我先通过每批 50 条的方式恢复全量处理能力,连续进行了约一周的全量运行。

随后,将日常查询范围收敛到最近 7 天,并利用 B 系统按业务 ID + 时间的 Upsert 机制处理重复同步。

整个修复过程主要解决了两个问题:

第一,让单次数据处理规模有边界。

第二,让日常任务不必每天重复处理全部历史数据,同时保留短期失败补偿能力。

但系统维护层面的问题同样值得记住。

这个任务已经长期失败,调度平台也存在失败记录,却直到业务人员发现页面数据停留在两年前,才真正引发排查。

技术缺陷解释了任务为什么失败。

而功能交接不足、维护责任不清晰以及大量失败记录缺少有效处理,解释了这个问题为什么能够长期存在。

对我来说,这次经历最大的收获,并不只是学会如何将全量查询改成分批同步。

而是进一步认识到:

修复一次故障,解决的是眼前的代码问题;长期维护一个系统,还需要知道它有哪些功能、谁在关注这些功能,以及怎样判断它们是否仍然正常。

一个系统是否健康,不能只看它是否还在运行。

还应该看它所承担的业务职责,是否仍然得到有效履行。