Node 后端实战 · 边缘 Cron 定时任务怎么写?Cloudflare 三个实战任务与踩坑
各位看官,把定时任务跑在边缘网络上,和我以前在单机 crontab 或容器里写个 @Scheduled 是完全两码事。以前那套心智模型是「一台机器,一个进程,准点跑一次」,但 Cloudflare Workers 的 Cron Triggers 是「每天某个时刻,平台在世界各地的边缘节点挑一个触发你的 Worker」------你不知道它跑在哪、上一秒的实例还在不在、这一次要跑多久会被掐。
我这个多租户系统有四个每天凌晨跑的定时任务:预聚合统计、拦截名单对账、审计日志冷热归档、导出文件清理。这篇文章把它们的真实实现拆开讲,重点不是「怎么调 API」,而是边缘环境逼出来的那几个设计取舍和真实踩过的坑。

一、Cron Triggers 长什么样
配置即代码,写在 wrangler.toml 里,dev 本地不触发,只有 test/prod 注册:
toml
# ---- Cron Triggers(仅 test/prod 注册,dev 本地不触发)----
[env.prod.triggers]
crons = ["0 0 * * *", "0 1 * * *", "0 2 * * *", "0 3 * * *"]
四个 cron 表达式,分别对应四个任务。入口是 Worker 的 scheduled 钩子,它把事件分发到我的任务处理器:
typescript
// src/index.ts
async scheduled(event: ScheduledEvent, env: Bindings, ctx: ExecutionContext) {
dispatchCron(env, "stats-aggregate").then((r) => { /* 0 点 */ });
dispatchCron(env, "blocklist-reconcile").then((r) => { /* 1 点 */ });
dispatchCron(env, "audit-sweep").then((r) => { /* 2 点 */ });
dispatchCron(env, "export-cleanup").then((r) => { /* 3 点 */ });
}
这里第一个要建立的认知:Cron Triggers 是「尽力而为」的,不是精确 cron 。平台正常情况下每天触发一次,但极端情况下可能漏跑,也可能(极少见地)重跑。所以我的每一个任务都设计成幂等------重跑不会重复写脏数据。后面会反复看到这一点。
四个任务的全貌先放一张表,后面对着看更清楚:
| 任务 | 触发时间(每日) | 作用 | 幂等方式 | 分批策略 |
|---|---|---|---|---|
stats-aggregate |
0 点 | 预聚合昨日统计宽表 | upsert(存在则更新,可重跑) | 单租户内并行聚合,无大表扫 |
blocklist-reconcile |
1 点 | 刷新拦截标记 + 联动取消计划 | 仅值变更才写 | 游标 1000 条/批 |
audit-sweep |
2 点 | 审计日志冷热分层 + R2 冷归档 | 先 R2 后删 D1,失败跳过 | 游标 1000 条/批 |
export-cleanup |
3 点 | 清理过期导出文件 | 按 TTL 删除 | 游标(详见导出篇) |
二、通用骨架:日志 + 锁 + 错误脱敏

四个任务共用一套骨架,核心在 dispatchCron 里:
typescript
export const dispatchCron = async (env: Bindings, task: string) => {
const db = getDb(env);
// 分布式锁:KV 读-写非原子(KV 没有 CAS),纯并发下两个调用可能同时读到空都进入执行。
// Cloudflare Cron 正常不会重复触发,此为 best-effort 防护,非严格互斥。
const lockKey = `cron:lock:${task}`;
const lockVal = await env.KV.get(lockKey);
if (lockVal === "running") return { ok: true, processed: -1, error: "already running" };
await env.KV.put(lockKey, "running", { expirationTtl: 600 });
const { logId, startedMs } = await cronStart(db, task); // 写 cron_exec_logs
try {
// ...按 task 名分发...
await cronEnd(db, logId, startedMs, processed, true);
return { ok: true, processed };
} catch (e: unknown) {
const raw = e instanceof Error ? e.message : String(e);
const msg = raw.length > 120 ? raw.slice(0, 120) + "..." : raw; // 脱敏:截断+移除 SQL/路径
await cronEnd(db, logId, startedMs, 0, false, msg).catch(() => {});
return { ok: false, error: "cron task failed" };
} finally {
await env.KV.delete(lockKey).catch(() => {});
}
};
三个设计点值得单独拎出来:
- 执行日志
cron_exec_logs:每个任务首尾都写一条记录(开始时间、结束时间、处理条数、耗时、状态、错误信息)。定时任务最怕「静默失败」------你以为它跑了,其实半路挂了。有了这张表,运维直接查status='failed'就能知道哪天哪次挂了,而不是靠用户投诉才发现。 - KV 分布式锁是 best-effort :锁用
KV.put(key, "running", { expirationTtl: 600 }),600 秒自动过期兜底。但注释里写得很诚实------KV 没有 CAS(比较并交换),读-写非原子,理论上并发时两个调用都能读到空值、都进入执行。Cloudflare Cron 正常不会重复触发,所以这把锁只是兜底,不能当严格互斥用。真要强一致互斥得用 Durable Objects,但为了防一个几乎不会发生的重跑去引入 DO,不划算。 - 错误信息脱敏 :异常堆栈里可能含 SQL 语句、表名、路径,直接落库有信息泄露风险。所以截断到 120 字符,对外只回
cron task failed,细节进日志。这条和我在审计日志里的脱敏思路一致。
三、任务一:stats-aggregate 预聚合统计
这个任务每天凌晨把前一天的实时数据聚合成一张宽表(lead_stats_daily),接口查统计时直接读这张预聚合表,而不是每次对大表 GROUP BY。
为什么必须预聚合 :实时统计要同时算「按状态分布、按分类分布、按项目分布、当日新增、当日跟进、当日转化、通话统计、坐席绩效」......这些如果每次请求都现算,大表上一堆 GROUP BY 直接把接口拖垮。每天算一次、结果落表,读接口从 O(聚合扫描) 变成 O(1 主键查)。
核心难点是「一次算全」,我用 Promise.all 把七个无依赖的聚合查询并行发出去,而不是串起来等:
typescript
// 并行执行无依赖的聚合查询(DATA-10)
const [byStatusRows, catRows, projRows, addedRow, fuRow, convRow, callStatRow] = await Promise.all([
db.select({ status: leads.status, n: count() }).from(leads)
.where(and(eq(leads.tenantId, tid), isNull(leads.deletedAt))).groupBy(leads.status),
// ...按分类、按项目、当日新增、当日跟进、当日转化...
db.select({
callCount: count(),
answeredCount: sql<number>`sum(case when ${callRecords.answerType} = 'answered' then 1 else 0 end)`,
noAnswerCount: sql<number>`sum(case when ${callRecords.answerType} != 'answered' then 1 else 0 end)`,
}).from(callRecords).where(/* 时间窗 + 租户隔离 */),
]);
// 坐席绩效:用 GROUP BY 聚合查询替代「循环内每条查一次」的 N+1 模式(DB-07)
const userRows = await db.query.users.findMany({ where: /* 租户内 */, columns: { id: true, name: true } });
const fuAgg = await db.select({ userId: leadFollowups.userId, cnt: count() })
.from(leadFollowups).where(/* 时间窗 + 租户 */).groupBy(leadFollowups.userId);
// ...通话聚合、转化聚合、最近跟进时间 同理 GROUP BY,再用 Map 在内存里按 userId 拼装...
两个真实优化点:
Promise.all并行:七个聚合查询之间没有依赖,串行会累积延迟,并行把总耗时压到最慢那一个。- GROUP BY 替代 N+1 :坐席绩效如果按「先查用户列表、再循环为每个用户发一条查询」写,就是经典的 N+1。我改成几条
GROUP BY聚合 + 内存Map拼装,一次扫全表而不是 N 次。
幂等 upsert:任务支持手动重跑------如果某天数据算错了,触发一次补算不会插重复行,而是覆盖:
typescript
const existing = await db.query.leadStatsDaily.findFirst({
where: and(eq(leadStatsDaily.tenantId, tid), eq(leadStatsDaily.statDate, dateStr)),
});
if (existing) {
await db.update(leadStatsDaily).set({ /* 全部字段 */ }).where(eq(leadStatsDaily.id, existing.id));
} else {
await db.insert(leadStatsDaily).values({ id: crypto.randomUUID(), /* 全部字段 */ });
}
一个真实的坑(必讲) :聚合查询里 sum(case when ... then 1 else 0 end) 这种,如果当天的 callRecords 一条都没匹配上(空集),SQLite 的 sum() 会返回 NULL ,而不是 0。而我的表字段是 NOT NULL。我第一次跑的时候,直接 callStat.callCount 当数字用写进去,结果触发 NOT NULL 约束报错、整批失败。后来改成逐字段 ?? 0 兜底------注意 ?? 0 只兜底「缺行」,不兜底「行内有 NULL 字段」,所以每个 answeredCount / noAnswerCount 都得单独兜底。这种边缘 case 不跑一次真发现不了。
四、任务二:blocklist-reconcile 拦截名单对账
这个任务每天扫描全量数据,根据最新的拦截名单刷新每条记录的「是否被拦截」标记,并联动取消其待执行的计划。
用 Set 消除 N+1 :最蠢的写法是对每一条记录去查一次「它在不在拦截名单里」。正确做法是先把名单一次性查出来建一个 Set,然后 O(1) 判断:
typescript
const blRows = await db.query.blocklist.findMany({
where: and(isNull(blocklist.deletedAt),
or(eq(blocklist.scope, "platform"), and(eq(blocklist.scope, "tenant"), eq(blocklist.tenantId, tid)))),
});
const blockedPhones = new Set(blRows.map((r) => r.phone)); // 平台级 + 租户级名单合并
let cursor = 0;
for (;;) {
const batch = await db.query.leads.findMany({
where: and(eq(leads.tenantId, tid), isNull(leads.deletedAt), gte(leads.createdAt, cursor)),
orderBy: [asc(leads.createdAt)], limit: RECONCILE_BATCH, // 1000
});
if (batch.length === 0) break;
for (const lead of batch) {
if (!lead.phone) continue;
const target = blockedPhones.has(lead.phone) ? 1 : 0;
if (target !== lead.isBlocked) { // 仅值变更才写,避免无谓写放大
await db.update(leads).set({ isBlocked: target }).where(eq(leads.id, lead.id));
if (target === 1) {
await db.update(schedules).set({ status: "cancelled" })
.where(and(/* 该线索的待执行计划 */));
}
affected++;
}
processed++;
}
if (batch.length < RECONCILE_BATCH) break;
cursor = batch[batch.length - 1]!.createdAt + 1; // 游标续跑
}
两个细节:
- 游标分页替代 OFFSET :大表用
OFFSET深翻页会越来越慢,我用createdAt游标(where createdAt >= cursor),每批 1000 条,批次末尾的createdAt+1作为下一批起点,断点可续、深翻页稳。 - 只写变更 :
target !== lead.isBlocked才 UPDATE,绝大多数记录标记没变,省下大量写操作。

五、任务三:audit-sweep 审计日志冷热分层归档
审计日志只增不删,时间一长 D1 存储和查询都扛不住。这个任务做分层清理:常规操作保留 365 天,关键操作(导出、擦除、重置密码、登出全部设备等)保留 1825 天(5 年),过期且非关键的转存到 R2 冷归档后从 D1 删除。
原子性铁律------先归档成功,才删源数据:这是整个系统我立得最死的一条规矩。R2 写入失败,宁可跳过这一批、绝不删 D1,绝不能「源没了归档也没成」导致数据丢失:
typescript
const hotThreshold = Math.floor(Date.now() / 1000) - AUDIT_HOT_DAYS * 86400; // 365 天
const criticalThreshold = Math.floor(Date.now() / 1000) - AUDIT_CRITICAL_DAYS * 86400; // 1825 天
const criticalActions = ["lead.export", "lead.erase", "customer.erase", "user.reset-password", "logout-all", "tenant.suspend", "tenant.renew", "user.create"];
for (;;) {
const batch = await db.select().from(auditLogs)
.where(and(gte(auditLogs.createdAt, cursor),
or(
and(not(inArray(auditLogs.action, criticalActions)), lt(auditLogs.createdAt, hotThreshold)),
and(inArray(auditLogs.action, criticalActions), lt(auditLogs.createdAt, criticalThreshold)),
)))
.orderBy(asc(auditLogs.createdAt)).limit(SWEEP_BATCH); // 1000
if (batch.length === 0) break;
for (const row of batch) {
const ndjsonLine = JSON.stringify(row) + "\n";
const key = `audit-archive/${row.tenantId ?? "_platform"}/${month}/${row.id}.ndjson`;
try {
await env.BUCKET.put(key, ndjsonLine); // 先写 R2 冷归档
await db.delete(auditLogs).where(eq(auditLogs.id, row.id)); // 成功后才删 D1
totalArchived++; totalDeleted++;
} catch (e) {
console.error(`[audit-sweep] R2 put failed for ${row.id}:`, e);
totalSkipped++; // R2 失败 → 跳过该条,绝不删源
}
}
if (batch.length < SWEEP_BATCH) break;
cursor = batch[batch.length - 1]!.createdAt + 1;
}
为什么这条铁律重要:如果反过来「先删 D1 再写 R2」,一旦 R2 那一下网络抖了或超限,这条审计记录就永久消失了------而审计日志在很多场景下是合规刚需,丢了是要出事的。归档和删除之间如果不保证顺序,就是在赌 R2 永远不出错。所以顺序必须「先 R2 成功,再删 D1」,R2 挂了就留着 D1 等下轮重试。
month 用 toISOString().slice(0, 7) 取 YYYY-MM,归档路径按租户+月份分目录,方便后续按时间检索或整体删桶。
六、边缘 Cron 的几个心智模型
写完三个任务,回头总结几条边缘 Cron 和单机 cron 最大的不同:
| 维度 | 单机 cron | 边缘 Cron Triggers |
|---|---|---|
| 执行位置 | 固定一台机器 | 平台挑边缘节点,不固定 |
| 触发保证 | 准点一次 | 尽力而为,可能漏跑/重跑 |
| 状态共享 | 本地内存/磁盘 | 必须走 KV/R2/D1,实例无状态 |
| 时长限制 | 看机器,通常很长 | 有单次执行上限,大任务要分批 |
| 互斥保证 | 本地锁即可 | KV 锁非严格,靠幂等兜底 |
所以边缘定时任务的设计主线就两条:幂等 (重跑不脏数据,靠 upsert/游标续跑)+ 分批(大表游标扫、每批 1000 条、R2 失败不删源)。把这两条焊死,漏跑重跑都不怕。
七、小结
边缘 Cron 不是「把 cron 表达式搬上云」那么简单。它逼你重新想清楚:状态放哪(KV/R2/D1)、会不会重跑(幂等 upsert)、大表怎么扫(游标分批)、跨存储操作怎么不丢数据(先归档后删)。我这系统的四个任务,就是用上面那些真实代码一点点磨出来的------尤其是审计归档的原子性铁律和聚合查询的 NULL 兜底,都是线上真踩过才长记性的。
如果你的系统也在用 Cloudflare Workers,定时任务这块建议从第一天就把「执行日志表 + 幂等 + 分批」当成标配,别等半夜被报警叫起来才发现任务静默失败了。
相关阅读
- Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
- Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战
- Serverless 导出 CSV 总超时?用 Queue + R2 异步任务彻底解决
- Node 后端实战 · 多租户 SaaS 的数据隔离
- Node 后端实战 · JWT 双密钥轮转与 token 版本号
- Node 后端实战 · D1 那些坑
- Node 后端实战 · 架构决策全景
- Koa 实现 JWT 会话与鉴权,前后端分离项目通用方案
- MySQL 生产环境备份与恢复完整方案
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!