Spring Boot 定时任务越跑越乱?一次重复执行把数据库也拖慢的常见链路
定时任务重复执行、越跑越堆,最后把数据库拖慢时,问题往往不是单点故障,而是一条事故链:执行权没兜住,单轮又越来越慢,最后把锁、事务和在线请求一起拖下水。
定时任务平时最容易被团队低估。
它不像接口那样天天有人盯,也不像数据库报警那样容易第一时间被注意到。很多后台任务上线后,只要看起来“能跑”,大家就默认它是稳定的。
所以真正出事时,现场通常会有点晚。
我见过很典型的一种链路是这样的:
- 某个定时任务本来每分钟扫一次数据
- 后来数据量变大,单轮开始跑不完
- 下一轮又照常进来
- 线上又正好是多实例部署
- 最后同一批数据被反复扫、反复改
- 数据库慢查询、锁等待、连接池占用一起抬头
- 白天接口开始慢,团队才回头发现起点是这个后台任务
这种事故最容易被拆碎了看。
有人盯“为什么重复执行”,有人盯“为什么数据库变慢”,有人盯“是不是线程池不够”。可从真实现场看,这些往往不是三件独立的事,而是一条前后相接的故障链。
所以比起先问“到底是哪一层坏了”,我更想先问:这条链最早是从哪里开始失控的,是执行权没兜住,还是单轮已经跑不过周期。
很多任务事故,开头其实都不夸张
最危险的后台任务,往往不是那种一眼就知道很重的大作业,而是看起来特别普通的:
- 每分钟扫一批待处理记录
- 每五分钟补偿一次状态
- 每小时聚合一次统计
- 凌晨做一轮汇总或清理
这些任务在系统还小的时候通常没问题。
因为:
- 数据量不大
- 实例数不多
- 单轮执行很快
- 下游数据库也轻松
可它们的危险恰恰在这里:一开始太正常了,后面一变复杂,风险不是一下子跳出来,而是慢慢叠上去。
于是你看到的往往不是“任务直接挂了”,而是:
- 同一批数据开始偶发重复处理
- 某些时间窗数据库突然变慢
- 任务窗口和接口慢的时间逐渐重合
- 大家先怀疑数据库,最后才发现源头在任务模型
这类问题我通常先切两刀:它是触发多了,还是一轮太慢了
这是我最常用的第一步。
因为定时任务事故看起来很乱,但最早失控的地方,通常先落在下面两类之一。
一类是任务触发得比你以为的多
常见长相包括:
- 多实例都在跑同一个任务
- 上一轮没结束,下一轮又按时间进来了
- 本地锁看着有,多实例环境其实没兜住
- 锁超时、锁续约、异常退出后的状态清理都不稳
这种场景下,问题的起点不是数据库慢,而是同一个执行窗口里,根本不止一个执行者。
从业务角度看,就会表现成:
- 同一批记录被重复扫
- 同一条状态被多次修改
- 幂等没做好时,重复处理更明显
很多人这时会说“是不是 @Scheduled 不靠谱”。但说实话,真实项目里更常见的根因,还是执行权设计得太乐观。
另一类是任务没有多跑,只是已经慢到跑不过周期了
这类现场也非常常见。
任务还是按原来的频率触发,代码也没看出明显问题,但因为下面这些因素,单轮越来越慢:
- 每次扫描的数据范围越来越大
- 本来是增量处理,后来慢慢接近全量扫
- 下游数据库、HTTP、MQ 变慢
- 批量更新带来更长事务和更多锁等待
一旦单轮耗时接近甚至超过调度周期,后面的叠加几乎是必然的。
这时你看到的“任务堆积”,很多时候不是队列意义上的堆积,而是任务模型已经进入一种永远追不上的状态。
重复执行最真实的现场,通常不是 cron 写错,而是线上副本和重入一起放大
单机开发环境最容易掩盖这个问题。
本地只有一个实例,任务看起来每分钟老老实实执行一次。可一上线上,多副本部署后,情况马上就不一样了:
- A 实例跑一轮
- B 实例也跑一轮
- 如果 C 也在,可能再来一轮
如果任务本身又缺少明确的执行权控制,那同一时间窗里就不是“有一个调度”,而是“有好几个执行者同时进场”。
更麻烦的是,很多任务即使做了锁,也不代表线上就真的安全。
我见过不少现场都是这样翻车的:
- 锁范围太粗,影响别的批次
- 锁范围太细,根本兜不住整轮任务
- 锁超时时间按理想耗时写,实际稍一变慢就失效
- 任务还没跑完,锁先过期,第二个执行者又进来了
所以“已经有分布式锁”这句话,在定时任务事故里往往不够说明问题。还得继续问:它到底兜住了哪一层,兜住了多久,异常情况下还能不能兜住。
任务一旦开始跑不完,数据库通常很快会被卷进去
后台任务事故最后几乎都会落到数据库头上,但数据库变慢常常不是起点,而是被任务模型拖进去的。
这里最典型的几条链我见得很多。
第一条:扫描范围越来越大
最开始任务可能只处理很少一批数据,SQL 看着没什么问题。后来随着数据增长,任务为了找到那一小批目标数据,不得不扫越来越大的范围。
于是现场会变成:
- 每分钟只想处理 1000 条
- 但要从 10 万、50 万甚至更多记录里筛出来
- 任务本身还没真正开始“干活”,数据库已经先忙起来了
第二条:一轮任务包着太大的事务
这也很常见。
很多任务代码一开始图省事,会写成:
- 查一批数据
- 循环处理业务
- 更新状态
- 全过程包在一笔事务里
当数据量还小,这样写没什么感觉;一旦处理时间被拉长,副作用就会一起出来:
- 事务持有时间变长
- 锁持有时间变长
- 连接归还变慢
- 别的请求也开始等
第三条:重复执行反过来让数据库更慢
这是最像事故链的一段。
原本只是任务慢了一点,结果因为重入或多实例重复执行,多个执行者同时打向同一批数据。接着就会出现:
- 同样的记录被重复扫描
- 同样的状态行被争抢更新
- 锁等待和热点竞争越来越明显
- 任务本身又因此更慢
这就是为什么很多任务问题最后会进入一种自我放大的状态:越慢越重入,越重入越慢。
最容易被忽略的一层,是后台任务和在线请求其实在抢同一套资源
很多团队脑子里会天然把两件事分开:
- 后台任务跑后台的
- 在线接口走在线的
可真实项目里,它们常常共享的是同一套东西:
- 同一个数据库实例
- 同一个连接池
- 同一个线程池,或者至少同一类机器资源
- 同一个 Redis、MQ、ES 或下游服务
所以后台任务一旦开始放大,很难只影响“后台”。
最常见的现场就是:
- 任务窗口数据库压力抬高
- 在线请求借连接变慢
- 在线查询开始等锁
- 接口 RT 和错误率一起上涨
很多白天接口慢、但代码看起来没变的故障,最后都是顺着这条线倒回某个定时任务窗口。
我自己更信的一条判断线
如果线上已经出现“重复执行、越跑越堆、数据库也慢了”的现场,我一般不会先把它拆成几个独立专题,而是顺着这条线去看:
先看执行权有没有真的兜住
先确认:
- 是不是多实例都在执行
- 上一轮没结束时,下一轮是不是又进来了
- 锁到底有没有覆盖完整执行窗口
- 异常退出、超时、续约失败时会不会放进第二个执行者
再看单轮为什么开始变慢
重点不是只看“现在慢”,而是看它为什么比以前慢了:
- 数据范围变大了吗
- 增量边界还在吗
- 下游依赖是不是慢了
- 哪一段最消耗事务时间和数据库资源
然后看数据库是不是已经进入放大环
比如:
- 大范围扫描
- 慢 SQL
- 长事务
- 锁等待
- 连接池 pending
如果这些已经一起出现,说明问题通常不再是单纯的调度小 bug,而是任务模型开始反噬数据库。
最后再看它怎么传导到在线请求
这一步特别重要,因为很多团队只有看到在线接口受影响才会真正处理后台任务问题。
可一旦你已经看到任务窗口和接口慢重合,就不要再把两件事分开看了。
这类问题里,最常见的几个误判
误判一:重复执行就是 cron 表达式写错了
有可能,但没那么常见。多实例、重入和执行权没兜住,才是线上更高频的根因。
误判二:任务堆积了,先把线程池开大
如果真正瓶颈在数据库扫描、长事务和锁等待,线程越多,往往只是更快把共享资源打满。
误判三:数据库慢了,就是数据库本身扛不住
很多时候数据库只是被任务模型拖下水。根因可能更早就出在执行频率、扫描方式、事务边界和重复执行上。
误判四:后台任务不在主链路上,所以不会影响在线请求
只要共用数据库和连接池,这个判断通常靠不住。
误判五:加了分布式锁,就等于不会重复执行
锁能不能真解决问题,取决于粒度、超时、续约和异常恢复,不是“代码里写了锁”这件事本身。
最后收一句
定时任务真正麻烦的地方,不是偶尔重复执行一次,而是它很容易沿着一条事故链往下放大:执行权没兜住,单轮又越来越慢,接着把事务、锁、连接池和在线请求一起拖进来。
所以排这类问题时,别急着把症状拆成好几摊。先看最早失控的是谁,后面很多现象自然就能串起来了。