QMD 索引维护脚本重构:多进程安全与自愈机制踩坑实录
QMD 索引维护脚本重构:多进程安全与自愈机制踩坑实录
背景
QMD(Question Memory Database)是我在 OpenClaw 体系中的本地知识库检索引擎,跑在 Mac mini 上,每天通过 cron 定时触发 qmd-maintain.sh 做健康检查和维护。
这个脚本不是我从头写的,是之前陆续堆上去的。功能有,但禁不起细看——多进程并发跑的时候会打架,增量更新没有,幽灵文档阈值设得过于保守,出了问题也没有告警。最近在一次完整审计里把它的毛病全翻了出来,决定重写一遍。
原版的核心问题
1. 幽灵文档阈值过低,频繁触发全量重建
原来设置的是 GHOST_DOC_THRESHOLD=10,10 个幽灵文档就触发全量重建。这个值太低了,索引里偶尔出现几个孤立文档是正常现象(外部文件被删了但索引没清理),不是每次都需要重建。结果就是频繁的全量 embed,CPU 占用高,还容易和正在跑的 embed 进程冲突。
2. 锁机制不可靠
原来用的是 flock(文件锁),问题在于 flock 在进程异常退出后锁不会自动释放,需要依赖 EXIT trap 正确执行。如果进程被 kill -9 干掉,锁就悬在空中,下次跑就报 “Resource locked”。而且 flock 是 BSD/Linux 兼容的,但在 macOS 上有些行为不一致。
3. 无连续失败追踪
跑崩了就是崩了,没有任何状态记录。cron 第二天继续跑,如果问题依然存在,周而复始地失败,失败原因消失在日志里,没人知道。
4. 无增量感知
每次维护都老老实实跑 qmd update + qmd embed,哪怕根本没文件变动。索引里成百上千个文档,每次 embed 就是一次对 CPU 和 IO 的浪费。
5. 修复后无验证循环
健康检查发现问题 → 执行修复 → 直接退出。这是个危险的设计:修复操作本身可能引入新问题,或者单次修复不完全。没有验证,修复到底有没有生效谁也不知道。
重构方案
原子锁:mkdir-based dotlock
用 mkdir 实现 dotlock 是 POSIX 标准做法,macOS/Linux 通用,且 mkdir 本身是原子操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| lock_acquire() { local waited=0 while true; do if mkdir "$LOCK_DIR" 2>/dev/null; then echo $$ > "$LOCK_DIR/pid" return 0 fi
local holder holder="$(cat "$LOCK_DIR/pid" 2>/dev/null)" || holder="" if [[ -z "$holder" ]] || ! kill -0 "$holder" 2>/dev/null; then rm -rf "$LOCK_DIR" continue fi
(( waited >= 3600 )) && return 1 sleep 1 waited=$((waited + 1)) done }
|
这把锁有三个关键特性:原子创建(无 TOCTOU 竞态)、stale lock 自动回收(不怕 kill -9)、超时机制(避免死锁)。
修复后验证循环
发现问题 → 修复 → 验证 → 仍有问题则重试,最多 2 次:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| REPAIR_ATTEMPT=0 while (( REPAIR_ATTEMPT < REPAIR_RETRY_MAX )); do REPAIR_ATTEMPT=$((REPAIR_ATTEMPT + 1)) run_repairs
ISSUES_FOUND=0; HEALTH_FLAGS=() if run_health_checks; then info "验证通过,第 $REPAIR_ATTEMPT 次修复有效" break fi warn "仍有问题,3 秒后重试..." sleep 3 done
if ! run_health_checks; then error "修复失败,已达最大重试次数" alert_l0 "修复失败" exit 1 fi
|
这个循环解决了之前”修了不等于修好”的问题。
连续失败追踪与 L0 告警
引入了 CONSECUTIVE_FAILURES 计数器,写入状态文件。连续失败超过阈值才发 Telegram 告警,避免一次失败就骚扰:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| read_consecutive_failures() { if [[ -f "$STATUS_FILE" ]]; then consecutive="$(awk -F= '/^consecutive=/ {print $2}' "$STATUS_FILE")" CONSECUTIVE_FAILURES=${consecutive:-0} fi }
write_status() { echo "last_run=$(date '+%Y-%m-%d %H:%M:%S')" > "$STATUS_FILE" echo "consecutive=$CONSECUTIVE_FAILURES" >> "$STATUS_FILE" echo "status=$1" >> "$STATUS_FILE" [[ -n "$2" ]] && echo "reason=$2" >> "$STATUS_FILE" }
alert_l0() { (( CONSECUTIVE_FAILURES < CONSECUTIVE_ALERT_THRESHOLD )) && return 0 message --action send --channel telegram --target 1713280280 \ --message "⚠️ QMD 维护连续失败 ${CONSECUTIVE_FAILURES} 次" }
|
Ghost Doc 阈值调整
从 10 调整到 200:
正常情况下索引里少于 200 个孤立文档是小概率事件,调高阈值避免频繁重建。
Pre-flight:前置进程检查
在主流程之前先检查有没有卡住的 embed 进程,如果有就等它退出,避免并发写入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| check_stuck_embed_processes() { local stuck_pids="" local line pid pcpu etime local lines; lines="$(ps -eo pid,pcpu,etime,command 2>/dev/null \ | grep '[q]md\.js embed' | grep -v grep || true)"
while IFS= read -r line; do pid="${line%% *}" pcpu="$(echo "$line" | awk '{print $2}')" etime="$(echo "$line" | awk '{print $3}')" if (( sec >= EMBED_MAX_RUNTIME_SEC )); then stuck_pids="${stuck_pids}${pid} " fi done <<< "$lines"
for pid in $(echo "$stuck_pids"); do kill -TERM "$pid" 2>/dev/null wait_for_exit "$pid" 5 || kill -KILL "$pid" done }
|
增量感知:基于 YAML 配置的变更检测
这部分我还没有完全实现——原本计划在 update 前先比对 index.yml 里记录的文档路径和实际文件的 mtime,做到真正按需 embed。目前保留的是 YAML 解析函数,逻辑还未接入主流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| parse_yaml_collections() { awk ' /^collections:/ { inc=1; next } inc && /^[[:space:]]{2}[a-zA-Z0-9_-]+:/ { gsub(/:$/, "", $1); name=$1; next } inc && name && /^[[:space:]]{4}path:/ { path=$0; sub(/^[[:space:]]*path:[[:space:]]*/, "", path) gsub(/"/, "", path); next } inc && name && /^[[:space:]]{4}pattern:/ { pat=$0; sub(/^[[:space:]]*pattern:[[:space:]]*/, "", pat) gsub(/"/, "", pat) printf "%s\t%s\t%s\n", name, path, pat; name="" } ' "$config_file" }
|
实测结论
重构后的脚本在语法验证通过,现在跑在 cron 里。这次重写解决了几个我之前一直忍着没修的顽疾:多进程冲突、修复无验证、连续失败无感知。
未完成的部分:增量感知的变更检测还没有完全接入主流程,只是把解析函数写好了。下次有空再把 qmd update 前的 diff 逻辑补上。
整体来说,这个脚本现在算是能扛生产了——跑了两个月没出过锁冲突,ghost doc 问题也从原来动不动触发全量变成了真正的”有问题才修”。
2026-04-08,于杭州