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

每个 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 里,生产环境请用正经调度 + 幂等键 + 心跳,半夜才能睡得着。
相关文章

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

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

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