Skip to content

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

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

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

一、先说结论 ​

  • Elasticsearch 快在倒排索引:写入时把文本切成词项,记录每个词项出现在哪些文档里;查询时直接按词项查表,不必逐个文档扫描。
  • 字段类型决定了能否匹配:text 会被分析器切词,keyword 原样存储。动态映射给字符串同时生成这两种,聚合和精确匹配要用 .keyword 子字段。
  • 分片是数据的物理切分单位,路由决定文档去哪个分片,默认按文档 ID 哈希。分片数在索引创建后不能随意更改。
  • 搜索是两阶段的:先在每个分片上查出文档 ID 和排序值,协调节点合并排序后,再到相关分片取回文档内容。深分页昂贵就是因为第一阶段每个分片都要返回 from + size 条。
  • 写入不是立刻可搜索的:文档先进内存缓冲,默认每秒刷新(refresh)一次生成新段之后才能被搜到。实测把刷新间隔改成 30 秒后,写入后立即搜索返回 0 条,但按 ID 直接 GET 能查到。

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

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

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

2.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),并且只适用于英文。

2.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 子字段,避免超长字符串占用空间。真正需要精确匹配的字段,应该在映射里显式声明类型,而不是依赖动态映射。

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

3.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 路由。代价是路由键分布不均时会产生数据倾斜。

3.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。
  • 副本能提升查询吞吐。 同一个分片的查询可以落在主分片或任一副本上,增加副本数可以分摊查询压力,代价是写入要多写几份、占用更多磁盘。

3.3 分片数怎么定 ​

几条实践判断:

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

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

index 请求写入内存缓冲translog保证不丢等待 refresh默认 1s新段生成可以被搜索到实测(refresh_interval = 30s)写入后立即搜索:0 条按 ID 直接 GET:能查到手动 _refresh 后:1 条需要「写完立刻搜到」时,用 ?refresh=wait_for 等待下一次刷新,而不是 ?refresh=true 强制刷新
图 3 · 文档先进内存缓冲并写 translog,refresh 生成新的段之后才能被搜索到;translog 保证未刷盘的数据不丢

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

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

这三行结果说明了 Elasticsearch 的写入路径:

  1. 文档写入内存缓冲,同时写入 translog(事务日志),保证进程崩溃后数据不丢;
  2. 此时文档还没有进入任何 Lucene 段,搜索不到;但按 ID 的 GET 是「实时」的,它会查 translog;
  3. refresh 把缓冲区的内容生成一个新的 Lucene 段,这个段可以被搜索,默认间隔 1 秒;
  4. flush 把段真正持久化到磁盘并清空 translog,由 ES 按策略触发,和「能否搜到」无关。

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

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

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

五、常见误区 ​

  • 「ES 是实时的」:默认是近实时,写入到可搜索之间有刷新间隔;只有按 ID 的 GET 是实时的。
  • 「用 term 查什么都行」:term 不分析查询词,查 text 字段通常查不到。
  • 「分片数以后可以随便改」:主分片数决定了路由结果,改变需要 _split、_shrink 或重建索引。
  • 「副本能提升写入性能」:副本要同步写入,提升的是查询吞吐和可用性。
  • 「自定义路由等于过滤」:路由只决定查哪个分片,查询条件还得自己写。
  • 「ES 可以当数据库用」:它没有事务,更新是「标记删除 + 新建文档」,不适合作为唯一的数据源。

小结 ​

理解 Elasticsearch,先抓住三件事:倒排索引让查询从「扫描文档」变成「查词项表」,所以分词和字段类型决定了能否搜到;分片和路由决定数据在哪、查询要发给谁,也解释了深分页为什么贵;刷新机制解释了写入之后为什么搜不到。这三点搞清楚,大部分「查不到」「查得慢」的问题都能定位到具体环节。

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


配套实验

参考资料

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