ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java从零搭建流媒体直播服务器:Netty+RTMP+HTTP-FLV实战

Java从零搭建流媒体直播服务器:Netty+RTMP+HTTP-FLV实战 简介这份资源面向具备一定Java基础、希望深入音视频与直播服务端开发的开发者聚焦流媒体直播服务器的完整设计与实现。内容围绕RTMP、HLS、DASH等主流协议展开涵盖Java NIO高并发连接处理、H.264与HEVC视频编码、AAC音频编码及转码逻辑并延伸至负载均衡、故障转移、Prometheus与Grafana监控等运维要点帮助读者打通从音视频处理到服务器架构的完整链路。资源包共31个文件以18个java源码为核心辅以png与gif界面素材、config配置文件和ucls等工程文件整体约278KB结构紧凑便于按模块研读。目前已有1944人学习下载适合作为直播服务端项目的实践参考读者可借此理解推流拉流流程、并发模型与配置组织方式为音视频方向的进阶开发积累可复用的工程经验。1. 从零搭一套 Java 流媒体直播服务器为什么我选 Netty 而不是 Spring Boot 全家桶很多人第一次听到“基于 Java 的流媒体视频直播服务器”脑子里浮现的是 Spring Boot 加个接口返回 m3u8 地址就完事了。真到要扛几十路并发推流、几百人同时拉流的时候才发现 HTTP 那套请求-响应模型根本撑不住长连接和实时数据分发。流媒体直播服务器的本质是一个长连接、高吞吐、低延迟的数据管道推流端把编码后的音视频帧持续灌进来服务器要缓存、转发、按需分发拉流端随时接入随时播放。它和普通 Web 服务的区别就像自来水管和消防水管的区别——口径、压力、持续供给能力完全不是一个量级。这篇文章面向的是想用 Java 从零实现一套能跑起来的直播服务器的开发者不管你是做课程设计需要可演示的完整链路还是工作中要自建小规模直播中转服务。我会把 RTMP 协议解析、Netty 事件循环、FLV 封装、HTTP-FLV 分发这条主线拆开讲清楚每一步给出可复现的代码结构和参数配置。你不需要事先懂音视频编解码但需要会写 Java、用过 Maven、能看懂基本的网络编程概念。读完你手里应该有一套能本地推流、浏览器拉流的最小可用系统以及知道往哪个方向继续加功能。2. 协议选型与 Netty 事件循环为什么 RTMP 推流 HTTP-FLV 拉流是当前最稳的组合2.1 推流协议为什么先锁 RTMP直播链路分两段推流端到服务器、服务器到播放端。推流端的选择其实不多。RTMP 虽然老但 OBS、FFmpeg 这些主流推流工具默认就支持协议规范公开且稳定基于 TCP 天然保证顺序对于“先跑通再优化”的目标来说是最短路径。相比之下 RTSP 更偏向安防设备WebRTC 推流对浏览器端要求高且服务端实现复杂度陡增SRT 需要额外编译支持。常见做法是推流走 RTMP服务器内部解出音视频帧后再用更适合分发的协议送出去。RTMP 的核心是 chunk 分块传输。一条消息比如一个视频帧可能几 KB 到几百 KBRTMP 把它切成默认 128 字节的 chunk每个 chunk 带一个基本头Basic Header和消息头Message Header接收端再按 csidchunk stream id和 timestamp 重新组装。理解这一点很关键因为后面写 Netty 解码器时你处理的不是完整消息而是一个个 chunk。2.2 拉流端为什么选 HTTP-FLV 而不是 HLSHLS 兼容性最好但延迟通常在 5 到 15 秒因为它是切片分发。HTTP-FLV 把 FLV 格式的流通过 HTTP 长连接持续推给客户端延迟可以压到 1 到 3 秒而且浏览器端用 flv.js 就能播不需要插件。对于“直播”这个场景HTTP-FLV 的延迟体验明显更好。代价是移动端原生播放器支持不如 HLS 广泛但如果是内部系统或 PC 端为主这个代价可以接受。选型结论推流 RTMP服务器内部转封装为 FLV拉流 HTTP-FLV。整条链路只涉及两种协议实现量可控。2.3 Netty 的 Reactor 模型怎么映射到直播场景Netty 的主从 Reactor 模型天然适合直播服务器。bossGroup 负责接受 TCP 连接workerGroup 负责处理已建立连接上的读写事件。每个推流连接和每个拉流连接都是独立的 Channel互不阻塞。推流端的数据到达时在 worker 线程里完成 RTMP 解析、帧缓存、分发给所有订阅了该流的拉流 Channel。这里有一个容易翻车的地方不要在 Netty 的 IO 线程里做耗时的转码或磁盘操作。RTMP 解析和 FLV 封装本身是内存操作速度够快可以放在 IO 线程但如果后续要加转码必须扔到独立业务线程池否则一个慢操作会阻塞整条 event loop 上的所有连接。// Netty 服务端启动骨架 EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(); // 默认 CPU 核数 * 2 try { ServerBootstrap b new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ChannelPipeline p ch.pipeline(); // 先判断端口1935 走 RTMP8080 走 HTTP-FLV p.addLast(new RtmpOrHttpDetector()); // 自定义协议探测 p.addLast(new RtmpDecoder()); // RTMP chunk 解码 p.addLast(new RtmpHandler()); // 业务处理 } }) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true); // 直播必须关 Nagle ChannelFuture f b.bind(1935).sync(); f.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }SO_BACKLOG设为 1024 是给突发连接留余量TCP_NODELAY必须开否则小包会被 Nagle 算法攒着发直播延迟直接爆炸。NioEventLoopGroup不传参数时默认线程数是 CPU 核数乘二对于直播这种 IO 密集型场景够用如果连接数上万再考虑调大。2.4 RTMP 握手与 chunk 解码的最小实现路径RTMP 连接建立分三步握手Handshake、连接Connect、创建流CreateStream。握手是 C0/C1/C2 和 S0/S1/S2 六条消息交换验证双方 RTMP 版本。连接阶段客户端发connect命令服务器回_result然后客户端发createStream服务器分配一个 stream id。之后publish命令开始推流。解码器的核心逻辑是读 Basic Header 拿到 fmt 和 csid根据 fmt 读 Message Header 拿到 timestamp、message length、message type id、message stream id然后按 length 收 chunk data收满一条消息就交给上层处理。前一条消息没收完时后续 chunk 可能交错到达所以每个 csid 要维护一个独立的组装状态。// 简化版 chunk 解码核心逻辑 private void decodeChunk(ByteBuf in, ListObject out) { int basicHeader in.readByte() 0xFF; int fmt (basicHeader 6) 0x03; int csid basicHeader 0x3F; if (csid 0) csid 64 (in.readByte() 0xFF); else if (csid 1) csid 64 (in.readByte() 0xFF) ((in.readByte() 0xFF) 8); ChunkStreamState state stateMap.computeIfAbsent(csid, k - new ChunkStreamState()); // 根据 fmt 读取不同长度的 Message Header if (fmt 1) state.timestampDelta readMedium(in); if (fmt 2) state.messageLength readMedium(in); if (fmt 2) state.messageTypeId in.readByte() 0xFF; if (fmt 0) state.messageStreamId readLittleEndianInt(in); int toRead Math.min(state.messageLength - state.received, in.readableBytes()); state.payload.writeBytes(in, toRead); state.received toRead; if (state.received state.messageLength) { out.add(new RtmpMessage(state.messageTypeId, state.messageStreamId, state.payload)); state.reset(); } }fmt决定 Message Header 的压缩程度0 是完整头1 省略 stream id2 再省略 message length 和 type3 全省略。timestampDelta是相对时间戳只有 fmt 为 0 时才是绝对时间戳。messageStreamId是小端序这个坑很多人第一次写会读错。stateMap用 ConcurrentHashMap 保证多连接安全。3. 从 RTMP 消息到 FLV 标签音视频帧的缓存、分发与 HTTP-FLV 输出3.1 识别音视频消息并剥离 FLV 需要的元数据RTMP 消息里message type id 为 8 是音频9 是视频18 是 AMF0 元数据比如onMetaData包含宽高、帧率、编码信息。推流端发的第一个视频消息通常是 AVC sequence headertype 9AVC packet type 0音频是 AAC sequence headertype 8AAC packet type 0。这两个头必须缓存下来因为每个新接入的拉流端都需要先收到它们才能正确解码。// 消息分发核心缓存 sequence header广播普通帧 public void onRtmpMessage(RtmpMessage msg) { switch (msg.getType()) { case 18: // AMF0 metadata metadata msg.getPayload(); break; case 8: // audio if (isAacSequenceHeader(msg)) { aacSeqHeader msg.getPayload().copy(); } broadcast(msg); break; case 9: // video if (isAvcSequenceHeader(msg)) { avcSeqHeader msg.getPayload().copy(); } broadcast(msg); break; } } private void broadcast(RtmpMessage msg) { for (FlvSubscriber sub : subscribers) { // 新订阅者先补发 sequence header if (!sub.isHeaderSent()) { sub.sendRaw(avcSeqHeader); sub.sendRaw(aacSeqHeader); sub.sendRaw(metadata); sub.markHeaderSent(); } sub.sendRaw(msg.getPayload()); } }isAacSequenceHeader判断音频 payload 第一个字节低 4 位是否为 0isAvcSequenceHeader判断视频 payload 第 1 字节低 4 位是否为 0 且第 2 字节为 0。补发 sequence header 这一步漏掉的话拉流端画面要么黑屏要么花屏是新手最常见的翻车点。3.2 FLV 标签封装时间戳和 TagType 怎么对齐FLV 文件结构是 FLV Header 加一连串 Tag。每个 Tag 包含 TagType8 音频、9 视频、18 脚本、DataSize、Timestamp3 字节低位加 1 字节扩展高位、StreamID 和 Data。RTMP 消息的 payload 基本可以直接作为 FLV Tag 的 Data但时间戳需要转换RTMP 的 timestamp 是 32 位FLV 的 timestamp 是 24 位低位加 8 位扩展实际也是 32 位直接按位拆就行。// 将 RTMP 消息封装为 FLV Tag 并写入 HTTP 响应 public ByteBuf toFlvTag(RtmpMessage msg, int timestamp) { ByteBuf tag Unpooled.buffer(); tag.writeByte(msg.getType()); // TagType: 8/9/18 tag.writeMedium(msg.getPayload().readableBytes()); // DataSize tag.writeMedium(timestamp 0xFFFFFF); // Timestamp 低 24 位 tag.writeByte((timestamp 24) 0xFF); // Timestamp 扩展高 8 位 tag.writeMedium(0); // StreamID 固定 0 tag.writeBytes(msg.getPayload()); // 写 PreviousTagSize tag.writeInt(11 msg.getPayload().readableBytes()); return tag; }时间戳单位是毫秒。如果推流端时间戳从 0 开始拉流端也从头播如果服务器做了缓存再分发要注意时间戳基准对齐否则 flv.js 可能因为时间戳跳变而卡顿。PreviousTagSize是 FLV 规范要求的值等于前一个 Tag 的总长度11 字节头加 Data漏写会导致部分播放器解析失败。3.3 HTTP-FLV 长连接怎么让浏览器持续收流拉流端通过 HTTP GET 请求/live/stream.flv服务器返回Content-Type: video/x-flv然后保持连接不关闭持续写入 FLV Tag。关键点是响应头不能带Content-Length要用Transfer-Encoding: chunked或者直接不设长度让连接保持打开。Netty 里用HttpResponse加HttpChunkedInput或者手动写HttpContent都行。// HTTP-FLV 订阅者先发 FLV Header再持续推 Tag public void sendFlvHeader(ChannelHandlerContext ctx) { ByteBuf header Unpooled.buffer(13); header.writeBytes(new byte[]{F, L, V}); // Signature header.writeByte(1); // Version header.writeByte(0x05); // Flags: 音视频都有 header.writeInt(9); // Header size header.writeInt(0); // PreviousTagSize0 ctx.writeAndFlush(new DefaultHttpContent(header)); } // 在 broadcast 中调用 public void sendRaw(ByteBuf payload) { if (ctx.channel().isActive()) { ctx.writeAndFlush(new DefaultHttpContent(payload.retain())); } }FLV Header 的 Flags 字节0x01 只有视频0x04 只有音频0x05 两者都有。写错的话播放器可能只播画面没声音或者反过来。retain()是因为同一个 payload 要发给多个订阅者引用计数要管理好否则第一个订阅者写完释放了后面的就拿到空缓冲区。3.4 订阅者管理与背压慢客户端不能拖垮服务器每个拉流连接是一个订阅者维护在CopyOnWriteArrayList或ConcurrentHashMap里。广播时遍历发送。问题在于如果某个客户端网络慢writeAndFlush会堆积在 ChannelOutboundBuffer 里内存持续上涨。Netty 提供了isWritable()判断当缓冲区超过高水位默认 64KB时返回 false。private void broadcast(RtmpMessage msg) { for (FlvSubscriber sub : subscribers) { if (!sub.getCtx().channel().isWritable()) { // 慢客户端丢弃非关键帧只保留 sequence header if (msg.getType() 9 isKeyFrame(msg)) { sub.getCtx().writeAndFlush(...); // 关键帧还是要发 } continue; } sub.sendRaw(msg.getPayload()); } }高水位和低水位可以通过WriteBufferWaterMark调整直播场景建议设成 32KB 到 128KB。丢弃策略要小心视频关键帧不能丢否则后续帧解码全花音频帧可以适当丢人耳对少量丢帧容忍度比画面高。4. 避坑与排查RTMP 推流成功但拉流黑屏的 5 个真实原因4.1 现象OBS 显示推流成功浏览器 flv.js 一直转圈原因通常是 HTTP-FLV 响应头不对。Content-Type必须是video/x-flv不能是application/octet-stream。另外响应不能带Content-Length否则浏览器会等整个响应体收完才交给播放器而直播流永远不会结束。解决检查 Netty 里HttpResponse的设置确保Transfer-Encoding: chunked或者不设长度。4.2 现象画面出来但全是绿屏或花屏原因几乎都是 sequence header 没补发。新订阅者接入时如果直接从当前帧开始发缺少 AVC/AAC sequence header解码器不知道 SPS/PPS 和音频采样率就会花屏或无声。解决在FlvSubscriber里维护headerSent标志首次发送前先补发缓存的avcSeqHeader、aacSeqHeader和metadata。4.3 现象延迟越积越大从 2 秒涨到十几秒原因通常是时间戳处理有问题。如果服务器转发时用了自己的系统时间而不是 RTMP 消息里的 timestamp或者时间戳基准没对齐播放器会按错误的时间轴缓冲。解决转发时严格使用 RTMP 消息的 timestamp不要自己生成。如果做了缓存缓存内的帧时间戳保持原样新订阅者从缓存头部开始播。4.4 现象推流几分钟后服务器 OOM原因一般是慢客户端导致 ChannelOutboundBuffer 无限增长。没有做isWritable判断或者判断了但没丢弃策略数据全堆在内存里。解决在 broadcast 里加isWritable检查不可写时丢弃非关键帧。同时设置WriteBufferWaterMark并在 pipeline 里加IdleStateHandler踢掉长时间无响应的连接。4.5 现象多个拉流端只有一个能播原因通常是 payload 的引用计数管理错误。ByteBuf是引用计数的广播时如果直接传同一个ByteBuf给多个 Channel第一个写完释放后后面的拿到的是已释放的缓冲区。解决广播时对每个订阅者调用payload.retainedDuplicate()或者retain()确保每个 Channel 持有独立引用。Netty 的SimpleChannelInboundHandler会自动释放消息如果用了它广播前必须retain。5. 进阶技巧用环形缓存做秒开和 GOP 缓存以及我踩过的那些坑秒开是直播体验的关键指标。用户点开播放器到看到第一帧的时间如果超过 3 秒流失率明显上升。实现秒开的核心是 GOP 缓存服务器在内存里维护一个环形缓冲区保存最近一个 GOPGroup of Pictures的所有帧从最近的关键帧开始。新订阅者接入时先把缓存里的帧按顺序推给它再切换到实时流。这样播放器拿到的是从关键帧开始的完整数据解码器立刻能出画面。// 环形 GOP 缓存保存最近一个关键帧之后的所有帧 public class GopCache { private final LinkedListRtmpMessage cache new LinkedList(); private static final int MAX_FRAMES 300; // 约 10 秒 30fps public synchronized void add(RtmpMessage msg) { if (msg.isVideo() msg.isKeyFrame()) { cache.clear(); // 遇到关键帧清空从新 GOP 开始 } cache.add(msg); while (cache.size() MAX_FRAMES) { cache.removeFirst(); } } public synchronized ListRtmpMessage getGop() { return new ArrayList(cache); } }MAX_FRAMES设 300 是经验值按 30fps 算覆盖 10 秒。设太小可能缓存不了一个完整 GOP设太大内存占用高。关键帧判断视频 payload 第 0 字节高 4 位为 1 表示关键帧。注意 sequence header 不是关键帧不要混进去。验证秒开效果的方法用 FFmpeg 拉流并记录首帧时间。ffmpeg -i http://localhost:8080/live/stream.flv -frames:v 1 -f image2 first_frame.jpg -y从发起请求到first_frame.jpg写盘的时间就是首帧延迟。优化前通常在 3 到 5 秒加了 GOP 缓存后能压到 1 秒以内。另一个验证手段是浏览器 flv.js 的statistics_info事件里面有decodedFrames和droppedFrames丢帧率高说明分发环节有问题。我踩过最深的坑是时间戳回绕。RTMP 的 timestamp 是 32 位无符号整数大约 49 天回绕一次。短时间测试不会遇到但长时间运行的服务必须处理。我的做法是在解码器里维护一个 64 位的扩展时间戳每次收到新 timestamp 时和上一个比较如果突然变小超过阈值比如 2^31就认为发生了回绕高位加一。这个逻辑不加服务跑一个月后所有拉流端都会卡死而且日志里看不出任何异常典型的玄学问题。另一个血泪经验是不要在生产环境用System.currentTimeMillis()做帧时间戳。系统时间可能因为 NTP 同步而跳变一跳变所有播放器全卡。帧时间戳应该完全基于 RTMP 消息里的相对时间服务器只做透传和基准对齐。这个习惯我从第一个版本就养成了后面省了无数排查时间。如果你准备把这套东西用到实际项目里我的建议是先跑通最小链路再逐步加 GOP 缓存、慢客户端丢弃、时间戳回绕处理。每一步都写一个可验证的测试用例不要凭感觉调参数。流媒体服务器的调试不像普通 Web 服务很多问题只在特定网络条件和特定播放器上出现没有测试用例兜底改一行代码可能引入三个新问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表