17.01-为什么需要异步任务
要点
- Workers 有严格的 CPU 时间限制——免费计划 10ms,付费计划 30 秒,很多 AI 任务跑不完
- HTTP 请求有超时限制——客户端、代理、负载均衡器都会在 30-60 秒断开连接
- 同步处理大文件、批量任务、外部 API 调用会导致用户体验极差
- 异步任务把「接收请求」和「执行任务」解耦——请求立刻返回 202,任务在后台慢慢跑
- 异步任务带来新的挑战:状态追踪、失败重试、进度通知、结果获取
- Workers 环境没有 Redis,但有 Cloudflare Queue、D1、KV 可以组合出完整的异步任务系统
1. 先碰到问题
前面十几章讲了很多 AI 能力:PDF 解析、图片 OCR、音频转文本、RAG 向量化。这些能力有一个共同特点——慢。
一个 50MB 的 PDF 解析需要十几秒。一段 10 分钟的音频转文本可能要几十秒。给 100 份文档生成 embedding 可能要几分钟。如果把这些操作放在 HTTP 请求里同步执行,会碰到三个硬限制。
Workers CPU 时间限制
Cloudflare Workers 运行在 V8 Isolate 上,每次请求有 CPU 时间上限:
- 免费计划:10 毫秒
- 付费计划:30 秒
注意这是 CPU 时间,不是墙钟时间。网络 IO(等外部 API 响应)不算 CPU 时间,但本地计算(解析 PDF、处理图片)算。
一个 50MB 的 PDF 解析,纯 CPU 时间可能就超过 30 秒。在 Workers 里同步跑,请求会被强制中断。
HTTP 超时限制
即使 CPU 时间够用,HTTP 连接也有超时:
- 浏览器默认超时:通常 60-120 秒
- Nginx/代理超时:通常 60 秒
- Cloudflare 代理超时:100 秒(免费计划)
- 客户端网络波动:用户不会等半分钟
用户在浏览器里上传一个文件,等了 30 秒没响应,大概率已经关掉页面了。即使后台还在处理,用户也看不到了。
并发阻塞
同步处理还有一个问题:占着 Worker 实例不放。Worker 在处理请求期间无法服务其他用户。如果同时有 10 个用户上传大文件,所有 Worker 实例都在跑 PDF 解析,新用户请求排队等待。
2. 同步 vs 异步
对比两种处理方式:
同步处理:
用户 → 上传文件 → Worker 解析 → Worker OCR → Worker 生成摘要 → 返回结果
(10 秒) (15 秒) (10 秒)
总耗时:35 秒,用户一直等着,可能超时
异步处理:
用户 → 上传文件 → Worker 接收 → 返回 taskId → 用户离开
↓
后台 Worker 解析 → OCR → 生成摘要 → 存入数据库
(10 秒) (15 秒) (10 秒)
用户通过 taskId 查询进度,完成后下载结果
异步处理的核心思想:把「接收请求」和「执行任务」拆成两件事。
- HTTP 请求只做接收:校验文件、存到 R2、创建任务记录、发消息到队列,然后立刻返回
- 后台消费者慢慢处理:从队列取消息、读 R2 文件、执行解析/OCR/摘要、把结果写入数据库
- 前端定期查询:通过 taskId 轮询任务状态,或者用 SSE 接收进度推送
3. 哪些场景需要异步
不是所有请求都需要异步。简单查询、小文件上传、单条记录 CRUD,同步处理完全够用。需要异步的场景有明确的特征:
耗时计算
| 操作 | 典型耗时 | 是否需要异步 |
|---|---|---|
| 查询数据库 | < 100ms | 否 |
| 上传小文件(< 1MB) | < 1s | 否 |
| 解析 PDF(50 页) | 5-30s | 是 |
| 图片 OCR | 2-10s | 视情况 |
| 音频转文本(10 分钟) | 30-120s | 是 |
| 生成 embedding(100 条) | 10-30s | 是 |
| 批量 LLM 调用(50 条) | 30-180s | 是 |
经验法则:如果一个操作可能超过 5 秒,考虑异步。超过 30 秒,必须异步。
批量处理
用户上传 100 份文档做 RAG 索引。每份文档需要解析、切 chunk、生成 embedding、写入向量库。即使用户愿意等单份文档的处理时间,100 份串行处理也要等很久。
异步 + 队列可以并发处理多份文档,用户提交后拿到 batchId,前端展示每份文档的独立进度。
外部 API 调用
调 LLM API、OCR API、翻译 API,外部服务的响应时间不可控。API 限流时延迟更高。把外部调用放到异步任务里,可以:
- 控制并发,避免触发限流
- 失败自动重试,指数退避
- 超时不影响用户体验
定时任务
每天凌晨清理过期数据、每小时汇总 token 用量、每周生成报告。这些任务没有用户请求触发,需要定时调度。
Workers 用 Cron Triggers 实现定时任务——按 cron 表达式触发 scheduled() 方法。
4. 异步任务的挑战
异步解决了超时和体验问题,但引入了新的复杂度:
状态追踪
同步请求里,状态在 HTTP 连接中。异步任务没有持久连接,任务状态需要显式存储:
- 任务是否已开始?
- 当前执行到哪个阶段?
- 成功了还是失败了?
- 结果存在哪里?
这些状态需要写入数据库(D1)或 KV,前端通过查询获取。
失败重试
同步请求失败,用户刷新页面重试。异步任务失败,需要自动重试机制:
- 哪些错误值得重试?(网络超时、API 限流——是;文件格式错误——否)
- 重试间隔多长?(立即重试会雪崩,指数退避更合理)
- 重试几次放弃?(3-5 次通常足够)
- 放弃后怎么办?(死信队列,人工排查)
进度通知
用户提交任务后想知道「跑到哪了」。进度通知有两种方式:
- 轮询:前端定时请求
/api/tasks/:id/status,简单但不够实时 - SSE:服务端主动推送进度事件,实时但连接数有限制
结果获取
任务完成后,结果怎么给用户?
- 结果小(几 KB 文本):直接存在数据库,查询时返回
- 结果大(生成的 PDF、视频):存到 R2,返回签名 URL
- 结果需要后续处理:触发下一个任务,形成工作流
5. Workers 环境的异步工具
Workers 没有 Redis、没有 RabbitMQ,但有几个工具可以组合出完整的异步任务系统:
| 工具 | 用途 | 特点 |
|---|---|---|
| Cloudflare Queue | 消息队列 | 原生集成,按消息计费,at-least-once 交付 |
| Cron Triggers | 定时调度 | 按 cron 表达式触发,适合周期性任务 |
| D1 | 状态存储 | 关系数据库,存任务状态、进度、结果 |
| KV | 快速缓存 | 存临时状态、去重标记、进度快照 |
| R2 | 结果存储 | 大文件存对象存储,返回签名 URL |
组合方式:
HTTP 请求 → 校验 → 存 R2 → 写 D1(任务记录)→ 发 Queue → 返回 taskId
↓
Cron Trigger → 定时扫描 → 发 Queue ──────────────→ Queue 消费者
↓
读 R2 → 处理 → 写结果到 R2/D1
↓
更新 D1 状态 → 前端轮询/SSE 获取
6. 一个完整的异步任务示例
先给一个概览,后续章节会展开每个部分。
提交任务
// src/routes/tasks.ts
import { Hono } from 'hono'
type Bindings = {
DB: D1Database
BUCKET: R2Bucket
TASK_QUEUE: Queue<TaskMessage>
}
interface TaskMessage {
taskId: string
userId: string
fileKey: string
fileType: string
}
const app = new Hono<{ Bindings: Bindings }>()
app.post('/api/tasks', async (c) => {
const formData = await c.req.formData()
const file = formData.get('file') as File
if (!file) return c.json({ error: 'No file' }, 400)
const taskId = crypto.randomUUID()
const now = new Date().toISOString()
// 1. 文件存 R2
const fileKey = `uploads/${taskId}/${file.name}`
await c.env.BUCKET.put(fileKey, file.stream(), {
httpMetadata: { contentType: file.type },
})
// 2. 写任务记录到 D1
await c.env.DB.prepare(`
INSERT INTO tasks (id, user_id, file_key, file_type, status, progress, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)
`).bind(taskId, 'anonymous', fileKey, file.type, now, now).run()
// 3. 发消息到队列
await c.env.TASK_QUEUE.send({
taskId,
userId: 'anonymous',
fileKey,
fileType: file.type,
})
// 4. 立刻返回 202
return c.json({ taskId, status: 'pending' }, 202)
})查询进度
app.get('/api/tasks/:taskId', async (c) => {
const task = await c.env.DB.prepare(`
SELECT id, status, progress, result_key, error_message, created_at, updated_at
FROM tasks WHERE id = ?
`).bind(c.req.param('taskId')).first()
if (!task) return c.json({ error: 'Not found' }, 404)
return c.json(task)
})消费任务
// src/consumers/task-consumer.ts
export async function taskConsumer(
batch: MessageBatch<TaskMessage>,
env: { DB: D1Database; BUCKET: R2Bucket; AI: Ai }
) {
for (const msg of batch.messages) {
const { taskId, fileKey, fileType } = msg.body
try {
// 更新状态为 processing
await env.DB.prepare(
"UPDATE tasks SET status = 'processing', updated_at = ? WHERE id = ?"
).bind(new Date().toISOString(), taskId).run()
// 从 R2 读文件
const object = await env.BUCKET.get(fileKey)
if (!object) throw new Error('File not found')
const buffer = await object.arrayBuffer()
// 处理文件(根据类型分发)
let result: string
if (fileType === 'application/pdf') {
result = await parsePdf(buffer)
} else if (fileType.startsWith('image/')) {
result = await ocrImage(buffer, env)
} else {
result = new TextDecoder().decode(buffer)
}
// 存结果
const resultKey = `results/${taskId}/output.txt`
await env.BUCKET.put(resultKey, result)
// 更新状态为 completed
await env.DB.prepare(`
UPDATE tasks SET status = 'completed', progress = 100, result_key = ?, updated_at = ?
WHERE id = ?
`).bind(resultKey, new Date().toISOString(), taskId).run()
msg.ack()
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
console.error(`Task ${taskId} failed:`, errMsg)
// 重试或标记失败
const retryCount = msg.attempts - 1
if (retryCount < 3) {
msg.retry({ delaySeconds: 30 * (retryCount + 1) })
} else {
await env.DB.prepare(
"UPDATE tasks SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?"
).bind(errMsg, new Date().toISOString(), taskId).run()
msg.ack()
}
}
}
}这个例子展示了异步任务的核心模式:HTTP 接口接收 → Queue 分发 → 消费者处理 → D1 存状态 → 前端查询。后续章节会展开每个部分——Queue 配置、重试策略、进度上报、死信队列。
总结
回顾这一节的要点:
- Workers 的 CPU 时间限制(10ms/30s)和 HTTP 超时限制决定了耗时操作必须异步
- 异步任务把「接收请求」和「执行任务」解耦——请求立刻返回 202,任务后台处理
- 需要异步的场景:耗时计算(PDF 解析、OCR、音频转文本)、批量处理、外部 API 调用、定时任务
- 异步引入新挑战:状态追踪、失败重试、进度通知、结果获取
- Workers 环境用 Cloudflare Queue + D1 + R2 + Cron Triggers 组合出完整的异步任务系统
- 一个完整的异步任务包括:HTTP 接口接收 → Queue 分发 → 消费者处理 → D1 存状态 → 前端查询
下一篇讲任务队列的基本模型——生产者-消费者、发布-订阅、点对点,理解这些概念才能选对工具。