Next.js 主要是一个前端框架,但在实际项目中,我们经常需要在同一个应用里跑一些后台定时任务——比如定期同步第三方数据、清理过期缓存、发送通知邮件等。我们在项目中用 node-cron 实现了这个需求,过程中踩了不少坑,这里做个总结。
基本方案:node-cron
最直接的方案是使用 node-cron,在 Next.js 服务启动时注册定时任务:
import cron from 'node-cron';
// 每天凌晨 3 点执行
cron.schedule('0 3 * * *', async () => {
await syncExternalData();
});
看起来很简单,但直接这样写会遇到几个问题。
问题一:开发模式下重复初始化
Next.js 开发模式会热重载模块,这意味着你的定时任务可能被注册多次。每次代码变更,node-cron 又创建一个新的调度器,结果同一个任务在同一时刻触发好几次。
解决方案是用全局标志位防止重复初始化:
// lib/cron.ts
let isInitialized = false;
export function initCronJobs() {
if (isInitialized) return;
isInitialized = true;
cron.schedule('0 3 * * *', async () => {
console.log('[Cron] 开始同步数据...');
await syncExternalData();
});
}
在生产环境中,可以利用 Node.js 的 global 对象来持久化这个标志:
const globalForCron = globalThis as typeof globalThis & {
cronInitialized?: boolean;
};
export function initCronJobs() {
if (globalForCron.cronInitialized) return;
globalForCron.cronInitialized = true;
// ...注册任务
}
问题二:启动时机
定时任务不应该在模块加载时立即执行。如果数据库连接还没建立,或者环境变量还没加载完成,任务执行就会失败。
我们采用延迟启动的策略:
export function initCronJobs() {
if (globalForCron.cronInitialized) return;
globalForCron.cronInitialized = true;
// 延迟 30 秒,确保服务完全就绪
setTimeout(() => {
registerAllJobs();
console.log('[Cron] 所有定时任务已注册');
}, 30_000);
}
触发初始化的位置也很关键。我们选择在 instrumentation.ts(Next.js 14+ 支持)中调用:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initCronJobs } = await import('./lib/cron');
initCronJobs();
}
}
注意检查 NEXT_RUNTIME === 'nodejs',避免在 Edge Runtime 中执行。
问题三:任务执行的安全防护
定时任务最怕的是:上一次还没执行完,下一次又开始了。特别是涉及外部 API 调用的任务,一旦对方响应变慢,任务堆积会迅速耗尽资源。
加一个执行锁:
let isRunning = false;
async function safeExecute(taskName: string, fn: () => Promise<void>) {
if (isRunning) {
console.log(`[Cron] ${taskName} 仍在执行中,跳过本次`);
return;
}
isRunning = true;
try {
await fn();
} catch (error) {
console.error(`[Cron] ${taskName} 执行失败:`, error);
} finally {
isRunning = false;
}
}
问题四:通过 API 手动触发
有时你需要手动触发一次定时任务(比如调试或数据修复)。我们为每个任务暴露一个内部 API 端点,用密钥保护:
// app/api/cron/sync/route.ts
export async function POST(request: Request) {
const secret = request.headers.get('x-cron-secret');
if (secret !== process.env.CRON_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
await syncExternalData();
return Response.json({ success: true });
}
这个端点也可以对接 Vercel Cron 或外部调度服务,作为定时触发的替代方案。
问题五:内存管理
如果定时任务中有缓存或状态积累(比如一个简易的 Rate Limiter),需要定期清理,否则长时间运行后内存会持续增长:
const cache = new Map<string, { value: any; expiry: number }>();
// 每 10 分钟清理过期条目
cron.schedule('*/10 * * * *', () => {
const now = Date.now();
for (const [key, entry] of cache) {
if (entry.expiry < now) {
cache.delete(key);
}
}
});
什么时候不该用这个方案
这个方案适合单实例部署的场景。如果你的应用部署了多个实例(比如 Kubernetes 多副本),每个实例都会启动自己的定时任务,导致重复执行。这种情况下应该:
- 使用专门的任务队列(如 BullMQ + Redis)
- 使用外部调度服务(如 Vercel Cron、AWS EventBridge)
- 使用分布式锁(如 Redis SETNX)
总结
在 Next.js 中跑定时任务并不复杂,但细节决定成败。核心要点是:防重复初始化、延迟启动、执行锁、密钥保护的手动触发端点、以及内存清理。对于中小规模的单实例应用,这套方案足够可靠;规模扩大后再考虑迁移到专业的任务队列系统。