Elasticsearch 底层存储与写入链路:从 Segment 到 Merge,一篇搞懂

作者:皮蛋0solo粥 | 发布日期:2026-04-22
标签:Elasticsearch、Lucene、Segment、写入链路、搜索引擎、底层原理


目录


引言:先建立正确的心智模型

很多人一开始就错在这里:

❌ 误解:Elasticsearch 像 MySQL,一条数据写进去就"更新文件"

真实情况是:

Elasticsearch 基于 Apache Lucene,底层是"追加写 + 不可变文件"模型

你可以理解为:

ES ≈ 一个不断写新文件、很少修改旧文件的系统

如果你带着"ES 像 MySQL"的假设去调优,你会觉得很多现象不可解释。但如果从"追加写 + 不可变文件"出发,一切都会变成"必然"。


一、核心概念:Segment(不可变的小索引文件)

1.1 什么是 Segment?

Segment 是 Lucene 中最核心的存储单元:

👉 一个 Segment 就是一个已经写好、不可再修改的小索引文件

一个 Index(索引)由多个 Shard(分片)组成,每个 Shard 内部维护了一组 Segment 文件。

Index
 └── Shard 0
 │    ├── Segment 1(不可变)
 │    ├── Segment 2(不可变)
 │    └── Segment 3(不可变)
 └── Shard 1
      ├── Segment 1(不可变)
      └── Segment 2(不可变)

1.2 Segment 的关键特性

特性说明
不可变Segment 一旦生成就不会修改
自包含每个 Segment 包含完整的倒排索引和存储数据
独立查询搜索时可以并行扫描多个 Segment
逐步增长写入时不断产生新的 Segment

二、写入一条数据,到底发生了什么?

不是修改原数据,而是:

写入数据 → 生成一个新的 Segment
时间线:
  T1: 写入 doc1 → 生成 segment_1
  T2: 写入 doc2 → 生成 segment_2
  T3: 写入 doc3 → 生成 segment_3

  Shard 内部:[segment_1, segment_2, segment_3]

到这里要形成一个核心认知:

ES 的数据是"越写越多文件",而不是"修改已有文件"


三、更新和删除的真相

3.1 更新一条数据?

ES 不会"就地修改",而是执行两步操作:

旧数据 → 标记删除(打 delete 标记)
新数据 → 写入一个新 Segment
示例:更新 doc_id=5

  旧 Segment 中:doc_id=5  → 标记为 deleted
  新 Segment 中:doc_id=5(新版本)→ 正常写入

3.2 删除一条数据?

只是打标记(deleted = true),并不会立刻释放空间

删除 doc_id=10

  Segment 中:doc_id=10 → 标记为 deleted
  磁盘空间:暂不释放(等 merge 清理)

3.3 核心公式

update = delete(标记旧版本)+ insert(写入新版本)
delete = 标记删除(不释放空间)

四、为什么需要 Merge(段合并)

如果一直只写不合并,会出现什么问题?

写入 10000 条数据 → 产生几千个小 Segment

问题:
  ├── 查询要扫描几千个 Segment → 慢
  ├── 有大量"已删除但没清理"的数据 → 浪费空间
  ├── 文件句柄占用多 → 系统资源紧张
  └── 倒排索引分散 → 无法利用压缩优化

所以引入一个机制:

👉 Merge(段合并):后台定期将多个小 Segment 合并成少量大 Segment


五、Merge 到底干了什么?

Merge 执行三个核心操作:

5.1 合并 Segment

seg_1 + seg_2 + seg_3 + seg_4  →  seg_big

5.2 清理已删除数据

合并前:
  seg_1: [doc1✅, doc2✅, doc3❌deleted]
  seg_2: [doc4❌deleted, doc5✅]

合并后:
  seg_big: [doc1✅, doc2✅, doc5✅]  ← doc3 和 doc4 被物理删除

5.3 重新压缩数据

  • 倒排索引重新组织
  • 字典编码(FST)优化
  • 存储格式更紧凑
合并前总大小:seg_1(5KB) + seg_2(3KB) + seg_3(4KB) = 12KB(含 3KB 废数据)
合并后总大小:seg_big(7KB) ← 压缩 + 清理,省了 5KB

六、写入链路全景图

一次写入不是"直接落盘",而是分阶段完成:

Client
  │
  ▼
Coordinating Node(协调节点)── 不存数据,只做路由调度
  │
  ▼
Primary Shard(主分片)── 真正写数据的地方
  │
  ├──→ Replica Shard(副本分片)── 数据冗余 + 读负载均衡
  │
  ▼
Indexing Buffer(内存缓冲区)── 数据先写这里,不可搜索
  │
  ▼
Refresh(每秒一次,默认)── Buffer → 新 Segment(可搜索,但未持久化)
  │
  ▼
Flush(条件触发)── Segment 落盘 + 清空 Translog(持久化完成)
  │
  ▼
Merge(后台持续)── Segment 合并 + 清理删除 + 压缩优化

一句话版本:

写入 = 先写内存 → 再变成 Segment → 最后后台优化


七、逐层拆解写入流程

7.1 Coordinating Node(协调节点)

职责:
  ├── 接收客户端请求
  ├── 根据 routing 规则找到目标 Shard
  ├── 转发请求到 Primary Shard
  └── 汇总各 Shard 结果返回客户端

关键:不存数据,只做调度

7.2 Primary Shard(主分片)

职责:
  ├── 接收写入请求
  ├── 执行实际写入操作
  ├── 保证数据一致性
  └── 写完后同步给 Replica Shard

写入是先写 Primary,再同步 Replica:

Client → Primary(写成功)→ Replica(同步)→ 返回成功

7.3 Replica Shard(副本分片)

职责:
  ├── 数据冗余(容灾,Primary 挂了可以切换)
  ├── 提供读能力(负载均衡)
  └── 提升查询吞吐

7.4 Indexing Buffer(内存缓冲区)

写入的数据先放这里
  ├── ❌ 不可搜索(还没变成 Segment)
  ├── ❌ 不在磁盘(纯内存)
  └── ✅ 写入延迟极低(内存操作)

八、refresh:近实时搜索的关键

8.1 refresh 做了什么?

Indexing Buffer(内存)──refresh──→ 新 Segment(可搜索)

refresh 将内存中的数据转成一个新的 Segment,并打开 Searcher 使其对查询可见。

8.2 refresh 之后的状态

状态说明
✅ 可搜索Searcher 已打开新 Segment
❌ 未持久化数据仍在内存中,宕机可能丢失

8.3 默认行为

# 默认每 1 秒 refresh 一次
index.refresh_interval: 1s

这就是 ES 被称为 Near Real-Time(近实时) 搜索引擎的原因——写入后最多 1 秒即可被搜索到。

8.4 refresh 的代价(很重要)

每次 refresh:
  ├── 生成一个新 Segment
  ├── Segment 越多 → 查询要扫越多
  └── 触发 Merge 的概率越大

代价链:
  频繁 refresh → 大量小 Segment → merge 压力激增 → IO/CPU 上升 → 写入吞吐下降

九、flush:真正的持久化

9.1 flush 做了什么?

flush 操作:
  1. 将内存中的 Segment 写入磁盘(fsync)
  2. 清空 Translog
  3. 更新 checkpoint

9.2 flush 触发时机

触发条件说明
translog 太大默认 512MB,防止恢复时间过长
定时触发每 5 秒检查一次
手动触发POST /index/_flush

9.3 refresh vs flush(易混淆点)

维度refreshflush
做了什么内存 → 新 SegmentSegment 落盘 + 清 Translog
数据可搜索?✅ 是✅ 是
数据持久化?❌ 否(内存 Segment)✅ 是(磁盘 Segment)
触发频率每秒(默认)条件触发(远低于 refresh)
代价中等(产生新 Segment)较高(磁盘 fsync)

十、translog:数据安全的第一道保障

10.1 什么是 translog?

translog(transaction log)是 ES 的预写日志(WAL),类似于 MySQL 的 redo log。

写入流程实际上是:

写数据 → 写 translog(磁盘,顺序写)→ 写 buffer(内存)

10.2 translog 的作用

如果 ES 宕机:
  ├── 内存中的 Buffer 数据丢失
  ├── 磁盘上的 Segment 数据保留
  └── Translog 记录了"Segment 之外"的写入

恢复时:
  ├── 读取已有 Segment
  └── 重放 Translog → 补回未 flush 的数据

10.3 translog 的配置

# translog 刷盘策略
index.translog.durability: request  # 每次写入都 fsync(默认,最安全)
index.translog.durability: async    # 定时 fsync(性能更好,可能丢数据)

# translog 大小阈值
index.translog.flush_threshold_size: 512mb

10.4 数据安全的三个阶段(面试级表述)

阶段 1:写入 translog 成功 → 具备"可恢复性"(宕机不丢数据)
阶段 2:refresh 完成 → 具备"可搜索性"(查询可见)
阶段 3:flush 完成 → 具备"持久性"(Translog 清空,数据在 Segment 中)

一句话总结:数据在 refresh 后对搜索可见,在 translog 写入后具备容灾能力,在 flush 后完成最终持久化。


十一、为什么 ES 查询快?倒排索引

11.1 核心数据结构

传统数据库(正排索引):

文档 → 内容
doc1  → "感冒药 阿莫西林 头孢"
doc2  → "止咳糖浆 胃药"
doc5  → "感冒药 布洛芬"
doc20 → "感冒药 对乙酰氨基酚"

ES(倒排索引):

词 → 出现在哪些文档

"感冒药"     → [doc1, doc5, doc20]
"阿莫西林"   → [doc1]
"止咳糖浆"   → [doc2]
"布洛芬"     → [doc5]

11.2 查询过程

查询"感冒药":
  ├── 不用扫描全量文档
  ├── 直接查倒排索引:"感冒药" → [doc1, doc5, doc20]
  └── 只需读取这 3 个文档 → 极快

查询"感冒药 AND 布洛芬":
  ├── "感冒药" → [doc1, doc5, doc20]
  ├── "布洛芬" → [doc5]
  └── 交集 = [doc5] → 只返回 doc1

11.3 倒排索引的组成

倒排索引
 ├── Term Dictionary(词典):所有去重词的有序列表
 │    └── 用 FST(Finite State Transducer)压缩存储
 ├── Postings List(倒排链):每个词对应的文档 ID 列表
 │    └── 用 Frame of Reference(FOR)压缩
 └── Doc Values(正排数据):用于排序、聚合
      └── 列式存储,按字段独立存储

十二、实战现象解释:文档数↑但 size↓

这是一个非常经典的"反直觉"现象。理解了前面的 Segment 和 Merge 机制,答案就水到渠成了:

写入阶段:
  ├── 产生大量小 Segment
  ├── 包含很多 delete 标记(update = delete + insert 产生的)
  └── 数据比较"松散",压缩率低

Merge 后:
  ├── 删除了所有标记为 deleted 的无效数据
  ├── 重新压缩 Segment(倒排索引优化)
  └── Segment 数量减少,文件系统开销降低

结果:
  文档数 ↑(有效文档确实多了)
  磁盘 size ↓(浪费空间被清理了)

十三、结合实际场景理解:bulk + embedding + 大量写入

在医药商品搜索系统的实际场景中,我们经常需要做大量数据写入(含 embedding 向量),这时候理解写入链路就非常关键。

13.1 为什么 bulk 写入比逐条写入快?

逐条写入(1000 条):
  ├── 1000 次 refresh → 1000 个 Segment → merge 压力巨大
  └── 1000 次网络往返 → 延迟累积

bulk 写入(1000 条/批):
  ├── 1 次 refresh → 1 个 Segment → merge 压力小
  └── 1 次网络往返 → 延迟极低

13.2 为什么大数据量写入时要调大 refresh_interval?

# 写入阶段:关闭 refresh,攒够数据再开
PUT /my_index/_settings
{
  "index": {
    "refresh_interval": "-1"  # 关闭自动 refresh
  }
}

# 写入完成后:恢复 refresh
PUT /my_index/_settings
{
  "index": {
    "refresh_interval": "1s"
  }
}
关闭 refresh 的效果:
  ├── 数据持续写入 Buffer → 不产生新 Segment
  ├── 没有 Segment → 不触发 merge
  └── IO 降低 → 写入吞吐大幅提升

13.3 写入优化参数速查表

参数默认值写入优化建议说明
refresh_interval1s-1(关闭)或 30s减少 Segment 生成
number_of_replicas10(写入阶段)减少副本同步开销
translog.durabilityrequestasync减少 fsync 次数
bulk_size-5~15MB/批平衡内存和网络
index.buffer_size10% JVM20%~30% JVM增大 Buffer

13.4 写入完成后别忘了恢复

# 恢复副本
PUT /my_index/_settings
{
  "index": {
    "number_of_replicas": 1,
    "refresh_interval": "1s"
  }
}

# 强制 merge(可选,让 Segment 尽快合并)
POST /my_index/_forcemerge?max_num_segments=1

十四、三道理解检查题(面试自测)

Q1:删除数据后,磁盘空间为什么不会立刻减少?

✅ ES 的删除只是打标记(deleted = true),物理空间在 Merge 时才真正释放。Merge 是后台异步执行的,不是实时触发。

Q2:bulk 写入为什么比逐条写入性能高?

✅ bulk 将多条写入合并为一次请求,减少了 refresh 次数和网络往返。每次 refresh 会生成新 Segment,而 Segment 越多,merge 压力越大,写入吞吐越低。bulk 从根源上减少了 Segment 的生成。

Q3:为什么 refresh 太频繁会拖慢写入?

✅ refresh 会生成新的 Segment。频繁 refresh 导致 Segment 数量激增,触发更多 Merge 操作。Merge 是重量级操作(读多 Segment + 写大 Segment + 清理删除 + 重压缩),大量消耗 IO 和 CPU,最终拖慢写入吞吐。


十五、总结:最基础的三板斧 + 写入链路一句话

如果你只记三件事:

#核心认知说明
1️⃣ES 不修改数据,只追加update = delete + insert
2️⃣数据存储在 Segment(不可变)Segment 一旦生成就不会改
3️⃣Merge 负责"清理 + 压缩 + 合并"后台持续优化存储结构

写入链路一句话(面试级表述):

Elasticsearch 的写入流程是先写入 Primary Shard,同时记录 Translog 并写入内存 Buffer。数据在 Refresh 时会被转为 Segment,从而对搜索可见。随后在 Flush 时持久化到磁盘并清理 Translog。后台通过 Merge 合并 Segment、清理删除数据并优化存储结构。

写入链路全景(一图总结):

写入请求
  │
  ▼
Coordinating Node ── 路由到目标 Shard
  │
  ▼
Primary Shard
  ├── 写 Translog(磁盘,容灾保障)
  ├── 写 Indexing Buffer(内存)
  └── 同步到 Replica Shard
  │
  ▼
Refresh(1s 默认)── Buffer → 新 Segment → 可搜索
  │
  ▼
Flush(条件触发)── Segment 落盘 + 清 Translog → 持久化
  │
  ▼
Merge(后台持续)── 合并 Segment + 清理删除 + 压缩优化
  │
  ▼
优化后的存储(文档数↑,size 可能↓)

参考资料

  • Elasticsearch 官方文档 - Index Lifecycle
  • Apache Lucene - Segment File Format
  • 《Elasticsearch 权威指南》- 索引管理
  • Lucene 源码 - IndexWriter / SegmentMerger
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐