简介:这套以SSM架构整合Nginx与FFmpeg的流媒体方案,专门面向需要将RTSP实时流转为HLS并在网页上播放的开发者,尤其适合刚接触视频接入的小白和希望快速预览实时画面的朋友。压缩包共52个文件,约70.26MB,涵盖png界面图、css样式、js脚本、html播放页、java后端代码、jsp页面、nginx配置及txt操作说明等,目录层次清晰,可直接对照部署。目前已有262人学习使用。资源内置FFmpeg与Nginx安装包、完整的SSM工程结构、playerJQueryDemo播放插件及转流命令行参考,并附有调试源和部署注意事项;读者按文档一步步配置,即可在本地完成从RTSP拉流、HLS切片到前端展示的全链路搭建,减少环境配置和兼容性方面的反复试错。
1. 一段工程录像引出的问题:为什么非要SSM、Nginx、FFmpeg三件套
你大概率接过这种需求:监控摄像头、教学录播机、车间产线上的设备,拉流地址清一色是rtsp://开头的,而浏览器里直接输入这个地址,除了报错什么都弹不出来。想要在HTML页面上嵌入实时画面,最简单可靠的做法就是把它转成HLS流,再用Nginx把切片文件以HTTP形式吐给前端。这个场景里,SSM负责管理设备和任务,FFmpeg负责把rtsp拉下来切分,Nginx负责把m3u8和ts文件送出去,前端拿着地址就能在网页里播。整条链路每一步都有坑:切片的参数设置不对,播放器只播两秒就卡住;Nginx少配一个响应头,Chrome直接白屏;FFmpeg进程挂掉没人管,大屏上画面就永远停在那。这篇笔记把这些坑一个个拆开讲透,新手照做能通,熟手看参数边界也有收获。
2. FFmpeg转流:先搞清协议和参数,再动手拉流
2.1 三种浏览器可播方案对比,为什么选HLS
RTSP本身是实时流协议,默认走RTP/UDP传输媒体数据,浏览器没有原生的RTSP解复用能力,所以必须转成HTTP可访问的格式。常见的替代方案有RTMP、HLS、WebRTC三种。RTMP需要Flash播放器才能直接播,Flash退出浏览器后基本没人用了,虽然现在有flv.js这类MSE方案接RTMP,但架构上多了一跳FLV封装,实时性也没占便宜。WebRTC延迟最低,能做到几百毫秒,但服务端要维护信令和媒体协商,网关实现复杂度高,小型项目中投入产出比不划算。HLS虽然是苹果定义的标准,但今天Chrome、Edge都能靠MSE解m3u8,Safari直接原生播放,兼容性覆盖最好,而且它本质是HTTP静态文件分发,Nginx不需要任何插件就能撑住,后续做回放、按帧截图也方便。所以我一般会选HLS作为落地格式。
HLS的延迟控制,核心在切片策略上。常见误解是HLS天生延迟五秒以上,其实播放延迟约等于「当前切片剩余时长 + 播放器缓冲段数 × 切片时长」。切片2秒、播放器只缓冲1段时,延迟可以压到3~5秒,这对监控预览、教学直播已经够用。RTSP源在转HLS时还可以直接复用H.264视频流,不重新编码,CPU占用极低,一台普通服务器带十几路没问题。
2.2 FFmpeg最小转流命令与参数拆解
先给出一条经过实际验证的最小命令,跑通后再逐步加参数:
ffmpeg -rtsp_transport tcp \ -i "rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101" \ -c:v copy \ -c:a aac \ -f hls \ -hls_time 2 \ -hls_list_size 5 \ -hls_flags delete_segments \ -use_wallclock_as_timestamps 1 \ /data/hls/live/live.m3u8-rtsp_transport tcp强制走TCP拉流。RTSP默认常走UDP,局域网偶尔丢包就会花屏、马赛克,TCP虽然增加一点延迟,但可靠得多。-c:v copy是视频流复制,不重新编码,这是让CPU占用保持在个位数百分比的关键。如果摄像头输出的是H.265,这条命令会直接报错,因为copy模式下不能改变编码格式,此时需要把-c:v copy换成转码参数,后面会专门说。-c:a aac把音频转成AAC,因为很多RTSP源音频是G.711格式,浏览器解码不了,必须转。
HLS相关参数是坑最多的区域。-hls_time 2控制每个切片时长,单位是秒。切片越小延迟越低,但切片文件数量和HTTP请求频率都会上涨,Nginx负载和播放器请求压力跟着变大。2秒是一个实测均衡点,低于1秒在某些设备上会出现切片不完整、播放器频繁缓冲的情况。-hls_list_size 5表示m3u8播放列表里最多保留5个切片,配合-hls_flags delete_segments,旧的切片文件会被自动清理,避免磁盘被ts文件塞满。如果没有加delete_segments,切片文件会无限堆积,播放列表也不会循环滚动,播几秒就卡住,这是最常见的翻车原因之一。
-use_wallclock_as_timestamps 1是一个容易忽略但对RTSP源很关键的参数。部分摄像头生成的RTP时间戳不是从0开始的,或者两个媒体轨时间基准不一致,转出来的HLS会出现时间戳回跳,播放器表现为画面抽搐、缓冲频繁。加上这个参数,FFmpeg用系统时钟重写时间戳,能解决绝大多数RTSP源的时间戳抖动问题。
2.3 转码还是复用:不同源格式的命令变体
-c:v copy有前提条件:视频必须H.264、音频必须AAC。现实里RTSP源五花八门,设备输出H.265、音频G.711都很常见。H.265编码的摄像头在浏览器里基本无解,必须转码成H.264:
ffmpeg -rtsp_transport tcp \ -i "rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101" \ -c:v libx264 \ -preset veryfast \ -tune zerolatency \ -c:a aac \ -f hls \ -hls_time 2 \ -hls_list_size 5 \ -hls_flags delete_segments \ -use_wallclock_as_timestamps 1 \ /data/hls/live/live.m3u8-preset veryfast表示编码速度优先于压缩率,实时场景不需要追求体积,速度越快延迟越低。-tune zerolatency是x264针对低延迟直播场景的调优参数,去掉编码缓冲,避免画面延迟叠加。转码模式下CPU占用明显上升,一台四核服务器带四路H.265转H.264已经到了极限,要带更多路就得考虑硬件编码或GPU转码。
只有视频没有音频的源,比如很多IPC的纯视频通道,直接用-an把音频轨去掉,避免FFmpeg因为没有音频流一直打日志:
ffmpeg -rtsp_transport tcp \ -i "rtsp://user:pass@192.168.1.64:554/Streaming/Channels/102" \ -c:v copy \ -an \ -f hls \ -hls_time 2 \ -hls_list_size 5 \ -hls_flags delete_segments \ /data/hls/live_noaudio.m3u8判断源是H.264还是H.265、音频是什么格式,可以用ffprobe先探一下:
ffprobe -show_streams -show_format "rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101"输出里看codec_name字段,视频是h264还是hevc,音频是aac还是pcm_alaw。这一步必须做,很多问题在源头就能发现,省得后面播放器报错再来排查。
3. Nginx挂载HLS切片:三个非改不可的配置
3.1 为什么静态服务就能扛住HLS
HLS协议拆开看就是两件事:播放器先GET一次m3u8清单文件,然后按清单里的顺序逐个GET后面的ts切片。整个过程没有长连接、没有服务端推送,全是普通HTTP请求。所以Nginx不需要任何特殊模块,一个裸静态文件服务就能撑住,关键只是把目录指对、把响应头配好。
切片请求的特点是短、频、小,一个2秒的ts切片大小通常几十到几百KB。Nginx的sendfile、tcp_nopush这些优化正好适合这种场景,而且可以从缓存里反复读同一个m3u8文件,对磁盘IO压力很小。部署的时候把FFmpeg切片输出目录直接指向Nginx的站点根目录,一步到位。
3.2 一段能用的Nginx配置:MIME、CORS、缓存头
给出一段可以直接落地的Nginx配置,放在conf/nginx.conf的server块里:
server { listen 8080; server_name _; root /data/hls; location /hls/ { types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } add_header Cache-Control no-cache; add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS"; add_header Access-Control-Allow-Headers "Range"; } }types块是第一个关键点。Nginx默认的mime.types里不一定包含m3u8和ts的映射,如果不在这里显式声明,浏览器拿到的是application/octet-stream,会直接触发下载而不是播放。Cache-Control no-cache是第二个关键点。直播场景下m3u8文件一直在更新,浏览器如果缓存了旧的清单,播放器就永远拿到老切片列表,表现出来的问题就是画面停在上一个片段。ts切片同样不缓存,因为一个ts文件被播放器消费完就没有价值了。
Access-Control-Allow-Origin *是第三个关键点,也是最容易踩的坑。前端页面如果跑在一个独立域名或端口,比如前端在http://localhost:8081,播放地址在http://192.168.1.50:8080,浏览器会发起跨域请求,没有这个响应头,播放器加载m3u8会被拦截,控制台报CORS错误。Access-Control-Allow-Headers "Range"是为支持拖拽播回放准备的,有些播放器请求ts时会带Range头,不放开会被Nginx拒绝。
配置文件改完后,先测试语法再重载:
/usr/local/nginx/sbin/nginx -t /usr/local/nginx/sbin/nginx -s reload3.3 目录规划与多路流隔离
多路摄像头接入时,目录和数据管理会迅速变成新的麻烦。我一般按摄像头ID建子目录,每个流一个独立文件夹:
/data/hls/ ├── cam001/ │ ├── live.m3u8 │ └── *.ts ├── cam002/ │ ├── live.m3u8 │ └── *.ts └── cam003/ ├── live.m3u8 └── *.ts这样一个location /hls/cam001/live.m3u8就能唯一定位,前端拿到的地址天然带上摄像头标识,方便后端做权限控制。切片文件名建议用递增序号或时间戳,配合-strftime参数按时间生成名字,比如%Y%m%d_%H%M%S.ts,这样即使误配了缓存也能靠文件名变化强制刷新。
还有一类问题属于Nginx深层行为:sendfile和open_file_cache。open_file_cache开启后,Nginx会缓存文件描述符和元数据,如果ts文件在缓存有效期内被FFmpeg删掉重建,Nginx可能还指向旧的文件描述符,播放器请求到的内容不是最新的。遇到这种诡异问题时,把open_file_cache_valid调小或暂时关闭open_file_cache,往往能解决。
4. SSM后端接管转流任务,前端拿到地址就能播
4.1 SSM在链路里到底管什么
很多人误以为SSM负责转流,其实不完全是。FFmpeg才是真正干活的,SSM在这里扮演的是「管家」角色:管理摄像头设备信息、拉起和监控FFmpeg进程、向前端下发播放地址。设备新增、编辑、删除时,SSM负责同步更新转流任务;某个流挂了,SSM负责重新拉起进程。
SSM的核心工作是进程生命周期管理。Java里拉起外部进程经常翻车:进程启动了,但没人知道它什么时候退出,退出了也没人重启;stdout没有重定向,缓冲区写满后FFmpeg直接阻塞卡死。这两个问题必须在一开始就处理掉,否则上线后就是各种诡异现象:画面突然不动了、服务器上多了一堆僵死进程。
4.2 Java拉起FFmpeg进程的正确姿势
用ProcessBuilder拉起FFmpeg的典型写法:
ProcessBuilder pb = new ProcessBuilder( "/usr/local/bin/ffmpeg", "-rtsp_transport", "tcp", "-i", String.format("rtsp://%s:%s@%s:%d%s", device.getUsername(), device.getPassword(), device.getIp(), device.getPort(), device.getStreamPath()), "-c:v", "copy", "-c:a", "aac", "-f", "hls", "-hls_time", "2", "-hls_list_size", "5", "-hls_flags", "delete_segments", "-use_wallclock_as_timestamps", "1", "/data/hls/cam" + device.getChannel() + "/live.m3u8" ); pb.redirectErrorStream(true); pb.redirectOutput(new File("/data/logs/ffmpeg_cam" + device.getChannel() + ".log")); Process process = pb.start(); processMap.put(device.getChannel(), process);redirectErrorStream(true)把错误输出和标准输出合并,redirectOutput重定向到日志文件,这两步必须做,否则FFmpeg的日志会填满Java进程的管道缓冲区,进程挂在write上。日志文件也是排查问题最重要的入口,FFmpeg输出什么、在哪一步报错,全在这里。
调用process.waitFor()要格外小心。它会阻塞当前线程直到FFmpeg退出,放在Controller请求线程里等于把接口卡死。常见做法是单独起一个线程监听进程退出状态,退出后执行重启:
Thread watcher = new Thread(() -> { try { int code = process.waitFor(); if (code != 0) { log.warn("ffmpeg exited, code: {}", code); restartStream(device); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); watcher.setDaemon(true); watcher.start();要注意进程退出码不是可靠的健康指标。FFmpeg有时异常退出但退出码是0,只靠waitFor不够,还得配合日志检测或文件检查。比如每5秒判断一次m3u8文件的最后修改时间,超过10秒没有更新,视为流中断,强制重启进程。这样比只盯进程状态可靠得多。
4.3 播放地址接口与video.js前端初始化
SSM对外提供一个获取播放地址的接口,简化代码如下:
@RestController @RequestMapping("/api/stream") public class StreamController { @GetMapping("/url") public Map<String, Object> getPlayUrl(@RequestParam Long deviceId) { Device device = deviceService.getById(deviceId); Map<String, Object> result = new HashMap<>(); if (streamService.isRunning(device.getChannel())) { String playUrl = "http://" + serverIp + ":8080/hls/cam" + device.getChannel() + "/live.m3u8"; result.put("code", 0); result.put("url", playUrl); } else { result.put("code", 1); result.put("msg", "stream not running"); } return result; } }前端拿到URL后交给video.js播放:
<video id="livePlayer" class="video-js vjs-default-skin" controls playsinline> </video>var player = videojs('livePlayer', { autoplay: true, controls: true, fluid: true, liveui: true }); $.get('/api/stream/url?deviceId=1', function (res) { if (res.code === 0) { player.src({ src: res.url, type: 'application/x-mpegURL' }); player.play(); } });type: 'application/x-mpegURL'是video.js识别HLS流的关键,写错了播放器会把m3u8当普通视频文件处理,直接报格式不支持。Chrome里播放依赖MSE,video.js内部会调用hls.js来解析m3u8并喂给video元素;Safari浏览器自身支持HLS,video.js直接走原生路径。这套方案在主流浏览器上的兼容性已经过大量验证,基本不会翻车。
4.4 地址下发时序:m3u8没生成就返回,播放器必然白屏
这条线路上最隐蔽的时序问题:FFmpeg进程刚启动,m3u8文件还没生成,接口就把播放地址返回给前端,播放器拿URL去请求时Nginx返回404,播放器直接报错。FFmpeg启动后必须等第一个切片写盘、m3u8刷新出来,播放器才能真正开播,这个过程通常要等2到3秒。
解决办法是后端在返回地址前,先确认m3u8文件已经存在且有不小的大小:
public boolean streamReady(String channel) { File m3u8 = new File("/data/hls/cam" + channel + "/live.m3u8"); if (!m3u8.exists()) { return false; } return m3u8.lastModified() > System.currentTimeMillis() - 5000; }接口层面做这个检查后,前端仍然要做好兜底:播放器error事件里提示用户稍后重试,或者前端轮询几秒后再取一次地址。这类问题排查起来极其隐蔽,控制台没有任何报错,看起来是URL不对,其实是时序问题。
5. 五个必踩的坑与排查清单
5.1 RTSP拉流卡死:不是命令错,是传输层和超时
现象:FFmpeg日志显示持续拉流,但画面流几十秒或几分钟后停住,日志不再滚动;或者播放器的画面停在某一帧,既不前进也不报错。
原因:RTSP源走UDP传输时网络丢包,FFmpeg收不到后续RTP包,进入等待状态;另一个原因是设备在空闲一段时间后主动断开。
解决:命令里强制-rtsp_transport tcp,TCP连接有状态,断线能被及时感知。同时在FFmpeg输入侧加超时参数,让它在源断开后主动退出,交给守护线程重启:
ffmpeg -rtsp_transport tcp \ -stimeout 5000000 \ -i "rtsp://user:pass@192.168.1.64:554/..." \ ...-stimeout单位是微秒,5000000就是5秒。这个参数的含义是:5秒内没有数据到达就报错退出。只有让它退出,上层监听到进程结束后才能重启,否则FFmpeg挂在那里既不报错也不退出,看起来像「还活着」,实际已经死了。
5.2 只播两秒就卡住:m3u8列表没循环起来
现象:播放器开播后画面正常,但过了第一个切片时长就不再前进,进度条停住,刷新页面又能播一次,然后再次卡住。
原因:两类。一类是命令里没加-hls_flags delete_segments,切片文件持续累积但m3u8列表不更新,播放器只会按旧列表播那几个切片。另一类是浏览器缓存了m3u8,每次刷新页面拿到的都是同一份旧清单。
解决:确认命令包含delete_segments,同时Nginx侧加Cache-Control no-cache。最直接验证方法是把m3u8文件内容打出来,看EXT-X-MEDIA-SEQUENCE是否在递增:
curl http://127.0.0.1:8080/hls/cam001/live.m3u8如果文件里的EXT-X-MEDIA-SEQUENCE始终是同一个值,说明切片列表没有循环,问题在FFmpeg侧;如果递增但播放器还卡,问题在浏览器或Nginx缓存,强制刷新页面验证。
5.3 画面正常没声音:音频编码不是AAC
现象:画面流畅,但没有声音,控制台没有报错;或者video元素直接显示格式不支持。
原因:RTSP源的音频轨不是AAC,常见的是G.711 a-law或u-law,浏览器无法解码,MSE直接拒绝。
解决:如果源是G.711,在-c:a aac转码即可。如果源有音频轨但播放器依然没声,用ffprobe看codec_name字段,确认转码后输出流的编码格式是aac:
ffprobe -show_streams /data/hls/cam001/live.m3u8注意一个变体:某些设备通道的RTSP地址实际上只有视频轨,没有音频轨,转码参数里还带着-c:a aac时FFmpeg会一直尝试找音频流,导致启动慢甚至失败。这时加-an明确告诉FFmpeg不要音频,问题立刻消失。
5.4 切片和播放器缓存:改代码无用,清缓存就好
现象:前端改了播放器逻辑,刷新页面后行为还是旧的;或者某一路流已经切换了切片时长,播放器还按旧节奏请求ts。
原因:浏览器HTTP缓存、Nginx的open_file_cache、以及播放器实例内部的缓冲队列叠在一起,形成三重缓存。其中播放器实例的缓存最隐蔽:video.js实例创建后,如果再给它赋值新的m3u8地址,旧实例的缓冲切片可能还在播放器内部队列里。
解决:Nginx侧在location /hls/下统一加add_header Cache-Control no-cache,关闭open_file_cache或调小时间。前端切换流时不要复用旧播放器实例,直接销毁重建最省心:
if (player) { player.dispose(); } player = videojs('livePlayer', {...});这问题看起来像玄学,其实全是缓存叠加。遇到「改了没生效」先判断是哪个层级的缓存,别急着改业务代码。
5.5 多路并发和磁盘:切片堆积是无底洞
现象:服务器跑了几天后磁盘告警,du -sh /data/hls一看几十GB;或者某一路流频繁卡顿,但这路流本身配置没问题。
原因:delete_segments只删除m3u8列表里已被淘汰的切片。如果-hls_list_size设置偏大,比如10以上,同时还有播放器不断地从列表头部请求旧切片,删除节奏就跟不上。还有一种情况是FFmpeg崩溃后,残留的ts文件没人清理。
解决:生产环境加一层磁盘兜底清理脚本,按时间删除超过N分钟的ts文件:
find /data/hls -name "*.ts" -mmin +30 -delete配合-hls_list_size 5和-hls_time 2,直播间保留大约10秒的窗口,回放场景单独分流到另一个目录和另一套保留策略。磁盘使用量要做到可监控,每5分钟统计一次/data/hls目录大小,超过阈值就报警,别让磁盘爆掉成为最后一个「惊喜」。
6. 延迟还能再压一压:三个调优项与一套验证方法
延迟的构成很简单:切片缓冲延迟、播放器缓冲延迟、网络传输延迟三段相加。切片已经是2秒了,再往下压空间不大,真正能优化的是播放器侧的缓冲策略。video.js里hls.js默认会缓冲约30秒的数据,这在直播场景下完全没必要。打开hls.js的低延迟模式,让播放器尽量追着直播边缘播放:
var player = videojs('livePlayer', { autoplay: true, html5: { hls: { lowLatencyMode: true } } });lowLatencyMode开启后,播放器会主动清空过期的缓冲区,把播放位置尽量贴近m3u8列表的尾部。实测延迟能从8秒左右压到4秒上下,体感明显。第二个可调项是-hls_list_size,直播场景3到5段足够,保留太多段只会让播放器默认从中间开始播,越播越滞后。第三个是切片文件名,用含时间戳的-strftime 1生成,保证文件名唯一,从根上避免客户端缓存问题。
验证链路是否健康,有一套固定的流程值得养成习惯:先用ffprobe -show_format看m3u8里拖尾切片的时长,确认切片时长和预期一致;再连续三次curl -I检查Nginx响应的Content-Type是不是application/vnd.apple.mpegurl,以及Cache-Control是否带上no-cache;最后开着浏览器Network面板播放一分钟,看ts请求间隔是否稳定在2秒左右。延迟实测有个土办法:手机秒表对着摄像头画面和网页画面同时掐,差值就是端到端延迟。
我在实际项目里吃过一次亏,就是没加-stimeout,FFmpeg进程挂死两天没人发现,大屏上画面停在一个固定的画面上,客户以为是新功能「冻结预览」。从那以后,每条流上线前都要跑一遍那套十分钟的验证流程。现在遇到新环境,也会先把ffprobe探源的输出完整看一遍,确认编码、分辨率、帧率,再决定copy还是转码。这套链路熟练之后,从拿到rtsp地址到网页出画面,半小时就能通,排查起来的顺序也固定:先看ffmpeg日志,再看m3u8内容,最后看浏览器Network面板,按这个次序走,百分之八十的问题十分钟内定位。希望帮到你。
本文还有配套的精品资源,点击获取