上一篇【第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的过程。触发条件包括:

  1. Translog大小达到阈值index.translog.flush_threshold_size,默认512MB)
  2. 定时触发(默认每30分钟自动flush一次)
  3. 手动触发POST /index/_flush

Flush执行时:

  1. 内存Buffer中的文档被写入新的Segment文件到磁盘
  2. 文件系统缓存被同步(fsync)到物理磁盘
  3. 旧的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

六、总结与最佳实践

核心要点回顾

  1. 分片路由公式 shard = hash(_routing) % num_primary_shards 决定了文档的存储位置,主分片数量因此在索引创建后不能修改。

  2. 文档写入流程严格遵循:协调节点路由→主分片写入Translog→内存Buffer构建索引→同步副本→返回成功。Translog的WAL机制是数据不丢失的保证。

  3. wait_for_active_shards 参数在数据安全性和写入延迟之间提供了灵活的平衡点,根据业务场景合理配置。

  4. 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两阶段搜索


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐