Node 后端实战 · 边缘 Cron 定时任务怎么写?Cloudflare 三个实战任务与踩坑

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(() => {});
  }
};

三个设计点值得单独拎出来:

  1. 执行日志 cron_exec_logs :每个任务首尾都写一条记录(开始时间、结束时间、处理条数、耗时、状态、错误信息)。定时任务最怕「静默失败」------你以为它跑了,其实半路挂了。有了这张表,运维直接查 status='failed' 就能知道哪天哪次挂了,而不是靠用户投诉才发现。
  2. KV 分布式锁是 best-effort :锁用 KV.put(key, "running", { expirationTtl: 600 }),600 秒自动过期兜底。但注释里写得很诚实------KV 没有 CAS(比较并交换),读-写非原子,理论上并发时两个调用都能读到空值、都进入执行。Cloudflare Cron 正常不会重复触发,所以这把锁只是兜底,不能当严格互斥用。真要强一致互斥得用 Durable Objects,但为了防一个几乎不会发生的重跑去引入 DO,不划算。
  3. 错误信息脱敏 :异常堆栈里可能含 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 等下轮重试。

monthtoISOString().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,定时任务这块建议从第一天就把「执行日志表 + 幂等 + 分批」当成标配,别等半夜被报警叫起来才发现任务静默失败了。


相关阅读

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
未来和明天11 小时前
领嵌4G/5GAI边缘计算盒子多路视频算力高达10.4Tops兼容Modbus、DLT645、OPC UA等多种行业协议
人工智能·5g·边缘计算
鲁邦通物联网14 小时前
充电站底层脱机DLB引擎解构:基于Linux调度的边缘计算网关并发控制实战
linux·运维·人工智能·边缘计算·边缘计算网关·5g数采·工业级边缘计算网关
土星云SaturnCloud1 天前
产线SOP动作级AI监管方案:土星云SE110S-WC8赋能合规识别、预警与效率分析
大数据·服务器·人工智能·ai·边缘计算
ai产品老杨1 天前
边缘计算盒子部署常见问题和排查清单
人工智能·边缘计算
FungLeo2 天前
Node 后端实战 · 老系统数据迁移怎么不出乱子?V1→V2 重构实战与 3 个生产坑
node.js·serverless·数据库迁移·d1 数据库
ai产品老杨2 天前
边缘计算盒子部署参数配置说明
人工智能·边缘计算
fthux2 天前
边缘计算:从概念到实践的全景解读
人工智能·边缘计算
FungLeo2 天前
Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
数据库·node.js·serverless·dsl 封装·通用列表查询
鲁邦通物联网3 天前
充电站柔性负荷架构演进:网络卡顿导致设备烧损,如何依托边缘计算网关重塑本地调功防线?
人工智能·边缘计算·边缘计算网关·物联网网关·5g数采·边缘计算盒子·工业级边缘计算网关