Vercel Cron Jobs 是 2023 年底全面开放的定时调度能力:通过在 vercel.json 中声明调度规则,你可以让 Vercel Serverless Functions 在指定时间自动执行,无需维护任何常驻进程或外部 cron 服务。对于数据清理、每日邮件、定时同步等周期性任务,Vercel 原生的 Cron Jobs 是最简单的实现路径。本文覆盖从配置到生产可靠性的完整方案。
一、Vercel Cron Jobs 的定位
1.1 核心能力
- 声明式调度:在
vercel.json中用 cron 表达式声明触发规则 - 零运维:无需配置服务器、systemd、crontab 或外部调度服务
- 函数级触发:定时调用 API Route(Node.js Functions),执行任意业务逻辑
- 日志集成:执行日志自动出现在 Vercel Dashboard → Functions Logs 中
1.2 限制与适用边界
| 维度 | Vercel Cron Jobs | 外部调度器(如 GitHub Actions) |
|---|---|---|
| 最大执行时间 | 普通 Functions 10-60s(取决于计划) | 无限制 |
| 调度精度 | 分钟级(cron 表达式) | 分钟级 |
| 并发任务 | 1 个 | 多个并行 |
| 超时重试 | 不支持(超时即失败) | 多种重试策略 |
| 长任务(>60s) | ❌ 不适合 | ✅ 适合 |
| 适用场景 | 轻量定时任务(清理/通知/同步) | 重型任务(ETL/编译/备份) |
结论:Vercel Cron Jobs 适合执行时间短的周期性任务(< 60s)。超长任务应使用 GitHub Actions、Inngest、QStash 或自建服务器。
二、基础配置
2.1 声明 Cron 规则
在 vercel.json 中配置:
{
"crons": [
{
"path": "/api/cron/daily-report",
"schedule": "0 9 * * *"
},
{
"path": "/api/cron/cleanup",
"schedule": "0 2 * * *"
},
{
"path": "/api/cron/sync-data",
"schedule": "*/15 * * * *"
}
]
}
| 字段 | 说明 |
|---|---|
path | 被定时调用的 API Route |
schedule | 标准 cron 表达式(5 字段:分 时 日 月 周) |
2.2 Cron 表达式速查
| 表达式 | 含义 |
|---|---|
0 9 * * * | 每天上午 9:00 |
0 */6 * * * | 每 6 小时(0:00, 6:00, 12:00, 18:00) |
*/15 * * * * | 每 15 分钟 |
0 2 * * 1 | 每周一凌晨 2:00 |
0 0 1 * * | 每月 1 日午夜 |
0 9 * * 1-5 | 工作日(周一至周五)上午 9:00 |
⚠️ 精度:Vercel Cron 按 UTC 时间执行。如果需要北京时间,需换算(UTC+8)。
2.3 创建被调用的 API Route
// app/api/cron/daily-report/route.ts
import { NextRequest, NextResponse } from 'next/server';
// Vercel Cron 会调用这个路由
export async function GET(request: NextRequest) {
// 安全校验:确保是 Vercel Cron 调用的,而不是外部访问
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// 生成每日报告
await generateDailyReport();
// 发送邮件给管理员
await sendEmailToAdmins('Daily report generated');
return NextResponse.json({
success: true,
message: 'Daily report generated',
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Daily report failed:', error);
return NextResponse.json(
{ error: 'Report generation failed', detail: (error as Error).message },
{ status: 500 }
);
}
}
2.4 Cron Secret 配置
Vercel Cron 调用 API 时会自动带上 Authorization 头:Bearer <CRON_SECRET>。
- 在 Vercel Dashboard → Settings → Environment Variables 中添加:
CRON_SECRET= 生成长随机字符串(openssl rand -hex 32)
- 在 API Route 中校验这个 header
- 这是防止外部直接调用你的 cron 接口的必要安全措施
2.5 Pages Router 兼容写法
// pages/api/cron/cleanup.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
// 清理过期数据
await cleanupOldData();
res.status(200).json({ success: true });
}
三、常见场景实战
3.1 场景一:数据库清理(删除过期数据)
// app/api/cron/cleanup/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// 删除 30 天前的临时数据
const deleted = await prisma.tempUpload.deleteMany({
where: { createdAt: { lt: thirtyDaysAgo } },
});
// 归档已完成的日志
const archived = await prisma.activityLog.updateMany({
where: {
status: 'completed',
createdAt: { lt: thirtyDaysAgo },
archived: false,
},
data: { archived: true },
});
return NextResponse.json({
deletedTempUploads: deleted.count,
archivedLogs: archived.count,
timestamp: new Date().toISOString(),
});
}
// vercel.json
{
"crons": [
{
"path": "/api/cron/cleanup",
"schedule": "0 2 * * *"
}
]
}
3.2 场景二:每日邮件通知
// app/api/cron/daily-email/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 获取需要发送邮件的用户
const users = await getUsersForDailyDigest();
// 分批发送(避免 Rate Limit)
const batchSize = 50;
for (let i = 0; i < users.length; i += batchSize) {
const batch = users.slice(i, i + batchSize);
await Promise.all(
batch.map(user =>
resend.emails.send({
from: 'digest@yourdomain.com',
to: user.email,
subject: `Daily Digest - ${new Date().toLocaleDateString()}`,
html: generateEmailHtml(user),
})
)
);
// 每批间隔 1 秒
await new Promise(r => setTimeout(r, 1000));
}
return NextResponse.json({
sent: users.length,
timestamp: new Date().toISOString(),
});
}
3.3 场景三:外部数据同步
// app/api/cron/sync/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 从第三方 API 同步数据
const externalData = await fetch('https://api.thirdparty.com/v1/data', {
headers: { 'X-API-Key': process.env.THIRD_PARTY_API_KEY! },
}).then(r => r.json());
// 批量写入本地数据库
for (const item of externalData.items) {
await prisma.externalData.upsert({
where: { externalId: item.id },
update: { ...item, lastSyncAt: new Date() },
create: { externalId: item.id, ...item },
});
}
return NextResponse.json({
synced: externalData.items.length,
timestamp: new Date().toISOString(),
});
}
3.4 场景四:生成站点地图(Sitemap)
// app/api/cron/sitemap/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const posts = await prisma.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
});
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://yourdomain.com/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
${posts.map(post => `
<url>
<loc>https://yourdomain.com/blog/${post.slug}</loc>
<lastmod>${post.updatedAt.toISOString().split('T')[0]}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`).join('')}
</urlset>`;
// 写入到 KV / 数据库 / 静态目录
await writeSitemapToStorage(sitemap);
return NextResponse.json({
urlCount: posts.length + 1,
timestamp: new Date().toISOString(),
});
}
四、超时与可靠性保障
4.1 Vercel Functions 超时限制
| 计划 | 超时时间 |
|---|---|
| Hobby | 10 秒 |
| Pro | 60 秒 |
| Enterprise | 可协商(900 秒) |
如果任务超过这个限制,Function 会被强制终止,返回 504 Gateway Timeout。
4.2 可中断任务模式
如果任务天然是批量的,设计为可中断的:
// app/api/cron/process/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const batchSize = 100;
const startTime = Date.now();
const maxDuration = 50000; // 预留 10s 余量(对于 60s 限制)
let processed = 0;
while (Date.now() - startTime < maxDuration) {
const items = await prisma.queue.findMany({
where: { status: 'pending' },
take: batchSize,
});
if (items.length === 0) break;
await Promise.all(
items.map(item => processItem(item.id))
);
processed += items.length;
}
// 记录本次进度,下次 Cron 继续
await prisma.cronLog.create({
data: {
job: 'process-queue',
processed,
completed: Date.now() - startTime < maxDuration,
timestamp: new Date(),
},
});
return NextResponse.json({ processed, timestamp: new Date().toISOString() });
}
4.3 幂等性设计
Cron Job 可能因网络问题或超时重试被多次触发。确保相同任务执行多次不会导致数据问题:
// 用唯一键保证幂等
await prisma.emailLog.upsert({
where: { cronDateUser: { cronDate: today, userId: user.id } },
update: {}, // 已发送过,不重复发送
create: {
cronDate: today,
userId: user.id,
sentAt: new Date(),
},
});
4.4 监控与告警
// 在 Cron 函数结束时主动上报健康状态
import { sendAlert } from '@/lib/alerts';
export async function GET(request: NextRequest) {
try {
// ... 任务逻辑
return NextResponse.json({ success: true });
} catch (error) {
await sendAlert({
service: 'cron-daily-report',
status: 'failed',
error: (error as Error).message,
timestamp: new Date().toISOString(),
});
return NextResponse.json({ error: 'Failed' }, { status: 500 });
}
}
五、与外部调度器对比选择
5.1 GitHub Actions
# .github/workflows/cron.yml
name: Daily Tasks
on:
schedule:
- cron: '0 9 * * *' # UTC 每天早上 9 点
jobs:
run-tasks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run cron:daily
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
| 对比 | Vercel Cron | GitHub Actions Cron |
|---|---|---|
| 执行环境 | Vercel Serverless Functions | GitHub 托管 Ubuntu VM |
| 超时 | 10-60 秒 | 6 小时 |
| 长任务 | ❌ 不适合 | ✅ 适合 |
| 依赖 | 项目依赖 | 需 checkout + install |
| 日志 | Vercel Dashboard | GitHub Actions UI |
| 触发方式 | HTTP GET 到 API Route | 直接运行脚本 |
5.2 Inngest(事件驱动调度)
// Inngest 更适合复杂工作流:步骤依赖、重试、并发控制
import { inngest } from '@/lib/inngest';
export const dailyReport = inngest.createFunction(
{ id: 'daily-report' },
{ cron: '0 9 * * *' },
async ({ step }) => {
const users = await step.run('fetch-users', async () => getActiveUsers());
for (const user of users) {
await step.run(`send-email-${user.id}`, async () => {
await sendEmail(user);
});
}
await step.run('cleanup', async () => cleanupTempFiles());
}
);
Inngest 优势:
- 步骤级重试、断路、限流
- 支持睡眠/延迟(
step.sleep) - 可视化工作流调试
5.3 选择矩阵
| 场景 | 推荐方案 |
|---|---|
| < 60s 的轻量任务(清理/通知) | Vercel Cron Jobs ✅ |
| 60s-6h 的中等任务(ETL/报表) | GitHub Actions |
| 复杂多步骤工作流 | Inngest |
| 需要高可靠性(金融/医疗) | Inngest / 自建 Worker |
| 需要队列能力(削峰填谷) | QStash / Bull MQ |
常见问题(FAQ)
Cron Jobs 在 Vercel Hobby 上可用吗?
可用。所有计划都支持 Cron Jobs,但 Hobby 的 Functions 超时时间为 10 秒,只能执行非常短的任务。
多个 Cron 规则可以调用同一个 API Route 吗?
可以。在 API Route 中通过自定义 header 或查询参数区分:
{
"crons": [
{ "path": "/api/cron/work?job=daily", "schedule": "0 9 * * *" },
{ "path": "/api/cron/work?job=hourly", "schedule": "0 * * * *" }
]
}
Cron 执行失败了怎么看日志?
Vercel Dashboard → 你的项目 → Logs → Functions → 筛选 api/cron/* 路径。每条 Cron 调用的 Function execution 都有独立日志。
可以手动触发一次 Cron 吗?
可以。直接用 curl 访问你的 Cron API(带上 CRON_SECRET):
curl -H "Authorization: Bearer $CRON_SECRET" \
https://yoursite.com/api/cron/daily-report
为什么我的 Cron 没有按时执行?
- 确认
vercel.json已提交并部署到 Vercel(不是只在本地修改) - 检查
vercel.json路径是否正确(与 App Router / Pages Router 路径匹配) - 确认时区:Vercel Cron 按 UTC 执行,
0 9 * * *= UTC 早上 9 点 = 北京下午 5 点 - 查看 Vercel Dashboard → Settings → Cron Jobs 中是否已识别到规则
一个 Cron 规则可以调用多个 Functions 吗?
不能直接调用多个。但可以在被调用的 API Route 中触发多个并发操作:
export async function GET(request: NextRequest) {
// 同时执行多个子任务
await Promise.all([
cleanupOldData(),
sendNotifications(),
syncExternalData(),
]);
return NextResponse.json({ success: true });
}
⚠️ 注意总体执行时间不要超过 Functions 超时限制。
相关阅读
- Vercel 详解:前端与 AI 应用的一站式云平台
- 用 Vercel 部署 Next.js + Postgres SaaS 实战
- Vercel Edge Functions 深度指南
- Vercel ISR 完整指南
- Vercel 定价与成本详解
- Vercel 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。