序、段与文档、分片、副本的关系
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 [文档1, 文档2]
段2 [文档3, 文档4]
段3 [文档5, 文档6]
合并后:
大段A [文档1, 文档2, 文档3, 文档4, 文档5, 文档6]
合并过程:
- ES 选择几个大小相近的段
- 读取这些段的所有数据
- 将数据合并成一个新的大段
- 写入磁盘
- 更新索引信息,指向新段
- 删除旧段
段如何解决查询效率问题
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 段合并的额外好处
- 清理删除文档:合并时物理删除已标记删除的文档
- 优化存储:合并后的段通常比原段总和小
- 提高压缩率:大段可以实现更好的压缩比
- 减少文件数:减少打开文件数,降低系统开销
四、为什么 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 - 监控段统计信息,了解删除文档的积累情况
总结
当段合并过程中有文档被删除时:
- 合并基于快照:合并开始时创建删除状态的快照,不考虑后续删除
- 文档仍然存在:合并过程中删除的文档在物理上仍会存在于新段中
- 逻辑删除维护:这些文档会在新的删除文件中被标记为已删除
- 查询正常:查询时这些文档会被正确过滤,用户不会看到它们
- 下次合并清理:这些文档会在下一次段合并时被物理删除
这种设计是对性能、复杂性和正确性的权衡,确保了ES在高并发环境下的稳定性和可预测性。