后台任务与队列

有些操作不能在请求-响应周期内完成——生成 PDF 报告、批量发送邮件、处理大文件、调用多个 AI 模型。这些任务需要在后台异步执行。本章讲清 Serverless 环境下后台任务的挑战和解决方案。

1. 为什么需要后台任务

1.1 问题

Serverless 函数(Vercel Functions / Cloudflare Workers)有执行时间限制

平台免费版Pro 版
Vercel Serverless10 秒60 秒
Vercel Edge25 秒25 秒
Cloudflare Workers10ms CPU50ms CPU

以下操作在时间限制内完不成:

  • AI 长对话:GPT-4o 生成长回复可能需要 30-60 秒
  • 批量处理:给 1000 个用户发送月度报告
  • 文件处理:解析和索引上传的 PDF 文档
  • 数据同步:从第三方 API 同步大量数据

1.2 解决思路

把长时间任务从 HTTP 请求中剥离:

同步流程(不好):
  用户请求 → 处理 30 秒 → 超时 ❌

异步流程(推荐):
  用户请求 → 创建任务(ms)→ 立刻返回 ✅
             └→ 后台处理 → 完成后通知用户

2. 方案选型

方案特点适合场景
Inngest事件驱动、可视化、自动重试工作流编排、定时任务
Trigger.dev代码优先、TypeScript 原生复杂后台任务
Vercel Cron简单定时任务每日清理、定时报告
BullMQ + Redis自托管队列需要完全控制
Upstash QStashServerless HTTP 消息队列简单异步调用

对于 Next.js + Vercel 部署的项目,推荐 Inngest(事件驱动,和 Next.js 集成最好)或 Trigger.dev(TypeScript 原生,调试体验好)。

3. Inngest 集成

3.1 安装与配置

pnpm add inngest
// inngest/client.ts — 创建 Inngest 客户端
import { Inngest } from 'inngest'
 
export const inngest = new Inngest({
  id: 'ai-chat-app',
  schemas: new EventSchemas(),
})
// app/api/inngest/route.ts — 注册 Inngest 路由
import { serve } from 'inngest/next'
import { inngest } from '@/inngest/client'
import { generateReport, syncUserData, processDocument } from '@/inngest/functions'
 
export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [generateReport, syncUserData, processDocument],
})

3.2 定义函数

Inngest 的函数由事件触发,支持自动重试、步骤编排、超时控制:

// inngest/functions/generate-report.ts
import { inngest } from '../client'
 
export const generateReport = inngest.createFunction(
  {
    id: 'generate-monthly-report',
    retries: 3,                  // 失败自动重试 3 次
    throttle: { limit: 5, period: '1m' }, // 每分钟最多 5 个并发
  },
  { event: 'report/generate.requested' },
 
  async ({ event, step }) => {
    const { workspaceId, month } = event.data
 
    // Step 1: 查询数据(每个 step 独立可重试)
    const stats = await step.run('fetch-stats', async () => {
      return await db.query.tokenUsages.findMany({
        where: and(
          eq(tokenUsages.workspaceId, workspaceId),
          sql`DATE_TRUNC('month', ${tokenUsages.createdAt}) = ${month}`,
        ),
      })
    })
 
    // Step 2: 生成 PDF
    const pdfUrl = await step.run('generate-pdf', async () => {
      const pdf = await renderReport(stats)
      return await uploadToS3(`reports/${workspaceId}/${month}.pdf`, pdf)
    })
 
    // Step 3: 发送通知邮件
    await step.run('send-email', async () => {
      await sendEmail({
        to: event.data.email,
        subject: `${month} 月度报告`,
        body: `您的报告已生成:${pdfUrl}`,
      })
    })
 
    return { pdfUrl }
  },
)

每个 step.run() 是一个独立的执行单元:

  • 如果 Step 2 失败,只重试 Step 2,不会重新执行 Step 1
  • 每个 Step 的结果会被持久化,重试时直接跳过已完成的步骤

3.3 触发事件

在 Server Action 或 Route Handler 中触发:

// actions/report.ts
'use server'
 
export async function requestReport(month: string) {
  const { workspace } = await requireWorkspaceAccess()
 
  await inngest.send({
    name: 'report/generate.requested',
    data: {
      workspaceId: workspace.id,
      month,
      email: (await auth())!.user.email,
    },
  })
 
  return { success: true, message: '报告生成中,完成后将发送到您的邮箱' }
}

3.4 定时任务

Inngest 支持 cron 触发——替代 Vercel Cron:

export const dailyCleanup = inngest.createFunction(
  { id: 'daily-cleanup' },
  { cron: '0 3 * * *' },  // 每天凌晨 3 点
 
  async ({ step }) => {
    // 清理 30 天前的临时文件
    await step.run('cleanup-temp-files', async () => {
      await db.delete(tempFiles).where(
        lt(tempFiles.createdAt, sql`NOW() - INTERVAL '30 days'`),
      )
    })
 
    // 更新 Token 使用统计
    await step.run('update-daily-stats', async () => {
      await updateDailyTokenStats()
    })
  },
)

4. Trigger.dev

4.1 核心特点

Trigger.dev 和 Inngest 类似,但更强调 TypeScript 原生体验本地调试

  • 任务代码就是普通 TypeScript 函数
  • 本地 npx trigger.dev dev 直接调试
  • 自带 Dashboard 查看任务执行日志
  • 支持长时间运行(最长 5 分钟免费,Pro 可到数小时)

4.2 定义任务

// trigger/process-document.ts
import { task } from '@trigger.dev/sdk/v3'
 
export const processDocument = task({
  id: 'process-document',
  maxDuration: 300, // 最长 5 分钟
  retry: { maxAttempts: 3 },
 
  run: async (payload: { documentId: string; workspaceId: string }) => {
    // 1. 下载文档
    const document = await downloadFromS3(payload.documentId)
 
    // 2. 提取文本
    const text = await extractText(document)
 
    // 3. 生成嵌入向量
    const embeddings = await generateEmbeddings(text)
 
    // 4. 存入向量数据库
    await storeEmbeddings(payload.workspaceId, payload.documentId, embeddings)
 
    return { chunks: embeddings.length }
  },
})

4.3 触发任务

import { processDocument } from '@/trigger/process-document'
 
export async function uploadDocument(formData: FormData) {
  // ... 上传文件到 S3
 
  // 触发后台处理
  await processDocument.trigger({
    documentId: doc.id,
    workspaceId: workspace.id,
  })
 
  return { success: true, message: '文档正在处理中' }
}

5. Vercel Cron(简单定时任务)

如果只需要简单的定时任务,不需要引入 Inngest/Trigger.dev:

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/daily-cleanup",
      "schedule": "0 3 * * *"
    },
    {
      "path": "/api/cron/sync-quotas",
      "schedule": "*/30 * * * *"
    }
  ]
}
// app/api/cron/daily-cleanup/route.ts
export async function GET(request: Request) {
  // 验证是 Vercel Cron 调用(防止外部触发)
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  await cleanupExpiredSessions()
  await updateDailyStats()
 
  return Response.json({ success: true })
}
限制

Vercel Cron 本质上就是定时调用一个 Route Handler——受限于 Serverless 函数的 60 秒超时。超过 60 秒的任务仍需要 Inngest/Trigger.dev。

6. Webhook 处理

Webhook 是外部服务主动调用你的 API——支付回调(Stripe)、消息通知(Slack)、代码推送(GitHub)。本质上就是一个 Route Handler,但需要额外的安全校验。

6.1 Stripe Webhook

// app/api/webhook/stripe/route.ts
import Stripe from 'stripe'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
 
export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!
 
  // 1. 验证签名(防止伪造请求)
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch {
    return Response.json({ error: 'Invalid signature' }, { status: 400 })
  }
 
  // 2. 根据事件类型处理
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      await activateSubscription(session.customer as string, session.subscription as string)
      break
    }
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      await deactivateSubscription(subscription.id)
      break
    }
    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice
      await notifyPaymentFailed(invoice.customer as string)
      break
    }
  }
 
  // 3. 必须返回 200,否则 Stripe 会重试
  return Response.json({ received: true })
}

6.2 Webhook 最佳实践

  • 验证签名:每个 Webhook 提供方都有签名机制,必须校验
  • 幂等处理:Webhook 可能重复发送,用事件 ID 去重
  • 快速返回:先返回 200,再异步处理。耗时操作用 Inngest/Trigger.dev
  • 日志记录:记录每次 Webhook 的 payload,方便排查问题
// 幂等处理:用事件 ID 去重
const processed = await db.query.webhookEvents.findFirst({
  where: eq(webhookEvents.eventId, event.id),
})
if (processed) return Response.json({ received: true }) // 已处理,跳过
 
// 记录事件
await db.insert(webhookEvents).values({ eventId: event.id, type: event.type, payload: body })
 
// 处理业务逻辑...

本章小结

  • Serverless 有超时限制:长时间任务必须异步化
  • Inngest:事件驱动 + 步骤编排 + 自动重试,推荐用于工作流
  • Trigger.dev:TypeScript 原生 + 本地调试,推荐用于复杂任务
  • Vercel Cron:简单定时任务,受 60 秒超时限制
  • Webhook:验证签名 + 幂等处理 + 快速返回 + 异步处理

下一章讲 Edge Runtime 与 Middleware。