【Elasticsearch从入门到精通】第44篇:Elasticsearch分布式索引原理——分片路由与写入流程
上一篇【第43篇】Elasticsearch搜索过程原理——分词、查询树与BM25评分
下一篇【第45篇】Elasticsearch分布式检索原理——Query Then Fetch两阶段搜索
摘要
Elasticsearch的分布式能力是其区别于传统搜索引擎的核心竞争力。本文聚焦分布式索引原理,从分片路由公式 shard = hash(_routing) % number_of_primary_shards 出发,解释为什么主分片数量创建后不可更改这一经典问题,并通过自定义routing参数演示将相关文档路由到同一分片的实战技巧。接着以完整的ASCII流程图追踪文档写入的全流程:客户端→协调节点→主分片→副本分片→响应,并深入解析wait_for_active_shards参数、translog的WAL机制以及Lucene Segment的flush流程。关键词:Elasticsearch分布式索引、分片路由公式、translog、主分片、写入流程。
一、分布式架构基础
Elasticsearch是一个构建在Lucene之上的分布式搜索引擎。它的分布式特性基于以下四大基石:
| 机制 | 作用 | 关键技术 |
|---|---|---|
| 分片(Shard) | 将完整数据切割为N份存储在不同节点上,突破单节点资源限制 | 路由公式计算目标分片 |
| 副本(Replica) | 数据冗余备份,节点宕机后快速恢复,提升查询吞吐量 | 主分片写入后同步到副本 |
| 集群发现(Discovery) | 新节点自动发现集群并加入,无需手动配置 | 种子节点+自动探测 |
| 负载均衡(Relocate) | 自动均衡分片分布,节点增删时无需人工干预 | 分片重分配机制 |
在进入技术细节之前,先建立几个核心概念:
- 主分片(Primary Shard):数据的"权威副本",写入操作必须先经过主分片
- 副本分片(Replica Shard):主分片的完整拷贝,用于容错和分担查询负载
- 协调节点(Coordinating Node):接收客户端请求的节点,负责请求路由和结果汇总
二、分片路由公式深度解析
2.1 路由公式
Elasticsearch通过以下公式决定文档存储在哪个分片中:
shard_num = hash(_routing) % num_primary_shards
其中:
_routing = 路由值(默认取文档的 _id 值)
hash() = MurmurHash3 哈希函数(保证均匀分布)
% = 取模运算
num_primary_shards = 主分片数量(创建时指定,不可变)
2.2 为什么 number_of_shards 创建后不能改变?
这是Elasticsearch新手最常问的问题之一。答案就藏在路由公式的取模运算中:
假设主分片数为3:
doc_001 → hash("doc_001") % 3 = 某个值 → 分配到分片 0/1/2
如果把主分片数改为4:
doc_001 → hash("doc_001") % 4 = 完全不同的值 → 分配到错误的分片!
取模运算的除数一旦改变,所有文档的Hash取模结果都会重新分布,导致原有路由关系全部失效。Lucene分片一旦创建就不可分割,因此Elasticsearch不支持动态修改主分片数量。
★ 伸缩性的正确姿势:
- 水平扩展读能力 → 增加副本分片(动态调整,无需重建索引)
- 水平扩展写能力 → 通过 Reindex API 创建新索引并修改分片数
- 预分配策略 → 创建时预留一定余量(如预期增长10倍则多分配一些分片)
2.3 routing参数:将相关文档路由到同一分片
在某些业务场景下,我们希望将相关联的文档存储在同一个分片中。例如,将同一个用户的所有订单文档路由到同一分片,便于后续聚合计算和父子关联查询。
// 创建支持自定义routing的索引
PUT /orders
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"user_id": { "type": "keyword" },
"product": { "type": "text" },
"amount": { "type": "double" },
"created_at": { "type": "date" }
}
}
}
// 插入文档时指定routing参数(使用 user_id 作为路由值)
POST /orders/_doc/1001?routing=user_42
{
"user_id": "user_42",
"product": "iPhone 14 Pro Max",
"amount": 8999.00,
"created_at": "2026-05-22"
}
POST /orders/_doc/1002?routing=user_42
{
"user_id": "user_42",
"product": "AirPods Pro",
"amount": 1799.00,
"created_at": "2026-05-22"
}
// 查询时必须指定相同的routing
GET /orders/_search?routing=user_42
{
"query": {
"match": { "user_id": "user_42" }
}
}
使用固定routing参数的好处:
- 减少搜索分片数:只需搜索一个分片,无需广播到所有分片
- 保证关联性:父子文档可确保在同一分片
- 提升聚合性能:局部聚合无需协调
代价是:
- 数据分布可能不均匀(如果某些routing值的数据量远大于其他值)
- 查询时必须记住并使用相同的routing值
三、文档写入全流程:从客户端到磁盘
当一个文档通过 POST /index/_doc 被写入Elasticsearch时,它要经历一个精确编排的多阶段流程。
3.1 完整写入流程(ASCII图)
客户端 协调节点 主分片节点 副本分片节点
│ │ │ │
│ ① POST /index/_doc│ │ │
│───────────────────>│ │ │
│ │ │ │
│ │ ② 路由计算 │ │
│ │ hash(doc_id) % N │ │
│ │ 确定目标分片 │ │
│ │ │ │
│ │ ③ 转发写请求 │ │
│ │───────────────────>│ │
│ │ │ │
│ │ │ ④ 写入 Translog │
│ │ │ (WAL, 先写日志) │
│ │ │ │
│ │ │ ⑤ 写入内存Buffer │
│ │ │ (倒排索引构建) │
│ │ │ │
│ │ │ ⑥ 同步到副本分片 │
│ │ │───────────────────>│
│ │ │ │
│ │ │ │ ⑦ 写入Translog
│ │ │ │ 写入内存Buffer
│ │ │ │
│ │ │ ⑧ 副本报告成功 │
│ │ │<───────────────────│
│ │ │ │
│ │ ⑨ 主分片报告成功 │ │
│ │<───────────────────│ │
│ │ │ │
│ ⑩ 返回写入成功 │ │ │
│<───────────────────│ │ │
│ │ │ │
3.2 分步详解
步骤①-②:请求接收与路由
客户端向任意节点发送写入请求。这个接收请求的节点承担了协调节点的角色。协调节点通过路由公式计算文档应该落在哪个主分片上:
shard = hash("doc_1001") % 3 = 1 → 该文档应写入分片1
步骤③:转发到主分片所在节点
协调节点查询集群元数据,找到分片1的主分片所在节点(假设为Node3),将请求转发过去。
步骤④:写入Translog(先写日志,Write-Ahead Logging)
这是关键步骤。在内存中修改索引之前,先将操作记录到Translog(事务日志)中。这是为了防止节点宕机导致内存数据丢失——即使Elasticsearch进程崩溃,重启后可以从Translog中恢复尚未持久化的数据。
Translog机制的核心价值:
正常写入:内存Buffer → Translog → 后续异步Flush → 磁盘Segment
节点宕机:内存数据丢失 → 从Translog恢复 → 数据不丢
步骤⑤:写入内存Buffer
文档内容被解析并构建倒排索引,暂存在JVM堆内存的Index Buffer中。此时数据还没有持久化到磁盘,但Translog中已有记录。
步骤⑥-⑧:同步到副本分片
主分片将写请求并发地转发给所有副本分片。副本分片同样先写Translog再构建内存索引。当所有副本分片都成功响应后,主分片才确认写入成功。
步骤⑨-⑩:响应返回
主分片向协调节点报告写入成功,协调节点再将结果返回给客户端。
3.3 wait_for_active_shards参数
在分布式环境中,副本同步是需要时间的。wait_for_active_shards 参数控制写入操作在返回成功前必须等待多少个分片被激活:
// 示例:必须等待至少2个分片(主分片+1个副本)确认
POST /shop/_doc?wait_for_active_shards=2
{
"title": "iPhone 14 Pro",
"price": 7999
}
// 常用配置值:
// wait_for_active_shards=1 默认值,仅主分片即可
// wait_for_active_shards=all 所有分片(主+全部副本)
// wait_for_active_shards=N 至少N个分片
| 配置值 | 数据安全性 | 写入延迟 | 适用场景 |
|---|---|---|---|
| 1(默认) | ★★☆☆☆ | ★★★★★(低) | 一般业务,容忍极短暂不一致 |
| all | ★★★★★ | ★★☆☆☆(高) | 金融/交易核心,零容忍丢失 |
| N ≥ 2 | ★★★★☆ | ★★★☆☆(中) | 平衡安全性与性能 |
四、Translog 与 Flush 机制
4.1 Translog:Elasticsearch的WAL
Translog(Transaction Log)是Elasticsearch实现数据持久性的关键组件。它是一种**预写日志(Write-Ahead Log, WAL)**机制,灵感来自数据库的redo log。
Translog工作流程图:
写入请求
│
▼
┌─────────────┐ 同步刷盘(每次写入) ┌──────────────┐
│ 内存Buffer │ ◄────────────────────────── │ Translog │
│ (倒排索引) │ │ (磁盘文件) │
└──────┬──────┘ └──────┬───────┘
│ │
│ 定期刷新(默认1秒) │ 达到阈值时清空
▼ │
┌──────────────┐ │
│ 文件系统缓存 │ │
│ (OS Cache) │ │
└──────┬───────┘ │
│ sync间隔(默认5秒) │
▼ ▼
┌──────────────┐ Flush清空后 ┌──────────────┐
│ 磁盘Segment │ ◄──────────────────────────── │ Translog │
│ (持久化) │ │ 被截断 │
└──────────────┘ └──────────────┘
4.2 Translog相关配置
// 索引级Translog配置
PUT /shop/_settings
{
"index.translog.durability": "request", // 默认值,每次请求后fsync
"index.translog.sync_interval": "5s", // async模式下的同步间隔
"index.translog.flush_threshold_size": "512mb" // translog达到该大小后触发flush
}
// durability的两种模式:
// "request" - 每次写入后fsync,数据安全性最高,延迟略高
// "async" - 异步刷盘,性能最优但可能丢失sync_interval内的数据
4.3 Flush流程:从内存到磁盘
Flush是将内存中的文档持久化为Lucene Segment的过程。触发条件包括:
- Translog大小达到阈值(
index.translog.flush_threshold_size,默认512MB) - 定时触发(默认每30分钟自动flush一次)
- 手动触发(
POST /index/_flush)
Flush执行时:
- 内存Buffer中的文档被写入新的Segment文件到磁盘
- 文件系统缓存被同步(fsync)到物理磁盘
- 旧的Translog被清空,开始写入新的Translog
// 手动触发Flush
POST /shop/_flush
// 强制刷新(立即将内存数据写入Segment,但不做fsync)
POST /shop/_refresh
// 查看Translog状态
GET /shop/_stats/translog?pretty
4.4 refresh vs flush vs fsync 区别
| 操作 | 作用 | 频率 | 对搜索的影响 |
|---|---|---|---|
| refresh | 将内存Buffer写入新Segment,打开Segment供搜索 | 默认1秒/次 | 新文档立即可搜索 |
| flush | 执行refresh+fsync,清空Translog | 30分钟或512MB | 不直接影响搜索 |
| fsync | 确保文件系统缓存同步到物理磁盘 | Flush时触发 | 保证数据持久化 |
五、Lucene Segment的合并机制
5.1 什么是Segment?
Lucene中的索引由多个不可变的**Segment(段)**组成。每次refresh都会创建一个新的Segment,随着时间推移,索引中会积累大量小Segment。
写入时间线:
t1: refresh → Segment_1 (小)
t2: refresh → Segment_2 (小)
t3: refresh → Segment_3 (小)
...
t100: 积累了100个小Segment
问题:
- 搜索时需要打开所有Segment,文件句柄开销大
- 每个Segment独立存储Term字典,内存占用高
- 删除文档只是标记为删除,占用空间
5.2 Segment合并(Merge)
Lucene在后台自动执行Segment合并,将多个小Segment合并为一个大Segment,并在此过程中物理删除已标记删除的文档。
// 查看索引的Segment信息
GET /shop/_segments?pretty
// 手动触发强制合并(生产环境谨慎使用)
POST /shop/_forcemerge?max_num_segments=1
// 查看Merge进度
GET /_cat/segments/shop?v&h=index,segment,size,docs.count
5.3 Force Merge注意事项
- 强制合并会消耗大量CPU和I/O资源
- 建议在业务低峰期执行
- 只对不再写入的只读索引执行
max_num_segments=1表示合并为最优单个Segment
六、总结与最佳实践
核心要点回顾
-
分片路由公式
shard = hash(_routing) % num_primary_shards决定了文档的存储位置,主分片数量因此在索引创建后不能修改。 -
文档写入流程严格遵循:协调节点路由→主分片写入Translog→内存Buffer构建索引→同步副本→返回成功。Translog的WAL机制是数据不丢失的保证。
-
wait_for_active_shards参数在数据安全性和写入延迟之间提供了灵活的平衡点,根据业务场景合理配置。 -
refresh控制搜索可见性,flush控制数据持久性,两者服务于不同的目标,要区分使用场景。
最佳实践清单
| 实践建议 | 详细说明 |
|---|---|
| 合理规划分片数 | 单分片建议10-50GB,总文档数不超过2^31,创建时预留增长空间 |
| 使用routing优化查询 | 将强相关的文档路由到同一分片,减少搜索分片数 |
| Translog配置评估 | 金融场景使用request模式,日志场景可用async模式 |
| 控制refresh频率 | 大量写入时调大refresh_interval(如30s),减少Segment创建压力 |
| 避免频繁ForceMerge | 仅在只读索引上执行,且选择业务低峰期 |
| 副本数合理配置 | 副本数≥1保证可用性,读多场景可增加副本分担查询 |
| 监控Segment数量 | 单分片Segment保持50以内,超出时考虑合并 |
上一篇【第43篇】Elasticsearch搜索过程原理——分词、查询树与BM25评分
下一篇【第45篇】Elasticsearch分布式检索原理——Query Then Fetch两阶段搜索
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)