入门指南
定时任务没有报错,也没有跑:三条停摆规则与一套心跳
对照 GitHub Actions 的 schedule 与 Cloudflare Cron Triggers 的公开文档,把「平台没触发」「配置没生效」「时间不是你以为的那个」拆开,并给出一套能区分「来过」与「做对了」的心跳设计。
最贵的故障不是报错,而是当天的任务根本没有开始。

先看答案
- 适合谁
- 用 GitHub Actions 或 Cloudflare Workers 跑定时任务的人
- 只有一个后台任务,却总在事后才发现它没跑的人
- 已经有监控,但只看「有没有报错」的人
- 你会获得
- 先分清平台为什么不触发,再用心跳把「来过」和「做对了」分开。
- 发布时间
- 实质更新
- 发布
- AOPC 编辑部
60 秒结论
一分钟看懂这篇文章
最贵的故障不是报错,而是当天的任务根本没有开始。
人负责
- 决定哪些任务必须留痕
- 设定可接受的延迟窗口
AI 负责
- 比对计划时间与实际执行
- 把失败写成可检索记录
- 列计划
- 设心跳
- 定窗口
- 留证据
直接答案
定时任务最贵的故障不是报错,而是**「什么都没发生」**:没有异常、没有告警、也没有产出,你直到有人问「今天的报表呢」,才发现它已经三天没跑。
这类故障通常不发生在你的代码里,而在三层之间:
- 平台没有触发——高峰延迟、队列满时丢弃、分支不对、长期无活动被自动停用;
- 触发了,但配置还没生效——定时规则的增删改需要传播时间;
- 触发了,但时间不是你以为的那个——默认 UTC、夏令时跳变顺延。
而多数「监控」只覆盖了另一件事:进程来过。它证明不了任务做对了,也证明不了产出存在。
下面先把两条公开文档里的规则逐条拆开,再给一套一个人也维护得动的心跳设计。
一、三种「没跑」,现象完全不同
| 你可能看到的现象 | 官方文档怎么写 | 该核对什么 |
|---|---|---|
| 某天少跑一次,任务日志里没有任何记录 | GitHub:schedule 事件「can be delayed during periods of high loads」,高负载时段包括「the start of every hour」;负载足够高时「some queued jobs may be dropped」 |
平台运行历史里有没有这次运行;把计划时间错开整点 |
| 改了工作流,定时任务还是老样子,或者根本不跑 | GitHub:只有工作流文件存在于默认分支时才会触发;「Scheduled workflows will only run on the default branch」,且跑默认分支的最新提交 | 文件是否已合并进默认分支;默认分支是不是你以为的那个 |
| 一个长期没人动的仓库,定时任务悄悄停了 | GitHub:公开仓库「scheduled workflows are automatically disabled when no repository activity has occurred in 60 days」 | 工作流是否被停用;这条只写在公开仓库语境下 |
| 刚改了 Cron Trigger,第一次执行没按新时间 | Cloudflare:新增/修改/删除触发器「may take several minutes (up to 15 minutes) to propagate to the Cloudflare global network」 | 部署时间与预期执行时间之间,是否留出了 15 分钟 |
| 本地 9 点跑,线上 17 点跑,或者某天时间跳了 | GitHub 默认 UTC,可显式设 IANA 时区;DST 春季跳变时,被跳过的时刻「advance to the next valid time」(2:30 顺延到 3:00)。Cloudflare「Cron Triggers execute on UTC time」 | 计划时间写的是哪个时区;当天实际执行时间 |
这张表最重要的不是五条规则,而是它们的共同点:没有一条会以「任务失败」的形式出现在你自己的任务日志里。任务没启动,就没有日志可写;任务被延迟,日志只会晚一点出现。只有平台侧的运行历史,才知道「平台到底有没有触发」。
二、GitHub schedule 的四条硬规则,和一个 60 天开关
① 只在默认分支上跑。 官方写了两句:工作流文件必须存在于默认分支才会触发;「Scheduled workflows will only run on the default branch」,并且跑的是默认分支的最新提交。这有两个直接后果:在功能分支上改 cron 不会生效;默认分支上一个坏提交,会被下一次定时任务原样拿去跑。
② 最短 5 分钟一次。 官方原文是「The shortest interval you can run scheduled workflows is once every 5 minutes.」所以「每分钟检查一次」在这条路上不是配置问题,是平台不支持。
③ 默认 UTC,可以显式设时区。 不写 timezone 时按 UTC 解释。对 UTC+8 的团队,0 9 * * * 实际是北京时间 17:00——这类错位不会报错,只会让任务在错误的时间安静地成功。
④ 夏令时春季跳变时,被跳过的时刻顺延,不是跳过。 官方举的例子是 2:30 AM 顺延到 3:00 AM。按这条规则的直接后果:如果你的心跳窗口按「计划时间 + 10 分钟」设,这一天会误报一次「没跑」。这不是平台故障,是窗口设得太窄。
60 天开关只写在公开仓库的语境里:「In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days.」页面没有展开「repository activity」的判定口径,所以不要假设「只要有人 star 就算活动」——你无法从这页推出这个结论。能做的只有一件事:在平台侧定期确认工作流仍处于启用状态,而不是等它自己恢复。
最后一条是官方建议,不是保证:「To decrease the chance of delay, schedule your workflow to run at a different time of the hour.」错开整点降低的是延迟概率,不是把延迟变成零。
三、Cloudflare Cron Triggers:UTC、15 分钟传播、100 条历史
Cloudflare 这一侧的字更少,但有三处对「事后取证」很关键。
① 执行时间按 UTC。 页面只有一句「Cron Triggers execute on UTC time.」没有本地时区选项可写。所以同一个 0 9 * * *,在 GitHub 上可以配时区,在这里就是 UTC 9 点。
② 配置变更最多 15 分钟传播。 「Changes such as adding a new Cron Trigger, updating an old Cron Trigger, or deleting a Cron Trigger may take several minutes (up to 15 minutes) to propagate to the Cloudflare global network.」注意限定语:它说的是触发器的增删改,不是每次代码部署。把「每次部署都会延迟 15 分钟」写进 SOP 是误读。
③ 执行历史只留最近 100 次。 Cron Events 里「stores the 100 most recent invocations of the Cron scheduled event」;新建 Worker 或改 Worker 名字后,最多要 30 分钟事件才会显示。做个算术:每小时一次的任务,100 条大约覆盖 4 天;每天一次的任务大约覆盖 3 个月。需要更长的留存,页面指向 Workers Logs(保留更久、可筛选查询)或 GraphQL Analytics API。这意味着「上周三那次到底跑没跑」可能已经查不到了——所以关键任务的执行记录要落到你自己的存储里,而不是指望平台历史。
关于投递语义,这一页没有承诺。 全文没有「at least once」「guaranteed」这类表述,也没有写失败重试的次数或间隔。页面里唯一与重试相关的字样,是本地测试返回的 noRetry 字段——当 handler 调用 controller.noRetry() 时为 true。它说明「重试」这个概念存在,但没有给出线上重试的次数、间隔或时间窗口。所以任何「Cloudflare 会替你重试三次」的说法,都不该从这一页推出来。
四、心跳:把「来过」和「做对了」分开
一个人维护时,最容易犯的错是只写一条「开始执行」的日志,然后把它当监控。它回答的只是「进程来过」。
一套够用的心跳至少要有三件事:
- 开始时写一条(证明触发成功、记录实际开始时间);
- 成功结束时更新状态,并带上业务量(处理了多少条、生成了几个文件、写入了多少行);
- 失败时也写,状态为失败并留下错误摘要。
第 3 条最容易被跳过,但它决定了一件事:「失败」和「没跑」在你的数据里是否长得一样。如果只有成功才写记录,这两种情况都是「没有记录」,你就无法在告警时区分「该重试」还是「该查平台为什么没触发」。
然后是告警条件。它不是「收到失败就告警」,而是:
现在已超过「计划时间 + 宽限」,且没有一条成功记录 → 告警。
宽限该设多大?文档没有给 GitHub 的延迟上限,Cloudflare 的 15 分钟也只针对触发器配置变更。所以宽限只能按你自己的执行历史分布来定,不能假设「晚 5 分钟就是故障」。对每天一次的任务,把宽限设成小时级通常比分钟级更接近现实;对每小时一次的任务,宽限至少要小于一个周期,否则两次失败会合并成一次告警。
还有一条是编辑判断,但很实用:给业务量设一个正常区间。「跑了,但处理 0 条」和「没跑」是两种完全不同的故障,前者往往意味着上游空了、权限掉了、或者查询条件写错了。心跳里不带业务量,这两种情况在监控上都是绿的。
| 信号 | 能回答 | 不能回答 |
|---|---|---|
| 任务日志里的「start」 | 进程来过、什么时候来的 | 是否跑完、是否做对 |
| 成功心跳 + 业务量 | 跑完了,且处理了 N 条 | 处理的内容是否正确 |
| 平台运行历史 | 平台是否触发、这次运行的结论 | 你的下游是否真的收到产出 |
| 产出物校验(文件存在、行数、下游查询) | 结果存在且形状对 | 结果在业务上是否正确 |
四行要一起看,缺一行就会留下一个「看起来是绿的」盲区。
五、上线前检查表
- 计划时间写的是哪个时区? GitHub 默认 UTC(可设 IANA 时区);Cloudflare 只有 UTC。
- 默认分支是哪一个? 工作流文件在不在默认分支?功能分支上的改动不会生效。
- 是不是整点跑? 官方点名整点是高负载时段,能不能错开几分钟?
- 间隔是否 ≥ 5 分钟? 这是 GitHub 的最短间隔。
- 公开仓库会不会因 60 天无活动被停用? 谁负责定期确认它仍启用?
- 改完 Cron Trigger 留了 15 分钟传播窗口吗? 别把首次执行时间卡在部署后立刻。
- 有没有「开始」和「成功」两种记录? 只有一种,就无法区分没跑与没跑完。
- 心跳带业务量了吗? 「0 条」和「没跑」区分得开吗?
- 失败会写心跳吗? 不写,失败就和没跑一样安静。
- 告警宽限 ≥ 你实测的最大延迟吗? 不要按「理论上应该很快」来设。
- 上线后第一次执行,去哪个界面确认? Cloudflare 的 Cron Events 只有最近 100 条。
六、一周节奏(可照抄)
- 上线当天:手动触发一次,确认 handler 真的被调用;再去平台侧运行历史里看到这一次记录。
- 首周每天:只看两件事——成功心跳有没有出现、业务量是否落在正常区间。不要先看错误日志,因为「没跑」根本没有错误日志。
- 每周:对一遍「计划次数」与「实际成功次数」。差值大于 0 的,逐条归因到:平台延迟/被丢弃/分支不对/配置未传播/时区错位。归因不了的那条,才是真正需要加监控的。
- 每月:确认公开仓库的定时工作流仍处于启用状态;确认 Cron Events 里还能看到你需要的那段历史(看不到就说明该落到自己的存储了)。
- 每季度:删掉没人看的心跳。一个人维护时,噪音不是来自告警太多,而是来自「设置了但从不处理」的告警。
七、三种误读
- 「cron 写了就会到点跑。」 官方明说会延迟,负载足够高时还会丢弃队列任务;此外还有默认分支、60 天停用、配置传播三个前置条件。它们共同的特点是:不报错。
- 「监控是绿的就说明任务成功。」 心跳只能证明进程来过;不带业务量的心跳,在「跑了但处理 0 条」时依然是绿的。成功与否要由「状态 + 业务量 + 产出物」三者共同回答。
- 「漏跑一次,补跑一下就好。」 补跑会改变下游看到的时间语义:按天分区的表、按小时聚合的报表、带「今天」条件的查询,都会因为补跑时间不同而写出不同的结果。补跑之前先确认下游是否幂等——这一条是编辑判断,不是平台文档的结论。
来源
本文所有引用均来自下列公开页面,访问日期均为 2026-10-26,发布前逐条重新验证为 HTTP 200:
- Events that trigger workflows – GitHub核查于 2026/10/26:
schedule事件在高负载时延迟、整点是高负载时段、队列满时可能丢弃、建议错开整点;工作流文件必须存在于默认分支、只在默认分支运行且跑最新提交;公开仓库 60 天无活动自动停用;默认 UTC、可选 IANA 时区;最短间隔 5 分钟;DST 春季跳变时顺延到下一个有效时刻(2:30 → 3:00)。 - Cron Triggers – Cloudflare Workers核查于 2026/10/26:按 UTC 执行;触发器增删改最多 15 分钟传播到全球网络;需要
scheduled()handler;Cron Events 只保留最近 100 次调用,新建或改名后最多 30 分钟才显示,更长的留存用 Workers Logs 或 GraphQL Analytics API;页面未披露投递保证与线上重试策略,仅本地测试返回noRetry字段。