本地向量库索引把 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)也在生产环境里跑着类似的本地存储,这套维护闸门后来成了我们所有本地数据服务的标配。

相关推荐
言乐62 小时前
Python排名统计折线图
开发语言·python·django·virtualenv·pygame
我是小白呀2 小时前
19-Temporal项目实战:将客户开通流程迁移到持久执行架构
java·开发语言·人工智能·架构·workflow
励志不掉头发的内向程序员2 小时前
【从零写一个CAD 02】画完第一条线之后:实体容器、Esc 取消,和按了没反应的键盘
开发语言·c++·qt·学习·系统架构
摇滚侠3 小时前
《On Java 中文版 基础卷》阅读笔记 安装 Java 和本书示例 02
java·开发语言·笔记
FfHUCisI3 小时前
sync.Once 与 sync.Cond 源码与并发控制陷阱
服务器·开发语言·后端·golang
阿钱真强道3 小时前
21 嵌入式操作系统 | JSON 与 json-c:生成与解析
c语言·开发语言·json·json-c
j7~3 小时前
【Python】(篇六)《基础语法(函数、列表和元组、字典、文件)》---详解
开发语言·python·文件·函数·字典·编程学习·元组与列表
重生之小比特3 小时前
【C++进阶】红黑树的实现
java·开发语言·c++
纪念 2293 小时前
C++算法(一)
开发语言·c++·算法