
Node 后端實戰 · 邊緣 Cron 定時任務怎么寫Cloudflare 三個實戰任務與踩坑各位看官把定時任務跑在邊緣網絡上和我以前在單機crontab或容器里寫個Scheduled是完全兩碼事。以前那套心智模型是「一臺機器一個進程準點跑一次」但 Cloudflare Workers 的 Cron Triggers 是「每天某個時刻平臺在世界各地的邊緣節點挑一個觸發你的 Worker」——你不知道它跑在哪、上一秒的實例還在不在、這一次要跑多久會被掐。我這個多租戶系統有四個每天凌晨跑的定時任務預聚合統計、攔截名單對賬、審計日志冷熱歸檔、導出文件清理。這篇文章把它們的真實實現拆開講重點不是「怎么調 API」而是邊緣環境逼出來的那幾個設計取舍和真實踩過的坑。一、Cron Triggers 長什么樣配置即代碼寫在wrangler.toml里dev 本地不觸發只有 test/prod 注冊# ---- Cron Triggers僅 test/prod 注冊dev 本地不觸發---- [env.prod.triggers] crons [0 0 * * *, 0 1 * * *, 0 2 * * *, 0 3 * * *]四個 cron 表達式分別對應四個任務。入口是 Worker 的scheduled鉤子它把事件分發到我的任務處理器// src/index.tsasyncscheduled(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-aggregate0 點預聚合昨日統計寬表upsert存在則更新可重跑單租戶內并行聚合無大表掃blocklist-reconcile1 點刷新攔截標記 聯動取消計劃僅值變更才寫游標 1000 條/批audit-sweep2 點審計日志冷熱分層 R2 冷歸檔先 R2 后刪 D1失敗跳過游標 1000 條/批export-cleanup3 點清理過期導出文件按 TTL 刪除游標詳見導出篇二、通用骨架日志 鎖 錯誤脫敏四個任務共用一套骨架核心在dispatchCron里exportconstdispatchCronasync(env:Bindings,task:string){constdbgetDb(env);// 分布式鎖KV 讀-寫非原子KV 沒有 CAS純并發下兩個調用可能同時讀到空都進入執行。// Cloudflare Cron 正常不會重復觸發此為 best-effort 防護非嚴格互斥。constlockKeycron:lock:${task};constlockValawaitenv.KV.get(lockKey);if(lockValrunning)return{ok:true,processed:-1,error:already running};awaitenv.KV.put(lockKey,running,{expirationTtl:600});const{logId,startedMs}awaitcronStart(db,task);// 寫 cron_exec_logstry{// ...按 task 名分發...awaitcronEnd(db,logId,startedMs,processed,true);return{ok:true,processed};}catch(e:unknown){constraweinstanceofError?e.message:String(e);constmsgraw.length120?raw.slice(0,120)...:raw;// 脫敏截斷移除 SQL/路徑awaitcronEnd(db,logId,startedMs,0,false,msg).catch((){});return{ok:false,error:cron task failed};}finally{awaitenv.KV.delete(lockKey).catch((){});}};三個設計點值得單獨拎出來執行日志cron_exec_logs每個任務首尾都寫一條記錄開始時間、結束時間、處理條數、耗時、狀態、錯誤信息。定時任務最怕「靜默失敗」——你以為它跑了其實半路掛了。有了這張表運維直接查statusfailed就能知道哪天哪次掛了而不是靠用戶投訴才發現。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把七個無依賴的聚合查詢并行發出去而不是串起來等// 并行執行無依賴的聚合查詢DATA-10const[byStatusRows,catRows,projRows,addedRow,fuRow,convRow,callStatRow]awaitPromise.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:sqlnumbersum(case when${callRecords.answerType} answered then 1 else 0 end),noAnswerCount:sqlnumbersum(case when${callRecords.answerType}! answered then 1 else 0 end),}).from(callRecords).where(/* 時間窗 租戶隔離 */),]);// 坐席績效用 GROUP BY 聚合查詢替代「循環內每條查一次」的 N1 模式DB-07constuserRowsawaitdb.query.users.findMany({where:/* 租戶內 */,columns:{id:true,name:true}});constfuAggawaitdb.select({userId:leadFollowups.userId,cnt:count()}).from(leadFollowups).where(/* 時間窗 租戶 */).groupBy(leadFollowups.userId);// ...通話聚合、轉化聚合、最近跟進時間 同理 GROUP BY再用 Map 在內存里按 userId 拼裝...兩個真實優化點Promise.all并行七個聚合查詢之間沒有依賴串行會累積延遲并行把總耗時壓到最慢那一個。GROUP BY 替代 N1坐席績效如果按「先查用戶列表、再循環為每個用戶發一條查詢」寫就是經典的 N1。我改成幾條GROUP BY聚合 內存Map拼裝一次掃全表而不是 N 次。冪等 upsert任務支持手動重跑——如果某天數據算錯了觸發一次補算不會插重復行而是覆蓋constexistingawaitdb.query.leadStatsDaily.findFirst({where:and(eq(leadStatsDaily.tenantId,tid),eq(leadStatsDaily.statDate,dateStr)),});if(existing){awaitdb.update(leadStatsDaily).set({/* 全部字段 */}).where(eq(leadStatsDaily.id,existing.id));}else{awaitdb.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 消除 N1最蠢的寫法是對每一條記錄去查一次「它在不在攔截名單里」。正確做法是先把名單一次性查出來建一個Set然后 O(1) 判斷constblRowsawaitdb.query.blocklist.findMany({where:and(isNull(blocklist.deletedAt),or(eq(blocklist.scope,platform),and(eq(blocklist.scope,tenant),eq(blocklist.tenantId,tid)))),});constblockedPhonesnewSet(blRows.map((r)r.phone));// 平臺級 租戶級名單合并letcursor0;for(;;){constbatchawaitdb.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.length0)break;for(constleadofbatch){if(!lead.phone)continue;consttargetblockedPhones.has(lead.phone)?1:0;if(target!lead.isBlocked){// 僅值變更才寫避免無謂寫放大awaitdb.update(leads).set({isBlocked:target}).where(eq(leads.id,lead.id));if(target1){awaitdb.update(schedules).set({status:cancelled}).where(and(/* 該線索的待執行計劃 */));}affected;}processed;}if(batch.lengthRECONCILE_BATCH)break;cursorbatch[batch.length-1]!.createdAt1;// 游標續跑}兩個細節游標分頁替代 OFFSET大表用OFFSET深翻頁會越來越慢我用createdAt游標where createdAt cursor每批 1000 條批次末尾的createdAt1作為下一批起點斷點可續、深翻頁穩。只寫變更target ! lead.isBlocked才 UPDATE絕大多數記錄標記沒變省下大量寫操作。五、任務三audit-sweep 審計日志冷熱分層歸檔審計日志只增不刪時間一長 D1 存儲和查詢都扛不住。這個任務做分層清理常規操作保留 365 天關鍵操作導出、擦除、重置密碼、登出全部設備等保留 1825 天5 年過期且非關鍵的轉存到 R2 冷歸檔后從 D1 刪除。原子性鐵律——先歸檔成功才刪源數據這是整個系統我立得最死的一條規矩。R2 寫入失敗寧可跳過這一批、絕不刪 D1絕不能「源沒了歸檔也沒成」導致數據丟失consthotThresholdMath.floor(Date.now()/1000)-AUDIT_HOT_DAYS*86400;// 365 天constcriticalThresholdMath.floor(Date.now()/1000)-AUDIT_CRITICAL_DAYS*86400;// 1825 天constcriticalActions[lead.export,lead.erase,customer.erase,user.reset-password,logout-all,tenant.suspend,tenant.renew,user.create];for(;;){constbatchawaitdb.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);// 1000if(batch.length0)break;for(constrowofbatch){constndjsonLineJSON.stringify(row)\n;constkeyaudit-archive/${row.tenantId??_platform}/${month}/${row.id}.ndjson;try{awaitenv.BUCKET.put(key,ndjsonLine);// 先寫 R2 冷歸檔awaitdb.delete(auditLogs).where(eq(auditLogs.id,row.id));// 成功后才刪 D1totalArchived;totalDeleted;}catch(e){console.error([audit-sweep] R2 put failed for${row.id}:,e);totalSkipped;// R2 失敗 → 跳過該條絕不刪源}}if(batch.lengthSWEEP_BATCH)break;cursorbatch[batch.length-1]!.createdAt1;}為什么這條鐵律重要如果反過來「先刪 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 優化校閱轉發請注明首發地址謝謝大家