RTMP Chunk Stream 分块流深度解析:多路复用、分块格式与性能优化实战
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 011 字节头包含完整的时间戳、消息长度、消息类型 ID、消息流 ID。通常用于某个分块流上第一条消息或者时间戳发生跳变时。Type 17 字节头省略了消息流 ID因为同一分块流上消息流 ID 一般不变。时间戳只给差值。Type 23 字节头进一步省略了消息长度和类型 ID因为同一流上连续消息往往类型和长度一致。Type 30 字节头完全没有头信息用于同一个消息的后续分块或者延续上一个分块的状态。这种“增量编码”的思路和视频编码里的 P 帧、B 帧是一个道理能省则省只传变化的部分。实测下来对于连续的视频帧大量使用 Type 1 和 Type 3能显著降低协议开销。3. 核心字段逐个拆解3.1 基础头里的五个关键字段不管哪种类型分块头里最核心的信息就那么几个我把它们列出来并说明每个字段的实际意义。字段长度作用实操注意点分块基础头类型2 bit标识用哪种头格式决定后续头长度解析第一步分块流 ID3~11 bit标识属于哪个逻辑通道2~63 用 1 字节64~319 用 2 字节更大用 3 字节时间戳3 或 7 字节消息的相对或绝对时间超过 0xFFFFFF 需用扩展时间戳消息长度3 字节当前消息总字节数接收端据此判断消息何时结束消息类型 ID1 字节标识消息种类8 音频、9 视频、20 命令等消息流 ID4 字节应用层流标识小端序容易踩坑这里要特别提醒字节序的问题。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[offset1] 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 头导致后续所有分块错位。这种问题不拆开看根本发现不了。