这几天我把一个原本只服务于自己 NAS 的媒体整理脚本,逐步做成了一个可以公开发布的 Go CLI 项目:nas-media-archiver

这个过程很适合记录下来,因为它并不是一个“先设计好架构,再按部就班实现”的过程。相反,它更像是一次不断做减法、不断靠近问题本质的工程收敛。

起点其实很简单

一开始的需求非常直接:

  • NAS 上有很多手机备份出来的照片和视频
  • 目录结构混乱,来源很多
  • 文件名没有统一规范
  • 重复文件很多
  • 时间信息不稳定

目标也很明确:把这些文件从输入目录整理到归档目录,统一命名成:

1
YYYYMMDD_HHMMSS_SIZE.ext

并按年月放到:

1
2
3
photos/YYYY/MM
videos/YYYY/MM
pngs/YYYY/MM

看起来像是一个脚本问题,但真正做起来,很快就不是“写几行 shell”能解决的了。

第一轮误区:把问题想复杂了

最初我试图把它设计成一个更“完整”的系统:

  • Mac 桌面客户端
  • NAS 端服务
  • HTTP API
  • 后面又想过 gRPC
  • 甚至想过 Web 前端和 Next.js

这些东西单看都合理,但放到这个问题里,其实是偏离了主轴。

因为这个项目真正的核心,不是“远程控制”,也不是“多端 UI”,而是:

  • 本地文件扫描
  • 元数据提取
  • 去重
  • 原子移动
  • 状态持久化
  • 失败恢复

也就是说,它的本质是一个运行在 NAS 本机上的文件处理程序。

一旦把这个边界想清楚,很多复杂度就可以直接砍掉:

  • 不需要桌面客户端
  • 不需要 Web
  • 不需要 server
  • 不需要 gRPC
  • 不需要数据库

最后收敛出来的形态反而非常朴素:

  • 一个单机 Go CLI
  • 本地 job.json
  • 本地 events.jsonl
  • 直接在 NAS 上运行

这是整个过程里最重要的一次判断。很多工程工作,难点不在于“怎么做”,而在于“哪些东西根本不该做”。

为什么最终没有继续用 shell

如果只是扫目录、跑 find、调用 ffmpegmv 一下,shell 脚本当然也能做。

但一旦需求里包含下面这些条件,shell 就开始显得吃力:

  • 每个文件要有明确状态
  • 任务中断后要能恢复
  • 要能查询失败文件
  • 要能做 dry run
  • 要能导出结果
  • 要能逐步优化性能

shell 适合线性流程,不适合承载复杂状态机。

所以最后的方向不是“继续堆 shell”,而是做一个比脚本更可维护、但比服务架构更轻的工具。

Go 很适合这个中间地带。

真正的瓶颈,从来都不是语言本身

在实现过程中,我一度也想过:

  • 要不要换 Rust?
  • 要不要纯 C?
  • Go 会不会太慢?

但真实测下来,瓶颈根本不是 “Go vs Rust vs C”。

主要瓶颈其实是这几类:

  1. 每个文件整份重写 job.json
  2. 每条事件都反复打开/关闭 events.jsonl
  3. 完全串行处理
  4. 视频元数据依赖外部进程
  5. 跨文件系统移动导致 rename 失败,需要复制

也就是说,瓶颈主要来自:

  • I/O 模式
  • 外部进程
  • 任务模型
  • 状态持久化方式

不是来自语言 runtime。

哪些优化是真的有效

后面做了几轮优化,对性能影响最大的不是大改语言,而是两类结构性调整。

1. 降低状态持久化开销

最早的版本里,每处理一个文件就:

  • 重写整份 job.json
  • 单独打开一次 events.jsonl

这在几百个文件时还能忍,到了几万甚至十万文件就会非常浪费。

后来改成:

  • job.json 批量写
  • events.jsonl 在运行期间保持文件句柄打开

这一步本身就能带来明显改善。

2. metadata 并发,writer 单线程

我没有把整个流程都做成完全并发,而是保留了一个我认为更稳的边界:

  • metadata / planning 并发
  • 最终写入单线程

这样做的好处是:

  • 前面的探测和规划可以提速
  • 最后文件写入仍然容易保证正确性
  • 不容易引入重复写、路径冲突或奇怪竞态

在一台 4 核 ARM 的 QNAP NAS 上,dry run 的最佳点最后落在:

1
2
--workers 8
--snapshot-every 100

比早期单线程版本大概有 2 倍以上提升。

这个结果也进一步验证了一件事:先改处理模型,通常比先换语言更值。

真机运行时,问题往往不是你在本地能想到的那个

真正把程序跑到 NAS 上以后,出现过几个非常真实的环境问题。

1. 输入目录和归档目录不在同一文件系统

最开始我默认 rename 就能完成移动,结果真机一跑,直接报:

1
invalid cross-device link

原因是输入目录和输出目录不在同一个文件系统上。

后面改成了:

  1. 先复制到目标目录的临时文件
  2. fsync
  3. rename 到正式文件名
  4. 删除源文件

这一步属于“不是理论优化,但是真机必须处理”的工程现实。

2. QNAP 的 ACL 和 admin 模型

QNAP 不是普通 Linux。

它的共享目录、ACL、admin 权限模型都有自己的行为方式,导致“程序能跑”和“程序能正确写共享目录”是两件事。

最后比较可靠的执行方式是:

  • 普通用户负责上传和扫描
  • 真实归档时 sudo -u admin

这件事如果不记录清楚,别人即使拿到程序,也很容易以为是工具坏了,实际上是权限模型不一样。

3. 源文件在运行过程中消失

在实际批量归档时,我碰到过几次:

  • 任务运行到一半
  • 同步系统把源文件移动或清掉了
  • 程序报 no such file or directory

早期实现会把整个 job 标成 failed

后来我把这个逻辑改成:

  • 如果文件在处理过程中已经不存在
  • 标记成 skipped
  • 不把整个 job 当成灾难性失败

这是那种只有在真实环境里连续跑很多目录之后,才会意识到必须补的边角。

从私人脚本变成公开仓库,难点不在代码

真正把项目推上 GitHub 时,我发现最麻烦的不是“发布”,而是“去个人化”。

比如这些东西都不适合直接公开:

  • NAS 的 IP
  • SSH 用户名和密码
  • 私有部署路径
  • 自己真实目录名
  • 真实 job id
  • 某次归档的运行记录

所以我做了几轮整理:

1. 把敏感信息移到 .secrets

公开仓库不应该出现任何真实连接信息。

2. 文档改成通用占位符

像下面这种:

1
2
/share/ssd/upload/iphonexs-2026-03
job-20260327-155804

都不应该出现在公共文档里。

要改成:

1
2
/path/to/input
<job-id>

3. 删掉太 personal 的运行报告

我原来还保留了一篇真实归档报告,里面有真实文件名、真实路径、真实时间和性能细节。

这类内容对自己有价值,但不适合作为 public repo 的正式文档。最后我把它删掉了,只保留泛化后的 benchmark 结论。

4. 调整文档结构

原来仓库根目录里散着很多中文文档名。

后来统一整理成:

  • README.md
  • docs/usage.md
  • docs/design.md
  • docs/benchmarks.md
  • docs/qnap-notes.md

开源仓库的第一印象,往往不是代码,而是目录结构和 README。

这次做对的几件事

如果回头看,我觉得有几件事是做对了的。

1. 不断做减法

从桌面客户端、Web、gRPC,最后收敛成单机 CLI。

2. 先解决真实瓶颈

没有急着重写语言,而是先处理:

  • 状态写入开销
  • 外部进程成本
  • 并发模型
  • 跨文件系统移动

3. 在真实环境里反复跑

很多问题不是代码审查能看出来的,只能靠真机、大量目录、长时间执行去暴露。

4. 在开源前认真做清理

至少要把:

  • 敏感信息
  • 个人路径
  • 临时运行记录
  • 过于内部化的文档

清掉之后,才像一个别人可以真正使用的项目。

还没做完的部分

项目虽然已经能用了,但还远远不是“完成态”。

后面比较值得继续做的还有:

  • 支持 gif / webp
  • 增加回归测试
  • 优化 watch / status 的统计输出
  • 继续降低视频元数据 fallback 的成本

最后

这次过程对我最大的提醒其实很简单:

工程里最重要的能力之一,不是把系统做大,而是把系统做对。

很多时候,最有价值的不是多加一层架构,而是少加一层架构。不是多引入一个组件,而是确定这个组件根本没必要存在。

最后这个项目落成的形态很朴素:

  • 一个 Go CLI
  • 一些本地状态文件
  • 一套可恢复、可追踪、可批量执行的归档流程

但它已经足够解决真实问题。