1. 从凌晨三点那次"没跑"的任务说起:先把字段数清楚
Cron表达式这东西,第一次接触的人觉得它简单得不行——不就是几个星号加数字吗?真上手一段时间就会发现,它是那种"看上去五分钟学会,实际上三个月还在踩坑"的典型。我印象最深的一次,某个后台系统的日报任务,代码里写的是0 3 * * *,开发同学信誓旦旦说每天早上三点跑,结果连续一周没出报表。查了半天发现,那个调度框架用的是六字段格式,第一个字段是秒,0 3 * * *实际含义变成了"每分钟的第 0 秒之后、且分钟数等于 3"——也就是每小时只在第 3 分钟执行一次,一天跑 24 次,而不是每天三点一次。凌晨三点那一刻它确实也跑了,但报表被后面二十几次的重复执行覆盖得面目全非。
这就是Cron表达式最典型的第一个坑:字段数量不统一。它是一个被多个系统各自实现的标准,不是某一家说了算的规范。Linux 的 crontab、Quartz、Spring 的@Scheduled、各种语言的调度库,甚至一些数据库内置的定时任务,全都叫"Cron",但字段个数、特殊字符支持范围、星期的编号起点,彼此之间都有差异。你在网上抄一段表达式,直接贴到自己的项目里,有相当概率跑出来的东西跟你想象的不是一回事。
所以聊Cron表达式,第一件事不是背语法,而是先确认你手里这套调度系统的"方言"是哪一种。我这十多年里见过的定时任务事故,一半以上不是表达式写错,而是把 A 系统的表达式搬到了 B 系统里。
1.1 五个字段和六个字段是两个世界
最经典的是五字段格式,来自 Unix 系 crontab,顺序是:
分钟 小时 日 月 星期比如30 2 * * 1表示每周一凌晨两点半。这套格式用了几十年,几乎所有系统管理员都熟悉。
另一个主流分支是 Quartz 引入的六字段(或七字段)格式,顺序是:
秒 分钟 小时 日 月 星期 [年]Spring 的@Scheduled(cron = "...")默认就走这一套,六字段,年份可选。国内 Java 技术栈的项目里,这个格式出现频率极高。
两者差一个"秒"字段,这个差异带来的影响远比"多一个字段"本身严重。因为它会导致你抄来的五字段表达式在六字段环境里整体右移一位,语义完全改变,而且它不会报错。这一点非常要命——语法上完全合法,运行时也不抛异常,只是执行时间和你想的不一样。你可能要等到业务数据对不上、日志时间戳不对劲的时候,才会回头去看表达式的字段数。
我一般的习惯是,在任何新项目里第一次写定时任务之前,先确认三件事:这套调度器接受几个字段、是否必须写?、星期的 0 和 1 分别代表周几。这三条确认完,后面能省掉大量排查时间。尤其是从别的项目复制配置过来的时候,这三条几乎是必查项。
还有一种情况需要注意:同一个项目里可能同时存在多套调度。比如应用内部用 Spring 的@Scheduled,操作系统层面又用 crontab 跑一些运维脚本,两边格式不一样。我见过有同学把 Spring 里的表达式贴进 crontab 做测试,结果 crontab 把第一个字段当分钟解析,0 0 3 * * ?里那个?在标准 crontab 里是不认识的,直接报语法错误——这还算是幸运的,至少报错了。更麻烦的是那种"恰好也能解析"的情况,静悄悄地按错误语义运行。
1.2 多出来的那个"秒"带来的连锁反应
六字段格式里的秒字段,不只是让你能精确到秒,它还改变了我们对"周期性"的表达习惯。
在五字段世界里,"每五分钟一次"写*/5 * * * *,含义是每小时的 0、5、10……55 分触发。在六字段世界里,"每五分钟一次"通常写0 */5 * * * ?,含义是每小时的 0、5、10……55 分的第 0 秒触发。看起来差不多,但如果你漏写了那个0,写成*/5 * * * * ?,含义就变成"每 5 秒一次",一天跑一万七千多次。这个错误我见过不止一次,尤其在复制粘贴的时候,从五字段表达式后面直接补了个?就提交了。
还有一个更隐蔽的情况:某些框架里秒字段是必填的,你不能省略;而某些框架里秒字段是可选的,写了就是六字段,不写就是五字段。这种"自适应"的解析器最坑,你以为自己写的是五字段,解析器却按六字段理解了,因为它看到的东西"看起来像"六字段。所以我现在写定时任务,一律把秒字段显式写出来,绝不依赖解析器的自动推断。
秒级精度还带来一个副作用:对系统时钟的依赖变强了。分钟级的任务,即使某台机器的时钟差几十秒,也无所谓;但秒级的任务,如果多台机器时钟不同步,就可能出现同一时刻多台同时触发,或者本该错开的任务挤在一起。分布式调度里这个问题尤其明显,后面讲重叠执行的时候还会提到。
1.3 每个字段的取值范围我列了张表
写错范围是另一个高频问题。比如把"每月的第 32 天"写进去,或者在六字段格式里把星期写成8。有的解析器会直接抛异常,有的则会静默地取模或者忽略,行为不一致。
下面这张表是我自己整理的一张对照表,涵盖两个主流格式的字段范围和特殊字符支持情况,贴在工位旁边用了好几年:
| 字段位置(五字段) | 字段位置(六字段) | 名称 | 取值范围 | 常用特殊字符 |
|---|---|---|---|---|
| 1 | 2 | 分钟 | 0-59 | *,-/ |
| 2 | 3 | 小时 | 0-23 | *,-/ |
| 3 | 4 | 日 | 1-31 | *?,-/LW |
| 4 | 5 | 月 | 1-12 或英文缩写 | *,-/ |
| 5 | 6 | 星期 | 0-7 或 1-7(看实现) | *?,-/L# |
| 无 | 1 | 秒 | 0-59 | *,-/ |
| 无 | 7 | 年 | 1970-2099 | *,-/ |
表格里最需要盯住的是"星期"这一行,那个范围写的是0-7 或 1-7(看实现),这种模糊表述不是我不严谨,是它本来就模糊。同一个数字1,在不同实现里可能指周日,也可能指周一。这个必须单独拿出来讲,下一节展开。
另外注意月份那一栏,虽然很多实现支持JAN、FEB这样的英文缩写,也有一些只认数字。跨团队协作的项目里,我建议统一用数字,别用缩写,减少一层理解成本。同理,星期用MON、TUE这种写法虽然可读性好,但同样不是所有实现都认。稳妥起见,数字优先,注释里补一句人类可读的说明。
2. 特殊字符的语义边界:别把 * 和 ? 当同义词
刚接触六字段表达式的人,几乎都会问同一个问题:既然已经有*了,为什么还要??这俩看起来不都是"任意"的意思吗?
答案跟"日"和"星期"这两个字段的特殊关系有关。在 Cron 的语义里,日(Day-of-Month)和星期(Day-of-Week)是互斥的:你不能同时精确指定"每月 15 号"和"每周一",因为这两个条件组合起来会让触发时间变得难以预测。所以标准里规定,这两个字段里必须有一个是?,表示"不关心",另一个才是真正的约束条件。
*和?的区别就在这里:*是"任意值",?是"不指定、不参与计算"。在分钟、小时、月这些字段里,*表示每一个可能的值;但在日和周这两个字段里,如果两个都写*,有的实现会取"或"(满足任一即触发),有的会报错,有的会取"与"(几乎永远不触发)。为了避免这种歧义,Quartz 系强制要求其中一个写?。
我见过最典型的错误是:想表达"每天凌晨两点",写成0 0 2 * * *。六字段下这是秒 分 时 日 月 周,六个位置全都填了,日和周都是*,在严格的 Quartz 里这会直接抛异常Support for specifying both a day-of-week AND a day-of-month parameter is not implemented。正确写法是0 0 2 * * ?或者0 0 2 ? * *,把其中一个星换成问号。
2.1 * 与 ? 的分工到底是谁规定的
要说清楚这个设计,得回到调度的本意。定时任务本质上是"在满足全部条件的时间点上执行"。分、时、月这些维度彼此独立,取交集很自然。但"日"和"星期"不同,它们描述的是同一件事的两种表达方式——"每月 15 号"和"每月第三个周五",在某些月份会重合,在另一些月份会冲突。如果强行取交集,你会得到一个非常稀疏的触发集合;如果取并集,语义又变得不直观。
Quartz 的取舍是:必须明确一个,模糊的那个用?标记为"我不关心"。这个设计从工程角度看是合理的,它把歧义消灭在配置阶段,而不是留给运行时。
理解了这一点,很多表达式就顺了。比如"每月 1 号凌晨执行",写0 0 1 1 * ?——这里星期位是?,因为已经被"1 号"约束住了。再比如"每周一早上八点",写0 0 8 ? * MON——日位是?。
不过现实中的实现并不统一。Spring 在较新版本的CronExpression里对*和?的处理比较宽松,两者在很多位置可以互换;但如果你用的是老版本的CronSequenceGenerator,行为又不一样。这就是我一直强调"先确认方言"的原因。我的建议很朴素:在日和周这两个字段上,永远用?明确表达"不关心",不要图省事写*。这样不管迁到哪个实现里,语义都是清楚的。
2.2 步长 / 的两种写法与"起点"陷阱
/是另一个经常被误解的符号。*/5表示"从最小值开始,每隔 5 个单位取一次"。在分钟字段里,*/5等价于0,5,10,15,...,55。但很多人会把它理解成"每 5 分钟",然后写成就完事了,忽略了起点这个概念。
0/5和*/5在大多数实现里是等价的,都从 0 开始。但3/5呢?意思是"从 3 开始,每隔 5 个",得到 3、8、13、18……直到 58。它不是"从 0 开始每 5 分钟一次"。这个区别在写"每隔 N 分钟"类需求时很容易翻车。
举个具体的例子。需求是"每 20 分钟执行一次数据同步"。写0/20 * * * * ?,触发时间是 0 分、20 分、40 分,每小时三次,看起来没问题。但如果需求其实想表达的是"从服务启动那一刻起,每 20 分钟一次",那 Cron 就表达不了,因为 Cron 是绝对时间对齐的,它没有"相对起点"的概念。这时候需要的是固定间隔调度器(比如scheduleAtFixedRate),而不是 Cron。
我踩过这个坑。早期做数据采集服务,需求说"每 10 分钟拉取一次数据",我写了0 */10 * * * ?。上线后发现,采集日志里的时间戳永远是 00、10、20、30……这种整点。业务方看着觉得有点奇怪,问为什么不能错开。答案是能错开,只是 Cron 表达的本来就是"对齐到时间刻度",要错开就得换个调度方式,或者直接换个起始偏移量,比如7/10。
还有个细节:某些实现里1/5这种写法和1-59/5是等价的,但有些实现要求必须写成后者才认。稳妥写法是用完整范围形式,可读性也好。
2.3 # 和 L 只在部分实现里成立
L(Last)和#(第几个)是 Quartz 系的两大利器,但它们不是 Cron 的通用能力,Linux crontab 就完全不支持。
L用在日字段表示"当月最后一天",用在星期字段表示"最后一个周几"。L-3表示"倒数第三天"。#用在星期字段,格式是星期#序号,比如6#3表示"第三个周五"。
问题在于,很多项目的调度链路里可能横跨多个组件。应用层用 Spring 的@Scheduled,底层可能接的是别的调度框架,配置层可能还有个可视化编辑器。L和W在较新的 Spring 版本里是支持的,但在老版本里会直接抛IllegalArgumentException。我就遇到过服务启动失败,日志里就一句"invalid cron expression",排查了半小时才发现是L不被支持。
所以我现在有一条硬性原则:如果一个表达式里用到了L、W、#,一定先写个最小可运行 Demo 验证一遍,别直接写进业务代码。验证成本很低,写个测试方法跑一下就知道支不支持。
3. 星期和月份的编号:那个"1"指的东西不一样
如果只能给一条关于Cron表达式的忠告,我会说:永远不要假设星期字段的 1 是周一。
这是跨系统迁移时最容易出事的地方。Linux crontab 里,0和7都代表周日,1到6分别是周一到周六。而 Quartz 里,1是周日,2是周一,一直到7是周六。同样是1,一个指周一,一个指周日,差了一整天。
更麻烦的是 Spring。Spring 的老实现CronSequenceGenerator大致沿用了类似 crontab 的语义(0 或 7 为周日,1-6 为周一到周六),而较新的CronExpression又做了调整,行为和 Quartz 更接近。也就是说,同一个框架的新旧版本之间,可能出现语义差异。
3.1 周日到底是 0、1 还是 7
我总结了几种常见实现的行为:
| 实现/环境 | 周日 | 周一 | 周六 | 是否接受 7 表示周日 |
|---|---|---|---|---|
| Unix crontab | 0 或 7 | 1 | 6 | 是 |
| Quartz | 1 | 2 | 7 | 否(7 是周六) |
| Spring 老版本生成器 | 0 或 7 | 1 | 6 | 是 |
| Spring 新版本解析器 | 1 | 2 | 7 | 取决于配置 |
看这张表就能明白,为什么我说"别靠记忆"。同一段* * * * 1,在 crontab 里是每周一,在 Quartz 里是每周日。如果这个任务是每周一给业务方发报表,错一天可能就要写事故报告。
我的做法是:在需要指定星期的场景下,一律用英文缩写。MON、TUE、WED、THU、FRI、SAT、SUN这几个写法,绝大多数实现都支持,而且没有歧义,人看代码的时候也不用去数数字。唯一的例外是少数只支持数字的老实现,那就得老老实实查文档。这里说的是"查文档",不是"凭经验"——经验在跨系统场景下经常是错的。
还有个细节值得提:0和7同时表示周日这件事,会导致一种奇怪的情况——如果你写1-7,本意是周一到周日,在某些实现里它会展开成"周一到周六加周日",七个值,正常;但如果你写0-7,在某些实现里会变成八个值,周日的条件被匹配了两次。虽然最终触发时间不会重复(同一分钟只会触发一次),但在做条件判断或者算触发次数的时候,可能会出问题。这种边角情况很少见,但确实存在。
3.2 月份从 1 开始,这点倒是统一的
相比星期的混乱,月份倒是没什么争议,所有实现都是 1 到 12,1 是 January,12 是 December。这一点算是 Cron 语法里少有的"全球统一"部分。
不过月份有一个容易忽略的点:31 号和 30 号的处理。如果你写0 0 0 31 * ?,表示每月 31 号。2 月没有 31 号,4 月、6 月、9 月、11 月也没有。那么这些月份会怎样?大部分实现是"跳过",也就是那几个月不触发。这个行为本身是合理的,但如果你没意识到这一点,就会疑惑"为什么 2 月、4 月没跑"。
更糟糕的是"每月最后一天"这个需求,很多人会写成 31 号,结果二月份直接漏掉。正确的做法是用L,但L又未必被支持,于是就得用别的方式兜底,比如在代码里判断月底,或者干脆每天跑一次然后在业务逻辑里判断是不是当月最后一天。这个取舍后面还会细说。
3.3 跨框架写法对照表
我把几个常见需求在不同框架下的写法整理成一张表,平时转技术栈的时候直接对着抄,省得反复试:
| 需求 | 五字段(crontab) | 六字段(Quartz 系) |
|---|---|---|
| 每天凌晨 3 点 | 0 3 * * * | 0 0 3 * * ? |
| 每周一上午 9 点 | 0 9 * * 1 | 0 0 9 ? * MON |
| 每月 1 号零点 | 0 0 1 * * | 0 0 0 1 * ? |
| 每小时的第 30 分钟 | 30 * * * * | 0 30 * * * ? |
| 工作日上午 8 点半 | 30 8 * * 1-5 | 0 30 8 ? * MON-FRI |
| 每 15 分钟 | */15 * * * * | 0 0/15 * * * ? |
| 每月最后一天 23 点 | 不支持,需变通 | 0 0 23 L * ?(需确认支持) |
注意最后一行,五字段下"每月最后一天"没有原生写法,只能用28-31加上业务判断。这个"看起来能用实际有坑"的写法,后面会专门分析。
4. 看起来对、跑起来错的表达式清单
前面讲的偏语法,这一节讲实战里真正让人头疼的几类问题。共同特点是:表达式本身语法合法,解析器也不报错,只是执行结果跟预期不匹配。这类问题排查成本最高,因为你首先得意识到"是我写错了",才可能去查。
4.1 "每 30 分钟执行一次"被写成了"整点和半点"
需求原话是"每 30 分钟执行一次"。开发者写了0 0/30 * * * ?。
这个表达式的实际含义是:每小时的 0 分 0 秒和 30 分 0 秒触发,也就是整点和半点。如果任务在 10:31 部署上线,那么下一次执行是 11:00,间隔 29 分钟;再下一次 11:30,间隔 30 分钟。看起来没问题。
但问题出在业务语义上。有些业务希望的是"从现在开始,每 30 分钟一次",比如缓存刷新、心跳上报这类。用 Cron 表达就成了"对齐到半点整点",如果上游数据是按自己的节奏生成的,就会出现"数据刚到,任务刚走"的时序错位。
这种情况我一般会先问一句:这个"每 30 分钟"到底是"对齐时间刻度"还是"固定间隔"?对齐刻度用 Cron,固定间隔用固定速率调度。两者的区别在上线时刻不整齐的时候会非常明显。我见过一个监控采集任务,用 Cron 写成了整点对齐,结果每次采集窗口和监控系统的聚合窗口错开,数据总是少一段,查了很久才定位到是这个原因。
还有一个变体:*/30和0/30的区别。在分钟字段,*/30从 0 开始,得到 0 和 30;0/30从 0 开始,也是 0 和 30。这俩等价。但如果写成5/30,就是 5 和 35,不再对齐半点了。这种细微差别在代码 review 的时候很难被发现。
4.2 "每月最后一天"最容易翻车
这个需求在业务系统里出现频率极高——月末对账、月末结算、月末生成报表。Cron 里最直接的写法是L,但不是所有实现都支持。
于是很多人退而求其次,写0 0 0 28-31 * ?,意思是 28 到 31 号每天都跑。然后在代码里判断"是不是本月最后一天"。这个方案能work,但有几个隐患:
第一,28 到 31 号每天都会触发一次任务,如果任务本身比较重(比如全量对账),资源开销是原来的四倍。第二,如果代码里的判断逻辑有 bug,可能会重复生成报表。第三,闰年的 2 月 29 号,有些实现处理起来会有问题。
我见过一个更隐蔽的写法:0 0 0 31 * ?。写的人以为"每个月都会跑,没有 31 号的月份自动跳过",这没错,但 2 月、4 月、6 月、9 月、11 月全都不跑。一年下来跑了 7 次,业务方一直以为系统在跑,因为每月"大部分时候"确实有数据。直到某个季度末对账,财务发现数据缺失才暴露出来。
比较稳妥的方案,我通常按这个顺序选:
- 如果调度器支持
L,直接用0 0 0 L * ?,最简单。 - 如果不支持,用
28-31配合业务判断,同时在日志里记录"本月是否已执行"的标记,做幂等。 - 如果对执行时间有严格要求(比如必须在当月最后一秒之后、下月第一秒之前),那 Cron 可能不是合适的工具,改用每月 1 号凌晨执行、处理上月数据,语义更清晰。
第 3 点值得展开。很多"月末任务"其实可以改写成"月初任务处理上月数据"。这样表达式变成0 0 0 1 * ?,简单、可靠、不会有月末判断问题。唯一的代价是任务的实际执行时间和业务语义上的"月末"差了几天,如果业务能接受,这是最省心的方案。
4.3 时区与夏令时:任务凭空少跑或多跑一次
这个问题在单机环境里几乎遇不到,一旦涉及到多地域部署就会冒出来。
Cron 表达式本身是不带时区信息的,它描述的是"本地时间的某个时刻"。这个"本地"到底是谁的本地,取决于运行环境。Linux crontab 用系统时区;Java 应用默认用 JVM 的默认时区,可以通过参数调整;某些调度框架允许在任务级别指定时区。
我遇到过的情况是:一个任务在国内环境配置的是0 0 2 * * ?,部署到海外机房之后,由于服务器时区不同,实际执行时间变成了北京时间下午。这在跨区域业务里是常态,不算 bug,但如果没意识到,排查起来很浪费时间。
更麻烦的是夏令时。在存在夏令时切换的地区,每年会有一次时间"跳过一小时"和一次时间"重复一小时"。如果你的任务配置在跳过的那一小时里,那天它就不会执行;如果配置在重复的那一小时里,可能会执行两次。这两种情况都会导致业务数据出现缺口或者重复。
应对方式我一般用两条:一是关键任务避开本地时间 2:00 到 3:00 这个区间,因为夏令时切换通常发生在这个时段;二是在任务逻辑里做幂等,不管执行一次还是两次,结果都正确。幂等这件事,不管有没有时区问题,都应该做,但时区问题让它从"最佳实践"变成了"必须做"。
4.4 长任务重叠:表达式对了,执行还是乱的
最后一个高频问题跟表达式本身无关,但表现出来的现象很像表达式写错了。
Cron 只负责"什么时候触发",不负责"上一次执行完了没有"。如果一个任务的执行时间超过了触发间隔,就会出现重叠执行。比如每 5 分钟触发一次,但每次执行要 8 分钟,那就会有两三个实例同时在跑。
重叠执行的后果取决于任务类型。如果是纯查询类,问题不大;如果是数据写入,可能出现并发冲突、重复写入、数据错乱。我在一个日志清理任务上踩过这个坑:每 10 分钟清理一次过期数据,某次数据库响应变慢,单次清理跑了 15 分钟,结果两个清理进程同时工作,把还没到期的数据也删了一部分。
解决办法有两个层面。调度层面,大多数框架支持"禁止并发执行"的配置,比如给任务加一个单例锁,或者配置成"上次没跑完就不触发下一次"。业务层面,任务本身要有幂等设计和边界校验,不能假设"我一定是唯一在跑的那个"。
调度层面的方案见效快,但有个副作用:如果某次执行真的卡死了,后面的任务会全部堆积或者跳过。所以还需要配套的超时控制和告警。这一块内容展开能写很多,这里只提一句:Cron 表达式解决的是"何时开始",不解决"如何结束",这两件事要分开设计。
5. 写完不等于写对:验证、测试与线上兜底
前面讲的都是"怎么写对",这一节讲"怎么确认自己写对了"。字面上看这两件事差不多,实际操作中差别很大。很多表达式语法上挑不出毛病,逻辑上也自洽,但就是跑的时机不对,只有实际跑一遍或者算一遍才知道。
5.1 本地快速验证的几条路子
最土也最有效的办法,是把 Cron 表达式改成"每分钟执行一次"或者"每 10 秒执行一次",观察几轮实际触发时间,确认语义符合预期,再改回真实的表达式。这个方法不需要任何工具,缺点是只能验证"周期性"的部分,验证不了"每天凌晨三点"这种长周期场景。
第二种办法是用在线解析工具,输入表达式之后它会列出未来若干次触发时间。这类工具很多,但要注意一个问题:不同工具的解析引擎不一样。同一个表达式,在 A 工具里显示"每周一",在 B 工具里可能显示"每周日"。所以用工具验证的时候,一定要选跟你的运行环境一致的引擎,或者至少交叉验证两个工具,看结果是否一致。我个人的做法是,先用工具生成候选时间列表,再写一段几十行的代码用项目里实际的调度库跑一遍,两边对比。代码很短,但结果最可信。
第三种办法是在项目里写一个临时的单元测试,直接调用调度库的nextExecutionTime之类的方法,打印未来十次触发时间。这个办法的好处是用的就是项目里真实的那套解析逻辑,不会出现"工具和线上不一致"的问题。我的习惯是把这类测试保留下来,作为回归测试的一部分。
5.2 把"下次触发时间"写进单元测试
接着上面说。测试里断言什么?不是断言"表达式等于某个字符串",那没有意义。应该断言的是未来若干次的具体触发时间点。
举个思路:构造一个固定的基准时间(比如某年某月某日零点),调用解析器算出接下来五次触发时间,跟预期列表比对。这样一旦有人改了表达式,或者升级了调度库导致语义变化,测试会立刻失败。
这种测试的价值在于,它把"隐式的语义理解"变成了"显式的断言"。表达式里的1到底是周一还是周日,测试一跑就清楚了。我在做技术栈迁移的时候特别依赖这类测试:迁移前把关键任务的表达式和预期触发时间都固化成测试用例,迁移后跑一遍,全绿了才敢上线。相比人肉 review 表达式,这个办法可靠得多。
需要注意一点:测试里用到的基准时间最好显式指定时区,否则在本机和 CI 环境可能表现不一致。我就被这个坑过一次,本地测试全过,CI 上挂了一个,最后发现是两地服务器时区配置不同。
5.3 线上留痕:记录实际触发与下次计划时间
再完善的测试也覆盖不了运行时环境。线上必须要能回答两个问题:这个任务上次是什么时候跑的,下次计划什么时候跑。
这两个信息建议直接打进日志,或者暴露出监控指标。很多调度框架本身提供了查询接口,可以在管理后台看到任务的下次触发时间。如果没有,就在任务执行的开头打一行日志,内容包含任务名、本次触发时间、表达式本身。
我特别推荐把表达式本身也打进日志。原因是,线上运行的版本和代码仓库里的版本,有时会因为配置中心覆盖、动态修改等原因不一致。日志里有表达式,排查的时候能第一时间确认"跑的是不是我以为的那套规则"。
还有一个实践:任务执行完成后,再打一行日志记录耗时。这样能直接看出任务是否接近或超过了触发间隔,为前面提到的重叠问题提供判断依据。耗时接近间隔的 80% 时,就该考虑加锁或者拆任务了。
6. 业务场景表达式速查与推导过程
表达式这东西,背下来意义不大,会推导才有用。这一节挑几个典型场景,把推导过程写出来,照着这个思路,其他场景都能自己推。
6.1 报表与对账类
报表任务的特点是时间点固定、不能漏跑、对执行时长不敏感。
"每天早上 7 点生成昨日日报":六字段写成0 0 7 * * ?。推导过程:秒位 0,分位 0,时位 7,日位*(每天都跑),月位*,星期位?(不关心,因为日位已经是*了)。
"每天 7 点和 19 点各一次":0 0 7,19 * * ?。用逗号枚举小时,比写两行更清晰。
"每月 1 号早上 8 点生成月报":0 0 8 1 * ?。注意这里是 1 号生成"上月"报表,而不是月末生成"本月"报表,理由在 4.2 节说过。
"工作日早上 9 点":0 0 9 ? * MON-FRI。用英文缩写更直观,也规避了数字编号歧义。
这类任务我一般会额外加一条规则:错过的任务要能补跑。因为不管什么原因,总会有漏跑的时候——服务重启、数据库维护、发布停机。Cron 本身不处理补跑,需要在任务逻辑或者调度框架层面做。常见的做法是,任务启动时先检查"上次执行时间",如果发现距现在超过一个周期,就补一次。
6.2 清理与归档类
清理任务的特点是周期性、不需要精确时间点、对资源敏感,最好避开业务高峰。
"每天凌晨 2 点清理 30 天前的日志":0 0 2 * * ?。选择 2 点而不是 0 点,是因为 0 点往往有一大堆跨天任务在跑,挤在一起容易互相影响。
"每 10 分钟清理一次临时文件":0 0/10 * * * ?。这类高频清理任务,建议加上"单次处理条数上限",避免某一次积压太多导致执行时间过长。
"每周日凌晨 3 点做数据归档":0 0 3 ? * SUN。选周日凌晨是因为业务量最低,归档操作涉及大量 IO,对线上影响小。
这里有个经验:清理类任务的间隔不要太密。我见过有人把清理任务设成每分钟一次,理由是"及时释放空间"。结果任务本身的查询开销比清理释放的空间还大,反而拖慢了数据库。后来改成每 30 分钟一次,问题消失。间隔的设定,最终要看数据产生速率和清理成本,不是越频繁越好。
6.3 "工作日"这一类需求的现实写法
"工作日"是最麻烦的一类需求,因为它不只是 Cron 问题,还牵扯到节假日。
Cron 能表达的"工作日"只有"周一到周五",写成MON-FRI。但中国的法定节假日和调休,Cron 完全表达不了。春节那一周,周一到周五也是假期;某个调休的周六,反而是工作日。
所以现实中的方案通常是分层的:Cron 层用MON-FRI做粗筛,业务层再加一个"今天是不是交易日/工作日"的判断。这个判断依赖一张维护好的日历表或者外部接口。
我一般会这样组织:任务在周一到周五都触发,进入逻辑后第一步查工作日日历,不是工作日就直接返回,日志里记一行"skip by calendar"。这样做的好处是,Cron 表达式保持简单稳定,节假日调整只需要改日历数据,不需要动调度配置。而且调休这种"周六上班"的情况,也能覆盖——把周六也加进触发日期,靠日历判断是否真的执行。
有些团队会反过来做,把日历直接编译进 Cron 表达式,比如每月枚举出具体日期。这个方案在短期内可行,但维护成本很高,每换一年就要重新生成一遍。我不太推荐,除非调度系统完全不支持业务层判断。
7. 几条踩出来的经验
写到最后,补几条零散但实用的经验,都是这些年磕磕碰碰攒下来的。
第一条,表达式写完之后,一定要人工念一遍。怎么念?从右往左念,或者按"秒分时日月周"的顺序逐个念出来。"零秒、零分、三点、任意日、任意月、不关心星期",念出来是"每天三点",那就对了。很多错误在念的过程中就会露馅,因为语法上通顺、语义上不通顺的表达式,念的时候会卡壳。
第二条,关键任务的表达式旁边必须写注释。注释不要写"定时任务配置"这种废话,就写"每天凌晨 3 点执行日报统计"。半年后接手的人(很可能就是你自己)看一眼注释就知道意图,不用去推表达式。
第三条,不要在一个表达式里塞太多逻辑。比如"周一到周五的 9 点、12 点、18 点,但每月 1 号不跑",这种需求硬要用一个表达式表达,必然变得又长又难懂。拆成多个任务,每个任务一个简单表达式,用业务代码做条件判断,可读性和可维护性都好得多。
第四条,升级调度库版本时重点回归定时任务。调度库的解析行为在不同版本之间可能变化,尤其是*和?的宽容度、星期编号的处理。升级前把关键任务的触发时间固化到测试里,升级后跑一遍,这个成本很低,但能挡住大部分问题。
最后一条偏感性一点。Cron 表达式是个很老的东西,老到很多人觉得它没什么可学的。但它老而不死是有原因的:它把"什么时候执行"这件事抽象得足够简单,简单到可以用一行字符串描述复杂的周期规则。真正难的从来不是记住那几个符号,而是想清楚你的业务到底需要"什么时候"执行,以及执行失败、错过、重叠的时候该怎么处理。表达式只是把想法翻译成配置的最后一步,前面那些思考才是真正值钱的部分。