推流架构设计:从采集到发送的完整链路

推流架构这件事,说白了就是搞清楚数据怎么从摄像头一路跑到网络上去。我见过不少团队,上来就写代码,结果延迟高、卡顿多,最后发现是架构设计埋了坑。这一章,咱们把推流端的整体架构、采集-编码-封装-发送链路、缓冲区模型和线程模型掰开揉碎了讲清楚。

推流端整体架构

先看一张整体架构图,我习惯把推流端分成四个模块:采集模块、编码模块、封装模块、发送模块。每个模块之间通过缓冲区解耦,线程模型负责调度。

核心原则:模块间低耦合,缓冲区做缓冲,线程分工明确。别让一个模块的抖动拖垮整个链路。

推流端整体架构图 采集模块 摄像头/麦克风 编码模块 H.264/H.265/AAC 封装模块 RTSP/RTP/PS 发送模块 TCP/UDP 网络 原始帧缓冲 YUV/PCM 编码帧缓冲 H.264 NAL 发送缓冲 RTP 包 缓冲区解耦各模块 避免级联阻塞

采集-编码-封装-发送链路详解

这条链路是推流的心脏。每个环节都有坑,我一个个说。

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 重传会阻塞后续数据,导致延迟飙升。我建议在公网场景下,发送缓冲区要适当加大。

推流缓冲区模型

缓冲区是架构的「减震器」。没有缓冲区,采集一帧编码一帧发送一帧,任何一个环节卡顿都会导致整个链路阻塞。

我常用的三级缓冲区模型:

  1. 原始帧缓冲:存放采集到的 YUV/PCM 数据。大小通常设为 2-3 帧,避免采集速度波动导致编码器饿死。
  2. 编码帧缓冲:存放编码后的 NAL 单元。这里要注意,编码器输出可能不均匀(I 帧大,P 帧小),缓冲区要能容纳一个 GOP 的数据。
  3. 发送缓冲:存放待发送的 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 包没发完,接收端花屏了好几秒。