本地向量库索引把 C 盘塞爆 48.6G:一次 LanceDB 膨胀排查与根治实录
热榜上在聊存储引擎的工程审阅,我翻到自己两个月前的工单记录,决定也交一份事故实录。主角是 LanceDB------一个嵌入式向量数据库,我们用它给本地知识库做检索。事故结果:一张 75MB 的表,索引目录膨胀到 48.6GB,把生产机的 C 盘干到了 0 字节。
倍的数先放这儿:索引体积是数据本身的 648 倍。下面是完整过程。
发现:不是报警,是满盘
那天生产机突然各种诡异地失败------日志写不进去、临时文件创建失败。一看 C 盘,可用空间 0。清理临时目录挤出来 2G,转眼又被吃掉。
顺着大目录往上摸,最后停在一个 LanceDB 表目录:`textbooks.lance/_indices`,底下 1492 个子目录,合计 48.6G。而表本身呢?13263 行数据,75MB。
第一反应是索引坏了,删掉重建就行。幸好没删------往下看你会知道为什么这一步差点酿成二次事故。
定位:三个因素叠出来的风暴
事后看,三个独立的设计决策严丝合缝地凑在了一起,单独任何一个都闹不出这个动静。
因素一:建索引不幂等。 我们的项目在初始化脚本里调用 `createIndex` 建全文检索索引。问题是 LanceDB 的这个 API 当时并不幂等------索引已经存在,再调一次,不会报错也不会跳过,而是老老实实又建一份,约 33MB。而我们的 init.js 是每次进程启动都会跑的。
因素二:全项目没有任何地方调 `optimize`。 建了索引,从没人清理旧版本。LanceDB 是 COPY-ON-WRITE 的设计:每次写入产生一个新版本,旧版本不会立刻消失,要靠显式的 optimize/cleanup 去回收。我们两天攒了 1517 个写入版本。
因素三:每个版本都带着一份索引快照。 这是压垮骆驼的那根稻草------旧版本不只是一个 manifest 记录,它引用的索引段文件也一起留着。1517 个版本 × 每个版本的 FTS 索引快照 = 48.6G。
三个因素单独看都"不算 bug":API 行为符合它的设计,不调清理也怪不着数据库,快照机制是为了版本一致性。但叠在一起,就是一个每次启动都长大一点、永不收缩的怪物。
为什么不敢手删
定位到 `_indices` 目录后,第二反应是写个脚本把旧目录批量删了。动手前做了个验证,然后停手了。
LanceDB 的 manifest 通过 UUID 引用索引段,目录名和 manifest 里的 UUID 的对应关系,没有官方文档保证稳定,也没有现成工具验证"哪些目录是活引用、哪些是死垃圾"。手删一旦误删活引用,整张表的索引直接报废,而那时 C 盘已经 0 字节,连重建索引的临时空间都紧张。
事故现场的正确姿势是:先用官方 API 做清理,哪怕它慢。
修复:一行 optimize 回收 48.5G
javascript
await table.optimize({
cleanupOlderThan: new Date(), // 清理所有过期版本
deleteUnverified: true, // 连未验证的孤儿文件一起清
});
两个参数都有讲究:
`deleteUnverified: true` 是必需的。 官方默认只清理 7 天以上的未验证文件,这是一个安全保护。但我们实测,不带这个参数跑 optimize,`bytesRemoved` 返回 0------等于白做。对于已经确认要清理的场景,必须显式打开。
`cleanupOlderThan` 要设到分钟级。 默认的保留期是为"可能有并发读者还在读旧版本"的场景设计的,对我们这种单进程写入、停机维护时清理的场景,保留到分钟级就够。
跑完:回收 48.5G,行数 13263 不变,全文检索命中结果和清理前基线完全一致,近 300 个测试全绿。
防复发:两道闸 + 一个维护服务
修完只是把水抽干,堤还得重修:
第一道闸:init.js 不再无脑建索引。 先 `listIndices()` 查已有索引,存在就跳过。幂等性这事,API 不给你,自己保证。
第二道闸:定时维护服务。 新增了一个每 6 小时跑一次的维护任务,但它不是无脑执行 optimize------带了三道前置检查:没有正在灌库的进程、库静默超过 30 分钟、索引体积超过数据体积 3 倍。三道全过才动手,任何一道没过就走短重试,而不是干等下一个整点。
还有个部署层的坑顺带记下: 生产环境是 Docker 跑的,改完代码如果只 restart 容器而不 `docker compose build`,容器里跑的还是旧镜像------我们第一次验证防复发逻辑时就被这个坑了,本地测试全绿,生产上"没生效"。其实是压根没部署。
教训清单
- 索引不是免费的。 每一种"加速结构"都有自己的生命周期管理成本,选型时除了看查询性能,还要看它的垃圾回收机制要不要你自己调。
- 初始化脚本里的写操作,默认假设它会被跑一万次。 幂等性要么 API 自带,要么自己包一层。
- 清理类的默认值,是给小库设计的。 7 天保护、默认保留期,在 75MB 的表上无感,在膨胀到 48G 时就是"optimize 跑了等于没跑"。
- 满盘是兜底防线。 等你看到 0 字节,一切排查动作本身都在制造新的空间压力。容量监控要在它之前。
- 事故现场不做没有验证过的删除。 手删索引目录看着爽,验证不了引用关系的删除都是赌博。
我们的电商 AI 客服产品叮当小宝CS (官网 dingdang.asia)也在生产环境里跑着类似的本地存储,这套维护闸门后来成了我们所有本地数据服务的标配。