上一篇解决了 ES 集群级架构——节点角色、集群状态和数据路径。这一篇进入单个 shard 内部的 Lucene 存储结构。

ES 的每个 shard 就是一个 Lucene index。Lucene index 不是一整块文件,而是由多个不可变的段(segment)组成。理解 segment 的内部结构,才能理解后续文章中 refresh、flush、merge、搜索延迟等机制为什么是那样设计的。

本文只抓一个问题:一个 Lucene segment 内部有哪些数据结构,它们各自承担什么角色。

Segment:不可变的存储单元

一个 Lucene index 由零个或多个 segment 组成。每个 segment 是一批文档的完整索引——包含这批文档的倒排索引、正排数据、字段信息等所有内容。

1
2
3
4
5
Lucene Index (= ES Shard)
├── Segment 0 (committed, immutable)
├── Segment 1 (committed, immutable)
├── Segment 2 (committed, immutable)
└── In-memory buffer (uncommitted, mutable)

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
2
3
4
5
6
7
8
9
10
11
Segment
├── Inverted Index 搜索:term → document list
│ ├── Term Dictionary (FST)
│ ├── Term Index
│ └── Posting Lists (docId, freq, positions, offsets, payloads)
├── Doc Values 排序/聚合:document → field values(列式)
├── Stored Fields 原文返回:document → _source(行式)
├── Norms 打分:每个字段的长度归一化因子
├── Points (BKD-tree) 范围查询:数值、日期、地理坐标
├── Term Vectors 高亮/MLT:每个文档的词项统计(可选)
└── Live Docs (.liv) 删除标记:哪些文档已删除

上面的 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
2
3
4
5
6
7
8
9
10
倒排索引(搜索方向):
term "java" → [doc1, doc3, doc7]
term "python" → [doc2, doc3]

Doc Values(排序/聚合方向):
price 字段:
doc1 → 29.99
doc2 → 15.00
doc3 → 42.50
doc7 → 8.99

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):数值和空间索引

数值字段(integerlongdoubledate)和地理坐标字段(geo_point)不使用倒排索引,而是使用 BKD-tree(Block K-Dimensional tree)索引。

BKD-tree 是一种空间数据结构,支持高效的范围查询。对一维数据(数值、日期),BKD-tree 本质上是一个排好序的块结构,范围查询的复杂度是 O(√N)。对多维数据(经纬度坐标),BKD-tree 支持矩形范围和距离范围查询。

实验:观察 Segment 信息

查看一个 index 的 segment 信息:

1
GET /test-inverted-index/_segments

返回示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"indices": {
"test-inverted-index": {
"shards": {
"0": [{
"num_committed_segments": 1,
"num_search_segments": 1,
"segments": {
"_0": {
"generation": 0,
"num_docs": 3,
"deleted_docs": 0,
"size_in_bytes": 4521,
"committed": true,
"search": true,
"compound": true,
"version": "9.8.0"
}
}
}]
}
}
}
}

num_docs 是 segment 中的活跃文档数。deleted_docs 是已标记删除但未回收的文档数。compound 表示是否使用复合文件格式(多个小文件合成一个 .cfs 文件)。version 是创建这个 segment 的 Lucene 版本。

explain 观察 Lucene 层的打分细节:

1
2
3
4
5
GET /test-inverted-index/_search
{
"query": { "match": { "title": "search" } },
"explain": true
}

explain 输出会显示 BM25 打分的每个因子(IDF、TF、字段长度归一化),这些数据直接来自 segment 内部的倒排索引和 norms。

模式提炼:不可变段 + 后台合并

1
write → new segment → search across all segments → background merge → fewer segments

这种"写入追加新的不可变单元,后台异步合并旧单元"的模式,是 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 成本之间取平衡。

练习

  1. 创建一个 index,分批写入 100 条文档(每批 10 条,间隔调用 _refresh),然后用 _segments API 观察 segment 数量的增长。

  2. 删除几条文档,再次查看 _segments,注意 deleted_docs 的变化。然后调用 POST /index/_forcemerge?max_num_segments=1,再次查看——segment 数量减少,deleted_docs 变为 0。

  3. 对比同一个搜索请求在 merge 前(多个小 segment)和 merge 后(一个大 segment)的 took 时间差异。

系列导航

上一篇 下一篇
架构总览:Node、Cluster 与集群状态 Mapping 与字段类型:给文档定义结构

参考资料