分布式读取写入过程 #
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)
这就解释了为什么主分片数量在索引创建后不能改变:分片数变了,取模结果就变了,所有之前路由的值都会无效,老文档的"地址"全得重算。工程上一般通过新索引 + 重建索引 + 别名切换来实现扩容(见 别名)。
所有的文档 API(
get、index、delete、bulk、update、mget)都接受一个routing参数。自定义路由常用于"把相关数据放在一起"——例如把同一租户/同一用户的数据路由到同一分片,减少查询时的 fan-out(参见 多租户建模)。
写入流程:新建、索引和删除 #
新建、索引和删除请求都是写操作,必须在主分片上完成之后才能被复制到副本分片。
执行步骤:
- 客户端向
Node 1(协调节点)发送写入请求 Node 1使用文档的_id(或自定义 routing)计算出文档属于分片 0,请求被转发到分片 0 的主分片所在节点(例如Node 3)Node 3在主分片上执行请求。成功后,将新版本的完整文档(或删除标记)并行转发到所有副本分片节点- 一旦所有副本分片都报告成功,
Node 3向协调节点报告成功,协调节点再向客户端返回成功
在客户端收到成功响应时,文档变更已经在主分片和所有副本分片执行完成。
关键细节:复制的是"新文档版本",不是"更新指令" #
主分片向副本转发的是文档的新版本,而不是"请把字段 A +1"这样的增量变更。
原因很直白:复制是并行的、网络到达顺序不保证一致。如果副本只收到"补丁指令",指令乱序就可能把文档"补坏"。转发整份新版本可以避免这个问题。
一致性保证 #
wait_for_active_shards 参数控制写入前至少需要多少个活跃分片副本(包括主分片):
| 值 | 行为 |
|---|---|
1(默认) | 只要主分片可用就执行 |
2 | 主分片 + 至少 1 个副本可用 |
all | 必须主分片和所有副本都可用 |
| 自定义数值 | 指定需要多少个活跃分片副本 |
早期版本曾使用 quorum(多数)确认作为默认值,但在当前版本中,默认值为
1(仅需主分片可用)。如需更强的数据安全保证,可以显式设置为更大的值(如2或all)。
超时(timeout):如果没有足够的副本分片可用,Easysearch 会等待(默认 1 分钟)。可通过 timeout 参数调整。
并发冲突与重试语义见 并发控制与版本。
读取流程:取回一个文档 #
文档可以从主分片或任意副本分片检索,通过轮询实现负载均衡:
- 客户端向协调节点发送
GET /{index}/_doc/{id}请求 - 协调节点使用
_id(或指定的routing)算出目标主分片编号 - 它在该分片的主/副本里挑一个可用的来读(通常会做轮询)
- 持有文档的节点将文档返回给协调节点,再返回给客户端
“刚写入就读不到"怎么办? #
你可能遇到两类"刚写入就读不到"的错觉:
- 近实时刷新(NRT):影响的是
_search,不是get。get直接查事务日志 / 存储层路径,通常更"实时”。NRT 细节见 写入与存储机制 - 路由不一致:如果写入用了自定义
routing,读取也必须带同样的routing,否则会去错分片,自然找不到
在文档被索引但尚未复制到副本分片时,副本可能报告文档不存在,但主分片可以成功返回。一旦索引请求成功返回给用户,文档在主分片和副本分片都是可用的。
更新流程:局部更新 #
update API 结合了读取和写入模式。本质上仍然是"读取旧 _source → 合并修改 → 重新索引整份文档":
- 客户端向协调节点发送更新请求
- 协调节点将请求转发到主分片所在节点
- 主分片节点从本地检索文档,修改
_source字段中的 JSON,然后重新索引整个文档。如果文档已被其他进程修改(版本冲突),会重试,超过retry_on_conflict次后放弃 - 更新成功后,将新版本的完整文档并行转发到所有副本分片重新索引。所有副本完成后向客户端返回成功
update 的更多细节(脚本更新、upsert、冲突处理)见
Update API。
批量操作:mget 和 bulk #
mget:批量读取 #
mget 和单条 get 的模式类似,区别是协调节点会按分片把请求拆成多份并行转发:
- 客户端向协调节点发送
mget请求 - 协调节点将请求按分片拆分,并行转发到各目标节点
- 收到所有响应后,汇总为单个响应返回给客户端
这也是 mget 比在客户端循环 get 更"省网络、更省延迟"的原因——docs 数组里还可以为每条文档指定不同的 routing。
bulk:批量写入 #
- 客户端向协调节点发送
bulk请求 - 协调节点为每个目标节点构建批量请求,并行转发到各主分片所在节点
- 各主分片按顺序执行每个操作,每个操作完成后并行转发到副本分片
- 所有节点完成后,协调节点汇总响应返回给客户端
为什么 bulk 用 NDJSON 而不是 JSON 数组? #
bulk 请求体是"一行 action/metadata,一行可选 body"的 NDJSON 格式。这不是为了折磨人,而是一个有意为之的性能优化。
如果用 JSON 数组:
- 需要先将整个请求完整解析到内存中(包括很大的文档内容)
- 再遍历每个元素计算路由、按分片分组
- 为每个分片构建新的数组/结构,再序列化转发
NDJSON 的做法更"流式":
- 先只解析很小的 action/metadata 行,立刻知道该把后续 body 转发去哪
- 原始数据可以更直接地从网络缓冲区被切分、转发
- 避免在 JVM 里制造大量短命对象,减轻 GC 压力
- 没有冗余的数据复制,整个请求在最小内存中完成处理
bulk 的格式细节与错误处理见 Bulk API。
小结 #
| 操作 | 执行节点 | 关键特点 |
|---|---|---|
| 写入(index/create/delete) | 主分片 → 副本分片 | 先主后副,需 quorum 确认 |
| 读取(get) | 任意分片(轮询) | 主副均可,负载均衡 |
| 更新(update) | 主分片读+写 → 副本 | 转发完整文档,非增量 |
| 批量读(mget) | 按分片拆分并行 | 协调节点汇总 |
| 批量写(bulk) | 按分片拆分并行 | NDJSON 格式,流式拆分 |
核心规则:
- routing 决定分片:自定义 routing 用于"相关数据放一起",但读写必须一致
- 写入先主后副本:主分片裁决,副本复制结果
- 副本复制整份新版本:避免乱序补丁把文档搞坏
- 读取可走副本:get 会在主/副本间做负载均衡
下一步 #
- 分布式查询过程:搜索请求如何在分片间执行
- 写入与存储机制:近实时搜索、refresh、flush、merge
- 分布式基础:集群、节点和分片的整体架构
- 文档操作:文档写入 API、批量操作的格式与错误处理
- 并发控制与版本:乐观锁与安全更新模式