26、直播推流架构:采集-编码-推流链路、缓冲区设计、内存管理、线程模型
做直播推流,说白了就是一条流水线:把摄像头和麦克风的数据抓过来,压缩成小尺寸,然后扔到网络上去。听起来简单,但真正落地的时候,坑多得很。
我记得刚接手公司第一个直播项目时,觉得不就是 MediaCodec 编码 + RTMP 推流嘛,三天搞定。结果呢?卡顿、花屏、内存暴涨、OOM…… 嗯,后来老老实实把架构重新梳理了一遍。今天我就把这条链路拆开,跟你聊聊每个环节该怎么设计。
采集 → 编码 → 推流:整条链路怎么串
先看整体流程。我习惯把它分成三个模块:
- 采集模块:从 Camera 和 AudioRecord 拿到原始数据
- 编码模块:用 MediaCodec 把 YUV 和 PCM 压缩成 H.264 和 AAC
- 推流模块:把编码后的数据包通过 RTMP 协议发到服务器
这三个模块是串行的,但你不能让它们在一个线程里跑。为什么?因为采集是 30fps 的节奏,编码是异步的,推流又依赖网络。混在一起,一个环节卡住,整条线就崩了。
核心原则:采集、编码、推流各自独立线程,通过缓冲区解耦。
下面这张图是我项目里实际用的架构,你可以参考:
缓冲区设计:别让数据堵在路上
缓冲区是整条链路的命脉。我见过太多人直接用 ConcurrentLinkedQueue 往里塞数据,结果内存越堆越高,最后 OOM。
我个人习惯用环形缓冲区。为什么?因为它固定大小,不会无限增长。采集线程往里写,编码线程往外读,写满了就阻塞或者丢帧——这比让内存爆炸强多了。
我的经验:视频环形缓冲区我一般设 3 帧。音频缓冲区设 2048 个采样点。太小了容易丢帧,太大了延迟高。3 帧是个不错的平衡点。
编码输出到推流之间,我用的则是Packet 队列。这里要注意:编码器输出的数据包大小不一,I 帧可能几十 KB,P 帧可能只有几 KB。队列长度我控制在 30 个包以内,超过就丢弃非关键帧。
注意:千万不要在编码回调里直接做网络 I/O。MediaCodec 的回调线程是内部线程,阻塞了会影响编码器性能。正确的做法是把编码后的数据包扔到队列里,让推流线程自己去取。
内存管理:别让 GC 成为你的噩梦
做直播最怕什么?最怕 GC 卡顿。你想想看,正推流呢,突然 GC 触发,卡了 100ms,画面就跳了一下。用户看到的就是花屏、卡顿。
我曾经在一个项目里,推流 10 分钟必卡一次。查了半天,发现是每次采集帧都 new 了一个 byte[]。GC 频繁触发,编码器输入队列就堵了。
解决方案其实不复杂:
- 预分配内存池:启动时一次性申请好 N 帧的 YUV 缓冲区,采集时直接复用,不 new 对象
- DirectBuffer 优先:MediaCodec 的 inputBuffer 是 DirectBuffer,尽量用 ByteBuffer.allocateDirect() 分配,避免堆内存拷贝
- 避免自动装箱:回调里不要用 Integer、Long 这些包装类,直接用基本类型
核心思路:推流过程中,零内存分配是理想目标。做不到零分配,也要做到极少分配。
下面是我项目里用的内存池伪代码,你可以参考:
// 视频帧内存池
class VideoFramePool {
private final ArrayDeque<ByteBuffer> pool = new ArrayDeque<>();
private final int frameSize; // 例如 1280x720 的 YUV 是 1382400 字节
public VideoFramePool(int frameSize, int poolSize) {
this.frameSize = frameSize;
for (int i = 0; i < poolSize; i++) {
pool.offer(ByteBuffer.allocateDirect(frameSize));
}
}
public ByteBuffer acquire() {
ByteBuffer buf = pool.poll();
if (buf == null) {
// 极端情况:所有缓冲区都在用,直接分配新的
// 但这种情况应该极少发生
return ByteBuffer.allocateDirect(frameSize);
}
return buf;
}
public void release(ByteBuffer buf) {
buf.clear();
pool.offer(buf);
}
}
线程模型:三条线程各司其职
线程模型我用了经典的生产者-消费者模式。三条线程,各自独立:
| 线程 | 职责 | 循环周期 | 注意事项 |
|---|---|---|---|
| 采集线程 | 从 Camera/AudioRecord 读取数据,写入环形缓冲区 | 30fps(视频)/ 按采样率(音频) | 不要做耗时操作,写缓冲区要快 |
| 编码线程 | 从环形缓冲区读取原始数据,送入 MediaCodec,取出编码后的 Packet | 异步,依赖 MediaCodec 回调 | 编码回调里只做数据搬运,不要阻塞 |
| 推流线程 | 从 Packet 队列取出数据,通过 RTMP 发送 | 按网络节奏,可能 1-10ms 一次 | 网络 I/O 可能阻塞,要做好超时处理 |
这里有个细节:编码线程和推流线程之间,我用了阻塞队列。推流线程如果网络慢,队列会堆积。这时候编码线程不能无限等,我设了一个超时时间——如果队列满了,直接丢弃非关键帧。
避坑指南:我曾经在推流线程里直接调了 socket.write(),没设超时。结果有一次服务器挂了,推流线程卡死,整个编码链路都堵住了。后来我给所有网络操作都加了 3 秒超时,并且用单独的线程池管理连接。
整体架构的取舍
你可能会问:为什么不用一个线程搞定所有事?嗯,理论上可以,但实际不行。单线程意味着采集、编码、推流串行执行,任何一个环节卡住,其他环节都得等。而多线程虽然增加了复杂度,但换来了更好的流畅度。
我个人建议:
- 如果只是做简单的录屏推流,可以用双线程(采集+编码一个线程,推流一个线程)
- 如果是摄像头直播,建议三线程,因为 Camera 的回调频率高,不能被打断
- 如果还要做美颜、滤镜,那就再加一个处理线程,变成四线程
线程多了,同步就成了关键。我习惯用 ReentrantLock 配合 Condition 做缓冲区同步,而不是用 synchronized。因为 ReentrantLock 支持超时等待,可以避免死锁。
总结一句话:直播推流架构的核心,就是用缓冲区解耦各模块,用内存池减少 GC,用独立线程避免互相阻塞。把这三点做好,你的推流就稳了八成。
好了,这一章就聊到这里。下一章我们具体看看 RTMP 协议怎么封装,以及怎么处理网络抖动。到时候我会分享一个我踩过的坑——嗯,跟 flv 封装的时序有关,挺有意思的。
公众号:蓝海资料掘金营,微信deep3321