Rust 打造本地 NVR:Moonfire NVR 架构与实战 简介Moonfire NVR 是一套面向安防监控场景的开源网络视频录像机实现适合具备 Rust 与 JavaScript 基础、希望自建 IP 摄像机存储方案的开发者研究。它把来自摄像机的 H.264-over-RTSP 流直接落盘视频帧按目录存放于磁盘、元数据存入 SQLite3并能按任意时间范围动态拼装 mp4全程不解码、不转码因此 CPU 占用极低可在普通机器上同时处理多路 1080p/30fps 流。资源包共 116 个文件约 5.22MB以 48 个 rs 源码与 24 个 js 前端脚本为主体辅以 md 文档、sql 建表脚本、toml 配置及 Dockerfile、yml 部署文件完整呈现服务端与 Web 界面结构。已有 1420 人学习下载可据此理解 NVR 的存储设计、时间范围检索与容器化运行方式。1. 从一台总在半夜掉线的摄像头说起家里或小店里装了几路网络摄像头最让人头疼的往往不是画质而是录像到底存哪儿、能不能回看、断电重启后还认不认账。Moonfire NVR 就是冲着这个场景来的一个用 Rust 写的网络视频录像机专门对接 RTSP 网络摄像头把视频流按时间段切成可检索的录像片段落盘再配一个浏览器界面做实时预览和历史回放。它不依赖云服务录像全在本地适合想自己掌控数据、又不想被商业 NVR 盒子绑死的开发者。技术栈上后端是 Rust前端是 JavaScript摄像头侧走 RTSP存储侧用分片文件加 SQLite 索引整体偏工程化、偏可维护不是那种拼凑出来的玩具项目。下面按「能干什么、怎么跑、怎么改」的顺序拆。2. 架构与目录一个 RTSP 流如何变成可回放的录像2.1 整体数据流从 RTSP 拉流到分片落盘Moonfire NVR 的核心链路可以概括成四段拉流、解复用、编码转换可选、分片写入。它启动后为每路摄像头建立一个采集任务通过 RTSP 协议向摄像头请求视频流拿到的是 RTP 包解出 H.264/H.265 的裸流后按固定时长常见做法是每片几分钟切成独立的录像文件同时把每个片段的起止时间、摄像头编号、文件路径写进 SQLite。回放时前端根据时间范围查 SQLite 拿到片段列表再按顺序请求这些文件浏览器端做拼接播放。这个设计的关键取舍是「分片 索引」而不是「一个大文件 偏移表」。分片的好处是写入简单、删除过期录像只需删文件加删记录坏处是片段边界会有轻微的时间对齐问题。项目里用 SQLite 存元数据正是为了让「按时间检索」这件事足够快几万条片段记录也能毫秒级查出某段时间有哪些文件。// 采集任务的核心循环示意按项目实际结构简化 // 每路摄像头一个 stream 任务循环拉流、切片、写库 async fn run_stream(camera_id: i32, rtsp_url: str, db: Db) - Result() { let mut session RtspSession::connect(rtsp_url).await?; let mut writer SegmentWriter::new(camera_id, db); loop { // 拉一个 RTP 包解出视频帧 let frame session.next_frame().await?; // 按时间戳判断是否该切新片段 if writer.should_rotate(frame.timestamp) { writer.finish_segment().await?; // 落盘 写 SQLite 记录 } writer.push(frame).await?; } }上面这段是采集主循环的骨架。camera_id是数据库里摄像头的唯一编号用来关联片段记录rtsp_url是摄像头地址通常形如rtsp://user:passhost:554/streamshould_rotate依据帧时间戳和配置的片段时长决定是否切片finish_segment负责把当前片段写完整并插入一条索引记录。真实代码里还要处理断流重连、时间戳回绕、磁盘写满这些边界但主干就是这个循环。2.2 目录结构后端、前端、数据库迁移各在哪拿到源码后先认目录不然改起来容易迷路。典型布局如下目录/文件作用server/Rust 后端主 crate采集、HTTP API、存储逻辑都在这里server/db/数据库 schema 与迁移脚本SQLite 表结构定义ui/前端源码JavaScript/TypeScript构建后由后端静态托管Cargo.tomlRust 依赖与 workspace 配置package.json前端依赖与构建脚本后端是主体采集任务、HTTP 接口、鉴权、存储管理都在server/下按模块拆分。数据库迁移单独放意味着升级版本时表结构变更走迁移脚本不会让你手动改库。前端独立构建产物丢给后端托管所以部署时通常先构建前端再启动后端。2.3 存储与索引SQLite 表结构决定了回放体验回放能不能秒开取决于索引表怎么设计。项目里核心是两张表摄像头表和录像片段表。片段表至少要有摄像头编号、起始时间、结束时间、文件路径、编码格式这几个字段起始和结束时间建索引前端按时间范围查询时才能走索引而不是全表扫。-- 录像片段索引表示意结构 CREATE TABLE recording ( id INTEGER PRIMARY KEY, camera_id INTEGER NOT NULL, start_time INTEGER NOT NULL, -- 微秒时间戳 end_time INTEGER NOT NULL, file_path TEXT NOT NULL, codec TEXT NOT NULL ); CREATE INDEX idx_recording_time ON recording (camera_id, start_time, end_time);start_time和end_time用整数微秒存避免浮点误差联合索引把camera_id放前面因为查询几乎总是「某路摄像头 某时间段」。这个索引设计直接决定了回放页拖动时间轴时的响应速度改存储逻辑时别轻易动它。3. 跑起来从编译到看到第一帧画面3.1 环境准备与依赖安装Rust 项目第一步永远是工具链。装好rustup后确认版本项目一般要求较新的 stable。前端需要 Node.js 和包管理器。系统层面Rust 编译要 C 链接器部分依赖可能还要pkg-config和 OpenSSL 开发包。# 确认工具链 rustc --version cargo --version node --version # 常见系统依赖以 Debian 系为例 sudo apt-get install -y build-essential pkg-config libssl-devbuild-essential提供 gcc 和 makelibssl-dev给 TLS 相关依赖用。如果编译时报找不到openssl八成是缺这个开发包而不是 Rust 代码问题。3.2 编译后端与构建前端后端用cargo build --release首次编译会比较久因为依赖树不小。前端进ui/目录装依赖再构建产物一般输出到后端能识别的静态目录。# 编译后端 cargo build --release # 构建前端 cd ui npm install npm run build cd ..--release别省调试构建下视频处理性能差很多回放会卡。前端构建完确认产物目录里有index.html和打包后的 js/css后端启动时按配置的静态目录去找路径对不上就是白屏。3.3 初始化数据库与配置摄像头首次运行要先建库。项目通常提供初始化命令或首次启动自动迁移。配置里要填摄像头信息编号、RTSP 地址、期望的编码参数。RTSP 地址里的用户名密码注意转义密码含特殊字符时容易解析失败。# 初始化数据库具体子命令以实际 CLI 为准 ./target/release/moonfire-nvr init --db-dir /var/lib/moonfire # 启动服务 ./target/release/moonfire-nvr run --db-dir /var/lib/moonfire --http-addr 0.0.0.0:8080--db-dir指向数据目录SQLite 文件和录像分片都放这里生产环境建议单独挂一块盘。--http-addr是监听地址局域网访问填0.0.0.0只本机用就127.0.0.1。启动后浏览器打开对应端口登录进配置页添加摄像头填 RTSP 地址保存后等几十秒应该能看到实时画面和第一段录像。提示摄像头编码优先选 H.264兼容性和 CPU 开销都更稳H.265 省带宽但部分浏览器回放要转码机器弱的时候会拖慢整个服务。4. 核心模块拆解采集、切片与 HTTP 接口4.1 采集模块断流重连与时间戳处理采集模块最容易翻车的地方不是拉流本身而是异常恢复。摄像头会因为网络抖动、供电波动、固件重启而断流采集任务必须能自己重连而不是让整个服务挂掉。常见做法是外层套一个重试循环指数退避连续失败到阈值才标记该路摄像头异常。// 带退避的重连外壳 async fn supervise(camera_id: i32, url: String, db: Db) { let mut backoff Duration::from_secs(1); loop { match run_stream(camera_id, url, db).await { Ok(_) backoff Duration::from_secs(1), // 正常结束重置退避 Err(e) { eprintln!(camera {} stream error: {}, camera_id, e); tokio::time::sleep(backoff).await; // 退避翻倍封顶 30 秒避免疯狂重连打爆摄像头 backoff (backoff * 2).min(Duration::from_secs(30)); } } } }backoff从 1 秒开始翻倍、封顶 30 秒是为了既快速恢复又不把摄像头打挂。run_stream返回Err时不要直接panic否则一路摄像头故障会拖垮整个进程。时间戳方面摄像头重启后时间戳可能归零切片逻辑要能识别回绕否则会生成一个时间跨度巨大的坏片段。4.2 切片写入文件命名与原子落盘切片写入要保证「要么完整可见要么不存在」否则回放时会读到半截文件。常见做法是先写临时文件写完fsync再原子重命名成正式文件名最后才写数据库记录。顺序很重要先文件后记录崩在中间最多留个孤儿文件不会出现「有记录没文件」的坏索引。async fn finish_segment(mut self) - Result() { self.file.flush().await?; self.file.sync_all().await?; // 确保数据落盘 let final_path self.tmp_path.with_extension(mp4); tokio::fs::rename(self.tmp_path, final_path).await?; // 原子重命名 // 文件就绪后再写索引顺序不能反 self.db.insert_recording(self.camera_id, self.start, self.end, final_path).await?; Ok(()) }sync_all是血泪经验不调用它断电后可能文件长度对但内容缺失。rename在同一文件系统内是原子的所以临时文件和正式文件必须放同一分区。数据库插入放最后保证索引永远指向真实存在的完整文件。4.3 HTTP 接口回放查询与静态资源托管前端回放靠一个按时间范围查片段的接口。接口接收摄像头编号和起止时间返回片段列表前端再逐个请求视频文件。后端同时托管前端静态资源所以一个端口就能跑完整套。// 前端按时间范围拉片段列表示意 async function loadSegments(cameraId, startUs, endUs) { const resp await fetch( /api/cameras/${cameraId}/recordings?start${startUs}end${endUs} ); if (!resp.ok) throw new Error(query failed: ${resp.status}); const list await resp.json(); // 按 start_time 排序后交给播放器拼接 return list.sort((a, b) a.start_time - b.start_time); }startUs/endUs用微秒和后端表结构对齐避免前后端单位不一致导致查不到数据。返回列表按起始时间排序播放器才能顺序拼接。接口失败时把状态码带出来排查时一眼能看出是鉴权问题还是参数问题。5. 避坑与排查那些让我半夜爬起来的问题5.1 现象页面能打开但实时画面一直转圈原因通常是 RTSP 地址或编码不匹配。摄像头地址填错、密码含特殊字符没转义、或者摄像头输出 H.265 而浏览器不支持直接播都会表现为「连上了但没画面」。解决先用命令行工具单独验证 RTSP 地址能拉到流确认编码格式必要时在配置里开启转码或换摄像头的子码流一般是 H.264。5.2 现象录像文件在但回放时间轴是空的这是索引和文件不一致。多半是写入时先写了数据库记录、文件却没落盘成功或者手动删了文件没删记录。解决检查数据库里片段记录的file_path是否真实存在写一段校验脚本扫一遍孤儿记录和孤儿文件清理掉。根治办法是坚持「先文件后记录」的写入顺序。5.3 现象跑几天后磁盘满、服务变慢没有配置录像保留策略。分片文件会一直累积SQLite 也会变大。解决配置按天数或按磁盘占用上限的清理任务定期删过期片段和对应记录。清理时先删记录再删文件避免回放时查到已删文件。5.4 现象多路摄像头同时开CPU 飙满常见于多路高分辨率流同时做转码或软件解码。解决优先让摄像头直出浏览器可播的编码减少服务端转码降低帧率或分辨率确认编译用的是--release。机器实在弱就减少同时采集的路数。注意改数据库 schema 前先备份db-dir迁移脚本跑失败时没有后悔药。6. 二次开发从改一个查询到加一路自定义处理想在 Moonfire NVR 上做二次开发最稳的切入点是「只加不改核心链路」。比如想给录像加自定义元数据人形检测标记、自定义标签不要动采集和切片逻辑而是在片段落盘后挂一个后处理钩子读刚写完的文件、跑你的分析、把结果写进一张新表用recording_id关联。这样核心录像链路保持稳定你的扩展出问题也不影响录像本身。// 片段完成后的扩展钩子示意 async fn on_segment_finished(recording_id: i64, path: Path, db: Db) - Result() { // 自定义分析失败不影响主流程 if let Ok(tags) analyze_segment(path).await { db.insert_tags(recording_id, tags).await?; } Ok(()) }recording_id是主表自增主键analyze_segment是你的分析函数返回标签列表。关键点是这个钩子里的错误要吞掉或只记日志绝不能让它把采集任务带崩。验证方法也简单故意让分析函数返回错误确认录像仍然正常写入、回放不受影响再放开真实逻辑。另一个常见改造是换存储后端比如把分片写到对象存储。这时候要改的是SegmentWriter的落盘部分和回放时的文件读取路径索引表结构基本不用动因为file_path字段可以存对象 key。改完用「写入一段、立刻按时间查、再读回文件比对哈希」这条链路验证一遍比看日志靠谱。我自己的习惯是任何动到采集或写入顺序的改动都先在单路摄像头上跑满 24 小时确认没有孤儿文件、没有索引错位再上多路。从那以后我每次改存储相关代码都强制走一遍「断电模拟 重启回放」的验证宁可慢一点也别让录像丢在半路。希望帮到你。本文还有配套的精品资源点击获取