SpringBoot 实现 RTSP/RTMP 转 FLV 与 WebSocket 推流实战
简介这份资源面向 Java 后端与流媒体开发方向的初中级开发者聚焦于将 RTSP、RTMP 等视频流地址推送给前端播放这一常见需求提供一套可直接运行的 Spring Boot 示例工程帮助读者快速理解服务端拉流、转封装与前端播放的完整链路。压缩包共 10 个文件以 8 个 Java 源码为主涵盖服务启动、媒体服务、流转换与客户端类型等核心逻辑另有 2 个 HTML 页面分别演示 FLV 与 WebSocket 两种播放方式整体仅 14KB结构精简、开箱即用。目前已有 107 人学习。读者可借此掌握基于 javacv 的流媒体转封装思路、FLV 与 WS 双通道推送的实现方式以及前后端联调时的关键配置适合作为流媒体播放模块的起步模板或二次开发基础。1. 从摄像头到浏览器SpringBoot 推 RTSP/RTMP 流给前端播放 FLV 和 WS 到底在解决什么很多做安防、直播或者物联网可视化的团队都会撞上同一个需求设备端给出来的是 RTSP 或 RTMP 地址但前端浏览器原生只认 FLV、HLS 和 WebSocket 这几种形态中间必须有人做协议转换和转发。这个“中间人”用 SpringBoot 来当是相当常见的选择——它本身是个 HTTP 服务能管连接、能鉴权、能横向扩展还能顺手把流地址的元数据存进数据库。核心链路其实就一句话后端拉取 RTSP/RTMP 源流转封装成 FLV 或通过 WebSocket 推二进制帧前端用 flv.js 或原生 WebSocket 播放。适合谁适合手里已经有 SpringBoot 服务、不想额外引入重型流媒体服务器、又需要快速把监控画面或直播画面塞进网页的开发者。但这里有个反直觉的结论SpringBoot 本身不擅长做流媒体转发它更适合当“信令和调度层”真正的流处理得靠外挂进程或嵌入式库否则线程模型和内存管理会让你在并发上来后翻车。2. 协议选型与 SpringBoot 的边界为什么不能纯靠 Java 硬扛2.1 RTSP、RTMP、FLV、WebSocket 四者的关系与转换成本先把这几个词的关系理清楚不然后面选型全是玄学。RTSP 是设备端常用的控制协议基于 TCP/UDP带信令交互拉流时通常伴随 RTP 包传输音视频数据。RTMP 是 Adobe 系的老牌推流协议基于 TCP延迟低但浏览器已经彻底抛弃了 Flash所以前端不能直接播 RTMP。FLV 是文件封装格式HTTP-FLV 就是把 FLV 标签流通过 HTTP 长连接持续吐给前端flv.js 能解这个。WebSocket 则是浏览器原生支持的二进制通道你可以把 FLV 标签、或者更原始的 H.264 NALU 直接塞进去前端用 MSE 或 WebCodecs 喂给 video 标签。转换成本上RTSP 转 FLV 需要拆 RTP 包、重组 NALU、再按 FLV Tag 封装这一步用 Java 纯手写非常痛苦因为要处理粘包、乱序、时间戳对齐。RTMP 转 FLV 反而简单因为 FLV 本身就是 RTMP 的消息格式落地版很多库能直接做。WebSocket 方案则更灵活你可以只推 H.264 裸流让前端用 WebCodecs 解码省掉封装开销但兼容性会差一些。常见做法是如果设备是 RTSP用外挂的 FFmpeg 进程拉流转 FLVSpringBoot 只负责管理进程和转发 HTTP 流如果设备是 RTMP可以用 Java 库直接拉流并转 WebSocket 推给前端。我一般会优先选 FFmpeg 外挂方案因为稳定性和协议兼容性经过验证Java 侧只做进程守护和流分发边界清晰。2.2 SpringBoot 在流媒体链路中的三种角色定位第一种角色是“纯信令服务器”SpringBoot 只提供接口前端调接口拿到流地址然后自己去连 FFmpeg 转出来的 HTTP-FLV 地址。这种架构最简单SpringBoot 压力最小但前端需要能直接访问到流媒体端口跨域和鉴权要单独处理。第二种角色是“代理转发器”SpringBoot 用 WebFlux 或 Servlet 异步 IO 把 FFmpeg 输出的 FLV 流再转发一层前端只连 SpringBoot 的端口。好处是鉴权、限流、日志都能在 Java 层统一做坏处是 SpringBoot 要扛住长连接线程模型没选对就会 OOM。我一般会选 WebFlux 的 Flux 来做背压能自动处理比 Servlet 的阻塞写要稳。第三种角色是“WebSocket 推流器”SpringBoot 建立 WebSocket 连接把从 RTMP 或 RTSP 转出来的 H.264 帧直接以二进制消息推给前端。这种延迟最低但前端要自己处理解码和渲染适合对延迟敏感的场景比如云游戏或远程操控。选哪种取决于你的并发量和团队前端能力。并发低于 50 路、前端只会用 flv.js 的选第二种并发高、前端愿意上 WebCodecs 的选第三种。2.3 最小可跑通的依赖与配置清单不管选哪种角色SpringBoot 侧的基础依赖都差不多。下面是一个最小化的 pom 片段只列关键项!-- WebFlux 用于异步流转发替代阻塞式 Servlet -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency !-- WebSocket 支持用于推二进制帧 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency !-- 如果要用 Java 拉 RTMP 流常用 flazr 或自己封装 -- dependency groupIdorg.flazr/groupId artifactIdflazr/artifactId version0.7/version /dependency配置上WebFlux 需要调大内存缓冲因为 FLV 流是持续不断的spring: codec: max-in-memory-size: 10MB # 单次缓冲上限太小会频繁 flush server: port: 8080 netty: connection-timeout: 60000 # 长连接超时设长一点参数说明max-in-memory-size 控制的是每次读取的 DataBuffer 上限不是总内存设 10MB 是为了避免大 GOP 时频繁切分。connection-timeout 要大于流的最长静默间隔否则会被 Netty 掐断。3. 用 FFmpeg 外挂进程拉 RTSP 转 HTTP-FLV 的完整落地步骤3.1 FFmpeg 命令参数逐项拆解与低延迟调优FFmpeg 是这条链路里最稳的一环但参数没给对延迟能飙到十几秒。下面是我在用的命令模板ffmpeg -rtsp_transport tcp -i rtsp://user:passip:554/stream \ -c:v copy -c:a aac -f flv -flvflags no_duration_filesize \ -tune zerolatency -preset ultrafast \ -f flv http://127.0.0.1:8080/live/stream.flv逐项说明-rtsp_transport tcp 强制走 TCP避免 UDP 丢包导致花屏代价是延迟略高但稳定。 -c:v copy 表示视频不转码直接复制这是低延迟的关键转码会引入几百毫秒到几秒的延迟。 -c:a aac 音频通常需要转码因为 RTSP 里常见 G.711 或 AACFLV 只认 AAC。 -flvflags no_duration_filesize 让 FLV 头不写总时长和文件大小因为直播流没有终点写了反而会让前端等。 -tune zerolatency 和 -preset ultrafast 是编码器参数在 copy 模式下其实不生效但如果你必须转码这两个能压延迟。注意如果设备输出的是 H.265-c:v copy 到 FLV 会失败因为 FLV 标准不支持 H.265。这时候要么转码成 H.264要么改用 WebSocket 推 H.265 裸流让前端 WebCodecs 解。转码命令加 -c:v libx264 -tune zerolatency 即可但 CPU 占用会上去。3.2 SpringBoot 管理 FFmpeg 进程的生命周期与异常重启SpringBoot 里不能直接 Runtime.exec 就完事进程挂了没人管流就断了。我一般会封装一个 StreamProcessManager用 ConcurrentHashMap 存进程句柄定时检查存活状态。Component public class StreamProcessManager { private final MapString, Process processMap new ConcurrentHashMap(); // 启动拉流进程streamId 作为唯一键 public void start(String streamId, String sourceUrl, String pushUrl) throws IOException { if (processMap.containsKey(streamId)) { stop(streamId); // 先停旧的避免端口冲突 } ListString cmd Arrays.asList( ffmpeg, -rtsp_transport, tcp, -i, sourceUrl, -c:v, copy, -c:a, aac, -f, flv, -flvflags, no_duration_filesize, pushUrl ); ProcessBuilder pb new ProcessBuilder(cmd); pb.redirectErrorStream(true); // 错误流合并方便日志采集 Process process pb.start(); processMap.put(streamId, process); // 异步读取输出防止缓冲区满导致进程阻塞 new Thread(() - { try (BufferedReader reader new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line reader.readLine()) ! null) { // 只记录关键错误避免日志爆炸 if (line.contains(error) || line.contains(failed)) { log.warn(ffmpeg[{}]: {}, streamId, line); } } } catch (IOException ignored) {} }).start(); } // 定时任务调用检查进程是否还活着 Scheduled(fixedDelay 10000) public void healthCheck() { processMap.forEach((id, process) - { if (!process.isAlive()) { log.error(stream {} died, restarting..., id); // 这里需要重新获取源地址和推流地址实际项目里从数据库或缓存读 // start(id, sourceUrl, pushUrl); } }); } public void stop(String streamId) { Process process processMap.remove(streamId); if (process ! null) { process.destroyForcibly(); } } }逻辑说明redirectErrorStream 把 stderr 合并到 stdout否则 FFmpeg 的错误信息会填满 stderr 缓冲区导致进程卡死。异步读线程必须存在否则同样会阻塞。healthCheck 里重启逻辑需要你从配置源重新拿地址不能直接复用旧变量因为地址可能已经变了。参数上fixedDelay 设 10 秒是折中太频繁浪费 CPU太慢会导致断流时间过长。destroyForcibly 比 destroy 更干脆FFmpeg 有时不响应正常终止信号。3.3 前端 flv.js 播放器接入与跨域配置后端把 FLV 流推到 http://127.0.0.1:8080/live/stream.flv 后前端用 flv.js 播放// 创建 flv.js 播放器实例 const player flvjs.createPlayer({ type: flv, url: http://your-server:8080/live/stream.flv, isLive: true, // 直播模式不缓冲整个文件 hasAudio: true, stashInitialSize: 128 // 初始缓冲 128KB太小会卡顿 }, { enableWorker: true, // 启用 Web Worker 解码避免阻塞 UI enableStashBuffer: false, // 直播场景关掉 stash降低延迟 autoCleanupSourceBuffer: true }); player.attachMediaElement(document.getElementById(video)); player.load(); player.play();参数说明isLive 必须为 true否则 flv.js 会等文件结束才播。enableStashBuffer 设 false 能降延迟但网络抖动时容易卡。stashInitialSize 根据网络质量调内网可以设 64公网建议 256 以上。跨域方面SpringBoot 的 WebFlux 需要加 CORS 配置否则前端域名不同会被浏览器拦Configuration public class CorsConfig { Bean public CorsWebFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); // 生产环境换成具体域名 config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsWebFilter(source); } }注意addAllowedOriginPattern 用 * 时不能同时 setAllowCredentials(true)浏览器会拒绝。生产环境必须写具体域名否则有安全风险。4. WebSocket 推流方案从 RTMP 拉流到二进制帧分发4.1 WebSocket 帧格式设计FLV Tag 还是裸 H.264WebSocket 推流有两种帧格式可选。第一种是推 FLV Tag前端收到后自己拼成 FLV 流喂给 flv.js好处是前端改动小坏处是每个 Tag 有 11 字节头带宽利用率略低。第二种是推裸 H.264 NALU前端用 WebCodecs 的 VideoDecoder 直接解延迟最低但前端要处理 SPS/PPS 和时间戳。我一般选第一种因为兼容性好flv.js 能直接吃。帧结构设计上每个 WebSocket 二进制消息就是一个完整的 FLV Tag前端按顺序 append 到 MSE 的 SourceBuffer 即可。关键是要在连接建立时先发 FLV Header 和 Metadata Tag否则 flv.js 不认。// 构造 FLV Header9 字节固定格式 public byte[] buildFlvHeader() { return new byte[]{ F, L, V, // 签名 0x01, // 版本 1 0x01, // 标志位0x01 表示有视频 0x00, 0x00, 0x00, 0x09 // 头长度 9 }; }逻辑说明FLV Header 之后紧跟一个 PreviousTagSize04 字节 0然后是 Metadata TagonMetaData再之后才是音视频 Tag。前端 flv.js 收到 Header 后才会初始化 MSE。4.2 SpringBoot WebSocket 处理器与背压处理WebSocket 处理器要继承 TextWebSocketHandler 或 BinaryWebSocketHandler这里用 BinaryComponent public class StreamWebSocketHandler extends BinaryWebSocketHandler { private final MapString, WebSocketSession sessions new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { sessions.put(session.getId(), session); // 发送 FLV Header 和 Metadata初始化前端解码器 session.sendMessage(new BinaryMessage(buildFlvHeader())); } // 由拉流线程调用把 FLV Tag 推给所有订阅了该流的 session public void broadcast(String streamId, byte[] flvTag) { sessions.values().forEach(session - { if (session.isOpen()) { try { // 用 sendMessage 异步发送避免阻塞拉流线程 session.sendMessage(new BinaryMessage(flvTag)); } catch (IOException e) { log.warn(send failed, closing session {}, session.getId()); } } }); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session.getId()); } }背压问题WebSocketSession.sendMessage 在 Spring 里是同步的如果客户端网络慢会阻塞调用线程。解决办法是用 ConcurrentWebSocketSessionDecorator 包装 session设置发送缓冲上限和超时WebSocketSession decorated new ConcurrentWebSocketSessionDecorator( session, 5000, 512 * 1024); // 5 秒超时512KB 缓冲上限超过缓冲上限时decorator 会直接关闭 session防止内存无限增长。这是血泪经验不加这个客户端一卡服务端内存就飙升。4.3 前端 WebSocket 接收与 MSE 拼接播放前端接收二进制帧并喂给 MSEconst ws new WebSocket(ws://your-server:8080/ws/stream/123); ws.binaryType arraybuffer; // 必须设否则收到 Blob 不好处理 const mediaSource new MediaSource(); document.getElementById(video).src URL.createObjectURL(mediaSource); mediaSource.addEventListener(sourceopen, () { const sourceBuffer mediaSource.addSourceBuffer(video/x-flv; codecsavc1.42E01E); ws.onmessage (event) { const data new Uint8Array(event.data); // 简单队列避免 SourceBuffer 忙时丢帧 if (!sourceBuffer.updating) { sourceBuffer.appendBuffer(data); } else { // 实际项目里要用队列缓存这里简化 setTimeout(() sourceBuffer.appendBuffer(data), 10); } }; });参数说明MSE 的 MIME 类型必须和实际编码匹配avc1.42E01E 是 Baseline Profile如果流是 High Profile 要改成 avc1.64001F。appendBuffer 在 updating 时调用会抛异常必须等 updateend 事件或做队列。5. 避坑与排查流媒体转发里最容易翻车的五个点5.1 现象前端播放几秒后卡死后端日志无异常原因FFmpeg 的 stdout 缓冲区满了进程阻塞不再推流。很多人只读 stderr 不读 stdout或者根本没起读线程。解决在 ProcessBuilder 里 redirectErrorStream(true)然后必须起一个线程持续 readLine哪怕你不打日志也要读否则缓冲区 64KB 一满就卡。5.2 现象WebSocket 连接数一多服务端内存暴涨然后 OOM原因每个 session 的发送缓冲没有上限客户端消费慢时消息堆积。解决用 ConcurrentWebSocketSessionDecorator 包装设 bufferSizeLimit 和 sendTimeLimit。超过就关连接让客户端重连比 OOM 强。5.3 现象FLV 流在 Chrome 能播Safari 黑屏原因Safari 的 MSE 对 FLV 支持不完整或者音频编码不是 AAC。解决优先用 HLS 给 Safari或者把音频统一转 AAC。如果必须用 FLV检查 flv.js 版本老版本对 Safari 兼容差。5.4 现象RTSP 拉流频繁断FFmpeg 报 “Connection reset by peer”原因设备端有连接数限制或者 TCP 超时设置太短。解决加 -rtsp_transport tcp 和 -stimeout 5000000微秒让 FFmpeg 等久一点。同时检查设备最大连接数别多个进程拉同一路流。5.5 现象延迟越播越大从 1 秒涨到 10 秒原因前端 MSE 的 SourceBuffer 没有清理旧数据或者 flv.js 的 stash 缓冲越积越多。解决flv.js 设 enableStashBuffer: false并且定期调用 sourceBuffer.remove() 清理播放点之前的数据。后端侧检查 FFmpeg 有没有加 -flvflags no_duration_filesize没加会导致前端等文件结束。6. 进阶技巧用 WebFlux 做零拷贝转发与动态码率切换6.1 WebFlux 的 Flux 流式转发替代进程外挂如果你不想外挂 FFmpeg可以用 Java 直接拉 RTMP 流并转 FLV然后用 WebFlux 的 Flux 吐给前端。核心是用 Netty 的 ByteBuf 做零拷贝避免 byte[] 来回复制。GetMapping(value /live/{streamId}, produces video/x-flv) public FluxDataBuffer live(PathVariable String streamId) { // 从自定义的 RtmpClient 获取 FLV Tag 的 Flux Fluxbyte[] tagFlux rtmpClient.getFlvTagFlux(streamId); return tagFlux.map(tag - { // 用 Netty 的 Unpooled 包装避免额外拷贝 return new NettyDataBufferFactory(ByteBufAllocator.DEFAULT) .wrap(Unpooled.wrappedBuffer(tag)); }); }逻辑说明NettyDataBufferFactory 包装后的 DataBuffer 直接持有 ByteBuf写回客户端时零拷贝。但要注意 ByteBuf 的引用计数wrap 之后由 DataBuffer 管理释放不要手动 release。参数上需要设置 spring.codec.max-in-memory-size 足够大否则大 Tag 会被切分。另外要加 onBackpressureDrop 或 buffer防止生产太快消费太慢。6.2 动态切换码率根据网络状况推不同质量的流前端可以通过 WebSocket 发信令告诉后端切换码率后端收到后重启 FFmpeg 进程换一个转码参数。比如MessageMapping(/switch) public void switchBitrate(String streamId, int level) { // level 0 原画 copy1 标清 720p2 流畅 480p String pushUrl buildPushUrl(streamId, level); streamProcessManager.stop(streamId); streamProcessManager.start(streamId, sourceUrl, pushUrl); }注意切换会导致几秒黑屏因为进程重启需要时间。更好的做法是用 FFmpeg 的 filter_complex 同时输出多路前端切 URL 而不是切进程。但多路会吃更多 CPU根据机器配置权衡。6.3 验证清单上线前必须压测的四个指标第一单路流延迟用秒表对着摄像头看画面延迟内网应低于 1.5 秒公网低于 3 秒。第二并发路数用脚本模拟 50 个 WebSocket 连接同时拉流看内存和 CPU 曲线内存增长应线性且可回收。第三断流恢复时间手动 kill FFmpeg 进程看 healthCheck 多久能拉起来应低于 15 秒。第四前端兼容性Chrome、Edge、Safari 各测一遍Safari 重点看 MSE 是否报错。我自己的习惯是每次改完 FFmpeg 参数或 WebSocket 缓冲配置都先跑一遍这四个指标不跑不上线。流媒体这东西玄学问题太多只有压测数据能给你后悔药。希望帮到你。本文还有配套的精品资源点击获取