---
title: "分布式读取写入过程"
date: 0001-01-01
description: "文档的路由、写入、读取和更新在分片间的执行流程。"
summary: "分布式读取写入过程 #  Easysearch 隐藏了分布式系统的大部分底层细节，让你可以专注在业务开发上。但线上出问题时你真正需要的是：这条请求在集群里到底走了哪几步。本页把分布式 CRUD 的关键流程串起来，读完你会更容易理解：
 为什么同一条数据总能&quot;找到回家的路&quot;（routing） 为什么写入一定要先到主分片（primary） 为什么副本不吃&quot;补丁&quot;，只吃&quot;整份文档&quot;（复制语义） 为什么 bulk 要用看起来奇怪的 NDJSON（性能与内存）   术语提示：你可以把请求发到集群中的任意节点。接收请求并负责&quot;拆分、转发、汇总&quot;的那个节点，称为协调节点（coordinating node）。为了均衡负载，更好的做法是轮询集群中所有节点发送请求。
 路由：文档如何找到分片 #  当索引一个文档时，Easysearch 需要确定它属于哪个主分片。这个过程是确定性的，基于以下公式：
shard = hash(routing) % number_of_primary_shards  routing 是一个可变值，默认是文档的 _id，也可以设置成一个自定义的值 routing 通过 Murmur3 x86 32-bit 哈希算法（种子为 0）生成一个数字，然后除以主分片数量取余 余数就是文档所在分片的编号（范围 0 到 number_of_primary_shards - 1）  这就解释了为什么主分片数量在索引创建后不能改变：分片数变了，取模结果就变了，所有之前路由的值都会无效，老文档的&quot;地址&quot;全得重算。工程上一般通过新索引 + 重建索引 + 别名切换来实现扩容（见 别名）。
 所有的文档 API（get、index、delete、bulk、update、mget）都接受一个 routing 参数。自定义路由常用于&quot;把相关数据放在一起&quot;——例如把同一租户/同一用户的数据路由到同一分片，减少查询时的 fan-out（参见 多租户建模）。
 写入流程：新建、索引和删除 #  新建、索引和删除请求都是写操作，必须在主分片上完成之后才能被复制到副本分片。
执行步骤：
 客户端向 Node 1（协调节点）发送写入请求 Node 1 使用文档的 _id（或自定义 routing）计算出文档属于分片 0，请求被转发到分片 0 的主分片所在节点（例如 Node 3） Node 3 在主分片上执行请求。成功后，将新版本的完整文档（或删除标记）并行转发到所有副本分片节点 一旦所有副本分片都报告成功，Node 3 向协调节点报告成功，协调节点再向客户端返回成功  在客户端收到成功响应时，文档变更已经在主分片和所有副本分片执行完成。"
---


# 分布式读取写入过程

Easysearch 隐藏了分布式系统的大部分底层细节，让你可以专注在业务开发上。但线上出问题时你真正需要的是：**这条请求在集群里到底走了哪几步**。本页把分布式 CRUD 的关键流程串起来，读完你会更容易理解：

- 为什么同一条数据总能"找到回家的路"（routing）
- 为什么写入一定要先到主分片（primary）
- 为什么副本不吃"补丁"，只吃"整份文档"（复制语义）
- 为什么 bulk 要用看起来奇怪的 NDJSON（性能与内存）

> **术语提示**：你可以把请求发到集群中的任意节点。接收请求并负责"拆分、转发、汇总"的那个节点，称为**协调节点（coordinating node）**。为了均衡负载，更好的做法是轮询集群中所有节点发送请求。

## 路由：文档如何找到分片

当索引一个文档时，Easysearch 需要确定它属于哪个**主分片**。这个过程是确定性的，基于以下公式：

```
shard = hash(routing) % number_of_primary_shards
```

- `routing` 是一个可变值，默认是文档的 `_id`，也可以设置成一个自定义的值
- `routing` 通过 **Murmur3 x86 32-bit** 哈希算法（种子为 0）生成一个数字，然后除以主分片数量取余
- 余数就是文档所在分片的编号（范围 `0` 到 `number_of_primary_shards - 1`）

这就解释了**为什么主分片数量在索引创建后不能改变**：分片数变了，取模结果就变了，所有之前路由的值都会无效，老文档的"地址"全得重算。工程上一般通过**新索引 + 重建索引 + 别名切换**来实现扩容（见 [别名]({{< relref "/docs/operations/data-management/aliases.md" >}})）。

> 所有的文档 API（`get`、`index`、`delete`、`bulk`、`update`、`mget`）都接受一个 `routing` 参数。自定义路由常用于"把相关数据放在一起"——例如把同一租户/同一用户的数据路由到同一分片，减少查询时的 fan-out（参见 [多租户建模]({{< relref "/docs/best-practices/data-modeling/multi-tenancy.md" >}})）。

## 写入流程：新建、索引和删除

新建、索引和删除请求都是**写操作**，必须在主分片上完成之后才能被复制到副本分片。

执行步骤：

1. 客户端向 `Node 1`（协调节点）发送写入请求
2. `Node 1` 使用文档的 `_id`（或自定义 routing）计算出文档属于分片 0，请求被转发到分片 0 的主分片所在节点（例如 `Node 3`）
3. `Node 3` 在主分片上执行请求。成功后，将新版本的**完整文档**（或删除标记）**并行**转发到所有副本分片节点
4. 一旦所有副本分片都报告成功，`Node 3` 向协调节点报告成功，协调节点再向客户端返回成功

在客户端收到成功响应时，文档变更已经在主分片和所有副本分片执行完成。

### 关键细节：复制的是"新文档版本"，不是"更新指令"

主分片向副本转发的是**文档的新版本**，而不是"请把字段 A +1"这样的增量变更。

原因很直白：复制是并行的、网络到达顺序不保证一致。如果副本只收到"补丁指令"，指令乱序就可能把文档"补坏"。转发整份新版本可以避免这个问题。

### 一致性保证

`wait_for_active_shards` 参数控制写入前至少需要多少个活跃分片副本（包括主分片）：

| 值 | 行为 |
|------|------|
| `1`（默认） | 只要主分片可用就执行 |
| `2` | 主分片 + 至少 1 个副本可用 |
| `all` | 必须主分片和所有副本都可用 |
| 自定义数值 | 指定需要多少个活跃分片副本 |

> 早期版本曾使用 quorum（多数）确认作为默认值，但在当前版本中，默认值为 `1`（仅需主分片可用）。如需更强的数据安全保证，可以显式设置为更大的值（如 `2` 或 `all`）。

**超时（timeout）**：如果没有足够的副本分片可用，Easysearch 会等待（默认 1 分钟）。可通过 `timeout` 参数调整。

并发冲突与重试语义见 [并发控制与版本](./concurrency-and-versioning.md)。

## 读取流程：取回一个文档

文档可以从**主分片或任意副本分片**检索，通过轮询实现负载均衡：

1. 客户端向协调节点发送 `GET /{index}/_doc/{id}` 请求
2. 协调节点使用 `_id`（或指定的 `routing`）算出目标主分片编号
3. 它在该分片的主/副本里挑一个可用的来读（通常会做轮询）
4. 持有文档的节点将文档返回给协调节点，再返回给客户端

### "刚写入就读不到"怎么办？

你可能遇到两类"刚写入就读不到"的错觉：

- **近实时刷新（NRT）**：影响的是 `_search`，不是 `get`。`get` 直接查事务日志 / 存储层路径，通常更"实时"。NRT 细节见 [写入与存储机制](./write-and-storage.md)
- **路由不一致**：如果写入用了自定义 `routing`，读取也必须带同样的 `routing`，否则会去错分片，自然找不到

> 在文档被索引但尚未复制到副本分片时，副本可能报告文档不存在，但主分片可以成功返回。一旦索引请求成功返回给用户，文档在主分片和副本分片都是可用的。

## 更新流程：局部更新

`update` API 结合了读取和写入模式。本质上仍然是"读取旧 `_source` → 合并修改 → 重新索引整份文档"：

1. 客户端向协调节点发送更新请求
2. 协调节点将请求转发到主分片所在节点
3. 主分片节点从本地检索文档，修改 `_source` 字段中的 JSON，然后**重新索引**整个文档。如果文档已被其他进程修改（版本冲突），会重试，超过 `retry_on_conflict` 次后放弃
4. 更新成功后，将新版本的**完整文档**并行转发到所有副本分片重新索引。所有副本完成后向客户端返回成功

`update` 的更多细节（脚本更新、upsert、冲突处理）见 [Update API]({{< relref "/docs/features/document-operations/update-api.md" >}})。

## 批量操作：mget 和 bulk

### mget：批量读取

`mget` 和单条 `get` 的模式类似，区别是协调节点会按分片把请求拆成多份并行转发：

1. 客户端向协调节点发送 `mget` 请求
2. 协调节点将请求按分片拆分，并行转发到各目标节点
3. 收到所有响应后，汇总为单个响应返回给客户端

这也是 `mget` 比在客户端循环 `get` 更"省网络、更省延迟"的原因——`docs` 数组里还可以为每条文档指定不同的 `routing`。

### bulk：批量写入

1. 客户端向协调节点发送 `bulk` 请求
2. 协调节点为每个目标节点构建批量请求，并行转发到各主分片所在节点
3. 各主分片按顺序执行每个操作，每个操作完成后并行转发到副本分片
4. 所有节点完成后，协调节点汇总响应返回给客户端

### 为什么 bulk 用 NDJSON 而不是 JSON 数组？

`bulk` 请求体是"一行 action/metadata，一行可选 body"的 NDJSON 格式。这不是为了折磨人，而是一个有意为之的性能优化。

如果用 JSON 数组：

- 需要先将整个请求完整解析到内存中（包括很大的文档内容）
- 再遍历每个元素计算路由、按分片分组
- 为每个分片构建新的数组/结构，再序列化转发

NDJSON 的做法更"流式"：

- 先只解析很小的 action/metadata 行，立刻知道该把后续 body 转发去哪
- 原始数据可以更直接地从网络缓冲区被切分、转发
- 避免在 JVM 里制造大量短命对象，减轻 GC 压力
- **没有冗余的数据复制，整个请求在最小内存中完成处理**

bulk 的格式细节与错误处理见 [Bulk API]({{< relref "/docs/features/document-operations/bulk-api.md" >}})。

## 小结

| 操作 | 执行节点 | 关键特点 |
|------|----------|----------|
| 写入（index/create/delete） | 主分片 → 副本分片 | 先主后副，需 quorum 确认 |
| 读取（get） | 任意分片（轮询） | 主副均可，负载均衡 |
| 更新（update） | 主分片读+写 → 副本 | 转发完整文档，非增量 |
| 批量读（mget） | 按分片拆分并行 | 协调节点汇总 |
| 批量写（bulk） | 按分片拆分并行 | NDJSON 格式，流式拆分 |

核心规则：

- **routing 决定分片**：自定义 routing 用于"相关数据放一起"，但读写必须一致
- **写入先主后副本**：主分片裁决，副本复制结果
- **副本复制整份新版本**：避免乱序补丁把文档搞坏
- **读取可走副本**：get 会在主/副本间做负载均衡

## 下一步

- [分布式查询过程](./distributed-search.md)：搜索请求如何在分片间执行
- [写入与存储机制](./write-and-storage.md)：近实时搜索、refresh、flush、merge
- [分布式基础](./distributed.md)：集群、节点和分片的整体架构
- [文档操作]({{< relref "/docs/features/document-operations/_index.md" >}})：文档写入 API、批量操作的格式与错误处理
- [并发控制与版本](./concurrency-and-versioning.md)：乐观锁与安全更新模式

### 最佳实践

- [索引与分片设计]({{< relref "/docs/best-practices/index-design.md" >}})：分片数量规划、路由策略
- [数据建模]({{< relref "/docs/best-practices/data-modeling/_index.md" >}})：文档设计与更新模式

