多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

倒排索引与 FST 在 Lucene 中的内存布局:segment、docValues 与词典压缩的工程取舍

倒排索引与 FST 在 Lucene 中的内存布局:segment、docValues 与词典压缩的工程取舍 倒排索引与 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 采用 FSTFinite 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和doFST 进一步把相同后缀和等价状态合并。这里最容易误解的是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 17Maven 项目依赖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{PathindexPathPath.of(/tmp/lucene-demo-index);Files.createDirectories(indexPath);DirectorydirectoryFSDirectory.open(indexPath);IndexWriterConfigconfignewIndexWriterConfig(newStandardAnalyzer());config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);try(IndexWriterwriternewIndexWriter(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(DirectoryReaderreaderDirectoryReader.open(directory)){System.out.println(maxDocreader.maxDoc());System.out.println(numDocsreader.numDocs());for(varleaf:reader.leaves()){System.out.println(segmentleaf.reader().toString());for(Stringfile:leaf.reader().getSegmentReader().files()){System.out.println( filefile);}}}}privatestaticDocumentdoc(intid,Stringtitle,longprice){DocumentdocumentnewDocument();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负责写入并形成 segmentcommit保证数据落到稳定文件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_segments1)当索引仍在持续写入时更合理的做法是调整 refresh 间隔、控制分片数量并让后台合并自然完成。下面的 Elasticsearch 请求展示如何查看 segment 和合并相关指标GET/products/_segments?verbosetrueGET/_nodes/stats/indices/merges?filter_pathnodes.*.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{PathindexPathPath.of(/tmp/lucene-demo-index);try(DirectorydirectoryFSDirectory.open(indexPath);DirectoryReaderreaderDirectoryReader.open(directory)){for(LeafReaderleaf:reader.leaves()){SortedNumericDocValuesvaluesleaf.getSortedNumericDocValues(price);if(valuesnull){System.out.println(no doc values for price);continue;}for(intdoc0;docleaf.maxDoc();doc){if(values.advanceExact(doc)){longvaluevalues.nextValue();System.out.println(docdoc pricevalue);}}}}}}关键步骤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 先写入内存缓冲区和 translogrefresh 时把缓冲区数据生成一个新 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.htmlElasticsearch 官方文档https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-search-speed.htmlElasticsearch 官方文档https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.htmlLucene 源码仓库https://github.com/apache/lucene《Lucene in Action》第二版作者 Michael McCandless 等《Elasticsearch: The Definitive Guide》作者 Clinton Gormley 与 Zachary Tong
返回列表