有次接手一个别人留下来的定时任务配置写着*/1 * * * *同事拍着胸脯说“每分钟跑一次没问题”结果跑了不到一天系统被重复任务压垮了。问题不是出在执行频率上而是他把 cron 理解成了“从任务启动那一刻开始每隔固定时间执行一次”。真实情况是cron 永远按固定时间刻度对齐触发跟任务什么时候启动基本没关系。很多人在“每 N 秒、每 N 分、每 N 小时执行一次”这个需求上栽跟头根子就在这里。这篇文章就把 cron 表达式这个老伙计彻底讲透重点拆“每隔 N 秒/分/小时”这类周期任务怎么写、为什么这么写哪些场景 5 位格式够用哪些必须上 6 位格式以及不同平台Linux crontab、Quartz、Spring Boot之间的写法差异和暗坑。后端开发、运维、数据仓库调度的朋友应该都用得上看完之后至少不会再写出让任务重叠爆炸的“每分钟执行”配置。1. 先把 cron 表达式的底子摸透5 位格式和 6 位格式的差别1.1 标准 5 位格式里每个位置放什么标准的 Linux crontab 用的是 5 位格式各位置依次是“分钟 小时 日期 月份 星期”。很多新手第一次看到就懵尤其容易把“月份”和“星期”搞混或者把顺序记成“小时 分钟”。记住一句话cron 的最小时间刻度是分钟所以第一位一定是分钟。具体范围如下字段取值范围说明分钟0-59第几分钟触发小时0-23第几小时触发日期1-31月的第几天月份1-121 月到 12 月星期0-70 和 7 都表示周日1-6 表示周一到周六举个例子30 9 * * *表示每天 9:30 执行一次。我见过太多人把顺序写成9 30 * * *那意思就变成了“每小时的第 9 分并且第 30 小时触发”——第 30 小时根本不存在整个任务直接废掉。所以拿到一个表达式第一件事就是按“分、时、日、月、周”的顺序拆开读一遍。还有个细节5 位格式里日期和星期是可以同时写的此时两者是“或”的关系。比如0 0 1 * 1的意思是“每月 1 号执行”加上“每周一执行”两个条件满足任意一个就触发。这跟后面要讲的 Quartz / Spring 规则完全不同跨平台迁移的时候非常容易踩雷。1.2 6 位格式多出来的“秒”到底是什么5 位格式最小粒度是分钟所以如果你想“每 10 秒执行一次”标准 crontab 压根做不到。而 Quartz、Spring Boot 这类框架支持 6 位甚至 7 位格式多出来的就是秒字段排在最前面。顺序变成“秒 分 时 日 月 周”。比如*/10 * * * * *是每 10 秒执行一次0 0 9 * * MON-FRI是工作日上午 9 点整执行一次。这里有个非常容易踩的坑Spring 定时任务默认要求 6 位格式你把 Linux 的 5 位格式30 9 * * *直接丢给Scheduled(cron ...)启动直接报错提示 cron 表达式必须至少 6 个字段。原因就是 5 位缺了秒字段。反过来有些同学把 6 位格式拿回 Linux crontab 里写也会因为字段太多导致语法错误任务直接被拒绝。所以第一步先问清楚你的调度器是 Linux crontab还是 Quartz还是 Spring还是 K8s CronJob不同平台对字段数量的要求不一样这是所有后续讨论的大前提。1.3 特殊字符里最容易被绕晕的?、L、W、#cron 的五个基本符号是*、,、-、/、?其中*表示任意值1,2,3表示列举1-5表示范围*/5表示每隔 5 个刻度。这些都好理解真正的分水岭是?、L、W、#。?只用在日期和星期两个字段它的含义是“不指定”。因为日期和星期很容易冲突比如你想“每月 1 号且是周一”的时候触发直接都填值会把条件搞复杂这时候就让其中一个字段为?明确告诉调度器“我不关心这个字段”。Quartz 和 Spring 要求日期、星期必须有一个是?而 Linux crontab 根本不认识?不要混用。L表示“最后一个”只能用在日期和星期字段。日期字段的L表示当月最后一天比如0 0 0 L * ?就是每月最后一天的零点执行。星期字段的L表示星期六6L表示最后一个星期五SATL也表示最后一个星期六。W是“最近的工作日”只能用在日期字段。15W表示“离 15 号最近的那个工作日”比如 15 号正好是周日就变成周一执行。把L和W拼在一起成LW就是“本月最后一个工作日”这个组合在月底跑账务、月底清理日志的场景非常好用。#只能用在星期字段表示“第几个星期几”。6#3表示“当月第三个星期五”。注意这些扩展符号只存在于 Quartz 体系的 cron 实现中Linux crontab 全都不支持Spring Boot 自带的CronExpression也不支持L、W、#只有挂了 Quartz 依赖才能用。1.4 一个时间单位错位导致的经典乌龙案例有个朋友在 Quartz 里配了个0 0 12 * * 1想的是“每周一中午 12 点执行”。但 Quartz 里星期字段的数字规则和 Linux 不一样Quartz 中 1 表示周日2 表示周一。结果他写出来的实际是“每周日中午 12 点”执行整整差了一天。这个平台间星期数字规则差异比字段数量错位还要隐蔽往往要等到线上任务跑错时间才暴露。所以后面看到任何带星期字段的表达式第一时间确认平台Linux crontab 中 0 和 7 是周日Quartz 中 1 是周日Spring 中 1 是周一。三个平台三种规则没有统一标准这是 cron 最不“跨平台”的一面。2. “每 N 秒/分/小时”的三种标准写法与背后逻辑2.1 每 N 分钟步长符号 / 和边界取整逻辑先来最简单的“每 N 分钟”。5 位格式第一位是分钟字段所以*/5 * * * *表示每 5 分钟执行一次时间点落在 0、5、10、15 ... 55 分。同样*/30 * * * *是每 30 分钟执行一次落在 0 分和 30 分。这里就要说到本文开头那个坑了*/5是从 0 分开始以 5 为步长跨过整个小时它在每个小时都重新从 0 对齐。比如 9:00 的任务在 9:00、9:05、9:10 触发10:00 又会重新从 10:00 开始。如果你希望“从任务启动后每 5 分钟执行一次”比如启动时间是 9:07那下一次应该在 9:12但 cron 不会这么算它只认固定的 0、5、10、15 刻度。很多人踩的第二个坑是写成1-59/5 * * * *这看起来是从 1 分开始每隔 5 分钟实际也成立1、6、11...56但语义上不够直观。更常见的错误是写*/1 * * * *其实等价于每分钟执行一次直接写* * * * *更简洁清晰没必要多加一个/1。还有个小技巧如果想“每 2 分钟执行一次”可以直接用*/2 * * * *触发点在 0、2、4...58。但是注意这种步长的周期必须能被 60 整除才能在一个小时内均匀分布。像*/7这种0、7、14...56最后一个周期从 56 到下一个 0 只有 4 分钟实际会造成不均匀的间隔。2.2 每 N 小时为什么0 */N * * *才正确“每 N 小时”的需求看起来没啥难度但我在 review 别人的 crontab 时见过不少写成* */2 * * *的。这个表达式的真实含义是“每小时的任意分钟并且小时是 0、2、4...22 时”也就是说每个偶数小时你会有 60 次触发这显然不是你要的效果。正确的写法是0 */2 * * *分钟位固定为 0小时位用步长*/2含义是“在 0 分这个时间点当小时是 0、2、4...22 时执行”。注意小时步长同样从 0 点开始对齐不是从任务首次启动时间开始。比如你中午 12 点部署完任务期望“每 2 小时一次”那下次触发应该是 14:00如果你写的是间隔型调度器可能就是这样但 cron 会老老实实等到 14:00 吗不会它还是按 0、2、4...22 的刻度来12 点之后的下一次就是 14:00这次碰巧是一致的。但如果部署时间是 13 点期望下次 15:00cron 却会在 14:00 触发提前了整整一小时。小时字段的步长还有个限制*/N的 N 必须是 24 的约数1、2、3、4、6、8、12、24才能在一天内均匀分布。写成*/50、5、10、15、20最后一段从 20 点直接跨到第二天 0 点间隔变成 4 小时不是标准的 5 小时这在严格的周期任务里是不能接受的。2.3 每 N 秒只有 6 位格式能搞定5 位环境的变通方案最折磨人的其实是“每 N 秒”。标准 Linux crontab 没有秒字段你写* * * * * *会被直接拒绝。而 Quartz 和 Spring 支持 6 位格式是“秒 分 时 日 月 周”所以*/10 * * * * *表示每 10 秒执行一次时间点落在每个分钟的 0、10、20、30、40、50 秒。注意秒字段的步长同样与分钟起点对齐。比如 9:00:00 启动之后 9:00:10、9:00:20... 触发到 9:00:50 之后下一次是 9:01:00。这就产生了一个很容易被忽略的问题如果任务的执行耗时接近 10 秒就可能与下一次触发叠加。比如任务跑了 9.5 秒下次触发时上一次还没结束两个实例并发执行数据库锁、文件写入冲突就全来了。所以在秒级任务里我通常建议在应用层加锁或者把执行逻辑放进消息队列让消费者自己控制消费频率。Linux crontab 想实现秒级任务的变通方案有三个。第一个是脚本内部做时间戳判断* * * * * /usr/bin/python3 /opt/check_and_run.pyPython 脚本里判断当前秒数是否为 10 的倍数import time now int(time.time()) if now % 10 0: # 每 10 秒的整数倍时刻执行实际逻辑 do_job()这种方案只能做到“每分钟内约 6 次触发”因为每分钟的第 0 秒肯定整除 10但如果任务在上一个 10 秒的倍数时刻被启动且耗时超过 10 秒就可能漏掉新一轮触发精度很一般只适用于对时间不敏感的对账、探测类任务。第二个方案是在 crontab 里写多个延迟启动的条目* * * * * sleep 0 /opt/task.sh * * * * * sleep 10 /opt/task.sh * * * * * sleep 20 /opt/task.sh * * * * * sleep 30 /opt/task.sh * * * * * sleep 40 /opt/task.sh * * * * * sleep 50 /opt/task.sh这相当于把一个 60 秒的周期切成了 6 段调度器每分钟触发 6 次间隔 10 秒。缺点是 crontab 会同时拉起多个 sleep 进程任务脚本一定要做好互斥别让上一条没跑完下一条又起来了。第三个方案稍微正规一点是改用 systemd timer 或者用 Python APScheduler、Java ScheduledExecutorService 这类程序内调度框架直接声明interval10 seconds由框架保证从启动时刻开始精确计数。这也是我最推荐的做法秒级周期任务就不要跟 crontab 死磕了交给更合适的工具。2.4 周期不是 60 的约数怎么办每 90 分钟、每 45 秒这类需求严格说cron 表达式的周期任务只能处理“与单位边界对齐”的情况。像“每 90 分钟执行一次”这种需求90 分钟既不能被 60 整除也不是标准小时刻度用 5 位 cron 很难优雅表达。90 分钟等于 1 小时 30 分钟。如果你希望从 0 点开始间隔 90 分钟那么触发时刻是 0:00、1:30、3:00、4:30、6:00...这不是一个字段步长能描述的。实际操作中我见过两种做法一种是把时间点拆成两个 cron 表达式挂到不同条目里0 0,3,6,9,12,15,18,21 * * * /opt/task.sh 30 1,4,7,10,13,16,19,22 * * * /opt/task.sh这样看似解决了 90 分钟周期但表达式冗长而且调整间隔时要改一大串数字极其容易出错。另一种是换掉 cron改用间隔触发器。Quartz 的CalendarIntervalTrigger可以直接设置withIntervalInMinutes(90)APScheduler 的IntervalTrigger也行程序内调度器会严格按“首次执行时间 固定间隔”计算不受分钟、小时的刻度对齐限制。如果你只是想要一个普通的周期任务这远比在 cron 上硬凑要靠谱。同理“每 45 秒执行一次”用*/45 * * * * *也不行。这个表达式的触发点是 0 秒和 45 秒间隔是 45 秒和 15 秒交替换句话说周期是 60 秒而不是 45 秒。真正严格的 45 秒间隔必须用程序内定时器cron 天生做不到。能进分钟范围的需求比如 5 的倍数才适合用 cron秒级不规则的周期任务直接放弃 crontab。3. 不同平台下的写法差异与一份可直接抄的常用表达式速查表3.1 Linux crontab只有 5 位想执行秒级任务的三种土办法Linux 的 crontab 是全系统最通用的定时工具但它的表达能力确实有限。它只有 5 位格式不支持秒不支持?、L、W、#而且日字段和星期字段之间是“或”的关系。在这个环境里写周期任务关键就是记住一个原则把希望触发的时间点落在分钟位上。比如“每 10 分钟执行一次”是*/10 * * * *等价于分钟为 0、10、20、30、40、50“每 3 小时执行一次”是0 */3 * * *在 0 点、3 点、6 点...触发“每天中午 12 点执行”是0 12 * * *。由于没有秒字段Linux 下要跑秒级任务基本只有前面提过的三种“土办法”脚本内时间戳判断、多条延迟 crontab 配合 sleep、或者干脆换 systemd timer。土办法应付简单验证场景没问题生产环境还是建议把定时逻辑挪到应用层可观测性和容错性都好得多。另外crontab 里%字符有特殊含义会被解析成换行符。如果你在命令里用到日期格式化比如date %Y%m%d必须写成date \%Y\%m\%d否则会被截断命令直接静默失败。这类问题日志都不打排查起来特别费劲。3.2 Spring Boot 与 Quartz6 位 cron 的正确吃法Spring 和 Quartz 是 Java 生态里最常遇到的 cron 使用者它们都支持 6 位格式但两者细节有差异。Spring Boot 的Scheduled(cron ...)用的是 Spring 内置的 cron 解析器要求至少 6 位字段秒 分 时 日 月 周第 7 位年份可有可无。它在日期和星期字段之间也要求必须有一个是?否则启动会报错。而且 Spring 的星期字段数字规则与 Quartz 不同在 Spring 中 1 表示周一7 表示周日在 Quartz 中 1 表示周日7 表示周六。最简单的规避办法是直接用英文缩写MON、TUE、WED在两边都一致能避免数字规则带来的混淆。一个经常被忽略的点是Spring Boot 自带的CronExpression不支持L、W、#这些特殊符号。你要写“每月最后一个工作日执行”用 Spring 原生注解是写不出来的只能自己写代码判断或者引入 Quartz 的调度器。网上很多教程把 Quartz 的 cron 语法直接套到 Spring 注解上结果一启动就报“Illegal cron expression”的错就是这个原因。Quartz 的 cron 表达式则要宽松得多支持L、W、#、?适合做复杂日历调度。比如CronScheduleBuilder.cronSchedule(0 30 23 L * ?)这是“每月最后一天 23:30 执行”。如果需求再变态一点比如“每年 5 月的最后一个工作日”也可以用L和W组合出来。Quartz 的精度能到毫秒级触发但实际执行时间还要受线程池调度影响不要对秒级触发精度抱有太高期待。3.3 常用 cron 表达式速查表直接抄作业这里整理了一份我在实际项目中验证过的常用表达式按平台分两张表。Linux crontab 5 位格式表达式含义* * * * *每分钟执行一次*/5 * * * *每 5 分钟执行一次*/30 * * * *每 30 分钟执行一次0 分和 30 分0 * * * *每小时整点执行一次0 */2 * * *每 2 小时执行一次0、2、4...点0 9 * * *每天 9:00 执行30 9 * * *每天 9:30 执行0 18 * * 1-5周一至周五 18:00 执行0 0 * * 0每周日 0 点执行0 0 1 * *每月 1 日 0 点执行0 3 1 1 *每年 1 月 1 日 3 点执行Quartz / Spring 6 位格式表达式含义*/10 * * * * *每 10 秒执行一次0 */5 * * * *每 5 分钟执行一次0 0 * * * *每小时整点执行一次0 0 */2 * * *每 2 小时执行一次0 30 9 * * *每天 9:30 执行0 0 18 * * SAT每周六 18:00 执行两边都认英文缩写0 0 0 1 * ?每月 1 日 0 点执行0 30 23 L * ?每月最后一天 23:30 执行Quartz0 0 8 LW * ?每月最后一个工作日 8:00 执行Quartz0 15 10 ? * 6#1每月第一个周五 10:15 执行Quartz这里特别提醒一下表里凡是带L、W、#的只有 Quartz 能用Spring 原生注解不支持。谁要是直接抄到Scheduled里等着报错就行。3.4 一个完整需求推演从一句话需求到最终表达式拿一个真实任务来演示整个推演过程。需求是“每月最后一个工作日的 21:30 执行一次报表任务遇到法定节假日顺延”。如果只考虑“工作日”这个维度不用管法定节假日表达式可以分两步拆。第一步是确定频率单位。这是月级别的任务日期用L星期用W所以日期字段可以写成LW表示最后一个工作日。第二步是确定时间点21:30 在 6 位格式中写第 2、3 位即30 21。月份字段不需要限制用*。星期字段因为日期字段用了LW按规则要设为?。最终表达式是0 30 21 LW * ?放在 Quartz 调度器里就可以直接用。但如果你用的是 Linux crontab这个需求就没法一条命令搞定了。LW表达不了只能退而求其次写成30 21 * * 1-5意思是“周一至周五每天 21:30 都跑”然后在任务脚本里判断“今天是不是本月的最后一个工作日”不是就提前退出。这种方案虽然糙但在 crontab 限制下反而是最稳的判断逻辑只需要几行# 判断今天是否为当月最后一个工作日粗略版 # 明天不是同一个月或者明天是周六/周日则认为今天是最后工作日 if [ $(date -d tomorrow %m) ! $(date %m) ]; then echo run fi这就是从需求到表达式的落地过程不同平台有不同的表达能力先确认平台能力边界再设计表达式最后补一层脚本兜底。4. 常见问题与排查技巧实录4.1 五个高频踩坑现场先看一眼你的配置有没有中招第一坑是“秒字段误当分钟字段”。5 位格式的任务被直接搬进 6 位格式里比如把30 9 * * *写进 Spring结果启动直接报错反过来在 Linux crontab 里写 6 位格式也会因字段太多被拒绝。解决办法就是先确认平台支持几位。第二坑是“日字段和星期字段同时设值”。Linux crontab 里0 0 1 * 1是“每月 1 号”和“每周一”的并集但很多人的本意是“每月 1 号且必须是周一”这一差就是几十倍的触发次数。Quartz 和 Spring 更干脆直接要求两个字段必须有一个是?否则报错。第三坑是“时区不一致”。同一台服务器上 crontab 默认用系统时区但 Java 应用用 JVM 默认时区容器里的应用又可能被设置成 UTC。你配的是“每天 9 点执行”系统时区是 UTC8JVM 时区却是 UTC任务实际触发是在北京时间 17 点。排查这种问题一定要先统一时区Spring 的Scheduled可以用zone属性显式指定。第四坑是“PATH 环境变量缺失”。定时任务执行时PATH 往往只有/usr/bin:/bin你脚本里写的python、docker、kubectl命令可能都不在 PATH 里导致任务执行到一半直接command not found。解决办法是脚本开头显式声明 PATH或者用绝对路径调用命令。第五坑是“任务重叠执行”。一个任务跑了 5 分钟cron 周期也是 5 分钟下一次触发时上一次还没结束两个实例同时操作同一批数据。日志里会看到执行时间波动数据却出了错。解决思路是加分布式锁、用flock命令做文件锁或者把任务的并发度控制在 1。4.2 用 croniter 手算下一次执行时间排查 cron 问题最实用的技巧就是手动推算表达式下一次执行的时间点。手推太累尤其是带L、#的复杂表达式我一般直接用 croniter 这个 Python 库。安装很简单pip install croniter写几行脚本就能算出下一次执行时间from croniter import croniter from datetime import datetime base datetime.now() it croniter(0 30 21 LW * ?, base) print(下一次执行, it.get_next(datetime)) print(再下一次, it.get_next(datetime)) print(未来 5 次, croniter(0 30 21 LW * ?, base).get_next(datetime, 5))执行结果会精确列出未来触发时间点一眼就能看出表达式是否符合预期不用上线试跑也不用盯着日志等半天。更关键的是croniter 还能反推过去若干次触发时间用来对账“这个任务上次到底该不该跑”。比如it croniter(*/30 * * * *, datetime.now()) print(上一次执行, it.get_prev(datetime))这类工具非常适合在写调度脚本前做一次性校验把表达式放到代码里让测试覆盖比人工用眼睛盯靠谱得多。4.3 个人实操心得先把执行时间点列出来再写表达式踩过太多 cron 的坑之后我现在拿到一个定时任务需求不会直接写表达式而是先把预期触发的具体时间点列出来。比如“每 30 分钟执行一次”先确认是从 0 点对齐的 0:00、0:30、1:00...还是从首次启动时刻对齐的 9:07、9:37、10:07...。这两者期望不同方案就完全不同。如果期望的是“从启动时刻开始严格间隔执行”就别用 cron直接改用程序内的间隔调度器比如 Python APScheduler 的IntervalTrigger、Java 的ScheduledExecutorService、Node.js 的setInterval它们天然支持任意间隔不跟你玩“刻度对齐”这套。如果必须用 cron那就要接受它的对齐规则并且把对齐规则写进文档。团队里每个人对 cron 的理解都有偏差一个*放错位置线上就是一次定时任务风暴。把时间点列清楚评审的时候一眼就能卡住低级错误。另外有一个维保经验周期任务的配置最好走代码仓库不要手动在服务器上改 crontab。配置留在 Git 里每次改动都有记录方便回滚和审计。服务器上的 crontab 改着方便但下次重装系统、迁移机器的时候这些配置很容易丢而且谁改了什么也无据可查。用 Ansible、盐堆这类工具统一管理 crontab 文件配好提醒机制会比人肉记忆稳得多。最后再分享一个小技巧每次上线新定时任务先设一个非常短的任务历史保留期观察一到两天确认触发时间点和执行时长都符合预期后再放宽日志保留策略。定时任务这东西上线前验证一百遍不如上线后看真实执行结果一遍管用。