98、Queues 与 Cron 定时任务

要点

  • 前面所有章节讲的都是「用户请求 → Worker 处理 → 返回响应」
  • Cron Triggers 就是给你的 Worker 加一个「时间驱动」的入口
  • Cron 解决的是「什么时候做」
  • 两个工具的定位差别清晰
  • 1 消息去重(幂等)

内容

1. AI 项目里的「不由用户触发」的工作

前面所有章节讲的都是「用户请求 → Worker 处理 → 返回响应」。但真实 AI 项目里有一类工作不由用户请求触发

  • 后台异步任务:用户上传了 100 份文档,你不能让他在浏览器里等 10 分钟 embedding 跑完
  • 定时任务:每小时把过去一小时的 token 用量汇总到日表,每天凌晨清理过期 session
  • 解耦重活:用户请求立即返回「已接收」,真正的 LLM 调用、R2 归档、向量索引在后台慢慢做
  • 失败可重试:第三方 API 偶发 429/500,直接返回给用户就是用户承担,扔进队列系统自动重试更友好

Cloudflare 在 Workers 生态里提供了两个互补的工具:

工具触发方式典型用途
Cron Triggers按 cron 表达式定时触发汇总、清理、定时同步
QueuesWorker 往里塞消息,另一个 Worker 异步消费耗时任务解耦、重试、批量处理

这一篇讲怎么在 Hono + Workers 里用这两个工具。

2. Cron Triggers:最简单的定时任务

Cron Triggers 就是给你的 Worker 加一个「时间驱动」的入口。你在 wrangler.jsonc 里写 cron 表达式,Cloudflare 按时调用你 Worker 的 scheduled() 方法。

2.1 配置

// wrangler.jsonc
{
 
  "triggers": {
 
    "crons": [
 
      // 每小时 07 分跑一次(避开整点峰值)
 
      "7 * * * *",
 
      // 每天 UTC 03:30 跑一次
 
      "30 3 * * *"
 
    ]
 
  }
 
}

注意:Cron Triggers 用的是 UTC 时间,不是你本地时间。中国用户写定时任务记得先减 8 小时。

2.2 Hono + Cron:写 scheduled 入口

Hono 的 app 对象本身只处理 fetch。要加 cron 入口,需要包一层自定义的默认导出:

// src/index.ts
import { Hono } from 'hono'
 
import { dailyUsageRollup, cleanupExpiredSessions } from './cron/jobs'
 
type Bindings = {
 
  DB: D1Database
 
  USAGE_KV: KVNamespace
 
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
// 正常的 HTTP 路由
 
app.get('/', (c) => c.text('OK'))
 
// 包装默认导出,把 fetch 和 scheduled 放在一起
 
export default {
 
  fetch: app.fetch,
 
  // scheduled 不经过 Hono,所以没有 c(Context)
 
  // env 和 ctx 从函数参数直接拿
 
  async scheduled(
 
    controller: ScheduledController,  // Workers 内置类型
 
    env: Bindings,
 
    ctx: ExecutionContext              // Workers 内置类型
 
  ) {
 
    // 根据 cron 表达式分发到不同任务
 
    switch (controller.cron) {
 
      case '7 * * * *':
 
        ctx.waitUntil(dailyUsageRollup(env))
 
        break
 
      case '30 3 * * *':
 
        ctx.waitUntil(cleanupExpiredSessions(env))
 
        break
 
    }
 
  },
 
}

几个关键点:

  • controller.cron:触发时的 cron 字符串,用来分发
  • ctx.waitUntil():和前面 AI Gateway 里用的一样——"先返回,但后台等这个 Promise 跑完再关"。多个任务可以并发 waitUntil,不必串行 await
  • 为什么 env 从参数拿scheduled 不走 Hono 的路由系统,没有 c(Hono Context),所以 env 要从函数参数拿,不是从 c.env

2.3 一个真实的定时任务:token 用量每小时汇总

第 18 篇的 AI 网关把每次请求的 token 消耗写进 KV,每小时聚合一次到 D1 日表(用 onConflictDoUpdate 反复刷新当天这一行)就是典型的 cron 场景:

// src/cron/jobs.ts
import { drizzle } from 'drizzle-orm/d1'
 
import { usageDaily } from '../db/schema'
 
export async function dailyUsageRollup(env: {
 
  DB: D1Database
 
  USAGE_KV: KVNamespace
 
}) {
 
  const db = drizzle(env.DB)
 
  const today = new Date().toISOString().slice(0, 10)
 
  // 列出所有用户的当天用量(KV 里按前缀扫)
 
  const list = await env.USAGE_KV.list({ prefix: `usage:` })
 
  for (const key of list.keys) {
 
    // key 格式:usage:<apiKeyId>:<date>
 
    const parts = key.name.split(':')
 
    if (parts.length !== 3 || parts[2] !== today) continue
 
    const apiKeyId = parts[1]
 
    const record = await env.USAGE_KV.get<{
 
      totalRequests: number
 
      totalPromptTokens: number
 
      totalCompletionTokens: number
 
    }>(key.name, 'json')
 
    if (!record) continue
 
    // 写进 D1 的日表
 
    await db.insert(usageDaily).values({
 
      apiKeyId,
 
      date: today,
 
      requests: record.totalRequests,
 
      promptTokens: record.totalPromptTokens,
 
      completionTokens: record.totalCompletionTokens,
 
    }).onConflictDoUpdate({
 
      target: [usageDaily.apiKeyId, usageDaily.date],
 
      set: {
 
        requests: record.totalRequests,
 
        promptTokens: record.totalPromptTokens,
 
        completionTokens: record.totalCompletionTokens,
 
      },
 
    })
 
  }
 
}

cron 跑完之后,你在 D1 里就有一张「每用户每日用量」表,做报表、算账单都很方便。KV 依然保留用于实时查询,D1 是历史的「真相源」。

2.4 本地触发 cron

Cron 在 wrangler dev 里不会自动触发。用这个端点手动触发一次:

// terminal
curl "http://localhost:8787/__scheduled?cron=7+*+*+*+*"

cron= 参数用 + 代替空格,告诉 wrangler 触发哪条规则。

3. Queues:异步任务队列

Cron 解决的是「什么时候做」。Queues 解决的是「怎么把做事情本身从用户请求里剥离出去」。

3.1 生产者 / 消费者模型

Queues 是经典的发布-订阅:

  • Producer(生产者):通常是处理用户请求的 Worker,调用 queue.send(msg) 往队列塞消息
  • Consumer(消费者):另一个 Worker(或同一个),通过 queue() handler 批量接收消息

两个 Worker 之间不直接调用——生产者只需要「把事情扔出去」,消费者慢慢吃就行。失败可以自动重试,队列里的消息不会丢。

3.2 创建队列和配置

// terminal
npx wrangler queues create rag-ingest

配置(可以生产者和消费者在同一个 Worker,也可以分开):

// wrangler.jsonc
{
 
  "queues": {
 
    "producers": [
 
      {
 
        "queue": "rag-ingest",
 
        "binding": "INGEST_QUEUE"
 
      }
 
    ],
 
    "consumers": [
 
      {
 
        "queue": "rag-ingest",
 
        "max_batch_size": 10,       // 一次最多攒 10 条
 
        "max_batch_timeout": 5,     // 或者等 5 秒就发一批
 
        "max_retries": 3,           // 失败重试 3 次
 
        "dead_letter_queue": "rag-ingest-dlq"
 
      }
 
    ]
 
  }
 
}

dead_letter_queue(DLQ,死信队列)是处理失败超过 max_retries 次后的兜底。比如一条消息重试了 3 次都失败,它不会直接丢掉,而是被转到 DLQ 里。你可以之后排查原因、手动重跑。建议配上,不然失败的消息就真丢了。

3.3 生产者:从请求里分离重活

以 RAG 系统为例。用户上传一批文档,你不想让他等 embedding 跑完才回 200:

// src/routes/upload.ts
import { Hono } from 'hono'
 
type Bindings = {
 
  INGEST_QUEUE: Queue<IngestJob>
 
}
 
interface IngestJob {
 
  docId: string
 
  text: string
 
  tenantId: string
 
}
 
const upload = new Hono<{ Bindings: Bindings }>()
 
upload.post('/documents', async (c) => {
 
  const { docs, tenantId } = await c.req.json<{
 
    docs: Array<{ id: string; text: string }>
 
    tenantId: string
 
  }>()
 
  // 不在这里做 embedding(可能要几秒到几十秒)
 
  // 只是把「要做的事」塞进队列,立即返回
 
  for (const doc of docs) {
 
    await c.env.INGEST_QUEUE.send({
 
      docId: doc.id,
 
      text: doc.text,
 
      tenantId,
 
    })
 
  }
 
  return c.json({
 
    accepted: docs.length,
 
    message: '已接收,正在后台处理',
 
  }, 202)
 
})
 
export default upload

注意 HTTP 状态码 202 Accepted——「我收到了,但还没做完」。这是异步任务接口的标准语义。

3.4 消费者:真正干活

消费者的 handler 接收的是一个 batch,里面有若干条消息:

// src/consumers/ingest.ts
interface IngestJob {
 
  docId: string
 
  text: string
 
  tenantId: string
 
}
 
type Env = {
 
  AI: Ai
 
  DOCS_INDEX: Vectorize
 
}
 
export async function ingestConsumer(
 
  batch: MessageBatch<IngestJob>,
 
  env: Env
 
) {
 
  // 1. 批量生成 embedding(一次调用比一条一条调便宜很多)
 
  const texts = batch.messages.map((m) => m.body.text)
 
  const { data: embeddings } = await env.AI.run(
 
    '@cf/baai/bge-base-en-v1.5',
 
    { text: texts }
 
  )
 
  // 2. 组装写入向量数据库
 
  const vectors = batch.messages.map((m, i) => ({
 
    id: m.body.docId,
 
    values: embeddings[i],
 
    namespace: m.body.tenantId,
 
    metadata: {
 
      text: m.body.text,
 
    },
 
  }))
 
  try {
 
    await env.DOCS_INDEX.upsert(vectors)
 
    // 批量 ack:告诉 Queue 这一批都成功了
 
    for (const msg of batch.messages) msg.ack()
 
  } catch (err) {
 
    // 整批重试。也可以逐条 retry() 做更精细的控制
 
    for (const msg of batch.messages) msg.retry({ delaySeconds: 30 })
 
  }
 
}

几个关键 API:

  • msg.ack():确认这条消息已处理,从队列里删除
  • msg.retry({ delaySeconds }):扔回队列稍后重试(可以指定延迟)
  • 默认行为:handler 正常返回就算全部成功;抛异常就整批重试

3.5 把消费者挂到 Worker 入口

Queue 消费者通过 queue() handler 挂到 Worker 默认导出:

// src/index.ts
import { Hono } from 'hono'
 
import { ingestConsumer } from './consumers/ingest'
 
import upload from './routes/upload'
 
const app = new Hono()
 
app.route('/upload', upload)
 
export default {
 
  fetch: app.fetch,
 
  async queue(batch: MessageBatch, env: any, ctx: ExecutionContext) {
 
    // 通过 queue 名字分发到不同 consumer
 
    if (batch.queue === 'rag-ingest') {
 
      return ingestConsumer(batch, env)
 
    }
 
  },
 
}

一个 Worker 可以同时是多个队列的消费者,根据 batch.queue 分发就行。

4. Cron 还是 Queue?

两个工具的定位差别清晰:

用 Cron用 Queue
定时、周期性、和用户请求无关的事由某次用户请求触发、但不想让用户等的事
没有状态(每次从头扫一遍数据)有状态(每条消息是独立任务)
失败就等下次再跑失败要重试、要 DLQ
汇总、清理、同步处理上传、调外部 API、批量计算

一条经验:如果数据量可能变大,Cron 里扫全表的写法迟早超时(Cron 单次最多 30 秒 CPU)。这时候 Cron 只负责「把待处理的任务塞进 Queue」,真正处理交给 Queue 消费者——这是一个常见的组合模式。

4.1 Cron + Queue 的组合实战

用户表有 100 万条,每天要检查他们的订阅是否过期。单次 Cron 扫不完:

// src/cron/jobs.ts
export async function checkSubscriptions(env: {
 
  DB: D1Database
 
  CHECK_QUEUE: Queue<{ userId: number }>
 
}) {
 
  const db = drizzle(env.DB)
 
  // Cron 只做「分发」,每 1000 条用户推一批到队列
 
  let lastId = 0
 
  while (true) {
 
    const batch = await db
 
      .select({ id: users.id })
 
      .from(users)
 
      .where(gt(users.id, lastId))
 
      .limit(1000)
 
      .all()
 
    if (batch.length === 0) break
 
    // 批量 send(一次 API 调用塞多条)
 
    await env.CHECK_QUEUE.sendBatch(
 
      batch.map((u) => ({ body: { userId: u.id } }))
 
    )
 
    lastId = batch[batch.length - 1].id
 
  }
 
}

Queue 消费者再慢慢跑——100 万条可以花几小时处理,用户无感知。

5. 常用姿势与陷阱

5.1 消息去重(幂等)

Queue 的交付语义是 at-least-once(至少投递一次)——同一条消息可能被投递多次(网络抖动、消费者超时都可能导致)。所以消费者逻辑必须是幂等的:同一条消息处理两次,结果和处理一次一样,不会产生副作用。

最常见的做法:消息体里带一个业务唯一 ID,消费前先查「这个 ID 处理过了吗」:

// src/consumers/ingest.ts
const seen = await env.KV.get(`processed:${msg.body.docId}`)
 
if (seen) {
 
  msg.ack()
 
  continue
 
}
 
// ... 实际处理 ...
 
await env.KV.put(`processed:${msg.body.docId}`, '1', { expirationTtl: 86400 })
 
msg.ack()

5.2 按条 retry vs 整批 retry

msg.retry() 针对单条,throw 会让整批都重试。规则很简单:

  • 部分失败:只 retry 失败的那几条,ack 成功的
  • 系统性失败(DB 连不上、模型全挂):直接抛异常,整批重试更合理

5.3 Delay:延迟任务

msg.retry(&#123; delaySeconds &#125;)queue.send(body, &#123; delaySeconds &#125;) 都支持延迟,单位秒。「30 分钟后再检查订单支付状态」这种场景直接用它:

// src/routes/order.ts
await c.env.CHECK_PAYMENT_QUEUE.send(
 
  { orderId: '123' },
 
  { delaySeconds: 30 * 60 }
 
)

6. 小结

Cron Triggers 按时间触发 scheduled() handler,适合汇总、清理这类周期性的事。Queues 是消息队列,生产者发、消费者收,支持批量处理、自动重试、DLQ。

两个经常配合用:Cron 负责把大任务切成小任务扔进 Queue,Queue 慢慢消费。用户请求里但凡不需要立即返回结果的重活(embedding、归档、调外部 API),都可以扔给 Queue,先回 202 再说。