会话库打开即卡死:65416个part里64575个84B碎片的合并、WAL治理与恢复语义
一、现象762MB 的会话库点开就冻结这个案子来自作者一款本地部署的微信自动回复工具的会话存储库。作为这类工具的开发者我过去一直把本地 SQLite 当成整条链路里最不需要操心的组件单机、单进程写入、并发压力几乎为零能出什么事直到用户反馈一个在开发环境几乎复现不出来的现象客户端只要一点开会话列表整个界面立即冻结短则十几秒长则半分钟往上系统甚至弹出了未响应的灰条。此时后台没有任何同步任务在跑网络请求响应正常CPU 占用也不高——唯一的异常是那个会话库文件已经悄悄膨胀到了 762MB。762MB 是什么概念按这个库两年的真实积累估算全部会话文本的原始内容撑死几十 MB加上索引的合理开销翻一倍也够了。也就是说这 762MB 里绝大部分体积既不是内容也不是索引的合理代价而是某种结构性垃圾。而且冻结发生在打开这个动作上说明问题大概率不在写入路径而在某个随行数线性恶化、每次打开都要现场支付的读取路径上。这篇复盘按当时的实际顺序完整记录取证排查怎么做的、怎么定位到 84 字节碎片的、根因是什么、修复按什么顺序做、最后沉淀了哪些预防机制。涉及三块主题行级碎片化的取证与合并、WAL 模式下 checkpoint 的治理以及 last-wins 恢复语义在一个隐蔽场景里挖的坑。二、取证怎么定位到 84 字节的2.1 宏观体检先看文件再看页排查数据库问题我的习惯是先不碰业务代码直接以只读方式看文件本身——这样既不会让检查动作污染现场也避免应用层缓存干扰判断。# 第一步看主库文件和 WAL 伴生文件的真实大小ls-lhsession.db session.db-wal session.db-shm# 第二步只读模式打开绝不在这台病人身上先做写操作sqlite3file:session.db?modero这里顺带解释三个文件的角色session.db 是主库文件-wal 是预写日志未合并回主库的修改都压在这里-shm 是 WAL 的共享内存索引wal-index 的落盘载体辅助读路径快速定位某页的最新版本在哪一帧。主文件之外 -wal 也很大时基本可以断定 checkpoint 长期没跑利索——这是后话。进到 shell 里先看页级参数PRAGMA journal_mode;-- wal确认日志模式PRAGMA page_size;-- 4096默认页大小PRAGMA page_count;-- 总页数PRAGMA freelist_count;-- 空闲页数PRAGMA integrity_check;-- 结构完整性先排除损坏762MB 按默认 4096 字节的页折算接近二十万个页——对一个本地会话库来说近乎天文数字。这四条读数里信息量最大的是 freelist_countSQLite 里 DELETE 释放的页会进入空闲链表但文件不会变小所以 freelist 占比高说明历史上发生过大规模删除且从未归还空间而本案 freelist 占比并不突出说明体积主要不是历史删除留下的窟窿而是活数据本身被存成了碎片。接着看每个对象到底占了多少页-- 各表和索引的字节数排名。-- dbstat 是虚拟表需要 SQLITE_ENABLE_DBSTAT_VTAB 编译选项-- 官方发布的 sqlite3 命令行工具一般已内置。SELECTname,SUM(pgsize)/1024/1024ASmbFROMdbstatGROUPBYnameORDERBYmbDESCLIMIT10;结果里part 表和它的两个索引稳居榜首加起来占了库体积的大头。到这里方向已经清楚不是某个失控的日志表或临时表而是会话 part 这张业务表本身在膨胀。2.2 行数与长度分布84 字节的指纹下一步看行数和行长分布。part 表的设计定位是消息内容的分片存储一行理应承载消息的一段有意义的增量-- 某个会话的 part 行数SELECTCOUNT(*)FROMpartWHEREsession_id...;-- 结果65416一个会话六万五千多行 part已经离谱。继续按 payload 长度画直方图SELECTLENGTH(payload)ASlen,COUNT(*)AScntFROMpartWHEREsession_id...GROUPBYlenORDERBYcntDESCLIMIT10;输出一目了然65416 行里64575 行的 payload 长度精确等于 84 字节。不是84 左右的一个区间是一字不差的 84占总行数的 98.7%。剩下 841 行才是长度各异的完整记录。长度直方图上出现占绝对主导的单点尖峰强烈说明这不是业务数据自然生长出来的分布而是某个固定分片逻辑的指纹上游以固定大小的块为单位落库块的净载荷恰好是 84 字节。抽样验证一下SELECTts,payloadFROMpartWHEREmsg_id...ORDERBYtsLIMIT5;连着看几条消息模式完全一致每个 84B 行就是某次流式输出里一小段增量文本的原始切片各自带着时间戳和序号。切出来是什么不重要重要的是为什么会按这个节奏切、以及为什么两年没人管。一个可以写进排查笔记的判断方法长度直方图上的单点尖峰几乎总是分片逻辑的指纹而不是内容特征。内容的长短是自然分布有胖有瘦不会几万行挤在同一个精确数值上只有按固定缓冲切的机械行为才会制造这种尖峰。顺着指纹反查写入代码很快就能找到那个来一块写一块的落库点也能顺藤摸瓜确认上游分片的节奏。2.3 按消息聚合平均 43.5 个 part 行再从消息维度统计每条消息被拆成了多少行SELECTAVG(cnt),MAX(cnt)FROM(SELECTmsg_id,COUNT(*)AScntFROMpartGROUPBYmsg_id);-- 平均每条消息 43.5 个 part 行平均每条消息 43.5 个 part 行——这是某个 LLM 模型流式输出时的典型形态上游每吐一小段增量写入层就原样落一行两年没有任何整理。64575 个碎片除以 43.5约 1484 条消息对照消息表一查这个会话恰好就是这个量级数字闭环了。至此写入侧的病史清楚了。但行多本身只是体量问题六万行也不至于冻结 UI。真正压垮打开体验的是读取侧的算法和语义。2.4 读路径last-wins 全量遍历打开会话时恢复逻辑要做的事是把这 65416 行全部读出来按时间戳排序然后逐行合并进 JSON 树——每个 part 携带会话状态的一部分或一个补丁按写入顺序逐行覆盖后写的赢这就是 last-wins 语义。这个语义有一个隐藏得很深的代价它要求打开任何一个会话都必须从第一行扫到最后一行一步都不能省因为你在读完之前不知道最后一次写的是树上的哪个节点。行数越大打开越慢而且慢在一条线性的、每次打开都要全额支付的路径上。六万多个小对象的解码、排序、合并全部发生在 UI 线程里冻结数十秒完全对得上账。三、根因写侧反模式、读侧读放大、页级碎片化3.1 写入侧每 token 一行的流式反模式流式输出天然是碎片化的上游按 token 或小块往外吐写入层照单全收来一块写一行。更糟的是当时的写入走的是自动提交模式每条 INSERT 都是一次独立事务。在 WAL 模式下每提交一次意味着帧追加、wal-index 更新一轮就算把同步级别放宽事务粒度太细也会让每帧 24 字节的帧头、页号、校验和这些固定开销被放大几万倍。事务粒度这一课值得展开说。WAL 模式配合较宽的同步级别时提交并不强制刷主库盘单次提交的成本已经很低但每条分片一个事务仍然意味着每个事务都要在 WAL 里落一组帧、推进一次提交记录六万多次提交的元数据流水账本身也在制造体积。批量写入恰恰相反一条消息一个事务几十次插入共享一次提交帧数与事务开销同时降一个数量级——写侧的治理和读侧的合并方向其实是同一个把行攒大。两年 × 每条消息 43.5 行 × 每天几百条消息就攒出了 65416 行。写放大本身还能忍——SQLite 的写吞吐对这种小行绰绰有余后台写慢用户感知不到。真正的账单寄到了读取侧。3.2 读取侧last-wins 的读放大last-wins 恢复语义下读成本与行数严格成正比而且这个正比几乎没有优化余地不能只读最近 N 行因为树深处的历史节点可能被很新的 part 覆盖不能跳过中间行因为不知道哪一行是最后写。想快要么改变语义改成快照式整体覆盖要么减少行数合并碎片没有第三条路。这里有一条值得刻在脑子里的经验读放大比写放大更先杀死交互体验。写是后台的、异步的、可攒批的慢半拍用户无感打开列表在 UI 关键路径上每一次点击都要现场支付全部读成本。所以写入路径安静从来不代表存储健康——健康要看行数分布和读取路径的复杂度。3.3 84B 行在页结构里的真实代价再从 SQLite 的存储结构看为什么84 字节的小行格外伤。SQLite 以页为最小的 I/O 与管理单元本库 page_size 为 4096 字节整库就是一棵以页为节点的 B 树大家庭表数据在表 B 树里每个二级索引是一棵独立的索引 B 树。表 B 树的叶子页页类型 0x0D布局大致是页头 8 字节页类型、首个空闲块偏移、单元数、单元内容区起始偏移、碎片字节数紧随页头的是单元指针数组每个单元占 2 字节单元内容从页尾向前生长单元本体是payload 长度 varint rowid varint payload的打包结构页内放不下才溢出到溢出页链。代入 84 字节的 payload整个单元算上两个 varint 和指针数组的分摊实际占用 95 字节上下理论一页最多装 43 个左右再算上两年反复插入留下的 freeblock页内已释放空间的块链和碎片字节实际每页只能装三四十行。算总账64575 × 84B净载荷不过 5.2MB 上下。摊到页结构上、加上每行固定的时间戳等字段、再算上二级索引里同样数量的条目几十 MB 就出去了。但和行数带来的处理成本相比体积反而是小事64575 个单元分布在约一千六百多个叶子页上全量扫描意味着跨一千多页的 I/O 序列驱动层要对每行做字符串解码与对象构造六万多次构造全挤在 UI 线程同步完成84B 的行净载荷占比不到九成指针数组与碎片字节把填充效率拉低恰好落在最不划算的行尺寸区间。还有一个当时没意识到、修复阶段补课的机制DELETE 只把页挂进空闲链表文件体积纹丝不动。也就是说就算直接删光全部碎片行762MB 也不会少一个字节——空间归还必须靠 auto_vacuum 的增量回收或 VACUUM 重建而 WAL 的尾巴还得靠 checkpoint 另行处理。体积治理是三件独立的事缺一不可。3.4 WAL 的尾巴为什么 checkpoint 也必须治理本库跑在 WALWrite-Ahead Logging模式。它和传统回滚日志的根本区别在写路径修改不直接进主库文件而是连同页号、校验和打包成帧顺序追加进 -wal 文件读路径通过 wal-index 找到某页的最新版本把 WAL 尾部与主库快照合并读出。写路径因此变成纯顺序追加这是 WAL 快的原因。WAL 不能无限长把帧搬回主库文件的动作就是 checkpoint有四个档位PASSIVE 尽力而为、不打扰任何读者写者FULL 要等所有读者让位、保证全部帧搬完RESTART 在此之上确保新写入从 WAL 头部开始TRUNCATE 再进一步搬完把 -wal 文件截断归零。SQLite 默认在 WAL 积到约 1000 页时于某次提交后自动触发但这个自动机制有几个经典失效姿势连接长期不关偶发的长读事务把不能再被覆盖的旧帧边界顶住checkpoint 能跑但 WAL 无法复位应用退出路径没有显式 checkpoint全指望下次打开时收拾。本案三条占全-wal 文件只增不减成了库体积的一部分也让文件大小这个最朴素的健康指标彻底失真。checkpoint 语义里最容易被误解的是读者这个角色。每个读者拿着自己开始那一刻的快照只要这个读者不结束它引用的旧帧就不能被新写入覆盖checkpoint 即使把帧搬回了主库也无法让 WAL 从头复用文件只能继续向后长。一个忘记关闭的游标、一个挂在后台的长查询都可能让 -wal 文件在无人察觉的情况下持续膨胀。所以治理 WAL 的第一步不是调参数而是先把所有长读找出来。所以治理清单除了合并碎片、归还空闲页必须再加一条把 checkpoint 归零纳入主动管理。四、修复备份先行的四步顺序是刻意设计的先备份再动数据每一步独立事务、独立验证、随时可回退。第 0 步全量备份到独立备份目录任何改写之前先拿到一致性快照。注意带 WAL 的库不能只 cp 主文件——那样会丢掉 -wal 里尚未 checkpoint 的帧快照是残缺的。正确姿势是用在线备份 APImkdir-p../backup/pre-fix# 方式一在线备份 API得到含 WAL 内容的一致性快照sqlite3 session.db.backup ../backup/pre-fix/session-snap.db# 方式二效果类似顺带做了一次紧凑化# sqlite3 session.db VACUUM INTO ../backup/pre-fix/session-snap.db# 快照之外把原文件三件套原样归档一份确认应用已退出、无写入进程时cpsession.db session.db-wal../backup/pre-fix/这半分钟的操作换来的是一切可撤销的底气后面每一步都踩着它。第 1 步碎片合并——rewind 会话必须排除合并思路很直接把同一消息的碎片 part 按时间戳拼成完整记录写成一行再清掉碎片行。示意 SQL 如下group_concat 的拼接顺序依赖扫描顺序严谨实现要在子查询里先按 ts 排好序我的真实修复在应用层逐消息拼接逻辑等价BEGIN;-- 1) 以消息为单位聚合碎片生成完整记录INSERTINTOpart(session_id,msg_id,ts,payload)SELECTsession_id,msg_id,MAX(ts),group_concat(payload,)FROMpartWHEREsession_id?-- 圈定本次修复的会话范围ANDLENGTH(payload)84-- 碎片指纹ANDsession_idNOTIN(-- 关键排除用户回退过的会话SELECTsession_idFROMrewind_log)GROUPBYmsg_id;-- 2) 校验聚合条数符合预期后清理碎片行同样排除回退会话DELETEFROMpartWHEREsession_id?ANDLENGTH(payload)84ANDsession_idNOTIN(SELECTsession_idFROMrewind_log);COMMIT;这里埋着整次复盘里最险的一个语义坑。在 last-wins 恢复语义下回退rewind过的会话被用户主动丢弃的那些 part 仍然躺在表里时间戳也还是老样子。如果合并时把它们一并拼回去等于把用户亲手回退掉的内容合并回来了。数据没有丢但语义错位用户看到的会话和他当时确认过的状态不一致而且整个过程没有任何报错——这是最坏的一种 bug 形态静默且反直觉。所以合并范围必须把 rewind 过的会话整体排除而且排除是双向的既不合并、也不清理原样留给恢复逻辑去折叠改动面越小越安全。随之而来的问题是怎么区分哪些行是程序自动写、哪些和人的动作有关答案靠带时间戳的字段程序自动写入的 part与用户触发回退、编辑、删除时产生的标记行在时间戳字段上呈现稳定可辨的模式。排查阶段靠它圈定碎片来源和写入节奏合并阶段靠它划定排除范围。一句话总结在 last-wins 的存储里做任何批量历史改写动手前必须先回答用户主动回退过的部分怎么办。第 2 步裁剪非关键状态表dbstat 排名靠前的还有一张 readFileState 状态表记录哪些文件读过、内容指纹是什么。它属于可随时重建的缓存态数据不参与任何恢复语义丢掉的最坏后果只是下次把文件重新读一遍。按保留策略裁剪即可BEGIN;DELETEFROMreadFileStateWHEREts?;COMMIT;裁剪的原则就一条先分清关键状态与可再生状态。会话内容是关键状态要逐行核对、划定范围后才能动readFileState 是可再生状态按时间窗直接裁。把这两类混为一谈结果要么不敢动手要么酿成事故。第 3 步WAL checkpoint 归零前两步的 DELETE 把大量页送进了空闲链表主文件并不会立刻变小-wal 里也还压着历史帧。执行PRAGMA wal_checkpoint(TRUNCATE);-- 返回三列 (busy, log, checkpointed)-- busy0 且 logcheckpointed表示全部帧已搬回主库并截断归零TRUNCATE 档位在把帧搬回主库之后还会把 -wal 文件截断到零字节。做完碎片合并、readFileState 裁剪、checkpoint 归零这一整套之后库体积从 762MB 降到 499MB。关于空闲页的归还补充一个当时补课的细节合并删除产生的空闲页此刻仍占着 499MB 里的份额只是不再承载有效数据。想让文件进一步回到内容的真实大小要么打开 auto_vacuumINCREMENTAL 后用 incremental_vacuum 渐进归还见第六节要么低峰期做一次 VACUUM 整库重建。本案考虑到 499MB 已经不再影响任何交互指标把整库 VACUUM 留给了后续低峰窗口没有在修复当晚硬做——修复的目标是打开不卡不是文件最小。五、验证三个层面结构层确认库没被动坏PRAGMA integrity_check;-- okPRAGMA quick_check;-- ok数据层确认改动范围精确可控-- 修复范围内碎片行清零SELECTCOUNT(*)FROMpartWHEREsession_id?ANDLENGTH(payload)84;-- 0-- 该会话行数回落约两个数量级只余完整消息记录SELECTCOUNT(*)FROMpartWHEREsession_id...;语义层重点盯 last-wins 坑对被排除的 rewind 会话逐条人工抽查确认用户主动回退过的内容原样保留没有被合并回来的任何片段对正常合并的会话抽样比对合并后的完整记录与修复前按 last-wins 折叠出的最终状态逐字段一致。这一步在动手前就写好了比对脚本以备份快照为基准跑而不是合并完再想验证的事。体验层打开会话列表回到秒级以内UI 冻结消失应用连续运行数日并保持例行 checkpoint 后-wal 文件体积稳定归零不再单调增长。流程上再补一个细节所有验证 SQL 都是用只读连接跑的验证动作本身不往库里写任何东西比对脚本也先于修复脚本存在。先定义什么样算修好了再动手修而不是修完再回头找证据——这是这次复盘里最值得保留下来的习惯比任何一条 SQL 都值钱。六、预防把一次性的手术变成机制6.1 流式写入必须攒批落库每 token 一行本案是每上游分片一行是要写进团队规范的反模式。正确姿势是内存攒批三个触发条件任一先到即落一行时间窗口比如 100 到 300 毫秒、字节数比如 4 到 8KB、语义边界句末、段落末、工具调用边界流结束再补一条终态行。落库以消息为事务粒度一条消息一个事务崩溃最多丢一个缓冲窗口的增量这个代价远小于两年攒出 64575 行碎片、再让每次打开全量遍历。附带收益是事务数骤减WAL 的帧追加与 wal-index 更新开销同步降下来写路径也轻了。还有一个容易被忽略的尾巴攒批逻辑要配合崩溃窗口一起设计。消息进行到一半进程崩了内存缓冲里那几百毫秒的增量就丢了最终落库的终态行会缺一段内容。可接受的解法是让恢复语义天然容忍这种缺口——last-wins 之下缺一个中间补丁并不影响已有内容成立整条消息重发时按时间顺序覆盖即可。把最多丢多少变成一个显式的设计参数而不是事故之后才发现的事实。6.2 checkpoint 要主动管别赌 auto-checkpoint默认的 wal_autocheckpoint约 1000 页是提交时顺带的机制连接长存、偶发长读都会让它名存实亡。沉淀下来三条做法应用退出路径显式执行PRAGMA wal_checkpoint(TRUNCATE);正常退出即归零把 -wal 文件体积列为健康指标超过阈值比如 100MB就告警并抓住机会 TRUNCATE排查所有永不结束的读事务——长读顶住的是 checkpoint 的复位点影响的是整条 WAL不是那一页的读。6.3 页管理与空闲页链表的基本盘几个必须随时说得出口的指标PRAGMA page_size;-- 建库时定事后修改需 VACUUM 重建整库PRAGMA page_count;-- 文件总页数PRAGMA freelist_count;-- 空闲页数DELETE 之后页先进这里文件不变小DELETE 释放的页进入空闲链表trunk、leaf 两级结构组织继续占着文件直到 auto_vacuum 增量归还或 VACUUM 重建才真正让出磁盘空间。freelist_count 占 page_count 的比例是判断这个库需不需要整理的第一指标。大量小行还有一笔隐性税行越小越多每页单元指针数组的占比和页内碎片字节就越高填充效率越差——84B 正是最差的那一档。6.4 什么时候 VACUUM以及 VACUUM 的代价判断标准很朴素刚经历过大规模删除、freelist 占比长期偏高比如两三成以上值得 VACUUM 一次。但必须清楚代价VACUUM 把整库按 B 树顺序重建到临时文件再原子换回磁盘要预留接近两倍的库体积耗时与库大小成正比期间写入基本无法并发。对本案这种数百 MB 的库VACUUM 是低峰期的一次性动作不是日常手段更不该塞进任何在线请求路径里。日常手段是 auto_vacuumINCREMENTAL它用 pointer-map 页维护空闲页的归属代价是每次写入略增开销换来的是随时可调的渐进回收——PRAGMA auto_vacuumINCREMENTAL;VACUUM;-- 对存量库此设置需一次 VACUUM 才生效PRAGMA incremental_vacuum(512);-- 日常按页数渐进归还空闲页碎片化写入频繁的库这笔写入换弹性的交换通常值得做。6.5 增量整理与业务低峰窗口整理不该是出事才做的手术而应是例行体检放在业务低峰窗口执行。对本地部署的工具低峰就是用户闲置时段。例行清单每消息 part 行数超阈值的会话增量合并碎片——永远带着 rewind 排除条件freelist 占比超阈值时执行 incremental_vacuum按页数渐进归还每次例行任务收尾执行 wal_checkpoint(TRUNCATE)把 WAL 拉回零点每次例行任务之前做快照式备份独立目录、保留最近若干份可回溯。6.6 语义侧的防御性设计last-wins 恢复语义加上批量历史改写等于必须显式处理回退。沉淀成两条硬规则任何合并、重建、迁移的范围计算第一步先排除 rewind 过的会话并为哪些会话回退过保留一张可查询的记录表结构里保留能区分程序自动写与人触发的时间戳字段——它是排查取证与合并划界唯一可靠的依据丢了它后面所有批量操作都是在雷区里裸奔。七、工程教训本地数据库能跑和打开快之间隔着行级碎片化。写入安静不代表存储健康762MB 里躺着的是两年无人查看的指纹64575 行一字不差的 84 字节。读放大比写放大更先杀死交互体验。写是后台的、可异步可攒批的读在 UI 关键路径上每次打开全额支付。设计存储时先问一句这条数据在打开路径上会不会被全量读。恢复语义是隐式契约。last-wins 一词同时意味着两件事读必须全量遍历读放大回退过的内容会被合并回来语义坑。两者都要在设计期和维护流程里显式应对。WAL 不是无限缓冲。-wal 文件体积是最容易被忽视的健康指标checkpoint 要主动管、退出要归零别赌 auto-checkpoint 一直活着。备份先行。所有数据改写之前先拿一致性快照这半分钟买的是全程可撤销验证脚本先于修复脚本写好用快照当基准。这套碎片体检低峰整理流程后来成了该工具存储层的例行维护项。