简介:面向Java后端开发者的流媒体推流演示工程,解决Spring Boot服务将RTSP/RTMP等视频流转换为FLV格式并推送给前端播放器的问题,同时支持WebSocket传输,实现浏览器低延迟播放。资源设计为开箱即用,下载后可直接运行体验,适合需要快速集成视频流功能的Java工程师参考。压缩包共10个文件,以8个Java源文件为主,涵盖服务启动、媒体转换、FLV处理器、客户端管理等核心模块;另有2个HTML页面,用于前端播放验证。整体仅14KB,轻量易读,便于在本地快速排查和调整源码。目前已有107人学习/下载。通过该资源可掌握基于JavaCV的流媒体转换思路、HTTP与WebSocket两种推送方式的协调方式,以及前后端联调的最小化实现。代码结构清晰,能帮助读者缩短从理论到落地的探索时间。 做过流媒体接入的朋友应该都有同感:最烦的不是前端怎么写播放器,而是后端怎么把一路 RTSP 或者 RTMP 流稳定、低延迟地送到浏览器里。浏览器不能直接播 RTSP,RTMP 也需要 Flash 或特定的播放插件,唯一省事的方式是把流转换成 FLV,再通过 HTTP 或 WebSocket 推给 flv.js 播放。结合 Spring Boot 来做这件事,是目前工程上比较主流、也相对好维护的一套方案。
我最初做这个项目的时候也踩了不少坑。第一版直接用 HTTP-FLV 转发,结果跨域和断线重连的问题折腾了几天;后来改成 WebSocket 推送,连接稳定性好了很多,前端启动播放明显变快。简单说,这套架构能解决“摄像头/直播源的 RTSP、RTMP 流在网页端直接播放”的问题,适合监控平台、在线直播、大屏展示这类项目。如果你是后端工程师,或者正在做一个需要网页看实时视频流的系统,这篇文章应该能帮你省下不少弯路。
1. 整体架构与方案选型
1.1 典型链路与核心角色
整个链路可以拆成四层:
- 源流层:摄像头 RTSP、直播平台 RTMP、或者本地媒体文件。
- 接入层:Spring Boot 服务的拉流模块,负责建立上游连接、保持会话、断线重连。
- 转发层:把拉到的音视频数据解复用、封装成 FLV,通过 WebSocket 推给前端。
- 展示层:浏览器里的播放器,最常用的就是 flv.js,配合 video 标签直接播放。
实际落地时,很多团队会在接入层和转发层之间再加一层消息队列(Kafka、RabbitMQ)或者本地流媒体服务(SRS、Nginx-RTMP),目的是削峰填谷、降低后端压力。但对中小项目来说,纯 Spring Boot + JavaCV 已经能处理几路到几十路的并发场景,没必要一开始就上重架构。
1.2 为什么选 JavaCV:不只是因为 Java
JavaCV 是老牌 Java 流媒体库,底层封装了 FFmpeg。选它的原因很直接:
- API 友好:不需要手写 JNI 调用 FFmpeg,用
FFmpegFrameGrabber就能拉 RTSP/RTMP。 - 帧操作能力强:可以直接拿到
Frame对象,做缩放、转码、加水印都很方便。 - 协议支持全面:RTSP、RTMP、HTTP-FLV、HLS 基本都是一套 API。
注意:JavaCV 的
FFmpegFrameGrabber默认会把音视频解码成原始像素和 PCM 数据,这一步非常耗费 CPU。如果你只是做转封装(不转码),应该优先用FFmpegFrameRecorder直接输出 FLV,不要碰Frame的像素数据。我早期做 4 路摄像头时没注意这点,直接把服务器的 CPU 打满了。
1.3 为什么不用 HTTP-FLV,而用 WebSocket
很多人会问:既然 Nginx 都能输出 HTTP-FLV,为什么还要用 WebSocket?主要原因有两个:
- 连接稳定性:HTTP 长连接在公网环境下容易被中间代理、网关断开,而 WebSocket 是一条全双工稳定通道,更适合流媒体实时推送。
- 控制与数据融合:WebSocket 可以在同一条连接里既传音视频数据,也传鉴权信息、控制指令(比如暂停、切换码率),这是纯 HTTP 很难做到的。
所以最终我选择了“后端 WebSocket 主动推送 FLV 分片,前端 flv.js 直接消费”的架构。
2. 核心细节解析与实操要点
2.1 为什么前端要 FLV,而不是裸 H.264
很多新手会问,能不能直接把 H.264 裸流通过 WebSocket 推给前端。答案是不能。裸 H.264 只有视频帧数据,没有时间戳、没有音频信息、也没有音视频同步机制,播放器根本不知道每一帧该在什么时刻显示。FLV 格式自带timestamp和音视频封装结构,播放器才能正常解析播放。
FLV 结构其实不复杂,由 header 和若干 tag 组成:
- FLV Header(9 字节):标识 FLV 版本、有无音频、有无视频。
- FLV Tag:每个 tag 有 11 字节头部,包含类型(音频/视频/脚本)、数据大小、时间戳、流 ID 等。
WebSocket 推送时,我们需要把 FLV 流拆成一个个 tag 顺序推送。flv.js 内部会有解复用器,收到这些 tag 后按时间戳排序播放,体验上和播放本地文件差不多。
2.2 JavaCV 拉流的关键参数
这部分是实操中踩坑最多的地方。下面这份配置可以直接参考:
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(rtspUrl); grabber.setOption("rtsp_transport", "tcp"); // 用 TCP 拉流,UDP 在跨网络时容易丢包花屏 grabber.setOption("stimeout", "5000000"); // 连接超时 5 秒,单位微秒 grabber.setOption("max_delay", "500000"); // 最大延迟 500ms grabber.setOption("buffer_size", "1024000"); // 缓冲大小 1MB grabber.start();这几个参数是血泪教训换来的:
rtsp_transport=tcp能大幅减少花屏和丢包,但延迟会比 UDP 略高,适合局域网摄像头。如果在内网监控场景,TCP 几乎是必选的。stimeout一定要设,否则摄像头掉线时,start()会一直阻塞,没有超时控制,整个拉流线程就卡住了。buffer_size设大一点可以减少网络抖动导致的卡顿,但也不能太大,否则延迟会明显增加。
2.3 WebSocket 推送消息的设计:文本还是二进制
这是很多新手会纠结的地方。WebSocket 既能传字符串也能传字节流。传 FLV 数据必须用字节流,因为音视频数据本质是二进制。但我们还需要在推流过程中传递控制信号,比如:
{"cmd":"start","streamId":"xxx"}{"cmd":"stop","streamId":"xxx"}{"cmd":"heartbeat","streamId":"xxx"}
所以建议做双通道:一个@ServerEndpoint专门传控制消息(文本),一个专门传二进制数据流。不过为了工程简化,也可以共用一个 endpoint,用首字节区分命令和数据。比如首字节为0x01表示 JSON 控制命令,0x02表示 FLV tag 数据。我实际用的是后者,省了一个端点的维护成本。
2.4 并发与连接管理:流复用是关键
推流服务最怕的问题是“一路流很多人看”时重复向上游拉流。比如一个摄像头同时被 10 个浏览器观看,如果每个浏览器进来都重新向摄像头拉流,摄像头会被拉爆,带宽也会浪费。所以我通常会做一个流复用缓存:
- 用
ConcurrentHashMap<String, StreamSession>缓存每一路流的会话。 - 第一个客户端请求时,后端才真正向上游拉流;后续客户端直接复用同一会话。
- 当最后一个客户端断开时,再释放上游连接。
这里要特别注意“最后一个客户端断开”的判定,因为 WebSocket 断开一般有延迟,需要做心跳超时清理,否则会话容易泄漏。
3. 实操过程与核心环节实现
3.1 准备环境与依赖
先创建一个标准的 Spring Boot 项目,版本用 2.7.x 或 3.x 都可以。JavaCV 建议用1.5.9之后的版本,对 FFmpeg 5.x 支持更好。Maven 依赖:
<dependency> <groupId>org.bytedeco</groupId> <artifactId>javacv-platform</artifactId> <version>1.5.9</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>注意,javacv-platform会带一堆平台相关的 native 包,如果只需要 Windows/Linux x86_64,可以换成javacv加上具体的ffmpeg-platform,减少打包体积。
3.2 实现拉流复用:全局 StreamSession 管理
先定义一个核心类StreamSession,它负责持有 grabber、recorder 以及当前连接的客户端集合。
public class StreamSession { private final String streamId; private final FFmpegFrameGrabber grabber; private final List<WebSocketSession> clients = new CopyOnWriteArrayList<>(); private volatile boolean running = true; public StreamSession(String streamId, String srcUrl) throws Exception { this.streamId = streamId; this.grabber = new FFmpegFrameGrabber(srcUrl); // 设置参数略,同 2.2 this.grabber.start(); new Thread(this::pushLoop, "stream-" + streamId).start(); } public void addClient(WebSocketSession session) { clients.add(session); } public void removeClient(WebSocketSession session) { clients.remove(session); } public boolean hasClients() { return !clients.isEmpty(); } public void stop() { this.running = false; try { grabber.stop(); grabber.release(); } catch (Exception e) { log.error("release grabber error", e); } } private void pushLoop() { // 核心推流逻辑,见 3.4 } }这个类的核心是pushLoop方法,不断从 grabber 读取帧,编码成 FLV tag,然后广播给所有客户端。
3.3 实现 WebSocket 端点
用 Spring 原生@ServerEndpoint是最简单的方案,但它默认不是单例,每个连接都会创建一个新实例,所以全局状态必须用静态变量或 Spring 容器管理。
@Component @ServerEndpoint("/live/{streamId}") public class LiveWebSocketEndpoint { private static final Map<String, StreamSession> SESSIONS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("streamId") String streamId) { StreamSession streamSession = SESSIONS.computeIfAbsent(streamId, id -> { try { // 实际业务中,这里应该从配置中心或数据库拉取流地址 return new StreamSession(id, "rtsp://user:pass@192.168.1.64:554/stream1"); } catch (Exception e) { throw new RuntimeException(e); } }); streamSession.addClient(session); } @OnClose public void onClose(Session session, @PathParam("streamId") String streamId) { StreamSession streamSession = SESSIONS.get(streamId); if (streamSession != null) { streamSession.removeClient(session); if (!streamSession.hasClients()) { SESSIONS.remove(streamId); streamSession.stop(); } } } }注意,computeIfAbsent里的拉流 URL 不能硬编码。生产环境一般会设计一张流管理表,前端通过 HTTP 接口拿streamId,后端再根据streamId去查真正的 RTSP/RTMP 地址。
3.4 核心推流循环:从 grabber 到 WebSocket
private void pushLoop() { try { // 使用自定义 OutputStream,每写入一帧就触发一次广播 FlvSplitOutputStream splitOutputStream = new FlvSplitOutputStream(this::broadcast); FFmpegFrameRecorder recorder = new FFmpegFrameRecorder( splitOutputStream, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels()); recorder.setFormat("flv"); recorder.setFrameRate(grabber.getFrameRate()); recorder.setVideoCodec(grabber.getVideoCodec()); recorder.setAudioCodec(grabber.getAudioCodec()); recorder.start(); Frame frame; while (running && (frame = grabber.grabFrame()) != null) { recorder.record(frame); } } catch (Exception e) { log.error("push loop error", e); } finally { // 释放资源 } }这段是核心逻辑。FFmpegFrameRecorder把每一帧数据写入FlvSplitOutputStream,该输出流在每次写入后自动切割数据并调用broadcast方法,把数据推给所有 WebSocket 客户端。直接使用ByteArrayOutputStream然后每次toByteArray()会导致内存无限增长,因为之前的旧数据一直留在流里,所以一定要做一个可重置的输出流,或者在每次写完一帧后立刻清空缓冲。
3.5 前端播放:flv.js + WebSocket
前端部分也很简单,flv.js 官方支持type: 'flv'+isLive: true+url: 'ws://yourdomain/live/abc'的方式播放 WebSocket 流。
<script src="https://cdn.jsdelivr.net/npm/flv.js/dist/flv.min.js"></script> <video id="video" controls autoplay muted></video> <script> const player = flvjs.createPlayer({ type: 'flv', isLive: true, url: 'ws://localhost:8080/live/abc' }); player.attachMediaElement(document.getElementById('video')); player.load(); player.play(); </script>这里有个细节:flv.js 的wsloader 希望 WebSocket 里传输的是完整的 FLV 数据流,包括 FLV header 和所有 tag。所以后端在推送时,第一帧必须先推送 FLV header(9 字节 + 4 字节 PreviousTagSize0),然后再按顺序推 tag。如果漏掉 header,前端会直接报Invalid FLV file之类的错误。
3.6 进阶:鉴权与动态拉流
实际部署时,你不能把 RTSP 地址硬编码在代码里。合理的设计是:
- 前端先通过 HTTP 接口请求播放凭证,比如
streamId+ token。 - 后端校验权限后,返回带签名的播放地址。
- WebSocket 连接时,在 query 参数里携带 token。
- 后端在
@OnOpen里校验 token,不合法直接关闭连接。
这种方式既保证安全,又不会把真实 RTSP/RTMP 地址暴露给前端,还能在推流层做负载均衡和流量控制。我目前的项目就是这么做的,排查问题时也更方便——出问题先查鉴权接口,再查拉流服务,链路清晰。
4. 常见问题与排查技巧实录
4.1 摄像头 RTSP 一直拉不起来
这个问题大概率是参数没设对。先检查 URL 格式是否正确,比如海康威视取流地址一般是:
rtsp://username:password@ip:554/Streaming/Channels/101其中101表示主码流,102表示子码流。如果密码里含有特殊字符(@、:、/),必须做 URL 编码,否则解析失败。另一个常见问题是rtsp_transport没设成 TCP,导致本地能拉、跨网段拉不动。
排查顺序建议是:先用 VLC 或者 ffprobe 测试源地址是否可用,再用 JavaCV 的日志确认 FFmpeg 拉流时的具体报错。不要每次都靠猜。
4.2 前端画面卡顿、延迟大
这个问题要分场景判断:
- 如果延迟持续增长,说明音视频时间戳有问题,FLV 的时间戳必须从 0 开始单调递增,不能跳变。
- 如果画面偶尔花屏,优先检查推流端是否丢帧,以及 WebSocket 是否有背压(发送缓冲区满)。
- 如果是局域网测试没问题,公网卡顿,考虑加一层本地流代理服务(如 SRS),而不是让浏览器直接连摄像头。用代理的好处是,公网客户端只跟 SRS 建立连接,SRS 再跟摄像头保持长连接,缓冲策略可控得多。
4.3 WebSocket 连接经常断
这是生产环境最常见的坑。Spring Boot 内置的 WebSocket 容器(Tomcat)默认超时时间较短,如果没有心跳机制,空闲连接会被服务端或中间代理断开。解决办法:
- 前端每 10 秒发送一个心跳消息。
- 后端在
@OnMessage里收到心跳后回复 pong。 - Java 端可以设置
session.setMaxIdleTimeout(60000)。
另外,Nginx 反代 WebSocket 时必须配置:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s;否则默认 60 秒后 Nginx 会主动断开连接。我遇到过好多次前端播放 1 分钟就黑屏的问题,最后都是查到这个配置上。
4.4 延迟与画质的权衡
流媒体项目永远绕不开“延迟 vs 画质”的权衡。我目前使用的参数组合:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 编码器 | H.264 | 浏览器兼容性最好 |
| 分辨率 | 1280x720 | 监控场景够用 |
| 码率 | 2Mbps | 局域网无压力,公网环境建议降到 1M |
| GOP 大小 | 2 秒 | 关键帧间隔太大,启动播放会很慢 |
| 音频编码 | AAC | FLV 支持度好 |
如果你追求极低延迟,可以尝试关闭 B 帧、开启zerolatency这类编码选项。但要注意,低延迟往往意味着更高的带宽消耗和更差的画质,需要结合实际场景做取舍。
4.5 内存泄漏与资源释放
推流服务长时间运行,最怕资源不释放。有几次现场排查发现FFmpegFrameGrabber没关闭,导致 socket 连接数暴涨。推荐的释放顺序:
- 置
running = false,停止推流循环线程。 - 关闭 recorder 和 grabber。
- 清空客户端列表。
- 从全局 Map 中移除该会话。
别忘了在@OnClose和异常分支都调用stopSession(),否则很容易泄漏。我还习惯加一个定时任务,定期扫描长时间没有客户端连接的会话,主动释放,防止极端情况下漏删。
4.6 延迟排查:先看时间戳,再看缓冲
我们在调试延迟问题时,最容易忽略的是时间戳。FLV 封装对时间戳极为敏感,如果时间戳不是从 0 开始递进,播放器会认为数据乱序,从而启动 JitterBuffer 重排,延迟瞬间飙升。如果遇到延迟突然变成几十秒的诡异问题,建议先把 FLV tag 的时间戳打印出来,看看有没有异常跳变,而不是盲目去调 WebSocket 缓冲。
最后的实践体会
这套架构我前前后后改过三轮。第一轮直接用 HTTP-FLV 转发,遇到跨域和断线重连问题;第二轮改成 WebSocket 后,连接稳定性和前端启动速度有明显提升;第三轮加入流复用和 token 鉴权,才算真正达到生产可用标准。
对于想快速上手的读者,我建议不要一上来就啃 JavaCV 源码,先按上面这套链路把“拉流 -> 转 FLV -> WebSocket 推送 -> flv.js 播放”跑通,再逐步优化细节。遇到问题优先怀疑网络参数,其次怀疑封装格式,最后才怀疑框架本身。流媒体这块坑不少,但把链路拆开定位,每一步都验证,其实没那么神秘。
如果你打算长期维护这样一个系统,我个人建议把设备接入层抽成独立服务,不要直接和 Web 推流混在一起。这样摄像头厂商 SDK 升级、新增接入协议的时候,影响的只是接入层,播放端完全无感。另外,日志一定要把上游地址、流 ID、连接断开原因这些关键信息打全,不然线上出问题的时候,排查成本会非常高。
本文还有配套的精品资源,点击获取