ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RTMP推流深度解析:Chunk分块、时间戳对齐与断流重连实战

RTMP推流深度解析:Chunk分块、时间戳对齐与断流重连实战

1. 为什么又要回头聊RTMP推流

做音视频开发这些年,我有个很深的体会:RTMP这个协议,你绕不开它。不管后来WebRTC多火、SRT多能打、HLS多普及,只要涉及到“推流”这个动作,尤其是直播场景的上行链路,RTMP依然是绝大多数平台默认的第一选择。我见过太多项目,前端播放已经换成HTTP-FLV或者WebRTC了,但推流端还是老老实实跑着RTMP。

这次“再探”,不是重复造轮子,而是因为我在实际项目里踩了几个之前没注意到的坑,回头把RTMP推流的底层逻辑重新捋了一遍。这篇文章适合两类人:一是刚接触音视频、准备自己写一个推流客户端的开发者;二是已经用过现成库(比如FFmpeg、librtmp)推流,但遇到延迟高、花屏、断流等问题不知道怎么排查的人。我会从协议握手讲到时间戳对齐,从缓冲区设计讲到网络抖动处理,尽量把“为什么这么设计”说透。

核心关键词就几个:RTMP推流、Chunk分块、时间戳、握手、音视频同步。你把这几个词搞明白了,推流这件事就通了八成。

2. RTMP推流的整体链路与设计思路

2.1 推流到底在推什么

很多人一上来就写代码调av_interleaved_write_frame,但没想过一个根本问题:推流推的不是文件,是流。文件可以慢慢写,流必须实时。这个“实时”两个字,决定了RTMP协议的所有设计取舍。

一个完整的RTMP推流链路是这样的:采集(摄像头/麦克风)→ 编码(H.264/AAC)→ 封装(FLV Tag)→ 分块(Chunk)→ 传输(TCP)→ 服务端接收。每一层都有它的脾气。采集和编码我们这次不展开,重点在封装、分块和传输这三层,因为这是RTMP推流客户端自己必须实现的部分。

为什么用FLV Tag封装?因为RTMP协议在设计之初就是为Flash服务的,FLV是Flash的原生容器格式。虽然Flash已经退出历史舞台,但FLV Tag的结构足够简单——一个Tag Header加Tag Data,Tag Header里标明类型(音频/视频/脚本)、数据大小、时间戳。这种简单结构对实时传输非常友好,解析开销小。

2.2 为什么不用HTTP直接推

有人会问,既然TCP都能传,为什么不用HTTP POST把编码后的数据块直接发给服务器?原因在于双向通信和低延迟。HTTP是请求-响应模型,推流端发一个请求,得等服务器响应才能发下一个,这个往返延迟在直播场景里是不可接受的。RTMP建立在TCP之上,但它在应用层维护了一个持久连接,双方可以随时发数据,不需要等对方回应。而且RTMP有自己的一套消息分块机制,可以在一个TCP连接上多路复用音频、视频、脚本数据,互不阻塞。

2.3 握手阶段的设计逻辑

RTMP握手是整个推流的第一步,也是最容易被忽略的一步。很多人觉得握手就是走个过场,其实握手阶段协商的版本号和时间戳,会影响后续的Chunk解析。

标准握手是三次交换,每次交换一个固定大小的块(C0+C1、S0+S1+S2、C2)。C0和S0各占1字节,表示RTMP版本号,目前基本都是3。C1和S1各占1536字节,前4字节是时间戳,后4字节是零,再后面1528字节是随机数。C2和S2是对应方的回应,内容基本是复制对方的S1和C1。

这里有个细节:时间戳字段在握手阶段其实没什么实际作用,但有些服务端实现会校验它是否为零。我遇到过某服务端在C1时间戳非零时直接断开连接的情况,排查了半天才发现是握手阶段的问题。所以稳妥起见,C1和S1的时间戳字段填零就行。

注意:握手阶段的随机数部分不要填全零,某些服务端会做简单校验,全零可能被拒绝。用随机数填充即可。

3. Chunk分块机制与消息封装细节

3.1 Chunk Header的四种格式

RTMP最核心的设计就是Chunk分块。一个消息(Message)可能很大,比如一个视频关键帧可能有几十KB,但RTMP的Chunk默认大小是128字节。所以一个消息要被拆成多个Chunk发送。每个Chunk前面有一个Chunk Header,分为四种格式:

格式头部大小包含字段使用场景
Type 011字节时间戳、消息长度、消息类型、流ID消息的第一个Chunk
Type 17字节时间戳增量、消息长度、消息类型同一流的上一个消息之后
Type 23字节时间戳增量同一消息的后续Chunk
Type 30字节无同一消息的后续Chunk且时间戳增量相同

这个设计非常巧妙。Type 0最完整,用于新消息的开始。Type 1省略了流ID,因为同一个连接上流ID通常不变。Type 2进一步省略了消息长度和类型,因为同一个消息的后续Chunk这些字段都一样。Type 3最省,连时间戳增量都省了,直接沿用上一个Chunk的。

为什么这么设计?因为RTMP要在一个TCP连接上同时传音频、视频、脚本数据,如果每个Chunk都带完整头部,开销太大。通过这种增量编码,大部分Chunk的头部只有3字节甚至0字节,传输效率大幅提升。

3.2 Chunk Size的协商与调整

默认Chunk Size是128字节,这个值对于视频数据来说太小了。一个1080p的关键帧可能100KB,按128字节分块要分成800个Chunk,每个Chunk至少1字节头部,光头部开销就800字节。所以RTMP允许通过Set Chunk Size控制消息来调整Chunk Size,最大可以到65536字节。

我一般会把Chunk Size设成4096或8192。设太大也不好,因为TCP本身有MTU限制,Chunk太大可能导致底层分片,增加丢包重传的概率。4096是一个比较平衡的值,既减少了Chunk数量,又不会给TCP造成太大压力。

调整Chunk Size的时机是在握手完成之后、发送音视频数据之前。发送一个Type 0的Chunk,消息类型ID为1(Set Chunk Size),消息体是4字节的新Chunk Size值(大端序)。服务端收到后会回复一个同样的控制消息,之后双方都用新的Chunk Size。

3.3 消息类型与流ID的分配

RTMP的消息类型ID决定了这个消息是干什么的。推流场景常用的有:

  • 8:音频消息
  • 9:视频消息
  • 18:脚本数据(AMF0编码),用于发送onMetaData
  • 20:命令消息(AMF0编码),用于connect、publish等
  • 1:Set Chunk Size
  • 5:Window Acknowledgement Size
  • 6:Set Peer Bandwidth

流ID在推流场景下,音频和视频消息的流ID通常都是1。命令消息的流ID是0。这个不要搞错,流ID错了服务端会直接忽略消息。

3.4 AMF0编码的坑

connect和publish命令以及onMetaData都是用AMF0编码的。AMF0是一种二进制序列化格式,支持Number、Boolean、String、Object、Null、ECMA Array等类型。手写AMF0编码很容易出错,尤其是Object和ECMA Array的嵌套。

我建议直接用现成的AMF0库,比如FFmpeg里的libavformat/rtmpproto.c就有完整的实现。如果非要自己写,注意几个点:String类型是1字节类型标记加2字节长度加UTF-8内容;Number是1字节标记加8字节双精度浮点数(大端序);Object是1字节标记加键值对,以0x00 0x00 0x09结尾。

实操心得:onMetaData里至少要包含width、height、videodatarate、framerate、videocodecid、audiodatarate、audiocodecid、audiosamplerate、audiosamplesize、stereo这些字段。有些服务端会根据这些信息做转码决策,缺了可能导致播放端花屏或无声。

4. 时间戳对齐与音视频同步的实操

4.1 时间戳的单位与基准

RTMP的时间戳单位是毫秒,这一点和很多人的直觉不同。H.264的PTS通常是90kHz时钟,AAC是采样率时钟,推流前必须统一转换成毫秒。

转换公式很简单:rtmp_ts = pts * 1000 / timebase。比如H.264的timebase是1/90000,一个PTS为90000的帧,对应RTMP时间戳就是1000毫秒。

但这里有个坑:时间戳的基准点。音频和视频必须用同一个基准点,否则会出现音视频不同步。我一般用第一个音频帧或第一个视频帧的采集时间作为基准,后续所有帧的时间戳都相对于这个基准计算。

4.2 音视频交错发送的策略

RTMP允许在一个连接上交错发送音频和视频Chunk,但怎么交错是有讲究的。如果先发完所有视频再发音频,播放端会先看到画面没声音,然后突然声音追上来。正确的做法是按时间戳顺序交错发送。

具体实现时,我维护两个队列:音频队列和视频队列。每次从两个队列头部取时间戳较小的那个发送。如果时间戳相同,优先发视频(因为视频帧通常更大,早发可以早传输)。

但实际网络环境中,编码器输出的音视频帧到达推流端的时间可能不均匀。比如视频编码慢,音频帧已经攒了5帧,视频才出来1帧。这时候如果严格按时间戳排序,音频会等待视频,导致整体延迟增加。我的做法是设置一个最大等待窗口,比如200毫秒。如果音频队列头部的时间戳比视频队列头部小超过200毫秒,就先把音频发出去,不等视频了。

4.3 时间戳回绕的处理

RTMP时间戳是32位无符号整数,最大值是4294967295毫秒,约49.7天。超过这个值会回绕到0。对于长时间运行的推流(比如监控摄像头),必须处理回绕。

处理方法是维护一个64位的时间戳,发送时取低32位。接收端如果发现当前时间戳比上一个时间戳小很多(超过2^31),就认为发生了回绕,给高位加1。这个逻辑在推流端和服务端都要有,否则回绕时会出现时间戳跳变,导致播放端卡顿或花屏。

4.4 音频时间戳的累积误差

AAC音频帧的采样数通常是1024,采样率44100Hz,那么每帧的时长是1024/44100≈23.22毫秒。如果每帧都按23毫秒计算,累积误差会越来越大。1000帧后误差达到220毫秒,音视频就明显不同步了。

正确的做法是用采样数累积计算时间戳:ts = total_samples * 1000 / sample_rate。total_samples是到目前为止所有音频帧的采样数之和。这样计算出来的时间戳是精确的,没有累积误差。

视频同理,用帧数累积计算:ts = frame_index * 1000 / framerate。但视频帧率可能不恒定(比如可变帧率编码),这时候就要用实际的PTS转换,不能用帧数累积。

5. 网络抖动与断流重连的实战处理

5.1 TCP缓冲区与发送阻塞

RTMP跑在TCP上,TCP有发送缓冲区和接收缓冲区。当网络拥塞时,发送缓冲区会满,send调用会阻塞。如果推流端是单线程,阻塞会导致采集和编码也停摆,画面卡住。

我的做法是把推流放在独立线程,采集和编码在另一个线程,中间用一个有界队列连接。队列满了就丢帧,而不是阻塞采集。丢帧策略是:优先丢非关键帧,关键帧尽量保留。如果队列满的时候来的是关键帧,就丢掉队列里最老的非关键帧,腾出位置给关键帧。

队列大小怎么定?我一般设为500毫秒的帧数。比如30fps的视频,队列大小设15帧左右。太小容易丢帧,太大延迟高。

5.2 断流检测与重连

TCP连接断开时,send会返回错误。但有时候网络只是抖动,TCP还没断,只是发送变慢。这时候需要设置发送超时。我一般设5秒,如果5秒内send没完成,就认为连接有问题,主动断开重连。

重连不是简单的重新connect。重连后需要重新握手、重新发送connect和publish命令、重新发送onMetaData。而且重连期间采集和编码不能停,否则重连后时间戳会跳变。我的做法是重连期间继续采集和编码,把帧缓存在队列里,重连成功后从队列里取帧发送,但时间戳要重新对齐到重连后的第一个帧。

注意:重连后不要立即发送缓存的所有帧,否则会瞬间产生大量数据,再次压垮网络。应该只发送最新的关键帧和之后的帧,之前的非关键帧丢掉。

5.3 弱网下的码率自适应

严格来说,RTMP协议本身不支持码率自适应,但推流端可以根据网络状况调整编码码率。我实现过一个简单的自适应逻辑:统计每秒发送的字节数,如果连续3秒低于目标码率的70%,就通知编码器降低码率;如果连续10秒高于目标码率的90%,就尝试提高码率。

降码率时优先降分辨率和帧率,而不是降画质。因为降画质对主观体验的影响更大。比如从1080p降到720p,比从2Mbps降到1Mbps但保持1080p,观感更好。

5.4 常见问题速查表

现象可能原因排查方法解决措施
推流成功但播放端黑屏未发送onMetaData或字段缺失抓包看是否有类型18的消息补全onMetaData字段
音视频不同步时间戳基准不一致对比音视频首帧时间戳统一基准点,用采样数累积
播放端花屏Chunk Size过大导致TCP分片丢包抓包看是否有重传降低Chunk Size到4096
推流几分钟后断开时间戳回绕未处理检查时间戳是否突然变小实现64位时间戳回绕逻辑
延迟越来越高队列积压监控队列长度丢帧策略,限制队列大小
声音断断续续音频时间戳累积误差检查时间戳计算方式改用采样数累积计算

6. 从零实现一个最小推流客户端的核心步骤

6.1 环境准备与依赖选择

如果你不想从socket开始写,可以用现成的库。FFmpeg的libavformat提供了完整的RTMP推流支持,调用avformat_alloc_output_context2时指定flv格式和rtmp协议即可。但用FFmpeg有个问题:它帮你做了太多事,出了问题不好排查。

我的建议是:先用FFmpeg跑通流程,再自己实现一遍核心逻辑。自己实现时,socket用系统原生API,AMF0编码自己写或找个轻量库,FLV Tag封装自己写。这样你对整个链路的掌控力最强。

6.2 握手与连接建立

握手代码不复杂,但要注意字节序。C0是1字节版本号,C1是1536字节,其中前4字节时间戳(填0),接着4字节零,接着1528字节随机数。发送完C0+C1后,读取S0+S1+S2,校验S0版本号是否为3,然后发送C2(内容是S1的复制)。

连接建立后,发送connect命令。connect命令的消息类型是20,流ID是0,消息体是AMF0编码的Object,包含app、flashVer、tcUrl等字段。app字段很重要,它决定了推流地址的路径。比如推流地址是rtmp://server/live/streamkey,那么app就是live,streamkey在publish命令里指定。

6.3 发布流与元数据发送

收到connect的_result响应后,发送publish命令。publish命令的消息体包含流名称和发布类型(通常是live)。流名称就是推流地址里的streamkey。

publish成功后,发送onMetaData。这是一个脚本数据消息,消息类型18,流ID是1。消息体是AMF0编码的ECMA Array,包含视频和音频的参数。发送完onMetaData后,就可以开始发送音视频数据了。

6.4 音视频数据的封装与发送

每个视频帧封装成一个FLV Video Tag。Tag Header是11字节:1字节Tag类型(9表示视频),3字节数据大小,3字节时间戳低24位,1字节时间戳高8位,3字节流ID(填0)。Tag Data的第一个字节是帧类型和编码ID,比如0x17表示关键帧+H.264,0x27表示非关键帧+H.264。接着是AVC Packet Type,0表示AVC Sequence Header,1表示AVC NALU。最后是实际的NALU数据。

音频帧类似,Tag类型是8,Tag Data第一个字节是音频格式和采样率等信息,接着是AAC Packet Type,0表示AAC Sequence Header,1表示AAC Raw。

发送时,先把FLV Tag拆成Chunk。第一个Chunk用Type 0头,后续Chunk用Type 3头(如果时间戳增量相同)或Type 2头。每个Chunk的数据大小不超过协商的Chunk Size。

6.5 心跳与连接维护

RTMP没有标准的心跳机制,但很多服务端会设置空闲超时。如果一段时间没有数据发送,服务端会断开连接。推流场景下音视频数据是持续的,一般不会空闲。但如果采集暂停(比如摄像头被关闭),就需要发送一些空数据或控制消息来维持连接。

我一般会定期发送Window Acknowledgement Size或Set Peer Bandwidth控制消息作为心跳。这些消息很小,不会增加多少带宽,但能有效防止服务端超时断开。

7. 几个容易被忽略的细节与个人经验

7.1 FLV Tag的StreamID字段

FLV Tag Header里的StreamID字段是3字节,标准规定填0。但有些实现会填1,服务端也能接受。我建议填0,因为这是标准做法,兼容性最好。

7.2 AVC Sequence Header的发送时机

AVC Sequence Header(SPS/PPS)必须在第一个视频关键帧之前发送。有些编码器在输出第一个关键帧时才会生成SPS/PPS,这时候要先发Sequence Header再发关键帧。如果顺序反了,播放端无法解码。

7.3 音频Sequence Header的采样率索引

AAC的AudioSpecificConfig里有一个采样率索引,44100Hz对应索引4,48000Hz对应索引3。这个索引写错了,播放端会按错误的采样率解码,声音变调。我见过有人把44100写成48000,结果声音像快进一样。

7.4 时间戳的单调递增

RTMP要求时间戳单调递增。如果编码器输出的PTS有回退(比如B帧导致的PTS乱序),推流端必须做重排序,保证发送的时间戳是递增的。我的做法是维护一个小的重排序缓冲区,按PTS排序后再发送。

7.5 推流地址的解析

推流地址格式是rtmp://host:port/app/streamkey。解析时要注意,app可能包含多级路径,比如rtmp://host/live/sub/app/streamkey,这时候app是live/sub/app,streamkey是streamkey。不要简单按斜杠分割取最后一段。

7.6 个人踩坑记录

有一次推流到某服务端,一直失败,日志显示connect被拒绝。抓包发现服务端返回的_result里有一个code字段是NetConnection.Connect.Rejected。查了半天,最后发现是tcUrl字段填错了。tcUrl应该是完整的推流地址去掉streamkey,比如rtmp://host/live,我填成了rtmp://host,少了/live。这个细节在文档里没写,但服务端会校验。

还有一次,推流成功但播放端一直缓冲。抓包发现视频关键帧的Chunk被分成了很多小Chunk,每个Chunk之间间隔很大。原因是Chunk Size设成了128,一个100KB的关键帧要分成800个Chunk,发送耗时太长。把Chunk Size改成4096后问题解决。

这些坑,文档里不会写,只有实际做过才会遇到。我现在的习惯是,每次推流出问题,先抓包看RTMP层的交互,再看TCP层的重传和延迟,最后才怀疑编码和封装。这个排查顺序能解决90%的问题。

7.7 关于延迟的终极建议

如果你对延迟极其敏感(比如互动直播),RTMP可能不是最优选择。RTMP的延迟通常在1-3秒,主要来自TCP的重传机制和播放端的缓冲。要降到1秒以内,需要考虑基于UDP的协议。但RTMP的兼容性和稳定性是它的最大优势,大多数场景下1-3秒的延迟是可以接受的。

我个人的经验是,先把RTMP推流做稳定,再考虑优化延迟。稳定性是1,延迟优化是后面的0。没有稳定性,再低的延迟也没有意义。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进