第28章:ASR 异步 Worker
本章分析 internal/service/live_material_asr_worker.go:四组后台 Worker 的第一个,也是其余三组的模板。唤醒-轮询混合调度、drain 抢占循环、单调进度回写、调试记录器落盘——这些机制在 AI 切片/草稿 Worker 中原样复现,读懂这一个等于读懂四个。
流程
Worker 的调度由三条 goroutine 协作:
- N 个 loop goroutine(N=asr_concurrency,默认 6):阻塞在
wakechannel 上,被唤醒后进入drain; - 1 个 pollLoop goroutine:每 3 秒(pollInterval)执行「requeueStale + Enqueue」——回收孤儿 + 补发唤醒,兜底覆盖「wake 信号丢失」与「其他实例创建了任务」两个场景;
- Start 时的立即动作:先 requeueStale(服务重启后的 processing 孤儿无需等第一个周期)再 Enqueue(立刻开始消化积压)。
drain 是核心循环:ClaimPendingASR 乐观锁抢占(第 15 章)→ 抢到则 Process → 继续抢下一条 → 抢不到(nil)则返回继续等待。一个唤醒信号触发「尽量多干」的批处理语义。
Process 的业务主流程(线性叙事,异常各自归档):
校验 status=processing(防御)
→ ASRDebugRecorder 建立(staging/asr/{materialID}/{version}/ 调试目录)
→ 音频预处理(第 27 章)失败 → failASR
→ ASR 转写(第 24 章,进度回调单调递增刷库)失败 → failASR
→ LLM 后处理(第 30 章:主题分段 + 段落聚合)
→ UpdateASRCompleted 写回(原始 JSON + summaries + paragraphs + 分辨率)
→ 每阶段 rec.Write("00X_*.json") 落盘调试快照
failASR 统一处理失败:UpdateASRFailed 写 error_msg + 当前进度,同时把调试记录也写盘——失败排障与成功复盘用同一套证据链。
实现
四个值得单独指出的机制:
- 单调进度:
updateProgress闭包记lastProgress,回调值必须大于上次才刷库——ASR 轮询回调(5~95)与预处理进度可能交错乱序,单调化保证进度条永不回退,也减少无效写库(仓储的 version 条件再兜一层,第 15 章)。 - 版本条件贯穿:所有状态写回都带
asrVersion(抢占时的值)。若执行超时被回收重抢(version 已变),旧 Worker 的失败/成功写入全部被仓储 WHERE 拦截——双重保险让「僵尸 Worker 复活写脏数据」不可能发生。 - ASRDebugRecorder:
live_material_asr_debug.go提供,把每阶段的输入输出(prepare 结果、ASR 原始响应、LLM 请求响应、最终落库数据)按序号 JSON 落盘到staging/asr/{id}/{version}/。目录名带 version 意味着同素材多次转写的调试数据互不覆盖。调度器定时清理(ASRStagingMaxDirs=20,第 46 章)。这是「AI 黑盒链路可复盘」的关键投入。 - Enqueue 的容量 1 channel:
select { case w.wake <- struct{}{}: default: }非阻塞投递,信号已存在就不再投——唤醒是「电平」不是「脉冲」,N 个 worker 各自 drain 完成前不堆积信号。
live_material_asr_paths.go 负责调试目录的路径约定,asr_worker_test.go 用 stub 仓储 + 假客户端覆盖调度、抢占、失败、心跳各分支。
📌 设计决策
- 唤醒 + 定时轮询双通道:Enqueue 给「本实例新任务」毫秒级响应,3 秒 poll 给「跨实例任务与信号丢失」兜底——延迟与 DB 压力的平衡点。
- 调试落盘默认开启:LLM 后处理是质量问题的重灾区,没有证据链就只能猜;磁盘成本由清理任务封顶(20 个目录)。
- Process 里防御性检查 status:Worker 可能在陈旧数据上被调用(编排复用、测试直调),入口自检让函数契约自洽。
代码示例
drain 循环——「尽量多干」的批处理语义:
// internal/service/live_material_asr_worker.go(节选)
// drain 循环抢占并处理,直到当前没有更多 pending ASR。
func (w *liveMaterialASRWorker) drain(ctx context.Context, workerID int) {
for {
if ctx.Err() != nil {
return
}
material, err := w.repo.ClaimPendingASR(ctx)
if err != nil {
w.logger.Error("抢占直播素材 ASR 任务失败",
zap.Int("worker_id", workerID), zap.Error(err))
return
}
if material == nil {
return // 队列空,回去等唤醒
}
if err := w.Process(ctx, material); err != nil {
w.logger.Error("直播素材 ASR 任务执行失败",
zap.Uint("material_id", material.ID), zap.Error(err))
}
// 失败不退出循环:继续抢下一条
}
}
单调进度闭包:
var lastProgress int16 = 5
updateProgress := func(progress int16) {
if progress <= lastProgress {
return // 进度只增不减,乱序回调直接丢弃
}
lastProgress = progress
if updateErr := w.repo.UpdateASRProgress(ctx, materialID, asrVersion, progress); updateErr != nil {
w.logger.Warn("更新 ASR 进度失败", zap.Error(updateErr))
}
}
小结
- Worker 三件套:N 个 loop + pollLoop(3 秒)+ 启动即回收唤醒。
- 正确性双保险:仓储 version 条件 + Worker 单调进度 + 入口状态自检。
- 调试落盘按
{materialID}/{version}隔离,AI 链路全程可复盘。
思考题
- drain 中 Process 失败后继续抢下一条——若数据库连接整体故障,这个循环会怎样?需要熔断吗?
- 调试目录按 version 隔离,同一素材反复 retry 会累积多份目录,MaxDirs=20 的淘汰策略是否会误删正在排障的旧版本?
项目信息
- GitHub仓库:Chyona/live-mixer