本地向量库索引把 C 盘塞爆 48.6G:一次 LanceDB 膨胀排查与根治实录

本地向量库索引把 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`,容器里跑的还是旧镜像------我们第一次验证防复发逻辑时就被这个坑了,本地测试全绿,生产上"没生效"。其实是压根没部署。

教训清单

  1. 索引不是免费的。 每一种"加速结构"都有自己的生命周期管理成本,选型时除了看查询性能,还要看它的垃圾回收机制要不要你自己调。
  2. 初始化脚本里的写操作,默认假设它会被跑一万次。 幂等性要么 API 自带,要么自己包一层。
  3. 清理类的默认值,是给小库设计的。 7 天保护、默认保留期,在 75MB 的表上无感,在膨胀到 48G 时就是"optimize 跑了等于没跑"。
  4. 满盘是兜底防线。 等你看到 0 字节,一切排查动作本身都在制造新的空间压力。容量监控要在它之前。
  5. 事故现场不做没有验证过的删除。 手删索引目录看着爽,验证不了引用关系的删除都是赌博。

我们的电商 AI 客服产品叮当小宝CS (官网 dingdang.asia)也在生产环境里跑着类似的本地存储,这套维护闸门后来成了我们所有本地数据服务的标配。

相关推荐
曦樂~1 小时前
Cpp面筋
c语言·c++
来让爷抱一个2 小时前
Python 异步里混用同步库,事件循环被卡住的全过程
开发语言·python
心祭丫丫5 小时前
【每天一个 Python 小案例17】Python 实现通讯录
开发语言·人工智能·笔记·python·学习
露天赏雪5 小时前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
yeccheng8 小时前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试
联犀8 小时前
纯 Go 的 CAD 开源库 go-cad:直接解析 DWG/DXF,现在也能导出 PDF
开发语言·golang·pdf
zjxtxdy8 小时前
C++面向对象程序开发-类与对象
开发语言·c++
qq_4069810310 小时前
c++拷贝构造函数
开发语言·c++
爱喝雪碧的可乐11 小时前
MATLAB
开发语言·算法·matlab
HUANGZHENXUAN12312 小时前
最大子矩阵和:从 O(n⁴) 到 O(n³) 的列压缩 + Kadane
c语言·前端