cron表达式真是个神奇的东西,看起来就几个星号加斜杠,真要写对却没几个同事能一次搞定。尤其是“每N秒”“每N分钟”“每N小时”这种高频需求,网上答案五花八门,抄错了也不知道问题出在哪。我翻了翻手头的项目,发现大部分定时任务的坑都出在表达式写法和框架差异上,今天索性把这块彻底捋清楚。
1. cron表达式的基础认知:五段、六段还是七段
在动手写任何表达式之前,得先把cron表达式的“规格”搞明白。不同平台采用的cron标准并不完全一样,最直观的差别就是字段数量。
1.1 标准cron格式速览
平时用得最多的格式是六段式:
秒 分 时 日 月 周Linux系统自带的crontab用的是五段式,没有秒:
分 时 日 月 周Quartz框架在六段基础上还支持可选的年,就是七段式:
秒 分 时 日 月 周 年不少云平台和分布式任务调度系统也是七段式,可以指定到年份和最后一个周的偏移量。
各字段的取值范围:
| 字段 | 取值范围 | 说明 |
|---|---|---|
| 秒 | 0-59 | 有些实现支持0-59,有些实现不支持秒 |
| 分 | 0-59 | |
| 时 | 0-23 | |
| 日 | 1-31 | 注意月份天数差异 |
| 月 | 1-12 或 JAN-DEC | |
| 周 | 0-7 或 SUN-SAT | 0和7都代表周日,部分实现有差异 |
| 年 | 留空或1970-2099 | 不常用 |
1.2 特殊字符的含义与使用边界
cron表达式的灵活性主要靠几个特殊字符撑起来:
*:匹配字段的任意值,比如“每分钟”就是每一分钟都匹配?:仅在“日”和“周”两个字段中使用,表示“不指定具体值”,避免日和周同时约束造成的冲突-:范围,例如10-12表示从10到12,:枚举,例如1,15,30表示第1分、第15分、第30分/:步长,例如*/5表示每5个时间单位L:最后的含义,例如L在日字段表示当月最后一天,在周字段表示周六W:最近的工作日,仅日字段使用#:第几个周几,例如3#2表示第二周的周三C:结合日历值的含义,实际很少用
?和*的区别经常被追问。*表示“每”这个概念,而?表示“我不关心这个字段”。举个例子,0 0 12 ? * *和0 0 12 * * ?在大多数实现里行为等价,但如果在日和周两个字段同时写具体值,比如0 0 12 1 * 1(每月1号且又是周一),有些实现会报错或行为不一致,所以规范做法是日和周只使用一个来指定。
1.3 举一个完整的例子
0 */5 9-18 * * MON-FRI意思是:工作日(周一到周五)的上午9点到下午18点之间,每5分钟执行一次。
拆开看:
- 秒=0:整分触发
- 分=*/5:每5分钟
- 时=9-18:在9点到18点这个时间范围内
- 月=*:任何月份
- 周=MON-FRI:周一至周五
这个表达式在工作日看报表任务里很常见。注意“时=9-18”的含义是9:00到18:59之间每5分钟都会跑,实际业务需求常常只想跑到18:00,就得写成0 0 18单列一个表达式,或者把时字段拆开处理。
2. 三种高频需求:每N秒、每N分钟、每N小时的写法拆解
标题里最核心的其实就是“每N秒,分,小时执行一次”这个写法。这部分单独展开讲,因为里面的细节特别多,少写一个0或者多写一个/,效果天差地别。
2.1 每N秒怎么处理
最标准的写法:
*/5 * * * * *这表示每5秒执行一次。类似的*/10 * * * * *每10秒,*/30 * * * * *每30秒。
这里有个容易忽略的问题:*/N在秒字段上的含义是从0秒开始,每隔N秒取一次。如果N不能被60整除,比如每7秒或每25秒,那么每个周期内的时间点是不均匀的,实际执行时刻会“错位”。
*/7在0到59秒内实际匹配的是:0、7、14、21、28、35、42、49、56,然后跳回0。也就是说每轮执行间隔基本上是7秒,但在56到下一次0秒之间只隔了4秒(59到下一轮0秒)。在长时间运行下,这个误差很难被感知,但如果你在日志里精确看时间戳,就会发现分布不均匀。
如果你的需求不是“自然秒序列均匀分布”,而是“从第N秒开始每隔M秒执行一次”,可以用范围加步长的组合,比如从第10秒开始每5秒一次:
10-59/5 * * * * *表示10到59秒范围内每5秒触发一次。
对于秒级任务,还要考虑执行时间本身。如果任务执行耗时超过设置间隔,就会产生堆积或重叠。一般要配合任务框架的“不重叠执行”机制,比如在任务入口加锁或使用单线程调度器。
2.2 每N分钟怎么写
标准写法:
0 */N * * * *例如每5分钟:
0 */5 * * * *每15分钟:
0 */15 * * * *前面的0是秒字段,表示在分钟的0秒触发,避免在分钟中间某个随机秒上执行。如果漏掉这个0,写成* */5 * * * *,就会在每5分钟的窗口内每一秒都执行一次,效果变成每分钟执行60次,这显然不是本意。
从“整分触发”的控制粒度想更细一点,比如要在每分钟的第30秒跑一次:
30 * * * * *这就是每分钟的第30秒执行,不是每30秒执行一次,两者完全不同。
2.3 每N小时怎么写
标准写法:
0 0 */N * * *例如每2小时:
0 0 */2 * * *每6小时:
0 0 */6 * * *在小时字段使用*/N时同样存在“从0点开始每隔N小时”的语义。0 0 */6 * * *实际执行时间是0点、6点、12点、18点。如果你希望任务从凌晨2点开始每6小时跑一次,可以这样写:
0 0 2-20/6 * * *含义是2点到20点之间每6小时,实际是2点、8点、14点、20点。
小时字段还有一种常见写法是多个枚举值,比如0 0 1,3,5 * * *,每天1点、3点、5点执行,这个表示法在排班和定时拉取中特别常用。
2.4 范围类“每N”的特殊处理
有时候业务上希望“每天凌晨1点到5点之间,每半个小时执行一次”。如果写成:
0 0/30 1-5 * * *注意小时字段范围是1到5,分钟字段的0/30是“从0分开始每30分”,那么这个表达式的实际执行时刻是1:00、1:30、2:00、2:30……5:00。看起来没问题,但如果期望的是1:00、1:30、2:00这种,标准写法确实就是这样。
Quartz的0/30与*/30在大多数实现中等价,但Spring的@Scheduled在这两种写法上解析规则基本一致。为了减少歧义,我建议统一用*/N而不是0/N来表达“从0开始每隔N”,把0/N留给真正需要显式指定起始位置的场景。
3. 日常工作里高频使用的cron表达式及解读
这里列一个自己项目里高频在用的速查表,基本涵盖了95%以上的场景。每个表达式都附上了适用场景说明,直接抄也行。
| 场景 | 表达式 | 说明 |
|---|---|---|
| 每分钟执行 | 0 * * * * * | 秒字段为0,整分触发 |
| 每5分钟执行 | 0 */5 * * * * | 缓存刷新、轻量数据同步 |
| 每30分钟执行 | 0 */30 * * * * | 状态检查 |
| 每小时执行 | 0 0 * * * * | 整点任务 |
| 每2小时执行 | 0 0 */2 * * * | 数据聚合 |
| 每天凌晨执行 | 0 0 0 * * * | 日志清理、备份 |
| 每天固定时刻执行 | 0 30 2 * * * | 每天凌晨2:30执行 |
| 每周一凌晨执行 | 0 0 0 * * MON | 周报生成 |
| 每月1号凌晨执行 | 0 30 0 1 * * | 月报、账单 |
| 工作日每15分钟 | 0 */15 9-18 * * MON-FRI | 工作时段内轮询 |
| 每月最后一天执行 | 0 0 0 L * * | 月末统计 |
| 月末倒数第三天 | 0 0 0 L-3 * * | 注意只有部分实现支持L-n格式 |
3.1 速查表之外的几个特殊写法
“每个工作日的中午12点和下午6点”可以合并:
0 0 12,18 * * MON-FRI“每月的第5个周五”在Quartz里可以写:
0 0 0 ? * 5#5意思是周字段为周五且是该月第5个周五,如果这个月没有第5个周五就不执行。注意这种写法日字段必须用?。
“每季度第一个月的第一天执行”写起来比较啰嗦,我是直接用三个表达式拼的:
0 0 0 1 1,4,7,10 *这三种写法在面试和协作中很容易遇到,别慌,对照字段拆开看就明白了。
3.2 为什么秒字段在多数场景下不用
六段式cron的秒字段功能很强大,但大多数业务任务根本不需要秒级触发。秒级任务放大到整个系统里,如果多个任务同时踩秒跑,会产生一种“整点冲击”,打满CPU或数据库连接池。我见过某系统因为凌晨0点整堆积了几十个定时任务一起跑,导致数据库瞬时负载飙高。如果你的任务不是对时效敏感的类型,建议错峰设置,比如0点5分、0点10分、1点30分。
排定时任务有个小技巧:固定秒位置为0,分钟位置错峰,比如10分钟以内多个任务分别设0 1/10、0 2/10、0 3/10,避免在同一分钟扎堆。
4. 同样是cron,不同调度框架的“方言”差异很多人不知道
把同一个表达式从Linux crontab搬到Quartz或Spring里,结果可能完全不同,这不是表达式写错了,而是框架对语法的实现有差异。
4.1 Linux crontab:五段式,不支持秒
Linux系统自带的crontab是五个字段且最小精度是分钟,没有秒字段:
分 时 日 月 周例如每5分钟:
*/5 * * * * commandLinux crontab里的/步长工作和Quartz基本一致,但要注意两个方面:一是周和日同时设置时的逻辑与其他实现不同,Linux的crontab在“周”和“日”任一匹配即可(OR关系),而Quartz默认是AND关系(除非日字段用?);二是不支持表达式动态预判,需要自己用crontab命令验证。
想实现Linux下每30秒执行一次的任务,原生的crontab无法直接做到。可以写一个小循环脚本:
* * * * * for i in $(seq 1 30); do command; sleep 1; done这种方式等于每分钟内循环执行30次,简单粗暴,但脚本自身耗时会叠加,实际间隔比预定值偏大。更可控的做法是改用systemd timer,它能精确到秒级且支持日历事件的复杂表达。
4.2 Quartz:六段或七段式,支持年
Quartz是Java生态使用最广的任务调度库,它的cron格式支持秒和年:
0 0/30 9-18 ? * MON-FRI注意上面表达式中日字段用了?,这是为了避免日和周同时约束。
Quartz里还有一个重要的配置叫Misfire(错过触发策略)。当调度器长时间停机或任务被阻塞导致错过触发时间,Quartz不会补上所有错过的调度,而是根据任务设置的misfire策略决定行为,比如MISFIRE_INSTRUCTION_FIRE_ONCE_NOW表示立即补偿执行一次,DO_NOTHING则直接跳过。
我在一个订单超时检查任务里踩过这个坑:调度器因发布重启停止了5分钟,重建后发现所有错过的触发全部立即补偿,导致几千条超时消息瞬间发出。解决方式是使用DO_NOTHING策略,每次调度时只处理当前窗口内的数据。
4.3 Spring @Scheduled:六段式,秒级可用
Spring从4.3开始支持cron表达式中的秒位,@Scheduled的cron默认是六段式:
@Scheduled(cron = "0 */5 * * * *") public void refreshCache() { // 每5分钟刷新缓存 }使用中有几个容易踩的地方:
- 时区问题:
@Scheduled默认使用服务器默认时区。如果你的服务器是UTC时区而业务在北京时间,每天0点执行的任务会在早上8点才跑,务必显式配置时区。 - 线程池问题:
@Scheduled默认使用单线程调度器,如果前一个任务运行时间超过间隔,后面所有任务都会排队。业务量上来后记得使用TaskScheduler并配置自定义线程池。 - 不支持动态修改:
@Scheduled的cron在应用启动时解析一次,运行期间改注解属性不生效。需要动态修改定时策略的,要么用SchedulingConfigurer注册动态cron的Trigger,要么把任务抽到独立组件中管理。
4.4 分布式任务调度系统的cron:基本都是七段式
像xxl-job、ElasticJob这类分布式调度系统,前端录入的cron用的是Quartz标准(七段式,年可省略)。这类系统自己的几个特点:
- 秒字段支持完整,常用于实现每5秒或每10秒的极短周期任务
- 分片广播和路由策略不在cron表达式的范畴内,需要额外配置
- 时区以调度中心的服务器时区为准,调度中心与执行机之间的时间差会影响触发准点率
如果你服务器的系统时间误差较大,记得用NTP同步一下,否则表达式再准也没用。
5. 调试cron表达式的方法论:在线工具、计算脚本、日志验证一个都不能少
表达式写完了,怎么确认下一次性就能跑在预期的时刻?我的习惯是“三层验证法”:先用在线工具算一遍,再写一个小脚本或命令验证,最后看日志确认。
5.1 在线工具的边界
常用工具:
- crontab.guru:最直观,支持5字段格式,输入表达式会翻译成一段英文描述
- Quartz的在线生成器:搜索“cron maker”就能找到,支持6字段和7字段
- Spring官方文档中的cron示例
在线工具适合快速检查语法,但有两个局限:
- 只验证格式和常规语义,不验证框架的特殊扩展(比如
L-3或#5这类) - 不加载你的业务时区,结果展示按浏览器时区来,会有偏差
5.2 自己算下一次执行时间
写代码时可以用主流语言的cron库来计算表达式下一次执行时间。Java生态里可以用CronExpression(Quartz自带):
CronExpression cronExpression = new CronExpression("0 */5 * * * *"); Date next = cronExpression.getNextValidTimeAfter(new Date()); System.out.println("下一次执行时间: " + next);Python里用croniter:
from croniter import croniter from datetime import datetime cron = croniter('0 */5 * * * *', datetime.now()) next_time = cron.get_next(datetime) print(f"下一次执行时间: {next_time}")在调度框架中调试时,直接在单元测试里断言“下一次执行时间”往往比手工看日志快得多。我习惯把核心任务的cron表达式写成配置项,然后加一个单元测试,跑未来24小时所有触发点,检查是否有落在预期窗口之外的情况。
5.3 日志验证的三个要点
线上任务第一次上线,我基本都会在任务入口打日志,重点记录三个东西:
- 当前时间戳(精确到毫秒)
- 配置的cron表达式
- 实际触发时间与理论触发时间的时间差
只记录“任务执行了”这一条日志是不够的,因为你要判断的是“调度准不准”,而不是“任务跑没跑”。
如果发现实际触发时间与预期相差固定间隔,多半是时区问题;如果相差不固定且整体延迟,优先检查任务执行时间是否超过了执行周期,导致下一次触发被阻塞或堆积。
5.4 几个必踩的坑
日字段与周字段的互斥。写表达式时,如果日字段和周字段都指定了具体值,在不同框架里要么报错,要么行为诡异。遇到这种需求,先用?放弃一个字段,比如“每月1号且是周一”这种本身就比较绕,干脆拆成两个表达式。
秒级任务的高延迟陷阱。每5秒执行一次的表达式配合3秒的接口调用,如果任务本身是同步执行且没有加锁,你看到的实际触发间隔会逐渐偏离设定值。处理方法是任务方法内部用异步线程池执行,调度线程只负责投递任务。
cron表达式与节假日无关。cron是纯日历调度,它不会“知道”今天是法定节假日。需要跳过节假日的任务,只能在任务逻辑里判断,或者依赖调度平台提供的日历功能。
测试时不要用真实的短周期。每5秒执行一次的任务直接上线观察,日志量会很大。合适的方式是把表达式下载到本地用假时钟模拟,或者临时把*/5改成*/15验证链路,确认无误后再改回正式周期。
最后分享一个使用习惯:我始终会在项目的配置中心放一个“表达式自查表”,每个定时任务都配上上次修改时间、修改人、期望触发逻辑说明。cron表达式的语义密度很高,看起来就几十个字符,如果没有说明,三个月之后再回来看大概率要重新推演一遍。表达式本身不需要注释,但它周围一定得有上下文说明。