Spring @Scheduled cron 表达式解析:从写法到生产环境避坑

发布时间:2026/9/26 18:42:24
Spring @Scheduled cron 表达式解析:从写法到生产环境避坑 线上任务没按点跑十有八九是定时表达式出了问题。上周我们部门就查了一个凌晨报表不执行的问题代码里赫然写着 Scheduled(cron 0 */5 * * * ?)乍一看每5分钟触发一次怎么到点不跑这篇文章把这种写法背后的 cron 表达式规则、Spring 调度线程模型和生产环境踩过的坑统统捋一遍。如果你正在用 Spring 做定时任务或者被 Scheduled 折腾过这篇值得花十分钟看完。我先把结论放这儿0 */5 * * * ?确实表示每5分钟执行一次但这个“每5分钟”不是“任务结束后等5分钟”也不是“从服务启动开始每5分钟”而是“每天的每个小时里当分钟数能被5整除、秒数等于0的那个时刻执行”。理解不了这个区别线上迟早要出问题。1. cron 表达式的六个字段先搞明白每个数字站的位置1.1 六段结构拆解与取值范围Spring 的 cron 表达式一共六段用空格隔开顺序是固定的秒 分 时 日 月 周对照0 */5 * * * ?来看位置含义取值范围本例取值说明第一位秒0-590只在第0秒触发第二位分0-59*/5分钟逢0、5、10...55触发第三位时0-23*每小时都允许第四位日1-31*每天第五位月1-12 或 JAN-DEC*每月第六位周0-7 或 SUN-SAT?不关心星期几这里最重要的就是前三位。0 */5 * * * ?的执行时刻展开写就是00:00:00、00:05:00、00:10:00……23:55:00每个整点零秒只要分钟数能被5整除就触发一次。1.2 Spring 的 cron 只支持六位别拿 Quartz 的七位往上套很多在线生成器默认生成的是 Quartz 的 cron带年字段一共七位秒 分 时 日 月 周 [年]比如0 0 2 * * ? 2025。这种东西粘到 Spring 里直接报错因为 Spring 的 CronExpression 只解析六位第七位会被当成非法字符。我见过不止一个人把七位表达式贴进 Scheduled启动时抛IllegalArgumentException: cron expression must consist of 6 fields然后一脸懵。遇到这种情况先把年字段去掉。另外一点容易被带偏Spring 的 cron 不支持 Quartz 里的L最后一天、W最近的工作日、C日历这几个特殊字符。你如果想写“每月最后一个工作日”别指望 Spring 原生表达式能直接表达老老实实写业务代码判断或者直接用fixedDelay配合日期处理。2. 特殊字符逐个讲*、?、/ 到底什么时候用2.1*表示“任意值”但它不是“每”这是新手最容易理解错的一个点。*在某个字段里表示“这个位置不限制”比如小时位写*意思是任何小时都能触发但秒位写*意思就是每一秒都会触发不是“每秒”这种口语化的理解问题是完全不同的执行频率。0 */5 * * * ?里的分钟位是*/5它其实是一种简写展开是0,5,10,15,20,25,30,35,40,45,50,55Spring 内部解析*/5时等价于“从最小值0开始每隔5取一个值”。所以分钟位*/5和0/5在 Spring 里行为一致。但你要是写成1/5那就是从1开始隔5取一个触发时刻变成01分、06分、11分……这个细节虽然不起眼但在排查问题时能帮大忙。2.2?只用于“日”和“周”别的位置写了就报错?的语义是“不指定”它存在的意义是为了解决日字段和周字段的冲突。你想想cron 表达式里日和周如果都填了具体值比如“每月1号”和“每周一”那到底按哪个执行所以这两者必须二选一留空。Spring 接受的习惯是其中一个字段写?另一个写具体值或*。0 */5 * * * ?就是周一“不关心”位置上的?让这个表达式逻辑自洽。如果你写成0 */5 * * * *日和周都是*Spring 不一定报错但某些实现下会解析异常。为了稳妥凡是日和周同时出现但不需要限定其中之一时统一把周位写成?。这不是玄学是让表达式的语义清晰化。2.3 逗号、连字符、斜杠的组合用法逗号,枚举值比如分钟位0,15,30,45表示每小时的这四个分钟点触发。连字符-范围比如小时位9-17表示上午9点到下午5点含端点。斜杠/步长0/10表示从0开始每10个单位取一个10-50/10表示从10到50之间每10取一个。很多人喜欢把这三个混在一起写比如0 0,30 9-18 * * ?这是“每天9点到18点每个整点和半点执行”一天要执行20次。我自己在写监控类任务时经常用这种组合既能精确控制时段又不用把每个小时枚举出来。3. 触发时刻的推算逻辑为什么执行点是 0 分 5 分而不是 3 分 8 分3.1 逐步计算一次触发条件把0 */5 * * * ?翻译成人话触发需要同时满足秒位等于 0分钟数 mod 5 等于 0也就是分钟位在{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}小时位、日位、月位都是任意值周位不参与约束。所以任何一个小时的执行时刻是固定的00:00、00:05、00:10……你会发现任务永远不会在 00:03 或者 00:08 执行因为分钟数不符合条件。这个“逢 0 和 5”的节奏是很多业务方喜欢 5 分钟一次跑批任务的原因——好记、可预测和自然语言里的“每5分钟”能对上。3.2 和*/5 * * * * ?的区别差了 60 倍SEO 热搜里有 cron 表达式说明很多人都在搜这个概念。我要提醒一个高频误写少写秒位的0表达式变成*/5 * * * * ?。前者是每5分钟执行一次后者是每5秒执行一次。如果你写的是数据同步任务每5秒打一次接口数据库连接池瞬间就会被拖垮。这个坑我亲眼见过同事踩过排查的时候先看监控图表特征非常明显每隔5秒一个尖峰一天产生一万多次调用。3.3 常见业务表达式的推算示例业务需要表达式触发规律每5分钟一次0 */5 * * * ?秒0分钟逢5倍数每30分钟一次0 */30 * * * ?整点和半点每小时整点一次0 0 * * * ?每小时0分0秒每天凌晨2点0 0 2 * * ?每天02:00:00工作日9点整0 0 9 * * MON-FRI周一到周五09:00:00每月1号0点0 0 0 1 * ?每个月1号零点写这些的时候有个小技巧不要只眼算写反了很难发现。Spring 5.3 之后提供了CronExpression类可以在测试里直接算下一次执行时间// Spring 5.3 CronExpression cron CronExpression.parse(0 */5 * * * ?); LocalDateTime next cron.next(LocalDateTime.now()); System.out.println(next);老版本可以这样CronSequenceGenerator generator new CronSequenceGenerator(0 */5 * * * ?); Date next generator.next(new Date());我每次改完 cron都会先跑一次这种代码把未来三五个触发时间打印出来看看。成本极低但能筛掉九成表达式错误。4. Scheduled 的三种调度模式和底层线程模型4.1 cron、fixedDelay、fixedRate 选谁Scheduled不只有 cron 一个属性还有两个很容易和 cron 混用的兄弟fixedRate和fixedDelay。三者的区别是本质性的fixedRate两次任务的开始时间间隔固定。任务耗时3秒fixedRate5000那第2次在第5秒启动不管第1次是否提前结束。fixedDelay上次任务结束后再间隔固定时间启动下一次。任务耗时3秒fixedDelay5000那第2次在第8秒启动。cron以日历时间为准只认某个时刻和前一次任务执行多久无关。从语义上看fixedRate 适合“尽量固定节奏”的心跳、轮询fixedDelay 适合“上一次必须彻底跑完再算下一次”的批处理cron 适合“必须在某个时刻执行”的业务规则。4.2 默认线程池只有1个线程这是最大隐患Spring Boot 默认的调度器是自动配置出来的ThreadPoolTaskScheduler核心线程数默认为1。这意味着所有 Scheduled 方法默认共享同一个线程。你可以做个实验在类里写两个 Scheduled 方法一个每5秒执行一次执行体里 sleep 20秒另一个每1秒执行一次。跑起来你会发现第二个方法完全不按每秒触发了它只能等第一个把线程让出来。这就是0 */5 * * * ?这种任务最典型的翻车场景任务本身确实每5分钟触发一次但如果方法体里做了耗时操作数据库查询慢、外部接口超时、日志写入阻塞下一次触发就会延后甚至积压。你看着告警里“该跑没跑”本质不是 cron 写错了是线程被占死了。4.3 自定义调度线程池的两种姿势第一种最直接通过SchedulingConfigurerConfiguration public class ScheduleConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); } }第二种项目里统一管理线程池 BeanConfiguration EnableScheduling public class ScheduleConfig { Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); scheduler.setThreadNamePrefix(schedule-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(30); scheduler.initialize(); return scheduler; } }我个人比较推荐第二种因为还能顺便配置优雅关闭项目停机时等待正在执行的任务收尾避免跑到一半的数据任务被 kill 掉。这里要补充一个实际操作中很容易忽略的问题线程池配到多线程只能解决“多个任务互相排队”的问题解决不了“同一个任务自身重入”的问题。比如你的任务是每5分钟一轮但轮询里调了个外部接口响应经常需要8分钟那下一次触发时上一次还没结束两个线程同时跑同一个同步逻辑数据库重复插入是必然的。这种情况下要么方法开头用分布式锁要么任务内部维护一个“是否正在执行”的标记。一个简单的本地防重入写法private final AtomicBoolean running new AtomicBoolean(false); Scheduled(cron 0 */5 * * * ?) public void syncData() { if (!running.compareAndSet(false, true)) { return; } try { // 业务逻辑 } finally { running.set(false); } }这个写法在单机场景够用多实例部署时还是要靠分布式锁。5. 生产环境里的坑多实例、时区、异常与重启5.1 集群部署时同一 cron 会在所有节点重复执行如果你的服务部署了三个实例每个实例都会加载 Scheduled都会在自己的时区里计算下一次触发时间。三个实例同时执行任务轻则重复写数据重则资源竞争、接口被打爆。我经历过一次比较尴尬的事故一个每5分钟执行一次的订单状态同步任务部署了4台机器等于每5分钟触发4次导致下游系统被限流整条链路雪崩。事后我们把触发逻辑改成由一台机器跑但更通用的做法是引入分布式任务锁。业界常用的方案是 ShedLock核心思路是给任务加一把数据库级别的锁谁拿到锁谁执行。不过这不是今天文章的主题我只是提醒如果你服务是多实例部署先确认这个任务是否天然幂等不谈幂等就上 Scheduled早晚出事。5.2 zone 属性服务器时区不同会怎样Scheduled 默认使用服务器本地时区计算 cron。如果你的服务器是 UTC而业务要求北京时间凌晨2点执行那么表达式0 0 2 * * ?会在北京时间上午10点执行差8个小时。解决办法是在注解里显式指定时区Scheduled(cron 0 0 2 * * ?, zone Asia/Shanghai) public void dailyTask() { }经验之谈凡是定时任务注解里不管服务器时区是什么都把 zone 写死。这样换服务器、迁机房都不会出偏差。5.3 任务抛异常不会中断调度但会让业务状态失控Spring 调度器捕获到任务抛出的异常后会默认打日志不会让整个调度线程挂掉后续的触发点依然会命中。听起来还算安全但问题是很多业务代码把异常吞了项目里一个任务失败后留下半截脏数据下次触发继续跑继续失败。这种问题比任务完全不跑更隐蔽。我的习惯是在每个定时任务里显式捕获业务异常并记录任务标记、执行耗时和结果状态推送到监控系统。任务方法的结构大致是Scheduled(cron 0 */5 * * * ?, zone Asia/Shanghai) public void reportTask() { long start System.currentTimeMillis(); try { // 业务逻辑 log.info(reportTask success, cost{}ms, System.currentTimeMillis() - start); } catch (Exception e) { log.error(reportTask failed, cost{}ms, System.currentTimeMillis() - start, e); // 告警 } }5.4 重启服务会跳过错过的触发点Spring Boot 启动后调度器才开始计算下一次触发时间。如果服务在凌晨 00:02 重启而 cron 是0 0 2 * * ?那么这次重启会直接错过凌晨2点的触发因为启动时已经过了触发点。下次触发要等到明天凌晨2点。这不算 bug但产品经理经常会拿“为什么今天没跑”来问你。解决办法通常不是自己造补偿机制而是把这类任务抽出来做成手动触发接口或者用分布式任务调度平台那里有“错过即补跑”的成熟方案。如果你的业务确实严格要求不丢任务Scheduled 天生是不合适的换成带调度记录的中间件才是正解。5.5 秒级任务要慎重0 */5 * * * ?这种分钟级任务还好但如果是*/5 * * * * ?这种秒级任务就要考虑三个问题数据库压力、日志量、线程池是否够用。秒级任务往往意味着每5秒一次数据库连接如果方法里还做了 update连接池很容易被打满。我见过一个踩坑案例把 Redis 心跳检测写成每3秒一次方法里同步获取连接池资源高峰期直接连接池饥饿连带业务请求也拿不到连接。后来改成每30秒一次并把检测逻辑异步化问题才解决。定时任务不是越快越好要结合业务对实时性的真实需要来定。6. 排查与验证从“看着对”到“跑得对”6.1 快速预判表达式的三个步骤数一下空格和字段数必须是6段每段用空格分隔。确认秒位不是*除非你真想每秒执行。确认日位和周位至少有一个是?避免潜在冲突。然后写个测试类打印未来几个触发时间对照需求一秒一秒看。6.2 监控指标与日志规范对定时任务我最建议加的监控是任务开始时间、结束时间、执行耗时、成功失败状态。实践中可以直接用 AOP 切所有 Scheduled 方法统一记录Aspect Component public class ScheduledTaskAspect { Around(annotation(org.springframework.scheduling.annotation.Scheduled)) public Object around(ProceedingJoinPoint point) throws Throwable { long start System.currentTimeMillis(); Object result point.proceed(); long cost System.currentTimeMillis() - start; String task point.getSignature().getDeclaringType().getSimpleName() . point.getSignature().getName(); log.info(scheduled task {} executed in {}ms, task, cost); return result; } }配合每5分钟一次的执行频率如果某次日志缺失直接告警。这样之前“该跑没跑”的问题就能在3分钟内发现。6.3 从 Scheduled 走向分布式调度如果团队规模变大任务数量超过十几个共用一套服务内调度已经不合适了。到时候你会面临每个任务独立线程池、失败重试、补偿执行、并发控制、运维配置化等需求这些 Scheduled 都给不了。业界常见的做法是迁移到 XXL-Job、PowerJob 这类开源调度平台或者上云上的托管调度产品。我个人在实际操作中的体会是Scheduled 就像一把好用的瑞士军刀适合小工程、少任务、逻辑简单的场景但不是所有定时问题都必须塞进这个注解。反过来如果你真的只需要“每5分钟干一件小事”0 */5 * * * ?再配一个多线程调度器已经是一个稳定可靠的方案了。关键是明白它什么时候会坑你以及怎么提前堵住这些坑。最后再分享一个小技巧当你对自己写的 cron 不放心别只在网上找生成器时间允许的话在 Spring Boot 测试里直接注入任务类手工调用一次目标方法顺便把下一次触发时间打印出来看。你纠结的那几个字符跑一次所有疑惑都会消失。