定时任务里的cron表达式,说简单也简单,说坑也真坑。我见过太多同事在@Scheduled(cron = "0 0 0 * * ?")这种写法上栽跟头,有的是凌晨三点没执行,有的是每分钟跑一次把数据库拖垮,还有的是周五的任务在周一莫名其妙补跑。归根结底,就是对cron表达式的理解停留在"网上抄一段能跑就行"的层面。这篇就把Spring里@Scheduled的cron表达式从头到尾掰开揉碎讲一遍,从六段式的字段含义,到?和*的区别,再到L、W这些特殊字符的真实行为,最后配合几个我实际踩过的坑来收尾。
1. cron表达式的结构与字段含义
1.1 六段式还是七段式,先搞清楚你写的是哪种
Spring的@Scheduled用的是六段式cron表达式,这一点和Linux系统的crontab(五段式)、Quartz的七段式都不一样。六段式从左到右分别是:秒、分、时、日、月、周。
拿0 0 3 * * ?举例,意思就是每天凌晨3点整执行。有人可能会问,为什么是六段不是五段?因为Spring的设计里需要精确到秒,所以最前面多了一个秒字段。如果你把Linux的0 3 * * *直接搬到@Scheduled里,恭喜你,启动直接报错,因为Linux的写法在Spring看来是六段里的前五位,最后少了一位,解析器根本认不出来。
这里有一个非常容易混淆的点:日和周两个字段是互斥的。你在日字段写了具体数字,那么周字段必须写?;反过来,周字段写了具体星期几,日字段就必须写?。两个都写具体的值,Spring启动时会直接抛异常。这个设计逻辑其实很好理解——cron表达式的语义是"某一天"还是"某个星期几",只能二选一,否则就产生了歧义:你说每月15号执行,又说周五执行,那15号恰好是周五的时候到底执行不执行?Spring选择直接不让你这么写。
1.2 六个字段的取值范围和通配符速查
先给一张我平时查得最多的速查表,建议收藏:
| 字段 | 是否必填 | 取值范围 | 支持的特殊字符 |
|---|---|---|---|
| 秒 | 是 | 0-59 | , - * / |
| 分 | 是 | 0-59 | , - * / |
| 时 | 是 | 0-23 | , - * / |
| 日 | 是 | 1-31 | , - * / ? L W |
| 月 | 是 | 1-12 或 JAN-DEC | , - * / |
| 周 | 是 | 1-7 或 SUN-SAT | , - * / ? L # |
需要注意,Spring的周字段中,1代表周日,2代表周一,以此类推,7代表周六。这一点和Quartz完全一致,但和很多人的直觉相反——你可能会觉得1是周一,结果任务在周日跑了,排查半天才发现是数字理解错了。所以如果记不住数字映射,直接用英文缩写是最稳的,SUN、MON、TUE、WED、THU、FRI、SAT都不会有歧义。
特殊字符的含义逐个过一遍:
*:整个字段的每一个取值,秒字段写*就是每秒都执行。?:不指定值,只能用在日和周字段,表示"我不关心这个字段"。-:区间,10-15在小时字段表示10点到15点。,:枚举,MON,WED,FRI在周字段表示周一、周三、周五。/:步长,0/5在秒字段表示从0秒开始,每5秒执行一次。L:last,最后一天。日在字段表示月末最后一天,在周字段单独写L表示周六(最后一天),6L表示最后一个周五。W:工作日,只能用在日字段,15W表示距离15号最近的工作日。#:第几个星期几,只能用在周字段,2#1表示第一个周一。
1.3 为什么说?和*看起来像但完全不是一回事
这是新手最容易踩的坑,必须单独拎出来讲。在日和周字段里,?表示"不指定值",*表示"每一个值"。这两个在日字段里的行为完全不同。
举个例子,0 0 12 ? * MON表示每周一中午12点执行。这里的日字段写的是?,意思就是我不限制具体是几号,只看周字段。如果日字段写成*,语义就变成了"每一天",但周字段又限定了周一,两个字段同时生效,Spring会认为这是非法组合。实际上Spring的解析器对*和?混用的检查很严格,日字段用了*,周字段必须用?,反之亦然。
我见过一个真实案例,同事写了个0 0 2 * * 1,本意是每周一凌晨2点跑任务,结果Spring启动直接报错,提示日字段和周字段不能同时指定。他跑来问我说"我明明照着网上的例子写的",我一看,网上的例子是0 0 2 ? * 1,他把?抄丢了。就这么一个字符的区别,任务直接起不来。
2. @Scheduled注解的核心规则与参数对比
2.1 cron、fixedRate、fixedDelay到底怎么选
很多人在@Scheduled里只知道cron属性,其实Spring还提供了fixedRate和fixedDelay两种更简单的定时方式。搞清楚三者的区别,能帮你避免很多不必要的cron表达式书写。
fixedRate:固定频率。fixedRate = 5000表示每5秒执行一次,不管上一次任务是否执行完毕,只要到了时间点就触发下一次。这个模式有一个隐患:如果任务执行时间超过了间隔时间,会出现多个任务并发执行的情况。fixedDelay:固定延迟。fixedDelay = 5000表示上一次任务执行完毕后,再等5秒执行下一次。这个模式是串行的,绝对不会并发,适合对数据一致性要求高的场景。cron:基于cron表达式的调度。它的执行时机完全由表达式决定,与上一次任务执行了多久无关。
实际项目里,如果是简单的"每隔N秒/分钟执行一次",我推荐优先用fixedDelay,因为它的语义更明确,不容易出问题。cron表达式虽然强大,但它的强项是处理"每天凌晨2点""每周一早上9点"这类有固定时间点的需求。
2.2 用initialDelay给任务一个"预热"时间
@Scheduled注解除了上面三个核心属性,还有个容易被忽略的initialDelay。这个属性表示应用启动后延迟多久再执行第一次任务,单位是毫秒。
为什么要这个?因为应用刚启动的时候,Spring容器还在初始化,数据库连接池可能还没就绪,Redis连接可能还没建立。如果定时任务在启动瞬间就跑起来,大概率会报连接异常。虽然框架有重试机制,但像@Scheduled这种无脑执行的定时任务,一旦crash可能整个线程就挂了。
我习惯的做法是,对于每天凌晨跑的任务,initialDelay设个10000,让应用启动后缓10秒再开始调度,给其他Bean的初始化留足时间。有些团队还会把initialDelay配成随机值,比如5000 + RandomUtil.randomInt(10000),避免多个实例同时启动时任务在同一刻扎堆执行——这个技巧在微服务多实例部署时特别实用。
2.3 zone属性:时区问题的隐藏杀手
@Scheduled还有一个zone属性,默认是服务器本地时区。如果你的服务器时区是UTC,而你人在中国,那么0 0 3 * * ?实际执行时间就是北京时间上午11点。这个问题排查起来特别诡异,因为代码在本地跑得好好的,上到服务器就"时差"了。
最简单的解决办法是在@Scheduled里显式指定时区:@Scheduled(cron = "0 0 3 * * ?", zone = "Asia/Shanghai")。这样就算服务器时区被运维改成了UTC,任务依然会在北京时间凌晨3点跑。
不过要注意,zone属性接受的字符串必须符合Java的时区ID格式,比如Asia/Shanghai、America/New_York。你写GMT+8这种偏移量格式有些版本也能识别,但保险起见还是用标准时区ID。Java的TimeZone.getAvailableIDs()可以列出所有支持的时区ID,实在不确定就运行一下查。
3. 从零写一个可用的cron表达式:一步步拼出来
3.1 最常见的业务场景表达式
我整理了几个实际开发中高频出现的cron表达式,直接拿去用,但后面我会逐个拆解,让你以后能自己写:
| 业务场景 | cron表达式 | 含义说明 |
|---|---|---|
| 每天凌晨2点执行 | 0 0 2 * * ? | 秒、分、时都为0,日、月不限制,周不指定 |
| 每小时的15分和45分执行 | 0 15,45 * * * ? | 分字段用逗号枚举 |
| 每5分钟执行一次 | 0 0/5 * * * ? | 分字段从0开始,每5步进 |
| 每周一早上9点执行 | 0 0 9 ? * MON | 日字段用?,周字段指定MON |
| 每月1号凌晨执行 | 0 0 0 1 * ? | 日字段写1,周字段写? |
| 每月最后一天23点执行 | 0 0 23 L * ? | 日字段用L表示最后一天 |
| 每工作日中午12点执行 | 0 0 12 ? * MON-FRI | 周字段用区间 |
| 每季度第一个月1号执行 | 0 0 8 1 1,4,7,10 ? | 月字段用枚举 |
这里有一个值得注意的地方:0 0/5 * * * ?和0 */5 * * * ?是等价的,都是从0分开始每5分钟跑一次。但0 5/10 * * * ?就不是"从5分开始每10分钟",而是"5分、15分、25分……55分会执行",因为它是在5分的基数上每10步进一次,到55分就停了,不会像0/10那样到50分之后回绕到0分继续。
3.2 一步一步推导:从需求到表达式的完整链路
拿一个典型需求举例:"每月最后一个工作日的下午6点,执行对账任务。"
第一步,确定执行时间点:下午6点,即0 0 18(秒、分、时)。
第二步,处理"每月":月字段用*,表示每个月都执行。
第三步,处理"最后一个工作日":这个最复杂。Spring的cron表达式没有直接的"最后一个工作日"写法,但有L和W的组合。LW表示"最后一个工作日",放在日字段就是LW。
所以完整的表达式是:0 0 18 LW * ?。
这里需要验证一件事:LW只能用在日字段,表示当月的最后一个工作日(周一至周五)。如果28号是周五,29号是周六,30号是周日,31号是周一,那么LW会解析为31号。但是,如果最后一天本身就是周末,比如某个月的31号是周日,那么LW会向前取到前一个周五。
再举个更复杂的场景:"每年最后一天23:59:59执行一次。"表达式是0 59 23 31 12 ?。这个看起来没问题,但要注意,如果某一年12月没有31号,这个表达式在该年就不会触发。实际上12月一定有31号,所以这个表达式是安全的。如果换成"每年2月最后一天",你写0 0 0 L 2 ?,L会自动解析为闰年29号、平年28号,这个才是L最方便的地方。
3.3 用在线工具和本地测试验证你的表达式
写完表达式不验证就直接上生产,这不是一个好习惯。我见过有人把0 0 9 ? * MON写成了0 0 9 ? * 1,结果周一是对了,但因为他记错了数字映射(以为1是周一,实际上1是周日),任务在周日跑了一次。这种错误如果在生产环境发生了,影响面可能很大。
验证cron表达式的方法有三种:
- 在线工具:cron.qqe2.com、crontab.guru这类网站可以直接把表达式翻译成人话,并且能模拟出未来若干次执行时间。先拿去翻译一下,至少能确认有没有语法错误。
- Java测试:Spring的
CronExpression类可以直接拿来用,CronExpression.parse("0 0 9 ? * MON"),然后调用next(Instant.now())看看下一次执行时间对不对。这是最靠谱的验证方式,推荐在单元测试里写。 - 本地起服务实测:把
@Scheduled的轮询间隔调成秒级,启动项目后观察日志。这种适合最终验证,但不适合开发阶段频繁调整。
4. 实战案例:从简单到复杂的任务拆解
4.1 案例一:数据清理任务的cron设计
假设有个需求:每天凌晨3点半清理7天前的日志数据。这个任务要求的执行周期是"每天",执行时间是"凌晨3点半",清理逻辑是"删除7天前数据"。
表达式:0 30 3 * * ?。
这里我特意把秒位写成0而不是*,因为如果写成0 0 3 * * ?那就是凌晨3点整执行,而需求是3点半。秒位为什么要写0?因为如果不写0,比如写成30 3 * * ?,那Spring解析时会把秒位默认理解为0,但这已经写错了,因为表达式少了一位。六段式必须写够六位,少一位直接启动报错,别指望Spring帮你默认。
4.2 案例二:周报邮件提醒任务
"每周五下午5点发送周报邮件。"表达式:0 0 17 ? * FRI。
这里有两个关键点:日字段必须用?,因为周字段已经指定了FRI;时间字段顺序是秒、分、时,所以下午5点是17。
如果需求改成"每两周的周五发送一次",写法就比较麻烦了。cron表达式本身不支持"每两周"这种语义,因为周次周期超出了一个表达式的表达能力。这时候有两个方案:一是表达式仍然写0 0 17 ? * FRI,在任务代码里加一个判断,用当前日期和某个基准日期计算周数差,逢双周才执行;二是用数据库或配置中心维护一个"下次执行日期",每次执行完往后推两周。方案一实现简单,方案二更灵活,但需要额外的存储。
4.3 案例三:并发任务的时间错峰
有多个定时任务在同一时刻执行时,数据库和外部接口的压力会很大。比如早上8点整,报表生成任务、数据同步任务、缓存刷新任务全都在跑,数据库直接被干趴下。
应对方法有两个:
方法一,改表达式错峰。把三个任务的表达式分别设为0 0 8 * * ?、0 30 8 * * ?、0 50 8 * * ?,让它们间隔半小时左右执行。
方法二,保持同一时刻,但在任务内部加分布式锁。用Redis的SETNX实现一个简单的锁,只有拿到锁的实例才执行,其他实例直接跳过。这种方案在多实例部署时尤其重要,因为每个实例都会启动@Scheduled,如果表达式相同,几个实例会在同一时刻同时跑同一个任务,造成重复执行。分布式锁才是根治方案。
5. 线程池配置与任务阻塞问题
5.1 默认单线程池的坑
Spring的@Scheduled默认使用单线程的调度器,这是很多人没注意到的。默认情况下,Spring Boot会配置一个线程池大小为1的TaskScheduler,也就是说,所有定时任务都在同一个线程里排队执行。
这意味着什么呢?假设你有三个任务,分别在8:00、8:00、8:01执行。8:00的任务A开始执行,任务A是一个耗时3分钟的大任务,那么任务B和任务C都会因为线程被占用而延迟执行,直到任务A结束。更严重的是,如果任务A抛出异常并且没有被捕获,这个线程可能会被释放,但任务B和C的调度时间已经过了,等到线程空闲时,Spring会判断"下一个计划执行时间还没到",于是B和C当天的执行就直接被跳过了。
解决方法是自定义一个线程池:
@Configuration public class SchedulerConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { ThreadPoolTaskScheduler executor = new ThreadPoolTaskScheduler(); executor.setPoolSize(10); executor.setThreadNamePrefix("scheduled-task-"); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); taskRegistrar.setTaskScheduler(executor); } }把线程池设为10,基本够绝大多数项目用。setWaitForTasksToCompleteOnShutdown(true)很重要,它保证应用关闭时,正在执行的任务能跑完再停,而不是被强制中断。
5.2 任务内部异常导致下次调度不执行
还有一种情况比较隐蔽:任务方法内部抛了异常,但没有被捕获,@Scheduled的调度器线程因为异常终止了。在默认配置下,异常会向上抛出,但调度器并不会因为这个异常而崩溃,可是线程可能被污染,导致后续调度不稳定。
所以我的建议是:所有@Scheduled标注的方法,内部逻辑一律用try-catch包住,至少保证异常不会逃逸出方法边界。同时,对于"跑批"类任务,最好有告警机制,比如任务里失败时通过企业微信/钉钉机器人推送一条消息,否则失败了你都不知道。
@Scheduled(cron = "0 0 2 * * ?") public void cleanData() { try { // 业务逻辑 } catch (Exception e) { log.error("定时清理任务执行失败", e); // 推送告警 } }5.3 动态修改cron表达式
Spring的原生@Scheduled注解不支持运行时动态修改cron值。如果你需要在不重启应用的情况下调整任务的执行频率,可以用ScheduledTaskRegistrar配合配置中心来实现。
思路是,在SchedulingConfigurer的实现类里,从配置中心读取cron表达式,然后注册任务。等配置发布后,通过事件机制触发重新注册。
@Component public class DynamicScheduleTask implements SchedulingConfigurer { private String cron = "0 0/5 * * * ?"; public void updateCron(String newCron) { this.cron = newCron; } @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.addTriggerTask(() -> { // 任务逻辑 }, triggerContext -> { CronTrigger trigger = new CronTrigger(cron); return trigger.nextExecutionTime(triggerContext); }); } }这种方式比写死@Scheduled更灵活,但要注意线程安全问题,cron字段最好加volatile修饰。
6. 常见问题与排查技巧实录
6.1 启动报错:Cron expression must consist of 6 fields
这个报错是最常见的,原因很简单:表达式的字段数不对。常见情况有三种:
- 把Linux的五段式写进来了,
0 3 * * *只有5位。 - 字符串里混入了空格或Tab,导致解析错乱。
- 表达式整体被引号包含时,前后多了空格。
排查方法很简单:把表达式打印出来,数一数用空格分隔后是不是正好6段。如果是5段,要么补一个秒位在开头,要么补一个?在末尾——但要注意,补在开头和末尾的语义差别很大。
另外一个隐蔽的坑:有些版本的Spring对表达式里的空格非常敏感,0 0 3 * * ?中间如果混入了全角空格,直接报错。写代码的时候建议避免手敲空格,直接从文档复制。
6.2 任务没执行,但也没有报错
遇到这种情况,最先查的是表达式的时区和线程池配置。时区问题前面说过了,用zone属性显式指定即可。线程池问题则需要看日志,如果任务被跳过但没有任何异常,多半是线程池被长任务占满了。
还有一种情况:@Scheduled方法所在的Bean没有被Spring扫描到。比如类上忘记加@Component,或者@Scheduled写在了private方法上。Spring对@Scheduled方法的可见性要求是,必须是非静态的、非私有的方法。私有方法虽然不会启动报错,但任务永远不会执行。
6.3 任务重复执行
任务重复执行,一般有两个原因:
第一,应用在多实例环境下部署,每个实例都启动了自己的调度器。解决办法是加分布式锁,或者用ShedLock这种专门解决分布式定时任务重复执行的库。
第二,fixedRate模式下的并发执行。fixedRate不等待上次执行完成,如果任务执行时间超过了频率间隔,就会出现重叠执行。解决办法是把fixedRate改成fixedDelay,或者给方法加@Lock等同步机制。
6.4 表达式写对了但就是不在预期时间执行
最后一种情况最让人抓狂:表达式看起来完全正确,在线工具验证也没问题,但任务就是不在预期时间执行。
这时候大概率是应用服务器的时间有问题。检查一下服务器的系统时间和时区:date -R命令输出里有没有+0800后缀,如果没有,说明服务器时区是UTC。还有一种可能,是Java进程的默认时区和系统时区不一致,JVM启动参数里没有加-Duser.timezone=Asia/Shanghai。
这种问题我在真实项目里遇到过两次,每次排查都是先从代码查起,最后发现是服务器时区的问题。现在我的习惯是,新项目上线第一件事,就是把JVM参数里的user.timezone和file.encoding都显式指定,避免各种奇奇怪怪的隐性问题。
7. 一些值得记住的经验
我写定时任务踩过的坑,比看的文档多。这里分享几个个人习惯,都是血泪教训换来的。
第一,所有cron表达式必须写入配置文件,不要硬编码在注解里。这样不同环境可以配不同的执行频率,测试环境可以调成每5分钟一次,生产环境按真实的业务节奏来。
第二,每次写新的cron表达式,务必用CronExpression.parse在单元测试里跑一遍,打印出接下来5次执行时间人工确认。这个习惯帮我拦下了至少三次事故级别的错误。
第三,定时任务的执行日志一定要有独立的Logger和日志文件。定时任务本来就容易出问题,日志混在业务日志里,排查的时候翻半天翻不到。单独打印一份任务日志,看执行时间、执行结果、异常信息,效率会高很多。
第四,对于长时间运行的批处理任务,强烈建议在任务内部记录执行开始时间和结束时间,并输出耗时。这样每次执行完都能快速判断任务有没有变慢,为优化提供依据。
定时任务的cron表达式,本质上就是用一行字符串描述一套时间规则。掌握了六段式的字段含义、特殊字符的语义、以及Spring的调度机制,写起来就不是玄学,而是有章可循的工程实践。希望这篇能帮你少走一些弯路。