简介:面向 Java 后端开发者的流媒体播放集成资源,聚焦于将 RTSP、RTMP 等流地址经服务端转换后推送给前端,以 FLV 与 WebSocket 两种方式实现浏览器端播放,适合具备 Spring Boot 基础、需要快速落地视频监控或直播预览场景的中级开发者。压缩包共 10 个文件,以 8 个 Java 源文件和 2 个 HTML 页面为主:Java 部分承担服务启动、摄像头数据模型、客户端类型区分、FLV 处理与基于 Javacv 的流转换等职责,HTML 页面则提供 FLV 与 WebSocket 两种播放方式的演示入口,整体约 14KB,结构精简、开箱即用。已有 108 人学习。读者可从中获得一套可直接运行的流地址转发与前端播放骨架,理解服务端拉流、转封装与推送的衔接方式,并借助现成页面快速验证播放效果,为后续接入真实摄像头或直播源提供可复用的参考实现。
1. 从一次监控大屏联调说起:SpringBoot 把 RTSP/RTMP 转成前端能播的 FLV 和 WS
去年帮一个做园区安防的团队收拾过一个烂摊子:前端大屏要同时预览十几路摄像头,后端接口返回的全是rtsp://开头的地址,浏览器里<video>标签一挂上去就是一片黑。这不是前端偷懒,浏览器原生就不认 RTSP 和 RTMP 这两种流协议,它们只吃 HTTP-FLV、HLS、WebRTC 这几类。真正要干的活,是在 SpringBoot 服务里架一层流媒体网关,把摄像头的 RTSP/RTMP 拉过来,转封装成 HTTP-FLV 给<video>或 flv.js 播,再开一路 WebSocket-FLV 兜底那些对延迟敏感、又走不了长连接的场景。这套东西适合谁?手上有一堆 IPC、NVR、直播推流地址,又不想额外维护一套独立流媒体集群,希望用 Java 技术栈一把梭的团队。下面按我拆这个项目的顺序讲:它到底怎么转、目录怎么摆、核心代码长什么样、跑起来会踩哪些坑、最后怎么改成你自己的。
2. 架构与选型:为什么是 SpringBoot + 转封装而不是转码
2.1 先分清转封装和转码,这决定了服务器扛不扛得住
很多人一上来就说“我要把 RTSP 转成 FLV”,其实这里藏着一个成本差十倍的选择。摄像头出来的 RTSP 里,视频轨绝大多数是 H.264,音频轨是 AAC 或 G.711。如果只是把 H.264 裸流从 RTP 里剥出来,重新打成 FLV 的 Tag 结构,全程不解码不编码,这叫转封装,一个 4 核 8G 的机器扛几十路没问题。而一旦你要改分辨率、改码率、把 G.711 转 AAC,那就得走 FFmpeg 解码再编码,CPU 直接起飞,十几路就能把机器打满。
这个项目的定位就是转封装优先。核心链路是:拉流(RTSP/RTMP 客户端)→ 解协议(RTP/RTMP chunk)→ 取 H.264/AAC 裸流 → 按 FLV Tag 重新封装 → 通过 HTTP 或 WebSocket 推给前端。整条链路里 Java 负责调度、鉴权、连接管理,真正的协议解析和封装交给流媒体库。常见做法是引入javacv(对 FFmpeg 的 Java 封装)或者直接用 Netty 手写 RTP 解析,前者上手快、后者可控性强。我一般会先看项目里有没有ffmpeg-platform这个依赖,有它基本就是走 JavaCV 路线。
提示:转封装对输入流的编码格式有硬要求。摄像头如果配的是 H.265,FLV 封装在多数播放器上放不出来,得先确认设备侧能切回 H.264,这一步在联调前就要问清楚,别等代码写完才发现流根本封不进去。
2.2 三种输出协议怎么选:HTTP-FLV、WS-FLV、HLS 的取舍
前端能播的形态不止一种,项目里通常同时开两路,让前端按场景挑:
| 输出方式 | 延迟量级 | 传输通道 | 适用场景 | 主要代价 |
|---|---|---|---|---|
| HTTP-FLV | 1~3 秒 | HTTP 长连接 chunked | 大屏预览、PC 端 flv.js | 连接数受容器线程模型影响 |
| WebSocket-FLV | 1~3 秒 | WS 全双工 | 需要双向控制、穿透代理受限 | 需自己管心跳和重连 |
| HLS | 5~20 秒 | HTTP 分片 | 移动端、弱网、回看 | 切片有固有延迟 |
HTTP-FLV 的好处是前端一个flv.js就能吃,服务端就是一个不断写OutputStream的 Servlet 或 WebFlux 响应;WS-FLV 的好处是连接状态好管理,前端断线重连逻辑可以自己控,还能顺带传控制信令。HLS 延迟高,一般只用来做回看或者给不支持 FLV 的端兜底。这个项目标题里点名了 FLV 和 WS,说明主战场是前两种,HLS 属于可选项。
2.3 整体模块划分:拉流、缓存、分发三层
把链路拆开看,服务端大致分三层,这个划分直接对应后面的目录结构:
- 拉流层:每路流一个拉流任务,负责连摄像头、维持 RTSP/RTMP 会话、断线重连。一路流一个线程或一个事件循环任务,不能共用。
- 缓存层:拉流层解出来的 H.264/AAC 帧不能直接扔给前端,要先写进一个环形缓冲区(GOP Cache)。新观众进来时先把最近一个关键帧到当前的缓存吐出去,否则要等下一个 I 帧才能出画面,体验就是“点了半天才亮”。
- 分发层:一个拉流任务对应 N 个观众连接,缓存层有新数据就广播给所有订阅者。HTTP-FLV 和 WS-FLV 只是分发层的两个出口,共用同一份缓存。
这个结构的关键在于一路流只拉一次。我见过反面教材:每个前端连接都自己去拉一遍 RTSP,十个人看同一路摄像头就是十路拉流,摄像头直接拒绝连接。正确做法是拉流任务和观众连接解耦,用streamId做订阅关系。
2.4 目录结构长什么样
按上面的分层,一个能跑的工程目录大致是这样,实际项目里包名会换,但层次不会差太多:
src/main/java/com/example/stream/ ├── StreamApplication.java # 启动类 ├── config/ │ ├── WebSocketConfig.java # 注册 WS-FLV 端点 │ └── StreamProperties.java # 拉流超时、缓存大小等配置 ├── puller/ │ ├── StreamPuller.java # 单路拉流任务,RTSP/RTMP 客户端 │ ├── PullerManager.java # streamId -> Puller 的注册与复用 │ └── FrameCache.java # GOP 环形缓存 ├── distributor/ │ ├── FlvHttpController.java # HTTP-FLV 出口 │ ├── FlvWebSocketHandler.java # WS-FLV 出口 │ └── Subscriber.java # 单个观众连接的抽象 └── flv/ ├── FlvTagWriter.java # H.264/AAC -> FLV Tag 封装 └── FlvHeader.java # FLV 文件头与 Metadatapuller和distributor之间只通过FrameCache和订阅回调通信,不直接互相引用,这样后面加 HLS 出口或者换成 WebRTC,拉流层一行不用动。
3. 核心链路拆解:从 RTSP 拉流到 FLV 封装的代码实现
3.1 拉流任务:一路流一个 Puller,用 JavaCV 抓帧
拉流是整个链路的地基。用 JavaCV 的话,核心是FFmpegFrameGrabber,它把 RTSP/RTMP 的协议细节都封在 FFmpeg 里,Java 侧只负责取帧和重连。下面这段是拉流任务的主循环,重点看重连和帧类型判断:
public class StreamPuller implements Runnable { private final String streamId; private final String sourceUrl; // rtsp:// 或 rtmp:// 地址 private final FrameCache cache; private volatile boolean running = true; @Override public void run() { while (running) { FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(sourceUrl); try { // 关键参数:超时单位微秒,避免连不上时线程卡死 grabber.setOption("rtsp_transport", "tcp"); // 优先 TCP,UDP 丢包严重 grabber.setOption("stimeout", "5000000"); // 5 秒连接超时 grabber.setOption("fflags", "nobuffer"); // 降低首帧延迟 grabber.start(); Frame frame; while (running && (frame = grabber.grab()) != null) { if (frame.image != null) { // 视频帧:判断是否关键帧,写入缓存并广播 boolean keyFrame = (frame.keyFrame); cache.writeVideo(frame, keyFrame); } else if (frame.samples != null) { cache.writeAudio(frame); } } } catch (Exception e) { log.warn("拉流异常 streamId={}, 5 秒后重连", streamId, e); } finally { try { grabber.stop(); grabber.release(); } catch (Exception ignored) {} } sleepQuietly(5000); // 固定退避,避免疯狂重连打爆摄像头 } } }逐段说逻辑。rtsp_transport设成tcp是血泪经验:UDP 模式下网络一抖就花屏、丢帧,TCP 虽然延迟略高但稳定得多,安防场景基本都选 TCP。stimeout是连接超时,单位是微秒,不设的话摄像头掉线时start()可能挂很久。fflags nobuffer是拿首帧速度的常用开关,代价是可能丢一点缓冲。frame.keyFrame这个标志决定缓存层能不能正确切 GOP,判断错了新观众进来就是黑屏等 I 帧。重连用固定 5 秒退避,别用指数退避也别立刻重连,摄像头被频繁连接会直接限流。
3.2 GOP 缓存:新观众进来为什么能秒出画面
缓存层是体验的分水岭。FLV 播放器必须从关键帧开始解码,如果新连接进来时缓存里只有 P 帧,播放器会一直等下一个 I 帧,表现就是“转圈”。所以缓存要保留最近一个完整 GOP(从一个 I 帧到下一个 I 帧之前的所有帧),新订阅者先补发这个 GOP 再进入实时广播。
public class FrameCache { private final Deque<CachedFrame> gopBuffer = new ArrayDeque<>(); private final List<Subscriber> subscribers = new CopyOnWriteArrayList<>(); private final int maxGopFrames = 300; // 防止关键帧间隔过大撑爆内存 public synchronized void writeVideo(Frame frame, boolean keyFrame) { if (keyFrame) { gopBuffer.clear(); // 遇到新 I 帧,旧 GOP 作废 } CachedFrame cf = CachedFrame.of(frame, keyFrame); gopBuffer.addLast(cf); if (gopBuffer.size() > maxGopFrames) { gopBuffer.pollFirst(); // 超限丢最老的,保证内存可控 } broadcast(cf); // 实时观众立刻收到 } public synchronized void subscribe(Subscriber sub) { // 先补发当前 GOP,再挂进实时列表,顺序不能反 for (CachedFrame cf : gopBuffer) { sub.send(cf); } subscribers.add(sub); } }这里有两个容易翻车的点。第一,subscribe里补发 GOP 和加入订阅列表必须在同一把锁里,否则补发过程中新帧进来会漏或者乱序。第二,maxGopFrames必须设上限,有些摄像头关键帧间隔配到 10 秒,30fps 就是 300 帧,不设限内存会慢慢涨。CachedFrame里存的是编码后的裸数据(H.264 NALU 或 AAC 帧),不是解码后的Frame对象,后者内存占用大得多,缓存几十路会直接 OOM。
3.3 FLV 封装:把 H.264 裸流打成播放器认的 Tag
前端 flv.js 要的不是裸 H.264,而是标准 FLV 结构:9 字节文件头 + 若干 Tag(每个 Tag 有 11 字节头,类型分 audio/video/script)。封装的核心是把 NALU 前面加上 AVCDecoderConfigurationRecord(也就是常说的 AVCC),再把长度前缀从 Annex-B 的00 00 00 01换成 4 字节大端长度。下面是一个精简的 Tag 写入逻辑:
public class FlvTagWriter { // 写 FLV 文件头,前端拿到第一段数据必须先有这个 public static void writeHeader(OutputStream out) throws IOException { out.write(new byte[]{'F', 'L', 'V', 0x01, 0x05, 0, 0, 0, 9}); out.write(new byte[]{0, 0, 0, 0}); // PreviousTagSize0 } // 视频 Tag:keyFrame 决定 FrameType,CodecId 固定 7 表示 AVC public static void writeVideoTag(OutputStream out, byte[] nalu, boolean keyFrame, int timestamp) throws IOException { int dataSize = nalu.length + 5; // 1 字节帧类型 + 4 字节 composition time out.write(0x09); // TagType = video writeUInt24(out, dataSize); writeUInt24(out, timestamp & 0xFFFFFF); out.write((timestamp >> 24) & 0xFF); out.write(0); out.write(0); out.write(0); // StreamID 固定 0 out.write(keyFrame ? 0x17 : 0x27); // 0x17 = keyframe+AVC, 0x27 = inter+AVC out.write(0x01); // AVCPacketType = NALU out.write(new byte[]{0, 0, 0}); // CompositionTime = 0 out.write(nalu); writeUInt24(out, dataSize + 11); // PreviousTagSize } }参数逐个解释。0x17里的高 4 位是 FrameType(1 关键帧、2 普通帧),低 4 位是 CodecID(7 表示 AVC/H.264),所以关键帧是0x17、普通帧是0x27,写错播放器直接黑屏。AVCPacketType为 1 表示这是 NALU 数据,为 0 表示这是 AVCDecoderConfigurationRecord(SPS/PPS),后者必须在第一个视频 Tag 之前发一次,否则 flv.js 解不出分辨率。timestamp是毫秒,跨 24 位和 8 位两段写,超过 24 位的部分放最后一个字节。PreviousTagSize是给播放器做校验的,值等于 Tag 头 11 字节加数据长度,漏写有些播放器会卡住。
3.4 HTTP-FLV 出口:一个不断写响应的 Controller
HTTP-FLV 的本质是一个永不结束的 HTTP 响应,服务端持续往OutputStream写 Tag。用 Spring MVC 的话,返回StreamingResponseBody最省事:
@GetMapping(value = "/live/{streamId}.flv", produces = "video/x-flv") public StreamingResponseBody play(@PathVariable String streamId) { return outputStream -> { Subscriber sub = new Subscriber(outputStream); FlvTagWriter.writeHeader(outputStream); PullerManager.getOrCreate(streamId).getCache().subscribe(sub); try { sub.awaitClose(); // 阻塞直到客户端断开或超时 } finally { PullerManager.getOrCreate(streamId).getCache().unsubscribe(sub); } }; }produces = "video/x-flv"这个头必须对,flv.js 靠它判断流类型。subscribe里会先补发 GOP,所以观众进来立刻有画面。awaitClose一般用CountDownLatch或者检测写异常来实现,客户端关页面时写OutputStream会抛IOException,在finally里退订,避免订阅者列表越积越多。注意 Spring MVC 默认的异步超时(spring.mvc.async.request-timeout)要调大或者设成 -1,否则连接会被容器主动掐断。
3.5 WS-FLV 出口:WebSocket 里推同样的字节流
WS-FLV 和 HTTP-FLV 共用缓存和封装,区别只是传输通道。用 Spring 的TextWebSocketHandler或BinaryWebSocketHandler都行,推二进制帧:
public class FlvWebSocketHandler extends BinaryWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String streamId = extractStreamId(session); // 从 URI 里取 /ws/live/{streamId} Subscriber sub = new Subscriber(session); session.sendMessage(new BinaryMessage(FlvHeader.bytes())); // 先发 FLV 头 PullerManager.getOrCreate(streamId).getCache().subscribe(sub); sessions.put(session.getId(), sub); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Subscriber sub = sessions.remove(session.getId()); if (sub != null) sub.getCache().unsubscribe(sub); } }Subscriber在这里要适配两种输出:HTTP 的OutputStream和 WS 的WebSocketSession,抽一个send(CachedFrame)接口,内部判断走哪条路。WS 要注意发送是同步的,一路流观众多了会互相阻塞,常见做法是每个订阅者一个小的发送队列加独立线程,或者用ConcurrentWebSocketSessionDecorator包一层限流。afterConnectionClosed里必须退订,这是 WS 最容易漏的一步,漏了就是内存泄漏加无效广播。
4. 跑起来:环境、配置与联调步骤
4.1 依赖与运行环境
工程是标准 SpringBoot 项目,JDK 8 或 11 都行(JavaCV 对高版本 JDK 的兼容要自己验),构建用 Maven。核心依赖两块:Web 和 WebSocket 用spring-boot-starter-web+spring-boot-starter-websocket,流处理用 JavaCV:
<dependency> <groupId>org.bytedeco</groupId> <artifactId>javacv-platform</artifactId> <version>1.5.9</version> <!-- 版本按项目实际 pom 为准 --> </dependency>javacv-platform会把各平台的 FFmpeg 原生库都拉下来,包体很大(几百 MB),本地开发图省事可以先用它,上生产建议换成只含目标平台 classifier 的依赖,镜像能小一大截。启动就是常规的mvn spring-boot:run或打 jar 跑,端口默认 8080。
4.2 关键配置项
配置集中在一个application.yml里,几个参数直接决定稳定性:
stream: pull-timeout-ms: 5000 # 拉流连接超时 reconnect-delay-ms: 5000 # 断线重连间隔 gop-max-frames: 300 # GOP 缓存上限 rtsp-transport: tcp # tcp 稳定,udp 低延迟但易花屏 server: port: 8080 spring: mvc: async: request-timeout: -1 # HTTP-FLV 长连接不能被容器超时掐断request-timeout: -1是 HTTP-FLV 能不能长时间播的关键,不设的话默认 30 秒左右连接就断了,前端表现为播一会儿就卡住重连。gop-max-frames按摄像头关键帧间隔和帧率估,30fps、2 秒一个 I 帧就是 60 帧左右,留到 300 是给间隔大的设备兜底。
4.3 联调顺序:先验证拉流,再接前端
别一上来就开浏览器,按这个顺序排错最快:
- 先用
ffplay rtsp://...或 VLC 确认摄像头地址本身能播,排除地址、账号密码、网络问题。 - 起服务后访问
http://localhost:8080/live/{streamId}.flv,用curl -v看响应头是不是video/x-flv、有没有持续数据下来。 - 前端页面引 flv.js,
flvjs.createPlayer({ type: 'flv', url: '.../live/xxx.flv' }),打开控制台看有没有报错。 - WS 那路用浏览器控制台
new WebSocket('ws://localhost:8080/ws/live/xxx')手动连,看onmessage有没有二进制数据。
这个顺序的价值在于把问题分层:第 1 步挂了是设备问题,第 2 步挂了是服务端拉流或封装问题,第 3 步挂了是前端或跨域问题,第 4 步挂了是 WS 配置问题。跳过任何一步都会让你在错误的方向上查半天。
5. 避坑与排查:五条真实踩过的坑
5.1 现象:前端一直转圈,服务端日志显示拉流正常
原因通常是 GOP 缓存没生效或者关键帧判断错了。JavaCV 里frame.keyFrame在某些封装格式下不可靠,尤其是 RTMP 源,可能整段都是 false,导致缓存永远不清空、新观众补发的第一帧不是 I 帧。解决办法是别只信这个标志,自己解析 NALU 类型:H.264 的 NALU 头低 5 位等于 5 就是 IDR 帧,用这个判断更稳。改完在writeVideo里打日志确认每个 GOP 的第一帧确实是 IDR。
5.2 现象:播十几秒就断,前端自动重连后又正常
这是 HTTP 长连接被容器或中间层掐了。先查spring.mvc.async.request-timeout是不是 -1,再查有没有 Nginx 之类的反向代理,代理默认proxy_read_timeout60 秒,超时就把连接关了。解决是在代理层把这个超时调大,或者服务端定时写一个空的 FLV metadata Tag 保活。另外有些云负载均衡对空闲连接也有超时,这个要在部署环境里确认,代码层面解决不了。
5.3 现象:同一路流两个人看,第二个人进来摄像头就拒绝连接
典型的拉流任务没复用。检查PullerManager.getOrCreate是不是真的按streamId做了单例,常见错误是每次请求new StreamPuller()。正确做法是用ConcurrentHashMap.computeIfAbsent保证一路流只有一个 Puller,观众连接只往缓存里订阅。改完可以用日志数一下同一路流的grabber.start()调用次数,应该只有一次。
5.4 现象:内存缓慢上涨,跑一天后 OOM
三个嫌疑点按顺序查:一是订阅者没退订,HTTP 断开或 WS 关闭时unsubscribe没执行,subscribers列表只增不减;二是 GOP 缓存没设上限,关键帧间隔大的流把整个 GOP 都攒着;三是CachedFrame里存了解码后的Frame对象而不是编码数据。前两个看代码就能改,第三个要确认缓存的是byte[]。我一般会在缓存类里加一个subscribers.size()和gopBuffer.size()的定时打点,涨得不正常一眼就能看出来。
5.5 现象:本地能播,部署到服务器就黑屏
先怀疑原生库。javacv-platform在本地开发机(比如 Mac)能跑,部署到 Linux 服务器如果依赖裁剪过、缺了对应平台的 FFmpeg 原生库,grabber.start()会直接抛UnsatisfiedLinkError或者静默失败。解决是确认部署包的依赖里包含目标平台的 classifier,或者干脆用完整 platform 包先验证,跑通再裁剪。另一个常见原因是服务器访问不到摄像头网段,这个用curl或telnet测端口就能排除。
6. 二次开发:把单机 Demo 改成能上生产的多路网关
这套代码跑通单路之后,真正的工作量在“多路”和“可控”上。我一般按三个方向改。第一是拉流任务的生命周期管理:加一个空闲回收,某路流超过 N 分钟没有观众就停掉 Puller 释放连接,有人再看时再懒加载拉起,否则几十路摄像头常年挂着,摄像头连接数和服务器线程都吃不消。第二是鉴权和流地址脱敏:前端拿到的streamId不能直接暴露摄像头地址和密码,服务端维护streamId -> sourceUrl的映射,播放接口先校验 token 再决定放不放行,这一步在安防项目里是硬要求。第三是输出协议可插拔:把Subscriber抽象成接口,HTTP-FLV、WS-FLV、HLS 各实现一个,缓存层只认接口,后面加 WebRTC 出口时拉流层一行不用动。
验证改造是否成功,我习惯用两个指标:一是同一路流开 20 个前端连接,看服务端grabber.start()只被调用一次、CPU 占用平稳;二是手动拔掉摄像头网线,看 5 秒后日志出现重连、恢复后前端能自动出画面而不用刷新页面。这两个场景过了,基本就能交给前端联调了。
有个细节值得单独说:时间戳。多路流混在一起时,每路流的 PTS 起点不一样,FLV Tag 里的 timestamp 如果直接用frame.timestamp,某些源会从很大的值开始,播放器可能卡在开头。稳妥做法是每路流记录一个基准时间戳,写 Tag 时用当前帧时间戳 - 首帧时间戳,保证从 0 附近开始递增。这个坑我在两个项目里都踩过,表现都是“第一帧出来后就卡住”,查半天以为是封装错了,其实是时间戳跳变。
从那以后我每次接新的流媒体项目,都强制先跑一遍“单路拉流 → curl 看数据 → 前端播 → 拔网线重连”这条最小验证链,再谈多路和性能。很多看起来玄学的问题,其实在第一步就能暴露。希望帮到你。
本文还有配套的精品资源,点击获取