Netty实现RTMP推流服务端:协议解析、握手与Chunked实战
发布时间:2026/10/26 7:54:10 锦皓数字建站

简介这是一份基于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 握手分三个阶段客户端先发送 C01 个字节表示 RTMP 版本号一般是 3和 C11536 字节的随机数据服务端收到 C0C1 后返回 S01 字节 S11536 字节 S21536 字节客户端收到后回复 C21536 字节。也就是说服务端最少要读 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 字节然后一次性回复S0S1S2之后再等 C2。关键点是这中间任何一次读取都可能是半个包所以代码必须围绕“累积 ByteBuf、判断可读字节数”来写不能假设一次channelRead就能拿到完整数据。顺便说一嘴S1 和 S2 的内容虽然协议允许拷贝 C1 的数据回传但主流实现大多会自己生成随机数不影响兼容性。2.2 AMF0 消息connect 和 createStream 就是三个字段握手完成后RTMP 就进入了 AMF 消息阶段。AMFAction Message Format是一种二进制序列化格式RTMP 的命令层完全建立在它之上。connect、createStream、publish、play这些控制命令全部都是 AMF0 编码的消息。每条命令消息的格式是固定的命令名字符串比如connect、事务 ID数字、命令对象一个 Object 类型里面放着app、tcUrl、type等字段。服务端解析connect命令时要做的就是按 AMF0 的类型标志位往后读读到 0x02 表示字符串读到 0x00 表示数字读到 0x03 表示对象开始。核心消息类型要记牢类型 180x12是AMF0 Command Messageconnect、createStream、publish、play都走它类型 190x13是AMF0 Data Message推流端的onMetaData走它类型 8 是音频数据类型 9 是视频数据。下面给出服务端判断消息类型的典型逻辑// Netty 解码器中对 RTMP 消息类型的分流判断 switch (messageType) { case 18: // AMF0 Command Message handleAmfCommand(ctx, in); break; case 19: // AMF0 Data MessageonMetaData 在这里 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 typefmt。服务端要做的是按 chunk stream ID 累积同一消息的多个 chunk等攒够消息长度后组装成完整消息再上抛。下面是一个简化的 chunk 合并逻辑// 按 chunk stream ID 累积块拼成完整 RTMP 消息 MapInteger, 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.levelparanoid \ -jar target/rtmp-server-1.0.0.jar重点解释leakDetection.levelparanoidNetty 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 ChannelInitializerSocketChannel() { 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_BACKLOG1024是连接等待队列长度直播场景下突然涌入大量推流请求时能兜住。TCP_NODELAYtrue关闭 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 21 放后台跑。我习惯看一眼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 21 | grep -i Video日志里能看到Video: h264 ...和Stream #0的输出如果拉到一半就EOF说明服务端在传送完序列头后没有继续推进帧数据问题在订阅关系的维护逻辑而不是解码器配置。还有一种情况是启动时直接报ClassNotFoundException这多半是运行 jar 的方式不对。Netty 项目打出来的 jar 如果不是可执行 fat jarjava -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 现象粘包/半包引起握手状态错乱服务器刚启动第一个推流连接正常第二个连接进来就握手失败。抓包发现第二次连接的 C0C1 和上一次连接的连接池复用产生了数据错乱。RTMP 基于 TCPTCP 本身是字节流没有消息边界。C0/C1/C2 一起到是常态C2 被拆成两半也常发生这是纯粹的 Netty 粘包半包问题和协议实现无关。解决方式是不要在channelRead里做一次性判断而是把输入累积到独立的ByteBuf并按长度判断。Netty 自带ByteToMessageDecoder可以天然处理这个场景decode方法只在累积数据足够时才消费少读的字节留在缓冲里等下次channelRead触发。使用自带解码器能省掉手工管理缓冲的生命周期代码。5.3 现象运行 10 分钟内存开始飙升GC 压不住播放一段时间后内存曲线持续上升最后 OOM 或者 GC 停顿明显。开启leakDetection.levelparanoid后日志出现LEAK: ByteBuf.release() was not called before its 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 formatstart_time -of defaultnw1 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播放端积压时触发 channelWritabilityChangedEventLoopGroup线程数CPU 核数 × 2按连接数调整连接多时增大 worker 线程数Chunk Size 协商1284096减少小包数量降低 CPU 开销在新项目上我会强制自己按这个顺序检查一遍先抓包确认协议节点完整再跑一份高码率流压测最后用秒表法实测延迟。如果这三关都过了服务器基本不会在大并发场景里出那种“玄学断流”的问题。如果你正在折腾这个rtmpServer-master或者自己写的 Netty RTMP 服务端先把握手缓冲和 chunk 合并这两块代码重新读一遍绝大多数翻车现场都在那里。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。