Hono 生产实战 05:队列、定时任务和后台作业
wxk1991 Lv5

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,意思是:

1
请求已经接受,但任务还没有完成

前端拿到 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 的好处是它不会强迫你接受庞大的框架模型。

但生产系统里的异步边界,仍然需要你自己清楚地设计出来。