给无人值守的定时任务配一张执行台账:跑没跑、跑多久、挂在哪一行,一个接口就能查
定时任务出问题,通常不是报错,是什么都没发生。
站长最熟悉的一幕:每天早上 9 点该跑的抓取没跑,一周后才发现内容停在上周二。任务本身没写错,是 cron 没触发、密钥过期了、或者第一天就 500 了,而没有任何东西告诉你。
解决这件事不需要监控平台。你需要一张表,每次执行留一行。
表结构
只记五件事:哪个任务、什么时候开始、跑完没有、花了多久、错在哪一行。
-- schema.sql
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job TEXT NOT NULL, -- 任务名,跟 cron 一一对应
run_key TEXT NOT NULL UNIQUE, -- 幂等键:job + 计划时间
started_at INTEGER NOT NULL, -- epoch 毫秒
finished_at INTEGER,
status TEXT NOT NULL, -- running | ok | failed
duration_ms INTEGER,
error TEXT -- 只存首行 + 栈顶,别存整段日志
);
CREATE INDEX IF NOT EXISTS idx_job_started ON job_runs (job, started_at DESC);
run_key 那个唯一索引是防重复的:同一个任务同一个计划时间只允许一条记录。重试、多实例、手动补跑都不会把表写花。
三处代码
先建库、绑到 Worker 上:
npx wrangler d1 create ops
# 把输出的 database_id 填进 wrangler.toml 的 [[d1_databases]]
npx wrangler d1 execute ops --remote --file=./schema.sql
# wrangler.toml
name = "ops-worker"
main = "src/index.ts"
compatibility_date = "2026-10-01"
[triggers]
crons = ["0 13 * * *"] # 每天 13:00 UTC = 北京时间 21:00
[[d1_databases]]
binding = "DB"
database_name = "ops"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Worker 里两个入口:scheduled 负责跑,fetch 负责查。
// src/index.js
export default {
async scheduled(controller, env, ctx) {
const job = "daily-sync";
const planned = controller.scheduledTime; // 计划时间(毫秒)
const runKey = `${job}:${planned}`;
const started = Date.now();
// 1. 先落一条 running,崩溃了也能看见「它开始过」
await env.DB.prepare(
"INSERT OR IGNORE INTO job_runs (job, run_key, started_at, status) VALUES (?,?,?, 'running')"
).bind(job, runKey, started).run();
try {
await doTheRealWork(env); // 你原来那段逻辑
await env.DB.prepare(
"UPDATE job_runs SET status='ok', finished_at=?, duration_ms=? WHERE run_key=?"
).bind(Date.now(), Date.now() - started, runKey).run();
} catch (e) {
await env.DB.prepare(
"UPDATE job_runs SET status='failed', finished_at=?, duration_ms=?, error=? WHERE run_key=?"
).bind(Date.now(), Date.now() - started, String(e).split("\n")[0], runKey).run();
throw e; // 让平台也记一次失败
}
},
async fetch(request, env) {
const { results } = await env.DB.prepare(
`SELECT job, MAX(started_at) AS last_start,
MAX(CASE WHEN status='ok' THEN started_at END) AS last_ok
FROM job_runs GROUP BY job`
).all();
const now = Date.now();
const jobs = results.map((r) => {
const ageH = r.last_ok ? (now - r.last_ok) / 36e5 : null;
return { job: r.job, lastOkHoursAgo: ageH, stale: ageH === null || ageH > 48 };
});
const stale = jobs.filter((j) => j.stale).length;
return Response.json(
{ ok: stale === 0, jobs },
{ status: stale === 0 ? 200 : 503 }
);
},
};
/health 返回 503 这一点很重要:能被外部探针和刷新面板同时看懂。任何 uptime 检查、任何「我打开网页看一眼」的动作,都能直接得到答案,不用再进数据库查。
本地先验一遍,别等上线才发现 cron 没配
本地开发时 cron 不会自动触发,要手动打一次:
npx wrangler dev
# 另开一个终端
curl "http://localhost:8787/cdn-cgi/local/scheduled?format=json"
# => {"outcome":"ok","noRetry":false}
要模拟某个具体时刻,带 cron 与 time 参数(time 是 epoch 毫秒),能验证「计划时间」算得对不对。返回 outcome 是 exception 就说明 handler 抛了。
怎么知道自己做对了
- 手动打一次
/cdn-cgi/local/scheduled,job_runs里多一条status=ok的记录,duration_ms大于 0。 - 在
doTheRealWork里抛一个throw new Error("boom"),再打一次,表里应该出现status=failed且error里含boom,绝不是ok。 - 连打三次同一个
time,表里只有一条记录——run_key唯一索引生效。 - 把某条记录的
last_ok手动改成 3 天前,/health返回 503 且stale: true。 - 部署后等一个真实周期,第二天打开
/health看到lastOkHoursAgo约 24,说明线上 cron 真在跑。
三个坑
[triggers] crons是覆盖式部署。 部署时 Wrangler 用文件里的数组替换线上已有的 Cron Trigger。写成空数组crons = []会把所有定时器删掉;整个triggers字段不写则保持原样。改配置时先看一眼这行,别顺手删。- 时区是 UTC,不是本地时区。 想在北京时间 21:00 跑,cron 要写
0 13 * * *。写错时区不会报错,只会安静地在错误的时间跑。 - Cron 事件只保留最近 100 次。 平台侧的调用记录留不住长期趋势,这也是为什么台账要写在自己的库里。
泼冷水
- 这张表也会挂。 D1 写失败、Worker 报错、账号欠费,任何一种都会让台账跟着沉默。真要硬一点,得在 Worker 外面再放一个探针(另一家的免费 uptime 检查,盯着
/health的 200/503)。 - 它只告诉你「没跑」,不告诉你「为什么」。 错误摘要只存首行,够定位到哪一步,不够复盘。真正的日志还是得留在你原来的日志系统里。
- 别把它做成 BI。 见过有人往里塞完整请求体和响应体,三个月后表比业务数据还大。这张表只需要回答一个问题:上一次成功的执行是什么时候。
- 频率高的任务不要每条都写。 每分钟一次的任务一天 1440 条,一年 52 万条。这种量级要么按小时聚合,要么只在状态变化时写。
最后一句
你现在就能做的最小动作:把每天那个最重要的定时任务挑出来,只加一次 INSERT 和一次 UPDATE,再把 /health 挂上去。二十分钟的活,换掉的是「一周后才发现」这种事故。