Complete product requirements document covering: - Transparent SMB/NFS/WebDAV cache proxy with rclone VFS - SD card ingest + auto archive pipeline for photographers - Three-timestamp consistency model with write-back controller - Time Machine backup target with independent sparsebundle sync - Layered SFTP polling for remote change detection - Cache space protection and import state machine - Paid services: Headscale + DERP relay, cloud disaster backup - Hardware appliance roadmap (v1.0 MVP → v3.0 hardware product) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1538 lines
77 KiB
Markdown
1538 lines
77 KiB
Markdown
# NAS Cache Proxy — 产品方案与需求描述(v3)
|
||
|
||
---
|
||
|
||
## 一、产品定位
|
||
|
||
**一句话描述**:摄影师的随身存储中枢——外拍插卡自动归档,路上缓存加速访问,全程高速组网,云端容灾兜底。
|
||
|
||
**核心价值**:
|
||
|
||
1. **远程访问加速**:用户在外通过 Tailscale 等组网工具访问家中 NAS 时,受限于公网带宽,SMB 协议体验极差(卡顿、超时、缩略图加载慢)。本产品在客户端侧部署一层 SSD 缓存,对上层应用(Lightroom、Finder、Explorer 等)完全透明,首次访问按需拉取并缓存,后续访问直接命中本地 SSD,写入先落本地再异步回写远程。
|
||
2. **外拍现场备份归档**:摄影师外拍结束插入 SD 卡,一键备份到本地 SSD,后台自动异步归档回家中 NAS。把「现场备份」和「远程归档」打通成一条自动流水线,市面上没有产品做到这一点。
|
||
3. **数据安全兜底**:支持 macOS Time Machine 备份目标 + 可选的云端异地容灾,为用户的数据提供多层保护。
|
||
|
||
**产品形态**:
|
||
- **软件方案**(MVP):配置文件 + 一键部署脚本,部署在任意 Linux 主机上(Docker 镜像在 v2.5 提供)
|
||
- **硬件一体机**(目标形态):定制盒子,内置 SSD + 电池 + SD 卡槽 + WiFi,开箱即用
|
||
|
||
**市场机会**:
|
||
- Gnarbox(曾最受欢迎的摄影师外拍备份设备)已停产,市场空缺明显
|
||
- UnifyDrive UT2($599)硬件形态相似但软件体验差、电池仅 1 小时
|
||
- ClouZen TAINER 功能单一,只能备份不能联网同步
|
||
- **没有产品把「现场备份」和「远程归档回 NAS」打通成自动流水线**
|
||
|
||
---
|
||
|
||
## 二、目标用户
|
||
|
||
| 用户画像 | 典型场景 | 痛点 |
|
||
|----------|----------|------|
|
||
| 摄影师 | 出差/外拍,酒店回看、粗修当天照片(Lightroom) | RAW 文件 25-60MB,SMB 远程逐张打开极慢,预览生成卡死 |
|
||
| 视频创作者 | 远程剪辑,浏览素材库、拖拽代理文件 | 视频文件更大,顺序播放需持续带宽 |
|
||
| 设计师 | 出差访问公司 NAS 上的 PSD/AI 源文件 | 大文件 + 多图层,打开一个文件几分钟 |
|
||
| 远程办公族 | 日常办公文档、项目资料存 NAS | 小文件多,SMB 目录浏览延迟高,体验卡顿 |
|
||
| NAS 重度用户 | 旅行途中访问个人数据 | 没有公网 IP 或带宽不足,现有方案都不理想 |
|
||
|
||
**核心用户优先级**:摄影师(Lightroom + 外拍备份)> 远程办公 > 视频创作者
|
||
|
||
---
|
||
|
||
## 三、系统架构
|
||
|
||
### 3.1 整体架构
|
||
|
||
```
|
||
局域网 (高速) 公网 / Tailscale (低速)
|
||
┌──────────────────────────────┐ ┌──────────────────────┐
|
||
│ │ │ │
|
||
┌──────────┐ SMB │ ┌─────────────┐ │ SFTP │ ┌────────────────┐ │
|
||
│ Lightroom│──────┼─▶│ Samba Server│ │ │ │ │ │
|
||
│ macOS │ │ └──────┬──────┘ │ │ │ 任意品牌 NAS │ │
|
||
└──────────┘ │ │ │ │ │ (SFTP) │ │
|
||
│ ▼ │ │ │ │ │
|
||
┌──────────┐ NFS │ ┌─────────────────────────┐ │ ┌────────┐│ └────────────────┘ │
|
||
│ Linux │──────┼─▶│ │─┼▶│Tailscal││ │
|
||
│ 客户端 │ │ │ rclone VFS mount │ │ │/WireGrd││ │
|
||
└──────────┘ │ │ + Write-back Controller│ │ └────────┘│ │
|
||
│ │ │ │ │ │
|
||
┌──────────┐WebDAV│ └────────────┬────────────┘ │ │ │
|
||
│ iPad │──────┼─▶ │ │ │ │
|
||
│ 移动端 │ │ ┌────────────▼────────────┐ │ │ │
|
||
└──────────┘ │ │ SSD 缓存 + 元数据 DB │ │ │ │
|
||
│ │ (btrfs/ZFS) │ │ │ │
|
||
│ └─────────────────────────┘ │ │ │
|
||
│ Linux Proxy │ │ │
|
||
└──────────────────────────────┘ └──────────────────────┘
|
||
```
|
||
|
||
### 3.2 协议选择说明
|
||
|
||
| 段 | 协议 | 原因 |
|
||
|----|------|------|
|
||
| 客户端 → Proxy(主) | SMB | 对 Lightroom/macOS/Windows 原生兼容,应用无感 |
|
||
| 客户端 → Proxy(辅) | NFS | Linux 客户端性能更好,内核级支持 |
|
||
| 客户端 → Proxy(辅) | WebDAV | 移动端 App 支持广泛 |
|
||
| Proxy → NAS | SFTP | 高延迟链路下比 SMB 稳定得多,任意品牌 NAS 均支持,无需额外套件 |
|
||
|
||
### 3.3 多协议对外服务(设计讨论)
|
||
|
||
**问题**:客户端 → Cache Proxy 之间只支持 SMB 是否足够?
|
||
|
||
**讨论**:不同客户端设备对协议的偏好不同。macOS + Lightroom 最适合 SMB,但 Linux 客户端用 NFS 性能更好(内核级支持,且 Linux 侧还能再叠一层 FS-Cache),iPad/移动端 App 则普遍支持 WebDAV。
|
||
|
||
**设计决策**:所有对外协议服务共享同一个 rclone FUSE 挂载点。缓存层只有一份,不会因为多协议而重复缓存。
|
||
|
||
```
|
||
SMB Server ───┐
|
||
NFS Server ───┼──▶ /mnt/nas-photos (rclone FUSE mount) ──▶ SSD 缓存
|
||
WebDAV ───┘ 唯一缓存层
|
||
```
|
||
|
||
**注意事项**:SMB 和 NFS 的文件锁机制不同,同一文件不建议多协议同时写入。产品层面通过文档告知用户"多协议是为不同设备类型服务,非同时并发写同一文件"。
|
||
|
||
---
|
||
|
||
## 四、核心功能
|
||
|
||
### P0(MVP 必须)
|
||
|
||
#### 4.1 透明多协议代理
|
||
- 对外暴露标准 SMB 共享,客户端连接方式与直连 NAS 完全一致
|
||
- 支持 SMB2/SMB3 协议
|
||
- 同时支持 NFS 导出(Linux 客户端)和 WebDAV 服务(移动端)
|
||
- 支持 macOS(Finder/Lightroom)、Windows(Explorer)、Linux、移动端客户端
|
||
- 文件读写、目录浏览、文件属性(时间戳/权限)均正常工作
|
||
- 所有协议共享同一个缓存层,不重复存储
|
||
|
||
#### 4.2 读缓存(Read-through Cache)
|
||
- 文件首次被访问时,从远程 NAS 拉取并存入本地 SSD 缓存
|
||
- 后续访问同一文件直接从本地 SSD 返回
|
||
- 支持分块读取(chunked read):大文件不需要整个下载完才能开始读取
|
||
- 支持预读(read-ahead):顺序读取场景下提前拉取后续数据
|
||
- 目录列表缓存:目录结构缓存一段时间,避免频繁远程查询
|
||
|
||
#### 4.3 写回缓存(Write-back Cache)
|
||
- 文件写入先落本地 SSD
|
||
- 文件关闭后延迟一段时间再异步回写远程 NAS
|
||
- 频繁写入(如 Lightroom catalog 自动保存)自动合并,避免重复传输
|
||
- 回写前必须检查远程状态,不盲写(详见第五章一致性模型)
|
||
- 回写失败自动重试(指数退避)
|
||
|
||
#### 4.4 数据一致性保证
|
||
- 基于三时间戳模型保证最终一致性(详见第五章)
|
||
- 元数据持久化到 SSD,重启后可恢复所有状态继续回写
|
||
- 回写前检查远程 mtime,防止覆盖更新数据
|
||
- 远程删除感知,防止已删文件被回写复活
|
||
- 写冲突检测与冲突副本保留
|
||
|
||
#### 4.5 远程变更检测
|
||
- 基于 SFTP 的分层轮询机制,自动发现远程数据变化(详见第六章)
|
||
- 不依赖任何 NAS 品牌特有 API,纯 SFTP 协议实现
|
||
- 每日全量校对兜底
|
||
|
||
#### 4.6 缓存空间管理
|
||
- 设置缓存总大小上限
|
||
- 超出上限时按 LRU(最久未访问)策略自动淘汰
|
||
- 可设置缓存盘最低保留空间,防止磁盘写满
|
||
- 可设置缓存最大保留时间
|
||
- 脏文件(未回写)永不被淘汰
|
||
|
||
#### 4.7 一键部署
|
||
- 提供完整的配置文件 + 部署脚本
|
||
- 自动安装依赖(rclone、Samba、NFS、fuse)
|
||
- 自动配置 systemd 服务,开机自启
|
||
- 自动配置日志轮转
|
||
- 缓存盘文件系统建议 btrfs/ZFS(CoW + journal 保护一致性)
|
||
|
||
### P1(重要但非 MVP)
|
||
|
||
#### 4.8 SD 卡导入 + 自动归档(Ingest)
|
||
|
||
摄影师外拍结束,把 SD 卡插入盒子,一键备份到本地 SSD,后台利用已有的 write-back 引擎异步上传回家中 NAS。
|
||
|
||
- 检测到 SD/CFexpress 卡插入后,支持物理按钮一键触发导入或自动导入模式
|
||
- **导入前空间检查**:估算 SD 卡总数据量,检查 SSD 可用空间(扣除已有 dirty 文件 + CACHE_MIN_FREE),空间不足时拒绝导入并通知用户
|
||
- 复制文件**通过 rclone FUSE 挂载点写入**(而非直接写入 VFS 缓存目录),确保 rclone 能正确跟踪这些文件(详见 5.8.1 架构讨论)
|
||
- 导入时计算文件 checksum(SHA-256),确保数据完整
|
||
- 导入的文件在 metadata.db 中标记为 `state=dirty, origin_mtime=NULL`(表示从未在远程存在)
|
||
- Write-back Controller 自动接管,拦截 rclone 的原生回写,按自定义逻辑(INGEST_TARGET_PATH 模板)决定 NAS 上的目标路径并上传,无网络时排队等待
|
||
- 支持重复文件检测(基于文件名+大小+checksum),同时查询当前 cache_files 和 import_history 持久表(详见 5.7.6),避免重复导入已回写到 NAS 但被 LRU 淘汰的文件
|
||
- 导入完成通过 LED 指示灯 / 蜂鸣器提示
|
||
- 支持按日期模板自动组织目标路径(如 `/{year}/{month}/{date}/`),日期来源为 EXIF 拍摄日期,非 EXIF 文件回退到文件 mtime(详见 INGEST_DATE_SOURCE 配置)
|
||
- **导入中断保护**:导入过程维护状态机(`detecting → copying → checksumming → registered → complete`),中断的文件(未完成 checksum 校验)将被清理而非作为 dirty 文件进入缓存(详见 5.9)
|
||
|
||
#### 4.9 双卡备份 + 校验
|
||
|
||
摄影师同时插入两张 SD 卡(或一张 SD 卡 + 一个 USB 移动硬盘),盒子自动做双向 checksum 比对,确保两份备份完全一致。
|
||
|
||
- 并行读取两个存储设备的文件列表
|
||
- 逐文件比对:文件名 + 大小一致后计算 SHA-256 比对
|
||
- 不一致的文件标记为异常并通知用户
|
||
- 仅一侧存在的文件单独提示
|
||
- 校验完成输出报告(LED 状态 + CLI 可查详情)
|
||
- 校验通过的文件自动进入 4.8 导入归档流程
|
||
|
||
#### 4.10 Time Machine 备份目标
|
||
|
||
利用 Samba 原生 Time Machine 支持,让 macOS 用户的 Mac 出差时也有本地备份兜底。
|
||
|
||
- Samba 配置开启 `fruit:time machine = yes`
|
||
- **Time Machine 使用独立目录**(`/mnt/ssd/nas-cache/timemachine/`),不通过 rclone VFS 管理,避免 sparsebundle 的大量小文件干扰 rclone 缓存逻辑
|
||
- Time Machine 备份写入 SSD 的 `timemachine/` 目录
|
||
- **独立的 TM 回写引擎**异步归档到 NAS 的 `TIMEMACHINE_PATH` 目录(与通用 Write-back Controller 分离,详见下文)
|
||
- `TIMEMACHINE_MAX_SIZE` 作为硬配额强制执行(通过 smb.conf 的 `fruit:time machine max size` 参数),防止占满缓存盘
|
||
- 几乎零 Samba 配置成本,但 TM 回写引擎需要少量开发
|
||
|
||
**Time Machine 回写策略(sparsebundle 特殊处理)**:
|
||
|
||
Time Machine 使用 sparsebundle 格式(一个目录包含数千个 8MB band 文件)。通用回写引擎的 60s 延迟+写入合并机制不适用于此场景:
|
||
- TM 备份持续 10-30 分钟,持续写入不同 band 文件,通用计时器会不断重置
|
||
- 每次 TM 备份只修改部分 band 文件,不需要传输整个 sparsebundle
|
||
|
||
```
|
||
TM 回写策略:
|
||
① TM 备份会话结束检测:监控 sparsebundle 目录,连续 5 分钟无新写入 → 视为会话结束
|
||
② band 文件级增量同步:rsync/rclone sync 只传输 mtime 变化的 band 文件到 NAS
|
||
③ 目标路径:TIMEMACHINE_PATH(NAS 上独立目录,如 /volume1/timemachine/)
|
||
④ 不走通用 Write-back Controller 的三时间戳冲突检测(TM 数据只有一个写入源)
|
||
```
|
||
|
||
**TIMEMACHINE_PATH 与 NAS_REMOTE_PATH 的关系**:两者是 NAS 上的不同目录,互不关联。NAS_REMOTE_PATH 是用户的照片/文件目录(如 `/volume1/photos`),TIMEMACHINE_PATH 是 TM 备份专用目录(如 `/volume1/timemachine`)。TM 回写通过独立的 SFTP 连接直接传输到 TIMEMACHINE_PATH,不经过 rclone VFS。
|
||
|
||
#### 4.11 缓存预热(Warm-up)
|
||
- 命令行手动预热指定目录
|
||
- 按时间范围预热(如"最近 7 天新增的文件")
|
||
- 定时预热任务(如每天凌晨自动拉取最新数据)
|
||
- 预热进度显示
|
||
|
||
#### 4.12 管理工具(CLI)
|
||
- `nas-cache status` — 查看服务状态、缓存命中率、回写队列、冲突文件数
|
||
- `nas-cache cache-list` — 列出缓存中的文件
|
||
- `nas-cache cache-clean` — 清理缓存
|
||
- `nas-cache warmup` — 手动预热
|
||
- `nas-cache bwlimit` — 动态调整带宽限制
|
||
- `nas-cache conflicts` — 查看和处理冲突文件
|
||
- `nas-cache ingest` — 手动触发 SD 卡导入
|
||
- `nas-cache verify` — 双卡校验
|
||
- `nas-cache log` — 查看实时日志
|
||
- `nas-cache speed-test` — 链路速度测试
|
||
|
||
#### 4.13 带宽管理
|
||
- 支持上传/下载分别限速
|
||
- 运行时动态调整限速(不重启服务)
|
||
- 回写带宽不影响读取体验
|
||
|
||
#### 4.14 连接容错
|
||
- Tailscale 断连时自动重试
|
||
- 已缓存的文件在离线时仍可正常读取
|
||
- 写回队列在恢复连接后自动续传
|
||
- 连接超时参数可配置
|
||
|
||
#### 4.15 写冲突通知
|
||
- 冲突发生时通知用户(CLI 提示 / 日志 / 可选 Webhook)
|
||
- 冲突文件清单管理
|
||
- 手动解决冲突工具
|
||
|
||
### P2(后续迭代)
|
||
|
||
#### 4.16 WiFi AP 现场共享
|
||
|
||
盒子内置 WiFi 模块开启热点,现场团队设备连上即可通过 SMB/WebDAV 访问缓存目录。
|
||
|
||
- 支持 AP 模式,复用已有的 SMB/WebDAV 多协议服务
|
||
- AP 网络与 Tailscale/WAN 网络隔离(安全考虑)
|
||
- AP 模式下仍可同时通过有线/4G 连接 Tailscale 做后台回写
|
||
- **硬件要求**:需要两个独立网络接口——WiFi 模块用于 AP 热点,有线/USB 4G 网卡用于 WAN/Tailscale 连接。一体机硬件设计需预留双网卡
|
||
- 典型场景:婚礼现场摄影师导入 SD 卡后,助理 iPad 连上 WiFi 即可浏览选片
|
||
|
||
#### 4.17 Web 管理界面
|
||
- 缓存状态仪表盘(大小、命中率、回写队列、冲突文件、带宽趋势图)
|
||
- 缓存文件浏览器(查看/手动清除/手动预热)
|
||
- 配置修改界面(参数调整无需编辑配置文件)
|
||
- 冲突文件可视化处理
|
||
- 实时日志查看器
|
||
|
||
#### 4.18 NAS 侧 Agent 推送(可选增强)
|
||
- 在 NAS 上运行轻量 Agent(Docker 容器),监听文件变化主动推送给 Proxy
|
||
- 实现秒级远程变更感知(替代分钟级轮询)
|
||
- 不依赖品牌 API,基于 inotify 通用方案
|
||
|
||
#### 4.19 多 NAS / 多目录支持
|
||
- 同时连接多个远程 NAS(如家里 + 工作室)
|
||
- 每个 NAS 独立共享名,独立缓存策略
|
||
- 每个共享可配置不同的缓存大小和保留时间
|
||
|
||
#### 4.20 智能缓存策略
|
||
- 根据文件类型自动调整策略:
|
||
- `.lrcat` / `.xmp`(Lightroom catalog/sidecar)→ 高优先级回写,短写回延迟
|
||
- `.CR3` / `.ARW` / `.NEF`(RAW 照片)→ 大块预读,长缓存保留
|
||
- `.mp4` / `.mov`(视频)→ 顺序预读优化
|
||
- `.psd` / `.ai`(设计文件)→ 完整缓存,避免分块导致的兼容问题
|
||
- 基于访问频率自动调整缓存优先级(热数据不被淘汰)
|
||
|
||
#### 4.21 Docker 镜像
|
||
- 一行命令启动:`docker run -v /mnt/ssd:/cache nas-cache-proxy`
|
||
- docker-compose 配置
|
||
- 支持环境变量或挂载配置文件
|
||
|
||
#### 4.22 通知机制
|
||
- 回写失败告警(Webhook / Telegram / 邮件)
|
||
- 缓存空间不足告警
|
||
- NAS 离线告警
|
||
- 写冲突告警
|
||
- 回写完成通知(可选)
|
||
|
||
---
|
||
|
||
## 五、数据一致性模型
|
||
|
||
### 5.1 设计目标
|
||
|
||
采用**最终一致性(Eventual Consistency)**模型。具体承诺:
|
||
|
||
1. 所有成功写入本地缓存的数据最终会同步到远程 NAS(断电恢复靠硬件 UPS 保证)
|
||
2. 远程 NAS 上的变更会在可控时间内被 Proxy 感知并更新本地缓存
|
||
3. 写冲突可检测、可追溯,不会静默丢数据
|
||
|
||
### 5.2 设计讨论与决策过程
|
||
|
||
#### 问题 A:写回中断(断电/crash)是否会丢数据?
|
||
|
||
**讨论**:rclone 的 write-back 机制下,文件先写入本地 SSD 缓存,延迟回写到远程。如果在回写之前 Proxy 断电,未回写的脏数据面临丢失风险。
|
||
|
||
**考虑过的方案**:
|
||
- 方案 1:软件层 Write-Ahead Log(WAL),写入前先写日志再写数据,恢复时重放
|
||
- 方案 2:选用支持 Power Loss Protection 的企业级 SSD
|
||
- 方案 3:缓存盘用 btrfs/ZFS,利用 CoW + journal 保证文件系统一致性
|
||
- 方案 4:硬件内置电池(类似 UPS),保证断电后有时间 flush
|
||
|
||
**最终决策**:硬件层面通过内置电池/UPS 保证断电安全,软件层面不做额外的 WAL。原因是硬件方案最简单可靠,且后续做硬件产品时电池是自然的组成部分。软件层面只需保证**重启后能正确识别脏文件并继续回写**即可。
|
||
|
||
rclone 的 VFS cache 目录本身维护了元数据,重启后会自动发现未同步的脏文件并继续回写,满足此需求。
|
||
|
||
#### 问题 B:写冲突如何处理?谁来比较时间?
|
||
|
||
**讨论**:核心场景是 Cache 在外改了文件但还没回写,NAS 侧也被别人改了同一个文件。如果 Cache 关机几天后重启,盲目回写会覆盖 NAS 上的新版本。
|
||
|
||
反过来,如果 NAS 上删了文件,Cache 重启后发现本地有脏文件,盲目回写会把已删文件复活。
|
||
|
||
**考虑过的方案**:
|
||
- 方案 1:Last-write-wins 盲写(简单但会丢数据)
|
||
- 方案 2:基于 mtime 比较的 last-write-wins(有保护的覆盖)
|
||
- 方案 3:全版本保留(两边都存,用户手动选)
|
||
- 方案 4:基于 vector clock 的分布式一致性(过于复杂)
|
||
|
||
**最终决策**:采用方案 2——基于 mtime 的 last-write-wins,配合冲突副本保留。由 Proxy 侧的 **Write-back Controller** 负责在回写前查询远程 mtime 并做比较决策。
|
||
|
||
### 5.3 三时间戳模型
|
||
|
||
每个缓存文件维护三个关键时间戳:
|
||
|
||
```
|
||
origin_mtime ── 文件从远程拉下来时(或上次成功回写后)远程的 mtime
|
||
含义:上次同步时远程的状态,是判断"远程是否变了"的基准线
|
||
|
||
cache_mtime ── 本地缓存文件当前的 mtime
|
||
含义:本地版本的时间,如果 != origin_mtime 则说明本地改过
|
||
|
||
remote_mtime ── 回写/读取时实时查询远程的当前 mtime
|
||
含义:远程此刻的真实状态
|
||
```
|
||
|
||
状态推导:
|
||
|
||
```
|
||
本地是否修改过:
|
||
origin_mtime = NULL → 从未在远程存在(SD 卡导入的新文件),始终视为 dirty
|
||
cache_mtime == origin_mtime → 干净(clean)
|
||
cache_mtime != origin_mtime → 脏(dirty),有未回写的本地修改
|
||
|
||
远程是否变化过:
|
||
origin_mtime = NULL → 不适用(文件从未同步过远程,回写逻辑见 5.4b)
|
||
remote_mtime == origin_mtime → 远程没变(自上次同步后)
|
||
remote_mtime != origin_mtime → 远程被别人改了
|
||
远程文件不存在 → 远程被删了
|
||
```
|
||
|
||
**注意**:虽然 cache_files 表中有显式的 `state` 字段存储状态(clean/dirty/conflict),运行时依赖 `state` 字段即可。上述推导规则用于状态字段的正确性校验和初始化逻辑。`origin_mtime = NULL` 的记录 `state` 必须为 `dirty`(或 `conflict`),不可能为 `clean`。
|
||
|
||
### 5.4 回写决策矩阵
|
||
|
||
当 Write-back Controller 准备回写一个脏文件时,先通过 SFTP stat 查询远程 mtime,然后按以下矩阵决策:
|
||
|
||
```
|
||
远程没变 远程也改了 远程被删了
|
||
(remote == origin) (remote != origin) (文件不存在)
|
||
┌────────────────────┬───────────────────────┬──────────────────┐
|
||
│ │ │ │
|
||
│ ✅ 正常回写 │ ⚠️ 比较 mtime │ ⚠️ 不回写 │
|
||
│ │ │ │
|
||
本地改过 │ 远程自上次同步后 │ cache > remote: │ 尊重远程删除 │
|
||
(dirty) │ 没人动过 │ → 本地更新,回写 │ 本地脏文件移到 │
|
||
│ 安全回写 │ remote > cache: │ conflict 目录 │
|
||
│ 更新 origin_mtime │ → 远程更新,远程胜 │ 通知用户 │
|
||
│ │ → 拉远程新版本 │ │
|
||
│ │ → 本地存冲突副本 │ │
|
||
│ │ 相等: │ │
|
||
│ │ → 内容可能不同 │ │
|
||
│ │ → 本地胜(写入者优先)│ │
|
||
└────────────────────┴───────────────────────┴──────────────────┘
|
||
```
|
||
|
||
### 5.4b SD 卡导入文件的回写决策(补充)
|
||
|
||
从 SD 卡导入的文件,本质上等同于「本地新创建的脏文件」。在 metadata.db 的 cache_files 表中,`origin_mtime = NULL` 表示此文件从未存在于远程 NAS。
|
||
|
||
```
|
||
origin_mtime = NULL(SD 卡导入的新文件)
|
||
│
|
||
├── SFTP stat 查远程:文件不存在
|
||
│ → 直接上传(新建),更新 origin_mtime,state → clean
|
||
│
|
||
├── SFTP stat 查远程:文件已存在(说明之前某次导入已成功上传,或远程碰巧有同名文件)
|
||
│ → 比较 cache_mtime vs remote_mtime
|
||
│ → 按 5.4 正常冲突逻辑处理
|
||
│
|
||
└── 上传失败
|
||
→ 重试(指数退避),保持 dirty 状态
|
||
```
|
||
|
||
### 5.5 读取时的缓存验证
|
||
|
||
当应用读取一个已缓存的文件时:
|
||
|
||
```
|
||
远程没变 远程也改了 远程被删了
|
||
(remote == origin) (remote != origin) (文件不存在)
|
||
┌────────────────────┬───────────────────────┬──────────────────┐
|
||
干净缓存 │ │ │ │
|
||
(clean) │ ✅ 直接用缓存 │ 🔄 拉新版本 │ 🗑️ 删除本地缓存 │
|
||
│ │ 更新 origin_mtime │ 返回文件不存在 │
|
||
├────────────────────┼───────────────────────┼──────────────────┤
|
||
本地改过 │ │ │ │
|
||
(dirty) │ ✅ 用本地版本 │ ⚠️ 标记冲突 │ ⚠️ 用本地版本 │
|
||
│ 等待回写 │ 两边版本都保留 │ 但标记为冲突 │
|
||
│ │ 通知用户 │ 通知用户 │
|
||
└────────────────────┴───────────────────────┴──────────────────┘
|
||
```
|
||
|
||
**注意**:读取时不是每次都查远程 mtime(那样太慢)。远程 mtime 信息由后台轮询线程定期更新(见第六章)。热路径上读取命中缓存直接返回,只有当轮询发现 mtime 变化时才触发重新验证。
|
||
|
||
### 5.6 关键场景走查
|
||
|
||
#### 场景 1:Cache 关机几天,NAS 上文件被修改
|
||
|
||
```
|
||
Day 1: Cache 缓存了 photo.cr3 (origin_mtime = Day1, 状态 clean)
|
||
Day 1: Cache 关机
|
||
|
||
Day 3: 有人在 NAS 上修改了 photo.cr3 (NAS mtime → Day3)
|
||
|
||
Day 5: Cache 开机
|
||
│
|
||
▼
|
||
rclone 启动,后台轮询线程开始工作
|
||
│
|
||
▼
|
||
轮询发现 photo.cr3 的 remote_mtime(Day3) != origin_mtime(Day1)
|
||
│
|
||
▼
|
||
文件是 clean 的(本地没改过)
|
||
│
|
||
▼
|
||
标记本地缓存失效
|
||
下次应用访问时拉新版本,更新 origin_mtime = Day3
|
||
│
|
||
▼
|
||
✅ 结果:用户读到 NAS 上的最新版本,符合预期
|
||
```
|
||
|
||
#### 场景 2:Cache 关机几天,本地有脏数据,NAS 也被更新
|
||
|
||
```
|
||
Day 1: 用户在 Cache 上修改了 photo.cr3
|
||
(origin_mtime = Day0, cache_mtime = Day1, 状态 dirty)
|
||
Day 1: 回写还没执行,Cache 关机(电池保护数据落盘)
|
||
|
||
Day 3: 家人在 NAS 上也修改了 photo.cr3 (NAS mtime → Day3)
|
||
|
||
Day 5: Cache 开机,rclone 恢复,发现本地有脏文件
|
||
│
|
||
▼
|
||
Write-back Controller 准备回写 photo.cr3
|
||
│
|
||
▼
|
||
回写前先 SFTP stat 查远程:remote_mtime = Day3
|
||
│
|
||
▼
|
||
remote_mtime(Day3) != origin_mtime(Day0) → 远程被改过!
|
||
│
|
||
▼
|
||
比较 cache_mtime(Day1) vs remote_mtime(Day3)
|
||
│
|
||
▼
|
||
Day3 > Day1 → 远程版本更新 → 远程胜
|
||
│
|
||
├── 本地脏版本 → 移到 /conflict/photo.cr3.local-20260216-Day1
|
||
├── 拉取远程新版本覆盖本地缓存
|
||
├── 更新 origin_mtime = Day3, 状态 → clean
|
||
└── 通知用户:"photo.cr3 存在冲突,远程版本更新已采用,本地修改已保存到冲突目录"
|
||
│
|
||
▼
|
||
✅ 结果:不会丢数据,两个版本都保留,用户自行决定
|
||
```
|
||
|
||
#### 场景 3:NAS 删了文件,Cache 上有脏数据
|
||
|
||
```
|
||
Day 1: 用户在 Cache 上修改了 photo.cr3 (dirty)
|
||
Day 1: Cache 关机
|
||
|
||
Day 3: 有人在 NAS 上删了 photo.cr3
|
||
|
||
Day 5: Cache 开机,准备回写脏文件
|
||
│
|
||
▼
|
||
Write-back Controller 查远程:photo.cr3 不存在
|
||
│
|
||
▼
|
||
远程文件被删 + 本地有未回写修改 → 冲突
|
||
│
|
||
├── ❌ 不回写(尊重远程删除决定)
|
||
├── 本地脏版本 → 移到 /conflict/photo.cr3.local-20260216
|
||
└── 通知用户:"photo.cr3 已在远程被删除,本地修改已保存到冲突目录"
|
||
│
|
||
▼
|
||
✅ 结果:不会把已删文件复活回 NAS,用户可从冲突目录恢复
|
||
```
|
||
|
||
#### 场景 4:NAS 删了文件,Cache 上是 clean 的
|
||
|
||
```
|
||
Day 1: Cache 缓存了 photo.cr3 (clean)
|
||
|
||
Day 3: NAS 上删了 photo.cr3
|
||
|
||
Day 3+: 后台轮询检测到远程目录变化
|
||
│
|
||
▼
|
||
photo.cr3 远程不存在 + 本地是 clean → 无争议
|
||
│
|
||
▼
|
||
直接删除本地缓存
|
||
│
|
||
▼
|
||
✅ 结果:本地缓存与远程保持一致
|
||
```
|
||
|
||
#### 场景 5:Cache 在外编辑文件,网络正常,NAS 无变化(最常见 happy path)
|
||
|
||
```
|
||
用户在酒店打开 Lightroom 修图
|
||
│
|
||
▼
|
||
首次打开 photo.cr3 → 缓存未命中 → 从远程下载 → 缓存到 SSD
|
||
(origin_mtime = T0, cache_mtime = T0, 状态 clean)
|
||
│
|
||
▼
|
||
用户编辑并保存
|
||
(cache_mtime = T1, 状态 dirty)
|
||
│
|
||
▼
|
||
60 秒后 Write-back Controller 触发
|
||
│
|
||
▼
|
||
查远程 mtime:remote_mtime == origin_mtime(T0) → 远程没变
|
||
│
|
||
▼
|
||
正常回写,更新 origin_mtime = T1,状态 → clean
|
||
│
|
||
▼
|
||
✅ 结果:编辑秒存本地,后台静默同步,用户无感
|
||
```
|
||
|
||
### 5.7 元数据持久化(metadata DB)
|
||
|
||
三时间戳和文件状态必须持久化到 SSD,确保重启不丢失。使用 SQLite 存储。
|
||
|
||
#### 5.7.1 设计讨论:metadata DB 应该包含什么?
|
||
|
||
**问题**:metadata DB 需要存 NAS 侧的所有文件元数据吗?
|
||
|
||
**分析**:NAS 上可能有几十万甚至上百万文件,但用户一次出差实际访问的可能只有几百到几千个。如果全量存储 NAS 文件元数据,会带来几个问题:
|
||
|
||
- 初始化成本高——首次使用需要递归扫描整个 NAS 目录树
|
||
- 存储浪费——大量从未访问的文件元数据没有价值
|
||
- 同步负担重——需要持续维护全量数据的一致性
|
||
|
||
**结论**:metadata DB **只管"跟缓存有交集的文件"**,不存全量 NAS 元数据。NAS 上有 10 万张照片,用户只打开过 500 张,那核心表就只有 500 行。
|
||
|
||
但有一个衍生问题:**怎么检测远程文件被删了?**
|
||
|
||
轮询发现某目录 mtime 变了,做 `sftp ls` 拿到当前远程文件列表,但要知道"哪个文件消失了",需要跟之前的列表比较。这引出了"是否需要额外存目录文件列表"的设计选择。
|
||
|
||
**方案推导**:
|
||
|
||
假设目录 `/2026/02/` 下有 200 张照片,用户只缓存了 3 张。
|
||
|
||
场景 A:NAS 上 IMG_0050.cr3 被删了(从未缓存过)
|
||
→ 跟缓存无关,不需要感知,不处理 ✅
|
||
|
||
场景 B:NAS 上 IMG_0001.cr3 被删了(缓存过)
|
||
→ `sftp ls` 结果里找不到 IMG_0001 → 但 cache_files 表里有它 → 检测到删除 ✅
|
||
|
||
场景 C:NAS 上新增了 IMG_0201.cr3,同时修改了 IMG_0050.cr3
|
||
→ IMG_0201 从没缓存过,不关心 ✅
|
||
→ IMG_0050 从没缓存过,不关心 ✅
|
||
→ 如果 IMG_0050 缓存过,cache_files 里有 origin_mtime 可以直接比 ✅
|
||
|
||
**关键洞察**:删除检测只需要用 `sftp ls` 结果去反查 cache_files 表——"cache_files 里有记录,但 sftp ls 结果里没有这个文件"即为远程删除。不需要额外维护一张完整的目录文件列表。
|
||
|
||
**最终决策(分阶段)**:
|
||
|
||
| 阶段 | 策略 | 表结构 | 理由 |
|
||
|------|------|--------|------|
|
||
| MVP (v1.0) | 精简模式 | cache_files + dir_snapshots(两张表) | 删除检测通过反查 cache_files 实现,逻辑简单 |
|
||
| v1.5+ | 完整模式 | 加 dir_file_list(三张表) | 记录关心目录下全部远程文件,支持精确变更类型识别和智能预热(如"自动缓存新增文件") |
|
||
|
||
#### 5.7.2 缓存目录结构
|
||
|
||
```
|
||
/mnt/ssd/nas-cache/
|
||
├── vfs/ # rclone VFS 缓存目录(rclone 内部管理,外部进程不直接写入)
|
||
│ └── photos/
|
||
│ ├── 2026/
|
||
│ │ └── 02/
|
||
│ │ ├── IMG_0001.cr3 # 远程拉取缓存
|
||
│ │ └── IMG_0002.cr3 # 或通过 FUSE 挂载点写入的 SD 卡导入文件
|
||
│ └── ...
|
||
├── metadata.db # SQLite 元数据库(WAL 模式,详见 5.7.7)
|
||
├── conflict/ # 冲突文件暂存目录
|
||
│ └── IMG_0001.cr3.local-20260216-143022
|
||
├── ingest_staging/ # SD 卡导入暂存目录(导入状态机使用,详见 5.9)
|
||
│ └── <session-id>/ # 每次导入会话独立目录
|
||
└── timemachine/ # Time Machine 备份目录(独立于 rclone VFS,详见 4.10)
|
||
└── MacBook-Pro.sparsebundle
|
||
```
|
||
|
||
**重要**:`vfs/` 目录由 rclone VFS 内部管理,维护自己的元数据来跟踪缓存了哪些文件。**任何外部进程(包括 SD 卡导入)不得直接写入此目录**,否则会导致 rclone 内部元数据与 metadata.db 不同步。SD 卡导入必须通过 rclone FUSE 挂载点(`/mnt/nas-photos`)写入,详见 5.8.1。
|
||
|
||
#### 5.7.3 表结构定义
|
||
|
||
**表 1:cache_files — 缓存文件状态(核心表)**
|
||
|
||
只有进过缓存的文件才会有记录。
|
||
|
||
```sql
|
||
CREATE TABLE cache_files (
|
||
path TEXT PRIMARY KEY, -- 相对路径,如 /2026/02/IMG_0001.cr3
|
||
dir_path TEXT NOT NULL, -- 所属目录,如 /2026/02/(加速目录级查询)
|
||
origin_mtime INTEGER, -- 拉下来时(或上次成功回写后)远程的 mtime
|
||
-- NULL 表示 SD 卡导入的新文件,从未在远程存在
|
||
cache_mtime INTEGER NOT NULL, -- 本地缓存文件当前 mtime
|
||
file_size INTEGER NOT NULL, -- 文件大小(字节,统计 + 淘汰决策用)
|
||
state TEXT NOT NULL, -- clean / dirty / conflict
|
||
source TEXT DEFAULT 'remote', -- 文件来源:remote(远程拉取)/ ingest(SD 卡导入)
|
||
last_accessed INTEGER NOT NULL, -- 最后访问时间(LRU 淘汰用)
|
||
writeback_retry INTEGER DEFAULT 0, -- 回写失败累计重试次数
|
||
checksum TEXT, -- SHA-256 校验和(SD 卡导入时计算)
|
||
created_at INTEGER NOT NULL -- 首次缓存时间
|
||
);
|
||
|
||
CREATE INDEX idx_state ON cache_files(state);
|
||
CREATE INDEX idx_dir_path ON cache_files(dir_path);
|
||
CREATE INDEX idx_last_accessed ON cache_files(last_accessed);
|
||
CREATE INDEX idx_dirty ON cache_files(state) WHERE state = 'dirty';
|
||
```
|
||
|
||
生命周期:
|
||
|
||
```
|
||
文件首次被访问并下载 → INSERT (state=clean, source=remote, origin_mtime=远程mtime, cache_mtime=远程mtime)
|
||
SD 卡导入新文件 → INSERT (state=dirty, source=ingest, origin_mtime=NULL, cache_mtime=当前时间, checksum=SHA256)
|
||
本地修改(应用保存) → UPDATE (state=dirty, cache_mtime=当前时间)
|
||
回写成功 → UPDATE (state=clean, origin_mtime=更新后的mtime, writeback_retry=0)
|
||
回写失败 → UPDATE (writeback_retry += 1)
|
||
缓存被 LRU 淘汰 → DELETE(仅 state=clean 可被淘汰)
|
||
检测到远程删除(clean)→ DELETE
|
||
检测到冲突 → UPDATE (state=conflict)
|
||
冲突处理完成 → DELETE 或 UPDATE (state=clean)
|
||
```
|
||
|
||
**表 2:dir_snapshots — 目录级轮询快照**
|
||
|
||
用于分层轮询的"目录 mtime 快检"。只记录被缓存文件所在的目录,不是 NAS 全量目录。
|
||
|
||
```sql
|
||
CREATE TABLE dir_snapshots (
|
||
dir_path TEXT PRIMARY KEY, -- 目录相对路径,如 /2026/02/
|
||
remote_mtime INTEGER, -- 上次轮询时远程目录的 mtime
|
||
-- NULL 表示 SD 卡导入创建的目录,远程尚不存在
|
||
last_polled INTEGER NOT NULL, -- 上次轮询时间
|
||
last_accessed INTEGER NOT NULL, -- 目录最后被访问时间(决定热/温/冷分级)
|
||
cached_count INTEGER DEFAULT 0 -- 该目录下 cache_files 条目数(辅助清理判断)
|
||
);
|
||
```
|
||
|
||
生命周期:
|
||
|
||
```
|
||
某目录下的文件首次被缓存 → INSERT(或 UPDATE cached_count)
|
||
SD 卡导入创建新目录 → INSERT(remote_mtime = NULL,表示远程目录尚不存在)
|
||
回写成功后更新 remote_mtime 为实际值
|
||
轮询时目录 mtime 没变 → UPDATE last_polled
|
||
轮询时目录 mtime 变了 → UPDATE remote_mtime,触发文件级检查
|
||
目录下已无缓存文件 → DELETE(可选)
|
||
remote_mtime = NULL 的目录 → 跳过轮询(远程还不存在,等回写创建后再轮询)
|
||
```
|
||
|
||
**表 3:dir_file_list — 目录文件列表快照(v1.5+)**
|
||
|
||
MVP 阶段不需要。v1.5 加入后,记录被关心目录下的全部远程文件,支持精确变更类型识别和智能预热。
|
||
|
||
```sql
|
||
CREATE TABLE dir_file_list (
|
||
dir_path TEXT NOT NULL, -- 所属目录
|
||
file_name TEXT NOT NULL, -- 文件名
|
||
remote_mtime INTEGER NOT NULL, -- 上次已知的远程 mtime
|
||
remote_size INTEGER NOT NULL, -- 上次已知的远程文件大小
|
||
snapshot_time INTEGER NOT NULL, -- 快照时间
|
||
PRIMARY KEY (dir_path, file_name)
|
||
);
|
||
|
||
CREATE INDEX idx_dir ON dir_file_list(dir_path);
|
||
```
|
||
|
||
#### 5.7.6 导入历史表(重复检测用)
|
||
|
||
**问题**:重复文件检测如果只查 cache_files 表,那么已回写到 NAS 并被 LRU 淘汰的文件(cache_files 记录已被 DELETE)再次导入同一张卡时无法检测到重复。
|
||
|
||
**解决方案**:增加 import_history 持久表,记录所有历史导入记录,不随 LRU 淘汰删除。
|
||
|
||
```sql
|
||
CREATE TABLE import_history (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
original_path TEXT NOT NULL, -- SD 卡上的原始路径
|
||
target_path TEXT NOT NULL, -- 导入到缓存/NAS 的目标路径
|
||
file_size INTEGER NOT NULL, -- 文件大小
|
||
checksum TEXT NOT NULL, -- SHA-256 校验和
|
||
source_device TEXT, -- SD 卡设备标识(如序列号)
|
||
imported_at INTEGER NOT NULL, -- 导入时间
|
||
writeback_at INTEGER, -- 回写到 NAS 的时间(NULL=未回写)
|
||
state TEXT NOT NULL -- imported / writeback_done / failed
|
||
);
|
||
|
||
CREATE INDEX idx_checksum ON import_history(checksum);
|
||
CREATE INDEX idx_original_path ON import_history(original_path, file_size);
|
||
```
|
||
|
||
重复检测流程:
|
||
```
|
||
导入文件前 → 计算 checksum
|
||
→ 查 import_history WHERE checksum = ? AND file_size = ?
|
||
→ 命中 → 跳过该文件,标记为"已导入过"
|
||
→ 未命中 → 继续查 cache_files WHERE checksum = ? AND file_size = ?
|
||
→ 均未命中 → 执行导入
|
||
```
|
||
|
||
#### 5.7.4 四张表的关系与数据规模
|
||
|
||
```
|
||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||
│ dir_snapshots │ │ dir_file_list │ │ cache_files │
|
||
│ (目录级) │ │ (v1.5+, 可选) │ │ (文件级) │
|
||
│ │ │ │ │ │
|
||
│ /2026/02/ │────▶│ /2026/02/ │ │ │
|
||
│ remote_mtime │ │ IMG_0001.cr3 │────▶│ /2026/02/ │
|
||
│ last_polled │ │ IMG_0002.cr3 │ │ IMG_0001.cr3 │
|
||
│ last_accessed │ │ IMG_0003.cr3 │ │ origin_mtime │
|
||
│ │ │ ... │ │ cache_mtime │
|
||
│ │ │ (全部远程文件) │ │ state=dirty │
|
||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||
每个关心的目录 该目录下远程全部文件 只有被缓存过的文件
|
||
1 行记录 可能 200 行 可能 3 行
|
||
|
||
┌──────────────────┐
|
||
│ import_history │ ← 独立于缓存生命周期,永久保留
|
||
│ (导入记录) │
|
||
│ │
|
||
│ IMG_0001.cr3 │
|
||
│ checksum=abc.. │
|
||
│ imported_at │
|
||
│ writeback_at │
|
||
└──────────────────┘
|
||
每次 SD 卡导入的文件
|
||
持续累积
|
||
```
|
||
|
||
数据规模估算(以 NAS 上 10 万个文件、用户缓存了 1000 个为例):
|
||
|
||
| 表 | 记录范围 | 预估行数 | 存储开销 |
|
||
|---|---|---|---|
|
||
| cache_files | 本地缓存过的文件 | ~1,000 | ~100 KB |
|
||
| dir_snapshots | 有缓存文件的目录 | ~50 | ~5 KB |
|
||
| dir_file_list (v1.5+) | 关心目录下的全部远程文件 | ~10,000 | ~1 MB |
|
||
| import_history | 所有历史导入记录(持续累积) | ~5,000 | ~500 KB |
|
||
| **总计** | | | **< 2 MB** |
|
||
|
||
#### 5.7.5 删除检测流程(MVP 精简模式)
|
||
|
||
MVP 阶段不使用 dir_file_list,通过反查 cache_files 实现删除检测:
|
||
|
||
```
|
||
轮询发现 /2026/02/ 目录 mtime 变了
|
||
│
|
||
▼
|
||
sftp ls /2026/02/ → 拿到当前远程文件列表 remote_set
|
||
│
|
||
▼
|
||
SELECT path FROM cache_files WHERE dir_path = '/2026/02/'
|
||
→ 拿到该目录下所有缓存文件 cached_set
|
||
│
|
||
▼
|
||
对比:
|
||
在 cached_set 但不在 remote_set → 远程被删了 → 按决策矩阵处理
|
||
在 cached_set 且在 remote_set → 比较 mtime,变了则标记失效
|
||
在 remote_set 但不在 cached_set → 远程新增/未缓存文件 → 不处理(等访问时再拉)
|
||
│
|
||
▼
|
||
更新 dir_snapshots 的 remote_mtime 和 last_polled
|
||
```
|
||
|
||
这样只用两张表就完成了所有检测,逻辑清晰,开销极小。
|
||
|
||
#### 5.7.7 SQLite 并发访问策略
|
||
|
||
metadata.db 会被多个进程/线程并发访问:Write-back Controller、轮询线程、SD 卡导入进程、CLI 管理工具。
|
||
|
||
**要求**:metadata.db 必须以 **WAL(Write-Ahead Logging)模式**运行:
|
||
|
||
```sql
|
||
PRAGMA journal_mode=WAL;
|
||
PRAGMA busy_timeout=5000; -- 锁等待超时 5 秒
|
||
```
|
||
|
||
WAL 模式的优势:
|
||
- 读操作不阻塞写操作,写操作不阻塞读操作
|
||
- 多个进程可以同时读取,只有写入互斥
|
||
- 适合"多读少写"的缓存元数据场景
|
||
|
||
所有访问 metadata.db 的进程必须使用同一个 WAL 模式配置。部署脚本在初始化数据库时自动设置。
|
||
|
||
### 5.8 Write-back Controller 架构
|
||
|
||
rclone 原生的 `--vfs-cache-mode full` 不做回写前的 mtime 比较(盲写),因此需要在 rclone 之上包一层 Write-back Controller:
|
||
|
||
```
|
||
原来(rclone 默认):
|
||
rclone VFS → 脏文件 → 直接 SFTP 上传 → 可能覆盖新数据 ❌
|
||
|
||
改为(双管道架构):
|
||
|
||
管道 A — 远程拉取后的本地编辑:
|
||
rclone VFS → 脏文件 → Write-back Controller
|
||
│
|
||
├── SFTP stat 查远程 mtime
|
||
├── 三时间戳比较
|
||
├── 决策:回写 / 冲突保留 / 跳过
|
||
└── 回写到原始远程路径 + 更新 metadata.db
|
||
|
||
管道 B — SD 卡导入的新文件:
|
||
SD 卡导入(通过 FUSE 挂载点写入)→ rclone 自动标记为脏文件 → Write-back Controller
|
||
│
|
||
├── 检测 source=ingest, origin_mtime=NULL
|
||
├── 按 INGEST_TARGET_PATH 模板计算 NAS 目标路径
|
||
├── 在 NAS 上创建目标目录(如不存在)
|
||
├── SFTP stat 查远程:按 5.4b 决策矩阵处理
|
||
└── 上传到目标路径 + 更新 metadata.db + 记录 import_history
|
||
```
|
||
|
||
Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中的脏文件,替代 rclone 的原生回写逻辑。Controller 通过 metadata.db 中的 `source` 字段区分两条管道,对 `source=ingest` 的文件执行路径重映射和目录创建逻辑。
|
||
|
||
#### 5.8.1 SD 卡导入与 rclone VFS 的集成方案(架构讨论)
|
||
|
||
**问题**:rclone VFS 维护自己的内部元数据来跟踪缓存了哪些文件。如果外部进程(SD 卡导入)直接往 VFS 缓存目录写文件,rclone 完全不知道这些文件的存在,会导致:
|
||
1. rclone 的内部元数据与 metadata.db 不同步
|
||
2. 应用通过 FUSE 挂载点访问这些文件时,rclone 可能尝试从远程拉取(因为它不知道本地已有)
|
||
3. Write-back Controller 需要处理的脏文件从 rclone 角度看根本不是脏文件
|
||
|
||
**考虑过的方案**:
|
||
|
||
| 方案 | 原理 | 优点 | 缺点 |
|
||
|------|------|------|------|
|
||
| A. 通过 FUSE 挂载点写入 | SD 卡导入写入 `/mnt/nas-photos/ingest/...`,rclone 自然跟踪 | rclone 自动管理缓存元数据;脏文件对 rclone 可见 | 需要拦截 rclone 的原生回写以实现自定义路径映射;FUSE 写入性能可能低于直写 SSD |
|
||
| B. 自定义缓存层 | 不依赖 rclone 作为唯一缓存管理器,构建自定义缓存层 | 完全控制缓存行为 | 开发量大,需要自己实现远程拉取、缓存淘汰等 rclone 已有功能 |
|
||
|
||
**最终决策**:采用**方案 A**——通过 rclone FUSE 挂载点写入。
|
||
|
||
实现要点:
|
||
1. SD 卡导入进程先将文件复制到 `ingest_staging/` 暂存目录并计算 checksum
|
||
2. 校验通过后,通过 FUSE 挂载点(`/mnt/nas-photos`)写入到 INGEST_TARGET_PATH 指定的路径
|
||
3. rclone 自动将其纳入 VFS 缓存管理并标记为脏文件
|
||
4. 导入进程同步在 metadata.db 中创建 `source=ingest, origin_mtime=NULL` 记录
|
||
5. Write-back Controller 拦截 rclone 的原生回写(通过配置 `--vfs-write-back` 为极大值或禁用),执行自定义回写逻辑
|
||
|
||
**⚠️ 实现前需要做架构 spike 验证**:确认 rclone 在 `--vfs-write-back` 被禁用时的行为,以及通过 FUSE 挂载点写入大文件的性能是否满足导入场景需求(目标:SD 卡导入速度 ≥ 80MB/s)。
|
||
|
||
### 5.9 SD 卡导入状态机(中断保护)
|
||
|
||
**问题**:SD 卡导入过程中可能发生中断(卡被拔出、电池耗尽、进程崩溃)。部分复制的文件如果被标记为 dirty 并进入回写队列,会把不完整的文件上传到 NAS。
|
||
|
||
**解决方案**:导入过程维护严格的状态机,只有完整校验通过的文件才进入缓存。
|
||
|
||
```
|
||
导入状态机:
|
||
|
||
detecting SD 卡插入检测,列出文件清单
|
||
│
|
||
▼
|
||
copying 文件从 SD 卡复制到 ingest_staging/ 暂存目录
|
||
│ (注意:此时文件还没有进入 rclone VFS 或 cache_files)
|
||
▼
|
||
checksumming 对暂存文件计算 SHA-256,可选与 SD 卡原文件二次比对
|
||
│
|
||
▼
|
||
registered 校验通过,将文件从暂存目录移到 FUSE 挂载点
|
||
│ 同步在 metadata.db 创建记录(state=dirty, source=ingest)
|
||
│ 同步写入 import_history
|
||
▼
|
||
complete 该文件导入完成,Write-back Controller 可接管
|
||
|
||
中断处理:
|
||
- detecting/copying 阶段中断 → ingest_staging/ 中的临时文件在下次启动时清理
|
||
- checksumming 阶段中断 → 同上,checksum 不完整的文件清理
|
||
- registered 阶段中断 → 文件已在 FUSE 挂载点和 metadata.db 中,重启后 Write-back Controller 正常接管
|
||
- 每个导入会话有唯一 session_id,中断后可报告哪些文件已完成、哪些需要重试
|
||
```
|
||
|
||
**实现要点**:
|
||
- 暂存目录 `ingest_staging/<session-id>/` 按导入会话隔离
|
||
- 进程启动时扫描 `ingest_staging/`,清理所有未完成的会话(非 registered 状态的文件)
|
||
- 导入进度持久化到 `ingest_sessions` 表或简单的 JSON 文件,支持断点续传
|
||
|
||
---
|
||
|
||
## 六、远程变更检测机制
|
||
|
||
### 6.1 设计约束
|
||
|
||
**不依赖任何 NAS 品牌特有 API**。产品需要支持群晖、QNAP、威联通、TrueNAS 等任意品牌 NAS,因此只能基于标准 SFTP 协议实现。
|
||
|
||
### 6.2 SFTP 协议的能力边界(设计讨论)
|
||
|
||
**问题**:远程 NAS 上数据更新了,Cache 怎么知道?能否实时感知?
|
||
|
||
**讨论**:SFTP 协议本身**没有任何通知/推送/订阅机制**。它是无状态的文件传输协议,不支持 inotify、webhook、filesystem watch 等概念。每次想知道远程状态,必须主动发请求查询。
|
||
|
||
**考虑过的方案**:
|
||
|
||
| 方案 | 原理 | 实时性 | 品牌依赖 | 取舍 |
|
||
|------|------|--------|----------|------|
|
||
| SFTP 全量轮询 | 递归 ls 对比 mtime | 分钟级 | 无 | 文件多时开销大 |
|
||
| SFTP 分层轮询 | 先查目录 mtime,变了再查文件 | 分钟级 | 无 | 高效,推荐 |
|
||
| 群晖 FileStation API | 调 DSM Web API | 秒级 | 仅群晖 | ❌ 不通用 |
|
||
| NAS 侧 Agent 推送 | inotifywait → HTTP 通知 | 秒级 | 无 | 需要 NAS 装软件 |
|
||
|
||
**最终决策**:
|
||
|
||
- **P0(MVP)**:SFTP 分层轮询,零额外依赖
|
||
- **P2(后续)**:可选的 NAS 侧 Agent 推送,作为增强项给需要秒级同步的用户
|
||
|
||
### 6.3 分层轮询策略
|
||
|
||
核心优化思路:**SFTP 目录本身也有 mtime**。当目录下有文件新增/修改/删除时,目录的 mtime 会更新。因此可以先查目录 mtime(一个 stat 请求),没变就跳过整个目录下所有文件的检查,大幅减少远程请求量。
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ 分层轮询策略 │
|
||
│ │
|
||
│ 第一层:目录 mtime 快检 │
|
||
│ 对每个已缓存的目录,SFTP stat 查其 mtime │
|
||
│ 目录 mtime 没变 → 该目录下所有文件一定没变 → 跳过 ✅ │
|
||
│ 目录 mtime 变了 → 进入第二层 │
|
||
│ 开销:每个目录 1 个 stat 请求,非常轻量 │
|
||
│ │
|
||
│ 第二层:文件级 mtime 对比 │
|
||
│ 仅对 mtime 有变化的目录执行 SFTP ls -l │
|
||
│ 逐个对比文件 mtime 与 metadata.db 中的 origin_mtime │
|
||
│ 发现变化 → 标记缓存失效(不主动下载,等访问时再拉) │
|
||
│ 发现删除 → 按决策矩阵处理 │
|
||
│ │
|
||
│ 第三层:按热度分级轮询间隔 │
|
||
│ 热目录(最近 7 天有访问) → 每 30 秒轮询 │
|
||
│ 温目录(7-30 天内有访问) → 每 5 分钟轮询 │
|
||
│ 冷目录(30 天+ 未访问) → 每 1 小时轮询 │
|
||
│ │
|
||
│ 第四层:每日全量校对(兜底) │
|
||
│ 每天凌晨一次全量递归对比 │
|
||
│ 捕捉所有分层轮询可能遗漏的变更 │
|
||
│ 同时清理已不存在的缓存条目 │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
轮询伪代码(与 5.7.5 删除检测对齐):
|
||
|
||
```
|
||
# watched_directories = SELECT dir_path FROM dir_snapshots
|
||
# 按热度分级决定轮询间隔(热 30s / 温 5m / 冷 1h)
|
||
|
||
for dir in watched_directories:
|
||
if now - dir.last_polled < poll_interval_for(dir):
|
||
continue # 还没到该目录的轮询时间
|
||
|
||
# 第一层:目录 mtime 快检(1 个 SFTP stat 请求)
|
||
dir_mtime = sftp_stat(dir.dir_path).mtime
|
||
if dir_mtime == dir.remote_mtime:
|
||
UPDATE dir_snapshots SET last_polled = now WHERE dir_path = dir.dir_path
|
||
continue # 目录没变,跳过全部文件 ✅
|
||
|
||
# 第二层:目录变了,查里面的文件
|
||
remote_files = sftp_ls(dir.dir_path) # { name → mtime }
|
||
cached_files = SELECT * FROM cache_files # 该目录下的缓存文件
|
||
WHERE dir_path = dir.dir_path
|
||
|
||
# 检查已缓存文件的变化
|
||
for file in cached_files:
|
||
remote = remote_files.get(file.path)
|
||
if remote is None:
|
||
# 远程文件被删了 → 按决策矩阵处理
|
||
handle_remote_deletion(file)
|
||
elif remote.mtime != file.origin_mtime:
|
||
# 远程文件被修改了
|
||
if file.state == "clean":
|
||
invalidate_cache(file.path) # 标记失效,下次访问拉新版
|
||
else:
|
||
mark_conflict(file.path) # 本地有脏数据,标记冲突
|
||
|
||
# 远程新增的文件(在 remote_files 但不在 cached_files)不处理
|
||
# 等用户实际访问时再按需拉取
|
||
|
||
# 更新目录快照
|
||
UPDATE dir_snapshots SET remote_mtime = dir_mtime, last_polled = now
|
||
WHERE dir_path = dir.dir_path
|
||
```
|
||
|
||
### 6.4 后续增强:NAS 侧 Agent 推送(P2)
|
||
|
||
对于需要秒级同步的用户,可选在 NAS 上部署轻量 Agent(Docker 容器),通过 inotify 监听变化并推送:
|
||
|
||
```
|
||
群晖 NAS Linux Proxy
|
||
┌────────────────────┐ ┌────────────────┐
|
||
│ inotifywait 监听 │ Tailscale │ HTTP 接收端 │
|
||
│ 文件变化 │───── 通知 ────────▶│ 触发缓存刷新 │
|
||
│ (Docker 容器) │ (轻量 HTTP POST) │ │
|
||
└────────────────────┘ └────────────────┘
|
||
```
|
||
|
||
此方案作为分层轮询的增强,不是替代。即使 Agent 不可用,轮询机制仍然工作。
|
||
|
||
---
|
||
|
||
## 七、缓存行为详细描述
|
||
|
||
### 7.1 读取流程
|
||
|
||
```
|
||
应用请求读取文件
|
||
│
|
||
▼
|
||
① 查本地缓存 ──── 命中 ──▶ ② 后台轮询是否已标记失效?
|
||
│ │ │
|
||
未命中 未失效 已失效
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
③ 向远程请求 直接返回缓存 ④ 检查文件状态
|
||
│ (SSD 速度) │
|
||
▼ ┌────┴────┐
|
||
④ 按 chunk 分块下载 clean dirty
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
⑤ 写入本地 SSD 缓存 拉远程新版本 标记冲突
|
||
⑥ 记录 origin_mtime 更新缓存 两边保留
|
||
│
|
||
▼
|
||
⑦ 返回数据给应用
|
||
```
|
||
|
||
**设计要点**:
|
||
|
||
- 读取热路径上**不查远程**(不产生网络请求),直接返回缓存,保证响应速度
|
||
- 远程变更检测由后台轮询线程异步完成,发现变化时标记缓存失效
|
||
- 失效的 clean 文件下次访问时自动拉新版本
|
||
- 失效的 dirty 文件标记为冲突,需要用户介入
|
||
|
||
### 7.2 写入流程
|
||
|
||
```
|
||
应用写入文件
|
||
│
|
||
▼
|
||
① 写入本地 SSD 缓存(立即返回成功给应用)
|
||
│
|
||
▼
|
||
② 更新 metadata.db:cache_mtime = now, state = dirty
|
||
│
|
||
▼
|
||
③ 启动写回计时器(默认 60 秒)
|
||
│
|
||
├── 计时期间又有写入 → 重置计时器(合并频繁写入)
|
||
│
|
||
▼
|
||
④ 计时器到期,Write-back Controller 接管
|
||
│
|
||
▼
|
||
⑤ SFTP stat 查远程 mtime
|
||
│
|
||
▼
|
||
⑥ 三时间戳比较,按决策矩阵执行
|
||
│
|
||
├── 正常回写 → 上传 + 更新 origin_mtime + state → clean
|
||
├── 冲突 → 保留双版本 + 通知用户
|
||
└── 远程已删 → 不回写 + 移到冲突目录
|
||
│
|
||
├── 上传失败 → 重试(指数退避: 10s, 20s, 40s... 最多 10 次)
|
||
│
|
||
└── 最终失败 → 保留在本地缓存,state 保持 dirty,记录日志
|
||
```
|
||
|
||
### 7.3 缓存淘汰策略
|
||
|
||
```
|
||
淘汰触发条件(任一):
|
||
- 缓存总大小超过 CACHE_MAX_SIZE
|
||
- 缓存盘可用空间低于 CACHE_MIN_FREE
|
||
|
||
淘汰规则(优先级从高到低):
|
||
① dirty 文件永不淘汰(必须等回写完成)
|
||
② conflict 文件永不淘汰(必须等用户处理)
|
||
③ 超过 CACHE_MAX_AGE 且状态为 clean 的文件优先淘汰
|
||
④ 剩余 clean 文件按 LRU(last_accessed 最早的先淘汰)
|
||
⑤ 淘汰至空间满足条件为止
|
||
⑥ 如果淘汰全部 clean 文件后空间仍不足 → 进入「缓存空间告警」状态(详见下文)
|
||
```
|
||
|
||
#### 7.3.1 缓存空间保护机制
|
||
|
||
**问题**:dirty 文件永不被淘汰,但 SD 卡导入和 Time Machine 写入都会创建大量 dirty 文件。极端场景下(如离线导入 350GB 照片),dirty 文件总量可能超过 SSD 可用空间。
|
||
|
||
**保护措施**:
|
||
|
||
```
|
||
1. 导入前空间预检(SD 卡导入):
|
||
可用空间 = CACHE_MAX_SIZE - 当前dirty文件总量 - 当前conflict文件总量 - CACHE_MIN_FREE
|
||
if SD卡总数据量 > 可用空间:
|
||
→ 拒绝导入,通知用户(LED 红灯 + CLI 提示剩余空间和 SD 卡大小)
|
||
→ 建议用户先连接网络完成回写,或清理部分缓存
|
||
|
||
2. Time Machine 硬配额:
|
||
TIMEMACHINE_MAX_SIZE 作为硬配额强制执行(由 smb.conf 的 fruit:time machine max size 实现)
|
||
Time Machine 写入超过配额时 macOS 会自动清理旧备份
|
||
|
||
3. 配置验证(部署时检查):
|
||
TIMEMACHINE_MAX_SIZE + 预期最大单次导入量(INGEST_MAX_IMPORT_SIZE) + CACHE_MIN_FREE < CACHE_MAX_SIZE
|
||
不满足时部署脚本报警告
|
||
|
||
4. 缓存空间告警状态:
|
||
当 dirty 文件 + conflict 文件总量 > CACHE_MAX_SIZE × 80%:
|
||
→ 触发告警(CLI 提示 / 日志 / 可选 Webhook)
|
||
→ 提示用户尽快连接网络完成回写
|
||
```
|
||
|
||
### 7.4 离线行为
|
||
|
||
| 场景 | 行为 |
|
||
|------|------|
|
||
| 远程不可达,读取已缓存文件 | 正常返回,无影响 |
|
||
| 远程不可达,读取未缓存文件 | 超时报错(可配置超时时间) |
|
||
| 远程不可达,写入文件 | 正常写入本地缓存,回写排队等待恢复 |
|
||
| 远程不可达,后台轮询 | 静默跳过,不报错,下次重试 |
|
||
| 恢复连接后 | 自动续传回写队列 + 立即触发一轮轮询 |
|
||
|
||
---
|
||
|
||
## 八、配置参数清单
|
||
|
||
### 连接配置
|
||
|
||
| 参数 | 说明 | 默认值 | 建议值 |
|
||
|------|------|--------|--------|
|
||
| `NAS_HOST` | 远程 NAS 的 Tailscale IP | - | `100.x.x.x` |
|
||
| `NAS_USER` | SFTP 用户名 | - | - |
|
||
| `NAS_PASS` / `NAS_KEY_FILE` | 认证信息 | - | 建议密钥 |
|
||
| `NAS_REMOTE_PATH` | NAS 上的目标路径 | - | `/volume1/photos` |
|
||
| `SFTP_PORT` | SFTP 端口 | `22` | `22` |
|
||
| `SFTP_CONNECTIONS` | SFTP 连接复用数 | `8` | `4-16` |
|
||
|
||
### 缓存配置
|
||
|
||
| 参数 | 说明 | 默认值 | 建议值 |
|
||
|------|------|--------|--------|
|
||
| `CACHE_DIR` | 缓存存储路径 | - | SSD 路径,建议 btrfs/ZFS |
|
||
| `CACHE_MAX_SIZE` | 缓存大小上限 | `200G` | SSD 容量的 70-80% |
|
||
| `CACHE_MAX_AGE` | 缓存最大保留时间 | `720h`(30天) | 按使用习惯 |
|
||
| `CACHE_MIN_FREE` | 缓存盘最低可用空间 | `10G` | `10-20G` |
|
||
|
||
### 读取优化
|
||
|
||
| 参数 | 说明 | 默认值 | 场景建议 |
|
||
|------|------|--------|----------|
|
||
| `READ_CHUNK_SIZE` | 分块读取大小 | `256M` | RAW 照片: `256M`,视频: `512M`,文档: `64M` |
|
||
| `READ_CHUNK_LIMIT` | chunk 自动增长上限 | `1G` | - |
|
||
| `READ_AHEAD` | 预读缓冲区 | `512M` | 视频场景可加到 `1G` |
|
||
| `BUFFER_SIZE` | 内存缓冲区 | `256M` | - |
|
||
|
||
### 写回配置
|
||
|
||
| 参数 | 说明 | 默认值 | 场景建议 |
|
||
|------|------|--------|----------|
|
||
| `WRITE_BACK` | 写回延迟 | `60s` | Lightroom: `60-120s`,文档: `10-30s` |
|
||
| `TRANSFERS` | 并发回写线程 | `4` | 带宽小就设 `2` |
|
||
| `BW_LIMIT_UP` | 上传限速 | `0`(不限) | 酒店 WiFi 建议 `10-20M` |
|
||
| `BW_LIMIT_DOWN` | 下载限速 | `0`(不限) | 一般不限 |
|
||
|
||
### 目录缓存与轮询
|
||
|
||
| 参数 | 说明 | 默认值 | 场景建议 |
|
||
|------|------|--------|----------|
|
||
| `DIR_CACHE_TIME` | 目录列表缓存时间 | `1h` | 个人: `1-2h`,协作: `5-15m` |
|
||
| `POLL_HOT_INTERVAL` | 热目录轮询间隔(7天内有访问) | `30s` | - |
|
||
| `POLL_WARM_INTERVAL` | 温目录轮询间隔(7-30天内访问) | `5m` | - |
|
||
| `POLL_COLD_INTERVAL` | 冷目录轮询间隔(30天+未访问) | `1h` | - |
|
||
| `FULL_SYNC_SCHEDULE` | 每日全量校对时间 | `03:00` | 凌晨低峰期 |
|
||
|
||
### 冲突处理
|
||
|
||
| 参数 | 说明 | 默认值 | 建议值 |
|
||
|------|------|--------|--------|
|
||
| `CONFLICT_DIR` | 冲突文件存放目录 | `{CACHE_DIR}/conflict` | - |
|
||
| `CONFLICT_STRATEGY` | 冲突策略 | `mtime_wins` | `mtime_wins` |
|
||
| `CONFLICT_NOTIFY` | 冲突通知方式 | `log` | `log` / `webhook` |
|
||
| `CONFLICT_RETAIN_DAYS` | 冲突副本保留天数 | `30` | - |
|
||
| `CONFLICT_CLEANUP_SCHEDULE` | 冲突目录自动清理时间 | `04:00` | 与 FULL_SYNC_SCHEDULE 错开 |
|
||
|
||
**冲突目录清理进程**:每天在 `CONFLICT_CLEANUP_SCHEDULE` 时间自动扫描 `CONFLICT_DIR`,删除超过 `CONFLICT_RETAIN_DAYS` 天的冲突副本。清理前记录日志。
|
||
|
||
### 多协议配置
|
||
|
||
| 参数 | 说明 | 默认值 | 建议值 |
|
||
|------|------|--------|--------|
|
||
| `ENABLE_SMB` | 启用 SMB 共享 | `yes` | `yes` |
|
||
| `ENABLE_NFS` | 启用 NFS 导出 | `no` | 有 Linux 客户端时开启 |
|
||
| `ENABLE_WEBDAV` | 启用 WebDAV 服务 | `no` | 有移动端需求时开启 |
|
||
| `NFS_ALLOWED_NETWORK` | NFS 允许访问的网段 | `192.168.0.0/24` | 按实际局域网设置 |
|
||
| `WEBDAV_PORT` | WebDAV 监听端口 | `8080` | - |
|
||
|
||
### SD 卡导入配置
|
||
|
||
| 参数 | 说明 | 默认值 | 建议值 |
|
||
|------|------|--------|--------|
|
||
| `INGEST_MAX_IMPORT_SIZE` | 单次导入预留空间上限 | `256G` | 按最大 SD 卡容量设置 |
|
||
| `INGEST_AUTO` | 插卡后自动导入 | `no` | 需按按钮确认 |
|
||
| `INGEST_TARGET_PATH` | 导入到 NAS 的目标路径模板 | `/{year}/{month}/{date}/` | 按个人习惯,变量从 INGEST_DATE_SOURCE 确定 |
|
||
| `INGEST_DATE_SOURCE` | 路径模板中日期变量的来源 | `exif` | `exif`=EXIF拍摄日期(回退到mtime),`mtime`=文件修改时间,`import`=导入时间 |
|
||
| `INGEST_DUPLICATE_CHECK` | 重复文件检测(基于文件名+大小+checksum) | `yes` | `yes` |
|
||
| `INGEST_DELETE_AFTER` | 导入+校验完成后是否删除卡上数据 | `no` | `no`(安全起见) |
|
||
| `INGEST_PRIORITY` | 导入文件的回写优先级 | `high` | 高于普通编辑文件 |
|
||
| `INGEST_IO_CLASS` | 导入时的 I/O 调度优先级 | `best-effort:4` | 使用 ionice 设置,避免导入阻塞缓存读取 |
|
||
|
||
### WiFi AP 配置
|
||
|
||
| 参数 | 说明 | 默认值 | 建议值 |
|
||
|------|------|--------|--------|
|
||
| `AP_ENABLED` | 启用 WiFi 热点 | `no` | 现场共享时开启 |
|
||
| `AP_SSID` | 热点名称 | `NAS-Cache` | - |
|
||
| `AP_PASSWORD` | 热点密码 | 随机生成 | 首次配置时设定 |
|
||
| `AP_ISOLATION` | AP 网络与 WAN 隔离 | `yes` | `yes` |
|
||
| `AP_MAX_CLIENTS` | 最大连接数 | `8` | - |
|
||
|
||
### Time Machine 配置
|
||
|
||
| 参数 | 说明 | 默认值 | 建议值 |
|
||
|------|------|--------|--------|
|
||
| `TIMEMACHINE_ENABLED` | 启用 Time Machine 支持 | `no` | Mac 用户开启 |
|
||
| `TIMEMACHINE_MAX_SIZE` | Time Machine 空间上限 | `200G` | 按 SSD 容量调整 |
|
||
| `TIMEMACHINE_PATH` | NAS 上的归档目标路径 | `/timemachine/` | - |
|
||
|
||
---
|
||
|
||
## 九、场景预设(模板)
|
||
|
||
为降低用户配置门槛,提供开箱即用的预设模板。
|
||
|
||
### 摄影师模式
|
||
|
||
```
|
||
重点优化:大文件读取性能、Lightroom catalog 回写保护
|
||
- CACHE_MAX_SIZE=500G
|
||
- READ_CHUNK_SIZE=256M
|
||
- READ_AHEAD=512M
|
||
- WRITE_BACK=120s ← Lightroom 频繁自动保存,合并写入
|
||
- DIR_CACHE_TIME=2h ← 目录结构不常变
|
||
- POLL_HOT_INTERVAL=30s
|
||
- TRANSFERS=4
|
||
- ENABLE_SMB=yes
|
||
- ENABLE_NFS=no
|
||
- ENABLE_WEBDAV=no
|
||
```
|
||
|
||
### 视频剪辑模式
|
||
|
||
```
|
||
重点优化:顺序读取性能、大文件预读
|
||
- CACHE_MAX_SIZE=1T
|
||
- READ_CHUNK_SIZE=512M
|
||
- READ_AHEAD=1G ← 大预读保证播放流畅
|
||
- WRITE_BACK=60s
|
||
- DIR_CACHE_TIME=1h
|
||
- POLL_HOT_INTERVAL=1m
|
||
- TRANSFERS=2 ← 减少回写并发,保带宽给播放
|
||
- ENABLE_SMB=yes
|
||
- ENABLE_NFS=no
|
||
- ENABLE_WEBDAV=no
|
||
```
|
||
|
||
### 文档办公模式
|
||
|
||
```
|
||
重点优化:小文件快速响应、写入快速同步
|
||
- CACHE_MAX_SIZE=50G
|
||
- READ_CHUNK_SIZE=64M
|
||
- READ_AHEAD=128M
|
||
- WRITE_BACK=10s ← 文档改完快同步
|
||
- DIR_CACHE_TIME=30m ← 协作场景需要较快看到新文件
|
||
- POLL_HOT_INTERVAL=15s ← 更频繁感知远程变更
|
||
- TRANSFERS=4
|
||
- ENABLE_SMB=yes
|
||
- ENABLE_NFS=no
|
||
- ENABLE_WEBDAV=yes ← 移动端也能访问
|
||
```
|
||
|
||
---
|
||
|
||
## 十、部署要求
|
||
|
||
### 硬件要求(通用 Linux 主机部署)
|
||
|
||
| 组件 | 最低配置 | 推荐配置 |
|
||
|------|----------|----------|
|
||
| CPU | ARMv8 / x86_64 任意 | N100 或同级 |
|
||
| 内存 | 1 GB | 2-4 GB |
|
||
| 缓存盘 | 任意 SSD | NVMe SSD |
|
||
| 缓存容量 | 32 GB | 常用数据量的 30%+ |
|
||
| 网口 | 100M | 千兆(2.5G 更好) |
|
||
| 断电保护 | - | 内置电池或外接 UPS |
|
||
|
||
### 硬件要求(一体机目标形态)
|
||
|
||
通用要求之外,一体机额外需要:
|
||
|
||
| 组件 | 说明 |
|
||
|------|------|
|
||
| SD 卡槽 | SD / microSD,覆盖大多数相机 |
|
||
| CFexpress 槽(可选) | CFexpress Type-B,高端相机用户 |
|
||
| USB-A/C 口 | 至少 2 个,用于外接读卡器(XQD 等)或移动硬盘 |
|
||
| WiFi 模块 | 支持 AP 模式,建议 WiFi 6 |
|
||
| 物理按钮 | 触发 SD 卡导入 / 确认操作 |
|
||
| LED 状态指示 | 导入进度 / 完成 / 错误 / 回写状态 |
|
||
| 内置电池 | 支持断电保护 + 便携使用 |
|
||
|
||
**缓存盘文件系统建议**:btrfs 或 ZFS。利用 CoW(Copy-on-Write)和 journal 机制,即使意外断电也能保证文件系统级别的一致性。
|
||
|
||
```bash
|
||
# btrfs 格式化示例
|
||
mkfs.btrfs /dev/ssd_partition
|
||
mount -o compress=zstd /dev/ssd_partition /mnt/ssd/nas-cache
|
||
```
|
||
|
||
### 软件要求
|
||
|
||
| 组件 | 版本 |
|
||
|------|------|
|
||
| OS | Ubuntu 22.04+ / Debian 12+ / 任意 Linux |
|
||
| rclone | 1.65+(关键参数:`--vfs-cache-mode full --vfs-write-back 999h --vfs-cache-max-size {CACHE_MAX_SIZE}`) |
|
||
| Samba | 4.x |
|
||
| NFS server | nfs-kernel-server(如启用 NFS) |
|
||
| FUSE | 3.x |
|
||
| SQLite | 3.x(元数据存储) |
|
||
| Tailscale / ZeroTier | 已配置并可连通 NAS |
|
||
|
||
### NAS 侧要求
|
||
|
||
| 项目 | 要求 |
|
||
|------|------|
|
||
| SFTP 服务 | 开启(群晖:控制面板 → 文件服务 → FTP → 勾选 SFTP) |
|
||
| 用户权限 | SFTP 用户对目标目录有读写权限 |
|
||
| Tailscale | 已安装并登录同一网络 |
|
||
| 品牌 | **无限制**,任何支持 SFTP 的 NAS 均可(群晖/QNAP/威联通/TrueNAS/DIY 等) |
|
||
|
||
---
|
||
|
||
## 十一、风险与局限
|
||
|
||
| 风险 | 等级 | 说明 | 缓解措施 |
|
||
|------|------|------|----------|
|
||
| 断电丢数据 | 低 | write-back 窗口期内断电 | 硬件 UPS/电池保证落盘;btrfs/ZFS 保证文件系统一致性 |
|
||
| 写冲突 | 低 | 多端同时改同一文件 | 回写前 mtime 比较 + 冲突副本保留 + 通知用户 |
|
||
| 远程删除后复活 | 低 | Cache 脏文件回写已删文件 | 回写前检查远程存在性,远程已删则不回写 |
|
||
| 首次访问慢 | 固有 | 未缓存文件必须走远程 | 预热功能;分块下载优化 |
|
||
| 缓存一致性延迟 | 低 | 远程变更在轮询间隔内不可见 | 分层轮询(热目录 30s);后续可选 Agent 推送 |
|
||
| Tailscale 断连 | 中 | 远程不可达时新文件无法获取 | 已缓存文件仍可用;回写自动排队;恢复后自动续传 |
|
||
| 多协议锁冲突 | 低 | SMB/NFS 锁机制不同 | 文档约束"同一文件不建议多协议同时写" |
|
||
| 轮询开销 | 低 | 大量文件目录轮询消耗带宽 | 目录 mtime 快检跳过未变目录;热度分级降低冷目录频率 |
|
||
| SD 卡导入数据损坏 | 低 | 卡本身坏块导致导入不完整 | 导入时计算 SHA-256 校验和;双卡校验比对 |
|
||
| SD 卡导入中断 | 低 | 卡被拔出 / 电池耗尽 / 进程崩溃 | 导入状态机保护(5.9);未完成文件清理而非标为 dirty |
|
||
| 缓存空间耗尽 | 中 | dirty 文件(导入+TM)撑满 SSD | 导入前空间预检;TM 硬配额;缓存空间告警(7.3.1) |
|
||
| 中转服务带宽成本 | 中 | DERP 中继带宽随用户增长上升 | 大部分连接走 P2P 直连;按流量分级限速/计费;初期节点少按需扩容 |
|
||
| 云备份存储成本 | 低 | 用户数据增长导致存储费用上升 | 接低价对象存储(B2/R2);按量计费传导成本;增量备份减少传输量 |
|
||
|
||
---
|
||
|
||
## 十二、后续演进方向
|
||
|
||
| 阶段 | 内容 | 重点 |
|
||
|------|------|------|
|
||
| **v1.0 — MVP** | 配置文件 + 部署脚本 + CLI 管理 + 基础一致性 + Time Machine 支持 | SMB + 读写缓存 + 三时间戳 + 精简 metadata(两表)+ 分层轮询 + TM 备份目标(几乎零 Samba 配置成本) |
|
||
| **v1.5 — 硬件原型** | SD 卡导入 + 自动归档 + 双卡校验 + LED/按钮交互 + 缓存预热 + 带宽管理 + 连接容错 + 写冲突通知 | 外拍现场核心场景 + P1 功能补全,验证硬件形态 |
|
||
| **v2.0 — 组网服务** | 内置 Headscale + 高速 DERP 节点 + WiFi AP 共享 | 开箱即连 + 现场团队协作 |
|
||
| **v2.5 — 容灾 + 附加** | 云端异地备份 + Docker 镜像 + 多协议(NFS/WebDAV)+ NAS 侧 Agent 推送 | 数据安全闭环 + 降低部署门槛 |
|
||
| **v3.0 — 硬件产品** | 定制硬件(SSD + 电池 + SD 槽 + WiFi),工业设计,开箱即用 | 产品化,面向非技术用户 |
|
||
|
||
---
|
||
|
||
## 十三、付费服务
|
||
|
||
### 13.1 Headscale + 高速 DERP 中转
|
||
|
||
**问题**:Tailscale 官方 DERP 是共享资源,跨运营商/跨国时带宽受限。用户自建 DERP 需要有 VPS + 运维能力,门槛高。
|
||
|
||
**方案**:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 运营基础设施 │
|
||
│ │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ Headscale │ │ DERP 节点 │ │ DERP 节点 │ │
|
||
│ │ 控制面板 │ │ 国内 BGP │ │ 香港/日本 │ │
|
||
│ │ (用户管理) │ │ (低延迟) │ │ (跨境加速) │ │
|
||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
▲ ▲ ▲
|
||
│ │ │
|
||
盒子开箱即连 NAS 端自动连接 出差设备自动连接
|
||
(内置配置) (安装脚本) (通过盒子中继)
|
||
```
|
||
|
||
**用户体验**:
|
||
|
||
1. 买盒子 → 开机 → 扫码绑定账号
|
||
2. NAS 端运行一行安装脚本,加入用户的 Tailnet
|
||
3. 完成。盒子带到任何地方,自动通过最优 DERP 节点连回家
|
||
4. 无需了解 Headscale、DERP、WireGuard 等概念
|
||
|
||
**迁移路径**(避免 vendor lock-in):
|
||
- 盒子底层使用标准 WireGuard 协议,用户可随时切换到自建 Headscale 或官方 Tailscale
|
||
- 提供配置导出工具:一键导出 WireGuard 配置、节点列表、DERP 自定义映射
|
||
- 如用户不再使用我们的 Headscale 服务,盒子仍可正常工作(手动配置 Tailscale/WireGuard)
|
||
|
||
**定价思路**:
|
||
|
||
| 套餐 | 内容 | 参考价 |
|
||
|------|------|--------|
|
||
| 免费 | Headscale 控制面板 + 1 个基础 DERP 节点(限速) | ¥0 |
|
||
| 基础版 | + 多节点智能选路 + 不限速 | ¥15-30/月 |
|
||
| 专业版 | + 优先带宽 + 跨境加速节点 + SLA 保证 | ¥50-100/月 |
|
||
|
||
**成本控制**:
|
||
|
||
- DERP 中继只在无法打洞直连时使用,大部分 Tailscale 连接是 P2P 直连,中继流量占比通常不高
|
||
- 可按实际中继流量动态限速/计费,避免被少数大流量用户拖垮
|
||
- 初期节点少(1-2 个),按用户增长逐步扩容
|
||
|
||
### 13.2 异地容灾备份
|
||
|
||
**问题**:NAS 在家里是单点故障——硬盘坏、被盗、火灾、水灾都可能导致数据永久丢失。
|
||
|
||
**方案**:
|
||
|
||
```
|
||
盒子 SSD 缓存(热数据子集)
|
||
│
|
||
▼ 空闲时段,加密增量备份
|
||
│
|
||
┌───▼──────────────────────────────────────────────┐
|
||
│ 云存储服务 │
|
||
│ │
|
||
│ 用户视角:盒子里一键开通,按月付费 │
|
||
│ 实际后端:Backblaze B2 / Cloudflare R2 / MinIO │
|
||
│ (对象存储,成本约 $5/TB/月) │
|
||
│ │
|
||
│ 数据加密:用户本地生成密钥,运营方看不到明文 │
|
||
│ 密钥备份:首次设置时强制引导用户备份恢复密钥 │
|
||
│ (下载密钥文件 / 抄写助记词 / 导出到密码管理器)│
|
||
│ 增量同步:只传变化的部分,节省带宽 │
|
||
│ 恢复流程:新盒子 → 输入恢复密钥 → 自动拉取 │
|
||
└──────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**与现有架构的关系**:
|
||
|
||
- 复用 write-back 引擎的思路:本地脏文件 → 异步上传
|
||
- 不同点:备份目标是云端对象存储而非 NAS,且可以备份 NAS 全量数据(不限于缓存过的文件)
|
||
- 可以做成两级:
|
||
- **热备份**:盒子 SSD 上缓存过的文件自动备份(几乎零额外成本)
|
||
- **全量备份**:通过以下任一路径从 NAS 全量增量备份到云端:
|
||
- **方案 1(推荐)**:盒子在家局域网时自动执行(高速,零公网带宽消耗)
|
||
- **方案 2**:盒子在外通过 Tailscale 远程执行(速度受限于公网带宽,但保证便携场景也能跑备份)
|
||
- **方案 3(远期)**:在 NAS 侧部署独立的备份 agent(Docker 容器),NAS 直接备份到云端,不依赖盒子在线
|
||
- 便携性说明:盒子的核心场景是带出去用。全量备份不要求盒子必须在家——方案 2 保证在外时也能慢速备份,方案 3 完全解耦盒子和全量备份
|
||
|
||
**定价思路**:
|
||
|
||
| 套餐 | 内容 | 参考价 |
|
||
|------|------|--------|
|
||
| 免费 | 热数据备份(仅缓存过的文件),上限 50GB | ¥0 |
|
||
| 基础版 | 全量备份,500GB | ¥15/月 |
|
||
| 专业版 | 全量备份,5TB | ¥50/月 |
|
||
| 按量 | 超出部分 | ¥10/TB/月 |
|
||
|
||
---
|
||
|
||
## 十四、明确不做的方向
|
||
|
||
| 方向 | 原因 |
|
||
|------|------|
|
||
| 缩略图/预览生成、Web 相册 | 破坏「透明代理」核心定位,产品本质是协议透传不是数据加工 |
|
||
| AI 选片 | 非核心,远期可选 |
|
||
| 程序员场景(Git 缓存、Docker 镜像等) | 痛点不够强,已有成熟方案(Git 天然分布式、Codespaces 等) |
|
||
| 公网文件分享链接 | 法律风险 + 需求不明确 |
|
||
| 多设备 SaaS 管理面板 | 需求不明确,过早 |
|
||
| Docker 开放运行环境 | 产品定位发散(注:这里指的是允许用户在盒子上运行任意 Docker 容器,而非 4.21 的"将本产品打包为 Docker 镜像部署") |
|
||
|
||
---
|
||
|
||
## 十五、术语表
|
||
|
||
| 术语 | 说明 |
|
||
|------|------|
|
||
| **Cache Proxy / 盒子** | 本产品——部署在用户身边的 SSD 缓存代理设备 |
|
||
| **NAS** | 用户家中的网络存储设备(Network Attached Storage) |
|
||
| **VFS** | rclone 的虚拟文件系统层(Virtual File System),将远程存储挂载为本地目录 |
|
||
| **FUSE** | 用户空间文件系统(Filesystem in Userspace),Linux 内核机制,允许 rclone 在不修改内核的情况下提供文件系统挂载 |
|
||
| **FUSE 挂载点** | rclone 通过 FUSE 暴露的本地目录(如 `/mnt/nas-photos`),应用通过它访问远程文件 |
|
||
| **dirty / 脏文件** | 本地已修改但尚未回写到远程 NAS 的文件 |
|
||
| **clean / 干净文件** | 与远程 NAS 保持一致的缓存文件 |
|
||
| **origin_mtime** | 文件上次与远程同步时远程的修改时间,用于检测远程变更 |
|
||
| **Write-back Controller** | 自定义的回写控制器,替代 rclone 原生的盲写逻辑,加入冲突检测 |
|
||
| **sparsebundle** | macOS 的稀疏包磁盘映像格式,由大量 8MB band 文件组成,Time Machine 使用此格式 |
|
||
| **LRU** | 最近最少使用(Least Recently Used),缓存淘汰算法 |
|
||
| **SFTP** | SSH 文件传输协议,本产品与 NAS 通信的主要协议 |
|
||
| **Tailscale** | 基于 WireGuard 的组网工具,用于建立盒子与 NAS 之间的安全隧道 |
|
||
| **Headscale** | Tailscale 控制面板的开源自建实现 |
|
||
| **DERP** | Tailscale 的中继服务器(Designated Encrypted Relay for Packets),在无法直连时中转流量 |
|
||
| **Ingest / 导入** | SD 卡文件导入到缓存并自动归档到 NAS 的过程 |
|
||
| **WAL** | SQLite 的写前日志模式(Write-Ahead Logging),允许并发读写 |
|