返回探索
指南VibeFix 编辑部更新于 2026年10月8日

定时任务是 vibe 项目的隐形杀手:从 setInterval 到生产级 cron 的完整实战

每个 vibe 项目迟早需要定时任务:每日数据同步、过期订单清理、账单对账、定时报告。AI 给你的第一个版本通常是 setInterval——开发够用,生产必死。这篇实战给出四种跑法的选型地图(应用内/Vercel Cron/GitHub Actions/Cloudflare),cron 表达式速查与时区坑,幂等性、防重叠分布式锁、失败重试与告警、可观测性 run log,以及 cron 接口的鉴权,最后附上线清单。

深色背景上的时钟与齿轮,象征 vibe 项目的定时任务调度

每个 vibe 项目都会长到需要定时任务的那一天:每天凌晨同步一次第三方数据、每小时清理过期订单、每月 1 号跑账单对账、每周一早上给运营发数据报告。让 AI 写一个,它大概率给你 setInterval——开发环境跑得好好的,上生产第一周就出事:服务一重启任务就丢,多实例部署任务跑 N 遍,任务挂了没人知道。

定时任务是 vibe 项目的隐形杀手:平时感觉不到存在,出问题全是半夜。这篇实战把生产级 cron 拆成七个具体问题:跑在哪、怎么写时间、跑两次怎么办、跑重叠了怎么办、失败了怎么办、怎么知道它跑了、怎么不让外人触发它。

第一问:跑在哪?四种跑法的选型地图

1. 应用内调度(node-cron / node-schedule)。 和业务代码放一起,最简单。适合:单实例、低频、不重要的任务(比如每天生成一次站点地图)。致命缺点:多实例部署时每个实例各跑一遍;服务重启/ redeploy 会打断正在跑的任务。判断标准:这个任务跑两遍会不会出事?会,就别用这种。

2. Vercel Cron / 平台定时任务。 在 vercel.json 里声明,平台按时调你的 HTTP 接口。适合:已部署在 Vercel 的项目、任务能在 60 秒(Hobby)/ 800 秒(Pro maxDuration)内跑完的场景。优点是零运维、和部署一体化;缺点是按调用次数和时长计费,高频任务账单感人,且免费档有频率限制。

3. GitHub Actions schedule。 用 cron 表达式定时跑 workflow,可以调你的 API 或直连数据库。适合:低频(每天/每周)、能接受分钟级延迟抖动的任务,比如每日报告、数据备份校验。优点是免费额度慷慨;缺点是定时不准(高峰期可能延迟几十分钟)、不适合对时间敏感的任务。

4. Cloudflare Cron Triggers / 独立调度服务。 Worker 按 cron 触发,全球边缘运行。适合:高频、要求准时、有全球用户场景。2026 年独立开发者的常见组合是:Cloudflare Cron 触发 + 队列削峰 + 业务 API 执行。

选型一句话:单实例 demo 用应用内;Vercel 项目优先 Vercel Cron;不重要的低频任务扔给 GitHub Actions;其他一律用独立调度 + 队列。

第二问:时间怎么写?cron 表达式速查与三个坑

标准 5 字段:分 时 日 月 周。常用速查:

0 2 * * *      每天凌晨 2:00
*/15 * * * *   每 15 分钟
0 9 * * 1      每周一 9:00
0 0 1 * *      每月 1 号 0:00

坑 1:时区。 绝大多数平台用 UTC。你想「北京时间每天 9 点」发报告,表达式要写 0 1 * * *(UTC 1:00)。AI 生成的代码十个有九个直接写 0 9 * * *,结果报告半夜发出去。更保险的做法:表达式永远写 UTC,注释里写明对应北京时间,夏令时国家还要每年检查两次。

坑 2:*/5 不是「每 5 分钟跑一次」,是「每小时的第 0/5/10…分钟」。 语义一样,但任务耗时超过 5 分钟时,下一次触发会和上一次重叠——直接引出第四问的防重叠。

坑 3:月末。 0 0 31 * * 在没有 31 号的月份直接不跑。月末任务用「下月 1 号 0 点」代替,别赌日历。

第三问:跑两次怎么办?幂等性设计

定时任务一定会被跑两次:平台重试、部署重叠、你手动点了一次又忘了。幂等性不是优化,是入场券。

核心模式:每个任务实例一个唯一键,执行前先占位。

// 每天的对账任务:key 精确到天
const key = `reconcile:${today()}`;   // reconcile:2026-10-08
const claimed = await db.jobRun.upsert({
  where: { key },
  create: { key, status: 'running', startedAt: new Date() },
  update: {},                          // 已存在 → 说明跑过,直接返回
});
if (claimed.status !== 'running' || claimed.startedAt < now) {
  return; // 今天已经跑过(或正在跑),退出
}

关键细节:upsert 的原子性替你挡掉了并发——两个实例同时抢,只有一个能 create 成功。业务层面再加一层保险:状态机。比如发通知任务,通知记录表里 status 从 pending → sending → sent 单向流转,即使任务跑两次,第二次看到 sent 也不会重发。

最危险的反模式:「先查有没有跑过,没有就跑」——查和跑之间有时间窗口,并发一上来照样跑两次。必须用原子操作(upsert / INSERT … ON CONFLICT / Redis SET NX)把「检查」和「占位」合成一步。

第四问:跑重叠了怎么办?分布式锁

和「跑两次」不同,「跑重叠」是上一次还没跑完,下一次触发又来了。5 分钟跑一次的同步任务,某天第三方 API 变慢跑了 8 分钟——两个实例同时写同一批数据,轻则重复,重则写坏。

标准解法:Redis 分布式锁,锁的超时必须大于任务的最大耗时:

const lockKey = 'lock:sync-orders';
const token = crypto.randomUUID();
// SET NX EX:原子占位,10 分钟自动释放(防死锁)
const ok = await redis.set(lockKey, token, 'NX', 'EX', 600);
if (!ok) { console.log('上次还没跑完,跳过'); return; }
try {
  await syncOrders();          // 业务逻辑
} finally {
  // 只释放自己的锁(Lua 脚本保证原子性)
  await redis.eval(
    "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end",
    [lockKey], [token]
  );
}

三个细节:锁超时 > 任务超时(任务 5 分钟,锁给 10 分钟);释放时校验 token,别把别人的锁删了;任务内部再设一个硬超时(如 AbortSignal.timeout),防止无限 hanging 占着锁。

如果你的任务天然支持分片(按用户 id 取模),更优雅的方案是分片并行:N 个 worker 各跑 1/N 的数据,重叠也不怕,因为数据不相交。

第五问:失败了怎么办?重试、死信与告警

定时任务失败有三种姿势,对应三种处理:

1. 瞬时失败(第三方 API 抖了一下):指数退避重试——等 1 分钟、2 分钟、4 分钟,最多 3-5 次。别写固定间隔重试,故障期间固定间隔等于帮倒忙。

2. 持续失败(配置错了、表结构变了):重试 5 次还失败就停手,把现场写进死信记录(参数、错误堆栈、时间),别让它每小时烧一次 API 配额还污染日志。

3. 静默失败(任务根本没被触发):这是最恐怖的——cron 表达式写错、平台没调度,你一个月后才发现报告停了。解法:心跳监控。任务每次成功结束时 ping 一下 healthchecks.io(或自建),超过预期时间没收到心跳就告警。定时任务没有心跳,等于没有任务。

告警通道按严重程度分两级:失败 → 邮件/Slack 通知;连续失败 3 次 → 短信/电话。vibe 项目至少要有第一级——凌晨 3 点的失败,早上 9 点才知道,和永远不知道,区别不大。

第六问:怎么知道它跑了?可观测性 run log

给定时任务建一张 job_runs 表,每次运行记一行:任务名、开始时间、结束时间、状态(success/failed/skipped)、影响行数、耗时、错误信息。管理后台做个最简单的列表页,按时间倒序。

这张表解决三个实际问题:用户问「为什么今天没收到报告」,你 10 秒能查到是没触发还是失败了;失败分析有现场(错误堆栈+参数);容量规划有数据(任务耗时是不是逐月变长)。

日志里再加一行结构化输出,方便 grep:

console.log(JSON.stringify({ job: 'sync-orders', status: 'done',
  durationMs: 42000, affected: 1280, at: new Date().toISOString() }));

第七问:怎么不让外人触发它?鉴权

平台 Cron(Vercel/Cloudflare)调的是你的 HTTP 接口——那是个公开 URL。AI 生成的代码经常把 /api/cron/sync 裸奔在公网上,任何人 curl 一下就能触发你的对账任务。

最小可用方案:共享密钥鉴权。

// Vercel Cron 会自动带上 Authorization: Bearer <CRON_SECRET>
// 自建调度则在请求头里手动带
const auth = req.headers.get('authorization');
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
  return new Response('forbidden', { status: 403 });
}

CRON_SECRET 用 openssl rand -hex 32 生成,存环境变量,别进代码仓库。另外:cron 接口只接受 POST(防浏览器预加载/爬虫误触),返回 200 要快(先接请求、扔队列、立即返回,别让平台等到超时)。

上线检查清单

发布前逐项打勾:

□ 表达式是 UTC,注释写了对应的北京时间;月末任务避开 31 号
□ 每个任务有唯一键 + 原子占位,手动触发两次不会出事
□ 可能重叠的任务加了分布式锁(SET NX EX),锁超时 > 任务超时
□ 任务内部有硬超时,hang 住不会占锁到天荒地老
□ 重试是指数退避、最多 5 次;5 次失败进死信记录并告警
□ 有心跳监控:成功结束必须 ping,超时未收到就告警
□ job_runs 表 + 后台列表页,能查到每次运行的状态和耗时
□ cron 接口有 CRON_SECRET 鉴权、只接受 POST、密钥不在仓库里
□ 在 staging 把系统时间调快(或手动触发)完整跑过一遍全流程

一句话总结:定时任务的七个问题里,「跑两次怎么办」和「没跑怎么知道」是最先要解决的两个——前者保正确,后者保命。setInterval 留在 demo 里,生产环境请用正经调度 + 幂等键 + 心跳,半夜才能睡得着。

浏览项目广场发布你的项目

相关文章

API 网关流量控制与请求限流的抽象示意,象征对后端服务的保护
指南
一夜被脚本刷掉 300 美元:vibe 项目的 API 限流与配额实战

公网上的每个接口都会在某个深夜被超预期调用。这篇实战为一人团队搭建限流体系:算法选型(滑动窗口 vs 令牌桶)、四层防御、AI 接口烧钱专项防护、配额设计、429 响应规范、误伤排查,最后附上线检查清单。

后端工程安全与隐私部署上线
深色错误监控仪表盘界面,象征 vibe 项目的错误追踪与崩溃上报体系
指南
上线第一天用户白屏了你却最后一个知道:vibe 项目的错误监控与崩溃上报实战

每个 vibe 项目都会经历同一个黑色幽默时刻:网站白屏了,朋友比你的监控先告诉你。这篇实战为一人团队搭建完整错误监控体系:5 分钟 Sentry 最小闭环、错误边界、上报上下文设计、后端结构化日志、AI 调用专项防护、告警分级降噪,最后附上线检查清单。

调试排错后端工程部署上线
数据中心机房里的服务器与网线,象征 vibe 项目的缓存架构与性能优化
指南
缓存是 vibe 项目 ROI 最高的性能手段,也是 bug 最多的地方:一份从浏览器到 AI 结果的完整实战

每个 vibe 项目迟早会遇到同一个时刻:列表页一打开就要查十几次库,并发稍高数据库就被打满。这篇实战从缓存的三问心智模型讲起,逐层拆解 HTTP 缓存头、Next.js 数据缓存、Redis 应用缓存与 AI 结果缓存(语义缓存/prompt 缓存),给出缓存键设计、穿透击穿雪崩的三件套解法和失效策略,最后附一份上线检查清单。

后端工程性能优化独立开发