多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring定时任务cron表达式从语法到避坑实践指南

Spring定时任务cron表达式从语法到避坑实践指南 1. 一个凌晨三点没跑的任务让我把cron表达式从头啃了一遍先讲个真事。之前维护一个订单超时关闭的服务代码里写着Scheduled(cron 0 0 0 * * ?)本意是每天零点去扫一遍超时订单。结果上线第二天一查日志凌晨的任务根本没执行倒是下午两点莫名跑了一次。排查了半天最后发现是表达式里的星期段和日期段起了冲突Spring压根没按我理解的“每天零点”去解析。那次之后我才意识到很多人包括当时的我对 Scheduled 里的 cron 表达式只是“能用”远没到“懂它”的程度。这篇文章就围绕 Scheduled 注解里的 cron 表达式把它讲透从最基本的六段结构开始到* ? - , / L W #这些符号各自的脾气再到工作日、月末、季度任务的写法最后把那些“看起来对但跑起来错”的坑全部摆出来。适合刚接触定时任务的新人也适合写了两三年 Spring 但没深究过表达式的同学看完至少能少踩一半的坑。顺带说一句网上搜 cron 表达式的时候经常会蹦出来“波兰表达式”“逆波兰表达式”这些词它们是计算器解析算术公式用的跟定时任务的 cron 表达式没有任何关系。别被这些搜索词带偏了。2. 六段结构拆开看秒、分、时、日、月、周的取值范围与冲突规则2.1 为什么Spring的cron是六段而不是五段接触过 Linux 的 crontab 的同学都知道Linux 的 cron 是五段制分 时 日 月 周没有秒。但 Spring 的 Scheduled 是六段制秒 分 时 日 月 周多了一个秒段。很多人第一次写的时候会照着 Linux 的习惯直接写0 0 2 * * *想着这是“每天凌晨两点”结果实际效果是“每秒执行一次”——因为第一位的 0 被当成了秒后面还有多余的位置被当成了周整个表达式的语义完全变了。这就是 Spring 多出秒段后最典型的误用。位置含义取值范围常用的通配符第1位秒0-59* , - /第2位分0-59* , - /第3位时0-23* , - /第4位日1-31* , - / ? L W第5位月1-12 或 JAN-DEC* , - /第6位周0-7 或 SUN-SAT其中0和7都表示周日* , - / ? L #注意两点。第一Spring 的 cron 表达式只支持六段不支持七段七段里带“年”的是 Quartz 的写法在 Scheduled 里直接写带年的表达式会启动报错。第二第6位的周0和7都代表周日这是个很反直觉的设计来自于 Unix 老传统里“7”表示周日Spring 为了兼容把两个都保留了。2.2 日和周之间的“互斥约定”为什么有一个必须是?六段里面日期段第4位和星期段第6位是一对冤家。它们只能二选一作为“触发日期”的依据不能同时指定。这就是为什么你看到的所有 Spring cron 表达式里要么第4位是?要么第6位是?绝不可能同时出现两个具体值。?的意思就是“不指定”。比如0 0 9 ? * MON-FRI意思是“周一至周五早上9点执行”这里日期段用了?表示日期不参与匹配只看星期。反过来0 0 9 1 * ?意思是“每月1号早上9点执行”星期段用?表示不管这天是星期几。为什么要这样设计因为如果日期和星期同时指定会产生大量逻辑冲突。比如0 0 9 15 * FRI本意可能是“每月15号且是周五”但一年里满足“15号”又恰好是“周五”的月份可能只有一两次大多数月份这个任务就静默不跑了。更麻烦的是如果15号不是周五到底是该在15号执行还是该在最近的周五执行不同框架会给出不同答案容易引发事故。Spring 干脆规定二者互斥谁能匹配到谁说了算另一个必须写成?。2.3*和?的区别以及它们各自的使用范围新手最容易混淆的两个符号就是*和?。*表示“任意值”也就是该字段的每一位都匹配。0 * * * * ?表示“每分钟的第0秒执行一次”注意不是“每秒执行”因为秒段固定为0了分段的*才是每分钟都过。?表示“不指定”是一个占位符只允许出现在日期段和星期段。它的存在意义就是让日、周互斥规则得以实现。在其他四个字段中出现?Spring 会直接抛异常启动就失败。有个常见的理解误区0 * * * * ?和0 */1 * * * ?是不是一回事严格说都表示“每分钟执行一次”第一个写法分段用*每分钟都匹配第二个写法用步长*/1也是每分钟都匹配。业界习惯上写*就够*/1有点多余但也不会有问题。真正要注意的是别把*和?混用在一个字段里比如*?这种写法在 Spring 里直接非法。3. 五个关键通配符的运算逻辑从基本用法到嵌套组合3.1 区间符-写时间段最直观的工具-用来表示一个连续范围。0 0 9-18 * * ?表示“每天9点到18点的整点执行”也就是9:00、10:00……18:00共10次。这里的“整点执行”要注意理解秒段是0分段的0表示整分时段的9-18表示范围内的每个小时都命中。更细一点0 30 9-18 * * ?表示“每天9:30到18:30执行”也就是每隔一小时在半点跑一次。这种写法在做工作时段内的任务非常常用比如每小时产出一份报表只需要业务时间执行半夜不跑。区间的两端都是闭区间9-18包含9也包含18。如果写成18-9就是非法表达式范围不能倒着写。想要跨越午夜的时间段就得拆成两个表达式写两个方法或者用两个 Scheduled 注解在一个方法上Spring 4.3 之后 Scheduled 支持重复标注。3.2 步长符/秒、分、时上的高频主角/配合*或具体的起始值来用格式是“起始值/步长”。0/15从0开始每15秒一个触发点即第0、15、30、45秒5/15从第5秒开始每15秒一个触发点即第5、20、35、50秒。这两种写法看着只差一个数字实际触发的相位完全不同。在分段上0 */2 * * * ?表示“每2分钟执行一次”触发点是每个偶数分钟的0秒。这里有个很多人会踩的点*/2是“从0开始的偶数分钟”也就是第0、2、4……58分钟第59分钟是不会执行的。如果你希望的是“以任意分钟为起点每2分钟执行”用*/2就足够满足大多数场景但如果任务是“从服务启动后每2分钟”那 cron 表达式做不到得用 fixedRate。时段的步长写法基本一样0 0 */3 * * ?表示“每3小时整点执行一次”触发点是0点、3点、6点……21点。看起来像是“每隔3小时”实际上它的对齐基准是0点而不是锚定任务第一次运行的时间。这是 cron 表达式和 fixedDelay、fixedRate 最本质的区别cron 只认钟表时间不认任务的启动时间。3.3 枚举符,拼出跨时段、跨星期的执行计划,用来列出多个离散值。0 15 10 ? * MON,WED,FRI表示“每周一、周三、周五的10:15执行”。这种写法比写三个 Scheduled 方法清爽得多也是工作日报表类任务的常见写法。再配合范围可以写出0 15 10 ? * MON-FRI,0 30 11 ? * SUN这种注意逗号分隔的是可以直接写在同一个表达式里的枚举项组合但 Spring 没有括号来嵌套分组所以复杂逻辑还是要靠拆方法。有一个小细节在月段1,4,7,10和JAN,APR,JUL,OCT是等价的。如果你代码里有跨部门协作建议统一用一种不然别人维护时得反复翻文档对照缩写。我个人偏好用数字因为不需要记英文缩写但有些人觉得 JAN 可读性更高。这个纯看团队习惯。3.4 特殊符L和W月底、最后一个工作日怎么表达L是 Last 的意思在日期段单独写L表示“本月最后一天”在星期段写6L表示“本月最后一个周六”。0 0 2 L * ?就能实现“每月最后一天凌晨2点执行”这个用于月底对账、月结结算非常实用。W是 Weekday 的意思它只能跟在日期段的具体数字后面。15W表示“最接近15号的工作日”。规则是如果15号是周一至周五就按15号执行如果15号是周六就提前到周五14号执行如果15号是周日就顺延到周一16号执行。这里还有一层隐藏细节如果顺延后跨到了下个月比如 31 号是周五31W 会落在31号但有些实现里如果日期拉得太近会产生边界歧义所以实际使用中建议避开月底和月初的交界日。需要注意的是W后面不能再接其他具体日期15W是一个整体不能写成15,16W这种格式。而LW可以连写表示“本月最后一个工作日”这在考勤结算、工资发放场景里特别常用。0 0 6 LW * ?就是“每月最后一个工作日早上6点跑工资单”。我在实际项目里用LW做代发工资定时任务跑了一年多只有一次碰到月底最后一天恰好是周五的情况——任务正常执行没有出现跨月覆盖问题。但如果你所在公司财务场景复杂我建议在代码里再加一道日期校验逻辑防止任务执行日与业务预期不一致。3.5 第n个星期几#只有星期段能用但很容易被忽略#用于指定“某月的第几个星期几”。6#2表示“每月第二个周六”5#1表示“每月第一个周五”。注意#前面的数字是“星期几”取值范围是1-71代表周一7代表周日这里和星期段直接用SUN-SAT的取值习惯不一样#前面只能用数字。这个符号主要用于“每月第几个周末”这类场景。比如“每月最后一个非工作日做一次全量备份”光靠L和W都不好表达用7L最后一个周日或者6#4第四个周六通常也就是倒数第二个周末附近都不太精确。0 0 22 ? * 6#1表示“每月第一个周六晚上10点跑一次大促预演任务”这种需求用其他符号确实很难实现。不过要提醒的是#这个符号在 Spring 的官方文档里是支持的但实际使用时有的版本对它的解析存在兼容性问题我在 Spring Boot 2.1 上遇到过6#1解析正常、7#5解析报错的情况——因为有些月份根本没有第五个周日。所以写#的时候最好限定一个合理范围别指望它处理不存在的周次。3.6 类型对比一个表格看清7个符号的适用字段符号含义可用字段注意事项*任意值全部6段在日期/星期段使用时配合??不指定仅日期、星期日和周必须有一个为?-区间全部6段不可倒序不可跨天跨月/步长全部6段基准点固定为起始值,枚举全部6段可混合数字和英文缩写L最后日期、星期日期段单独用星期段可加数字W工作日日期只能跟在具体日期后不可独立使用#第n个星期几仅星期前面的数字1-7表示周一至周日4. 高频实战表达式对照表从秒级任务到年终任务的写法直接抄4.1 工作日内跑批、每小时报表、整点半点等常见场景我直接把项目里验证过的一批表达式列出来按场景分好大部分是生产环境跑过的可以直接抄。需求表达式说明每秒执行*/1 * * * * ?秒段用步长注意这是每秒慎用每分钟执行0 * * * * ?每分钟第0秒触发最常见的写法每5分钟执行0 */5 * * * ?0、5、10……55分触发每小时执行0 0 * * * ?每小时的整点触发每半小时执行0 0/30 * * * ?0分和30分触发工作日每2小时跑一次0 0 */2 ? * MON-FRI日期段用 ?星期段限定工作日每分钟检查线程池队列0 0/1 * * * ?和每分钟写法的效果一样每天10:30执行0 30 10 * * ?最简单、最常用的日常任务每天0点执行0 0 0 * * ?适合日更数据统计每天9点到18点每半小时0 30 9-18 * * ?业务时段内跑批这里面有一个容易被忽略的业务细节工作日跑批通常要考虑节假日。MON-FRI只认周一至周五不认法定节假日。如果你的任务是“工作日跑的报表”用MON-FRI只能覆盖常规工作周春节、国庆这种法定节假日它依然会跑。真正要处理节假日需要在方法体里查节假日表命中就 return表达式只是第一道闸。4.2 每月1日、每季度第一个周一、每半年这类低频任务需求表达式说明每月1日0点执行0 0 0 1 * ?月度任务首选注意日期段第1位是1每月1日和15日执行0 0 0 1,15 * ?枚举日期适合月中结算每月最后一天23:590 59 23 L * ?利用 L 取月末用于月结每季度第一个月的1号执行0 0 6 1 1,4,7,10 ?月段枚举1、4、7、10每年1月1日执行0 0 0 1 1 ?年度任务如证书过期检查每周一8:300 30 8 ? * MON周任务模板每月第二个周日0 0 9 ? * SUN#2利用 # 实现这里特别说下季度任务。很多人会在代码里写0 0 6 1 * ?以为它“每个月1号跑一次而我只想让它在季度初跑”然后靠方法体里的currentMonth % 3 1再做一层过滤。这种写法不算错但更干净的做法是直接控制月段枚举0 0 6 1 1,4,7,10 ?一步到位。定时表达式能表达的范围尽量别往业务代码里塞额外判断分布式环境下多个实例同时执行时方法体的后半段判断反而容易成为不一致的来源。4.3 秒级和分钟级任务真的适合用cron吗表格里有两条“每秒/每分钟”的示例但我得说实话cron 表达式并不适合高频率任务。Spring 的 Scheduled 虽然支持秒级 cron但它的调度线程池默认只有一个线程如果任务本身执行超过1秒下一次触发就会等到当前任务跑完才继续。也就是说你写*/1 * * * * ?想让任务每秒执行一次任务体一旦跑超过1秒实际频率立刻掉到“按执行时长排队”完全不是你预期的每秒触发。如果确实需要秒级、毫秒级或者固定周期的任务优先考虑Scheduled(fixedRate 1000)或Scheduled(fixedDelay 1000)。fixedRate 是“无视任务时长从上一次开始时间计算下一次”fixedDelay 是“从上一次结束时间计算下一次”。cron 表达式适合的是“必须对齐钟表时间”的场景比如每天0点、每周一9点、每月1号如果是“每3秒拉一次消息”“每次执行完休息5秒”用 fixed 系列更合适。5. 排错实录六段结构里最容易被忽略的四个错误模式5.1 秒段没写0任务以“每秒”频率疯狂空转先说最经典的错误。有人写Scheduled(cron 30 * * * * ?)本意是“每30秒执行一次”但实际上这行代码的含义是“每秒执行一次且只在第30秒那个时刻命中”。换句话说它每分钟只执行一次执行时间点是每分钟的第30秒。想表达“每30秒一次”应该写0/30 * * * * ?。这个错误的核心是混淆了“秒段的值”和“秒段的步长”。30是固定值表示“秒数等于30的那一秒才触发”0/30是步长表示“从0秒开始每隔30秒触发一次”。很多定时任务刚开始不明显一旦某个接口的业务处理比较耗时每秒的误触发会导致不可预期的并发压力。我记得有个老项目里就出现过这种问题任务里面调了一个外部接口表达式的秒段写成了5结果每分钟只在第5秒调一次完全没达到“每5秒轮询一次”的预期外部接口倒是没被打爆但业务方的数据始终不刷新排查了一下午才发现是秒段的语义理解错了。5.2 日期段和星期段同时给值Spring直接启动失败前面提过?的互斥规则这里再展开讲一个真实日志。如果写了Scheduled(cron 0 0 8 15 * ?)这是合法的但如果写0 0 8 15 * MONSpring 容器启动时会直接抛CronExpression解析异常错误信息大致是“Support for specifying both a day-of-week AND a day-of-month parameter is not implemented”。所以这个坑不需要等到运行期启动时就会暴露。问题是很多人项目依赖特别大启动一次要好几分钟日志被刷过去根本注意不到。我的建议是写完表达式先在本地跑一个最简单的 SpringBootTest 启动类专门用来验证定时任务能否正常装配别等集成环境才暴露。5.3 每月31号的陷阱任务在某些月份静默消失0 0 8 31 * ?表示“每月31号早上8点执行”。这个表达式在 Spring 的解析阶段完全合法但它实际只在有31号的月份触发2月、4月、6月、9月、11月它会静默跳过不报错不写日志就像不存在一样。更隐蔽的情况是0 0 8 29,30,31 * ?本意可能是“月底最后几天都跑一次”但遇到2月平年只有28天就一个都不跑。所以涉及“月末”需求时如果逻辑是“每个月最后一天”老老实实用L如果逻辑是“每个月最后几天”建议用多个表达式拆开或者干脆在每个月的1号先算好这个月有几天再动态注册任务。动态注册用的是ThreadPoolTaskScheduler的schedule方法可以接受一个Trigger但这就是另一个话题了这里不展开。5.4 时区问题服务器时区改一下所有任务全部漂移Spring 的 Scheduled 还有一个可配置项zone默认取服务器本地时区。如果服务器设了 UTC你写0 0 0 * * ?想的是北京时间0点实际是北京时间8点执行。这个问题在容器化部署、多区域部署时特别考验人。我们的一个服务当时部署在多个区域的 Kubernetes 集群上节点时区没统一结果同一个任务在不同环境的执行时间差了8个小时。排查到最后发现不是代码问题是容器的TZ环境变量没有设置。解决方式有两种一是规范基础镜像统一把时区设成Asia/Shanghai二是在注解上显式写Scheduled(cron 0 0 0 * * ?, zone Asia/Shanghai)。我个人建议用第二种把时区钉死在注解上不管容器环境怎么变业务行为不变。错误写法实际效果正确写法30 * * * * ?每分钟第30秒执行一次0/30 * * * * ?0 0 8 15 * MON启动即报错0 0 8 15 * ?或0 0 8 ? * MON0 0 8 31 * ?仅31号月份执行0 0 8 L * ?0 0 0 * * *每秒执行0 0 0 * * ?6. 表达式之外的三个关键配置调度线程池、异常处理与可观测性6.1 默认单线程调度的坑一个任务卡住后面全部阻塞Spring 的 Scheduled 默认用的是ThreadPoolTaskScheduler但如果没有显式配置线程池大小默认核心线程数是1。这意味着你项目里写了十个 Scheduled 任务它们默认是在同一个线程里排队执行的。只要其中一个任务因为网络 IO 阻塞、数据库慢查询、或者死等外部接口而长时间不返回其他所有定时任务全部停摆。我在一个支付对账项目里就遇到过一个凌晨的对账任务因为某个通道响应超时卡了20分钟导致同一个线程上的所有其他定时任务全部延迟其中包括一个“每5分钟检查一次退款单状态”的核心任务。后来给调度器配置了合适的线程池才彻底解决了这种“连带阻塞”。配置线程池的方式很简单实现SchedulingConfigurer接口Configuration public class ScheduleConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10)); } }线程池大小怎么定我的经验是任务总数除以 2再加 1多数项目 5 到 10 个线程足够。太多线程也不好毕竟定时任务多数是低频 IO 操作不是高并发场景线程数太多反而浪费。6.2 异常处理定时任务里抛异常默认是静默的Scheduled 标注的方法如果抛出异常Spring 默认不会对外暴露任何信息只会打一条 error 日志而且异常会影响当前这一次执行但不会影响下一次调度。也就是说你的任务凌晨3点抛了个空指针第二天日志里可能有一行 error但任务第三天照常跑。这带来两个问题。第一异常日志默认级别是 error如果没有统一的日志采集系统很容易被淹没第二任务频繁抛异常时调度线程虽然不会停但异常对象和堆栈会不断占用内存间接影响线程健康。我的建议是方法体里做两层处理业务代码用 try-catch 包住关键异常手动记录业务日志同时用Async把执行移到业务线程池并配合一个自定义的UncaughtExceptionHandler。不过对多数项目来说最实用的一步是定时任务方法里千万不要抛异常让框架去捕获一定要自己兜底。因为一旦异常抛出方法就中断了后面可能还有补偿逻辑没执行完。6.3 可观测性连表达式带执行结果的监控建议最后说一点工程化的建议。生产环境里定时任务最容易出现“看起来配置了但不知道跑没跑、跑了几次、用了多久”的问题。尤其是服务有多个实例时Scheduled 默认是每个实例都执行一次如果业务上没有做分布式锁同一个任务在多实例环境下会重复执行。对于这类问题建议在任务方法里埋点开始时间、结束时间、执行结果、耗时全部输出到日志或者监控系统。表达式那一栏也可以加进日志的打印上下文里这样排查问题时能直接看到“这个任务的预期频率是每分钟为什么今天只跑了三次”。没有监控的定时任务就像没有仪表盘的飞机——能飞但什么时候出问题完全不知道。7. 最后分享一点个人习惯表达式上线前先过一道在线校验关于 cron 表达式的细节上面基本覆盖了。最后说一个我自己的操作习惯每写一个 cron 表达式都先放到在线 cron 校验工具或者本地写个测试用例跑一遍用接下来几个月的具体日期去验证触发点。特别是涉及L、W、#这几个特殊符号时肉眼很难判断比如0 0 9 LW * ?到底会不会在2月28号触发、会不会在1月31号触发这些边界问题只有在验证工具里把未来几个月的时间点拉出来才能确认。Spring Boot 也提供了CronExpression.parse()这个方法在测试类里可以直接把表达式解析出来然后循环校验一堆期望的时间点比手工推算靠谱得多。至于表达式在业务上的扩展——比如“每个交易日收盘后跑一次”“每个版本的发布日执行一次”——cron 表达式的表达能力是有限的该配合任务表、开关配置和分布式锁的时候就得上这些组件。表达式只是定时任务的第一道门槛跨过这道门槛之后还有很多比表达式本身更需要小心的地方。
返回列表