1. 从一个被忽视的协议细节说起
如果你做过直播推流、录屏工具或者互动连麦类的项目,大概率绕不开 RTMP 这个协议。很多人对 RTMP 的印象停留在“握手、连接、推流”这三步,觉得只要调通一个开源库,把流推上去就完事了。但真正在生产环境里跑过一段时间之后,你会发现一个很微妙的现象:同样是推流,有的流稳定得像老狗,有的流隔三差五就卡顿、花屏、甚至直接断连,而排查到最后,问题往往不在网络带宽,也不在编码器参数,而是出在RTMP Chunk Stream这一层。
RTMP Chunk Stream,中文一般叫“RTMP 分块流”或者“消息分块层”,它是 RTMP 协议里承上启下的关键一层。往上,它要承载各种类型的消息(音频、视频、命令、元数据);往下,它要面对 TCP 这条字节流,把不同通道的消息切碎、打标签、交错发送,再在对端重新拼装还原。你可以把它理解成快递分拣中心:上层把一个个大小不一的包裹丢过来,分块层负责把大包裹拆成标准尺寸的小箱子,贴上“属于哪个通道、是不是最后一个、总共多长”的标签,然后混在一起发走;接收端再根据标签把箱子重新组装成原来的包裹。
这个标题“05 RTMP Chunk Stream”里的“05”,通常意味着它是某个系列内容中的第五篇,前面大概率已经讲过了握手、连接、消息结构等基础。所以这一篇的核心任务,就是把分块层彻底讲透。它适合谁看?适合已经能跑通基础推流、但想搞清楚底层为什么这么设计的人;适合遇到“大消息发送失败”“分块大小协商异常”“多通道交错乱序”这类问题的开发者;也适合单纯想把 RTMP 协议吃透、为后续做自定义协议或性能优化打基础的人。接下来我会从设计思路、核心字段、实操拆解、问题排查几个角度,把这一层掰开揉碎讲清楚。
2. 分块层到底解决了什么问题
2.1 为什么不能直接把消息丢给 TCP
要理解 Chunk Stream 的价值,先得想清楚一个反问题:如果不用分块,直接把上层的消息原样写进 TCP,会怎样?
RTMP 上层消息的类型很多,有几十字节的命令消息,也有几十 KB 甚至更大的视频帧消息。TCP 本身是字节流,它不关心你的消息边界。如果直接把一个大视频帧整块写进去,会带来两个麻烦。第一,队头阻塞。假设通道 A 正在发一个 100KB 的视频帧,通道 B 有一个 20 字节的控制消息要发,如果 A 不拆块,B 就只能干等 A 发完,控制指令的实时性完全没法保证。第二,多路复用困难。RTMP 一个连接上同时跑着音频、视频、命令、控制等多个逻辑通道,如果不做分块和标签,接收端根本分不清这段字节属于哪个通道。
分块层的设计目标就非常明确了:把不同通道的消息切成统一规格的小块,每块带上通道标识和边界信息,让它们可以在同一条 TCP 连接上交错传输,接收端再按通道归位重组。这就是所谓的“多路复用”。
2.2 分块、消息、通道三者的关系
这里有几个概念容易混,我用一个生活化的类比说清楚。
- 消息(Message):上层要发送的完整数据单元,比如一个视频帧、一条命令。它有自己的类型、长度、时间戳、流 ID。
- 消息流(Message Stream):某个逻辑通道上按顺序排列的消息序列,比如视频通道上的所有视频帧。
- 分块(Chunk):消息被切碎后的片段,是实际在网络上传输的单位。
- 分块流(Chunk Stream):某个通道上分块的序列,接收端按这个序列重组消息。
关键点在于:分块流 ID 和消息流 ID 不是一回事。一个消息流可以被映射到某个分块流上传输,但分块流更多是传输层的概念,消息流是应用层的概念。很多人第一次看协议文档时在这里卡住,就是因为把两者当成同一个东西了。
2.3 分块格式的三种类型
RTMP 的分块头不是固定长度的,它设计了三种类型,分别用 2、3、4 个字节的基础头,加上可选的扩展时间戳。这个设计非常巧妙,核心思想是压缩重复信息。
- Type 0:11 字节头,包含完整的时间戳、消息长度、消息类型 ID、消息流 ID。通常用于某个分块流上第一条消息,或者时间戳发生跳变时。
- Type 1:7 字节头,省略了消息流 ID,因为同一分块流上消息流 ID 一般不变。时间戳只给差值。
- Type 2:3 字节头,进一步省略了消息长度和类型 ID,因为同一流上连续消息往往类型和长度一致。
- Type 3:0 字节头,完全没有头信息,用于同一个消息的后续分块,或者延续上一个分块的状态。
这种“增量编码”的思路,和视频编码里的 P 帧、B 帧是一个道理:能省则省,只传变化的部分。实测下来,对于连续的视频帧,大量使用 Type 1 和 Type 3,能显著降低协议开销。
3. 核心字段逐个拆解
3.1 基础头里的五个关键字段
不管哪种类型,分块头里最核心的信息就那么几个,我把它们列出来,并说明每个字段的实际意义。
| 字段 | 长度 | 作用 | 实操注意点 |
|---|---|---|---|
| 分块基础头类型 | 2 bit | 标识用哪种头格式 | 决定后续头长度,解析第一步 |
| 分块流 ID | 3~11 bit | 标识属于哪个逻辑通道 | 2~63 用 1 字节,64~319 用 2 字节,更大用 3 字节 |
| 时间戳 | 3 或 7 字节 | 消息的相对或绝对时间 | 超过 0xFFFFFF 需用扩展时间戳 |
| 消息长度 | 3 字节 | 当前消息总字节数 | 接收端据此判断消息何时结束 |
| 消息类型 ID | 1 字节 | 标识消息种类 | 8 音频、9 视频、20 命令等 |
| 消息流 ID | 4 字节 | 应用层流标识 | 小端序,容易踩坑 |
这里要特别提醒字节序的问题。RTMP 协议里大部分多字节整数是大端序,但消息流 ID 是小端序。这个不一致是历史遗留,很多自己手写解析的人在这里翻车,解析出来的流 ID 完全对不上。我的建议是,写解析代码时把每个字段的字节序单独标注清楚,别想当然。
3.2 扩展时间戳的触发条件
时间戳字段在 Type 0 里是 3 字节,最大值 0xFFFFFF,也就是约 1677 万毫秒,换算下来大概 4.66 小时。对于长时间直播,这个值会被超过。协议规定,当时间戳大于等于 0xFFFFFF 时,这 3 个字节全部填 1,真正的 32 位时间戳放到头后面的扩展字段里。
这里有个细节:扩展时间戳的位置和是否存在,取决于头类型。Type 3 的分块如果延续上一个分块,它本身没有时间戳字段,但如果上一个分块用了扩展时间戳,Type 3 也要带上扩展时间戳。这个规则在协议文档里写得比较绕,实际实现时容易漏掉,导致长时间推流后时间戳解析错乱。
3.3 分块大小的协商机制
分块大小(Chunk Size)默认是 128 字节。这个值可以通过Set Chunk Size控制消息来修改,范围是 1 到 0xFFFFFF。发送方和接收方各自维护自己的分块大小,互不影响。
为什么默认是 128?因为这是协议设计时对“小控制消息实时性”和“大消息传输效率”的一个折中。128 字节足够小,保证控制消息能快速插入;又不至于太小,导致分块头开销占比过高。
实际项目中,我一般会把分块大小调到 4096 或 8192。原因很简单:视频帧动辄几十 KB,用 128 字节切,一个 50KB 的帧要切成 400 个分块,每个分块至少 1 字节头(Type 3),加上 TCP 和协议其他开销,浪费明显。调到 4096 后,分块数量降到十几个,效率提升肉眼可见。但也不能无限调大,因为分块越大,单个分块占用 TCP 发送的时间越长,控制消息的插入延迟就越高。4096 到 8192 是我实测下来比较舒服的区间。
注意:修改分块大小后,必须确保对端能正确处理。有些老旧的服务器实现只认 128,你发大了它可能直接报错或行为异常。上线前一定要在目标服务器上验证。
4. 消息发送与接收的完整流程
4.1 发送端如何切分一个消息
假设现在要发送一个 10000 字节的视频消息,当前分块大小是 4096,分块流 ID 是 5,消息流 ID 是 1,时间戳是 1000。
第一步,判断这是该分块流上第一条消息,还是延续。如果是第一条,用 Type 0 头,写入完整的时间戳、消息长度、类型 ID、消息流 ID。如果不是,且时间戳增量、长度、类型都没变,可以用 Type 1 或 Type 2。
第二步,切分数据。10000 字节按 4096 切,得到 4096、4096、1808 三块。第一块用 Type 0 头,后面两块用 Type 3 头(因为属于同一个消息,所有信息都延续)。
第三步,写入。第一块:Type 0 头 + 4096 字节数据。第二块:Type 3 头(1 字节)+ 4096 字节。第三块:Type 3 头 + 1808 字节。
这里有个容易忽略的点:Type 3 头虽然只有 1 字节,但它仍然存在。它的格式是 2 bit 类型(11)+ 6 bit 保留,实际就是 0xC0 这个字节。很多人以为 Type 3 完全没头,直接拼数据,结果接收端解析全乱。
4.2 接收端如何重组消息
接收端的逻辑是发送端的逆过程,但更复杂,因为它要同时处理多个分块流。
每收到一个分块,先读第一个字节,取出头类型和分块流 ID。然后根据头类型,决定后续要读多少字节的头信息。如果头类型是 0,读 11 字节;是 1,读 7 字节;是 2,读 3 字节;是 3,读 0 字节。
接着,从当前分块流的状态里取出之前保存的消息长度、类型、时间戳等信息,结合本次头里的增量,计算出当前消息的完整信息。然后把数据追加到该分块流对应的缓冲区里。
最后,判断缓冲区里的数据长度是否达到了消息长度。如果达到了,说明一个完整消息接收完毕,把它交给上层处理,并重置该分块流的状态,准备接收下一个消息。
这个过程中,每个分块流都要独立维护一套状态:当前消息长度、已接收字节数、时间戳、消息类型、消息流 ID。状态管理是接收端的核心难点,写不好就会出现消息错位、内存泄漏。
4.3 交错发送的实际场景
多通道交错是分块层最直观的体现。假设现在音频通道和视频通道同时在发数据,音频分块大小 4096,视频分块大小 4096。
发送顺序可能是:视频分块 1、音频分块 1、视频分块 2、视频分块 3、音频分块 2……接收端根据分块流 ID 把它们分别归入视频缓冲区和音频缓冲区,各自独立重组。这样即使视频帧很大,音频也不会被长时间阻塞。
但交错也带来一个问题:如果某个通道的分块一直发不完,其他通道的分块会不会被饿死?理论上不会,因为发送端是主动调度的,它可以在任意分块边界切换到其他通道。但实际实现中,如果发送端逻辑写得不好,比如在一个循环里把一个大消息的所有分块一次性写完,那交错就失效了。所以好的实现会在每个分块发送后检查是否有更高优先级的通道需要发送。
5. 实操中踩过的坑与排查技巧
5.1 分块大小协商不一致导致的诡异现象
我遇到过一个案例:推流端把分块大小改成了 60000,但服务端没有正确处理Set Chunk Size消息,仍然按 128 来解析。结果就是推流端发出去的分块,服务端按 128 切,数据完全错位,表现为花屏加随机断连。
排查这类问题的思路是:抓包看Set Chunk Size消息有没有被正确应答,以及后续分块的实际大小。如果发现发送端分块明显大于接收端预期,基本就是协商没生效。解决办法是,在发送Set Chunk Size后,等待对端的确认,或者保守一点,先用默认 128 发几条消息,确认对端正常后再调整。
5.2 时间戳回退引发的播放异常
时间戳在 RTMP 里应该是单调递增的,但有些编码器在遇到 B 帧或者时间基转换时,会发出比前一个消息更小的时间戳。接收端如果直接使用,播放器可能会卡住或者跳帧。
处理办法是,接收端维护一个“最大已见时间戳”,如果新来的时间戳小于它,就做修正,比如加上一个偏移量,或者直接钳制到最大值。当然,更好的做法是在发送端就保证时间戳单调,但作为接收端,必须做防御。
5.3 大消息分块时的内存管理
一个 4MB 的视频关键帧,按 4096 分块,有 1000 多个分块。如果接收端为每个分块流都预分配大缓冲区,内存占用会很可观。我的做法是,缓冲区按需增长,但设置一个上限,超过上限就丢弃当前消息并记录日志。因为正常情况下,单个消息不应该超过几 MB,如果出现几十 MB 的消息,大概率是发送端出了问题。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 花屏、画面撕裂 | 分块重组错位 | 检查分块流 ID 解析、消息长度字段 |
| 推流几小时后断连 | 扩展时间戳未处理 | 检查时间戳超过 0xFFFFFF 后的逻辑 |
| 控制消息延迟高 | 分块太大或交错失效 | 检查分块大小设置、发送调度逻辑 |
| 内存持续增长 | 分块流状态未释放 | 检查消息完成后是否重置缓冲区 |
| 服务端报协议错误 | 分块大小协商失败 | 抓包看 Set Chunk Size 交互 |
6. 几个容易被忽略的进阶细节
6.1 分块流 ID 的编码规则
分块流 ID 在头里不是固定 3 字节,而是变长编码。第一个字节的低 6 位如果是 0,表示后面还有字节;如果是 1 到 63,就是最终值;如果是 64 到 319,用两个字节表示;更大的值用三个字节。这个编码规则和很多变长整数类似,但具体位操作容易写错。
我的建议是,直接参考成熟开源实现的位操作代码,别自己从头推导。因为这里错一位,整个分块流 ID 就全错了,而且症状不明显,可能只是偶尔串流。
6.2 协议控制消息与用户控制消息的区别
分块层之上,有一类特殊的消息叫“协议控制消息”,包括Set Chunk Size、Abort Message、Acknowledgement、Window Acknowledgement Size等。它们的分块流 ID 固定是 2,消息流 ID 固定是 0。而“用户控制消息”分块流 ID 也是 2,但消息类型 ID 是 4,用于传递流开始、流结束、缓冲时长等事件。
区分这两类消息很重要,因为它们的处理时机不同。协议控制消息往往需要在分块层就介入处理,比如Set Chunk Size会直接影响后续分块的解析。如果把它当成普通消息交给上层,可能就晚了。
6.3 确认窗口与流量控制
RTMP 有确认机制:接收端每收到一定字节数,要发一个Acknowledgement给发送端。发送端根据这个来调整发送速率。这个机制和分块层配合,构成了 RTMP 的流量控制。
实际项目中,如果发现推流速度上不去,可以检查确认窗口大小是否设置得太小。默认窗口是 2500000 字节,如果网络延迟高,这个值可能需要调大,否则发送端会频繁等待确认,吞吐量上不去。
7. 自己动手实现一个最小分块解析器
7.1 数据结构设计
如果要自己写一个分块解析器,核心数据结构是一个哈希表,键是分块流 ID,值是该流的状态对象。状态对象里至少要有:消息长度、已接收长度、时间戳、消息类型、消息流 ID、数据缓冲区。
class ChunkStreamState: def __init__(self): self.timestamp = 0 self.message_length = 0 self.message_type_id = 0 self.message_stream_id = 0 self.received_length = 0 self.buffer = bytearray() self.has_extended_timestamp = False7.2 解析主循环
解析器的输入是字节流,输出是完整消息。主循环每次读取一个分块,更新对应流的状态,如果消息完整就产出。
def parse_chunk(data, offset, states, chunk_size): first_byte = data[offset] fmt = (first_byte >> 6) & 0x03 csid = first_byte & 0x3F offset += 1 # 解析变长 csid if csid == 0: csid = 64 + data[offset] offset += 1 elif csid == 1: csid = 64 + data[offset] + (data[offset+1] << 8) offset += 2 state = states.setdefault(csid, ChunkStreamState()) # 根据 fmt 读取头字段 # ... 省略具体位操作 return offset这段代码的关键在于,每个分块流的状态必须持久化,不能每次解析都新建。因为 Type 1、2、3 的头都依赖之前的状态来补全信息。
7.3 边界情况处理
实现时至少要处理这几种边界:分块数据跨 TCP 包、消息长度为零、分块大小在解析中途被修改、扩展时间戳出现。每一种都需要单独的测试用例。我建议用真实的推流数据做回归测试,因为手工构造的用例很难覆盖所有组合。
8. 性能优化的几个实际手段
8.1 减少分块头开销
对于连续的视频帧,尽量使用 Type 1 和 Type 3。具体做法是,在发送端维护每个分块流的上一次消息信息,如果本次消息的类型、长度、流 ID 都没变,就用 Type 1;如果连时间戳增量都一致,可以用 Type 2。实测下来,这能减少 30% 以上的协议头开销。
8.2 合理设置分块大小
前面说过,4096 到 8192 是比较好的区间。但具体值要根据业务来定。如果是低延迟互动场景,分块小一点,比如 1024,保证控制消息及时插入;如果是大文件推流,分块可以大一点,比如 16384,减少分块数量。
8.3 缓冲区复用
接收端为每个分块流维护缓冲区时,不要每次都新建 bytearray。可以用一个对象池,消息处理完后把缓冲区清空放回池子。在高并发场景下,这能显著降低 GC 压力。
9. 和其他协议层的配合关系
分块层不是孤立的,它和上下层都有交互。往上,它要正确识别消息类型,把协议控制消息和用户消息分开处理。往下,它依赖 TCP 的可靠传输,但也要处理 TCP 粘包和拆包。因为分块本身有长度信息,粘包拆包相对好处理,只要按分块头里的长度字段读取即可。
还有一个容易忽略的点:RTMP 握手阶段是不使用分块格式的。握手是固定长度的字节交换,握手完成后才进入分块层。所以解析器要有一个状态机,先处理握手,再切换到分块解析。
10. 我在实际项目中的几点体会
做直播相关项目这些年,分块层的问题往往不是最显眼的,但一旦出问题,排查成本很高。因为它处在中间层,往上往下都可能背锅。我的经验是,在项目早期就把分块层的日志打全,包括每个分块流的 ID、消息长度、时间戳、分块数量。这样出问题时,一眼就能看出是哪个环节异常。
另外,不要迷信开源库。很多开源 RTMP 库在分块层的实现是有瑕疵的,尤其是在扩展时间戳和变长分块流 ID 的处理上。如果项目对稳定性要求高,建议自己通读一遍所用库的分块层代码,或者至少写一套针对性的测试用例。
最后分享一个小技巧:调试分块层问题时,可以把抓到的包按分块流 ID 分别导出成独立的文件,然后单独分析每个流的分块序列。这样比在混杂的抓包记录里翻找高效得多。我试过用这个方法定位一个困扰了两天的花屏问题,最后发现是某个分块流在消息边界处少发了一个 Type 3 头,导致后续所有分块错位。这种问题,不拆开看根本发现不了。