Elasticsearch 核心机制:倒排索引、分片路由与近实时搜索
刚写进去的文档搜不到,按 ID 却能查到;同样的关键词,用
term查不到、用match能查到;三个分片的文档数差得很远。这些现象都能从倒排索引、分片路由和刷新机制这三件事解释。
本文的实验都在 Elasticsearch 9.5.3(单节点 Docker 容器)上完成,见文末配套实验。文中不涉及 _type,它在 7.x 中被弃用、8.0 起已经完全移除;「Client Node」这个叫法也已经换成了协调节点(coordinating node),任何节点都可以承担这个角色。
一、倒排索引:为什么不用逐行扫描
关系型数据库的索引回答的是「这个值在哪一行」,而全文检索要回答的是「这个词出现在哪些文档里,相关性如何」。倒排索引在写入时就把这张表建好了。
1.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),并且只适用于英文。
1.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 子字段,避免超长字符串占用空间。真正需要精确匹配的字段,应该在映射里显式声明类型,而不是依赖动态映射。
二、分片、路由与搜索的两个阶段
2.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 路由。代价是路由键分布不均时会产生数据倾斜。
2.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。 - 副本能提升查询吞吐。 同一个分片的查询可以落在主分片或任一副本上,增加副本数可以分摊查询压力,代价是写入要多写几份、占用更多磁盘。
2.3 分片数怎么定
几条实践判断:
- 单个分片的大小控制在几十 GB 量级,官方常用的经验值是 10—50GB,太大会让恢复和迁移变慢。
- 分片不是越多越好:每个分片都是一套独立的 Lucene 索引,有固定的内存和文件句柄开销,查询时也要在每个分片上执行一次。
- 按时间滚动的日志类数据用数据流(data stream)或按时间创建索引,配合索引生命周期管理(ILM)滚动和删除,比一个巨大的索引更好管理。
三、近实时:写入之后为什么搜不到
durability=request,确认前同步到磁盘)。普通搜索要等 refresh 更新搜索视图;实时 GET 不等周期刷新,9.5.5 中可以经版本映射从 translog 读取,或刷新内部视图后读取。flush 做 Lucene 提交并开启新的 translog 代,旧日志按保留策略回收实测把索引的 refresh_interval 设为 30 秒:
写入文档 → result = created
立即搜索 → hits = 0
按 ID 直接 GET → found = true
手动 POST /_refresh → hits = 1这组结果说明的是可见性:搜索看不到,按 ID 的 GET 看得到,refresh 之后搜索也看到了。背后的机制分几件事:
- 写入进入索引缓冲,同时记入 translog。默认
index.translog.durability=request,每次写请求确认之前都会把 translog 同步到磁盘,所以已确认的写入在进程崩溃后可以从 translog 恢复;改成async时会有一个同步间隔的丢失窗口。数据最终是否安全,还取决于副本确认和存储本身。 - refresh 把缓冲中的文档变成一个可被搜索的新段,默认间隔 1 秒。在这之前,普通搜索看不到新文档。
- 按 ID 的 GET 默认是实时的,不必等 refresh。在 9.5.5 的实现中,它先查版本映射:如果能定位到这条记录在 translog 中的位置,就直接从 translog 读取;否则刷新内部使用的读取视图,再从索引中读。所以「GET 能查到」不能简单理解为「GET 总是读 translog」,上面的实验也只证明了可见性,不能说明这一次走了哪条路径。GET 传
realtime=false时,它和搜索一样要等 refresh,刷新前返回 404。 - 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 聚合分析与搜索线程池。
配套实验
- codesphere-labs/storage/elasticsearch-behaviors:分词、
text与keyword、3 分片的文档分布与自定义路由、刷新间隔 30 秒时的写入可见性(验证记录) - codesphere-labs/storage/elasticsearch-bulk-failures:Elasticsearch 9.5.5 关闭周期刷新时,实时 GET、
realtime=false的 GET 与搜索的可见性对照(evidence/get-visibility.json,验证记录)
参考资料
- Elasticsearch:Text analysis
- Elasticsearch:Field data types
- Elasticsearch:Routing
- Elasticsearch:Near real-time search
- Elasticsearch:Translog 设置(durability、flush 与保留)
- Elasticsearch:Get API(realtime)
- Elasticsearch 9.5.5 源码:InternalEngine 的实时 GET
- Elasticsearch:Paginate search results
- Elasticsearch:Size your shards