倒排索引与 FST 在 Lucene 中的内存布局:segment、docValues 与词典压缩的工程取舍
1. 先看一个真实困惑:为什么文档只有 2KB,索引却涨到 40GB
假设你的团队维护一个商品搜索服务,写入 Elasticsearch 的商品文档平均 2KB,日增 200 万条,保留 90 天。粗算原始数据大约 360GB,但集群实际占用接近 1.2TB,单个数据节点堆内存 32GB 还频繁触发 GC。更奇怪的是,日志里既没有大量聚合查询,也没有复杂排序,绝大部分请求只是按关键词查商品名。
这个问题不能简单归因为“ES 吃内存”。真正需要回答的是:一条文档写进去之后,Lucene 到底在磁盘和内存里多存了哪些结构?哪些结构可以按需从磁盘读,哪些必须常驻内存?为什么“只要关键词检索”也会引入排序、聚合之外的开销?
要回答这些,必须同时看三件事:倒排索引负责“从词找到文档”,FST 词典负责“用尽量小的内存装下所有词”,segment 负责“把一批批文档组织成不可变文件并周期性合并”。doc values 则是第四块拼图,它负责“从文档正向取值”,在排序、聚合、脚本访问字段时承担关键角色。
本文不追求把 Lucene 源码逐行讲透,而是先建立一个可以复述的模型:一次查询进来,会依次碰到哪些结构,它们分别在磁盘还是内存,什么时候被加载,什么时候被释放。有了这个模型,再看 FST、posting list、doc values 的实现细节,就不会变成名词堆砌。
2. 一句话模型与全局框架
可以先记住一个最小模型:Lucene 的索引是一堆不可变的 segment,每个 segment 内部由“词典 + 倒排表 + 正排存储 + 列式存储”组成;查询时先在最外层按时间或段过滤定位 segment,再用词典找到词,用倒排表找到文档号,最后按需读取存储字段或 doc values。
这里的关键词是“不可变”。Segment 一旦写成就不会原地修改,新增、更新、删除都通过新 segment 和删除标记实现。合并(merge)会把多个小 segment 合成大 segment,同时真正丢弃被删除的文档。这个机制决定了磁盘布局、内存占用和查询性能的大部分表现。
先看一次关键词查询的完整链路:
客户端查询 keyword | v [协调节点] 解析 Query DSL | v [每个分片] 准备 IndexSearcher | v [Segment 列表] 按段并行处理 | +----+----+ | | v v [词典 FST] [Term Dictionary] | | v v [Posting List] 文档号 + 词频 + 位置 | v [Collector] 打分、排序、分页从这张图可以看出,词典和倒排表解决的是“哪些文档包含这个词”,doc values 和 stored fields 解决的是“这些文档长什么样”。前者是查询路径,后者是取值路径。许多内存与性能问题,其实是没有区分这两条路径。
在 Elasticsearch 中,一个索引被切成多个分片,每个分片是一个完整 Lucene 索引,包含多个 segment。分片数决定并行度,也决定每个分片的内存开销;segment 数决定查询要打开多少文件、要做多少次词典查找。它们共同构成“分片 → segment → 数据结构”的三级框架。
3. 整体结构:倒排索引、词典、存储字段与 doc values 各自放什么
3.1 倒排索引:从词到文档号
倒排索引解决的是“给定一个词,快速找出包含它的文档集合”。它的核心是 posting list,即某个词对应的文档号列表,通常还带有词频和位置信息。词频用于相关性打分,位置用于短语查询和高亮。
假设有三个商品文档:
| 文档号 | 商品名 |
|---|---|
| 0 | 无线蓝牙耳机 |
| 1 | 蓝牙音箱 |
| 2 | 有线耳机 |
分词后,“蓝牙”出现在文档 0 和 1,“耳机”出现在文档 0 和 2。倒排表可以理解为:
蓝牙 -> [0, 1] 耳机 -> [0, 2] 无线 -> [0] 音箱 -> [1] 有线 -> [2]查询“蓝牙 耳机”时,Lucene 会分别取出两个 posting list,做归并求交集,得到文档 0。这个过程中的“取出”并不是把整个列表加载到堆内存,而是根据词典给出的文件偏移量,从磁盘按需读取压缩后的 block。
3.2 FST 词典:把词项集合压到内存里
如果 posting list 是“词到文档”的映射,那么词典就是“词到 posting list 位置”的映射。词典需要支持精确查找和范围查找,比如前缀查询、通配符查询。如果直接把所有词项放进一个哈希表,内存占用会非常可观;如果全部放磁盘,每次查找都要额外 IO。
Lucene 采用 FST(Finite State Transducer,有限状态转换器)作为词典的核心结构。可以把 FST 理解成一种共享前缀和后缀的有向无环图:所有词项按字典序排列后,尽量复用相同的前缀路径,从而用远小于原始词表的内存表示整个集合。
需要注意的边界是:FST 并不存储 posting list 本身,它只负责把词项映射到磁盘偏移。真正包含文档号的倒排表仍然在磁盘上,按 block 压缩存储。FST 常驻内存,但比“完整词表哈希”小得多。
3.3 stored fields 与 doc values:取值路径
stored fields 保存原始文档字段,用于返回_source或指定字段。它是行式存储,按文档读取。doc values 则是列式存储,按字段读取,适合排序、聚合和脚本访问。
一个常见误解是“排序字段会放在倒排索引里”。事实上,排序和聚合通常走 doc values,而不是 posting list。doc values 默认对非文本字段开启,对文本字段通常关闭,因为文本分词后不适合做精确排序。
四类结构的对比如下:
| 结构 | 解决什么问题 | 存储方式 | 典型内存行为 |
|---|---|---|---|
| 倒排表 | 从词找文档 | 磁盘,分 block 压缩 | 按需读取,不常驻 |
| FST 词典 | 从词找倒排表位置 | 磁盘加载为内存结构 | 常驻堆外或堆内,取决于实现 |
| stored fields | 返回原始字段 | 行式磁盘存储 | 按文档读取 |
| doc values | 排序、聚合、脚本取值 | 列式磁盘存储 | 按需读取,可有内存缓存 |
这张表说明:查询路径和取值路径是分开的。只做关键词检索时,最活跃的是 FST 和倒排表;一旦出现排序、聚合、脚本,doc values 就会进入关键路径。
4. FST 的核心机制:为什么它能把词典压到很小
4.1 共享前缀与后缀的基本直觉
FST 的压缩来自两件事:共享前缀和共享后缀。假设词表是cat、cats、car、cart、dog、dogs。普通哈希表要存六个完整字符串;而前缀树会共享ca和do,FST 进一步把相同后缀和等价状态合并。
这里最容易误解的是:FST 不是“压缩后的哈希表”,它牺牲了任意的随机写入能力,换来的是紧凑表示和有序遍历能力。它不支持高效插入新词,因为构建时需要按字典序处理所有词项。这也解释了为什么 Lucene segment 是不可变的:不可变才能一次性构建出紧凑的 FST。
4.2 查询时 FST 做什么,不做什么
当查询词是“蓝牙”时,FST 的工作是沿着字符路径走到对应状态,拿到一个输出值,这个值通常是从 term dictionary 读取 posting list 的偏移量。之后,Lucene 根据偏移量去磁盘读取 block。
FST 不负责计算相关性,不负责合并 posting list,也不负责存储文档内容。它像一本“压缩过的电话簿”,只告诉你“要打给谁”,但真正的通话内容在别处。把 FST 想成“词典索引”,而不是“完整索引”,能避免很多容量估算错误。
4.3 一个可观察的 Lucene 示例:构建索引并查看 segment 文件
下面这个完整示例用 Lucene 9.x 构建一个小索引,写入三条商品文档,然后打印每个 segment 的文件列表。目标是让你亲眼看到词典文件、倒排文件、存储文件和 doc values 文件确实存在。
前置环境:JDK 17,Maven 项目,依赖org.apache.lucene:lucene-core:9.9.2和org.apache.lucene:lucene-analysis-common:9.9.2。
importorg.apache.lucene.analysis.standard.StandardAnalyzer;importorg.apache.lucene.document.Document;importorg.apache.lucene.document.Field;importorg.apache.lucene.document.NumericDocValuesField;importorg.apache.lucene.document.StringField;importorg.apache.lucene.document.TextField;importorg.apache.lucene.index.DirectoryReader;importorg.apache.lucene.index.IndexWriter;importorg.apache.lucene.index.IndexWriterConfig;importorg.apache.lucene.store.Directory;importorg.apache.lucene.store.FSDirectory;importorg.apache.lucene.util.BytesRef;importjava.nio.file.Files;importjava.nio.file.Path;publicclassBuildIndexDemo{publicstaticvoidmain(String[]args)throwsException{PathindexPath=Path.of("/tmp/lucene-demo-index");Files.createDirectories(indexPath);Directorydirectory=FSDirectory.open(indexPath);IndexWriterConfigconfig=newIndexWriterConfig(newStandardAnalyzer());config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);try(IndexWriterwriter=newIndexWriter(directory,config)){writer.addDocument(doc(0,"wireless bluetooth earphone",1999L));writer.addDocument(doc(1,"bluetooth speaker",899L));writer.addDocument(doc(2,"wired earphone",299L));writer.commit();}try(DirectoryReaderreader=DirectoryReader.open(directory)){System.out.println("maxDoc="+reader.maxDoc());System.out.println("numDocs="+reader.numDocs());for(varleaf:reader.leaves()){System.out.println("segment="+leaf.reader().toString());for(Stringfile:leaf.reader().getSegmentReader().files()){System.out.println(" file="+file);}}}}privatestaticDocumentdoc(intid,Stringtitle,longprice){Documentdocument=newDocument();document.add(newStringField("id",String.valueOf(id),Field.Store.YES));document.add(newTextField("title",title,Field.Store.YES));document.add(newNumericDocValuesField("price",price));returndocument;}}关键步骤解释:IndexWriter负责写入并形成 segment;commit保证数据落到稳定文件;DirectoryReader打开索引后可以观察到 segment 和文件列表。预期输出会包含_0.cfs、_0.fdt、_0.fdx、_0.tim、_0.tip、_0.doc等文件,其中.tim与.tip与词典和倒排索引有关,.dvd与.dvm与 doc values 有关。
容易改错的地方:不要忘记关闭IndexWriter和DirectoryReader,否则文件句柄可能泄漏;索引目录需要提前创建;NumericDocValuesField不存储原值,只用于排序和聚合,所以上面没有把它设为Store.YES。
5. Segment 合并:为什么它既省钱又烧钱
5.1 不可变 segment 的代价与收益
每次 refresh 会产生一个新的小 segment。新 segment 一旦生成就不修改,删除只是打标记。这样做的好处是写入路径简单、并发读不用加锁、缓存友好;代价是 segment 数量会增长,查询需要在更多 segment 上重复做词典查找和打分。
合并就是把多个小 segment 读出来,过滤掉被删除的文档,再写成一个更大的 segment。合并后,文件数减少,被删除文档真正消失,词典和倒排表也可能因为重新排序而更紧凑。但合并本身消耗 CPU、磁盘 IO 和临时空间。
5.2 合并策略看的是“大小分层”,不是简单阈值
Lucene 默认的合并策略可以粗略理解为分层合并:小 segment 优先合并,大 segment 较少参与。这样做的目的是控制写放大。如果每次合并都把最大的 segment 重新写一遍,写入成本会非常高。
看一个具体例子:索引有 10 个小 segment,每个 100MB,合并成一个 1GB 的大 segment。期间需要读取 1GB、写出 1GB,峰值磁盘可能同时存在新旧两份数据。如果磁盘剩余空间不足,合并会失败,索引进入只读或写入受阻状态。
这也解释了一个工程现象:写入量大的集群,磁盘使用率不能只看“当前索引大小”,还要给合并留出余量。经验上至少预留 20% 到 30% 的可回收空间,具体取决于合并策略和更新频率。
5.3 force merge 的适用边界
forceMerge能把 segment 数强制降到指定值,常用于只读索引或归档索引。但它不是日常优化手段:对活跃写入索引执行forceMerge会带来巨大 IO,且之后的新写入又会产生新 segment。
当索引已经不再写入、查询延迟敏感时,可以考虑forceMerge(max_num_segments=1);当索引仍在持续写入时,更合理的做法是调整 refresh 间隔、控制分片数量,并让后台合并自然完成。
下面的 Elasticsearch 请求展示如何查看 segment 和合并相关指标:
GET/products/_segments?verbose=trueGET/_nodes/stats/indices/merges?filter_path=nodes.*.indices.merges第一个请求返回每个分片的 segment 数量、内存占用和是否搜索中;第二个请求返回合并次数、合并耗时和合并字节数。如果merges.total_time_in_millis持续增长且磁盘 IO 很高,说明合并正在成为写入瓶颈。
6. Doc values:排序和聚合为什么不用倒排索引
6.1 列式存储的基本动机
倒排索引适合“给词找文档”,但不适合“给文档找字段值”。排序需要按某个字段比较大量文档,聚合需要对某个字段做分组统计。如果每次都从行式存储里按文档读取字段,会产生大量随机 IO。
doc values 采用列式存储:同一个字段的所有文档值连续存放,按文档号顺序排列。这样排序和聚合可以顺序扫描,压缩率也更高。对于数值字段,常见压缩方式包括按块差分和位压缩。
6.2 doc values 与 fielddata 的边界
文本字段默认不做 doc values,因为分词后的文本不适合精确排序。如果一定要对文本字段排序或聚合,Elasticsearch 可能使用 fielddata,把词项加载到堆内存。fielddata 很容易导致堆内存暴涨,因此生产上通常应避免,改用keyword子字段。
这里最容易误解的是:text字段用于全文检索,keyword字段用于精确匹配、排序和聚合。它们不是重复存储,而是服务于不同路径。一个字段同时需要全文检索和聚合时,常见做法是使用 multi-field。
6.3 一个可运行的聚合与排序示例
下面这个完整示例继续使用 Lucene,演示如何通过SortedNumericDocValues和SortedSetDocValues读取 doc values。目标是证明排序和聚合取值不依赖倒排表。
前置环境与上一个示例相同,索引中已经包含price的NumericDocValuesField。
importorg.apache.lucene.index.DirectoryReader;importorg.apache.lucene.index.LeafReader;importorg.apache.lucene.index.SortedNumericDocValues;importorg.apache.lucene.store.Directory;importorg.apache.lucene.store.FSDirectory;importjava.nio.file.Path;publicclassDocValuesReadDemo{publicstaticvoidmain(String[]args)throwsException{PathindexPath=Path.of("/tmp/lucene-demo-index");try(Directorydirectory=FSDirectory.open(indexPath);DirectoryReaderreader=DirectoryReader.open(directory)){for(LeafReaderleaf:reader.leaves()){SortedNumericDocValuesvalues=leaf.getSortedNumericDocValues("price");if(values==null){System.out.println("no doc values for price");continue;}for(intdoc=0;doc<leaf.maxDoc();doc++){if(values.advanceExact(doc)){longvalue=values.nextValue();System.out.println("doc="+doc+" price="+value);}}}}}}关键步骤:getSortedNumericDocValues拿到字段的列式迭代器;advanceExact跳到指定文档;nextValue读取值。预期输出按文档号顺序打印价格。适用场景是自定义打分、离线分析或排查 doc values 缺失问题。容易改错的地方是把NumericDocValuesField当成存储字段直接读取,它不会返回原始_source。
7. 内存占用到底花在哪里:三层账本
7.1 第一层:JVM 堆内存
堆内存主要花在 FST 词典、查询缓存、聚合中间结果、分片请求对象和 Lucene 的 segment 读取器上。FST 常驻堆内或堆外,取决于版本和配置;查询缓存会缓存过滤结果;聚合会为每个分片维护桶。
很多“堆内存高”的问题,根因不是倒排索引本身,而是 fielddata、聚合桶过多或分片数过多。每个分片都是一个独立的 Lucene 索引,都会维护自己的结构,因此分片不是越多越好。
7.2 第二层:堆外与文件系统缓存
倒排表、stored fields 和 doc values 主要在磁盘上,读取时依赖操作系统页缓存。ES 节点通常建议把一半物理内存留给页缓存,因为随机读性能高度依赖缓存命中率。堆内存过大反而会挤压页缓存,导致查询变慢。
7.3 第三层:磁盘空间与合并余量
磁盘不仅存当前索引,还要存 translog、合并临时文件、快照和副本。合并期间峰值空间可能是当前 segment 大小的两倍。副本分片也会完整复制一份,因此磁盘容量要按“主分片 + 副本 + 合并余量”计算。
下面这张表可以帮助定位内存问题:
| 现象 | 常见根因 | 优先检查 |
|---|---|---|
| 堆内存持续高位 | fielddata、聚合桶、分片过多 | fielddata.memory_size、分片数 |
| 查询延迟抖动 | 页缓存不足、合并抢占 IO | 节点内存、merge 指标 |
| 磁盘快速增长 | 副本、translog、合并临时文件 | _cat/allocation、索引设置 |
| 写入变慢 | 合并跟不上、refresh 过频 | refresh 间隔、merge 线程 |
8. 一次查询和一次写入的完整过程
8.1 写入过程
写入一条文档时,Elasticsearch 先写入内存缓冲区和 translog;refresh 时把缓冲区数据生成一个新 segment,并打开供搜索;flush 时把 translog 持久化并清理。Segment 一旦生成,就可以被并发搜索,不需要停止写入。
这个过程中,词典 FST 是在 segment 生成时构建的,doc values 也是在此时写入列式文件。写入路径的性能瓶颈通常不在 FST 构建,而在 refresh 频率、合并压力和 translog 刷盘。
8.2 查询过程
查询进入协调节点后,会被广播到目标分片。每个分片在本地依次访问 segment:先用 FST 定位词项,再读取倒排表,得到文档号集合;然后通过 collector 打分、排序、分页;最后协调节点合并结果。如果查询涉及排序或聚合,还会读取 doc values。
下面用时序图表示一次关键词查询:
Client -> Coordinating Node: search request Coordinating Node -> Shard A: search Coordinating Node -> Shard B: search Shard A -> Segment 1: FST lookup Segment 1 -> Shard A: posting list Shard A -> Segment 1: doc values if needed Shard B -> Segment 2: FST lookup Segment 2 -> Shard B: posting list Shard A -> Coordinating Node: top docs Shard B -> Coordinating Node: top docs Coordinating Node -> Client: merged result从图中可以看到,FST 查找和 doc values 读取发生在分片内部,协调节点只负责合并。深度分页之所以昂贵,是因为每个分片都要返回from + size条候选,协调节点再排序截断。
9. 设计取舍:FST、doc values 与合并策略怎么选
9.1 词典压缩与查询能力的取舍
FST 在内存占用和查询能力之间取得了很好的平衡,但它要求词项有序且不可变。如果业务需要频繁更新词典,或者需要任意正则匹配,FST 就不是万能答案。前缀查询、通配符查询会退化为对 FST 的遍历,成本高于精确查询。
9.2 doc values 与 fielddata 的取舍
排序和聚合优先使用 doc values,因为它顺序扫描、压缩率高、对堆内存友好。fielddata 只在必要时使用,并且要严格控制基数和缓存。对于高基数字段,聚合本身就会产生大量桶,此时问题不在存储结构,而在查询设计。
9.3 合并策略与写入吞吐的取舍
合并越积极,segment 越少,查询越快,但写入放大越严重。合并越保守,写入越平滑,但查询要打开更多 segment。生产上通常根据索引是“写多读少”还是“读多写少”来调整:日志类索引可以接受较多 segment,商品类索引更应控制 segment 数量。
10. 常见误区
第一个误区是“倒排索引会全部加载到内存”。实际上,倒排表主要在磁盘,按需读取;常驻的更多是 FST 和缓存。第二个误区是“doc values 就是 stored fields”。前者按列存储,用于排序聚合;后者按行存储,用于返回原始字段。
第三个误区是“分片越多查询越快”。分片增加并行度,但也增加协调开销、文件句柄和堆内存。通常每个分片控制在几十 GB 量级,具体取决于查询模式和硬件。第四个误区是“force merge 能解决所有查询慢”。force merge 只对只读索引有意义,对活跃索引可能适得其反。
第五个误区是“FST 能存所有东西”。FST 只存词典映射,不存 posting list,也不存文档值。把 FST 当成完整索引,会严重低估磁盘需求。
11. 生产实践建议
写入侧建议:控制 refresh 间隔,避免每秒生成大量小 segment;适当增大 translog 缓冲区;监控 merge 指标,避免合并线程成为瓶颈。对更新频繁的索引,考虑使用别名切换重建,而不是频繁原地更新。
查询侧建议:能用filter就不用query,因为 filter 可以缓存;排序和聚合使用keyword或数值字段,避免 fielddata;深度分页使用search_after,不要用超大from。
容量侧建议:为合并预留 20% 到 30% 磁盘空间;副本会翻倍存储;堆内存不要超过物理内存的一半,给页缓存留出空间。分片数按数据量和查询并发综合评估,不要机械套用固定值。
12. 排障清单
当查询变慢时,按以下顺序检查:先看_nodes/stats/indices/search和_nodes/stats/indices/query_cache,确认是查询量还是缓存命中问题;再看_segments,确认 segment 数量是否异常;然后看_nodes/stats/indices/merges,确认合并是否占用大量 IO。
当内存告警时,先看_nodes/stats/indices/fielddata,确认是否有 fielddata 被加载;再看分片数和聚合查询;最后检查 JVM 堆使用和 GC 日志。当磁盘告警时,先看_cat/allocation,再看 translog 和合并临时文件。
当写入变慢时,检查 refresh 间隔、translog 刷盘策略和 merge 线程数。不要一上来就加节点,先确认瓶颈是 CPU、磁盘还是内存。
13. 面试与复盘问题
- 为什么 Lucene 的 segment 不可变?不可变带来了哪些查询和写入上的好处?
- FST 为什么能压缩词典?它存储的是什么,不存储什么?
- 倒排索引和 doc values 分别服务哪条路径?排序和聚合为什么不用倒排索引?
- 合并为什么既提升查询性能又增加写入成本?如何判断合并是否成为瓶颈?
- 分片数、segment 数和堆内存之间是什么关系?如何为一个新索引估算分片数?
14. 总结
回到最初的问题:文档只有 2KB,索引却涨到很大,通常不是倒排索引单独造成的,而是 segment 数量、副本、translog、doc values、合并临时文件和分片开销共同作用的结果。理解 Lucene 的内存与磁盘布局,关键是区分查询路径和取值路径,区分常驻结构和按需读取结构。
一张简化的决策清单:关键词检索优先关注 FST 和倒排表;排序聚合优先关注 doc values;写入性能优先关注 refresh 和合并;内存问题优先排查 fielddata 和分片数;磁盘问题优先预留合并余量。把这五条记住,大部分 Elasticsearch 容量与性能问题都能找到方向。
15. 参考资料
- Apache Lucene 官方文档:https://lucene.apache.org/core/
- Elasticsearch 官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html
- Elasticsearch 官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-search-speed.html
- Elasticsearch 官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html
- Lucene 源码仓库:https://github.com/apache/lucene
- 《Lucene in Action》第二版,作者 Michael McCandless 等
- 《Elasticsearch: The Definitive Guide》,作者 Clinton Gormley 与 Zachary Tong