Java

Spring Boot 定时任务越跑越乱?一次重复执行把数据库也拖慢的常见链路

定时任务重复执行、越跑越堆,最后把数据库拖慢时,问题往往不是单点故障,而是一条事故链:执行权没兜住,单轮又越来越慢,最后把锁、事务和在线请求一起拖下水。

  • Spring Boot
  • 定时任务
  • 数据库
  • 调度
  • 性能排查
15 分钟阅读

定时任务平时最容易被团队低估。

它不像接口那样天天有人盯,也不像数据库报警那样容易第一时间被注意到。很多后台任务上线后,只要看起来“能跑”,大家就默认它是稳定的。

所以真正出事时,现场通常会有点晚。

我见过很典型的一种链路是这样的:

  • 某个定时任务本来每分钟扫一次数据
  • 后来数据量变大,单轮开始跑不完
  • 下一轮又照常进来
  • 线上又正好是多实例部署
  • 最后同一批数据被反复扫、反复改
  • 数据库慢查询、锁等待、连接池占用一起抬头
  • 白天接口开始慢,团队才回头发现起点是这个后台任务

这种事故最容易被拆碎了看。

有人盯“为什么重复执行”,有人盯“为什么数据库变慢”,有人盯“是不是线程池不够”。可从真实现场看,这些往往不是三件独立的事,而是一条前后相接的故障链。

所以比起先问“到底是哪一层坏了”,我更想先问:这条链最早是从哪里开始失控的,是执行权没兜住,还是单轮已经跑不过周期。

很多任务事故,开头其实都不夸张

最危险的后台任务,往往不是那种一眼就知道很重的大作业,而是看起来特别普通的:

  • 每分钟扫一批待处理记录
  • 每五分钟补偿一次状态
  • 每小时聚合一次统计
  • 凌晨做一轮汇总或清理

这些任务在系统还小的时候通常没问题。

因为:

  • 数据量不大
  • 实例数不多
  • 单轮执行很快
  • 下游数据库也轻松

可它们的危险恰恰在这里:一开始太正常了,后面一变复杂,风险不是一下子跳出来,而是慢慢叠上去。

于是你看到的往往不是“任务直接挂了”,而是:

  • 同一批数据开始偶发重复处理
  • 某些时间窗数据库突然变慢
  • 任务窗口和接口慢的时间逐渐重合
  • 大家先怀疑数据库,最后才发现源头在任务模型

这类问题我通常先切两刀:它是触发多了,还是一轮太慢了

这是我最常用的第一步。

因为定时任务事故看起来很乱,但最早失控的地方,通常先落在下面两类之一。

一类是任务触发得比你以为的多

常见长相包括:

  • 多实例都在跑同一个任务
  • 上一轮没结束,下一轮又按时间进来了
  • 本地锁看着有,多实例环境其实没兜住
  • 锁超时、锁续约、异常退出后的状态清理都不稳

这种场景下,问题的起点不是数据库慢,而是同一个执行窗口里,根本不止一个执行者。

从业务角度看,就会表现成:

  • 同一批记录被重复扫
  • 同一条状态被多次修改
  • 幂等没做好时,重复处理更明显

很多人这时会说“是不是 @Scheduled 不靠谱”。但说实话,真实项目里更常见的根因,还是执行权设计得太乐观。

另一类是任务没有多跑,只是已经慢到跑不过周期了

这类现场也非常常见。

任务还是按原来的频率触发,代码也没看出明显问题,但因为下面这些因素,单轮越来越慢:

  • 每次扫描的数据范围越来越大
  • 本来是增量处理,后来慢慢接近全量扫
  • 下游数据库、HTTP、MQ 变慢
  • 批量更新带来更长事务和更多锁等待

一旦单轮耗时接近甚至超过调度周期,后面的叠加几乎是必然的。

这时你看到的“任务堆积”,很多时候不是队列意义上的堆积,而是任务模型已经进入一种永远追不上的状态。

重复执行最真实的现场,通常不是 cron 写错,而是线上副本和重入一起放大

单机开发环境最容易掩盖这个问题。

本地只有一个实例,任务看起来每分钟老老实实执行一次。可一上线上,多副本部署后,情况马上就不一样了:

  • A 实例跑一轮
  • B 实例也跑一轮
  • 如果 C 也在,可能再来一轮

如果任务本身又缺少明确的执行权控制,那同一时间窗里就不是“有一个调度”,而是“有好几个执行者同时进场”。

更麻烦的是,很多任务即使做了锁,也不代表线上就真的安全。

我见过不少现场都是这样翻车的:

  • 锁范围太粗,影响别的批次
  • 锁范围太细,根本兜不住整轮任务
  • 锁超时时间按理想耗时写,实际稍一变慢就失效
  • 任务还没跑完,锁先过期,第二个执行者又进来了

所以“已经有分布式锁”这句话,在定时任务事故里往往不够说明问题。还得继续问:它到底兜住了哪一层,兜住了多久,异常情况下还能不能兜住。

任务一旦开始跑不完,数据库通常很快会被卷进去

后台任务事故最后几乎都会落到数据库头上,但数据库变慢常常不是起点,而是被任务模型拖进去的。

这里最典型的几条链我见得很多。

第一条:扫描范围越来越大

最开始任务可能只处理很少一批数据,SQL 看着没什么问题。后来随着数据增长,任务为了找到那一小批目标数据,不得不扫越来越大的范围。

于是现场会变成:

  • 每分钟只想处理 1000 条
  • 但要从 10 万、50 万甚至更多记录里筛出来
  • 任务本身还没真正开始“干活”,数据库已经先忙起来了

第二条:一轮任务包着太大的事务

这也很常见。

很多任务代码一开始图省事,会写成:

  • 查一批数据
  • 循环处理业务
  • 更新状态
  • 全过程包在一笔事务里

当数据量还小,这样写没什么感觉;一旦处理时间被拉长,副作用就会一起出来:

  • 事务持有时间变长
  • 锁持有时间变长
  • 连接归还变慢
  • 别的请求也开始等

第三条:重复执行反过来让数据库更慢

这是最像事故链的一段。

原本只是任务慢了一点,结果因为重入或多实例重复执行,多个执行者同时打向同一批数据。接着就会出现:

  • 同样的记录被重复扫描
  • 同样的状态行被争抢更新
  • 锁等待和热点竞争越来越明显
  • 任务本身又因此更慢

这就是为什么很多任务问题最后会进入一种自我放大的状态:越慢越重入,越重入越慢。

最容易被忽略的一层,是后台任务和在线请求其实在抢同一套资源

很多团队脑子里会天然把两件事分开:

  • 后台任务跑后台的
  • 在线接口走在线的

可真实项目里,它们常常共享的是同一套东西:

  • 同一个数据库实例
  • 同一个连接池
  • 同一个线程池,或者至少同一类机器资源
  • 同一个 Redis、MQ、ES 或下游服务

所以后台任务一旦开始放大,很难只影响“后台”。

最常见的现场就是:

  • 任务窗口数据库压力抬高
  • 在线请求借连接变慢
  • 在线查询开始等锁
  • 接口 RT 和错误率一起上涨

很多白天接口慢、但代码看起来没变的故障,最后都是顺着这条线倒回某个定时任务窗口。

我自己更信的一条判断线

如果线上已经出现“重复执行、越跑越堆、数据库也慢了”的现场,我一般不会先把它拆成几个独立专题,而是顺着这条线去看:

先看执行权有没有真的兜住

先确认:

  • 是不是多实例都在执行
  • 上一轮没结束时,下一轮是不是又进来了
  • 锁到底有没有覆盖完整执行窗口
  • 异常退出、超时、续约失败时会不会放进第二个执行者

再看单轮为什么开始变慢

重点不是只看“现在慢”,而是看它为什么比以前慢了:

  • 数据范围变大了吗
  • 增量边界还在吗
  • 下游依赖是不是慢了
  • 哪一段最消耗事务时间和数据库资源

然后看数据库是不是已经进入放大环

比如:

  • 大范围扫描
  • 慢 SQL
  • 长事务
  • 锁等待
  • 连接池 pending

如果这些已经一起出现,说明问题通常不再是单纯的调度小 bug,而是任务模型开始反噬数据库。

最后再看它怎么传导到在线请求

这一步特别重要,因为很多团队只有看到在线接口受影响才会真正处理后台任务问题。

可一旦你已经看到任务窗口和接口慢重合,就不要再把两件事分开看了。

这类问题里,最常见的几个误判

误判一:重复执行就是 cron 表达式写错了

有可能,但没那么常见。多实例、重入和执行权没兜住,才是线上更高频的根因。

误判二:任务堆积了,先把线程池开大

如果真正瓶颈在数据库扫描、长事务和锁等待,线程越多,往往只是更快把共享资源打满。

误判三:数据库慢了,就是数据库本身扛不住

很多时候数据库只是被任务模型拖下水。根因可能更早就出在执行频率、扫描方式、事务边界和重复执行上。

误判四:后台任务不在主链路上,所以不会影响在线请求

只要共用数据库和连接池,这个判断通常靠不住。

误判五:加了分布式锁,就等于不会重复执行

锁能不能真解决问题,取决于粒度、超时、续约和异常恢复,不是“代码里写了锁”这件事本身。

最后收一句

定时任务真正麻烦的地方,不是偶尔重复执行一次,而是它很容易沿着一条事故链往下放大:执行权没兜住,单轮又越来越慢,接着把事务、锁、连接池和在线请求一起拖进来。

所以排这类问题时,别急着把症状拆成好几摊。先看最早失控的是谁,后面很多现象自然就能串起来了。