17.09-长任务进度查询
要点
- 用户提交长任务后想知道「跑到哪了」——进度查询是异步任务系统的标配
- 两种实现方式:轮询(简单可靠)和 SSE(实时推送)
- 进度数据来源:D1 存持久状态,KV 存实时进度快照
- 多阶段任务(解析→切chunk→embedding→入库)每阶段更新进度百分比
- 前端展示进度条 + 阶段名称 + 预计剩余时间
1. 为什么需要进度查询
用户上传一个 50MB 的 PDF,点下按钮后页面显示「处理中...」。等了 2 分钟,没有任何变化。用户开始怀疑:是不是卡住了?要不要刷新?
没有进度反馈的异步任务体验极差。用户需要知道:
- 任务开始了吗?
- 当前在做什么?
- 还要等多久?
- 失败了是什么原因?
2. 进度数据来源
进度数据存在哪?两个选择:
D1 存持久状态
任务状态、阶段、进度百分比存在 D1,查询时直接读。
优点:持久化、可查询、可统计。 缺点:写 D1 有延迟(~50ms),频繁更新影响性能。
KV 存实时快照
进度快照存 KV,每 5 秒更新一次。查询时先读 KV(快),没有再读 D1(准确)。
优点:更新快(~10ms),不影响处理性能。 缺点:有过期时间,任务完成后清理。
推荐组合:KV 存实时进度,D1 存最终状态。
// 消费者更新进度
async function updateProgress(
env: Env,
taskId: string,
progress: number,
stage: string
): Promise<void> {
// 写 KV(快)
await env.KV.put(
`progress:${taskId}`,
JSON.stringify({
progress,
stage,
updatedAt: Date.now(),
}),
{ expirationTtl: 3600 } // 1 小时后过期
)
// 每 10% 写一次 D1(减少写入)
if (progress % 10 === 0) {
await env.DB.prepare(`
UPDATE tasks SET progress = ?, stage = ?, updated_at = ? WHERE id = ?
`).bind(progress, stage, new Date().toISOString(), taskId).run()
}
}3. 轮询接口
前端定时请求进度,简单可靠。
// GET /api/tasks/:taskId/progress
app.get('/api/tasks/:taskId/progress', async (c) => {
const taskId = c.req.param('taskId')
// 先查 KV(实时进度)
const kvData = await c.env.KV.get(`progress:${taskId}`, 'json')
if (kvData) {
return c.json({
source: 'realtime',
...(kvData as any),
})
}
// KV 没有,查 D1(最终状态)
const task = await c.env.DB.prepare(`
SELECT id, status, stage, progress, error_message, created_at, updated_at
FROM tasks WHERE id = ? AND user_id = ?
`).bind(taskId, c.get('user').id).first()
if (!task) return c.json({ error: 'Not found' }, 404)
return c.json({
source: 'persistent',
status: task.status,
stage: task.stage,
progress: task.progress,
errorMessage: task.error_message,
createdAt: task.created_at,
updatedAt: task.updated_at,
})
})前端轮询:
function pollProgress(taskId: string): void {
const interval = setInterval(async () => {
const response = await fetch(`/api/tasks/${taskId}/progress`)
const data = await response.json()
// 更新 UI
updateProgressBar(data.progress)
updateStageName(data.stage)
if (data.status === 'completed') {
clearInterval(interval)
showResult()
} else if (data.status === 'failed') {
clearInterval(interval)
showError(data.errorMessage)
}
}, 2000) // 每 2 秒查询一次
}轮询间隔建议 2-5 秒。太短浪费请求,太长用户觉得卡顿。
4. SSE 接口
Server-Sent Events 实时推送,不需要轮询。
import { streamSSE } from 'hono/streaming'
// GET /api/tasks/:taskId/progress/sse
app.get('/api/tasks/:taskId/progress/sse', async (c) => {
const taskId = c.req.param('taskId')
return streamSSE(c, async (stream) => {
let lastProgress = -1
while (true) {
// 查询进度
const kvData = await c.env.KV.get(`progress:${taskId}`, 'json')
const task = await c.env.DB.prepare(`
SELECT status, stage, progress, error_message
FROM tasks WHERE id = ?
`).bind(taskId).first()
const progress = (kvData as any)?.progress ?? task?.progress ?? 0
const status = task?.status ?? 'unknown'
// 只在进度变化时推送
if (progress !== lastProgress) {
await stream.writeSSE({
event: 'progress',
data: JSON.stringify({
status,
stage: (kvData as any)?.stage ?? task?.stage,
progress,
errorMessage: task?.error_message,
}),
})
lastProgress = progress
}
// 完成或失败时停止
if (status === 'completed' || status === 'failed') {
await stream.writeSSE({
event: 'done',
data: JSON.stringify({ status }),
})
break
}
await stream.sleep(1000) // 每秒检查一次
}
})
})前端接收 SSE:
function subscribeProgress(taskId: string): void {
const source = new EventSource(`/api/tasks/${taskId}/progress/sse`)
source.addEventListener('progress', (event) => {
const data = JSON.parse(event.data)
updateProgressBar(data.progress)
updateStageName(data.stage)
})
source.addEventListener('done', (event) => {
const data = JSON.parse(event.data)
source.close()
if (data.status === 'completed') {
showResult()
} else {
showError()
}
})
source.addEventListener('error', () => {
source.close()
// 连接断开,降级为轮询
pollProgress(taskId)
})
}SSE 比轮询更实时,但有局限:
- 连接数有限——Workers 免费版每个 IP 最多 100 个并发连接
- 不支持 POST——SSE 只能 GET,不能传认证信息(需要 URL 里带 token 或用 cookie)
- 重连逻辑——连接断开后需要手动重连或降级
大批量场景用轮询更稳妥。单用户场景用 SSE 体验更好。
5. 多阶段进度
复杂任务有多个阶段——每个阶段耗时不同,进度计算需要考虑权重。
const STAGES = [
{ name: 'parse', weight: 20 }, // 解析占 20%
{ name: 'chunk', weight: 10 }, // 切分占 10%
{ name: 'embed', weight: 50 }, // embedding 占 50%(最耗时)
{ name: 'index', weight: 20 }, // 入库占 20%
]
function calculateProgress(currentStage: string, stageProgress: number): number {
let totalWeight = 0
let completedWeight = 0
for (const stage of STAGES) {
if (stage.name === currentStage) {
completedWeight += totalWeight + stage.weight * stageProgress
totalWeight += stage.weight
break
}
totalWeight += stage.weight
completedWeight += stage.weight
}
return Math.round((completedWeight / totalWeight) * 100)
}
// 消费者中使用
async function processWithProgress(taskId: string, env: Env): Promise<void> {
// 阶段一:解析
await updateProgress(env, taskId, calculateProgress('parse', 0), 'parse')
const text = await parsePdf(buffer)
await updateProgress(env, taskId, calculateProgress('parse', 1), 'parse')
// 阶段二:切分
await updateProgress(env, taskId, calculateProgress('chunk', 0), 'chunk')
const chunks = chunkText(text)
await updateProgress(env, taskId, calculateProgress('chunk', 1), 'chunk')
// 阶段三:embedding(逐步更新)
for (let i = 0; i < chunks.length; i += 10) {
const batch = chunks.slice(i, i + 10)
await generateEmbeddings(batch, env)
const stageProgress = (i + 10) / chunks.length
await updateProgress(env, taskId, calculateProgress('embed', stageProgress), 'embed')
}
// 阶段四:入库
await updateProgress(env, taskId, calculateProgress('index', 0), 'index')
await indexVectors(chunks, env)
await updateProgress(env, taskId, 100, 'index')
}6. 预计剩余时间
用户想知道「还要等多久」。可以根据历史数据估算。
async function estimateRemainingTime(
db: D1Database,
taskId: string,
currentProgress: number
): Promise<number | null> {
// 查询任务开始时间
const task = await db.prepare(`
SELECT started_at FROM tasks WHERE id = ?
`).bind(taskId).first()
if (!task?.started_at || currentProgress === 0) return null
const elapsed = Date.now() - new Date(task.started_at).getTime()
const estimated = (elapsed / currentProgress) * (100 - currentProgress)
return Math.round(estimated / 1000) // 秒
}
// 或者用历史平均耗时
async function getAverageDuration(db: D1Database, taskType: string): Promise<number> {
const result = await db.prepare(`
SELECT AVG(JULIANDAY(completed_at) - JULIANDAY(started_at)) * 86400 as avg_duration
FROM tasks
WHERE type = ? AND status = 'completed' AND created_at > datetime('now', '-7 days')
`).bind(taskType).first()
return result?.avg_duration ?? 0
}7. 前端展示
进度条 + 阶段名称 + 预计时间:
function renderProgress(data: ProgressData): void {
const container = document.getElementById('progress')
// 进度条
const progressBar = container.querySelector('.progress-bar')
progressBar.style.width = `${data.progress}%`
// 阶段名称
const stageNames: Record<string, string> = {
parse: '解析文档',
chunk: '切分段落',
embed: '生成向量',
index: '写入索引',
}
const stageName = stageNames[data.stage] ?? data.stage
container.querySelector('.stage-name').textContent = stageName
// 百分比
container.querySelector('.progress-text').textContent = `${data.progress}%`
// 预计时间
if (data.estimatedSeconds) {
const minutes = Math.ceil(data.estimatedSeconds / 60)
container.querySelector('.eta').textContent = `预计还需 ${minutes} 分钟`
}
}8. 超时处理
任务长时间没有进度更新,可能卡住了。前端检测到超时后提示用户。
function watchTimeout(taskId: string, timeoutMs = 60000): void {
let lastUpdate = Date.now()
const interval = setInterval(async () => {
const data = await fetchProgress(taskId)
if (data.updatedAt > lastUpdate) {
lastUpdate = data.updatedAt
} else if (Date.now() - lastUpdate > timeoutMs) {
clearInterval(interval)
showTimeoutWarning()
}
}, 5000)
}后端也可以主动检测——Cron 扫描 processing 状态超过 30 分钟的任务,标记为 failed。
总结
回顾这一节的要点:
- 进度查询是异步任务系统的标配——用户需要知道任务跑到哪了
- 两种方式:轮询(简单可靠)和 SSE(实时推送)
- 进度数据:KV 存实时快照,D1 存最终状态
- 多阶段任务用权重计算进度——embedding 最耗时,权重最大
- 预计剩余时间:根据已耗时和当前进度估算,或用历史平均值
- 前端展示:进度条 + 阶段名称 + 预计时间
- 超时处理:检测长时间无更新,提示用户
下一篇讲文档向量化任务——把文档变成可检索的向量。