推流架构设计:从采集到发送的完整链路
推流架构这件事,说白了就是搞清楚数据怎么从摄像头一路跑到网络上去。我见过不少团队,上来就写代码,结果延迟高、卡顿多,最后发现是架构设计埋了坑。这一章,咱们把推流端的整体架构、采集-编码-封装-发送链路、缓冲区模型和线程模型掰开揉碎了讲清楚。
推流端整体架构
先看一张整体架构图,我习惯把推流端分成四个模块:采集模块、编码模块、封装模块、发送模块。每个模块之间通过缓冲区解耦,线程模型负责调度。
核心原则:模块间低耦合,缓冲区做缓冲,线程分工明确。别让一个模块的抖动拖垮整个链路。
采集-编码-封装-发送链路详解
这条链路是推流的心脏。每个环节都有坑,我一个个说。
1. 采集模块
采集负责从摄像头、麦克风拿到原始数据。视频一般是 YUV 或 RGB,音频是 PCM。
- 视频采集:用 V4L2(Linux)或 DirectShow(Windows)抓帧。注意分辨率、帧率、像素格式的匹配。
- 音频采集:ALSA 或 WASAPI,采样率 44100 或 48000,位深 16bit 常见。
- 避坑:我曾经遇到过摄像头输出 NV12,但编码器只认 I420,中间没做格式转换,画面直接花屏。嗯,采集和编码之间的格式协商一定要提前做好。
2. 编码模块
原始数据太大,必须压缩。视频用 H.264/H.265,音频用 AAC。
- 硬件编码 vs 软件编码:硬件编码(如 Intel QSV、NVIDIA NVENC)延迟低、功耗小,但画质略差;软件编码(x264)画质好,但 CPU 吃紧。
- 关键参数:GOP 大小、码率控制模式(CBR/VBR/CRF)、preset(ultrafast/medium/slow)。
- 我的习惯:直播场景用 CBR + ultrafast preset,保证码率平稳;录制场景用 CRF + slow preset,画质优先。
小技巧:编码器输出的是 NAL 单元(H.264)或 ADTS 帧(AAC)。记得给每个帧打上 PTS(显示时间戳),后面封装和发送全靠它。
3. 封装模块
编码后的数据要打包成 RTSP 能理解的格式。RTSP 通常搭配 RTP 传输,视频封装成 RTP 包,音频也一样。
- 视频 RTP 打包:H.264 用 FU-A 分片(单个 NAL 太大时),或 STAP-A 聚合(小 NAL 合并)。
- 音频 RTP 打包:AAC 用 LATM 或 ADTS 格式,每个 RTP 包放一帧或几帧。
- 时间戳管理:RTP 时间戳基于采样率(视频 90000Hz,音频按实际采样率)。
4. 发送模块
最后一步,把 RTP 包扔到网络上。传输层可选 TCP 或 UDP。
| 传输协议 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| UDP | 延迟低,开销小 | 丢包不重传,可能花屏 | 内网、低延迟直播 |
| TCP | 可靠传输,无丢包 | 延迟高,重传导致抖动 | 公网、弱网环境 |
注意:RTSP 的 RTP over TCP 模式(即 RTP 包封装在 RTSP 的 TCP 连接中)虽然可靠,但一旦网络抖动,TCP 重传会阻塞后续数据,导致延迟飙升。我建议在公网场景下,发送缓冲区要适当加大。
推流缓冲区模型
缓冲区是架构的「减震器」。没有缓冲区,采集一帧编码一帧发送一帧,任何一个环节卡顿都会导致整个链路阻塞。
我常用的三级缓冲区模型:
- 原始帧缓冲:存放采集到的 YUV/PCM 数据。大小通常设为 2-3 帧,避免采集速度波动导致编码器饿死。
- 编码帧缓冲:存放编码后的 NAL 单元。这里要注意,编码器输出可能不均匀(I 帧大,P 帧小),缓冲区要能容纳一个 GOP 的数据。
- 发送缓冲:存放待发送的 RTP 包。网络拥塞时,这个缓冲区会积压,需要配合拥塞控制策略。
关键指标:缓冲区大小 = 期望容忍的延迟 × 平均码率 / 8。比如容忍 500ms 延迟,码率 4Mbps,缓冲区大小 = 0.5 × 4,000,000 / 8 = 250KB。别设太大,否则延迟会失控。
推流线程模型设计
线程模型决定了推流的并发能力和稳定性。我见过单线程推流,采集、编码、发送全在一个线程里跑,结果编码一卡,采集也卡,画面直接冻住。
推荐的多线程模型:
| 线程名称 | 职责 | 优先级 | 注意事项 |
|---|---|---|---|
| 采集线程 | 从设备读取原始帧,放入原始帧缓冲 | 高 | 避免阻塞,用异步 I/O 或双缓冲 |
| 编码线程 | 从原始帧缓冲取数据,编码后放入编码帧缓冲 | 中 | 编码是 CPU 密集,线程数不宜过多 |
| 封装线程 | 从编码帧缓冲取 NAL,打包成 RTP,放入发送缓冲 | 中 | 注意时间戳计算,避免抖动 |
| 发送线程 | 从发送缓冲取 RTP 包,通过 socket 发送 | 高 | 网络 I/O 用非阻塞模式,配合 epoll/select |
线程间通信用无锁队列或带锁的环形缓冲区。我习惯用无锁队列(如 boost::lockfree::spsc_queue),减少锁竞争。你想想看,编码线程和封装线程之间如果频繁加锁解锁,性能损耗可不小。
经验之谈:我曾经在一个项目中,编码线程和发送线程共用同一个缓冲区,结果编码器输出突发大帧时,发送线程来不及发,缓冲区溢出。后来改成三级缓冲,每个线程只管自己的缓冲区,问题就解决了。
线程模型中的避坑指南
- 线程优先级不要乱设:采集和发送线程设高优先级,编码线程设普通优先级。否则编码线程抢 CPU,采集线程拿不到时间片,画面会掉帧。
- 避免线程间依赖:每个线程独立运行,不要互相等待。用条件变量或事件通知,而不是轮询。
- 线程退出要优雅:推流停止时,先停止采集,等编码和发送线程处理完缓冲区里的数据再退出。我曾经直接 kill 线程,结果 RTP 包没发完,接收端花屏了好几秒。