
背景单机本地工具的存储形态我在维护一款微信自动回复/AI 接待方向的本地工具纯单机架构会话记录、消息历史、联系人和回复日志全部落在本地磁盘不依赖任何云端存储。存储层用了两年多长期是两层的形态- SQLite 承担结构化持久化。会话表、消息表、联系人表、回复流水表都在一个sessions.db里。日志模式用的是 WALjournal_mode wal选它的理由很直接本地 GUI 进程和后台采集进程会并发读写WAL 的单写多读让读不再被写阻塞这对一个几乎全天候有后台写入的桌面工具来说是比回滚日志DELETE 模式平滑得多的选择。- 消息中转目录承担落库前的缓冲区。后台采集进程收到一条消息先在消息目录里写一个.part文件主进程启动或收到唤醒信号后把目录里的 part 解析、入库、然后删除对应文件。这个设计起于早期版本——当时主进程经常还没起来消息不能丢先落文件再落库成了一条看起来朴素稳妥的通路。这个形态跑了一年多问题不是先出在 SQL 上而是出在文件数量和日志文件这两个没人盯的地方。## 踩坑启动链路不崩只是慢坏消息一开始很模糊打开历史会话面板明显变慢偶尔卡住工具从冷启动到可交互的等待时间越拉越长。等我决定认真排查时有一次点开历史会话面板卡了将近一分钟——没有崩溃、没有报锁、没有弹窗任务管理器里 CPU 占用很低磁盘活动时间零星跳动就是一份典型的看着没干活但就是不动的现场。先按直觉排了三个嫌疑1. 数据库被锁。SQLite 遇到锁会返回SQLITE_BUSY日志里通常有重试记录或报错。翻了一遍应用日志没有任何 busy 相关记录排除。2. 杀软或系统索引在扫目录。换到深夜磁盘安静时段复测卡顿依旧排除。3. 磁盘 I/O 性能衰退。同盘读一个大文件计时速度正常写一个测试库、建索引、批量插入都流畅排除。三个方向全部落空说明卡顿不在数据库引擎慢也不在磁盘慢。回头看启动链路它是一条串行同步的流水线扫描消息目录 → 合并 part 入库 → 打开会话库、加载历史会话列表。链路里任何一段慢整体就慢而三段串在同一个函数里慢在哪一段根本看不出来。排查就从拆开计时开始。## 排查逐层拆开定位两处病灶### 步骤一先数消息目录的文件把启动链路拆开跑计时第一刀砍在目录扫描上数字吓人bash$ find D:/tool/data/msg_parts -type f -name *.part | wc -l65416$ find D:/tool/data/msg_parts -type f -name *.part -size -128c | wc -l64575$ powershell -NoProfile -Command \ [math]::Round((Get-ChildItem D:/tool/data/msg_parts -File \ | Measure-Object -Property Length -Sum).Sum / 1MB, 2)5.32消息目录下堆着 65416 个 part 文件其中 64575 个不足 128 字节——只有消息头、没写进正文的空壳来自采集进程处理到一半被异常中断的场景进程崩溃、断电强杀、版本升级时旧进程没退干净。全部文件加起来才 5MB 多一点但代价根本不在体积而在条目数- 目录遍历要逐条走目录项单目录几万文件之后NTFS 的枚举耗时肉眼可见地上涨这个结论对 ext4 同样成立。- 更要命的是这条全目录扫描被绑在打开历史会话面板和每次冷启动的路径上重复执行。正常的合并通路收到消息→入库→删文件早就失效了主进程断掉之后没人替它把残留的 part 收掉合并只在启动时跑一次目录越大启动越慢越慢用户越不想重启工具碎片越积越多——一条碎片堆积 → 启动变慢 → 不爱重启 → 碎片继续堆积的正反馈跑了一年多。### 步骤二再看 SQLite 侧的 WAL 状态目录是一头库内是另一头。查日志模式与 checkpoint 状态bash$ sqlite3 D:/tool/data/sessions.db PRAGMA journal_mode;wal$ sqlite3 D:/tool/data/sessions.db PRAGMA wal_checkpoint;0|74612|74612$ ls -l D:/tool/data/sessions.db-wal-rw-r--r-- 1 me me 307658752 Sep 24 22:41 sessions.db-walwal_checkpoint 返回三个数busy 标志、WAL 帧数、已 checkpoint 帧数。那次返回 busy0 且两数相等说明执行时刚好没有读连接抢锁一次性收干净了。但 -wal 文件已经涨到几百 MB暴露的是另一个结构性问题默认自动 checkpoint 的阈值是 1000 帧而 WAL 模式下只要有任何读事务在跑checkpoint 一旦返回 busy1 就直接放弃、不重试。本地工具恰恰是几乎永远有读的负载GUI 面板常年开着一条只读连接后台采集进程也时不时查一下历史消息。于是 WAL 帧常年积压、日志文件常驻膨胀读查询要在主文件和 WAL 之间做合并查找越到后期越吃力。到这里两处病灶都现形了**应用层的中转目录碎片堆积是主因数据库层的 WAL 长期无人调度是次因**。一个在文件系统里一个在日志文件里共同点是都没有人监控数量这种维度。### 步骤三对照实验钉死主因为了把目录枚举慢从数据库慢里摘干净做了个对照实验在同盘新建一个 65000 个空文件的目录只测枚举耗时pythonimport os, time, pathlibbench pathlib.Path(“D:/tool/data/bench_parts”)# 造一个同等规模的空目录只做枚举计时t0 time.perf_counter()n sum(1 for _ in os.scandir(bench))print(n, f{time.perf_counter() - t0:.1f}s)单独枚举就要四十秒开外和启动时卡顿的体感基本吻合。而同一时刻对 sessions.db 直接跑一条按会话聚合的查询只用几十毫秒。结论钉死卡顿主因是单目录 65416 个碎片的枚举WAL 膨胀是次要放大项。如果不做这一步对照很可能第一反应去调 SQL、建索引方向就全偏了。## 修复先合并归位再治理日志### 一次性合并分批事务、幂等键、先提交后删除修复思路一句话把 part 文件全量合并进消息库然后清空目录最后归零 WAL。合并脚本要敢在几万个文件上跑靠三条纪律1. 幂等消息表的 msg_id 带 UNIQUE 约束重复导入走 INSERT OR IGNORE 直接跳过天然去重。脚本中途断了、跑两遍结果一致。2. 分批事务每 500 条一个事务。一口气 6 万条的巨型事务会把 WAL 顶出一大段帧还会长时间占写锁分批之后读连接有空隙可进进度也可见。3. 先提交后删除事务提交成功之后才删除对应 part 文件。提交前进程被打断文件还在重试自动接上。pythonimport os, json, sqlite3DB sqlite3.connect(“D:/tool/data/sessions.db”)PART_DIR “D:/tool/data/msg_partsBATCH 500def flush(buf): if not buf: return with DB: # 事务内提交 DB.executemany( “INSERT OR IGNORE INTO message” “(msg_id, conv_id, ts, sender, body) VALUES (?,?,?,?,?)”, buf, ) for row in buf: os.remove(row[-1]) # 提交成功才删源文件 print(“merged”, len(buf), “flush”, DB.execute( “PRAGMA wal_checkpoint(PASSIVE)”).fetchone())buf []for name in os.listdir(PART_DIR): if not name.endswith(”.part): continue path os.path.join(PART_DIR, name) try: meta json.loads(open(path, encoding“utf-8”).read()) msg_id meta[“msg_id”] except (json.JSONDecodeError, KeyError, OSError): os.remove(path) # 空壳/损坏文件直接丢弃 continue buf.append((msg_id, meta[“conv_id”], meta[“ts”], meta[“sender”], meta[“body”], path)) if len(buf) BATCH: flush(buf) buf []flush(buf)跑完的结果64575 个没有正文的空壳文件在解析阶段被当作损坏丢弃它们本来也不该进库剩余的真消息合并入库消息表行数与库里原有行数 文件里的真消息数逐段对账一致没有丢消息也没有重复插入。目录里 part 清零。整个合并过程十来分钟期间面板查询明显比修复前轻快——因为那条全目录扫描的路径已经没东西可扫了。### WAL 治理checkpoint(TRUNCATE) 归零 上限约束碎片清完趁库空闲把日志收拾干净sql-- 在没有任何读连接的窗口执行PRAGMA wal_checkpoint(TRUNCATE); – 帧全部回写主库并把 -wal 截断到 0 字节PRAGMA journal_size_limit 4194304; – 之后 WAL 回落时只保留 4MB 备用段PRAGMA synchronous NORMAL; – WAL 模式下的常规档位崩溃不损坏库三个参数的分工要理清楚wal_checkpoint(TRUNCATE) 是一次性大扫除把积压帧全部写回主库并把日志文件归零journal_size_limit 是长效闸门checkpoint 之后 WAL 允许保留的备用空间有上限日志不会无限再膨胀synchronous NORMAL 配合 WAL 是常见的耐久档位——进程崩溃不会损坏数据库文件极端断电可能丢最后一小段事务对本地会话记录这种数据是可接受的如果换成钱货两讫的账本场景这个档位要另议。另外把自动 checkpoint 阈值从默认 1000 帧调大到 4096 帧本地读多写少的负载下频繁小 checkpoint 抢锁失败的次数太多不如攒一批、在窗口期一次做完。关键是执行时机TRUNCATE 要求没有读事务在场所以这个动作做成了后台维护任务的一部分——检测到 GUI 空闲面板无查询时触发而不是绑在每次启动路径上。### 源头治理中转改单文件追加合并走增量一次性修复只能救今天要不再长碎片得动中转机制本身- 采集进程不再一条消息一个 part改为把待处理消息按行追加进单个 inbox.jsonl写完 fsync合并进程消费后做截断重写。中转的条目数从随消息量增长变成恒定 1 个文件。- 合并逻辑改增量记录上次消费到的字节水位启动只读水位之后的新增部分从此告别全目录枚举这里彻底是动作描述不是效果承诺——水位文件本身也纳入启动自检防止漂移。- 加了一个最基础的监控指标msg_parts 目录的文件数。超过一千就记一条告警日志。当年哪怕只有一个这种数字也不至于堆到 6 万才发现。## 验证目录归零、日志归零、体感恢复bash$ find “D:/tool/data/msg_parts” -type f | wc -l0$ ls -l D:/tool/data/sessions.db-wal-rw-r–r-- 1 me me 0 Sep 25 03:12 sessions.db-wal$ sqlite3 D:/tool/data/sessions.db PRAGMA wal_checkpoint;“0|0|0part 目录 65416 个文件归零-wal截断到 0 字节checkpoint 三项全零。打开历史会话面板从卡近一分钟回到即点即开冷启动的耗时构成里目录枚举那一段整体消失。之后连续跑了两周journal_size_limit生效WAL 没有再涨回大体积inbox.jsonl方案上线后中转目录不再新增文件。## 复盘四条经验1.碎片化临时文件是拿条目数换的设计债。“一条消息一个文件在写的时候很直观原子、好定位、不怕写坏整块。但目录枚举的成本随条目数上涨而且这个成本藏在启动路径里平时不可见堆到几万才爆炸。凡是文件数随流量增长的设计从第一天就要配回收路径和条目数监控别指望事后扫。2.WAL 的 checkpoint 需要主动调度而不是甩给默认阈值。自动 checkpoint 碰到 busy1 就放弃且不重试“几乎永远有读连接的本地工具正好踩在这个机制的盲区里。要么在空闲窗口手动wal_checkpoint(TRUNCATE)要么用journal_size_limit兜住上限别把日志文件的膨胀当成自然现象”。3.慢要拆开分层计时再定性。打开面板卡顿的第一反应是数据库慢”但拆开计时后发现主因在文件系统枚举数据库侧只是次要放大项。串行链路不拆段计时修复就会打偏——这次如果先去给消息表加索引方向全错。4.修复脚本要按会跑两遍来设计。几万个文件的事务化合并中断是常态不是意外。UNIQUE 键 INSERT OR IGNORE 先提交后删除这三件事让脚本天然幂等再加上分批小事务让修复过程本身不再制造新的 WAL 尖峰。这套纪律和消息系统的去重幂等是同一个思路——数据修复路径才最考验幂等。## 收尾回头看这次事故没有一行代码是错的”WAL 选得合理先落文件再入库的缓冲设计初衷也没问题错的是两个维度的数量从来没人看——目录条目数、WAL 帧数。单机本地工具不像服务端有成熟的监控体系很多病灶只能靠主动把计数当指标。把文件数、日志大小列进自检清单的那一行代码往往比事后写三百行修复脚本便宜得多。