简介:基于Netty构建的RTMP服务器端参考项目,面向Java网络编程与流媒体服务开发者,重点解决RTMP推流、拉流、协议解析及高并发连接管理等问题。项目利用Netty的ChannelHandler逐层解析RTMP数据包,配合EventLoopGroup完成线程调度,并借助FFmpeg实现推流验证,适合希望深入理解实时流媒体服务端实现原理的读者。压缩包共50个文件,以Java源码16个、class编译产物19个、XML配置9个为主,另有Maven工程文件、README说明及kotlin_module等辅助内容,整体仅59KB,结构紧凑,便于快速定位核心代码。已有767人学习浏览,可作为从零搭建RTMP服务的入门范本,也适合作为二次开发的代码参考。通过阅读可掌握Netty协议解析思路、RTMP握手与AMF编解码细节、推拉流交互流程,以及基于FFmpeg的测试方法;项目中关键模块均有对应实现,目录按Netty引导、协议解析、业务处理划分,便于对照源码理解,为自研直播服务或性能调优提供基础。
1. 用 Netty 自研 RTMP 推流服务器,到底在解决什么问题
做 rtmp 推流服务器搭建时,Java 后端最常见的尴尬是:SRS、nginx-rtmp 都是 C/C++ 体系,想改协议细节、嵌进自己的业务系统,成本高到劝退。rtmpServer-master 这类基于 Netty 的 RTMP 服务端项目,解决的就是这个问题——它把 RTMP 协议收流端用纯 Java 重写了一遍,握手、消息块、AMF0 命令、音视频封装解析全部放在 Netty 的 pipeline 里,你拿到手改的是 Java 代码,不是 C++ 源码。适合两类人:一是后端团队想自己掌控推流接入层,二是对 RTMP 协议好奇、想通过 Netty 把协议栈彻底读透的开发者。它的价值不在“能收流”这个结果,而在“每一层协议怎么被代码翻译”这个过程。
先说清楚一点:网上很多标着 rtmpServer 的开源包,质量参差不齐,有的只实现了握手和命令应答,有的能完整收流。但判断一个包能不能用的唯一标准,是你自己能不能读懂它的协议处理链路。下面按我自己拆这种包、以及用 Netty 重写 RTMP 服务端时的主要环节展开,每一步都落到能抄的代码和参数上。
2. 把 RTMP 协议拆成 Netty 能落地的三层:握手、消息块、AMF0
RTMP 本身是 Adobe 制定的私有协议,公开文档不完整,实现时最容易踩的坑就是“照着 FLV 封装猜”。实际用 Netty 开发 RTMP,核心就三层:TCP 之上的握手层、消息块(chunk)编码层、AMF0 命令层。三层各管一段,Netty 的 handler 也按这个切。
2.1 握手阶段:C0/C1/C2 的时序为什么不能省
RTMP 握手是客户端先发言:C0(1 字节,固定 0x03)加 C1(1536 字节)一起发出。C1 由 4 字节客户端时间戳、4 字节零、1528 字节随机数组成。服务端收到后要回 S0S1S2,其中 S2 要回显客户端的 C1 时间戳和随机数,S1 由服务端自己生成。客户端收到 S0S1S2 后会发 C2 做确认,服务端收到 C2 才算握手完成。
很多简化实现会把 C2 直接跳过,但 OBS、FFmpeg 这些客户端会在握手没完成时就尝试发 RTMP 命令帧,导致服务端把命令帧当成握手数据解析,后续一切错乱。用 Netty 写握手时,我一般会用一个独立的ByteToMessageDecoder做状态机,代码骨架大致这样:
public class RtmpHandshakeDecoder extends ByteToMessageDecoder { private static final int HANDSHAKE_LENGTH = 1536; private int state = 0; // 0: 等待C0+C1, 1: 等待C2 private byte[] c1 = new byte[HANDSHAKE_LENGTH]; @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { if (state == 0) { if (in.readableBytes() < 1 + HANDSHAKE_LENGTH) return; in.readByte(); // C0 版本号, 校验可选 in.readBytes(c1); ByteBuf s0s1s2 = ctx.alloc().buffer(1 + HANDSHAKE_LENGTH * 2); s0s1s2.writeByte(0x03); long serverTime = System.currentTimeMillis() & 0xFFFFFFFFL; s0s1s2.writeInt((int) serverTime); // S1 time s0s1s2.writeInt(0); s0s1s2.writeBytes(randomBytes()); // 随机数 1528 字节 s0s1s2.writeInt(in.getInt(1)); // S2 回显 C1 的 time, 位置1是C0之后 s0s1s2.writeInt(0); s0s1s2.writeBytes(c1, 8, HANDSHAKE_LENGTH - 8); ctx.writeAndFlush(s0s1s2); state = 1; } else { if (in.readableBytes() < HANDSHAKE_LENGTH) return; in.skipBytes(HANDSHAKE_LENGTH); // C2 内容可校验可不校验 ctx.pipeline().remove(this); // 转交下一个 handler 处理 RTMP 消息块 } } }代码逻辑不复杂,但有两个参数容易写错。第一个是in.getInt(1)这个位置,C0 占 1 字节,所以 C1 的时间戳在缓冲区的 offset 1 处,不是 0,写错回显时间戳虽然很多客户端不校验,但遇到严格实现的 SDK 会直接断连。第二个是随机数生成,不要用new Random()每次现算,建议在服务启动时预生成 1528 字节的数组复用,握手频繁时能省不少内存分配。还有一点值得注意:ByteToMessageDecoder的decode方法在缓冲区数据不够时直接 return,Netty 会在数据到达后再次调用,这个机制天然适合处理半包,不需要自己拼 TCP 粘包。
2.2 RTMP 消息块(chunk)编码:Netty 粘包处理的关键
RTMP 握手完成后,所有数据都以消息块(chunk)形式传输。这一层是 Netty 粘包处理的主战场。chunk 的头部分三部分:basic header、message header、extended timestamp。basic header 的格式是:前 2 位是 fmt,决定 message header 的长度(fmt=0 是 12 字节,fmt=1 是 8 字节,fmt=2 是 4 字节,fmt=3 是 0 字节),后 6 位是 chunk stream id(CSID)。但 CSID 小于 64 时 1 个字节就够,64 到 319 时需要 2 字节,320 以上需要 3 字节,字节数不同,basic header 长度也不同。
这一层用LengthFieldBasedFrameDecoder是不行的,因为 RTMP 消息没有统一的总长度字段——message header 里有消息长度,但你要先解析 basic header 才能知道 message header 的存在,属于“变长头 + 依赖状态”的协议。正确的做法是继承ByteToMessageDecoder,自己维护从上次收到数据到当前的消息块组装状态。我在新版实现里一般会用一个RtmpChunkDecoder,核心逻辑简化如下:
public class RtmpChunkDecoder extends ByteToMessageDecoder { private final int maxChunkSize; // 配合对端协商, 不要超过 65536 private RtmpMessage currentMessage; // 当前正拼到一半的消息 @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { while (in.readableBytes() > 0) { int basicHeader = in.readUnsignedByte(); int fmt = (basicHeader >> 6) & 0x03; int csid = basicHeader & 0x3F; if (csid == 0) { if (!in.isReadable()) return; csid = in.readUnsignedByte() + 64; } else if (csid == 1) { if (in.readableBytes() < 2) return; csid = in.readUnsignedShort() + 64; } // 这里按 fmt 决定读多长的 message header, 并处理 timestamp 回绕 // fmt=0: 12字节新消息头; fmt=3: 无消息头, 复用上一条同 CSID 的消息 // ... 组装完成后放入 currentMessage, 数据不够则等待下一轮 out.add(currentMessage); } } }注意几点。第一,chunk 默认大小是 128 字节,也就是说一个大的 AMF 命令消息会被切成多个 chunk 传输,每个 chunk 都会带上 basic header,其中只有第一个 chunk 带完整的 message header,后续 chunk 的 fmt=3 表示“沿用上一条消息头”。处理粘包时要在消息级别做聚合:收到一个 chunk,发现它的消息长度还没满足,就继续攒,直到攒够,才把完整消息抛给上层。第二,时间戳有 3 字节,范围 0 到 0xFFFFFF,超过之后必须用 extended timestamp 的 4 字节表示,fmt=2/3 的消息如果需要扩展时间戳也要读这个字段。第三,对端可以发送 Set Chunk Size 消息(命令消息类型 1)来修改 chunk 大小,常见值是 4096 或 65536,服务端要动态记录每个连接当前的 chunk size。
2.3 AMF0 命令解析:connect、createStream、publish 的一问一答
chunk 之上就是 AMF0 格式的 RTMP 命令。AMF0 是简单的类型序列化格式:每个值前面有一个类型标记字节,0x00 是数字(8 字节大端双精度浮点),0x02 是字符串(2 字节长度 + UTF-8 内容),0x03 是对象(一组 key-value,以 0x00 0x09 结束),0x08 是 ECMA 数组(4 字节计数 + 同对象的 key-value)。推流需要处理的命令就三个:connect、createStream、publish。
connect 是客户端发的第一条命令,参数里有 app(应用名,一般是 "live")、tcUrl(推流地址)、flashVer 等。服务端要回一个_result消息,事务号与客户端一致(常为 1)。createStream 在 connect 成功后发送,事务号通常为 2,服务端回_result并携带分配的流 ID(从 1 开始递增)。publish 命令不带事务号,参数是流名和发布类型(live/record/append)。
这里有个容易忽视的点:_result的返回体里第一层是命令名(字符串_result),第二层是事务号(数字),第三层才是真正的属性对象。如果 Netty 里的 AMF 解析器漏掉事务号,客户端会以为自己连上了但所有后续消息都发不出来。我自己维护过一个极简的 AMF 编码器,回 connect 响应的部分长这样:
public ByteBuf encodeConnectResult(int transactionId, int streamId) { ByteBuf buf = PooledByteBufAllocator.DEFAULT.buffer(256); buf.writeShort(0); // 命令消息的消息长度占位, 后续计算 // 这里先用简单方式: 分两段写, 实际项目建议用 CompositeByteBuf buf.writeByte(0x02); writeAmfString(buf, "_result"); // 命令名 buf.writeByte(0x00); buf.writeLong(transactionId); // 事务号 buf.writeByte(0x03); // 对象开始 writeAmfKeyValue(buf, "fmsVer", "FMS/3,0,1,123"); // Level writeAmfKeyValue(buf, "capabilities", 31.0); // capabilities buf.writeByte(0x00); buf.writeByte(0x09); // 对象结束 buf.writeByte(0x03); // 第二个对象: information writeAmfKeyValue(buf, "level", "status"); writeAmfKeyValue(buf, "code", "NetConnection.Connect.Success"); writeAmfKeyValue(buf, "description", "Connection succeeded."); buf.writeByte(0x00); buf.writeByte(0x09); return buf; }编码对象时别忘记闭合标记0x00 0x09,漏掉后客户端解析会读越界,表现为 OBS 弹“连接失败”但服务器日志一切正常。事务号按客户端发送的原值回传,部分老客户端在 createStream 时会用 0 或负数,不要自作主张改成自增序列。publish 命令解析出来后,再收到的消息基本就是音视频数据了,这时进入下一层:封装解析与转发。
3. 用 Netty 搭建 RTMP 服务器主流程:pipeline 设计、命令应答与音视频接入
这一章把前文的三层协议放进 Netty 的 pipeline 里跑通。rtmpServer-master 这类项目的主链路基本都是:Bootstrap 绑定 1935 端口 → 每个连接经过握手解码器、chunk 解码器、AMF 命令处理器 → 推流状态下进入音视频数据分发。重点看 pipeline 怎么排、线程模型怎么选、音视频怎么识别。
3.1 pipeline 怎么装:一个解码器、一条业务链路、一个写回缓冲
Netty 的 pipeline 顺序就是数据流动顺序。我惯用的装配顺序是:RtmpHandshakeDecoder→RtmpChunkDecoder→RtmpCommandHandler,最后一个 handler 里做 AMF 命令解析和音视频分发。注意RtmpChunkDecoder要在握手完成后才加入,不能在 pipeline 初始化时就装,否则握手数据也会被当成 chunk 解析。这里用ChannelInitializer动态改 pipeline:
public class RtmpServerInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline p = ch.pipeline(); p.addLast("handshake", new RtmpHandshakeDecoder()); // chunk 解码器等握手完成后再加, 避免握手数据进入错误解析分支 } }连接建立后,Netty worker 线程执行decode,如果 handler 里有耗时的写数据库、调用远端 API 等操作,必须丢到业务线程池。如果不丢,OBS 推流时的onMetaData一到,整个 worker 线程被阻塞,后面所有连接的读写全部卡住,现象是服务器 CPU 不高但推流全部超时,这个坑我踩过不止一次。推荐的做法是业务线程池用有界队列 + 拒绝策略,队列长度按单机连接数估算,一般 1000 并发推流的话线程池 8 线程就够了,线程再多的收益很小。
3.2 推流三连:connect、createStream、publish 的应答要点
客户端依次发送 connect、createStream、publish。服务端处理这三个命令时要维护一个连接级的状态对象,字段至少包括:握手是否完成、是否已认证、流 ID、发布流名、发布类型、当前 chunk size、当前时间戳基线。connect 收到的参数里有 app、tcUrl,最常见的校验逻辑是检查 app 是否匹配(比如只允许 "live")。createStream 的应答逻辑前文已经给了代码,注意流 ID 从 1 开始,每新连接独立计数。
publish 命令需要确认消息里的事务 ID——大多数实现是 0,但也有客户端发 1。处理 publish 后要回onFCPublish和onStatus两个命令消息,其中onStatus的 code 是NetStream.Publish.Start。这两个应答缺一不可:OBS 如果收不到onStatus会一直卡在“正在连接”状态;FFmpeg 则直接报Operation not permitted。具体应答消息结构如下:
// 编码一个 onStatus 命令, 返回的 ByteBuf 写完后交给 encoder 发送 public ByteBuf encodePublishStart(int streamId) { ByteBuf buf = ctx.alloc().buffer(); // AMF 命令名 onStatus writeAmfString(buf, "onStatus"); // 事务号, publish 命令不需要事务号, 回 0 buf.writeByte(0x00); buf.writeLong(0); // 命令对象: null buf.writeByte(0x05); // 信息对象 buf.writeByte(0x03); writeAmfKeyValue(buf, "level", "status"); writeAmfKeyValue(buf, "code", "NetStream.Publish.Start"); writeAmfKeyValue(buf, "description", "Start publishing"); buf.writeByte(0x00); buf.writeByte(0x09); return buf; }这里的事务号不是你发的,是回显客户端 publish 命令里的。拿不准的时候抓包看一眼:FFmpeg 推流时 publish 的事务号就是 0。应答消息要带上消息头(fmt=0,带完整的 12 字节头),chunk stream ID 建议复用对方发来的 CSID,时间戳为 0,消息流 ID 为 createStream 返回的流 ID。
3.3 音视频数据落点:onMetaData、AAC 与 H.264 的 Sequence Header 怎么认
推流开始后,客户端先发一个@setDataFrame开头的 AMF 数据消息,里面是 onMetaData,包含视频编码器(如 avc1)、音频编码器(如 mp4a)、分辨率、帧率、码率等。这部分是 FLV 规范的元数据,解析后建议把关键字段存进流上下文,后续转 HLS 或者 rtsp 都要用。
音频和视频数据是两条独立的 CSID 通道,通常视频走 CSID 6,音频走 CSID 4。首帧音频和首帧视频是始序列头(Sequence Header),是后面所有解码的前提。判断方式是看 FLV tag 头:音频消息第 1 字节的高 4 位是音频格式(AAC 是 10,即 0xA0),后 2 位是采样率,第 5 位是采样精度,第 6-7 位是声道;AAC 的 Sequence Header 从第 2 字节开始是 0x00,表示 AACPacketType=0。视频消息第 1 字节是帧类型和编码 ID:0x17 表示 H.264 关键帧,0x27 表示 H.264 普通帧;第 2 字节是 AVCPacketType,0 为 Sequence Header,1 为 NALU 数据。
在 Netty 里区分这两者的代码很简单,但很容易漏掉“只解析一次”这个约束:
// 假设 message 已经是完整的 RTMP 消息, payload 是跳过消息头后的数据 public void onMediaMessage(RtmpMessage message) { byte[] payload = message.getPayload(); int type = message.getTypeId(); // 8: audio, 9: video if (type == 9) { // video int frameTypeAndCodec = payload[0] & 0xFF; int avcPacketType = payload[1] & 0xFF; if (avcPacketType == 0 && ((frameTypeAndCodec & 0x0F) == 7)) { // AVCDecoderConfigurationRecord: 从 payload[5] 开始解析 SPS/PPS // 注意这里 payload[2..4] 是 0x00 0x00 0x00 (composition time) saveSpsPps(payload); } else { // 普通 NALU, 前 4 字节是长度, 之后是 NALU 内容 dispatchH264Nalu(payload); } } else if (type == 8) { // audio int format = (payload[0] >> 4) & 0x0F; if (format == 10 && payload[1] == 0) { // AudioSpecificConfig, 解析出 profile/采样率/声道 // 转 FLV 时要先写这个 header saveAacSpecificConfig(payload); } else { // AAC 原始数据, 转封装时需要自己拼 ADTS 头 dispatchAacRawData(payload); } } }saveSpsPps和saveAacSpecificConfig需要在线程安全的地方操作。我见过一个实现把这两个方法写在 Netty handler 里,又用了一个 HashMap 存流状态,导致多线程并发时偶发丢帧。正确做法是流级状态用ConcurrentHashMap<StreamKey, StreamContext>,每个流一个独立的上下文对象,串行访问。
4. Netty 开发 RTMP 的五个必踩坑:粘包、时间戳回绕与握手边界
任何一个 rtmpServer-master 类的包都无法绕开以下五个问题。这些坑不是协议没读透,是真实业务场景里反复出现的,我按“现象→原因→解决”逐条拆。
4.1 现象:解码出来的数据全是乱码,或者连接建立后收不到命令
原因:把 RTMP 当普通 TCP 协议处理,直接用 Netty 自带的LengthFieldBasedFrameDecoder拼包。RTMP 没有在 chunk 头部提供整条消息长度的统一字段,消息头分四种类型,长度还不一样,LengthFieldBasedFrameDecoder根本没法配置。另一个常见原因是 CSID 的扩展字节没处理,CSID 在 64 到 319 时 basic header 是 2 字节,有些实现只读第 1 字节,导致所有大于 64 的消息流错位。
解决:按 2.2 节的方案自己实现 chunk 状态机解码器。关键参数就两个:csid的扩展判断、fmt与消息头的映射关系。建议给解码器加一个可配置的maxMessageLength,默认 10MB,超过直接断开,防止恶意推流包把内存打爆。
4.2 现象:推流十几分钟后画面花屏、绿块,或者干脆卡死
原因:RTMP 时间戳是 3 字节,最大值 0xFFFFFF。超过这个值后,必须启用 extended timestamp,4 字节。很多实现只在“当前时间戳超过 0xFFFFFF”时写入 4 字节扩展,但如果 fmt=3 的 chunk 复用旧消息头,而旧消息头的时间戳本身是扩展值,后续消息没有正确处理扩展时间戳的携带方式,解码端就会得到错乱的时间戳序列,播放器据此丢帧、花屏。
解决:内部时间戳统一用 long 存储,解码时如果读取到 0xFFFFFF 标记,必须继续读 4 字节扩展值;在写回编码时,统一把时间戳转成增量,fmt=1 的增量字段是 3 字节,超过同样要扩展。这里没有捷径,只能严格按协议实现,并用超过 5 小时的推流做回归测试。
4.3 现象:有画面没声音,或者声音断断续续、第一帧爆音
原因:AAC 的 Sequence Header 是一段 AudioSpecificConfig(ASC),包含了 audioObjectType、samplingFrequencyIndex、channelConfiguration 三个关键参数。很多简化实现把这段数据当成普通音频帧直接转发或丢弃,转封装时没有先写 ASC,播放器无法初始化解码器。另一种情况是转 FLV 时没有把 AAC 原始数据封装成 ADTS,播放器不认识裸数据。
解决:按 3.3 的代码,把payload[1] == 0的音频消息识别为 Sequence Header,解析后存在流上下文里。转 FLV 时要先写这个 Header;转 HLS 时要把 ASC 转成 MPEG 的 AudioSpecificConfig,再逐帧拼接 ADTS 头。
4.4 现象:OBS 推流正常,FFmpeg 推流一上来就闪断,或者握手永远没完成
原因:部分客户端(尤其是 FFmpeg)握手流程比 OBS 更快,C0C1 和 C2 之间的间隔极短。如果服务端把 C0C1 和 C2 放在同一个解码器里,但握手状态机没有做“数据不够就等”的处理,C2 和后续命令可能会被当成同一个包的残留数据,解析错位就断开。还有一个常见原因是握手完成后没有移除握手解码器,后续所有消息都被再次当作 C0C1 解析;或者decoder在缓冲区还有数据时就被移除,数据丢失。
解决:用ByteToMessageDecoder的天然半包机制,数据不够就等,同时确保握手完成后把ctx.pipeline().remove(this)放在decode方法最后,且 remove 前不要消费多余数据。另外,C2 的 1536 字节不需要校验完整性,直接 skip 即可,跳过最好判断readableBytes() >= 1536,否则多连接并发时会偶发错位。
4.5 现象:多个推流并发时,一个流的卡顿拖垮所有连接
原因:解码后的消息直接在同一 Netty worker 线程里做转封装、写磁盘、调远程接口,任何一个阻塞点都会阻塞整个 eventLoop。RTMP 是长连接,一个 eventLoop 往往承载几十上百个推流连接,一个流卡在 write 等待,其他流全部连带。
解决:把解码和业务处理拆开。解码器只负责投递对象,业务处理用独立的线程池。同时,写数据时留意背压:Channel.writeAndFlush返回的 Future 不是立即成功,如果对端消费慢,要监听Channel.isWritable()或者用ChannelGroup统一管理,不要无限往内存里堆。我一般会在业务线程池的拒绝策略里设置CallerRunsPolicy,宁可让解码线程慢一点,也不要丢消息。
5. 把 rtmpServer-master 跑起来:最小推流链路的验证方法
拿到一个基于 Netty 的 RTMP 服务端包,第一件事不是改代码,而是先跑通“编译→启动→推流→验证”这条最小链路。验证链路走通后,再谈改造。我用的是经典的 ffmpeg 推流 + 服务端日志 + 抓包三重验证,每一步都有明确的通过标准。
5.1 编译启动:日志里出现哪些关键行算握手成功
rtmpServer-master 这类项目一般用 Maven 或 Gradle 管理,先看根目录有没有pom.xml或build.gradle。前者执行mvn clean package -DskipTests,后者执行gradle build -x test。打出的 jar 在target或build/libs目录下,启动命令是java -jar xxx.jar port,端口参数按项目 README 里的约定来,最常见是 1935。启动后看控制台是否出现 “Netty started on port 1935” 类似字样,没有的话先检查端口占用:lsof -i:1935。
握手的成功标志是服务端日志里出现类似 “handshake success from /192.168.1.5:53421” 的字段。如果没有,在握手解码器里加一行日志打印读到的 C0 版本号和 C1 时间戳,OBS 基本固定发 0x03 版本号,这行日志能快速判断是不是端口被劫持或解码器没接到数据。
5.2 用 FFmpeg 推流到本机 rtmp 测试地址的完整命令
推流分两步:准备测试视频源,然后起 FFmpeg。测试视频源可以用机器上任意 mp4,如果没有,用 ffmpeg 生成一个彩条测试卡也行。核心命令:
ffmpeg -re -i test.mp4 -c:v copy -c:a copy -f flv rtmp://127.0.0.1:1935/live/test-re表示按原帧率读取文件,模拟实时推流,不加这个参数 FFmpeg 会全速推,本地测试时服务端压力会失真。-c:v copy -c:a copy是流复制,不做转码,这套测试验证的是 Netty 服务端对 RTMP 封装的解析是否正确,不验证转码能力。-f flv指定输出封装格式,RTMP 只接受 FLV 封装。路径里的/live/test中live是 app 名,test是流名,服务端 publish 解析时要把这两段切分存好,很多包的 bug 就出在只存了test丢掉了live。
推流成功的日志标志是服务端打印出 publish 命令的流名,以及后续不断增长的视频帧计数。如果 FFmpeg 报Connection timed out,先确认服务端端口是 0.0.0.0 监听而不是 127.0.0.1;报Operation not permitted说明 publish 应答有问题,回到 3.2 节核查 onStatus 是否发送。
5.3 抓包与落盘验证:不靠猜,靠 Wireshark 和 ffprobe
推流通了之后,还要验证“收到的数据是正确的音视频数据”。方法有二。第一是 Wireshark 抓包,过滤器填tcp.port == 1935,RTMP 协议底层是 TCP,应用层协议识别为 RTMP。重点看三个东西:握手阶段的四段报文、connect 后的_result应答、publish 后连续的音视频 chunk。如果 Wireshark 里 RTMP 解析为乱码,一般是 chunk size 没协商成功,抓包能直接看到对端发的 Set Chunk Size 消息值。
第二是落盘验证:在 Netty 的 onMediaMessage 里把收到的音频 (typeId 8) 和视频 (typeId 9) 原始数据分别写入文件。写完后用 ffprobe 验证:
ffprobe -show_streams -show_format video.h264 ffprobe -show_streams -show_format audio.aac这份日志流会有大量空行(空消息)和播放测试信息,请适当过滤。
从视频文件里能看到codec_name=h264、profile=High、width=1920这类参数,音频能看到codec_name=aac、sample_rate=44100、channels=2。看不到这些关键字段,说明 Sequence Header 解析或写入有问题,直接回到第 3.3 节检查。这一步是最快的排查手段——不用看协议文档,ffprobe 告诉你数据对不对。
6. 进阶:从“能接收推流”到“能上线服务”的四个关键补充
能收流只是第一步,把它做成一个能上线的服务,还差四件事。第一是鉴权:publish 时校验推流 URL 里的密钥或 token,在 Netty 的 command handler 里加一个authenticate(streamName, params)接口,简单实现用预置密钥比对,上线版可以接 Redis 做动态校验。注意校验失败时不要直接断开,先回一个NetStream.Publish.BadName的 onStatus,给客户端一个可读的错误信息。
第二是转封装。RTMP 推流本身没有播放端,要么实现 play 命令直出 FLV,要么转 HLS 走 HTTP 分发。常见做法是收到音视频帧后拼接成 FLV tag 写入本地文件,再用 FFmpeg 转 HLS,或者直接对接一个 Java 的 HLS muxer。这里最容易被忽视的是:播放端拉流时不需要你重传原封的时间戳,而是要用自己生成的单调递增时间戳。
第三是延迟与缓冲控制。RTMP 基于 TCP,天然有粘包和窗口效应,延迟抖动大。要么做 Gorilla 压缩,要么直接考虑支持 WHIP 推流这类新方向。WHIP 是 WebRTC 的 HTTP 推流协议,把 WebRTC 的 sender 通过 HTTP POST 封装,低延迟是它的核心价值,也是这个领域明显的演进方向。如果自己的服务要接入浏览器推流,RTMP 这条路已经到头,WHIP 在 2024 年后基本取代了浏览器端 RTMP 的工作,这个趋势在做技术选型时值得尽早考虑。
第四是监控。一个 RTMP 服务上线,至少要暴露这几个指标:当前活跃连接数、连接建立速率、推流帧率/码率、消息解析失败数、队列积压长度。Netty 里用ChannelGroup统计连接数,用LongAdder做计数器,接入 Prometheus 即可。最常见的故障就是连接泄露——客户端异常断开后 TCP 连接没释放,Netty 有IdleStateHandler可以兜底,超时时间建议 180 秒无读写就断开,并顺手把流上下文从内存里清理掉。
我自己的习惯是,每改一层协议解析,都要跑一遍“OBS 推流 10 分钟 + FFmpeg 推流 10 分钟 + 断线重连 20 次”的回归测试,再把抓包文件留档。这套测试比任何代码 review 都能发现边界问题。希望这些拆解和踩坑记录能帮你把这个方向做扎实,少走我走歪的路。
本文还有配套的精品资源,点击获取