ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Netty实现RTMP推流服务端:协议解析、握手与Chunked实战

Netty实现RTMP推流服务端:协议解析、握手与Chunked实战

简介:这是一份基于Netty实现的RTMP服务器源码工程,面向Java网络编程与流媒体开发人群,展示从RTMP协议解析、推流接收到并发连接管理的完整落地方式,适合用来研究直播服务端原理、作为课程设计素材,也适合有一定Java基础的学习者实践网络编程。压缩包共50个文件,其中Java源码与class编译产物为主要组成部分,辅以xml配置、iml工程文件、markdown说明及pom依赖,整体仅59KB,结构精简、目录清晰,便于快速定位。已有767人学习下载。项目内含Netty入口类、RTMP协议处理链、基于FFmpeg的推流测试脚本,并保留IDE工程与README说明,方便直接导入运行。通过源码可掌握Netty的ChannelHandler设计、EventLoop线程模型,同时理清RTMP握手、消息分块与音视频数据流转,对自建低延迟直播或点播服务有切实参考价值,也适合作为学习流媒体协议的网络编程示例。

1. RTMP 推流服务端,为什么偏偏要用 Netty 来啃

做流媒体的人都知道一个反直觉的结论:RTMP 协议本身不复杂,但实现一个能扛住并发、不死内存的服务端,麻烦全在协议状态和 TCP 字节流的细节上。nettyrtmp这个项目名听起来像下载站随手拼出来的关键词,拆开看其实是两件事:用 Netty 这个异步网络框架去解析 RTMP 协议。

这个rtmpServer-master项目解决的是流媒体链路里最正式的一环——接收推流端的音频视频数据,并且把这些数据交给拉流端播放。它适合两类人:一类是刚接触流媒体服务端、想找一份能跑起来的 RTMP 服务端参考实现的 Java 开发;另一类是已经在做直播相关系统、需要理解 RTMP 协议和 Netty 怎么配合的从业者。

项目不大,核心代码都在src/main/java下,Maven 结构,用 IDEA 打开pom.xml就能跑。接下来我会从协议细节、代码结构、编译运行、踩坑修复到性能验证,把它拆成可以直接上手的路径。

2. RTMP 协议的真实骨架:握手、AMF 消息与 Chunked 块

2.1 握手不是碰拳头:C0/C1/C2 的字节级约定

RTMP 连接建立的起点是握手。很多初学者以为握手就是把 TCP 连上,然后客户端发个connect命令就行——真要把这个过程抓到包看,你会发现握手是严格的字节流协议,顺序错了整个连接直接断开。

标准 RTMP 握手分三个阶段:客户端先发送 C0(1 个字节,表示 RTMP 版本号,一般是 3)和 C1(1536 字节的随机数据);服务端收到 C0+C1 后,返回 S0(1 字节)+ S1(1536 字节)+ S2(1536 字节);客户端收到后回复 C2(1536 字节)。也就是说,服务端最少要读 1537 个字节才能开始回应,再读 1536 个字节确认客户端完成握手。

# 这个 Python 片段只是用来演示握手字节长度关系 C0_LEN = 1 # 版本号 C1_LEN = 1536 # 客户端随机数据 S0_LEN = 1 S1_LEN = 1536 S2_LEN = 1536 C2_LEN = 1536 server_need_read_first = C0_LEN + C1_LEN # 服务端先读 1537 字节 server_reply_len = S0_LEN + S1_LEN + S2_LEN # 服务端回复 3073 字节 server_need_read_second = C2_LEN # 服务端再读 1536 字节

在 Netty 项目里,这个阶段通常由一个独立的ChannelInboundHandler负责,比如命名为RTMPHandshakeHandler。它做的事是:累积ByteBuf直到读满 1537 字节,然后一次性回复S0+S1+S2,之后再等 C2。关键点是这中间任何一次读取都可能是半个包,所以代码必须围绕“累积 ByteBuf、判断可读字节数”来写,不能假设一次channelRead就能拿到完整数据。顺便说一嘴,S1 和 S2 的内容虽然协议允许拷贝 C1 的数据回传,但主流实现大多会自己生成随机数,不影响兼容性。

2.2 AMF0 消息:connect 和 createStream 就是三个字段

握手完成后,RTMP 就进入了 AMF 消息阶段。AMF(Action Message Format)是一种二进制序列化格式,RTMP 的命令层完全建立在它之上。connect、createStream、publish、play这些控制命令,全部都是 AMF0 编码的消息。

每条命令消息的格式是固定的:命令名字符串(比如"connect")、事务 ID(数字)、命令对象(一个 Object 类型,里面放着app、tcUrl、type等字段)。服务端解析connect命令时,要做的就是按 AMF0 的类型标志位往后读:读到 0x02 表示字符串,读到 0x00 表示数字,读到 0x03 表示对象开始。

核心消息类型要记牢:类型 18(0x12)是AMF0 Command Message,connect、createStream、publish、play都走它;类型 19(0x13)是AMF0 Data Message,推流端的onMetaData走它;类型 8 是音频数据,类型 9 是视频数据。下面给出服务端判断消息类型的典型逻辑:

// Netty 解码器中对 RTMP 消息类型的分流判断 switch (messageType) { case 18: // AMF0 Command Message handleAmfCommand(ctx, in); break; case 19: // AMF0 Data Message,onMetaData 在这里 handleOnMetaData(ctx, in); break; case 8: // Audio Data forwardAudio(ctx, in); break; case 9: // Video Data forwardVideo(ctx, in); break; default: // 其他类型:0x01 Set Chunk Size、0x04 User Control 等 handleControlMessage(ctx, messageType, in); break; }

这段代码的判断顺序就是推流请求进来后服务端的处理路径。handleAmfCommand里解析出命令名,如果是connect就返回_result和Window Acknowledgement Size;如果是createStream就返回携带 stream ID 的_result;如果是publish就返回onFCPublish和_streamBegin。所谓“RTMP 服务器开发”,本质上就是把这个状态机走完整。

2.3 Chunked:为什么 RTMP 要把大包切碎

每家直播服务器都会遇到一个现象:明明 FFmpeg 推流正常,服务器一旦把数据转发给多个拉流端,内存占用就往上飙,视频帧还总丢。问题多半出在 Chunked 分块上。

RTMP 默认把消息切成 128 字节的 chunk 发送。为什么要切?因为直播是实时流,一个大视频帧可能有几十 KB,如果不切块,小带宽的客户端就要等整个大包到齐才能处理,延迟全烂掉了;切成块之后,控制命令这种小消息可以插在大媒体数据的块之间发出去,实现优先级抢占。

每个 chunk 自带一个 basic header,里面包含 chunk stream ID 和 chunk type(fmt)。服务端要做的是按 chunk stream ID 累积同一消息的多个 chunk,等攒够消息长度后组装成完整消息再上抛。下面是一个简化的 chunk 合并逻辑:

// 按 chunk stream ID 累积块,拼成完整 RTMP 消息 Map<Integer, RtmpMessage> messageMap = new ConcurrentHashMap<>(); public void handleChunk(ChannelHandlerContext ctx, ByteBuf chunkIn) { int csId = readChunkStreamId(chunkIn); // 从 basic header 读 csid RtmpMessage msg = messageMap.computeIfAbsent(csId, k -> new RtmpMessage()); msg.appendChunk(chunkIn); // 累积 chunk 数据 if (msg.isComplete()) { // 消息长度读够了 ctx.fireChannelRead(msg.toByteBuf()); messageMap.remove(csId); } }

这里最容易翻车的点是:同一 chunk stream 上的消息是一个接一个顺序到达的,如果前一个消息没攒完,下一个消息的 chunk 也跟着往同一个累积缓冲里写,数据就串了。我一般会在RtmpMessage里记录当前期望长度,发现累积长度超过期望值时,先把多余字节拆出来分配给下一条消息,而不是报错丢弃。等进入生产环境跑大码率推流,你会发现这个处理比协议解析本身更重要。

3. Netty 接管字节流:Handler 链、线程模型与消息边界

3.1 从 pom.xml 看项目依赖与运行方式

拿到rtmpServer-master压缩包,先别急着看代码,直接打开pom.xml。这个项目是标准 Maven 结构,意味着依赖管理、构建生命周期都交给 Maven 处理。src/main/java放核心源码,target/classes里是编译好的 class 文件,说明作者在打包前已经编译过,你不用装一堆奇怪的环境就能试着跑。

Netty 项目在pom.xml里基本只有一条硬依赖,就是io.netty:netty-all,或者按模块拆分的netty-handler、netty-codec。如果你的项目还要做 WebRTC 那边的东西,通常需要额外的com.github.willuhst之类的辅助库,但这个项目没有,说明作者刻意保持了 RTMP 单一职责。

运行入口一般是RTMPServer或者Main这样的类,内含main方法。Linux 下跑 Java 服务端,我建议不要用java -jar一把梭,而是加 JVM 参数控制堆内存:

mvn clean package -DskipTests java -Xms256m -Xmx512m -Dio.netty.leakDetection.level=paranoid \ -jar target/rtmp-server-1.0.0.jar

重点解释leakDetection.level=paranoid:Netty 4.x 的ByteBuf是基于引用计数的,这个参数开启内存泄漏检测,如果代码里某处没有释放缓冲,Netty 会在日志里打出具体的分配堆栈,对排查问题极有帮助。我自己接手新项目时,第一次启动必开这个参数,跑完一轮推流拉流就关掉,不然 paranoid 级别太耗性能。

3.2 Handler 链怎么编排:握手、解码、业务分离

Netty 的ChannelPipeline是责任链模式,每个 Handler 只管一段逻辑。这个项目如果是按典型 nettyrtmp 工程组织的,pipeline 大概是这个顺序:RTMPHandshakeHandler负责握手,RTMPChunkDecoder负责把字节流解析成消息对象,RTMPMessageHandler负责业务分发(响应 connect、转发音视频数据)。

// 服务端启动装配,Netty 样板代码 EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast("handshake", new RTMPHandshakeHandler()); pipeline.addLast("chunkDecoder", new RTMPChunkDecoder()); pipeline.addLast("commandHandler", new RTMPCommandHandler()); pipeline.addLast("mediaHandler", new RTMPMediaHandler()); } }); ChannelFuture future = bootstrap.bind(1935).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }

注意几个参数的选择理由。bossGroup设成 1 个线程,因为它的任务只是接收 TCP 连接,accept 是轻量操作,多了浪费;workerGroup不设线程数时默认为 CPU 核数的两倍,处理读写绰绰有余。SO_BACKLOG=1024是连接等待队列长度,直播场景下突然涌入大量推流请求时能兜住。TCP_NODELAY=true关闭 Nagle 算法,避免小包被内核攒着不发,RTMP 的命令消息大多不到几十字节,这个选项对延迟影响很直接。

Handler 的编排原则是“握手的只做握手,解码的只做解码”。很多半吊子项目把握手和 chunk 解码塞进同一个 Handler,结果 C0/C1 还没读完就开始按 AMF 解析字节,直接崩。拆成独立 Handler,还方便在测试时单独 mock 掉某一段逻辑。

3.3 推流与拉流在代码层面的差异

推流(publish)和拉流(play)走的是同一套 RTMP 连接,但服务端的行为完全不一样。推流端进来后,服务端要接收它的音视频数据,存到某个流 ID 对应的缓冲里;拉流端进来后,服务端要把这份数据分发给它。这也意味着一个流可以被多个拉流端共享。

代码层面,推流端上来发publish命令,服务端响应的是一系列状态消息:onFCPublish、_streamBegin(用户控制消息类型 4)。然后推流端会先发一个onMetaData(里面是分辨率、码率、编码器信息),再发 AVC sequence header 和 AAC sequence header,最后才是真正的音视频帧。服务端收到这一串数据时,必须在内存里保存“元数据 + 序列头”,因为后到的拉流端要靠它们初始化解码器,之后嫁接到这个流上。

// 拉流端接入时,先推已缓存的元数据和序列头 public void onPlay(ChannelHandlerContext ctx, String streamName) { RtmpStream stream = streamManager.get(streamName); if (stream == null) { sendStreamNotFound(ctx); return; } // 顺序必须保持:meta -> video sequence header -> audio sequence header ctx.write(stream.getMetaData()); ctx.write(stream.getVideoSequenceHeader()); ctx.write(stream.getAudioSequenceHeader()); // 后续随着发布端新数据到达,继续转发 stream.addSubscriber(ctx.channel()); }

这段代码是我在真实项目里用顺手的形式,核心思想是先让拉流端具备解码能力,再送实时帧。注意这里用ctx.write而不是ctx.writeAndFlush,因为要把三份声明数据合到一次 flush 里减少系统调用。很多从 Netty 入门教程走出来的同学习惯每段数据都writeAndFlush,在 RTMP 转发这种高频小包场景下,flush 次数直接变成性能瓶颈。一个做好 chunk 累积的项目,最终写入底层 Socket 的是合并后的ByteBuf,不是一条消息一个包。

4. 跑通推流链路:Maven 编译到 FFmpeg 实测

4.1 编译与启动:IDEA 导入要点与 mvn 命令

先确认环境。JDK 8 以上,Maven 3.6 以上,IDEA 直接File -> Open选择项目根目录的pom.xml,等待依赖下载完就能看到/src/main/java下源码。如果你是命令行走天下,那就在项目根目录执行编译:

mvn -U clean package -DskipTests

首次构建会拉不少 Netty 相关依赖,耐心等几分钟。构建完成后,target目录下会生成可执行 jar。用java -jar启动之前,先确认 TCP 1935 端口没被占:

sudo lsof -i:1935 -P -n

如果输出为空,说明端口空闲,直接启动。如果端口被其他服务占用,要么改代码里的 bind 端口,要么用nohup java -jar xxx.jar > server.log 2>&1 &放后台跑。我习惯看一眼server.log里的 Netty 启动日志,确认bind(1935)成功而不是抛出Address already in use。

4.2 FFmpeg 推流实测:参数逐项解释

服务器起来之后,最直接的验证方式是让 FFmpeg 当推流客户端。这里用本地文件模拟摄像头/采集卡,元数据和编码参数全走真实链路,验证服务器对 RTMP 协议的支持程度:

ffmpeg -re -i test.mp4 -c copy -f flv rtmp://127.0.0.1:1935/live/test

各参数的含义:-re按文件原始码率读取,模拟实时推流,不加这个参数 FFmpeg 会以最快速度把文件推完;-i test.mp4指定输入;-c copy表示不重新编码,直接用源文件的 H.264/AAC 数据封装进 FLV,本意是测试服务器转发能力,不想承担转码开销;-f flv强制输出封装格式为 FLV,因为 RTMP 消息流上的音视频数据就是 FLV Tag 的变体;rtmp://127.0.0.1:1935/live/test是应用名加流名,live是 app 名,test是 stream key。

跑起来后如果终端不断滚动「frame=」「speed=」这类输出,说明推流端在正常发送数据,这时去服务器日志里应该能看到RTMP Stream Start或者类似的自定义日志。如果 FFmpeg 卡住不动、报Connection timed out,优先检查防火墙和端口绑定地址,不要急着怀疑代码。

4.3 拉流验证与常见启动错误

推流稳定后,另开一个终端用ffplay直接拉播放:

ffplay rtmp://127.0.0.1:1935/live/test

弹出播放窗口说明整条链路已经通了。这里有一个常见的黑屏坑:推流端之后才接入的拉流端,必须先收到onMetaData和AVC sequence header才能解码。如果服务器只是转发后续的视频帧,拉流端会一直黑屏但有声音或者画面花掉。要确认是不是这个原因,可以开启 FFmpeg 的日志级别看播放端收到哪些包:

ffplay -loglevel debug rtmp://127.0.0.1:1935/live/test 2>&1 | grep -i "Video"

日志里能看到Video: h264 ...和Stream #0的输出;如果拉到一半就EOF,说明服务端在传送完序列头后没有继续推进帧数据,问题在订阅关系的维护逻辑,而不是解码器配置。

还有一种情况是启动时直接报ClassNotFoundException,这多半是运行 jar 的方式不对。Netty 项目打出来的 jar 如果不是可执行 fat jar,java -jar缺依赖类很正常。解决办法是用 Maven 插件组装可执行 jar,或者退回 IDEA 里点主类旁边的绿色三角直接跑main方法,IDE 会帮你拼好 classpath。

5. 避坑:Netty 做 RTMP 常见的几个翻车现场

5.1 现象:客户端 connect 就断,日志什么都没打

抓包看到 TCP 三次握手成功,客户端发了 C0/C1,服务端也回了 S0/S1/S2,但客户端马上断开,服务器没有任何异常堆栈。

原因是握手数据没读够就进入了下一个 Handler 阶段。RTMP chunk 解码器收到的是一个残留的半个 C2,按 chunk 头解析直接报错,连接被异常关闭。

解决办法是握手 Handler 里维护一个累积状态机,只有ChannelRead返回且累积缓冲区满足长度条件时才remove握手处理器。我一般顺手把握手阶段的 Log 也加上,收到 C0 时打一条 debug 日志,这样排查问题能直接看到“握手完成了吗”而非瞎猜。

5.2 现象:粘包/半包引起握手状态错乱

服务器刚启动,第一个推流连接正常,第二个连接进来就握手失败。抓包发现第二次连接的 C0+C1 和上一次连接的连接池复用产生了数据错乱。

RTMP 基于 TCP,TCP 本身是字节流,没有消息边界。C0/C1/C2 一起到是常态,C2 被拆成两半也常发生,这是纯粹的 Netty 粘包半包问题,和协议实现无关。

解决方式是不要在channelRead里做一次性判断,而是把输入累积到独立的ByteBuf并按长度判断。Netty 自带ByteToMessageDecoder可以天然处理这个场景,decode方法只在累积数据足够时才消费,少读的字节留在缓冲里等下次channelRead触发。使用自带解码器能省掉手工管理缓冲的生命周期代码。

5.3 现象:运行 10 分钟内存开始飙升,GC 压不住

播放一段时间后内存曲线持续上升,最后 OOM 或者 GC 停顿明显。开启leakDetection.level=paranoid后日志出现LEAK: ByteBuf.release() was not called before it's garbage-collected。

这是 Netty 4.x 的经典坑:ByteBuf是引用计数的对象,推流场景每秒钟进来几十个媒体包,每个包都经过多个 Handler,只要有一个人没调release,这个对象就不会归还对象池。Netty 事件循环里大量ByteBuf得不到释放,Off-Heap 内存先被吃干净。

解决方式分两层。第一层是规则:继承SimpleChannelInboundHandler,它的channelRead0方法会在处理完后自动释放消息;如果你用ChannelInboundHandlerAdapter,必须在每个分支的末尾ReferenceCountUtil.release(msg)。第二层是自查:把ctx.writeAndFlush换成ctx.channel().writeAndFlush时特别注意,前者不触发 outbound 链路、引用计数转移也可能不同。我在带新人时要求他们统一用SimpleChannelInboundHandler,能少踩 80% 的 Netty 内存坑。

5.4 现象:高码率推流视频帧频繁解析失败,画面断裂

推流 4Mbps 以上的 H.264 流时,服务端报Failed to decode RTMP message,播放端画面花屏,但低码率推流完全正常。

原因是 RTMP 消息被切成多个 chunk 之后,每个 chunk 单独进入解码器,某些实现没有按同一个 chunk stream ID 做累积合并,把半个视频帧当成完整帧上抛。视频帧大时,一个帧可能被切成十几个 chunk,任何中间状态丢失都会引发解析失败。

解决方式有两个关键点。一是解码器按 chunk stream ID 分表累积,每条消息维护独立的ByteBuf,消息完整后一次性发出。二是 chunk 大小协商:服务端收到Set Chunk Size消息后要正确记录新值,否则对端切到 4096 字节的 chunk,你还在按 128 字节切分,直接乱套。我自己实现时会把chunk size作为解码器内部字段,每次消息头进来都检查是否包含新的协商值。

5.5 现象:拉流黑屏,日志里播放列表正常

推流端日志显示正常推流,拉流端连进来也能拿到长度正确的流,但画面一直黑屏,用ffprobe看拉流端元数据为空。

原因是拉流端接入时,服务器只转发了后续的视频帧消息,没有先发送onMetaData和AVC sequence header。播放器没有这两个包就无法初始化解码器,拿到后续媒体帧也只能丢弃。

解决方式是服务器在publish过程中缓存onMetaData、视频序列头、音频序列头这三类声明消息,挂到对应流对象上。每当新播放器执行play时,按顺序先写这三条消息,再开始转发后续数据。另外一个细节是AVC sequence header不是视频帧,它的消息头里的 timestamp 通常为 0,转发时别改这个时间戳,否则 H.264 解码器时间基会乱。

6. 最后一步:验证服务器性能的实战技巧

前面几章的内容把服务器跑通了,但“能跑”和“能上线”之间差着一套验证方法。我习惯给服务器做一次体检,ffprobe是其中最趁手的工具,验证延迟用端到端秒表法,验证内容完整性用抓包对比法。

测延迟时找个 CRT 或者手机屏幕显示秒表,摄像头对着它推流,用ffplay在同一局域网里播放,截图对比播放端秒表和真实秒表的时间差。FFmpeg 系工具还有一个精确到毫秒的测量方式,用ffprobe看时间戳:

ffprobe -v error -show_entries format=start_time -of default=nw=1 rtmp://127.0.0.1:1935/live/test

这个命令返回的值表示从播放开始到第一个关键帧的等待时间。如果这个值突然涨到几百甚至几千毫秒,问题多半出在 chunk 合并缓存上,检查是不是服务器攒了太多 chunk 才上抛,而不是一有数据就 flush。

抓包验证内容完整性的命令是配合 Wireshark 使用。Wireshark 底层用libpcap,可以直接过滤 RTMP 应用层协议,看到一个连接里有connect、createStream、publish、onFCPublish,服务器握手和命令响应就齐了。不方便用 Wireshark 的纯命令行环境,可以用tcpdump抓包落盘:

sudo tcpdump -i eth0 -s 0 -w rtmp.pcap port 1935

用capinfos rtmp.pcap看包数量和平均包长,再交给 Wireshark 验证。如果包数量变化但播放端画面卡顿,说明消息顺序或缺失严重,需要回到 Handler 链上排查转发逻辑。

参数层面的快速调优,列出我常用的改法供对照:

参数默认推荐说明
SO_BACKLOG1281024突发连接时等待队列不够会拒绝连接
WRITE_BUFFER_WATER_MARK32KB64KB播放端积压时触发 channelWritabilityChanged
EventLoopGroup线程数CPU 核数 × 2按连接数调整连接多时增大 worker 线程数
Chunk Size 协商1284096减少小包数量,降低 CPU 开销

在新项目上,我会强制自己按这个顺序检查一遍:先抓包确认协议节点完整,再跑一份高码率流压测,最后用秒表法实测延迟。如果这三关都过了,服务器基本不会在大并发场景里出那种“玄学断流”的问题。如果你正在折腾这个rtmpServer-master或者自己写的 Netty RTMP 服务端,先把握手缓冲和 chunk 合并这两块代码重新读一遍,绝大多数翻车现场都在那里。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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