客户那边有 30 路网络摄像头,要求 7×24 录像存档,保留 30 天。
我一开始的想法很简单:30 条 ffmpeg 命令,每条拉一路 RTSP 存文件,完事。
上线第一周就出问题:
- 有 4 路画面频繁花屏(UDP 丢包);
- 有一路摄像头重启后,ffmpeg 进程挂了就再也没起来(ffmpeg 自己不会重连);
- 录出来的文件时长不对(30 分钟的片段,元数据说 2 小时)——摄像头的时间戳乱跳;
- 30 路同时跑,把接入交换机的带宽打满,其他业务受影响。
这篇写完整方案:拉流参数、重连机制、时间戳修正、存储策略,以及最后稳定运行半年多的架构。
TL;DR:四个必须做对的点——用
-rtsp_transport tcp(避免 UDP 丢包花屏);ffmpeg 不会自己重连,要靠外层守护(systemdRestart=always或者 while 循环);摄像头的时间戳经常不可靠,用-fflags +genpts和-use_wallclock_as_timestamps 1修正;录制用-c copy不转码(CPU 几乎为零,代价是文件大,事后转码)。另外:一定要有"流是不是还活着"的监控,否则断了一路你可能一周后才发现。
目录
- 一、先搞清楚 RTSP 地址怎么拼
- 二、拉流参数:TCP 还是 UDP
- 三、ffmpeg 不会重连:外层守护
- 四、时间戳:录出来时长不对的元凶
- 五、录制与切片
- 六、转不转码:不要实时转码
- 七、多路并发与带宽
- 八、健康监控:断流怎么发现
- 九、存储策略与保留期
- 十、安全与合规
- 十一、坑清单
一、先搞清楚 RTSP 地址怎么拼
不同厂商的 RTSP URL 格式都不一样,这是第一个要花时间的事:
| 厂商 | 主码流 | 子码流 |
|---|---|---|
| 海康 | rtsp://user:pass@IP:554/Streaming/Channels/101 |
.../102 |
| 大华 | rtsp://user:pass@IP:554/cam/realmonitor?channel=1&subtype=0 |
subtype=1 |
| 宇视 | rtsp://user:pass@IP:554/video1 |
video2 |
| TP-Link | rtsp://user:pass@IP:554/stream1 |
stream2 |
| ONVIF 通用 | 用 ONVIF 探测 GetStreamUri |
建议:如果摄像头支持 ONVIF,用 ONVIF 的 GetStreamUri 自动获取地址,比手工拼可靠:
# python-onvif 或者 onvif-cli
onvif-cli --host 192.168.1.64 --user admin --pass xxx GetStreamUri
先用 ffprobe 验证地址对不对:
ffprobe -v error -rtsp_transport tcp -show_entries stream=codec_name,width,height,r_frame_rate \
-of default=noprint_wrappers=1 "rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101"
# codec_name=h265
# width=1920
# height=1080
# r_frame_rate=25/1
这一步很重要:它同时告诉你编码格式(决定后面能不能 -c copy)、分辨率、帧率。我见过不少"地址能连上但取到的是子码流(640×360)"的情况——录了半天发现分辨率不对。
二、拉流参数:TCP 还是 UDP
RTSP 的音视频数据走 RTP,可以用 UDP 或 TCP:
| 方式 | 优点 | 缺点 |
|---|---|---|
| UDP(默认) | 延迟低、开销小 | 丢包就花屏(UDP 不重传) |
| TCP | 不丢包(TCP 重传) | 延迟略高、网络差时会卡顿 |
监控录制场景一律用 TCP——我们要的是"录得完整",不是"延迟低"。那点延迟(几十毫秒)对存档毫无意义。
-rtsp_transport tcp
这就是第一个坑的解法:那 4 路花屏的摄像头,改成 TCP 之后完全正常(因为接入网络有丢包,UDP 丢了关键帧的数据就花屏)。
还要加超时:
-stimeout 5000000 # 单位微秒,这里是 5 秒;连不上就报错退出
(老版本 ffmpeg 用 -stimeout,新版本也可以用 -timeout,注意两者单位不同——-stimeout 是微秒。)
三、ffmpeg 不会重连:外层守护
这是第二个坑,也是最常见的误解:ffmpeg 没有可靠的 RTSP 重连机制。
网上经常能看到这几个参数:
-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 30
它们只对 HTTP/HTTPS 类的协议有效,对 RTSP 基本没用(RTSP 的重连涉及重新 DESCRIBE/SETUP/PLAY,不是简单的"重发请求")。
所以正确的做法是外层守护——让 ffmpeg 挂掉后被重新拉起。
方案 A:systemd(推荐)
# /etc/systemd/system/cam-01.service
[Unit]
Description=Camera 01 recorder
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=camrec
ExecStart=/usr/local/bin/cam_record.sh 01
Restart=always
RestartSec=5
# ffmpeg 收到 SIGTERM 后会优雅退出,给它时间
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
关键三行:
Restart=always:进程退出就重启(不管是正常退出还是崩了);RestartSec=5:等 5 秒再重启(避免摄像头还没恢复时疯狂重试);TimeoutStopSec=30:给 ffmpeg 时间优雅退出,保证最后一个片段被正确封装。
30 路就写 30 个 service 文件? 用 systemd 的模板单元:
# /etc/systemd/system/cam@.service
[Unit]
Description=Camera %i recorder
After=network-online.target
[Service]
Type=simple
User=camrec
ExecStart=/usr/local/bin/cam_record.sh %i
Restart=always
RestartSec=5
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
启动:
systemctl enable --now cam@01 cam@02 cam@03 ... cam@30
# 或者
for i in $(seq -w 1 30); do systemctl enable --now cam@$i; done
方案 B:脚本内循环(更简单)
#!/bin/bash
# cam_record.sh
CAM_ID="$1"
URL=$(get_camera_url "$CAM_ID")
OUTDIR="/data/record/$CAM_ID"
mkdir -p "$OUTDIR"
while true; do
ffmpeg -hide_banner -loglevel warning \
-rtsp_transport tcp \
-stimeout 5000000 \
-i "$URL" \
-c copy \
-f segment -segment_time 1800 -segment_atclocktime 1 \
-strftime 1 "$OUTDIR/%Y%m%d_%H%M%S.mp4"
echo "[$(date)] camera $CAM_ID ffmpeg exited ($?), retry in 5s" >&2
sleep 5
done
while true 循环 + sleep 是最朴素也最可靠的。systemd 的方案更规范(有日志、有状态、能统一管理),脚本方案更简单。
我们最后用的是 systemd 模板 + 脚本(脚本里只跑一次 ffmpeg,重启由 systemd 负责)——职责分离,日志也干净。
四、时间戳:录出来时长不对的元凶
第三个坑,也是最隐蔽的。
现象:录了一个小时的文件,ffprobe 报告时长 3 小时;或者播放器拖动进度条时跳来跳去;或者多个片段拼接后时间戳跳变。
原因:摄像头给的时间戳(PTS/DTS)不可靠。常见问题:
- DTS 乱序或不递增(某些摄像头的固件 bug);
- 断流重连后时间戳回退(重新从 0 开始或者跳变);
- 时间戳单位(time_base)不对;
- 摄像头时钟不准,导致时间戳和实际时间差很多。
解决办法(组合使用):
ffmpeg -rtsp_transport tcp -i "$URL" \
-fflags +genpts+igndts \ # 忽略输入的 DTS,重新生成 PTS
-use_wallclock_as_timestamps 1 \ # 用"收到数据的墙钟时间"当时间戳
-avoid_negative_ts make_zero \ # 负时间戳归零
-c copy -f segment ...
| 参数 | 作用 | 什么时候用 |
|---|---|---|
+genpts |
重新生成 PTS | 时间戳缺失/乱序 |
+igndts |
忽略输入 DTS | DTS 不可靠(摄像头常见) |
-use_wallclock_as_timestamps 1 |
用到达时间当时间戳 | 时间戳跳变/回退严重时 |
-avoid_negative_ts make_zero |
负时间戳归零 | 录制起点为负 |
-use_wallclock_as_timestamps 1 是终极手段:它完全抛弃摄像头给的时间戳,用"ffmpeg 收到这一帧的系统时间"。代价是帧率不再精确(因为网络抖动会让到达时间不均匀),但时长一定是准的——对录制存档来说,这个交易很划算。
判断摄像头的时间戳是否可靠:
ffprobe -v error -rtsp_transport tcp -select_streams v:0 -show_frames \
-show_entries frame=pts_time -of csv=p=0 -read_intervals "%+#20" "$URL" | head -20
# 看 pts_time 是否单调递增、间隔是否均匀
我现在的做法是:先用默认参数录 5 分钟,检查时长对不对;不对再逐步加上面的参数。不同品牌摄像头的表现差异很大,没有通用答案。
五、录制与切片
不要录成一个无限大的文件。 原因:
- 一个文件坏了,整段丢失;
- 检索困难(要找某天下午 3 点的内容,得先定位到文件内的位置);
- 文件太大,后续转码/上传都很麻烦。
用 segment muxer 按时间切片:
-f segment \
-segment_time 1800 \ # 每 30 分钟一片
-segment_atclocktime 1 \ # 对齐到整点/半点(而不是从启动时刻算)
-strftime 1 \ # 文件名用时间格式
-reset_timestamps 1 \ # 每片时间戳从 0 开始(避免累积误差)
"$OUTDIR/%Y%m%d_%H%M%S.mp4"
-segment_atclocktime 1:让切片边界落在整点/半点(配合-segment_time 1800),而不是"进程启动后 30 分钟"——这样文件命名有规律,检索方便;-reset_timestamps 1:每片重置时间戳,避免长时间录制后的浮点累积误差;-strftime 1:文件名用20260311_143000.mp4这种格式。
关键帧对齐:-f segment 默认在关键帧处切。如果摄像头的关键帧间隔很大(比如 10 秒以上),切片时间会有偏差(30 分钟 ± 10 秒)。可以加:
-segment_frames 或 -force_key_frames
但对 -c copy(不重新编码)的模式,无法强制关键帧——只能接受误差,或者用 -segment_time 配合 -break_non_keyframes(会在非关键帧处切,但那一片的开头会花屏一小段)。录制场景我建议接受误差,不要为了精确切片牺牲画面。
六、转不转码:不要实时转码
这是最重要的性能建议。
摄像头输出的已经是 H.264/H.265 码流,直接 -c copy 存下来就行,CPU 占用接近 0。
| 方案 | CPU(30 路) | 体积 | 说明 |
|---|---|---|---|
-c copy |
~3% | 100% | 推荐:原样存 |
| 实时转 H.265 | ~600%(8 核不够) | 60% | 不现实 |
| 实时降分辨率 | ~300% | 40% | 一般没必要 |
如果最终要省空间,做法是:"先原样存 → 事后批量转码 → 删除原始文件":
实时录制(-c copy,CPU 几乎为 0)
↓
定时任务(凌晨低峰)转码前一天的文件
↓
转码成功 → 删原始文件;失败 → 保留原始文件并告警
这样做的好处:
- 录制端压力极小(30 路只要很少的 CPU);
- 转码可以挑业务低峰、可以用慢 preset(画质好);
- 转码失败还能重来(原始文件还在)。
转码阈值:保留 30 天的原始文件不现实(太大),所以我们是当天保留原始、次日转码归档,原始文件保留 3 天作为缓冲。
七、多路并发与带宽
30 路 1080p 摄像头,每路 4 Mbps:
30 × 4 Mbps = 120 Mbps 持续入站带宽
千兆网卡(实际 ~940 Mbps)够用,但要注意:
- 摄像头和录制服务器最好在同一个二层网络(跨公网/跨 VPN 录制是另一回事,延迟和丢包风险大很多);
- 交换机端口带宽:如果 30 路都走同一个上行口,那个口就是瓶颈;
- 磁盘写入带宽:120 Mbps = 15 MB/s 持续写入,机械盘也扛得住,但加上后续的转码读写就吃紧了。
磁盘 IO 规划:
录制写入:15 MB/s(持续)
转码读取 + 写入:峰值可能 200 MB/s(凌晨批量跑)
所以录制盘和转码输出盘最好分开,或者至少用 SSD。
进程数:30 个 ffmpeg 进程(每路一个),每个进程 CPU 占用很低(-c copy 时主要是 IO)。8 核机器绰绰有余。但内存要注意:每个 ffmpeg 进程大约 30~80 MB,30 路就是 1~2.4 GB,加上系统和其他服务,8GB 内存是起步。
八、健康监控:断流怎么发现
这是最容易被忽略、也最容易出事的一环。
ffmpeg 挂了 → systemd 会重启 → 但如果摄像头本身断了(断电、网络故障),ffmpeg 会一直等在那里或者不断重连,从外面看进程还在,实际上什么都没录。
三个必须有的检查:
1. 文件新鲜度检查(最重要)
每个摄像头目录里最新文件的修改时间,如果超过 N 分钟没更新,就是出问题了:
#!/bin/bash
# check_camera.sh
for d in /data/record/*/; do
cam=$(basename "$d")
latest=$(find "$d" -name '*.mp4' -printf '%T@\n' 2>/dev/null | sort -n | tail -1)
if [ -z "$latest" ]; then
echo "ALERT: $cam 没有任何录像文件"
continue
fi
now=$(date +%s)
age=$(( now - ${latest%.*} ))
if [ "$age" -gt 600 ]; then
echo "ALERT: $cam 最新录像已 $((age/60)) 分钟未更新"
fi
done
每 5 分钟跑一次,有输出就告警。
2. 服务状态检查
systemctl list-units 'cam@*' --state=failed --no-legend
3. 定期探测流是否可用(更主动)
#!/bin/bash
# probe_camera.sh —— 每 30 分钟探测一次所有摄像头
while read -r id url; do
if ! ffprobe -v error -rtsp_transport tcp -stimeout 5000000 \
-show_entries stream=codec_name -of csv=p=0 "$url" >/dev/null 2>&1; then
echo "ALERT: camera $id 探测失败"
fi
done < /etc/cameras.list
注意:探测会占用摄像头的一个连接(很多摄像头有并发连接数限制,常见的是 2~5 路)。如果已经有一路在录制,再探测可能超过限制。所以:
- 探测频率别太高(30 分钟一次够了);
- 或者用 RTSP OPTIONS/DESCRIBE(ffmpeg 的
-rtsp_flags? 简单办法是ffprobe -f rtsp只取头信息就退出)。
我们的最终方案:文件新鲜度检查为主(它最真实——没文件就是没录到),服务状态检查为辅,流探测每天一次(用于发现"进程在但流已断"的僵尸状态)。
九、存储策略与保留期
30 路 × 4 Mbps × 30 天:
4 Mbps × 30 路 = 120 Mbps
120 Mbps × 86400 秒 = 1.24 TB/天
1.24 TB × 30 天 = 37 TB
37 TB 不是小数目,所以存储策略要算清楚:
| 策略 | 说明 |
|---|---|
| 原始保留期 | 3 天(缓冲用) |
| 转码后保留期 | 30 天(合规要求) |
| 转码压缩率 | 转 H.265 后约 60% → 22 TB |
| 降帧率 | 监控场景 15fps 足够(很多摄像头本来就设 15fps) |
降码率的正确做法(不是压画质,而是从源头):
- 用子码流录制(很多场景子码流 640×360 就够用)——存储能省 80%;
- 降低帧率(15fps 甚至 10fps)——省 40~50%;
- 只在有运动时录制(用
ffmpeg的select='gt(scene,0.01)'或者摄像头的移动侦测)——省得最多,但实现复杂。
我们的实际配置:主码流 1080p@15fps 录制 7 天(高清),子码流 640×360 录制 30 天(长期)。这个组合在合规和成本之间取得了平衡。
清理任务:
# 每天凌晨清理超过保留期的文件
find /data/record -name '*.mp4' -mtime +30 -delete
注意 delete 的性能:一次性删几万个文件会很慢(而且 IO 打满)。用分批 + ionice:
find /data/record -name '*.mp4' -mtime +30 -print0 | \
ionice -c 3 xargs -0 -n 500 rm -f
十、安全与合规
这块必须说,因为监控录像涉及隐私。
技术安全:
- 摄像头不要暴露在公网——RTSP 的默认凭据(admin/12345)是扫描器的重点目标;
- 改默认密码,并且用强密码;
- 网络隔离:摄像头单独一个 VLAN,只有录制服务器能访问;
- 录制服务器的存储目录权限最小化(只有录制用户和授权人员能读);
- 录像文件加密(如果存储介质可能丢失)。
合规(不是法律建议,是我的处理原则):
- 告知义务:公共场所安装监控要有明示标识;
- 最小必要:不要录不需要的区域(比如对着更衣室、宿舍内部);
- 保留期:按业务需要和当地规定设定,到期自动删除("能删"和"删了"是两回事,要有自动任务);
- 访问控制:谁能看录像要有明确授权和审计日志;
- 不对外传播:录像不能随便转发、不能发到公开平台。
我在交付文档里会明确写一句:本方案仅用于客户自有场所的安全管理,录像数据的访问和保留由客户负责,并要求客户确认已履行告知义务。 这不是推卸责任,是提醒——技术上能做到的事,不代表可以做。
十一、坑清单
- 用默认的 UDP 拉流 → 丢包花屏。用
-rtsp_transport tcp。 - 以为
-reconnect对 RTSP 有效 → 无效。靠外层守护(systemd Restart=always)。 Restart=always但没设RestartSec→ 摄像头没恢复时疯狂重启,日志爆炸。设 5~30 秒。- 不设
TimeoutStopSec→ systemd 默认 90 秒后 SIGKILL,最后一个片段可能没封装完。 - 时间戳不加修正 → 录出来的时长不对、拼接跳变。用
+genpts+igndts,严重时-use_wallclock_as_timestamps 1。 - 实时转码 → CPU 打满、路数上不去。先
-c copy,事后批量转码。 - 录成一个大文件 → 坏了全丢、检索困难。用 segment 切片。
- 切片不对齐整点 → 文件名乱、检索麻烦。加
-segment_atclocktime 1。 - 没有文件新鲜度监控 → 断了一路一周后才发现。这是最常见的事故。
- 探测太频繁 → 超过摄像头的并发连接数限制,导致录制被挤掉。
- 摄像头并发连接数限制 → 多路访问同一摄像头会失败。了解设备的限制。
- 默认凭据 → 摄像头被入侵。改密码 + 网络隔离。
- 存储没算够 → 跑一周发现盘满了,然后录制全部失败。上线前先算:码率 × 路数 × 保留天数。
- 清理任务一次性删几万文件 → IO 打满。分批 +
ionice -c 3。 - 忽略摄像头本身的时钟 → 时间戳错乱。给摄像头配 NTP(很多设备支持)。
- 录制服务器单点 → 服务器挂了全部中断。重要场景要有第二台。
- 没验证过恢复流程 → 需要调阅录像时才发现文件格式有问题。定期做一次"调阅演练"。
最后说说这个系统的整体体会。
录制系统看起来是"起几个进程"的事,但它的难点全在"长期稳定运行"上:
- 网络会抖动 → TCP + 重连;
- 摄像头会重启 → 守护进程;
- 时间戳会乱 → 参数修正;
- 磁盘会满 → 容量规划 + 清理;
- 服务会静默失败 → 新鲜度监控。
这五件事每一件在测试时都不会发生(测试环境网络稳定、摄像头不重启、数据量小、跑几分钟)。所以录制系统的可靠性完全取决于"你有没有主动去想那些不会发生的事"。
我的习惯是:上线前把"如果 X 断了会怎样"列一遍,然后给每一条配一个检测手段。这个清单后来救过我们几次——比如有一次某路摄像头被施工碰断了电源,告警在 11 分钟后就发了,我们当天就恢复了。如果没有新鲜度监控,这可能要等客户需要调阅录像时才发现,那时候已经晚了。
最后一句:监控系统的价值在"需要调阅的那一刻"才体现。平时跑得好好的不代表系统可靠,只有"随时能调出任意一路任意时段的完整录像"才算真的可靠。所以定期做一次调阅演练(随机抽一路、抽一个时间段,看能不能顺利导出),比任何技术指标都更能说明问题。