Skip to content

Elasticsearch 核心机制:倒排索引、分片路由与近实时搜索 ​

刚写进去的文档搜不到,按 ID 却能查到;同样的关键词,用 term 查不到、用 match 能查到;三个分片的文档数差得很远。这些现象都能从倒排索引、分片路由和刷新机制这三件事解释。

本文的实验都在 Elasticsearch 9.5.3(单节点 Docker 容器)上完成,见文末配套实验。文中不涉及 _type,它在 7.x 中被弃用、8.0 起已经完全移除;「Client Node」这个叫法也已经换成了协调节点(coordinating node),任何节点都可以承担这个角色。

一、倒排索引:为什么不用逐行扫描 ​

写入的文档1: 无线降噪耳机2: 无线鼠标3: 降噪耳机 Pro分析器切词 · 归一化倒排表(词项 → 文档)无线 → [1, 2]降噪 → [1, 3]耳机 → [1, 3]鼠标 → [2]查询「降噪 耳机」时直接取两个词项的文档列表求交集,得到文档 1 与 3
图 1 · 写入时把文本切成词项并记录每个词项出现在哪些文档;查询时只需按词项查表,不必逐个文档扫描

关系型数据库的索引回答的是「这个值在哪一行」,而全文检索要回答的是「这个词出现在哪些文档里,相关性如何」。倒排索引在写入时就把这张表建好了。

1.1 分词决定了能搜到什么 ​

分析器(analyzer)把文本切成词项。用 _analyze 可以直接看到结果:

json
POST /_analyze
{ "analyzer": "standard", "text": "Elasticsearch 9 支持向量检索 in production" }

实测输出的词项:

text
["elasticsearch", "9", "支", "持", "向", "量", "检", "索", "in", "production"]

两个要点:

  • 英文按空格和标点切分,并转成小写,所以搜索时大小写不敏感。
  • 默认的 standard 分析器把中文切成了单字。这意味着搜索「检索」会匹配到所有包含「检」或「索」的文档,相关性很差。中文场景需要安装 IK、smartcn 这类中文分词插件,或者使用 ES 自带的 icu_analyzer 插件。

不同分析器的差别也很大,english 分析器会做词干还原:

text
"Running searches quickly" → ["run", "search", "quickli"]

这样搜索 run 也能匹配到 running。代价是词干还原可能过度(quickly 变成了 quickli),并且只适用于英文。

1.2 text 与 keyword ​

写入一个未定义映射的文档,动态映射会这样处理字符串:

json
"title": {
  "type": "text",
  "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
}
textkeyword
是否分词会被分析器切成词项原样存储
适合全文检索(match)精确匹配、排序、聚合(term、terms 聚合)
典型字段标题、描述、正文状态、品牌、标签、ID

常见的两个坑:

  • 用 term 查 text 字段查不到。 term 不分析查询词,而 text 字段里存的是切分后的词项,term: "无线降噪耳机" 与任何一个词项都不相等。实测写入一条「无线降噪耳机」:term 查 title 得 0 条,查 title.keyword 得 1 条。
  • 对 text 字段做聚合会报错或需要开启 fielddata,实测返回 400:Fielddata is disabled on [title]。正确做法是聚合 title.keyword。

ignore_above: 256 表示超过 256 个字符的值不会被索引进 keyword 子字段,避免超长字符串占用空间。真正需要精确匹配的字段,应该在映射里显式声明类型,而不是依赖动态映射。

二、分片、路由与搜索的两个阶段 ​

2.1 文档去哪个分片 ​

索引创建时确定主分片数,默认公式是:

text
shard = hash(_routing) % number_of_primary_shards

_routing 默认是文档 ID。这就是为什么主分片数确定后不能随意修改:改了分片数,同一个 ID 会算到另一个分片上,已有数据就找不到了。需要改变分片数时,要用 _split、_shrink 或 _reindex。

实测在一个 3 分片的索引里写入 40 个文档,分布并不均匀:

text
分片 0:18 个文档
分片 1:14 个文档
分片 2:8 个文档

文档数量少时哈希分布的偏差很明显,数据量大了才会趋于均匀。

自定义路由可以把相关文档放进同一个分片:

text
PUT /route_demo/_doc/r-1?routing=user-42
GET /route_demo/_search?routing=user-42

实测这次查询的 _shards.total 是 1,只查了一个分片,而不是全部 3 个。但要注意:路由只是缩小了查询范围,不是过滤条件。这个分片上还有其他文档,实测返回了 28 条,其中只有 10 条是 user-42 的,查询里仍然需要写明过滤条件。

自定义路由适合「查询总是带着某个维度」的场景,比如多租户系统按租户 ID 路由。代价是路由键分布不均时会产生数据倾斜。

2.2 一次搜索发生了什么 ​

协调节点接收请求数据节点上的分片分片 0(主)分片 1(主)分片 2(主)① query 阶段每个分片返回 from + size个文档 ID 与排序值② fetch 阶段协调节点排序后,只向相关分片取回文档内容深分页时每个分片都要返回 from + size 条,from 越大代价越高
图 2 · 协调节点把请求分发到每个分片,第一阶段只收集文档 ID 与排序值,第二阶段才按需取回文档内容

接收请求的节点成为这次请求的协调节点,它把请求分发到每个分片(主分片或其副本):

  1. query 阶段:每个分片在本地执行查询,返回前 from + size 条文档的 ID 和排序值,不返回文档内容。
  2. 协调节点合并:把各分片的结果合并排序,得到全局的前 size 条。
  3. fetch 阶段:按需向相关分片取回这些文档的完整内容,返回给客户端。

由此可以理解两件事:

  • 深分页很贵。 from=10000&size=10 时,每个分片都要返回 10010 条记录的 ID 和排序值,协调节点要合并 10010 × 分片数 条再丢掉绝大部分。ES 默认限制 index.max_result_window 为 10000。需要深翻或导出全部数据时,用 search_after(配合排序值游标)或 point in time + search_after。
  • 副本能提升查询吞吐。 同一个分片的查询可以落在主分片或任一副本上,增加副本数可以分摊查询压力,代价是写入要多写几份、占用更多磁盘。

2.3 分片数怎么定 ​

几条实践判断:

  • 单个分片的大小控制在几十 GB 量级,官方常用的经验值是 10—50GB,太大会让恢复和迁移变慢。
  • 分片不是越多越好:每个分片都是一套独立的 Lucene 索引,有固定的内存和文件句柄开销,查询时也要在每个分片上执行一次。
  • 按时间滚动的日志类数据用数据流(data stream)或按时间创建索引,配合索引生命周期管理(ILM)滚动和删除,比一个巨大的索引更好管理。

三、近实时:写入之后为什么搜不到 ​

index 请求索引缓冲新文档尚未对搜索可见translog按 durability 同步refresh默认 1s搜索可见实时 GET查版本映射:从 translog 读,或刷新内部视图后读实测(refresh_interval = 30s)写入后立即搜索:0 条按 ID 直接 GET:能查到手动 _refresh 后:1 条「能查到」只说明可见性,不能证明这次 GET 走了 translog
图 3 · 写入进入索引缓冲,同时记入 translog(默认 durability=request,确认前同步到磁盘)。普通搜索要等 refresh 更新搜索视图;实时 GET 不等周期刷新,9.5.5 中可以经版本映射从 translog 读取,或刷新内部视图后读取。flush 做 Lucene 提交并开启新的 translog 代,旧日志按保留策略回收

实测把索引的 refresh_interval 设为 30 秒:

text
写入文档            → result = created
立即搜索            → hits = 0
按 ID 直接 GET      → found = true
手动 POST /_refresh → hits = 1

这组结果说明的是可见性:搜索看不到,按 ID 的 GET 看得到,refresh 之后搜索也看到了。背后的机制分几件事:

  1. 写入进入索引缓冲,同时记入 translog。默认 index.translog.durability=request,每次写请求确认之前都会把 translog 同步到磁盘,所以已确认的写入在进程崩溃后可以从 translog 恢复;改成 async 时会有一个同步间隔的丢失窗口。数据最终是否安全,还取决于副本确认和存储本身。
  2. refresh 把缓冲中的文档变成一个可被搜索的新段,默认间隔 1 秒。在这之前,普通搜索看不到新文档。
  3. 按 ID 的 GET 默认是实时的,不必等 refresh。在 9.5.5 的实现中,它先查版本映射:如果能定位到这条记录在 translog 中的位置,就直接从 translog 读取;否则刷新内部使用的读取视图,再从索引中读。所以「GET 能查到」不能简单理解为「GET 总是读 translog」,上面的实验也只证明了可见性,不能说明这一次走了哪条路径。GET 传 realtime=false 时,它和搜索一样要等 refresh,刷新前返回 404。
  4. flush 执行一次 Lucene 提交,把段持久化到磁盘,并开启新的 translog 代;旧的 translog 按保留策略回收,并不是每次都清空。flush 由 ES 按策略触发,和「能否搜到」无关。

需要「写完立刻能搜到」时有两个选项:

写法行为代价
?refresh=true立即强制刷新每次都生成一个小段,段数量暴涨,后续合并压力大
?refresh=wait_for阻塞到下一次自然刷新请求延迟最多等于刷新间隔,但不会额外产生段

实测在刷新间隔为 30 秒的索引上,?refresh=wait_for 写入阻塞了约 30 秒(29,962ms),等到下一次自然刷新才返回,响应中没有 forced_refresh 标记,紧接着的搜索就能查到;?refresh=true 的响应里则带着 forced_refresh: true。批量导入时应该反过来:把 refresh_interval 调大甚至设为 -1,导入结束后再手动刷新。

近实时之外,还要记住 Elasticsearch 没有事务,更新是「标记删除旧文档 + 写入新文档」。它适合作为搜索和分析的副本,不适合作为唯一的数据源。

聚合结果为什么可能不准、搜索线程池打满怎么排查,见 ES 聚合分析与搜索线程池。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权