给无人值守的定时任务配一张执行台账:跑没跑、跑多久、挂在哪一行,一个接口就能查

2026-10-26 Skills定时任务可观测性CloudflareD1

定时任务出问题,通常不是报错,是什么都没发生。

站长最熟悉的一幕:每天早上 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 抛了。

怎么知道自己做对了

  1. 手动打一次 /cdn-cgi/local/scheduled,job_runs 里多一条 status=ok 的记录,duration_ms 大于 0。
  2. 在 doTheRealWork 里抛一个 throw new Error("boom"),再打一次,表里应该出现 status=failed 且 error 里含 boom,绝不是 ok。
  3. 连打三次同一个 time,表里只有一条记录——run_key 唯一索引生效。
  4. 把某条记录的 last_ok 手动改成 3 天前,/health 返回 503 且 stale: true。
  5. 部署后等一个真实周期,第二天打开 /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 挂上去。二十分钟的活,换掉的是「一周后才发现」这种事故。