系统设计系列 · 上篇

不假设一次调用会成功:一套录音转写系统的可靠性设计

技术笔记 · good fun 谷放出品 · 作者 Ivy

这是我个人做的一个项目——一个录音、转写、AI 摘要一体的应用。项目分两个子系统,这篇讲第一个:录音接收落库与转写服务对接。

这篇不讲产品,只讲一件事:转写服务是别人的系统,单次调用随时可能超时、报错、或者结果根本没送达。不能假设这次调用会成功,只能为每一种可能的失败,提前准备好接盘的路径。下面是这套设计具体长什么样。

01 · 转写请求为什么要过一层任务队列,而不是直接调用等结果

最初的需求其实是一条三段式流程:先从对接的通话平台系统那边接收录音文件、落到自己的存储里,再把录音转成文字,最后把"转写完成"这件事通知给对应的用户。三段各自异步、各自可能变慢或失败——如果把它们串成一条同步调用链,收到文件就原地转写、转写完就原地通知,任何一段卡住,都会把前面已经做完的事情一起拖住。

最初设想:三段串行

通话平台系统
录音文件
接收 · 落库
转写任务队列
(异步)
转写完成 · 通知用户

但真正落地时,通知用户这一步被单独拎出来了:录音一到、落库完成,就立刻通知用户"收到了",不等转写结果——转写完全走自己的异步节奏,两件事之间不再有依赖关系。

实际做法:落库后分叉两条线

通话平台系统
录音文件
接收 · 落库
立即通知用户
"收到录音了"
异步进入转写任务队列

转写这一段尤其如此:通常要几十秒到几分钟才能出结果,如果收到录音就同步调用转写服务、原地等返回,整条链路就被这一次外部调用的耗时和成功率绑死了——转写服务一抖动,拖累的是整个流程。所以录音一落库,先生成一条状态为"待处理"的转写任务记录,提交转写请求之后立刻返回,不阻塞,把"接收录音"和"转写完成"两个时间点彻底解耦。

中间靠一个状态机串起来:待处理 → 处理中 → 完成/失败。提交任务后,通过一个工作流引擎等待"转写完成"的回调信号,同时给这次等待设置了 25 分钟的超时保护——避免转写服务那边没了动静,这个任务就无限期悬在"处理中"。

02 · 同步优先,异步兜底:给部分场景开一条快速通道

异步队列解决了整体稳定性的问题,但产品侧有一个更激进的诉求:桌面客户端用户录完音、点上传,上传完成的那一刻就应该收到通知和转写结果——不是"提交了,稍后回调",而是"传完即出"。正好转写服务供应商提供了一种极速转写能力——处理速度本身更快,而且结果直接在请求响应中同步返回,不需要走回调,但只支持 2 小时以内的录音——这是供应商的硬限制。于是为桌面客户端产生的、时长在 2 小时以内的录音开了一条"同步优先"通道:直接同步调用极速转写、尽快拿到结果,不用干等回调信号。

但同步调用意味着又把命运绑回了这一次请求的成功率——产品要快,可靠性不能丢。所以这条快速通道一旦失败,不是直接把任务标记失败,而是自动降级回退到"异步提交 + 等回调"的常规路径,保持任务状态继续往前走。两条路径并发存在时(比如降级发生的瞬间),状态更新做了幂等保护——先把任务状态置为"处理中"再持久化,避免快速通道和常规路径互相抢着改状态,把彼此的进度覆盖掉。

03 · 转写没有一次成功:分级重试 + 回调补偿 + 主动轮询兜底

不管走的是第 02 节的同步快速通道,还是常规的异步路径,最终都会碰到同一个问题:这一次调用可能失败。转写服务作为外部依赖,是运营商级别的服务,不稳定是常态而不是意外——限流、内部错误、超时,各种问题都会发生。所以这层设计了几道保险,有的层层递进,有的各管一段,每一层对应一种不同的失败方式:

这几层保险在系统里各自的位置

任务队列 待处理 → 处理中 → 完成/失败 转写服务 (外部供应商) 发起请求 限流闸门 请求级重试:单次调用内本地退避3次 任务级重试:失败打回"待处理",绕回队列重新排期 供应商主动推送结果(回调) 回调接收接口 POST /stt_task/callback 写入结果 补偿定时任务 每5分钟 · 重放未处理成功的回调 主动轮询兜底 独立定时任务 · 每小时
这几层加在一起,覆盖的是单次调用的各种失败模式,设计的目标不是消灭失败,而是让每一种失败都有对应的兜底路径接住它。但不是所有问题都能靠重试解决——哪些问题它接不住,是下一节要说的。

04 · 这套设计的代价,以及哪些问题它接不住

这套组合不是没有代价的。多一条同步快速通道,就多一份幂等保护的复杂度要维护;多几层重试和轮询,系统里同时活跃的状态机分支就更多,排查一个任务"为什么卡在处理中"要多看几个地方。用复杂度换可靠性,是笔明确的交易,不是免费的午餐。

它也不是万能的:如果转写服务本身长时间大范围不可用,再多重试和轮询也只是在延迟失败的发生,不能凭空制造出一个可用的转写结果——这套设计兜的是"偶发性、局部性"的失败,不是"下游整体宕机"这种系统性风险,后者需要更上层的降级预案(比如换一家转写服务、或者先把原始录音保留、事后补转写)。

但对"外部依赖单次调用大概率会在某个环节抖一下"这个更常见的问题,让每一种失败——超时、错误码、回调丢失、进程重启——都有一条对应的路径接住它,结果就是:最终转写成功与否,不再取决于某一次外部调用刚好没出问题。

这是《录音、转写、可靠性》系列的上篇,讲的是转写服务这一侧。下篇讲的是它的上游——macOS 原生录音客户端如何在录音中途扛住设备切换与音频异常,同一个项目的另一半技术方案。

如果你也在做涉及外部服务依赖、需要稳定性保障设计的系统,或者原生客户端的音频/设备处理,可以聊聊。

聊聊你的想法 →