ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入理解 cron:表达式、定时任务与底层原理

深入理解 cron:表达式、定时任务与底层原理 摘要cron 把何时执行压缩成一行五字段的文本而真正让它落地的是三类各司其职的调度器系统守护进程按分钟心跳扫描应用框架在进程内维护最近到期的时间队列分布式平台用行锁和分片把一次触发扩展到整个集群。1. cron 是什么cron 诞生于 1970 年代末的贝尔实验室由 Unix 创始人之一 Ken Thompson 设计随 Version 7 Unix1979对外发布名字取自希腊语 chronos时间。今天各 Linux 发行版自带的实现大多源自 Paul Vixie 在 1987 年重写的 Vixie cron 及其维护分支 cronie语法上则保持了近五十年不变一行表达式声明一个周期规律。讨论定时任务时把三件事分开会让问题清晰得多表达式——声明何时的纯文本协议本身不执行任何东西调度引擎——解析表达式、维护时间、在触发时刻唤醒任务的程序如 Linux 的 crond、Java 的 Scheduler任务本体——被触发的命令或函数。表达式是一种被反复复用的时间语言。所以写一条 cron在不同语境里含义完全不同对运维是编辑 crontab对 Java 开发者是填Scheduled注解参数对 Kubernetes 用户是写 CronJob 的 spec 字段。三个语境本文都会覆盖。2. cron 表达式五个字段与八种符号2.1 五个字段五个并列的过滤器标准表达式由五个空格分隔的字段构成从左到右依次约束分钟、小时、日期、月份、星期[2]。一条表达式描述的不是一个孤立时刻而是一个可能无限延伸的匹配集合任何同时满足五个字段约束的分钟都会触发一次任务。五个字段的取值范围和含义如下字段取值范围说明分钟0-59每小时的第几分钟小时0-23每天的第几小时日期1-31每月的第几天月份1-12每年的第几个月星期0-7每周的第几天0 和 7 都表示周日2.2 特殊字符四个通用四个扩展五个字段支持四种通用操作符覆盖绝大多数场景。字符含义示例触发时机*该字段所有合法取值* * * * *每分钟,列举多个取值0 8,12,18 * * *每天 08:00、12:00、18:00-连续区间0 9-18 * * 1-5工作日 9 点至 18 点的每个整点/区间内按步长取值*/15 * * * *每小时的 0、15、30、45 分四种通用特殊字符。Linux 的标准 crontab 只支持这四种。步长的语义值得拆开看。*/15是0-59/15的缩写起点固定为 0写10/15则从 10 开始得到 10、25、40、55。这意味着每 15 分钟一次永远包含第 0 分钟——想避开整点的场景必须显式改起点比如5/15表示 5、20、35、50 分。Quartz 系框架Quartz、Spring、ElasticJob 的部分语法在通用字符之外扩展了四种用于表达月末最近工作日这类天然带日历语义的时刻[4]。字符含义示例触发时机?不指定限日期与星期二选一0 0 12 ? * 1每周日 12:00L最后一天 / 最后一个星期几0 0 0 L * ?、0 0 17 ? * 6L每月最后一天零点每月最后一个周五 17:00W距离指定日最近的工作日0 0 0 15W * ?每月距 15 号最近的工作日零点#本月第 N 个星期几0 0 0 ? * 6#3每月第三个周五零点Quartz 扩展字符。注意 Quartz 的星期编号是 1–7、从周日开始与 Linux 的 0–7 并不相同示例均为 Quartz 标准六字段写法需满足 2.4 节的字段数约束。2.3 常用表达式速查表达式含义*/5 * * * *每 5 分钟0 * * * *每小时整点30 2 * * *每天 02:300 0 * * *每天零点0 9 * * 1-5工作日 09:000 12 * * 0每周日 12:000 为周日0 0 1 * *每月 1 号零点0 4 8-14 * *每月 8 号至 14 号的每天 04:00五字段常用表达式。以上均为 Linux 标准写法Quartz 六字段版本需在最前面补秒位。2.4 字段数并不统一Linux cron 停在五字段Quartz 在最前面加了秒、在最后加了可选的年份共六到七字段Spring 的Scheduled采用 Quartz 风格的六字段秒开头、无年份[3]Go 的 robfig/cron 默认五字段、可显式开启秒位。同一个字符串在不同系统里可能指不同的时刻。易错点0 12 * * *在 Linux crontab 里是每天 12:00放进 Spring 注解里却成了每小时的第 12 分 0 秒。跨系统复制表达式时先核对字段数与星期编码再谈语义。2.5 四个经典陷阱表达式语法本身十分钟就能掌握翻车几乎都发生在以下几个语义细节上。日期与星期是或0 0 1 * 1不是每月 1 号且是周一而是1 号或周一都触发。Vixie cron 的规则是日期与星期两个字段都被显式限定时满足任意一个即匹配只有其中一个是*时另一个才单独生效[2]。要表达且的语义标准做法是拆成两条任务Quartz 则用?显式忽略一侧。不存在的日期永不触发0 0 31 2 *在等待一个永远不会到来的 2 月 31 日。同理0 0 30 2 *、0 0 31 4,6,9,11 *都是哑表达式不会报错只是永远沉默。月末类需求优先用 Quartz 的L或在脚本里自行判断。夏令时制造幽灵时刻在启用夏令时的时区春季换时某天的 02:30 可能不存在秋季换时某天的 01:30 会出现两次。各种实现对这两个区间的处理策略并不一致任务可能被跳过或延迟。务实做法是让关键任务避开 01:00–03:30 这段换时窗口。时区错位服务器跑在 UTC、业务在东八区工作日 9 点预热缓存实际会在北京时间 17:00 执行。cronie 支持CRON_TZAsia/Shanghai声明行Spring 用注解的zone属性容器环境要确认TZ环境变量真的传进了进程。3. 定时任务的实现方式3.1 Linux 系统crontab 与守护进程最原汁原味的用法在 Linux 服务器上。每个用户有一张自己的任务表由crontab命令编辑持久化在/var/spool/cron/下路径随发行版略有差异系统守护进程负责按分钟扫描并触发[2]。常用命令四件套crontab -e编辑、-l列出、-r清空无确认慎用、-u user操作指定用户。crontab -e 编辑的用户任务表# 每天 02:30 执行数据库备份输出追加进日志 30 2 * * * /opt/scripts/db_backup.sh /var/log/backup.log 21 # 工作日 08:45 预热缓存 45 8 * * 1-5 /usr/bin/curl -s http://127.0.0.1:8080/internal/warmup # 每 15 分钟采集一次系统指标 */15 * * * * /opt/scripts/collect_metrics.sh系统级任务不走crontab命令而是直接落文件/etc/crontab与/etc/cron.d/*每行比用户任务多一列执行用户/etc/cron.daily/、cron.weekly/、cron.monthly/目录里则放可执行脚本。这些目录由 anacron 体系驱动。anacron 解决的是 cron 的天然缺陷心跳以分钟为单位机器关机时错过的任务不会补跑。7×24 小时的服务器无所谓但笔记本和台式机常错过日清理、周备份。anacron 在开机后核对每个任务的上次执行时间戳把超过周期的任务补跑一遍粒度只到天。注任务环境里的PATH通常只有/usr/bin:/bin脚本里引用非标准路径的程序要写绝对路径crontab 里的%是特殊字符第一个 % 之后的内容会作为标准输入写date %F必须转义成date \%F任务的输出默认邮寄给本地用户邮箱没人看长期任务务必显式重定向否则失败无人知晓。3.2 应用内调度框架Web 应用本身是常驻进程再绕道系统 crontab 反而割裂——任务无法访问应用上下文还要处理权限与路径。主流语言因此都进化出了进程内调度器表达式解析、时间管理、触发执行全部在应用进程内完成。Java · Spring Scheduled六字段秒开头Component public class ReportJob { // 每天 02:30:00 执行显式锁定时区 Scheduled(cron 0 30 2 * * *, zone Asia/Shanghai) public void generateDailyReport() { reportService.aggregateAndExport(); } }Python · APSchedulerscheduler BackgroundScheduler() scheduler.add_job( cleanup_expired, # 目标函数 triggercron, hour3, minute0, misfire_grace_time600, # 错过 10 分钟内仍补跑 max_instances1, # 同一任务不并发 ) scheduler.start()Go · robfig/cronc : cron.New(cron.WithSeconds()) // 启用六字段秒开头 c.AddFunc(0 0 3 * * *, purgeExpired) c.AddFunc(every 30m, refreshCache) // 便捷间隔语法 c.Start()这一类框架的共同短板写在出生证上它们是单机组件。默认调度线程池很小Spring 默认单线程一个任务卡住会顺延其余全部任务进程重启后错过的时刻不会补跑APScheduler 配合持久化 jobstore 与 misfire 宽限是少数例外部署多实例时每个实例都会各自触发——重复执行不是故障而是进程隔离的必然结果。3.3 分布式调度锁、行锁与调度中心应用多实例部署后同一任务只跑一次从进程内的免费属性变成需要显式设计的问题。三条主流路线投入依次递增。路线一是分布式锁任务仍写在每个实例里触发时先抢一把 Redis 锁SET key token NX PX ttl抢到的实例执行。十行代码解决重复执行但边界很硬持锁实例在临界点崩溃这一轮任务直接丢失锁过期时间小于任务时长会导致第二个实例接着执行释放必须用校验唯一 token 再删除的 Lua 脚本否则会误删他人的锁。路线二是数据库行锁Quartz 集群模式把任务定义与触发时间写进数据库所有节点轮询同一张表用行级锁抢占触发权天然保证唯一执行。代价是全部调度流量经过数据库规模上限一般胜在零额外组件。路线三是调度中心XXL-Job、ElasticJob 把何时触发从应用中抽离集中到独立的调度层执行节点只暴露任务接口。调度中心之间用数据库行锁XXL-Job或 ZooKeeper 协调ElasticJob互斥再按路由策略把任务派给执行器集群附带故障转移、分片广播、失败重试与运行报表。任务多到几十上百个、需要可视化和告警时这是常规终点。3.4 容器化环境Docker 单机部署的常见做法是宿主机 crontab 调docker exec进入容器。Kubernetes 则提供原生的 CronJob 资源控制器按表达式定期创建 Job 对象Job 再拉起 Pod 执行每次任务都是全新容器天然避免了宿主机与镜像的耦合。concurrencyPolicy: Forbid可以禁止上一次未结束时的重叠调度超过容错窗口startingDeadlineSeconds错过的调度会被直接跳过。它不提供应用内的细粒度控制适合到点做一件事的批处理形态。3.5 秒级精度crontab 到不了的地方系统 crontab 的精度被它的心跳模型锚死在分钟级——守护进程睡到下一个整分钟才醒来匹配见 4.1 节五字段表达式里也没有写秒的位置。需要每 5 秒一次每分钟的第 15 秒这类粒度时落点不止一个。留在系统层换 systemd timer。它的日历表达式OnCalendar精确到秒如*-*-* *:*:15表示每分钟的第 15 秒。注意默认的AccuracySec1min允许实际触发落进计划点之后约 1 分钟的窗口——实现借此把相邻唤醒合并以省电追求精度必须显式配置AccuracySec1s。进入应用进程这是大多数场景的正解。Quartz、SpringScheduled、robfig/cron 的六字段表达式第一位就是秒APScheduler 的 cron 触发器则提供独立的second参数不需要表达式时语言自带定时器同样胜任——Java 的scheduleAtFixedRate、Go 的time.Ticker、Node 的setInterval。它们的精度由进程内的优先队列或时间轮保证见 4.4 节毫秒级唤醒正是这些结构的主场。自写循环时警惕两种写法的精度差别。while sleep 是最朴素的诱惑但下面两段代码一寸之差、天壤之别漂移写法fixed-delay每圈周期 执行耗时 sleep误差逐圈累计while True: do_work() # 耗时 0.2s 且有波动 time.sleep(1) # 从干完活起算实际周期 1.2s对齐写法fixed-rate每圈睡到下一个整秒误差不累计while True: do_work() time.sleep(1 - time.time() % 1) # 睡到下一个整秒边界耗时波动会被拉回差别只在下次时间怎么算——相对累加还是按绝对边界取整。Java 标准库里恰好有一对对应方法scheduleWithFixedDelay是前者的语义scheduleAtFixedRate是后者Go 的time.Ticker亦为固定速率语义。需求形态推荐方案关键点系统守护任务需要秒级systemd timerOnCalendar 写到秒AccuracySec1s应用内每 N 秒的业务动作六字段框架或语言定时器表达式首位即秒精度由进程内队列保证必须自写循环while sleep 对齐整秒sleep(1 - now() % 1)防累计漂移海量高频延迟任务延迟队列、时间轮别让调度中心每秒空转分布式别让调度中心每秒一跳把每秒一次塞给 XXL-Job、ElasticJob 这类平台意味着调度层每秒承受一次数据库行锁争抢与派发往返任务量一大必然压垮存储。更稳的形态是低频拉起、进程内自旋调度中心每分钟触发一次外壳任务任务内部用时间轮或循环展开成每秒执行或者改用延迟队列——Redis ZSET 按到期时间排序轮询、RocketMQ 延迟消息——把高频延迟任务从调度器里剥离出去。最后校准预期秒级定时器保证的是不早触发准时则受 GC 停顿、线程调度与系统负载影响几十毫秒内的抖动属于正常现象追求亚毫秒乃至硬实时的确定性用户态框架无能为力需要内核级定时器Linuxtimerfd或实时操作系统。3.6 选型对比方案适用场景多实例去重主要代价系统 crontab单机运维脚本、备份清理不适用单点环境受限、无观测、错过不补Scheduled 等进程内框架单实例应用内的周期任务无多实例必重复单线程瓶颈、错过不补进程内框架 分布式锁小规模多实例依赖锁实现的正确性临界崩溃丢一轮、需自管日志Quartz 集群传统 Java 应用、任务需持久化数据库行锁保证调度吞吐受数据库制约XXL-Job / ElasticJob中大型微服务集群调度中心统一派发多一套平台组件的运维Kubernetes CronJob容器原生的批处理由控制器与 Job 语义保证无秒级精度、无分片能力4. 底层原理从心跳循环到分布式互斥4.1 守护进程的心跳循环Vixie cron 的主循环朴素得近乎固执算出下一分钟的起点睡到那一刻扫描全部任务匹配则执行如此往复。它并不依赖每 60 秒设一个闹钟而是每次醒来都重新计算tick 60 秒并对齐到分钟边界把逐次累积的漂移消掉——这也是所有 cron 实现精度锚定在分钟级的根本原因。循环里的三个细节值得展开。变更检测用户运行crontab -e保存时命令会通知守护进程重载通知失效时靠每分钟核对文件修改时间兜底。匹配执行匹配的任务被 fork 成子进程切换到任务属主的身份后 exec 目标命令因此任务以用户权限运行、彼此隔离。错过不补关机或休眠期间的时间窗不会在下次开机时重新扫描只有 anacron 体系以天为粒度补偿日/周/月级任务。4.2 匹配算法与下一次触发时间解析阶段每个字段都被编译成显式的取值集合0-30/10展开为 {0, 10, 20, 30}1-5展开为 {1, 2, 3, 4, 5}星号就是该字段的全部合法值。判断一个时刻是否触发就是五个集合包含测试的交集唯一的例外在日期与星期这对字段上——Vixie cron 为它们各记录一个是否为星号的标志两个字段都被显式限定时改按或匹配这正是 2.5 节陷阱一的实现根源。调度器内部还有第二个常量问题下一次触发在何时。计算从当前时刻出发逐字段试探在分钟集合里找不小于当前分钟的最小值找不到就向小时字段进位并把分钟重置为集合最小值逐级向上直到所有字段收敛。Quartz、Spring、robfig/cron 都内置了这套表达式反推 next fire time的例程Quartz 更是把结果持久化为QRTZ_TRIGGERS表里的NEXT_FIRE_TIME列作为集群抢占的排序依据。4.3 两种工作模型与 Quartz 的混合设计心跳扫描并不是唯一的睡法。以谁决定醒来时机为准调度器分成两派工作模型心跳扫描型不记录任何下次时间固定周期醒来全表匹配。cron 守护进程是这一派——任务表随时增删、粒度只到分钟每分钟空转一次的开销可以忽略换来的是改了任务下一圈自然生效的简单性。最近到期型所有任务按下次触发时间排序线程只盯最近的一个算出时间差睡过去醒来执行、再算该任务的下一次时间。JDK 的ScheduledThreadPoolExecutor、Go 的运行时定时器都属此派。它必须解决心跳模型没有的问题新任务若比当前等待的更早到期必须主动唤醒线程否则会睡过头。Quartz 的调度线程是两者的混合这不是折中而是处境使然。它的粒度要求到秒——若照搬心跳扫描等于每个节点每秒空扫一次数据库集群模式下这是一场行锁风暴而它的触发器又必须持久化在数据库里供集群抢占见 4.5 节纯内存的优先队列重启即丢担不起这份职责。于是它两头取巧QuartzSchedulerThread从 JobStore 按NEXT_FIRE_TIME取出最近的一批触发器睡到最早的触发点这个等待有两个出口——进程内新调度了更早的任务时内部信号会提前叫醒它重算集群里其他节点写入的触发器无法跨进程通知本节点则由兜底超时idleWaitTime默认约 30 秒保证最多约 30 秒后重查数据库。空表时几乎不占资源新任务又不至于睡过头——这正是记录下次时间、睡到执行时刻的现实版本。4.4 两种计时内核优先队列与时间轮分钟粒度的 cron 不在乎计时开销但当精度提到毫秒、定时器数量到十万级——网络连接超时、延迟消息、限流窗口——调度内核的数据结构就开始分化。应用框架里存在两派主流实现。优先队列一派用最小堆维护按最近到期时间排序的全序结构堆顶永远是下一个该执行的任务。插入和取消是 O(log n)查看堆顶 O(1)。JDK 的ScheduledThreadPoolExecutor底层就是一个延迟工作队列。缺点在高并发下所有插入都竞争堆的全局锁每次出队都要重新堆化。时间轮一派放弃全序改用散列。把一圈划分成 N 个槽指针每 tick 前进一格新任务按(当前指针 延迟/tick) % N落槽追加到槽内链表尾插入退化为 O(1)。指针扫过某槽时链表里剩余轮数为零的任务执行其余轮数减一后原地等待。Netty 的HashedWheelTimer是单层实现的代表Kafka 则用层级时间轮覆盖任意长延迟——上层槽到期后任务降级进入下层像时钟的时针向分针、分针向秒针交棒一样用固定大小的小轮子管理无限的时间。维度优先队列最小堆时间轮插入O(log n)O(1)取消O(log n)O(1)链表摘除到期检查O(1)看堆顶O(1)扫当前槽高并发瓶颈堆的全局锁与堆化无全序维护几乎无竞争代表实现ScheduledThreadPoolExecutorQuartz 内存模式红黑树同一语义Netty HashedWheelTimer、Kafka 层级时间轮两种计时结构的复杂度对比。千级以内任务两者无感十万级毫秒级定时器是时间轮的主场。4.5 Quartz 集群如何只跑一次Quartz 集群的互斥完全落在数据库行锁上[4]。触发器记录带一个状态机WAITING → ACQUIRED → EXECUTING → 回到 WAITING。每个节点扫描下次触发时间已到的 WAITING 触发器时先在行锁事务内把状态改为 ACQUIRED——同一行只有一个节点能改成功其余节点查到状态已变就自然跳过。执行完毕后节点更新NEXT_FIRE_TIME把状态交还 WAITING等待下一轮争抢。misfire 机制处理另一种失败节点在触发后、执行前宕机触发器会卡在中间状态。超过阈值misfireThreshold默认 60 秒仍未真正执行的触发器进入 misfire 流程按策略二选一——立即补跑一次再恢复正常节奏FIRE_ONCE_NOWCronTrigger 的默认策略或放弃本轮、等下一个调度点DO_NOTHING。配合SCHEDULER_STATE表的实例心跳集群能感知节点离场并回收其触发器。4.6 分布式去重与分片的工程要点分布式锁的正确姿势比想象中窄。锁值必须放唯一 token释放时用 Lua 脚本校验 token 后再删除避免 A 的锁被 B 误删任务可能超过锁 TTL 时要么用看门狗自动续期Redisson 的做法要么放弃锁、改用抢占即写库的方案。还要清醒锁只解决重复不解决丢失——该触发的瞬间持锁节点恰好崩溃这一轮任务就消失了业务上需要幂等设计或补偿机制兜底。调度中心模式把问题转化得更彻底。以 XXL-Job 为例调度线程每秒把未来 5 秒内到期的任务推进一个 60 格的秒级环形队列由独立线程逐秒消费派发多调度节点之间靠数据库行锁争抢保证同一任务同一秒只被一个节点派发。执行层的重复被路由策略消化故障转移选一个可用实例分片广播让全部实例各领一个分片号把一份全量数据切片后并行消化——重复执行问题就此变成并行扩展能力这是分布式调度相对单机最本质的收益。5.END写表达式先核对字段数与星期编码跨系统差异比语法本身更容易埋雷从单机走向集群的分水岭不是性能而是互斥先考虑多实例时谁保证只跑一次再谈选型只有当精度超过分钟、定时器数量超过万级时时间轮这类结构才值得请进自己的代码其余时候它只是基础设施替你消化掉的复杂度。
返回列表