springboot 的 @Scheduled cron 延迟

1 天前
 frank1256

生产出现奇怪的事情

代码里配置 @Scheduled(cron="0 0 2 * * ?") 生产 k8s 部署了 4 副本,但是 apm 发现有一个副本是 4 点钟执行任务。并且任务执行时会有数据库更新,库里都更新成了 4 点。

进入容器内部,检查了 date 时区 ,代码版本,请求了接口的配置。4 个副本都一致。

没啥头绪,有大佬指点下吗

231 次点击
所在节点    程序员
2 条回复
NelsonZhao
1 天前
Gemini 的回答
这是一个非常经典且具有迷惑性的问题!你排查的方向(时区、代码、配置)是正确的,但既然它们都一致,那么问题很可能出在你看不到的地方:Spring 的任务调度线程池。

你遇到的“诡异现象”最可能的原因是:Spring TaskScheduler 线程池饥饿( Thread Pool Starvation )。

问题分析:为什么 2 点的 Cron 会在 4 点执行?
你对 LocalDateTime.now() 的分析非常关键,它证明了“任务执行时,JVM 时间确实是 4 点”。

这并不是 2 点的 Cron 延迟触发了,而是 2 点的 Cron 准时触发了,但任务被“排队”了,直到 4 点钟才“轮到”它执行。

详细的执行链条推测如下:

Spring @Scheduled 的默认配置: 默认情况下,Spring Boot 用于 @Scheduled 的任务调度线程池( TaskScheduler )大小为 1 。这意味着,在同一时间,一个应用实例中只能有一个 @Scheduled 任务在运行。

2:00 AM 准时触发: 在 2:00:00 ,4 个副本的调度器都准时醒来,说:“好了,该执行 cron="0 0 2 * * ?" 这个任务了。”

提交任务到线程池:

副本 1, 2, 3: 它们的调度线程池是空闲的。任务被提交,立刻被线程池中的那 1 个线程取出并立即开始执行。此时 LocalDateTime.now() 是 2:00 AM ,数据库更新为 2 点。

副本 4 (问题副本): 它的调度器也在 2:00:00 提交了任务。但是,它的那 1 个调度线程正在忙!

副本 4 为何忙碌?

最可能的原因是:你代码中还有另一个 @Scheduled 任务(比如一个 cron="0 0 1 * * ?" 在 1 点钟触发的任务,或者一个 @Scheduled(fixedDelay=...) 的任务)。

在副本 4 上,这个“更早的任务”由于某种原因(如 I/O 阻塞、数据库死锁、调用第三方接口超时、或者进入了 bug 循环)执行了非常长的时间。

假设这个“长任务”从 1:00 AM 开始执行,一直执行到了 4:00 AM 才结束。

任务排队与执行:

在 2:00 AM ,你这个 2 点的任务被提交到副本 4 的队列中,它在排队。

在 4:00 AM ,“长任务”终于执行完毕,释放了线程。

线程池立刻从队列中取出下一个任务——也就是你这个“本应 2 点执行”的任务,并开始执行它。

结果:

任务在 4:00 AM 开始执行。

APM 监控到任务在 4:00 AM 开始。

代码中的 LocalDateTime.now() 获取到的当前 JVM 时间就是 4:00 AM 。

数据库被更新为 4:00 AM 。

这个理论完美地符合了你的所有观察:时区没错,代码没错,配置也没错,错的是运行时的线程状态。

如何验证和解决?
如何验证
检查 APM: 这是最快的方法。去 APM 查看那个“问题副本 4”,在 2:00 AM 到 4:00 AM 之间,是不是有另一个 @Scheduled 任务正在运行?你会在 APM 上看到一个从 1 点(或更早)开始,到 4 点才结束的超长执行链路。

检查日志: 仔细翻看问题副本在 2:00 AM 之前的日志。看看有没有其他定时任务的“开始”日志,但迟迟没有打印“结束”日志。

检查代码: 审查你项目中所有其他的 @Scheduled 方法。评估一下:

它们有没有可能执行时间超过 1-2 个小时?

它们有没有可能因为网络或数据库问题而卡住?

jstack (如果能复现): 如果问题能复现,在 2:00 AM 刚过的时候,立即 ssh/exec 到问题容器,对 Java 进程执行 jstack 。你很可能会在线程 dump 中看到一个名为 scheduling-1 (或类似的) 线程,它正 RUNNABLE 或 BLOCKED 在某个长时间运行的任务代码上。

如何解决
你有两个层面的问题需要解决:

问题一:调度线程池饥饿(你当前遇到的问题)

方案 A (简单修复): 增加调度线程池的大小。这样即使一个任务卡住了,也不会阻塞其他任务。 在 application.yml (或 properties) 中配置:

YAML

spring:
task:
scheduling:
pool:
size: 10 # 默认是 1 ,改成 10 或更高
方案 B (最佳实践): 如果你确认某个任务就是会执行很久,应该将它与普通的短任务隔离。

让这个“长任务”的 @Scheduled 方法本身只做一件事:调用一个 @Async 异步方法。

为这个 @Async 方法配置一个单独的、专用的线程池。

这样,@Scheduled 线程(来自 scheduling-pool )会“秒级”执行完(仅仅是提交了一个异步任务),不会阻塞其他定时任务。真正的耗时工作会在另一个线程池( async-pool )中进行。

问题二:4 个副本重复执行(你潜在的更大问题)

你提到 K8s 部署了 4 个副本。这意味着,这个 2:00 AM 的任务,每天都会被执行 4 次(副本 1 、2 、3 在 2 点执行,副本 4 在 4 点执行)。

对于“更新数据库”这样的操作,这通常是一个严重的问题,会导致数据不一致或重复处理。

解决方案 (强烈推荐): 引入分布式调度锁,例如 ShedLock 。

ShedLock 是一个轻量级的库,它利用数据库(或 Redis/ZooKeeper 等)来确保一个带注解的 @Scheduled 任务在同一时间只会被一个集群实例执行。

使用方法很简单:

引入 ShedLock 依赖。

配置一个 LockProvider (比如指向你的数据库)。

在你的定时任务上加注解:

Java

import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;

@Scheduled(cron="0 0 2 * * ?")
@SchedulerLock(name = "myDailyTask", lockAtMostFor = "1h", lockAtLeastFor = "5m")
public void yourTask() {
// ... 数据库更新 ...
}
这样配置后,在 2:00 AM ,4 个副本都会尝试获取 myDailyTask 这个锁。只有一个副本能成功,然后执行任务。其他 3 个副本获取锁失败,会直接跳过当次的执行。

总结
你的时区/时钟 99% 没问题。

问题出在 Pod 4 的 Spring 调度线程池(默认大小为 1 )被另一个长任务占满了。

导致 2:00 AM 的任务在队列中排队,直到 4:00 AM 才轮到它执行。

请立刻检查 APM 和日志,找到那个“超长任务”并优化它。

请立刻配置 spring.task.scheduling.pool.size > 1 来避免阻塞。

请立刻使用 ShedLock 来防止 4 个副本重复执行任务。
shiny
1 天前
@NelsonZhao 不要在这里直接贴 AI 的答案

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1168080

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX