分布式读取写入过程

分布式读取写入过程 #

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

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

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

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

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

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

这就解释了为什么主分片数量在索引创建后不能改变:分片数变了,取模结果就变了,所有之前路由的值都会无效,老文档的"地址"全得重算。工程上一般通过新索引 + 重建索引 + 别名切换来实现扩容(见 别名)。

所有的文档 API(getindexdeletebulkupdatemget)都接受一个 routing 参数。自定义路由常用于"把相关数据放在一起"——例如把同一租户/同一用户的数据路由到同一分片,减少查询时的 fan-out(参见 多租户建模)。

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

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

执行步骤:

  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(仅需主分片可用)。如需更强的数据安全保证,可以显式设置为更大的值(如 2all)。

超时(timeout):如果没有足够的副本分片可用,Easysearch 会等待(默认 1 分钟)。可通过 timeout 参数调整。

并发冲突与重试语义见 并发控制与版本

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

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

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

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

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

  • 近实时刷新(NRT):影响的是 _search,不是 getget 直接查事务日志 / 存储层路径,通常更"实时”。NRT 细节见 写入与存储机制
  • 路由不一致:如果写入用了自定义 routing,读取也必须带同样的 routing,否则会去错分片,自然找不到

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

更新流程:局部更新 #

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

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

update 的更多细节(脚本更新、upsert、冲突处理)见 Update API

批量操作: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

小结 #

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

核心规则:

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

下一步 #

最佳实践 #