Hono 生产实战 05:队列、定时任务和后台作业 很多 Hono API 一开始都写得很直接:
1 2 3 4 5 6 收到请求 处理业务 写数据库 发邮件 调第三方接口 返回结果
小项目没问题。
但只要系统开始进入生产环境,这种写法很快会遇到三个问题:
1 2 3 接口响应越来越慢 第三方接口失败会拖垮主流程 后台任务没有统一的重试和观测方式
这时就要把一部分工作从“请求链路”里拆出去,交给队列、定时任务和后台作业。
这篇文章讲 Hono 项目如何设计后台任务。示例会偏向 Cloudflare Workers + Queues + Cron Triggers,因为这套组合和 Hono 很契合,也很适合前后端分离项目。
技术栈快照时间:2026-06-23。Cloudflare Queues、Cron Triggers、Workers handler 的配置细节会更新,生产使用前建议再核对官方文档。
一、哪些事情不该同步做 API 请求链路里只适合做必须立即完成的事情。
比如:
1 2 3 4 校验参数 校验身份 写入核心状态 返回用户下一步需要的信息
这些事情可以同步做。
但下面这些事情,通常更适合异步:
1 2 3 4 5 6 7 8 9 10 发送邮件 发送短信 生成报表 压缩图片 同步搜索索引 推送 Webhook 写分析事件 清理过期数据 批量导入导出 调用不稳定的第三方 API
核心判断标准很简单:
1 用户是否必须等这件事完成,才能得到当前请求的结果?
如果答案是否定的,就优先考虑异步化。
二、推荐的任务架构 一个常见架构是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Frontend | v Hono API | | 1. 写数据库,创建任务记录 | 2. 向队列发送消息 v Cloudflare Queue | v Queue Consumer | | 执行真实后台任务 v Database / R2 / Email / Third-party API
API 不直接执行耗时任务,而是创建任务记录并投递队列。
消费者从队列拿消息,再执行真正的工作。
这带来几个好处:
1 2 3 4 5 API 响应更快 任务可以自动重试 第三方接口抖动不会直接影响用户请求 后台任务可以单独观测 任务处理能力可以独立扩展
队列不是为了显得架构高级。
它是为了把“必须立即完成的事”和“可以稍后完成的事”拆开。
三、先设计任务表 不要只往队列里丢一条消息就完事。
生产系统最好有一张任务表。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 create table jobs ( id text primary key , user_id text not null , type text not null , status text not null , payload text not null , result text, error text, attempts integer not null default 0 , created_at text not null , updated_at text not null , finished_at text );
状态可以先简单一点:
1 2 3 4 5 pending processing succeeded failed cancelled
为什么要有任务表?
因为队列只负责传递消息,不适合承担完整业务状态。
任务表负责回答:
1 2 3 4 5 6 这个任务是谁创建的 现在处理到哪一步 失败原因是什么 重试了几次 什么时候完成 用户能否查询进度
如果后台任务对用户可见,比如“导出订单报表”,任务表就更重要。
四、Hono API 投递队列 假设我们要做一个报表导出接口。
用户点击“导出”,API 不立刻生成文件,而是创建任务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 import { Hono } from 'hono' import { z } from 'zod' import { zValidator } from '@hono/zod-validator' type Bindings = { REPORT_QUEUE : Queue DB : D1Database } const app = new Hono <{ Bindings : Bindings }>()const createReportSchema = z.object ({ startDate : z.string (), endDate : z.string (), }) app.post ( '/api/reports/export' , zValidator ('json' , createReportSchema), async (c) => { const user = c.get ('user' ) const input = c.req .valid ('json' ) const jobId = crypto.randomUUID () const now = new Date ().toISOString () await c.env .DB .prepare (` insert into jobs (id, user_id, type, status, payload, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?) ` ).bind ( jobId, user.id , 'report.export' , 'pending' , JSON .stringify ({ ...input, userId : user.id }), now, now ).run () await c.env .REPORT_QUEUE .send ({ jobId, type : 'report.export' , }) return c.json ({ data : { jobId, status : 'pending' , }, }, 202 ) } )
这里返回 202 Accepted,意思是:
前端拿到 jobId 后,可以轮询任务状态,也可以通过 SSE/WebSocket 接收完成通知。
五、队列消息不要塞太大 队列消息里不要塞完整大对象。
更推荐:
1 2 队列消息只放 jobId、type、必要上下文 详细 payload 放数据库或对象存储
例如:
1 2 3 4 await c.env .REPORT_QUEUE .send ({ jobId, type : 'report.export' , })
消费者拿到 jobId 后,再查数据库:
1 2 3 const job = await env.DB .prepare (` select * from jobs where id = ? ` ).bind (message.body .jobId ).first ()
这样做有几个好处:
1 2 3 4 5 消息更小 重试时状态可查 任务可以手动恢复 任务可以在后台管理界面展示 payload 结构升级更容易
队列是触发器,不是数据库。
六、实现 Queue Consumer 在 Cloudflare Workers 里,队列消费者通常写在同一个 Worker 导出对象上。
Hono 负责 HTTP 请求,queue handler 负责消费队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { Hono } from 'hono' const app = new Hono <{ Bindings : Env }>()app.get ('/health' , (c ) => c.json ({ ok : true })) export default { fetch : app.fetch , async queue (batch : MessageBatch , env : Env , ctx : ExecutionContext ) { for (const message of batch.messages ) { await processJob (message.body , env) } }, }
一个简单的处理函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 async function processJob (body : unknown , env : Env ) { const message = body as { jobId : string type : string } const now = new Date ().toISOString () await env.DB .prepare (` update jobs set status = ?, attempts = attempts + 1, updated_at = ? where id = ? and status in ('pending', 'failed') ` ).bind ('processing' , now, message.jobId ).run () try { if (message.type === 'report.export' ) { await exportReport (message.jobId , env) } await env.DB .prepare (` update jobs set status = ?, updated_at = ?, finished_at = ? where id = ? ` ).bind ('succeeded' , now, now, message.jobId ).run () } catch (error) { await env.DB .prepare (` update jobs set status = ?, error = ?, updated_at = ? where id = ? ` ).bind ( 'failed' , error instanceof Error ? error.message : String (error), now, message.jobId ).run () throw error } }
这里故意在失败时重新抛出错误。
原因是:要让队列系统知道这条消息没有处理成功,从而按照平台配置进行重试。
七、任务处理要幂等 队列系统通常是“至少一次投递”。
这意味着同一条任务可能被处理多次。
所以后台任务必须幂等。
比如导出报表:
1 2 3 同一个 jobId 只生成一个结果文件 如果文件已存在,直接返回成功 如果任务已经 succeeded,不重复执行
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 async function exportReport (jobId : string , env : Env ) { const job = await env.DB .prepare (` select * from jobs where id = ? ` ).bind (jobId).first <{ id : string status : string payload : string result : string | null }>() if (!job) { throw new Error (`job not found: ${jobId} ` ) } if (job.status === 'succeeded' && job.result ) { return } const outputKey = `reports/${job.id} .csv` const existing = await env.R2_BUCKET .head (outputKey) if (existing) { await markJobSucceeded (job.id , outputKey, env) return } const payload = JSON .parse (job.payload ) const csv = await buildReportCsv (payload, env) await env.R2_BUCKET .put (outputKey, csv, { httpMetadata : { contentType : 'text/csv; charset=utf-8' , }, }) await markJobSucceeded (job.id , outputKey, env) }
不要假设队列消息只会执行一次。
要把“重复执行也不会造成坏结果”当成默认要求。
八、定时任务适合做什么 队列解决的是异步处理。
定时任务解决的是周期性处理。
常见场景:
1 2 3 4 5 6 每天凌晨生成统计数据 每 10 分钟清理过期任务 每小时同步第三方状态 定期扫描 pending 太久的任务 定期重建搜索索引 定期发送订阅邮件
在 Cloudflare Workers 里,可以使用 Cron Triggers 触发 scheduled handler。
结构大概是:
1 2 3 4 5 6 7 8 9 10 11 export default { fetch : app.fetch , async scheduled ( event : ScheduledEvent , env : Env , ctx : ExecutionContext ) { ctx.waitUntil (runScheduledJobs (event, env)) }, }
ctx.waitUntil() 的意义是告诉运行时:
1 这个异步任务属于当前事件,即使 handler 返回了,也要继续执行
实际定时规则在 wrangler.jsonc 里配置:
1 2 3 4 5 { "triggers" : { "crons" : [ "*/10 * * * *" ] } }
这表示每 10 分钟运行一次。
九、定时任务不要做得太重 定时任务最容易踩的坑是:
1 2 3 4 5 6 每次触发都扫描全表 一次处理几万条数据 没有游标 没有锁 重复触发互相抢任务 失败后从头再来
更稳的方式是小批量处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 async function cleanupExpiredJobs (env : Env ) { const expiredJobs = await env.DB .prepare (` select id from jobs where status in ('pending', 'processing') and created_at < ? order by created_at asc limit 100 ` ).bind ( new Date (Date .now () - 24 * 60 * 60 * 1000 ).toISOString () ).all <{ id : string }>() for (const job of expiredJobs.results ) { await env.DB .prepare (` update jobs set status = ?, updated_at = ? where id = ? ` ).bind ('failed' , new Date ().toISOString (), job.id ).run () } }
核心原则:
1 2 3 4 每次只处理一小批 用 order by + limit 控制范围 保存游标或状态 允许下次继续
后台任务不怕慢一点,怕的是一次做太多导致整体不可控。
十、前端如何查询任务进度 最简单的方式是给一个查询接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 app.get ('/api/jobs/:id' , async (c) => { const user = c.get ('user' ) const jobId = c.req .param ('id' ) const job = await c.env .DB .prepare (` select id, type, status, result, error, created_at, updated_at, finished_at from jobs where id = ? and user_id = ? ` ).bind (jobId, user.id ).first () if (!job) { return c.json ({ error : { code : 'JOB_NOT_FOUND' , message : '任务不存在' , }, }, 404 ) } return c.json ({ data : job }) })
前端可以每隔几秒轮询。
如果任务很少,这就够了。
如果任务状态变化频繁,或者用户需要实时体验,可以再升级成 SSE 或 WebSocket。
不要一开始就上实时通信。
很多后台任务用轮询反而更可靠、更容易维护。
十一、后台任务的日志和告警 后台任务最怕“失败了但没人知道”。
至少要记录:
1 2 3 4 5 6 7 8 9 jobId type status attempts duration error message requestId 或 traceId tenantId userId
失败日志要能反查到任务表。
比如:
1 2 3 4 5 6 7 console .error ('job_failed' , { jobId, type , attempts, durationMs, message : error instanceof Error ? error.message : String (error), })
生产环境还应该做告警:
1 2 3 4 5 失败率超过阈值 队列积压过多 任务处理延迟过高 某类任务连续失败 定时任务超过预期时间未运行
后台任务没有用户坐在屏幕前等结果,所以系统要主动告诉你它坏了。
十二、我推荐的落地顺序 如果你正在把 Hono 项目从 demo 改成生产系统,可以按这个顺序做:
1 2 3 4 5 6 7 8 1. 先识别同步链路里的慢操作 2. 为慢操作建 jobs 表 3. API 创建任务并返回 jobId 4. 队列消费者处理任务 5. 任务处理逻辑改成幂等 6. 给前端提供任务状态查询接口 7. 增加定时清理和补偿任务 8. 补日志、指标和告警
不要一上来就设计复杂的任务平台。
先把一个真实耗时任务抽出去。
只要这个闭环跑通,你就会有一套可以复用的后台作业骨架。
Hono 的好处是它不会强迫你接受庞大的框架模型。
但生产系统里的异步边界,仍然需要你自己清楚地设计出来。