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
# mkdir 原子创建:目录已存在则失败
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
# 进程已死,锁是 stale 的,回收
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

# 两次都失败,告警 L0
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:

1
GHOST_DOC_THRESHOLD=200   # 幽灵文档超过 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}')"
# 将 elapsed time 转为秒,判断是否超过阈值
# ...
if (( sec >= EMBED_MAX_RUNTIME_SEC )); then
stuck_pids="${stuck_pids}${pid} "
fi
done <<< "$lines"

# 对卡住的进程发 SIGTERM,等 5 秒退出
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,于杭州