后台任务与队列
有些操作不能在请求-响应周期内完成——生成 PDF 报告、批量发送邮件、处理大文件、调用多个 AI 模型。这些任务需要在后台异步执行。本章讲清 Serverless 环境下后台任务的挑战和解决方案。
1. 为什么需要后台任务
1.1 问题
Serverless 函数(Vercel Functions / Cloudflare Workers)有执行时间限制:
| 平台 | 免费版 | Pro 版 |
|---|---|---|
| Vercel Serverless | 10 秒 | 60 秒 |
| Vercel Edge | 25 秒 | 25 秒 |
| Cloudflare Workers | 10ms CPU | 50ms CPU |
以下操作在时间限制内完不成:
- AI 长对话:GPT-4o 生成长回复可能需要 30-60 秒
- 批量处理:给 1000 个用户发送月度报告
- 文件处理:解析和索引上传的 PDF 文档
- 数据同步:从第三方 API 同步大量数据
1.2 解决思路
把长时间任务从 HTTP 请求中剥离:
同步流程(不好):
用户请求 → 处理 30 秒 → 超时 ❌
异步流程(推荐):
用户请求 → 创建任务(ms)→ 立刻返回 ✅
└→ 后台处理 → 完成后通知用户
2. 方案选型
| 方案 | 特点 | 适合场景 |
|---|---|---|
| Inngest | 事件驱动、可视化、自动重试 | 工作流编排、定时任务 |
| Trigger.dev | 代码优先、TypeScript 原生 | 复杂后台任务 |
| Vercel Cron | 简单定时任务 | 每日清理、定时报告 |
| BullMQ + Redis | 自托管队列 | 需要完全控制 |
| Upstash QStash | Serverless 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。