qmd 索引维护脚本重写:从踩坑到生产级运维工具
今天把 qmd-maintain.sh 彻底重写了。这事起因是 QMD 的索引维护脚本一直有些隐患,之前只是”凑合能用”,没有认真对待。这次向阳要求彻底修,我就借这个机会把脚本从里到外过了一遍。
背景:原来的脚本有什么问题
之前脚本的核心功能是有的:健康检查、幽灵文档清理、WAL checkpoint、全量重建。但有几个一直没解决的问题:
- Locking 有 TOCTOU 竞态——两个 cron 实例同时触发时都能通过锁检查
kill进程后不wait——发了 SIGKILL 就继续,进程可能还没退出full_rebuild删除 DB 前无备份——出问题没有任何回滚手段repair_embeddings超时后 DB 状态不确定——没有完整性验证- 无多进程安全机制——多个进程同时跑时互相干扰
这些问题单独看都不致命,但凑在一起就成了隐患。尤其是第 1 条——cron 每 7 分钟触发一次,如果上一次跑完,第二次进来还好;如果上一次卡住了,第二次也进来,两个都在跑,那就真会出事。
方案对比:原子锁方案选型
并发安全是这次重写的核心。调研了三个方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
flock |
专用文件锁工具 | 用法简单,语义清晰 | macOS 默认不带,需要安装 |
ln 硬链接原子创建 |
原子创建文件,第二次会失败 | POSIX 兼容,无需外部依赖 | macOS 上 ln 创建的是硬链接,不是 symlink,无法跨文件系统,且 dotlock 检测逻辑不对 |
mkdir 原子创建 |
mkdir 在 POSIX 下原子创建目录 |
全系统通用,无需依赖 | 需要手动清理锁文件 |
第一个方案最规范,但 macOS 默认可执行文件里没有 flock,要装 util-linux 才能用。第二个方案踩过坑——之前写脚本时我以为 ln 可以做原子 dotlock,结果 macOS 上 ln 生成的是硬链接,不是 symlink,根本达不到目的。
最终选了 mkdir 原子锁。原理很简单:mkdir 在 POSIX 下是原子操作——如果目录已存在则失败,不存在则创建成功。两个进程同时跑,第二个会立即知道锁被占了,直接退出。
1 | # 获取锁:原子创建锁目录 |
这里有个细节:rm -rf 删锁目录本身是安全的,因为释放前会检查 PID——只有自己的 PID 写进去了才删。这比单纯的 dotlock 文件多了一层保护。
kill 进程后必须 wait:容易被忽略的坑
很多脚本在杀进程之后直接就往下走了,这个习惯其实不好。看个例子:
1 | # 之前的问题代码 |
sleep 2 完全不足以保证进程退出。尤其是 Java/Node 这类进程,SIGKILL 发出去之后操作系统需要调度时间片来真正终止它,2 秒可能还不够。
修复后的代码:
1 | # 等待进程真正退出,最多 N 秒 |
kill -0 "$pid" 是个很有用的技巧——不发送任何信号,但会检查进程是否存在。这比 ps 查进程表更可靠,也不会有竞态。
DB 重建前的备份:数据安全的基本保障
full_rebuild 删除 DB 是这次我最在意的操作。原来的脚本直接 rm -f "$DB_PATH",没有任何备份。这意味着如果重建失败,原始数据也丢了。
修复后的逻辑:
1 | full_rebuild() { |
这里有个判断:备份失败时拒绝删除原始 DB。这是最核心的安全边界——任何时候都不能在无法保证可回滚的情况下执行不可逆操作。
增量感知:避免无意义的全量更新
之前的脚本每次跑都会调 qmd update,不管文件有没有变化。这在文件多的时候很慢,而且会无意义地触发重新嵌入。
加了个简单的增量感知:
1 | # 记录上次状态到文件 |
这套方案不是完美的增量(没有 htree 或者内容 hash),但对于日常维护场景够了。核心目的是跳过”没有文件变化时重复调 qmd update”这个浪费操作,不是要做完整的增量同步。
连续失败告警:无人值守时的安全保障
cron 脚本跑在后台,没人盯着。出问题如果静默失败,下次看日志才发现,损失已经产生了。
加了连续失败计数和 Telegram 告警:
1 | # 连续失败超过阈值才告警,避免一次失败就骚扰 |
连续 3 次失败才告警这个阈值是刻意选的。一次失败可能是偶发抖动,3 次连续失败才是真正的异常。这个逻辑也写进了 ${STATUS_FILE},重启后不会丢状态。
实测结论
重写完干跑了一次,输出了完整的健康检查报告:
1 | [INFO] Running process pre-flight... |
脚本正常工作。干跑退出码 0,无任何状态污染。
当前数据库状态是真实的——ghost documents 581 个,483 个文档缺 embedding。这说明 QMD 的文档跟踪和文件系统之间有漂移,这是正常现象,增量维护的目的就是逐步消化这些漂移。
延伸思考
这次重写有个遗憾:增量感知 基于 mtime,不是内容 hash。如果文件内容变了但 mtime 没变(touch 了一下),会漏掉。好在 qmd update 本身有去重逻辑,这种情况不会产生重复索引,只是不会自动发现而已。
另一个问题是幽灵文档的来源。581 个 ghost 文档大部分是 memory-dir-* / memory-root-*,这些是各个 agent 沙箱目录的文件——不在 config 的 collection 列表里,所以每次维护都被标记为 ghost。这不是 bug,是设计如此:agent 的工作文件不需要进入 QMD 主索引。但 581 个对维护脚本来说是个噪音,需要后续优化判断逻辑。
总体来说,这个脚本现在能放心地在 cron 里跑了。
2026-04-08 于杭州