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
图片 OCR2-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 存状态 → 前端查询

下一篇讲任务队列的基本模型——生产者-消费者、发布-订阅、点对点,理解这些概念才能选对工具。