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 能查到。
二、倒排索引:为什么不用逐行扫描
关系型数据库的索引回答的是「这个值在哪一行」,而全文检索要回答的是「这个词出现在哪些文档里,相关性如何」。倒排索引在写入时就把这张表建好了。
2.1 分词决定了能搜到什么
分析器(analyzer)把文本切成词项。用 _analyze 可以直接看到结果:
POST /_analyze
{ "analyzer": "standard", "text": "Elasticsearch 9 支持向量检索 in production" }实测输出的词项:
["elasticsearch", "9", "支", "持", "向", "量", "检", "索", "in", "production"]两个要点:
- 英文按空格和标点切分,并转成小写,所以搜索时大小写不敏感。
- 默认的
standard分析器把中文切成了单字。这意味着搜索「检索」会匹配到所有包含「检」或「索」的文档,相关性很差。中文场景需要安装 IK、smartcn 这类中文分词插件,或者使用 ES 自带的icu_analyzer插件。
不同分析器的差别也很大,english 分析器会做词干还原:
"Running searches quickly" → ["run", "search", "quickli"]这样搜索 run 也能匹配到 running。代价是词干还原可能过度(quickly 变成了 quickli),并且只适用于英文。
2.2 text 与 keyword
写入一个未定义映射的文档,动态映射会这样处理字符串:
"title": {
"type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
}text | keyword | |
|---|---|---|
| 是否分词 | 会被分析器切成词项 | 原样存储 |
| 适合 | 全文检索(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 文档去哪个分片
索引创建时确定主分片数,默认公式是:
shard = hash(_routing) % number_of_primary_shards_routing 默认是文档 ID。这就是为什么主分片数确定后不能随意修改:改了分片数,同一个 ID 会算到另一个分片上,已有数据就找不到了。需要改变分片数时,要用 _split、_shrink 或 _reindex。
实测在一个 3 分片的索引里写入 40 个文档,分布并不均匀:
分片 0:18 个文档
分片 1:14 个文档
分片 2:8 个文档文档数量少时哈希分布的偏差很明显,数据量大了才会趋于均匀。
自定义路由可以把相关文档放进同一个分片:
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 一次搜索发生了什么
接收请求的节点成为这次请求的协调节点,它把请求分发到每个分片(主分片或其副本):
- query 阶段:每个分片在本地执行查询,返回前
from + size条文档的 ID 和排序值,不返回文档内容。 - 协调节点合并:把各分片的结果合并排序,得到全局的前
size条。 - 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)滚动和删除,比一个巨大的索引更好管理。
四、近实时:写入之后为什么搜不到
实测把索引的 refresh_interval 设为 30 秒:
写入文档 → result = created
立即搜索 → hits = 0
按 ID 直接 GET → found = true
手动 POST /_refresh → hits = 1这三行结果说明了 Elasticsearch 的写入路径:
- 文档写入内存缓冲,同时写入 translog(事务日志),保证进程崩溃后数据不丢;
- 此时文档还没有进入任何 Lucene 段,搜索不到;但按 ID 的 GET 是「实时」的,它会查 translog;
- refresh 把缓冲区的内容生成一个新的 Lucene 段,这个段可以被搜索,默认间隔 1 秒;
- 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 聚合分析与搜索线程池。
配套实验
- codesphere-labs/storage/elasticsearch-behaviors:分词、
text与keyword、3 分片的文档分布与自定义路由、刷新间隔 30 秒时的写入可见性(验证记录)
参考资料