背景:高并发下的“老毛病”
去年做在线口语评测,高峰期 3000 路并发,每路 16 kHz/16 bit 单声道。老架构走「整包 HTTP + 转码落盘」:
- 先等整句 PCM 收完,再送 ASR,平均延迟 1.8 s;
- 高峰期内存直接飙到 14 GB,GC 抖动把 RTT 又抬高 200 ms;
- 偶尔出现 TCP 队头阻塞,客户端 15 s 收不到数据,主动重连,导致雪崩。
一句话:内存泄漏 + 队头阻塞,让“实时”成了笑话。
技术选型:WebSocket?SSE?还是 gRPC?
语音流场景最看重“低延迟 + 抗抖动 + 省内存”。我把三种主流方案拉到一起跑分:
| 维度 | WebSocket | SSE | gRPC |
|---|---|---|---|
| 握手 RTT | 1-RTT | 0(复用 HTTP/1.1) | 1-RTT(HTTP/2) |
| 二进制帧 | 支持 | 仅 text/event-stream,需 base64 | 原生 |
| 背压控制 | 需自己写 | 无 | 有(HTTP/2 flow window) |
| 中间代理友好 | 一般 | 极佳 | 一般(需 h2) |
| 客户端普及 | 高 | 高 | 需额外 pb 包 |
结论:
- 如果团队已经统一 gRPC 栈,直接上 gRPC streaming 最省心;
- 要穿透企业代理、防火墙,SSE 最顺滑,但二进制帧得额外编码;
- WebSocket 在浏览器场景逃不掉,但得自己管 backpressure。
CosyVoice 的定位是“一键嵌入”,所以内部默认用 HTTP/1.1 + Chunked Encoding,暴露 WebSocket 桥接选项,让前端无感切换。
核心实现三板斧
1. 分块编码:把“整包”拆成“流水”
HTTP/1.1 原生支持 Transfer-Encoding: chunked,每块 5-10 帧 PCM(20 ms/帧),服务端 flush 一次,客户端就能播放。
Go 示例:
func (w *pcmWriter) WriteChunk(frames []byte) error { // 单块 < 0x8000,避免 IP 分片 if len(frames) > 32768 { return errors.New("chunk too big") } // 写入 {hexSize}\r\n{data}\r\n if _, err := fmt.Fprintf(w.rw, "%x\r\n", len(frames)); err != nil { return err } if _, err := w.rw.Write(frames); err != nil { return err } if _, err := w.rw.Write([]byte("\r\n")); err != nil { return err } return w.rw.Flush() // 关键:立刻刷到内核 }要点:
- 单块别超过 32 KB,省得被 IP 层再分片;
- 每次 Flush,把 TCP 的 Nagle 绕过去,延迟立降 60-80 ms。
2. 动态缓冲:高/低水位线
流式最怕“写太快、读太慢”导致内存爆炸。CosyVoice 在服务端加了一个环形缓冲,用 channel 做背压:
type streamBuf struct { ch chan []byte low, high int // 水位线,单位:ms } func (s *streamBuf) Write(p []byte) bool { select使用时间戳估算缓冲时长: bufferedMs := len(s.ch) * 20 // 每帧 20 ms if bufferedMs > s.high { // 高水位:阻塞生产端,避免 OOM time.Sleep(time.Duration(bufferedMs-s.low) * time.Millisecond) } select { case s.ch <- p: return true default: // 低水位也丢弃,保证实时性 return false } }经验值:
- 高水位 400 ms(20 帧),低水位 200 ms(10 帧);
- 超过高水位就 sleep,低于低水位才丢包,既防抖动又省内存。
3. 错误恢复:指数退避重试
公网抖动 3% 很正常,客户端必须能“无缝续播”。重试策略伪代码(Python):
retry: delay = 0.1 for i in range(5): try: sock = reconnect() sock.send(seq_id) # 告诉服务端从哪帧开始补发 return sock except OSError: time.sleep(delay) delay = min(delay*2, 4) # 指数退避,封顶 4 s raise GiveUp()服务端收到 seq_id 后,回溯缓冲区内帧,重新流式下发;客户端把补发帧插到 jitter buffer,用户基本无感。
性能测试:数据说话
测试机:4C8G 容器,同机房千兆。
场景:300 并发路,每路 20 min 连续语音。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均 RTT | 1.8 s | 0.35 s |
| 95% CPU | 310% | 180% |
| 峰值内存 | 14.2 GB | 9.6 GB(降 32%) |
压测脚本(wrk):
wrk -t12 -c400 -d30s --latency -s voice.lua http://cosy:8080/streamvoice.lua 里用response(1)只收不解析,模拟纯拉流。
结论:分块 + 水位线,让 CPU 降 42%,内存省 30% 以上。
避坑指南
- 防乱序:在帧头加 2 字节 seq,客户端 jitter buffer 按 seq 重排,播放线程只认单调递增。
- 心跳间隔:NAT 普遍 60 s 过期,设 30 s 心跳包,大小 1 byte,既保活又省流量。
- 资源释放:Go 的
Flush后一定Close(),否则 chunk 末尾的 0\r\n\r\n 不会发出,客户端会卡“转圈”。 - 代理缓存:某些老派 Squid 看到 chunked 也会缓存,记得加 Cache-Control: no-cache, no-store。
延伸:QUIC 还能再榨 10 ms
HTTP/3 基于 QUIC,0-RTT 握手 + 无队头阻塞。CosyVoice 已出实验分支:
- 把 UDP 音频包直接封装在 QUIC STREAM;
- 内网同机房测试,RTT 再降 8-12 ms,弱网丢包 5% 时无额外抖动;
- 目前限制:部分企业防火墙默认拦 UDP 443,需要协商回退到 TCP。
如果你家客户端全在 4G/5G,不妨先灰度 QUIC,收益肉眼可见。
踩完坑回头看,CosyVoice StreamingResponse 把“流式”真正做到了“实时”:内存降三成,延迟降八成,代码量却没涨。
现在凌晨两点,看着监控曲线终于不再像心电图,可以安心睡觉了。