ES 常见问题

44 阅读13分钟

序、段与文档、分片、副本的关系

1. 文档 (Document)

定义:ES中存储的基本单位,类似数据库中的一行记录 特点:JSON格式,包含多个字段和值 例子:一个商品信息、一篇文章、一条用户记录

{
  "id": 1,
  "title": "iPhone 14",
  "price": 999.99,
  "vector": [0.1, 0.2, 0.3, ...]
}

2. 段 (Segment)

定义:Lucene的基本存储单元,包含一部分文档的完整索引 特点: 不可变(一旦创建就不能修改) 包含倒排索引、存储字段、文档值等 随着数据写入,会不断生成新段 生命周期: 文档先写入内存缓冲区 缓冲区刷新(refresh)生成新段 多个小段最终会合并(merge)成大段

3. 分片 (Shard)

定义:索引的水平分区,是一个完整的Lucene索引 特点: 包含多个段 独立的搜索单元 可以分布在不同节点上 作用: 数据分布:将大索引分散存储 并行处理:提高搜索性能 水平扩展:增加分片数量扩展容量

4. 副本 (Replica)

定义:分片的完整副本 特点: 与原分片数据完全相同 通常分布在不同节点上 可以独立处理搜索请求 作用: 高可用:节点故障时保证数据不丢失 提高吞吐量:分担搜索压力

想象一个图书馆系统:

  • 文档 = 一本具体的书
  • = 书架上的一组书
  • 分片 = 一个完整的书架
  • 副本 = 相同书架的备份(放在不同的图书馆分馆)

层级关系:

索引 (Index) ──────┐
  │                │
  ├── 分片1 (Shard) ├── 副本1 (Replica)
  │    │           └── 副本2 (Replica)
  │    │
  │    ├── 段1 (Segment)
  │    │    ├── 文档1 (Document)
  │    │    ├── 文档2 (Document)
  │    │    └── 文档3 (Document)
  │    │
  │    └── 段2 (Segment)
  │         ├── 文档4 (Document)
  │         └── 文档5 (Document)
  │
  └── 分片2 (Shard)
       │
       ├── 段3 (Segment)
       │    └── 文档6 (Document)
       │
       └── 段4 (Segment)
            ├── 文档7 (Document)
            └── 文档8 (Document)

一、什么是段?想象成一本本小字典

想象你在整理一个图书馆。当新书到达时,你不会每次都重新排列整个图书馆,而是先把新书放在一个小书架上。随着小书架越来越多,你需要定期整理,把多个小书架合并成大书架,这样查找更方便。

在 Elasticsearch 中:

  • 段(Segment) 就像这些"小书架"
  • 每个段是一个独立的、不可变的小索引
  • 包含了部分文档的完整索引信息

段的生命周期

1.1 内存缓冲与段生成

┌─────────────┐
│ 文档1,2,3.. │
└─────┬───────┘
      │ 写入内存缓冲区
      ▼
┌─────────────┐
│ 内存缓冲区   │
└─────┬───────┘
      │ 达到阈值或定时刷新
      ▼
┌─────────────┐
│   新段      │ ← 一次性写入磁盘(顺序IO)
└─────────────┘

关键点

  • 新文档先写入内存缓冲区(RAM Buffer)
  • 缓冲区达到阈值或定时刷新(refresh)时,生成新段
  • 新段一次性写入磁盘,利用高效的顺序IO

1.2 段的不变性

一旦段被写入磁盘,它就是不可变的

  • 不能添加新文档
  • 不能修改已有文档
  • 不能直接从段中删除文档

删除操作实际上是在一个单独的"删除文件"中标记文档已删除,而不是真正从段中移除。

二、为什么需要段合并?

想象你要查找一本书,但必须在10个小书架中逐一寻找,这会很慢。如果把这10个小书架合并成1个大书架,查找就会快很多。

段合并的好处:

  1. 提高查询速度:查询时只需检索少量大段,而不是多个小段
  2. 减少资源消耗:每个段都需要内存和文件句柄
  3. 节省磁盘空间:合并时会删除已标记删除的文档
  4. 优化索引结构:更紧凑的数据组织方式

三、段合并是如何工作的?

合并前:
段1 [文档1, 文档2]2 [文档3, 文档4]3 [文档5, 文档6]

合并后:
大段A [文档1, 文档2, 文档3, 文档4, 文档5, 文档6]

合并过程:

  1. ES 选择几个大小相近的段
  2. 读取这些段的所有数据
  3. 将数据合并成一个新的大段
  4. 写入磁盘
  5. 更新索引信息,指向新段
  6. 删除旧段

段如何解决查询效率问题

3.1 多段查询与结果合并

当有多个段时,查询过程:

查询: "苹果手机"
  ┌─────────┐  ┌─────────┐  ┌─────────┐
  │  段1    │  │  段2    │  │  段3    │
  └────┬────┘  └────┬────┘  └────┬────┘
       │            │            │
       ▼            ▼            ▼
  [文档1,3]     [文档7]      [文档9,10]
       │            │            │
       └────────────┼────────────┘
                    ▼
             [1,3,7,9,10] ← 合并结果

问题:段数量增多会导致查询性能下降

3.2 段合并策略

为解决段数量过多的问题,Lucene实现了智能的段合并策略:

┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│段1  │ │段2  │ │段3  │ │段4  │  ← 小段
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
   └───────┼───────┘      │
           ▼               │
        ┌─────┐            │
        │段A  │            │     ← 合并后的大段
        └──┬──┘            │
           └────────┬──────┘
                    ▼
                 ┌─────┐
                 │段X  │        ← 最终大段
                 └─────┘

合并策略特点

  • 优先合并大小相近的段
  • 在后台线程执行,不阻塞写入和查询
  • 合并完成后,原段被标记为可删除
  • 定期清理被标记删除的段文件

3.3 段合并的额外好处

  1. 清理删除文档:合并时物理删除已标记删除的文档
  2. 优化存储:合并后的段通常比原段总和小
  3. 提高压缩率:大段可以实现更好的压缩比
  4. 减少文件数:减少打开文件数,降低系统开销

四、为什么 Lucene 使用段作为基本单元而不是直接操作文档?

1. 写入性能的考虑

如果每个文档都是独立单元: 想象一下,如果每添加一个文档就要更新整个索引结构:

添加文档1 → 创建索引结构
添加文档2 → 重建整个索引结构
添加文档3 → 再次重建整个索引结构

问题: 每次写入都需要重建整个索引 写入成本随文档数量增加而指数级增长 高并发写入几乎不可能实现

段的解决方案:

添加文档1,2,3... → 内存缓冲区 → 生成新段1
添加文档4,5,6... → 内存缓冲区 → 生成新段2
...
后台:合并段1+段2 → 大段A

优势: 批量处理文档,摊销索引创建成本 写入操作只需追加,不修改现有数据 支持高并发写入

2. 索引结构的效率

倒排索引的本质: 倒排索引是词项到文档的映射:

"苹果" → [文档1, 文档3, 文档7]
"手机" → [文档2, 文档3, 文档5]

如果每个文档独立: 每个文档需要维护自己的词典 大量重复的词项存储 内存和磁盘空间浪费严重

段的优势: 一个段内的所有文档共享一个词典 大幅减少冗余存储 更高效的压缩和存储

五、为什么搜索引擎不能像MySQL那样简单地"加一行数据"?

当添加一个"苹果手机"文档时,需要更新"苹果"和"手机"等多个词项的倒排表,如果每次都直接更新,成本极高。段机制通过批量处理和延迟合并巧妙地解决了这个问题。

MySQL的数据组织方式

在MySQL中,数据是按行存储的:

表结构:
+----+--------+-------+
| ID | 商品名  | 价格  |
+----+--------+-------+

添加一行:
INSERT INTO products VALUES (1001, '苹果手机', 5999);

结果:
+------+----------+-------+
| 1001 | 苹果手机 | 5999  | ← 只是简单地添加一行
+------+----------+-------+

MySQL的特点: 数据按行物理存储 主要索引是B+树结构 添加行只需在表末尾追加数据 索引更新是局部的,只影响相关树节点

Lucene的数据组织方式

而Lucene是倒排索引结构:

倒排索引结构:
"苹果" → [文档1, 文档3]
"手机" → [文档2, 文档3]

添加新文档"苹果手机":
需要更新多个倒排表!

更新后:
"苹果" → [文档1, 文档3, 文档4]  ← 需要更新
"手机" → [文档2, 文档3, 文档4]  ← 需要更新

Lucene的特点: 主体是倒排索引(词项→文档列表) 一个文档会影响多个倒排表 每个词项都维护一个文档ID列表

为什么搜索引擎不能简单"加一行数"

1. 倒排索引的复杂性

当你添加一个新文档"苹果手机XS Max 256GB金色"时:

需要更新的倒排表:
"苹果" → 添加新文档ID
"手机" → 添加新文档ID
"XS" → 添加新文档ID
"Max" → 添加新文档ID
"256GB" → 添加新文档ID
"金色" → 添加新文档ID

每个词项都需要更新其文档列表!

2. 文本分析的开销

搜索引擎还需要对文本进行分析处理:

原始文本: "2023新款苹果手机Pro"

分词 → ["2023", "新款", "苹果", "手机", "Pro"]
去停用词 → ["2023", "新款", "苹果", "手机", "Pro"]
词干提取 → ["2023", "新", "苹果", "手机", "Pro"]
同义词扩展 → ["2023", "新", "苹果", "iPhone", "手机", "Pro"]
...
这些处理都需要在索引时完成。

3. 其他索引结构的更新

除了基本倒排索引外,还有: 词典结构:需要添加新词项 文档向量:计算TF-IDF等统计信息 位置信息:记录词在文档中的位置 评分信息:更新相关性计算数据

六、段合并过程中有文档被删除会发生什么?

你提出了一个非常好的问题!当段合并正在进行中,同时有文档被删除时,Elasticsearch 会如何处理这种情况?让我详细解释这个特殊场景:

1. 段合并与并发删除的时间线

假设我们有以下时间线:

时间点1: 开始合并段A、段B → 新段C
时间点2: 用户删除了段A中的文档X
时间点3: 合并完成,生成新段C

关键问题:文档X在新段C中是否会被物理删除?

2. 段合并如何处理并发删除

2.1 合并开始时的快照语义

当段合并开始时,Elasticsearch 会创建一个删除状态的快照

┌─────────────────┐  ┌─────────────────┐
│     段A         │  │  删除文件(t1)   │
├─────────────────┤  ├─────────────────┤
│ 文档1: 活跃     │  │ 段A:文档3       │ ← 合并开始时已知的删除
│ 文档2: 活跃     │  └─────────────────┘
│ 文档3: 活跃     │
└─────────────────┘

合并过程基于这个快照进行,而不会动态检查合并过程中发生的新删除。

2.2 合并过程中的删除

当合并正在进行时,如果有新的删除操作:

┌─────────────────┐  ┌─────────────────┐
│     段A         │  │  删除文件(t2)   │
├─────────────────┤  ├─────────────────┤
│ 文档1: 活跃     │  │ 段A:文档3       │
│ 文档2: 活跃     │  │ 段A:文档2       │ ← 合并进行中的新删除
│ 文档3: 活跃     │  └─────────────────┘
└─────────────────┘

2.3 合并结果

合并完成后的新段C将:

  • 包含合并开始时已知被删除的文档的物理删除(文档3)
  • 仍然包含合并过程中被删除的文档(文档2)
┌─────────────────┐  ┌─────────────────┐
│     段C         │  │  删除文件(新)   │
├─────────────────┤  ├─────────────────┤
│ 文档1: 活跃     │  │ 段C:文档2       │ ← 新删除文件标记段C中的文档2
│ 文档2: 活跃     │  └─────────────────┘
└─────────────────┘

关键点

  • 文档2物理上仍存在于新段C中
  • 但会在新的删除文件中被标记为已删除
  • 需要等待下一次段合并才会被物理删除

3. 技术原因解释

为什么ES采用这种设计?有几个重要原因:

3.1 避免合并过程中的竞态条件

如果合并过程中不断检查新的删除操作:

  • 可能导致合并过程无法收敛
  • 增加实现复杂性和错误风险
  • 需要复杂的锁机制

3.2 保持段的不可变性原则

段的不可变性是ES核心设计原则:

  • 合并是基于不可变段的只读操作
  • 删除操作只修改删除文件,不修改段本身
  • 这种分离使并发操作更安全

3.3 性能考虑

动态检查新删除会显著降低合并性能:

  • 需要频繁检查删除状态
  • 可能需要重新组织已处理的数据
  • 增加内存和CPU开销

4. 实际例子:合并中的并发删除

假设有以下场景:

初始状态:
段A: 文档[1,2,3,4,5] (全部活跃)
段B: 文档[6,7,8,9,10] (全部活跃)

时间点1: 开始合并段A和段B
时间点2: 用户删除文档3和文档7
时间点3: 合并完成

合并结果

新段C: 文档[1,2,3,4,5,6,7,8,9,10]
删除文件: 标记段C中的文档3和文档7为已删除

查询时: 文档3和文档7会被过滤掉,不返回给用户

5. 如何验证这个行为

你可以通过以下步骤验证这个行为:

# 1. 创建测试索引
PUT test_index
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  }
}

# 2. 添加一些文档
POST test_index/_doc/1 {"field": "value1"}
POST test_index/_doc/2 {"field": "value2"}
POST test_index/_doc/3 {"field": "value3"}

# 3. 刷新以创建段
POST test_index/_refresh

# 4. 添加更多文档创建新段
POST test_index/_doc/4 {"field": "value4"}
POST test_index/_doc/5 {"field": "value5"}
POST test_index/_refresh

# 5. 查看段信息
GET test_index/_segments

# 6. 开始手动合并(这需要一些时间)
POST test_index/_forcemerge?max_num_segments=1&wait_for_completion=false

# 7. 在合并进行中删除文档
DELETE test_index/_doc/2

# 8. 等待合并完成并检查段信息
GET test_index/_segments

# 9. 查询删除的文档(应该不返回)
GET test_index/_doc/2

# 10. 再次强制合并并检查段统计
POST test_index/_forcemerge?max_num_segments=1
GET test_index/_segments

在步骤8中,你会发现尽管文档2已被标记为删除,但段统计信息仍然显示它物理存在于合并后的段中。

6. 实际应用中的影响

6.1 空间使用

合并过程中删除的文档会暂时占用空间:

  • 这些文档会存在于新合并的段中
  • 需要等待下一次合并才会释放空间
  • 在高删除率场景下可能导致临时的空间膨胀

6.2 查询性能

对查询性能的影响很小:

  • 虽然文档物理存在,但查询时会被过滤掉
  • 删除文件的查找非常高效(位图实现)
  • 用户不会看到已删除的文档

6.3 优化策略

如果你的应用有大量并发删除和合并:

  • 考虑增加index.merge.policy.expunge_deletes_allowed
  • 在低峰期手动执行_forcemerge?only_expunge_deletes=true
  • 监控段统计信息,了解删除文档的积累情况

总结

当段合并过程中有文档被删除时:

  1. 合并基于快照:合并开始时创建删除状态的快照,不考虑后续删除
  2. 文档仍然存在:合并过程中删除的文档在物理上仍会存在于新段中
  3. 逻辑删除维护:这些文档会在新的删除文件中被标记为已删除
  4. 查询正常:查询时这些文档会被正确过滤,用户不会看到它们
  5. 下次合并清理:这些文档会在下一次段合并时被物理删除

这种设计是对性能、复杂性和正确性的权衡,确保了ES在高并发环境下的稳定性和可预测性。