"database is locked" 到底从哪来?Hermes 一次修掉了 state.db 的锁争抢、双写者和假警报


周日晚上你给 Hermes 挂了个定时任务,周一早上打开终端想看结果,第一条命令就卡在那儿不动。等了二十来秒,屏幕吐出五个字:database is locked。你没有同时跑两个 Hermes,也没手动开过数据库——那这把锁是谁拿的?

9 月 11 日合并的一批修复回答了这个问题,而且答案有点反直觉:锁不是“别人”拿的,是 Hermes 自己在每次打开会话数据库时顺手拿的;另外还有一批进程,本来就不该碰这个文件。下面按“谁在抢锁、谁在偷写、什么被误判”三条线讲清楚。

一、开一次库就要上写锁:一个 4 秒的卡顿

先说个背景:Hermes 把所有会话历史存在一个叫 state.db 的 SQLite 文件里(session_search、hermes sessions、导出、统计都读它)。SQLite 的并发规则很朴素——协议上的写语句就会拿写锁,哪怕它一行都没改。

问题就出在这儿。过去每个读写方式打开 state.db 的动作,都会顺带执行一串“实际上什么都没改”的写语句:

  • 写入 generation 戳(INSERT OR IGNORE);
  • UPDATE messages SET active=1 WHERE active IS NULL;
  • FTS 存储版本标记的 upsert;
  • 四个展示用触发器的 DROP TRIGGER IF EXISTS + CREATE TRIGGER。

于是一个完全健康的、没有任何待办写入的数据库,也会让兄弟进程(cron 的一轮、一次 hermes sessions、一个 doctor 检查)排队等着它把整段事务做完。等超过忍耐时间,就直接失败成 database is locked。

修法很直接:先读,确认需要改再写。戳存在就不写;只有真的存在 active IS NULL 的行才修;版本标记值相同就跳过;触发器只有在 sqlite_master.sql 里存的定义与期望不一致时才重建。

PR 里的实测数字很能说明问题:

场景 修复前 修复后
稳定库,兄弟进程持写锁 4 秒 开库耗时 4.14 秒 0.01 秒
持锁者超过忍耐时间 20.5 秒后报 database is locked 0.01 秒正常开库

也就是说,卡顿和报错的根源不是“库被占用了”,而是“明明不需要写,却照样申请了写权限”。

二、谁在偷偷当第二个写入者

同一批修复还揪出了四个把主会话库当自家草稿纸用的角色:

  • 跨 profile 的群聊协调库:过去它落在根 gateway 的 state.db 上,而每个 profile 的 gateway 都会启动群聊 worker——等于每个 profile 进程都成了主会话库的长驻写入者。现在它使用安装根目录下的 shared-state.db,不再碰会话库。
  • Web 面板:启动时的 schema 校对改成只读打开;只有真的需要修复 schema 时,才会走一次可写打开。
  • cron 的生命周期守卫:它会扫描命令里出现的路径并直接 os.open 读取——而一条命令只要提到 gateway 的 state.db,就会顺手取消 gateway 的 POSIX 锁。现在这类读取被纳入离线文件访问注册表统一管理。
  • hermes doctor --fix:检查点操作现在先看有没有活跃写入者。有就跳过并给一条可执行的提示,而不是插进去当第二个连接。

这几处加起来,才是 database is locked 在真实机器上反复出现的完整原因:不是单个 bug,而是“一个库、多个写入者”的拓扑。

三、WAL 明明健康,却被判了“死刑”

第三类问题更隐蔽。Hermes 有一个守护逻辑:如果 state.db 的 WAL 侧车文件被人删了,就切换回 JSONL 存储(慢,但安全)。这个判断的旧依据是 Linux 上 /proc/<pid>/fd 里的 " (deleted)" 后缀。

问题在于,OpenZFS 上一个仍然正常链接着的 -wal/-shm 文件也会被这样报告。于是守护永久触发,gateway 从此一直跑在 JSONL 上——性能下降,但没有任何报错。

现在的判断改成比对文件描述符的 (st_dev, st_ino) 与当前监视路径。真的被硬链接续命的孤儿文件依然会被抓出来(保留 fail-closed 的严谨性)。

同一族里还有两个竞态:

  1. 写入路径在拿自己的锁之前就跑了一遍“侧车消失”探测,而关闭流程是在锁内删除侧车的。写者与一次干净的关闭赛跑,就会把自己标成“WAL 丢了”,这个标记是粘性的。修复是把探测挪进锁里,11 行代码。
  2. WAL 探测不确定时,底层函数会返回 "wal" 作为假设模式,而 SessionDB 据此启用了免锁的只读连接池——如果文件其实是 DELETE 模式,这个池就是错的。现在必须先确认 WAL 头;确认不了就让读请求老实地排队走写者锁。

四、一行坏数据不该毁掉整张表

除了锁,还有一个“雪崩点”:一行坏数据能让整条命令挂掉。过去的场景包括——某个时间戳格子存了一段文本垃圾、某行的 model_config JSON 坏掉、或者一次批量删除超过了 SQLite 的绑定变量上限。

被拖下水的命令包括 hermes sessions list、hermes sessions browse、会话导出(JSONL / Markdown / HTML)、hermes insights 和 sessions prune。

现在的处理方式是分层兜底:

  • 读:统一走 hermes_cli.timefmt.coerce_epoch(),把存储值换算到合理窗口(1970 到大约 2103,落在 32 位 time_t 内)里的 epoch 秒;超出窗口就返回空值并打一条带 session id 的告警。坏格子显示成 ? 或 N/A,命令正常跑完。
  • 写:写入路径用同一套窗口守卫,越界的时间戳回落到当前时间。也就是说,Hermes 以后写不出新的坏行。
  • JSON:所有读取 model_config 标记的地方统一用 json_valid 兜底。
  • 批量:大清理查询按块发 IN 列表,绕开 “too many SQL variables”;导出时的 N+1 查询改成每 900 个 id 一批。

五、会话不再跨 profile 串门

如果你的机器上有多个 profile,这批修复里还有两条和你直接相关:

  • Desktop 启动后端过去调用 acquire() 时不带路径,第一次触达会按当时生效的 HERMES_HOME 覆盖来解析——如果那一刻正好有别的 profile 的覆盖窗口,这个进程级句柄就会永久指向另一个 profile 的库。现在它被钉在启动时的 home 上。
  • session_search 的裸 id 兜底扫描被删掉了。以前你在本 profile 找不到某个会话,它会去翻每个 profile 的库,返回第一个命中者的完整会话——还不告诉你这是谁的。现在找不到就是找不到,并提示你显式写 profile= 或 @session:<profile>/<id>。

另外,删除 profile 时现在会先关掉本进程的共享句柄再删目录,修掉了 Windows 上常见的 WinError 32(文件被占用删不掉)。

六、搜索索引坏了,不等于你的记录坏了

最后一条很实用:state.db 里的全文搜索索引(FTS5)损坏,过去会被笼统地报成“整库损坏”,然后把用户推向“恢复/重建”这条重路。

现在按错误来源分类:来自 messages_fts* 影子表的错误被归为 fts_index,界面会明确告诉你“搜索降级,正式记录完好”。同时:

  • hermes doctor 会通过 sqlite_master.rootpage 分辨“是索引坏了”还是“是数据表/索引结构坏了”,后者才建议走恢复通道;
  • hermes sessions repair --check-only 在不健康时以非 0 退出码结束,方便写进脚本;
  • 恢复通道现在能处理真实事故库里出现的形状:撕裂的 page-1 头、幽灵 NOT NULL 行、损坏的 rowid 边界、CJK 索引族、以及 .recover 留下的孤立影子表。

你该怎么做

升级到最新版本即可(hermes update)。想确认自己机器上的状态,这三条命令够用:

hermes sessions list --limit 5   # 列表还能正常读出来吗
hermes sessions repair --check-only   # 不健康时返回非 0
hermes doctor --fix              # 结构损坏会指出是索引还是数据表,并给出下一步

如果你是多 profile 用户,记得把 profile 显式带上:

hermes -p worker doctor --fix

小结

这批修复的共同点,是把“锁”这个模糊的现象拆成了三类具体原因:不需要写却申请写权限、多个进程争当唯一写入者、以及一次误判导致的永久降级。对用户来说,最直观的变化是——一个刚开完的库应该瞬间打开,而不是让你盯着屏幕等二十秒。

想回顾更早一轮的数据库自愈机制(检测、隔离、修复),可以看 state.db 自愈那一篇;会话为什么应该定期结束、而不是无限续跑,会话边界那篇讲得很透;日常排查数据库的命令清单在 hermes sessions 命令参考;这批修复目前只落在 main 上,等下一个版本打包后会在发版记录里出现。