Skip to content

Elasticsearch 写入和读取流程

理解 ES 的写入和读取流程,能帮我们解释很多现象:为什么刚写入的数据有时搜不到?为什么更新不是原地修改?为什么 segment 多了搜索会变慢?这篇就把这些问题串起来。

一条数据写入 ES 发生了什么?

客户端写入一条文档时,可以把流程理解成这样:

  1. 客户端把请求发到任意一个节点,这个节点成为协调节点。
  2. 协调节点根据 _id_routing 计算目标主分片。
  3. 请求被转发到目标 primary shard。
  4. primary shard 写入成功后,把操作复制到 replica shard。
  5. 满足写入确认条件后,协调节点返回结果给客户端。

路由公式可以简化理解为:

text
shard = hash(_routing) % number_of_primary_shards

默认 _routing 使用文档 _id。如果业务显式指定 routing,相同 routing 的数据会落到同一个分片,这对某些按租户、按用户聚合的场景有帮助,但也可能导致热点分片。

refresh:为什么 ES 是准实时?

写入主分片后,文档并不是立刻变成可搜索状态。一个简化的写入链路是:

  1. 文档先写入内存 buffer。
  2. 同时追加 translog。
  3. refresh 时,buffer 里的数据生成新的 segment,并进入文件系统缓存。
  4. 新 segment 被打开,开始可以被搜索。

默认情况下,ES 大约每 1 秒自动 refresh 一次。因此 ES 常说自己是 near real-time,也就是准实时搜索。

如果你刚写完数据立刻查询,可能出现:

  • _doc/{id} 读取能读到。
  • _search 搜索暂时搜不到。

原因是 get 请求可以走实时读取,而 search 依赖 refresh 后打开的 segment。

如果业务需要写入后马上可搜索,可以在写入时使用:

http
POST /articles/_doc/1?refresh=wait_for
{
  "title": "Elasticsearch 写入流程"
}

refresh=wait_for 会等待下一次 refresh 完成后再返回。它比每次强制 refresh=true 更温和,但仍然会增加写入延迟,不建议所有请求都这么做。

translog:为什么写入不马上 fsync segment?

Lucene segment 一旦生成就不可变,频繁把小 segment 刷到磁盘成本很高。所以 ES 用 translog 记录最近的写操作。

可以这样理解:

  • segment 是搜索使用的索引文件。
  • translog 是故障恢复使用的操作日志。

写入时先追加 translog,即使 segment 还没有真正落盘,节点异常重启后也可以通过 translog 恢复最近写入的数据。

一次 flush 通常会做这些事:

  1. 将内存中的数据生成并持久化 segment。
  2. 写入 commit point。
  3. 确认旧 translog 已经不再需要。
  4. 删除旧 translog,开启新的 translog。

refresh 让数据可搜索,flush 让数据更完整地持久化。两者不是一回事。

segment 为什么不可变?

Lucene 的 segment 是不可变文件,这样做有几个好处:

  • 不需要对已经写好的 segment 做复杂并发修改。
  • 文件系统缓存更友好。
  • 旧 segment 可以继续服务查询,新 segment 可以并行生成。

代价是更新和删除不是原地修改。

删除和更新是怎么做的?

删除文档时,ES 不会马上从旧 segment 中物理删除文档,而是记录一份删除标记。查询命中文档后,会在返回前过滤掉已经删除的文档。

更新文档时,本质上是:

text
标记旧文档删除 + 写入新文档

所以频繁更新的索引会产生更多无效文档和 segment 合并压力。这也是为什么 ES 更适合“写入搜索视图”,不适合高频局部更新的强事务模型。

segment 合并解决什么问题?

每次 refresh 都可能生成新的 segment。如果 segment 越来越多,搜索时就要在更多 segment 里查找,文件句柄、内存、CPU 都会有额外消耗。

ES 会在后台自动做 merge:

  1. 选择一些小 segment。
  2. 合并成更大的 segment。
  3. 在合并过程中清理已经删除的文档。
  4. 新 segment 可用后,删除旧 segment。

merge 不会中断搜索和写入,但它会消耗磁盘 IO 和 CPU。写入量很大的集群,如果磁盘性能不足,经常会被 merge 拖慢。

wait_for_active_shards 控制什么?

写入请求可以带上 wait_for_active_shards

http
PUT /articles/_doc/1?wait_for_active_shards=all
{
  "title": "ES 写一致性"
}

它表示写入返回前,至少要有多少个分片副本处于 active 状态。默认值通常是 1,也就是主分片可用即可。

注意,它不是“所有副本都写成功才返回”的强一致事务开关。它更像是写入前检查可用分片数量,避免在副本不可用时继续接受写入。

按 ID 读取流程

如果通过 _id 读取一篇文档:

http
GET /articles/_doc/1

流程大致是:

  1. 请求到达协调节点。
  2. 协调节点根据 _id 计算目标分片。
  3. 在主分片或副本分片之间选择一个副本读取。
  4. 目标分片返回文档。
  5. 协调节点返回给客户端。

因为 _id 可以直接路由到一个分片,所以按 ID 读取通常很快。

搜索流程:query phase 和 fetch phase

搜索请求就不一样了。比如:

http
GET /articles/_search
{
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  }
}

协调节点通常不知道哪些分片有结果,所以要把请求发给相关分片。搜索可以分成两个阶段。

query phase:

  • 协调节点把查询发给多个分片。
  • 每个分片在本地执行查询,得到命中的 doc id 和分数。
  • 每个分片返回自己的 top N 结果给协调节点。
  • 协调节点做全局排序和分页。

fetch phase:

  • 协调节点根据最终需要返回的 doc id,去对应分片拉取 _source
  • 分片返回完整文档内容。
  • 协调节点组装最终结果。

这也解释了为什么深分页很贵。比如 from=10000&size=10,每个分片都可能要先取出大量候选结果,再由协调节点合并排序,内存和 CPU 成本都很高。

搜索为什么有时结果不稳定?

如果多个文档分数相同,且没有稳定的二级排序,不同副本、不同 refresh 时刻、不同 segment 状态,都可能让排序看起来有轻微变化。

生产上建议:

json
{
  "sort": [
    { "_score": "desc" },
    { "publishTime": "desc" },
    { "_id": "desc" }
  ]
}

有稳定排序字段,分页体验会更可控。

写入性能怎么优化?

批量导入或重建索引时,可以临时做这些优化:

  • 使用 bulk API,减少请求次数。
  • 临时把 number_of_replicas 设置为 0,导入后再恢复。
  • 临时把 refresh_interval 设置为 -1,导入后恢复并手动 refresh。
  • 控制 bulk 单批大小,不要让单次请求过大。
  • 避免写入热点 routing。
  • 磁盘优先使用 SSD。

示例:

http
PUT /new_articles/_settings
{
  "index": {
    "number_of_replicas": 0,
    "refresh_interval": -1
  }
}

导入完成后恢复:

http
PUT /new_articles/_settings
{
  "index": {
    "number_of_replicas": 1,
    "refresh_interval": "1s"
  }
}

小结

ES 写入和读取可以用几句话总结:

  • 写入先到协调节点,再路由到主分片,之后复制到副本分片。
  • refresh 让新写入数据可搜索,所以 ES 是准实时系统。
  • translog 用于故障恢复,flush 才会产生新的提交点。
  • segment 不可变,所以删除是标记删除,更新是删旧写新。
  • 搜索分 query phase 和 fetch phase,深分页会放大协调节点和分片压力。

理解这条链路后,再看 Mapping、查询 DSL、重建索引,就会清楚很多。