Vercel Cron Jobs 完全指南:定时任务配置、场景实战与可靠性保障

详解 Vercel Cron Jobs 的完整用法:vercel.json 配置语法、Serverless Functions 定时触发、常见场景(数据清理/邮件发送/数据同步)、超时与错误处理、与 GitHub Actions / Inngest / QStash 等外部调度器的对比选择。含完整代码示例。

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>

  1. 在 Vercel Dashboard → Settings → Environment Variables 中添加:
    • CRON_SECRET = 生成长随机字符串(openssl rand -hex 32
  2. 在 API Route 中校验这个 header
  3. 这是防止外部直接调用你的 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 超时限制

计划超时时间
Hobby10 秒
Pro60 秒
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 CronGitHub Actions Cron
执行环境Vercel Serverless FunctionsGitHub 托管 Ubuntu VM
超时10-60 秒6 小时
长任务❌ 不适合✅ 适合
依赖项目依赖需 checkout + install
日志Vercel DashboardGitHub 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 没有按时执行?

  1. 确认 vercel.json 已提交并部署到 Vercel(不是只在本地修改)
  2. 检查 vercel.json 路径是否正确(与 App Router / Pages Router 路径匹配)
  3. 确认时区:Vercel Cron 按 UTC 执行,0 9 * * * = UTC 早上 9 点 = 北京下午 5 点
  4. 查看 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 超时限制。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「saas」更多文章