2. RTSP协议基础
推流架构设计,说白了就是搞清楚数据从摄像头到网络这条路上,每一步该怎么走。我见过不少团队,一上来就调编码参数、折腾推流协议,结果画面卡顿、花屏、延迟爆炸——回头一看,链路设计压根没想清楚。
这一节,我把推流端拆成四个核心环节来讲:采集 → 编码 → 封装 → 发送。每个环节都有坑,我会把我在项目中踩过的、填过的,都摊开来说。
2.1 推流端整体架构
先看一张整体架构图,帮你建立全局印象。
这个架构图里,我特意把缓冲区画在了中间偏下的位置。为什么?因为缓冲区是整个推流系统的心脏。采集太快、编码太慢、网络抖动——所有的不匹配,最终都要靠缓冲区来消化。
2.2 采集-编码-封装-发送链路详解
2.2.1 采集层
采集是推流的起点。常见的采集源有摄像头、屏幕、麦克风、系统音频输出。我个人习惯把采集分成两类:实时采集和文件回放。实时采集对延迟敏感,文件回放则更关注帧率稳定性。
采集层最容易踩的坑是什么?采集格式不统一。比如摄像头输出 NV12,屏幕采集输出 BGRA,麦克风输出 PCM S16LE。如果不在采集层做统一转换,后面编码器会直接崩溃。
2.2.2 编码层
编码层是推流链路中最吃计算资源的环节。编码器的选择直接影响画质、码率和延迟。关于编码器选型,我在第 4 章会详细展开,这里先讲链路设计。
编码层需要关注三个参数:
- 编码帧率:通常与采集帧率一致,但网络差时可以降帧
- GOP 结构:IDR 帧间隔决定了关键帧刷新频率,影响 seek 和丢包恢复
- 码率控制:CBR 适合推流,VBR 适合本地存储
我记得有一次做直播推流,编码器输出帧率突然掉到 15fps,画面一卡一卡的。排查了半天,发现是编码器线程被采集线程阻塞了——两个线程共用一个锁。后来改成生产者-消费者队列,问题就解决了。
2.2.3 封装层
封装层负责把编码后的视频流和音频流打包成容器格式。RTSP 推流常用的封装格式有:
| 封装格式 | 适用场景 | 特点 |
|---|---|---|
| PS(Program Stream) | 存储、本地回放 | 结构简单,适合文件 |
| TS(Transport Stream) | 实时传输、广播 | 抗丢包,支持拼接 |
| FLV | RTMP 推流 | 低延迟,Web 友好 |
封装层有个容易被忽略的点:时间戳管理。视频和音频的 PTS(显示时间戳)必须基于同一个时钟基准。我见过有人直接把系统时间戳塞进去,结果音画不同步得一塌糊涂。
正确的做法是:用采集时的单调时钟(monotonic clock)生成 PTS,编码和封装过程中保持 PTS 的相对关系不变。
2.2.4 发送层
发送层负责把封装好的数据包通过 RTSP 协议发送到服务器。发送层最核心的指标是发送速率和发送间隔。
发送速率不能超过网络带宽,否则会引发拥塞。发送间隔则要模拟帧率——比如 30fps 的视频,每帧的发送间隔大约是 33ms。
我曾经犯过一个错误:编码器输出一帧,我立刻调用 send() 发出去。结果网络稍微一波动,发送队列就堆积,延迟从 1 秒飙到 10 秒。后来我加了一个发送调度器,按帧率均匀发送,延迟就稳住了。
2.3 推流缓冲区设计
缓冲区设计是推流架构里最容易被低估的部分。很多人觉得缓冲区就是个队列,往里塞数据、往外取数据就行了。其实没那么简单。
2.3.1 缓冲区的作用
- 解耦:让采集、编码、发送三个环节独立运行,互不阻塞
- 平滑:吸收网络抖动和编码器输出波动
- 抗丢包:在发送失败时提供重传机会
2.3.2 缓冲区结构
我常用的缓冲区结构是环形缓冲区(Ring Buffer)。为什么不用链表?因为环形缓冲区内存预分配,没有动态分配的开销,适合实时场景。
// 环形缓冲区核心结构(伪代码)
struct RingBuffer {
uint8_t* data; // 数据区
int capacity; // 总容量(帧数)
int write_index; // 写指针
int read_index; // 读指针
int count; // 当前帧数
pthread_mutex_t lock; // 互斥锁
pthread_cond_t not_empty; // 非空条件变量
pthread_cond_t not_full; // 非满条件变量
};
2.3.3 水位管理
缓冲区不能太大,也不能太小。太大会增加延迟,太小则容易溢出。我一般设置三个水位:
| 水位 | 阈值 | 动作 |
|---|---|---|
| 低水位 | 20% | 通知采集层加速(如果有降帧策略) |
| 高水位 | 80% | 通知编码器降码率或降帧 |
| 满水位 | 100% | 丢弃非关键帧(B/P帧),保留 IDR 帧 |
2.3.4 避坑指南
我曾经在缓冲区设计上栽过一个大跟头。当时做一款安防摄像头推流,缓冲区用的是动态链表,每来一帧就 malloc 一次。结果跑了几个小时,内存碎片化严重,推流越来越卡。后来改成环形缓冲区,内存预分配,问题就解决了。
另一个坑是缓冲区锁竞争。如果采集线程和发送线程频繁争锁,性能会急剧下降。我的解决方案是:用 CAS(Compare-And-Swap)无锁操作来管理读写指针,只在缓冲区满或空时才加锁等待。
2.4 链路延迟分析
推流链路的延迟由每个环节的耗时累加而成。我整理了一个典型延迟分布:
| 环节 | 典型延迟 | 优化方向 |
|---|---|---|
| 采集 | 10-30ms | 使用零拷贝采集接口 |
| 编码 | 20-100ms | 硬件编码、降低编码复杂度 |
| 封装 | 1-5ms | 减少内存拷贝 |
| 发送 | 5-50ms | 调整发送缓冲区大小 |
| 缓冲区 | 30-200ms | 降低水位阈值 |
你看,缓冲区延迟占了很大一部分。所以如果你对延迟要求极高(比如远程手术、云游戏),缓冲区要尽量小,甚至直接旁路掉。但代价是抗抖动能力变差。
嗯,这就是推流端架构设计的核心内容。链路设计清楚了,缓冲区设计合理了,后面调编码器参数、优化网络传输才有意义。否则,再好的编码器也救不了糟糕的架构。