深入 Elasticsearch(02):Segment、倒排索引与 Doc Values
上一篇解决了 ES 集群级架构——节点角色、集群状态和数据路径。这一篇进入单个 shard 内部的 Lucene 存储结构。
ES 的每个 shard 就是一个 Lucene index。Lucene index 不是一整块文件,而是由多个不可变的段(segment)组成。理解 segment 的内部结构,才能理解后续文章中 refresh、flush、merge、搜索延迟等机制为什么是那样设计的。
本文只抓一个问题:一个 Lucene segment 内部有哪些数据结构,它们各自承担什么角色。
Segment:不可变的存储单元
一个 Lucene index 由零个或多个 segment 组成。每个 segment 是一批文档的完整索引——包含这批文档的倒排索引、正排数据、字段信息等所有内容。
1 | |
segment 的核心特性是不可变(immutable)。一旦 segment 被写入磁盘,就不会再被修改。新文档的写入产生新的 segment,删除操作只是在 segment 上打一个删除标记(.liv 文件),被删除的文档在 merge 时才真正回收空间。
这种不可变设计带来几个直接后果:
- 搜索时需要遍历所有 segment。每次搜索请求在每个 segment 上独立执行,然后在 shard 级别合并结果。segment 数量越多,搜索开销越大。
- 并发安全变得简单。segment 不会被修改,读操作不需要加锁。
- 磁盘上的 segment 可以被操作系统的 filesystem cache 高效缓存。不可变数据不会被 invalidate。
- segment 数量会持续增长,需要后台 merge 来合并小 segment 为大 segment。
Segment 从产生到消亡经过四个阶段。这张图回答的问题是:写入的文档经过哪些状态转换才变成可搜索的持久数据,旧 segment 又在什么时候被回收。
stateDiagram-v2
[*] --> InMemoryBuffer: 文档写入
InMemoryBuffer --> NewSegment: refresh(默认 1s)
NewSegment --> CommittedSegment: flush(fsync 到磁盘)
CommittedSegment --> MergedSegment: 后台 merge
MergedSegment --> [*]: 原 segment 删除
InMemoryBuffer: In-Memory Buffer\n可写 · 不可搜索
NewSegment: New Segment\n不可变 · 可搜索 · 未持久化
CommittedSegment: Committed Segment\n不可变 · 可搜索 · 已持久化
MergedSegment: Merged Segment\n多个旧 segment 合并为一个
Refresh 让文档从不可搜索变为可搜索,flush 让数据从内存落盘,merge 把碎片化的小 segment 合并为大 segment 并回收已删除文档的空间。后续文章会展开 refresh/flush/merge 各自的触发时机和性能影响。
Segment 内部的数据结构
一个 segment 内部包含多种数据结构,各有不同职责:
1 | |
上面的 ASCII 列表给出了每种数据结构的名称和职责。下面这张图换一个角度:按查询类型分组,回答"执行某种操作时 Lucene 访问的是 segment 中的哪块数据"。
flowchart LR
subgraph 查询类型
S["全文搜索"]
R["范围查询"]
A["排序 / 聚合"]
RET["返回原文"]
end
subgraph Segment 数据结构
INV["Inverted Index<br/>FST + Posting List"]
BKD["BKD-tree<br/>数值 · 日期 · 地理"]
DV["Doc Values<br/>列式存储"]
SF["Stored Fields<br/>行式 _source"]
end
S --> INV
R --> BKD
A --> DV
RET --> SF
style INV fill:#e8f4fd,stroke:#1a73e8
style BKD fill:#fef7e0,stroke:#f9ab00
style DV fill:#e6f4ea,stroke:#34a853
style SF fill:#f3e5f5,stroke:#7b1fa2
四种数据结构各管一类访问模式,在一次搜索请求中可能同时被命中(搜索用倒排索引,排序用 Doc Values,返回结果用 Stored Fields)。
倒排索引:从词项到文档
倒排索引是 segment 中最核心的数据结构。每个被索引的 text 或 keyword 字段都有自己的倒排索引。
物理结构分三层:
Term Index → Term Dictionary → Posting List
Term Dictionary 按字典序存储所有唯一词项,使用 FST(Finite State Transducer)压缩。FST 是一种特殊的有限自动机,能以极小的空间存储大量有序字符串,同时支持前缀查找。
Posting List 是倒排索引的核心。对每个词项,Posting List 存储:
| 数据 | 用途 | 是否默认存储 |
|---|---|---|
| Document IDs | 哪些文档包含这个词项 | 是 |
| Term Frequency | 词项在文档中出现的次数(用于 BM25 打分) | 是 |
| Positions | 词项在文档中的位置(用于 phrase query) | text 字段默认是 |
| Offsets | 词项在原文中的字符偏移(用于高亮) | 默认否,需配置 |
| Payloads | 自定义元数据(高级用法) | 默认否 |
Posting List 内部使用多种压缩技术:Document IDs 用 Frame of Reference(FOR)编码和 roaring bitmaps 压缩;频率和位置信息用变长整数编码。
Doc Values:列式正排数据
倒排索引解决"哪些文档包含某个词项"的问题。但排序和聚合需要回答另一个方向的问题——“这个文档的某个字段的值是什么”。
如果排序时要从倒排索引中反查每个文档的字段值,效率极低。Doc Values 就是为排序和聚合设计的列式存储:
1 | |
Doc Values 按列组织数据——同一个字段的所有文档值连续存储。这种布局让排序和聚合操作只需要顺序扫描一列数据,不需要跳跃读取整行文档。
Doc Values 对除 text 之外的所有字段类型默认启用。text 字段如果需要做聚合或排序,需要启用 fielddata(加载到 JVM 堆内存中,代价高昂)或使用 multi-field 同时映射为 keyword 类型。
Stored Fields:原始文档
Stored Fields 存储文档的原始内容,即 _source 字段。这是行式存储——一个文档的所有字段值存在一起。
ES 默认存储 _source,搜索结果返回时从 Stored Fields 中读取。可以通过 _source: false 禁用,但这会导致无法 reindex、无法使用 update API、无法高亮。一般不建议禁用。
Stored Fields 和 Doc Values 的分工:
| 存储方式 | 组织形式 | 用途 | 适合的访问模式 |
|---|---|---|---|
| Stored Fields | 行式(document → all fields) | 返回 _source |
取少量文档的完整内容 |
| Doc Values | 列式(field → all documents) | 排序、聚合、脚本 | 遍历大量文档的单个字段值 |
Points (BKD-tree):数值和空间索引
数值字段(integer、long、double、date)和地理坐标字段(geo_point)不使用倒排索引,而是使用 BKD-tree(Block K-Dimensional tree)索引。
BKD-tree 是一种空间数据结构,支持高效的范围查询。对一维数据(数值、日期),BKD-tree 本质上是一个排好序的块结构,范围查询的复杂度是 O(√N)。对多维数据(经纬度坐标),BKD-tree 支持矩形范围和距离范围查询。
实验:观察 Segment 信息
查看一个 index 的 segment 信息:
1 | |
返回示例:
1 | |
num_docs 是 segment 中的活跃文档数。deleted_docs 是已标记删除但未回收的文档数。compound 表示是否使用复合文件格式(多个小文件合成一个 .cfs 文件)。version 是创建这个 segment 的 Lucene 版本。
用 explain 观察 Lucene 层的打分细节:
1 | |
explain 输出会显示 BM25 打分的每个因子(IDF、TF、字段长度归一化),这些数据直接来自 segment 内部的倒排索引和 norms。
模式提炼:不可变段 + 后台合并
1 | |
这种"写入追加新的不可变单元,后台异步合并旧单元"的模式,是 LSM-Tree(Log-Structured Merge-Tree)家族的核心思想。Lucene 的 segment 机制可以看作 LSM-Tree 的一个变体。
| 系统 | 不可变单元 | 合并操作 | 搜索/读取 |
|---|---|---|---|
| Lucene / ES | Segment | Segment merge | 遍历所有 segment |
| LevelDB / RocksDB | SSTable | Compaction | 遍历所有 level |
| Apache Kafka | Log segment | Log compaction / retention | 按 offset 顺序读取 |
| Apache HBase | HFile (StoreFile) | Minor/Major compaction | 遍历所有 HFile |
| PostgreSQL | Heap page + MVCC | VACUUM | Index scan + heap fetch |
工程迁移表
| 概念 | Lucene Segment | LSM-Tree SSTable | PostgreSQL Heap Page | Kafka Log Segment |
|---|---|---|---|---|
| 可变性 | 不可变 | 不可变 | 可变(in-place update) | 追加不可变 |
| 搜索索引 | 倒排索引 + BKD-tree | Bloom filter + 有序键 | B+Tree 索引 | 无索引(按 offset) |
| 列式存储 | Doc Values | 无(行式) | 无(行式) | 无 |
| 删除方式 | 标记删除 (.liv) | tombstone | MVCC + VACUUM | 按 retention/compaction |
| 合并 | TieredMergePolicy | Leveled/Size-tiered | VACUUM | Log compaction |
常见误解
误解一:ES 更新文档是原地修改的。 ES 的更新操作实际上是"标记旧文档为删除 + 写入新文档到新 segment"。没有原地更新。这就是为什么频繁更新会导致 segment 膨胀和性能下降。
误解二:倒排索引就是 Lucene 的全部。 倒排索引只负责全文搜索。排序用 Doc Values,范围查询用 BKD-tree,返回原文用 Stored Fields。一个 segment 是多种数据结构的组合。
误解三:segment 越少越好。 segment 太多确实会增加搜索开销,但 merge 本身也消耗大量 I/O 和 CPU。在持续写入的场景中,过于激进的 merge 策略会影响写入吞吐。需要在 segment 数量和 merge 成本之间取平衡。
练习
-
创建一个 index,分批写入 100 条文档(每批 10 条,间隔调用
_refresh),然后用_segmentsAPI 观察 segment 数量的增长。 -
删除几条文档,再次查看
_segments,注意deleted_docs的变化。然后调用POST /index/_forcemerge?max_num_segments=1,再次查看——segment 数量减少,deleted_docs变为 0。 -
对比同一个搜索请求在 merge 前(多个小 segment)和 merge 后(一个大 segment)的
took时间差异。
系列导航
| 上一篇 | 下一篇 |
|---|---|
| 架构总览:Node、Cluster 与集群状态 | Mapping 与字段类型:给文档定义结构 |
参考资料
- Apache Lucene 官方文档:Index File Formats
- Elasticsearch 官方文档:Index Segments API
- Michael McCandless, Erik Hatcher, Otis Gospodnetić. Lucene in Action. Manning.(Lucene 内部结构参考)
- Elasticsearch: The Definitive Guide — Inside a Shard
