返回首页 · 时间与时区 · 日常运维 · 跨机器运维 · 给 agent 的调度工单
这一页帮你做什么¶
你想让某件事“每天自动跑一次”,但机器可能关机、可能错过时间、也可能上一次还没跑完下一次又来了。这篇讲清楚日历触发与间隔触发的区别、cron 与 systemd timer 各自的行为边界、补跑(catch-up)和并发(overlap)到底由谁决定,以及跨机器时锁、幂等、去重这些概念怎么落到可执行的判断上。目标是让你能判断自己的调度配置在错过、重叠、跨时区时会发生什么,而不是抄一段看不懂的 cron。
调度语义由具体实现决定。cron 与 systemd timer 不能互相代替,同一个 OnCalendar= 表达式在不同 systemd 版本、不同时区设置下也要按实际核对。
适用环境与验证范围
以 Ubuntu 24.04 + systemd 为主,兼讲 cron 的通用行为。时间与时区基础、UTC/本地显示见时间专题。
本文的 timer/service 配置只作阅读用的合成示例,不激活、不安装。仓库核对的是文档、静态语法与链接;未在真实主机上创建 timer、触发任务或验证补跑。
先读哪一段¶
| 眼前的问题 | 入口 |
|---|---|
| “每天九点”和“每隔 24 小时”有什么不同? | 日历触发与间隔触发 |
| 机器关机时错过的任务,开机后会不会补跑? | Persistent 只作用 OnCalendar |
| 上一次还没跑完,下一次又来了怎么办? | 同 service 仍 active 时 timer 不重启它 |
| 多台机器会不会重复执行同一件事? | 跨机器:锁、幂等与去重 |
| 一份可读的 timer/service 长什么样? | 合成配置示例 |
| 怎么只读检查任务状态和产物? | 只读检查 |
日历触发与间隔触发¶
“每隔一段时间”和“每个日历钟点”是两种触发语义,写错会得到不同的补跑和夏令时行为:
| 需求 | 语义 | 保留什么 | 注意 |
|---|---|---|---|
| 每天用户当地 09:00 | 当地日历时间 | IANA 时区 + 日历规则 | 夏令时附近可能不存在或出现两次,按实现规则处理 |
| 从某次开始每隔 24 小时 | 起点 + 固定间隔 | 起点、间隔 | 不保证始终落在同一当地钟点 |
| 每 15 分钟 | 固定间隔 | 间隔 | 与“每次整点后的第 0/15/30/45 分”不完全相同 |
systemd timer 用 OnCalendar= 表达日历触发(如 *-*-* 09:00:00),用 OnUnitActiveSec=/OnBootSec= 等表达基于其他事件往后算的相对触发。cron 的字段是日历式的(每天某个钟点),传统 cron 没有“从上次运行起间隔 N 分钟”这种语义。日历规则在夏令时切换附近可能产生不存在或重复的当地时间,各调度器有自己的取舍,不能凭直觉推断。systemd.time(7),Ubuntu 24.04
时区直接写在日历表达式末尾。 systemd 的 OnCalendar= 支持在末尾附加时区,例如 OnCalendar=*-*-* 09:00:00 Asia/Shanghai 或 … UTC,即把该日历规则锚定到该 IANA 时区;timer 没有 Timezone= 选项。不写时区时按系统时区解释。systemd.time(7),Ubuntu 24.04
调度精度:AccuracySec 与 RandomizedDelaySec¶
触发时刻不是整点即到即执行的保证:
AccuracySec=默认1min:systemd 允许把触发安排在目标时刻后的这段窗口内,以便合并唤醒省电。要更准,就把它设小(代价是更频繁唤醒)。RandomizedDelaySec=会给触发加一个随机延迟,用于打散同时刻任务;Persistent=的补跑也仍受它影响。
所以“配置写了 09:00:00”不等于“09:00:00 精确启动”。验收时看实际触发时间,而不是假设零误差。systemd.timer(5),Ubuntu 24.04
Persistent 只作用 OnCalendar¶
这是最容易误解的一条:systemd timer 的 Persistent=true 只在该 timer 由 OnCalendar= 触发时才生效。它记录上一次触发时间;若 timer 在 inactive 期间至少漏过一次触发,则在下一次激活时安排一次触发(不是把漏掉的每一次都补上),且仍受 RandomizedDelaySec= 影响,不一定立即完成补跑。
它不是:
- 不是“错过多少次就补跑多少次”。timer 只在激活时安排一次补跑,业务上要补哪些日期由任务自己决定;把漏掉的历史逐次补齐得任务自己设计。
- 不是对所有触发类型都生效。对
OnBootSec=、OnUnitActiveSec=这类相对触发,Persistent=没有“补跑”语义。 - 不是自动的幂等保证。补跑仍可能与直接调用底层程序、其他 unit 或其他机器的执行重叠;若同 manager 的相关 service 已在跑,补跑同样受“不新起实例”的边界约束(见下节)。
systemd.timer(5),Ubuntu 24.04、systemd.timer 源文档
因此“关机三天,开机后只安排一次补跑”是常见行为,想逐日补齐就得自己在任务里设计(例如按“缺失的日期”自己判断并逐日处理),而不是指望 Persistent=。
同 service 仍 active 时 timer 不重启它¶
一个 timer 触发的是对应的 service。若同一个 unit 的 service 已经在运行(active 或 activating),systemd 默认不会再启动一个新实例去覆盖它——timer 到了点也不会“重启”这个正在跑的服务,systemctl start 同一 unit 也不会制造并发。这可以避免简单的自我重叠,但要注意:
- 真正的旁路是“绕开这个 unit”的调用。 直接运行底层程序、另一个 service 或模板 instance(
@名字)、另一个 manager、另一台机器,都会绕开这个边界造成重叠。 - 长时间运行的任务会“吃掉”后续触发。 如果每次运行都长于间隔,那么运行期间的后续触发被跳过,实际频率低于你的间隔。
RemainAfterExit=true会让 service 长期保持 active。 这类 oneshot 服务一旦“完成”也一直算 active,后续触发按上面规则就不再新起实例——要确认这是否符合你的意图。
想要更细的重叠控制,可以显式设置服务的启动/运行约束;例如对不该并发的任务,用应用层自己的锁(见下节),而不是假设调度器替你解决了所有重复。
跨机器:锁、幂等与去重¶
先别默认上“分布式锁”。更实在的顺序是:先确定一个真实的调度 owner,让任务状态可查询(job/unit 状态、日志、已完成标记)——这本身就消除了大部分重复。只有当多台机器确实要共享写同一份数据时,才需要并发控制,并优先用应用已支持的事务或唯一约束,而不是自造锁协议。
需要判断的四个概念:
| 概念 | 回答的问题 | 常见实现 |
|---|---|---|
| 互斥锁 | 同一时刻只允许一个执行 | 文件锁 flock、advisory lock |
| 幂等 | 同一操作执行多次,结果与执行一次相同 | 任务自身设计(upsert、带 job id 去重) |
| 去重键 | 用唯一标识判断“这次是不是已经做过” | 业务表唯一约束、已完成标记 |
| 租约/心跳 | 判断某执行者是否还活着,避免死锁 | 带超时的锁或租约表 |
flock 提供的是 advisory 锁:只有同样去申请这把锁的进程才会被挡住;不申请锁的进程照样能执行。在同一台机器的普通文件系统上,它适合“让同一脚本的多个实例互斥”,但不能阻止别人绕过它直接跑底层命令。在 NFS/SMB 等网络文件系统上,锁行为取决于协议、挂载选项与内核版本;仅仅“共享一个路径”并不证明跨机器互斥真的成立,要按实际文件系统核实。flock(2)
下面只是展示用法,本仓库不执行它;它本身是写文件、取锁的动作(不是只读检查):
# 展示用:同一主机普通文件系统上,让本脚本的并发实例互斥
exec 9>/var/lock/example-job.lock
flock -n 9 || { echo "another run in progress"; exit 0; }
更稳妥的跨机器去重通常落在任务自己维护的状态上(唯一键、已完成标记、带执行者 id 的租约),或直接用数据库事务/唯一约束,而不是靠文件锁的巧合。
结果未知时,先查原任务¶
任务超时、连接断开或换了一个 harness 时,不要直接重发同一份写任务。先按原任务恢复的顺序:查原目标的 job/unit 状态与结果 → 仍在运行就继续观察 → 已结束就读结果并验收产物 → 确实无法判断就记录待定位,而不是再建一份。对发信、付款、迁移、外部 API 这类有副作用的动作,幂等键与操作 id 要在提交前就保留下来。
合成配置示例(只展示,不激活)¶
以下 unit 只作阅读示例,不要在未核实的主机上安装或启用。 目标是一个“每天当地 09:00 运行一次、错过则尽量补一次、单次不并发”的作业。文件名与路径都是占位符。
example-job.service(Type=oneshot,跑完即退出):
[Unit]
Description=Example daily job (synthetic)
[Service]
Type=oneshot
User=example
Group=example
WorkingDirectory=/opt/example-job
ExecStart=/usr/local/bin/example-job --batch daily
example-job.timer(触发上面的 service):
[Unit]
Description=Run example daily job (synthetic)
[Timer]
OnCalendar=*-*-* 09:00:00 Asia/Shanghai
Persistent=true
# AccuracySec=1min 是默认值,示例显式写出以便看清触发精度
AccuracySec=1min
Unit=example-job.service
[Install]
WantedBy=timers.target
阅读要点:
Type=oneshot适合“跑一段就退出”的批处理;Persistent=true只在这里因为用了OnCalendar=才有补跑含义。- 时区附在表达式末尾
… Asia/Shanghai(这是合成的示例时区,不代表读者实际设置)。不写时区则按系统时区。 AccuracySec=1min是默认,意味着允许在目标时刻后这段窗口内触发,不保证 09:00:00 精确启动。- timer 触发 service;timer 自身不会因为你手动
systemctl start example-job.service而记录,两者状态分开看。 Type=oneshot的TimeoutStartSec=默认是禁用的(不会自动超时)。若任务需要时间上限,要显式设置,并想清楚超时后如何停止、如何恢复、是否留下半成品。- 生产里通常还要考虑失败通知,以及别让
ExecStart依赖交互式PATH(见配置专题)。
cron 还是 systemd timer¶
两者都能做日历式调度,选择看你的实际条件:
| 维度 | cron(按实现的 crond) |
systemd timer |
|---|---|---|
| 日历语义 | 分 时 日 月 周 五字段 |
OnCalendar= 表达式,可附时区 |
| 补跑关机错过的任务 | 传统行为不补(开机不追) | Persistent= 可在激活时安排一次 |
| 单实例/并发 | 无内建“上次未完成不启动”边界,靠 flock 等自理 |
同 unit 已 active 时不新起实例 |
| 与日志/状态 | 输出常走邮件/系统日志 | 走 journal,状态可由 systemctl 查询 |
| 安装位置 | 用户/系统 crontab 或 /etc/cron.d |
unit + timer(system 或 user manager) |
| 依赖环境 | crond 给的很有限的环境,PATH 很短 |
由管理器给定,可用 Environment= |
要按具体实现引用:Vixie 系 cron、cronie 及其他实现的扩展不同。时间字段、环境、以及是否支持 CRON_TZ 都要按本机实际的 daemon、包版本与手册核对。文末的 Ubuntu manpage 入口返回 cronie/crond 内容,不能据此推断另一实现的行为。
一个合成 cron 表达式示例(只解释、不安装):0 9 * * * 表示“每天 09:00”,按 crond 所在时区解释——具体是哪个时区、能否改,取决于实现。若要跨机器一致,务必先确认每台机器 crond 的时区来源。
只读检查¶
运行位置:待检查的 Linux VPS;只读。 unit 名替换成已确认的目标。systemd-analyze calendar 只解析表达式、不触发任务。
# 这些表达式各自接下来几次会在什么时候触发
systemd-analyze calendar '*-*-* 09:00:00 Asia/Shanghai'
systemd-analyze calendar --iterations=5 '*-*-* 09:00:00 Asia/Shanghai'
# timer 与 service 的状态(active/enabled 分开看)
systemctl list-timers 'example-job*' --all
systemctl status example-job.timer example-job.service --no-pager
systemctl show example-job.timer -p NextElapseUSecRealtime -p LastTriggerUSec -p Persistent -p Unit -p Result
systemctl show example-job.service -p ActiveState -p SubState -p Result -p ExecMainStatus -p NRestarts
# 任务日志:上次什么时候跑、结果如何
sudo journalctl -u example-job.service -n 80 --no-pager
用 cron 时,对应的只读检查是查看本机实际的 crond 与 crontab(只读,不编辑):
# 本机 cron 实现与状态(按发行版/实现替换;只读)
command -v cron crond
systemctl status cron.service crond.service --no-pager
crontab -l # 当前用户的 crontab;加 sudo 会查 root
ls -l /etc/cron.d/ # 系统 drop-in(只列目录)
不同实现的 unit 名与目录不同,缺服务/无权限时保留这条信息,不要把 cron 当成“没有就是没跑”,也不要为检查去安装另一个 cron。
systemctl list-timers 的 NEXT/LAST 列是最直接的“下次何时、上次何时”证据;Result=success 且 ExecMainStatus=0 只说明进程级成功,不等于业务产物正确。若任务会写文件、上传结果或更新数据库,还要单独验收产物。
任务记录与产物验收¶
一个可审查的验收清单:
- 时间:
list-timers的LAST/NEXT、日志里的起止时间,与你的时区预期一致。 - 结果:
Result、ExecMainStatus、业务日志中的成功/失败标记。 - 产物:任务该产出的文件/记录/外部状态实际存在且内容合理,而不是只有退出码。
- 重复:检查是否存在同一逻辑单元被处理两次(去重键、计数)。
- 补跑:若发生过关机/错过,确认是补了一次还是被逐次补齐,与你的设计一致。
- 并发:若上次运行时间接近间隔,确认没有非预期重叠。
变更与恢复边界¶
调度相关的变更(新增/修改 timer、改 cron、改时区)会影响“什么时候、会不会、重复几次”执行,属于有副作用操作:
- 改动前备份原 unit / crontab,记录旧表达式和它的实际触发时刻(用
systemd-analyze calendar留证)。 - 改完要按你所用版本核实实际效果:
daemon-reload本身只是让管理器重读 unit 文件,是否重新计算、何时重算下次触发、以及 timer 是否需要单独重启,都按版本确认;不要假定它一定让 NEXT 变新。 Persistent=true可能立刻触发一次:若改动让 timer 重新激活且期间有错过,补跑可能随即发生。改之前先查目标 service 当前是否在跑(systemctl is-active),避免在任务运行中制造意外。- 停用一个 timer 用
systemctl disable --now example-job.timer,它会同时停掉计时;但已经在跑的 service 未必随之停止,需要单独处理。 - 回退:恢复备份的 unit、
daemon-reload,用systemctl list-timers核对NEXT/LAST是否回到预期(以实际输出为准)。 - 对有外部副作用的任务(发信、付款、迁移),变更和回退都要考虑幂等:重放一次是否安全。
提醒: 本文不提供“立即试跑一次”的写操作建议,也不会替你试跑。如需试跑,应另有一次明确的授权,并先确认任务的幂等/去重能力。
怎样算解决¶
一篇验收记录应能回答:
- 触发语义:日历还是间隔?按哪个时区?我写下的表达式解析出来是哪几次时刻?
- 错过处理:关机/停机错过的任务,激活时是否安排一次补跑?业务上要补哪些日期由任务自己决定,是否逐次补齐要单独设计。
- 并发边界:同 service 在跑时新的触发会怎样?有哪些调用者可能绕过这个约束?
- 结果归属:任务记录与产物在哪里?除退出码外,怎么证明业务正确?
缺少真实触发、补跑或跨机器实验时,明确写“未验证”,不把“配置看起来对”写成“已验证会按预期补跑”。
来源与维护¶
查阅日期:2026-10-05。systemd 示例按 Ubuntu 24.04 的 systemd 255.4 手册核对。本仓库未安装/启用 timer,未在真实主机触发调度或验证补跑;配置解析与真实触发分别验收。
- systemd.timer(5),Ubuntu 24.04(systemd 255.4):已 active 的同一 unit 到点不会被重启或产生新实例;
Persistent=只对OnCalendar=生效,timer 激活时若 inactive 期间至少漏过一次则安排一次触发,仍受RandomizedDelaySec=影响;AccuracySec=默认1min,触发不是精确09:00:00保证。 - systemd.time(7),Ubuntu 24.04:时区直接附在日历表达式末尾(支持 IANA/UTC);不存在 timer 的
Timezone=选项。 - systemd.service(5)、systemctl(1)、systemd-analyze(1):
Type=oneshot的TimeoutStartSec默认禁用;systemd-analyze calendar解析触发时刻。 - cron(8),Ubuntu 24.04:该网址实际返回的是 cronie/crond 系实现的内容,不能据此推断 Vixie 或其他 cron 的行为;引用时明确实现。
- flock(2)、flock(1):同机普通文件系统 advisory 锁;NFS/SMB 行为按协议、挂载与内核版本变,仅共享路径不证明跨机互斥。
- 时间同步专题:时区、UTC 与跨机器时间对应;判断“下一次何时触发”前先确认时钟与时区。
想一想:一个 timer 设了 Persistent=true,机器关机三天后才开机,它会把这三天里错过的那几次都补跑吗?
查看答案
通常不会。`Persistent=` 只作用于 `OnCalendar=` 触发,错过多次一般只在启动时补跑一次,而不是逐次补齐;“逐日补齐”需要任务自己按缺失的日期处理。具体行为以所用 systemd 版本的 systemd.timer(5) 为准。