Background jobs

定时任务

使用 Inngest Cron 函数或 Vercel Cron 调度周期性后台任务——包含安全重运行的幂等性模式。

概述

Nebutra 支持两种定期任务调度方式:

  • Inngest Cron — 推荐用于需要步骤函数、重试和可观测性的任务
  • Vercel Cron — 简单的 HTTP 触发调度,适合轻量任务或触发 Inngest 事件

Inngest Cron 函数

直接在 Inngest 函数上使用标准 Cron 语法定义调度:

import { inngest } from "@nebutra/event-bus";

export const weeklyUsageReport = inngest.createFunction(
  { id: "weekly-usage-report", retries: 2 },
  { cron: "0 9 * * MON" },   // 每周一 09:00 UTC
  async ({ step }) => {
    const tenants = await step.run("fetch-active-tenants", async () => {
      return db.tenant.findMany({ where: { status: "active" } });
    });

    // 扇出:并行为每个租户生成报表
    await step.run("generate-reports", async () => {
      return Promise.all(
        tenants.map((tenant) =>
          generateUsageReport(tenant.id, "weekly")
        )
      );
    });
  }
);

Cron 语法参考

┌─────────── 分钟(0–59)
│ ┌───────── 小时(0–23)
│ │ ┌─────── 日(1–31)
│ │ │ ┌───── 月(1–12)
│ │ │ │ ┌─── 星期几(0–6,0 = 周日)
│ │ │ │ │
* * * * *

常用调度表达式:

表达式含义
0 9 * * MON每周一 09:00 UTC
0 0 1 * *每月 1 日午夜
0 * * * *每小时整点
*/15 * * * *每 15 分钟
0 9 * * 1-5工作日 09:00 UTC

所有 Inngest Cron 时间均为 UTC。如果用户期望在特定本地时间执行任务,请在调度前转换时区。

Vercel Cron

对于只需要定时触发某个接口的简单任务,可在 vercel.json 中配置 Vercel Cron:

{
  "crons": [
    {
      "path": "/api/v1/cron/quota-reset",
      "schedule": "0 0 1 * *"
    },
    {
      "path": "/api/v1/cron/cleanup-expired-sessions",
      "schedule": "0 3 * * *"
    }
  ]
}

Vercel Cron 运行器通过带认证的 GET 请求调用你的接口。使用 Authorization 请求头验证请求来自 Vercel:

// backends/gateway/src/routes/cron.ts
import { Hono } from "hono";

const cron = new Hono();

cron.get("/quota-reset", async (c) => {
  const authHeader = c.req.header("Authorization");
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  // 触发 Inngest 事件而不是内联执行繁重任务
  await inngest.send({ name: "quota/monthly-reset", data: {} });

  return c.json({ ok: true });
});

在 Vercel 环境变量中将 CRON_SECRET 设置为随机密钥。

建议从 Cron 接口触发 Inngest 事件,而不是内联执行繁重任务。这样可以免费获得重试、步骤隔离和可观测性。

常见定时任务

每月配额重置

export const monthlyQuotaReset = inngest.createFunction(
  { id: "monthly-quota-reset", retries: 3 },
  { cron: "0 0 1 * *" },   // 每月 1 日午夜 UTC
  async ({ step }) => {
    await step.run("reset-all-quotas", async () => {
      return db.tenantQuota.updateMany({
        data: { usedTokens: 0, usedApiCalls: 0 },
      });
    });
  }
);

每日清理过期数据

export const dailyCleanup = inngest.createFunction(
  { id: "daily-cleanup", retries: 2 },
  { cron: "0 3 * * *" },   // 每日 UTC 03:00
  async ({ step }) => {
    await step.run("delete-expired-sessions", async () => {
      return db.session.deleteMany({
        where: { expiresAt: { lt: new Date() } },
      });
    });

    await step.run("delete-expired-exports", async () => {
      const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 天前
      return db.export.deleteMany({
        where: { createdAt: { lt: cutoff }, status: "completed" },
      });
    });
  }
);

每周用量报表

export const weeklyReport = inngest.createFunction(
  { id: "weekly-report-dispatch", retries: 2 },
  { cron: "0 9 * * MON" },
  async ({ step }) => {
    const tenants = await step.run("get-pro-tenants", async () => {
      return db.tenant.findMany({ where: { plan: { in: ["PRO", "ENTERPRISE"] } } });
    });

    // 每个租户发送一个事件——各自独立处理
    await step.run("emit-report-events", async () => {
      return inngest.send(
        tenants.map((t) => ({
          name: "report/weekly-requested",
          data: { tenantId: t.id },
        }))
      );
    });
  }
);

幂等性

在极端情况下(时钟偏差、部署重试),定时任务可能会运行多次。始终将定时任务处理器编写为幂等的——运行两次与运行一次产生相同结果。

使用 upsert 而非 insert

// ❌ 非幂等——重新运行时会创建重复记录
await db.report.create({ data: { tenantId, period: "2025-03", ... } });

// ✅ 幂等——第二次运行更新已有行
await db.report.upsert({
  where: { tenantId_period: { tenantId, period: "2025-03" } },
  create: { tenantId, period: "2025-03", ... },
  update: { updatedAt: new Date() },
});

使用唯一执行 ID 加锁

export const monthlyReset = inngest.createFunction(
  { id: "monthly-quota-reset" },
  { cron: "0 0 1 * *" },
  async ({ event, step }) => {
    const lockKey = `quota-reset:${new Date().toISOString().slice(0, 7)}`; // 例如 "quota-reset:2025-03"

    await step.run("acquire-lock-and-reset", async () => {
      const existing = await db.jobLock.findUnique({ where: { key: lockKey } });
      if (existing) return { skipped: true };  // 本月已执行

      await db.jobLock.create({ data: { key: lockKey, createdAt: new Date() } });
      await resetAllQuotas();
    });
  }
);

避免将繁重任务的调度频率设置得太高(每 15 分钟以内)。高频定时任务可能耗尽 Inngest 套餐的运行配额,并在大规模场景下产生意外费用。

本地测试定时任务

使用 Inngest Dev Server 按需触发定时函数,无需等待调度时间:

# 启动 Dev Server
npx inngest-cli@latest dev -u http://localhost:3001/api/v1/inngest

然后打开 http://localhost:8288,导航到你的函数并点击 Trigger。也可以通过 CLI 发送手动触发:

npx inngest-cli@latest trigger --event "inngest/scheduled.timer" --function "weekly-usage-report"

相关文档

How is this guide?

目录