分片与副本:ES 的分布式基石

22 May 2026 – wusfe · 4 min read

分片与副本:ES 的分布式基石

1. 分片路由机制

ES 把每个索引拆成多个分片(Primary Shard),分布在集群不同节点上。写入文档时,通过路由公式决定落盘位置:

shard = hash(_routing) % number_of_primary_shards

默认 _routing 使用文档 _id。这就解释了为什么分片数一旦设定就不能改:改了取余分母,所有文档的哈希映射就全变了,相当于数据全乱。

验证路由结果

GET _cluster/search_shards
{
  "index": "orders",
  "routing": "user_123"
}

返回结果直接告诉你 user_123 的路由目标分片编号。

路由实战——将同一用户数据放在同一分片

POST orders/_doc/1?routing=user_123
{
  "user_id": "user_123",
  "amount": 99.9,
  "product": "iPhone"
}

POST orders/_doc/2?routing=user_123
{
  "user_id": "user_123",
  "amount": 199.9,
  "product": "MacBook"
}

这样 user_123 的所有订单都在同一分片,对该用户的聚合和搜索不需要跨分片广播:

GET orders/_search?routing=user_123
{
  "query": { "term": { "user_id": "user_123" } }
}

踩坑:routing 热点倾斜

某项目按 user_id 路由,结果一个企业用户有 200 万条数据,其他用户只有几百条。该分片成为热点,写入被拒绝(429 Too Many Requests)。解决方案:

  • 拆分超大用户的数据,带后缀路由(user_123_1, user_123_2
  • 或者在业务层将超大用户单独分配索引

2. Primary → Replica 写入流程

Client → Node1(Coordinator)
           │
           ├──→ Node2(Primary Shard P0)     ← 先写 Primary
           │         │
           │         ├──→ Node3(Replica R0)  ← 同步到副本
           │         └──→ Node4(Replica R0)
           │
           └── 等所有 In-Sync 副本确认后返回 200

每个步骤都可能引发故障:

场景 行为 影响
Primary 宕机 升级一个 Replica 为 Primary 短暂写入中断
Replica 宕机 剔除该副本,新副本重建 读能力下降
网络分区 少数派节点降级,防止脑裂 牺牲可用性保一致性
所有副本失联 Primary 仅自己写入 风险极高(单副本)

3. 写一致性 —— wait_for_active_shards

该参数控制写入时至少需要多少个分片副本处于活跃状态才返回成功:

quorum = int((number_of_primary_shards + number_of_replica_shards) / 2) + 1

举例:1 Primary + 1 Replica = 2 个副本,quorum = 2/2+1 = 2,即主副都必须存活才能写入。1 Primary + 2 Replica = 3 个副本,quorum = 3/2+1 = 2,即只需 1 主 + 1 副存活就允许写入。

PUT users/_settings
{
  "index": {
    "number_of_replicas": 2
  }
}

PUT users/_doc/1?wait_for_active_shards=2
{
  "name": "张三"
}

如果只有 1 个活跃分片(不满足 quorum=2),ES 会返回错误:

{
  "error": {
    "type": "unavailable_shards_exception",
    "reason": "...not enough active copies to meet shard count of [2]..."
  }
}

建议设置

  • 生产环境:wait_for_active_shards=all(最严格)或 wait_for_active_shards=2
  • 日志场景:wait_for_active_shards=1(快速写入,容忍短暂丢失)

4. 读分发与一致性陷阱

读请求发到任意分片(Primary 或 Replica)均可响应,这带来了读吞吐量的横向扩展,但不保证强一致性

t0: 写入 doc1 → Primary 执行完毕,等待副本确认
t1: 读请求来到 Replica(尚未同步 doc1)→ 查不到
t2: 副本确认完毕,返回 200 给客户端
t3: 再次查询 Replica → 查到了

这就是 NRT(近实时)+ 主从复制带来的读写延迟窗口。业务上需要避免"写入后立即读取"的模式,或者使用 ?preference=primary 强制读主分片:

GET orders/_search?preference=primary
{
  "query": { "term": { "status": "paid" } }
}

5. 分片数量陷阱

1000 个小分片 vs 10 个大分片对比

维度 1000 个 1GB 分片 10 个 100GB 分片
并行度 极高(1000 线程并发) 中等(10 线程)
内存开销 极恐怖(每分片有 Segment 元数据) 可控
GC 压力 严重(对象数爆炸) 正常
写入吞吐 低(大量小分片竞争 IO)
故障恢复 快(单分片小) 慢(复制 100GB 耗时)
评分准确性 差(IDF 只在分片内计算) 较好

踩坑:默认 5 分片 × 200 索引 = 1000+ 分片

某 SaaS 平台按租户每天创建索引(租户隔离),200 个租户 × 5 分片 = 1000 分片,集群状态更新从毫秒级退化到秒级。Master 节点 CPU 满载,节点加入/退出都要重新分配分片元数据。解决方法:

  • 合并小索引(同租户同月份公用一个索引,用路由字段区分)
  • 将分片数从 5 减为 1(数据量不大的索引)
  • 单节点分片数控制在 600 以内(ES 官方建议)

分片大小最佳实践

  • 搜索场景:单分片 10GB~30GB
  • 日志场景:单分片 30GB~50GB
  • 堆内存:每 GB 堆内存管理不超过 20 个分片
  • 单节点分片总数:不超过 1000(包含主 + 副)

优化写入的 shrink 操作

对于按天分割的日志索引,当天的索引热(承受写入),昨天的索引不再写入,可以 shrink 减少分片:

POST logs-2024-01-15/_shrink/logs-2024-01-15-optimized
{
  "settings": {
    "index.number_of_shards": 1
  }
}

6. 总结

分片是 ES 横向扩展的基础,路由公式决定了分片数"一次设定终身不改"的约束。副本提供了数据冗余和读扩展能力,但引入了一致性延迟。生产上需要平衡分片数量——不多不少,单分片 10~30GB,单节点总分片不超过 1000。