简介:这是一份基于Netty实现的轻量级RTMP服务器开源项目,面向Java后端开发者、流媒体初学者及实时音视频服务实践者,解决从零构建RTMP推拉流服务器的核心技术落地问题。资源共50个文件,含16个Java源码(涵盖Netty服务启动、RTMP握手解析、Chunk解复用、AMF消息处理等核心逻辑)、19个编译后class文件、9个XML配置与依赖管理文件,以及README文档和IDE配置文件,整体仅59KB,结构精简、模块职责清晰,便于快速理解协议层与框架层协同机制。已有767人学习下载,适合用于深入掌握RTMP协议交互流程、Netty事件驱动编程模型,以及FFmpeg推流联调验证。读者可直接运行服务端,结合FFmpeg命令完成推流测试,并通过源码逐层分析连接建立、音视频包解析、会话管理等关键环节,获得可调试、可扩展的RTMP服务开发范例。
1. 为什么你本地跑通的 RTMP 推流,一上生产就卡顿、断连、花屏?——这不是网络问题,是 Netty 层没扛住粘包和心跳
你用 FFmpeg 推流到rtmp://localhost:1935/live/stream能播,但换到局域网另一台机器就黑屏;用 OBS 推流 10 分钟后自动断开,日志里只有一行Channel inactive;更玄的是,同一份推流配置,在 Windows 上稳如泰山,Linux 上每 37 秒必丢一次 GOP。这些不是“玄学”,而是rtmpServer-master_nettyrtmp这类基于 Netty 实现的轻量级 RTMP 服务在真实场景中暴露出的典型失稳现象。它不依赖 Nginx-RTMP 或 SRS 那样的重型中间件,靠纯 Java + Netty 构建协议栈,优势是代码透明、可定制强、嵌入成本低——特别适合 IoT 设备端嵌入、教育录播系统二次开发、或作为微服务架构中的流媒体网关模块。但代价是:Netty 的 ChannelHandler 链必须亲手处理 RTMP 协议握手、Chunk Stream 复用、AMF0/AMF3 解析、时间戳校准、以及最关键的——TCP 层粘包与半包。很多开发者卡在“能跑通”就停步,结果上线后被并发推流压垮、被弱网环境反向击穿、被安卓端低版本 MediaCodec 推出的非标 FLV Tag 搞崩溃。这篇笔记,就是帮你把rtmpServer-master从“玩具级 demo”拉回“可交付服务”的实操路径。
2. 从源码结构到启动流程:看清rtmpServer-master_nettyrtmp真正的骨架
这个项目名里的nettyrtmp不是噱头,它本质是一个Netty 4.x 驱动的 RTMP 协议解析器 + 内存级流管理器,而非完整 CDN 或转码服务。它的价值不在功能堆砌,而在协议层的可控性——你能直接修改RtmpDecoder中的decode()方法来兼容某款国产 IPC 的私有 Header,也能在RtmpSession里注入自定义鉴权逻辑。先厘清它到底由哪几块组成,再决定改哪里、不动哪里。
2.1 源码目录的真实分工(以主流 fork 版本为准)
提示:不要迷信 GitHub 上 star 数最高的分支。实际生产中,我们选的是 commit 在
2022-08-15后、明确标注support-h265且pom.xml中 Netty 版本为4.1.92.Final的 fork。旧版4.1.43存在ByteBuf内存泄漏风险,已在该 commit 中修复。
| 目录路径 | 核心职责 | 是否建议修改 | 关键文件举例 |
|---|---|---|---|
src/main/java/com/github/rtmpserver/protocol/rtmp | RTMP 协议状态机、消息编解码(RtmpMessage,RtmpDecoder,RtmpEncoder) | ⚠️ 高风险,仅当需支持 H.265 Annex B 或自定义 AMF 结构时动 | RtmpHandshake.java,RtmpMessageDecoder.java |
src/main/java/com/github/rtmpserver/handler | Netty ChannelHandler 链:连接管理、心跳保活、流注册、推拉流路由 | ✅ 推荐重点改造,这是业务逻辑入口 | RtmpConnectionHandler.java,RtmpStreamHandler.java |
src/main/java/com/github/rtmpserver/storage | 流数据暂存策略:内存队列(默认)、可插拔的 Redis 缓存、或文件落地(需自行实现) | ✅ 必调,决定并发承载力上限 | MemoryStreamStorage.java,StreamStorage.java |
src/main/resources/application.conf | 启动参数:端口、最大连接数、chunk size、心跳间隔、流超时阈值 | ✅ 必配,不调等于裸奔 | rtmp.port=1935,rtmp.max.connections=500 |
2.2 启动入口:RtmpServerApplication的三步初始化链
它不走 Spring Boot AutoConfiguration,而是手动构建 NettyServerBootstrap。关键在于initPipeline()中的 Handler 注册顺序——这直接决定粘包是否被正确切分:
// RtmpServerApplication.java private void initPipeline(ChannelPipeline pipeline) { // Step 1: 必须最先加——解决 TCP 粘包! pipeline.addLast("frameDecoder", new LengthFieldBasedFrameDecoder( 10 * 1024 * 1024, // max frame length: 10MB(覆盖大关键帧) 0, // length field offset 4, // length field length -4, // length adjustment 0 // initial bytes to strip )); // Step 2: RTMP 协议解码器(依赖上一步已切好的完整 chunk) pipeline.addLast("rtmpDecoder", new RtmpDecoder()); // Step 3: 业务处理器(连接、流、心跳) pipeline.addLast("connectionHandler", new RtmpConnectionHandler()); pipeline.addLast("streamHandler", new RtmpStreamHandler()); }为什么LengthFieldBasedFrameDecoder是生死线?
RTMP 协议本身不带长度头,但 Netty 要求每个ByteBuf必须代表一个完整 RTMP Message(即一个 Chunk Stream 的完整 payload)。而真实网络中,TCP 层会把多个小 chunk 合并发送(粘包),或把一个大 chunk 拆成多段(拆包)。LengthFieldBasedFrameDecoder就是靠读取每个 chunk 的message length字段(RTMP Header 后第 4 字节起的 3 字节)来切分。如果这里配错,后续所有解码都会错位——表现为AMF decode error、invalid timestamp、或直接ChannelInactiveException。
2.3 默认流地址规则与测试验证闭环
它不生成rtmp://xxx/live/xxx这种固定路径,而是按app+name两级动态注册。推流地址格式为:rtmp://<host>:<port>/<app-name>/<stream-name>
例如:rtmp://192.168.1.100:1935/live/camera01
<app-name>:对应application.conf中rtmp.app.name(默认live),也是流存储的命名空间<stream-name>:客户端指定,服务端不做校验,直接注册为StreamKey
验证是否真正跑通,不能只看 FFmpeg 命令返回success,要三步确认:
- 连接层:
telnet 192.168.1.100 1935能通,说明 TCP 监听正常; - 协议层:Wireshark 抓包,过滤
rtmp && ip.addr==192.168.1.100,看到connect→createStream→publish完整握手; - 数据层:用 VLC 打开
rtmp://192.168.1.100:1935/live/camera01,播放 5 分钟无卡顿、无花屏、时间戳连续(VLC 右下角显示FPS: 25.0且不跳变)。
3. 推流稳定性攻坚:Netty 粘包处理、心跳保活与安卓端兼容三板斧
推流中断不是偶然,是 TCP 连接在无数据时被中间设备(路由器、防火墙、NAT)静默回收。rtmpServer-master默认的心跳机制极其简陋——只在RtmpConnectionHandler中响应客户端发来的ping,却不主动探测。而安卓端(尤其 Android 8+)的 MediaCodec 推流常因省电策略导致onVideoFrameAvailable回调延迟,造成心跳超时。这三件事必须一起调。
3.1 粘包处理:从LengthFieldBasedFrameDecoder到RtmpChunkDecoder的双保险
上面提到的LengthFieldBasedFrameDecoder是第一道防线,但它只保证“字节流被切成 chunk”,不保证“chunk 被正确组装成 message”。RTMP 的 Chunk Stream 机制允许一个 message 被拆成多个 chunk 发送,RtmpDecoder必须缓存未完成的 chunk 并等待chunk type为0(full message)或1(first chunk)的标志位。常见错误是:
- 未设置
chunkSize全局变量(默认 128 字节),导致大 I 帧被过度切分; RtmpChunkDecoder中未处理timestamp delta跨 chunk 累加,造成音视频不同步。
实操修正:在RtmpDecoder.java中强化 chunk 组装逻辑
// RtmpDecoder.java private void decodeChunk(ByteBuf in, List<Object> out) { // ... 解析 chunk basic header ... if (chunkType == 0) { // full message // 直接解码 RtmpMessage msg = decodeMessage(in); out.add(msg); } else if (chunkType == 1 || chunkType == 2) { // first or middle chunk // 缓存到 currentChunkBuffer,并记录 expectedLength if (currentChunkBuffer == null) { currentChunkBuffer = Unpooled.buffer(expectedLength); } currentChunkBuffer.writeBytes(in.readBytes(in.readableBytes())); // 关键:检查是否收齐 if (currentChunkBuffer.readableBytes() >= expectedLength) { RtmpMessage msg = decodeMessage(currentChunkBuffer); out.add(msg); currentChunkBuffer.release(); currentChunkBuffer = null; } } }参数说明:
expectedLength来自 chunk header 中的message length字段,必须在chunkType == 0或==1时准确读取;Unpooled.buffer()创建堆外内存缓冲区,避免频繁 GC;currentChunkBuffer.release()必须显式释放,否则内存泄漏(这是rtmpServer-master旧版高频 Bug)。
3.2 心跳保活:双向探测 + 可配置超时阈值
默认实现只响应ping,但安卓端可能因后台限制不发ping。我们必须:
- 服务端主动
writeAndFlush(new RtmpPingMessage()); - 客户端连接后启动
IdleStateHandler; - 超时后执行
closeOnIdle()而非粗暴channel.close()。
在RtmpConnectionHandler.java中注入心跳逻辑:
// initPipeline 中添加 pipeline.addLast("idleStateHandler", new IdleStateHandler( 30, // readerIdleTimeSeconds:30秒没收到数据则触发 IDLE_STATE_EVENT 0, // writerIdleTimeSeconds:不主动发心跳时不启用 0 // allIdleTimeSeconds )); pipeline.addLast("heartbeatHandler", new HeartbeatHandler()); // HeartbeatHandler.java public class HeartbeatHandler extends ChannelInboundHandlerAdapter { @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event = (IdleStateEvent) evt; if (event.state() == IdleState.READER_IDLE) { // 主动发 ping 探测 ctx.writeAndFlush(new RtmpPingMessage()); // 同时启动超时计时器(防 ping 丢失) ctx.channel().attr(HEARTBEAT_TIMEOUT).set(System.currentTimeMillis()); } } } }配套配置项(application.conf):
rtmp.heartbeat { interval = 25s # 心跳间隔,必须 < readerIdleTimeSeconds timeout = 45s # 连续2次ping无响应则断连 enable = true }3.3 安卓端兼容:绕过MediaCodec输出的非标 FLV Tag
安卓MediaCodec输出的 H.264 Annex B 数据,常在 SPS/PPS 前插入0x00000001起始码,但RtmpMessageDecoder默认按标准 FLV Tag 解析(Tag Header 后直接是 NALU)。若不解析起始码,会导致AVCDecoderConfigurationRecord解析失败,进而无法生成正确的onMetaData。
解决方案:在RtmpMessageDecoder.java中预处理 NALU:
private byte[] normalizeH264Nalu(byte[] raw) { ByteBuffer bb = ByteBuffer.wrap(raw); // 检查是否以 0x00000001 开头 if (bb.remaining() >= 4 && bb.get(0) == 0 && bb.get(1) == 0 && bb.get(2) == 0 && bb.get(3) == 1) { // 跳过起始码,提取真实 NALU byte[] nalu = new byte[bb.remaining() - 4]; bb.position(4); bb.get(nalu); return nalu; } return raw; // 已是标准格式 }注意:此逻辑必须放在
decodeVideoData()中,且仅对AVC NALU类型生效(tagType == 9 && avcPacketType == 1)。对音频 AAC 数据无效。
4. 避坑指南:生产环境踩过的 5 个血泪坑,每个都让服务停摆超 2 小时
别等线上报警才翻日志。这 5 个坑,是我们用 3 台边缘盒子、7 种安卓机型、21 天压力测试撞出来的。它们不写在 README 里,但每个都足以让rtmpServer-master在凌晨 3 点崩给你看。
4.1 现象:推流 1 分钟后ChannelInactiveException,日志只显示connection reset by peer
原因:Linux kernel 的tcp_fin_timeout默认 60 秒,而rtmpServer-master的StreamStorage默认streamTimeout = 30s。当推流端(如 IPC)因网络抖动暂停发送超过 30 秒,服务端主动 close channel,但 TCP FIN 包未被 ACK,导致内核重传 FIN 失败后强制 reset。
解决:
- 调大
application.conf中rtmp.stream.timeout = 90s; - 同步调整 Linux 参数:
echo 120 > /proc/sys/net/ipv4/tcp_fin_timeout; - 关键:在
RtmpStreamHandler.channelInactive()中添加ctx.close()前的日志打点,确认是服务端主动关闭还是对端异常断开。
4.2 现象:多路推流时 CPU 暴涨至 95%,top显示java进程占满单核
原因:MemoryStreamStorage使用ConcurrentLinkedQueue存储待消费的RtmpMessage,但RtmpStreamHandler的read()方法未做批处理,每次只取 1 条 message 进行编码转发,导致高频锁竞争。
解决:
- 改造
MemoryStreamStorage.poll()为批量获取:
public List<RtmpMessage> pollBatch(int maxCount) { List<RtmpMessage> batch = new ArrayList<>(maxCount); for (int i = 0; i < maxCount && !queue.isEmpty(); i++) { RtmpMessage msg = queue.poll(); if (msg != null) batch.add(msg); } return batch; }- 在
RtmpStreamHandler中调用pollBatch(32)替代单条poll(); - 设置 JVM 参数
-XX:+UseG1GC -XX:MaxGCPauseMillis=200,避免 GC 导致消息积压。
4.3 现象:VLC 播放首帧黑屏 3 秒,之后正常
原因:RtmpServer-master默认不发送onMetaData,而 VLC 依赖此信息初始化解码器。缺少duration、width、height、framerate等字段,导致解码器等待超时。
解决:
- 在
RtmpStreamHandler.publishStart()后,立即构造并发送onMetaData:
AmfObject metaData = new AmfObject(); metaData.put("duration", 0.0); // live stream metaData.put("width", 1280.0); metaData.put("height", 720.0); metaData.put("framerate", 25.0); metaData.put("videocodecid", "avc1"); metaData.put("audiocodecid", "mp4a"); // ... 其他必要字段 ctx.writeAndFlush(new RtmpNotifyMessage("onMetaData", metaData));- 注意:
videocodecid必须与实际推流的 codec id 一致(H.264 为avc1,H.265 为hvc1),否则 VLC 拒绝解码。
4.4 现象:同一stream-name被重复推流,旧流未被踢出,新流无法播放
原因:StreamStorage的registerStream()方法未做冲突检测,直接覆盖ConcurrentHashMap中的 key。旧流的ChannelHandlerContext仍持有引用,但RtmpStreamHandler已失去对其控制。
解决:
- 在
registerStream()中添加踢出逻辑:
public void registerStream(String streamKey, RtmpStream stream) { RtmpStream old = streams.put(streamKey, stream); if (old != null && old.getChannel() != null && old.getChannel().isActive()) { old.getChannel().close(); // 主动关闭旧连接 logger.warn("Stream {} replaced by new connection", streamKey); } }- 血泪经验:必须
close()而非disconnect(),后者不触发channelInactive()事件。
4.5 现象:使用ffmpeg -re -i input.mp4 -f flv rtmp://...推流,服务端日志疯狂打印AMF decode error
原因:FFmpeg 的-re模式会严格按原始帧率发送,但rtmpServer-master的RtmpDecoder对timestamp的单调递增校验过于激进——当输入 MP4 的dts有轻微抖动(如 123456, 123458, 123457),校验失败直接抛异常。
解决:
- 修改
RtmpMessageDecoder.decodeTimestamp(),放宽校验:
if (Math.abs(timestamp - lastTimestamp) > 1000) { // 允许 1 秒内跳变 logger.debug("Timestamp jump detected: {} -> {}, ignored", lastTimestamp, timestamp); timestamp = lastTimestamp + 40; // 伪补帧,保持 25fps } lastTimestamp = timestamp;- 警告:此修改仅用于测试文件推流,生产环境 IPC 推流必须关闭此宽松模式。
5. 性能压测与监控:用 3 个命令摸清你的rtmpServer-master真实吞吐边界
能跑通不等于能扛住。真正的交付标准是:在目标硬件(如 Intel NUC i3 + 8GB RAM)上,稳定支撑 200 路 720p@25fps H.264 推流,CPU < 70%,内存 < 3GB,无丢帧。以下方法不依赖 Grafana 或 Prometheus,用原生命令就能拿到可信数据。
5.1 实时连接数与流数监控:netstat+jstack黄金组合
不要信application.conf里的max.connections,要看真实占用:
# 查看 ESTABLISHED 连接数(排除 TIME_WAIT) netstat -an | grep :1935 | grep ESTABLISHED | wc -l # 查看每个连接对应的 Java 线程(确认是否线程池耗尽) jstack $(pgrep -f "RtmpServerApplication") | grep "nioEventLoopGroup" | wc -l # 查看当前注册的流数量(直接读内存状态) jmap -histo $(pgrep -f "RtmpServerApplication") | grep "RtmpStream"解读:
- 若
netstat结果接近max.connections,但jmap显示RtmpStream实例远少于连接数,说明大量连接未完成 publish,卡在 handshake 阶段——检查RtmpHandshakeHandler是否阻塞; - 若
nioEventLoopGroup线程数达2 * CPU cores,且jstack中大量线程处于RUNNABLE状态,说明 Netty EventLoop 过载,需调大bossGroup和workerGroup线程数(application.conf中netty.boss.threads = 2,netty.worker.threads = 16)。
5.2 帧率与丢包率抓取:Wireshark 过滤 + Python 脚本分析
用 Wireshark 抓rtmp流,导出为rtmp.pcapng,然后用脚本统计关键指标:
# analyze_rtmp.py import pyshark cap = pyshark.FileCapture('rtmp.pcapng', display_filter='rtmp.msg_type == 9') # video data timestamps = [] for pkt in cap: try: ts = int(pkt.rtmp.timestamp) timestamps.append(ts) except: continue # 计算 FPS(每秒帧数) import numpy as np diffs = np.diff(timestamps) fps = 1000 / np.mean(diffs[diffs > 0]) # ms to sec loss_rate = 1 - len(timestamps) / (max(timestamps) - min(timestamps)) * 1000 / 40 # 40ms per frame @25fps print(f"Average FPS: {fps:.1f}, Loss Rate: {loss_rate:.2f}%")参数说明:
rtmp.msg_type == 9过滤视频帧(10 为音频);40ms是 25fps 的理论帧间隔,loss_rate计算基于时间窗口内应有帧数 vs 实际帧数;- 若
loss_rate > 2%,优先检查MemoryStreamStorage的queue.size()是否持续 > 1000 —— 这意味着消费者(拉流端)跟不上生产者(推流端)。
5.3 内存泄漏定位:jmap+jhat三步法
rtmpServer-master最隐蔽的坑是ByteBuf泄漏。症状:运行 24 小时后Used Heap从 500MB 涨到 2.8GB,Full GC 频繁。
# 1. 生成堆转储 jmap -dump:format=b,file=heap.hprof $(pgrep -f "RtmpServerApplication") # 2. 启动 jhat 分析(JDK8 自带) jhat -J-Xmx4g heap.hprof # -J-Xmx4g 防止 jhat 自身 OOM # 3. 浏览 http://localhost:7000,搜索 'io.netty.buffer',重点关注: # - PooledUnsafeDirectByteBuf 实例数是否持续增长 # - 是否存在大量 'io.netty.util.ResourceLeakDetector$DefaultResourceLeak' 报告根治方案:
- 所有
ByteBuf的readBytes()、writeBytes()操作后,必须调用buf.release(); - 在
RtmpDecoder和RtmpEncoder的encode()方法末尾,添加if (buf.refCnt() > 0) buf.release(); - 终极保险:在 JVM 启动参数中加入
-Dio.netty.leakDetection.level=paranoid,让 Netty 在每次ByteBuf未释放时打印完整调用栈。
我坚持在每个ChannelHandler的exceptionCaught()里加一行logger.error("Unexpected exception", cause),并确保cause.printStackTrace()不被注释掉——因为 90% 的线上故障,第一次报错日志里就藏着答案,只是没人去看。rtmpServer-master不是银弹,但它给你的,是协议栈完全透明的掌控感。当客户说“你们的推流服务器不如 SRS 稳定”时,你不用背锅,可以直接打开RtmpDecoder.java,指着第 137 行说:“这里少了个release(),我 5 分钟修好。” 希望帮到你。
本文还有配套的精品资源,点击获取