Skip to content

Elasticsearch 核心原理

前面我们知道 ES 是一个分布式搜索引擎。这篇再往里走一步,把 ES 的核心原理串起来:倒排索引怎么工作?写入为什么准实时?分片为什么能扩展?搜索结果为什么有相关性分数?

ES 的底层是谁?

ES 底层依赖 Lucene。

Lucene 是一个单机全文检索库,负责倒排索引、分词、打分、segment 管理等底层能力。ES 在 Lucene 之上做了分布式封装:

  • 用 index 管理一组业务文档。
  • 用 shard 把一个 index 拆到多台机器。
  • 用 replica 做高可用和读扩展。
  • 用 cluster state 管理节点、索引、分片元数据。
  • 用 HTTP API 和 Query DSL 对外提供易用的搜索能力。

所以可以简单理解:Lucene 负责“单分片怎么搜”,ES 负责“多个分片怎么组成集群并对外服务”。

正排索引和倒排索引

数据库里常见的是正排思路:通过文档 ID 找文档内容。

text
doc1 -> 我喜欢学习 Elasticsearch
doc2 -> Elasticsearch 适合全文搜索
doc3 -> MySQL 适合事务存储

全文检索更需要倒排思路:通过词找到包含它的文档。

text
Elasticsearch -> doc1, doc2
全文搜索        -> doc2
MySQL           -> doc3

搜索“Elasticsearch”时,只要查词典,就能快速拿到包含该词的文档列表,不需要逐篇扫描正文。

倒排索引通常包含两类信息:

  • term dictionary:词典,记录有哪些词。
  • posting list:倒排列表,记录某个词出现在哪些文档里,以及词频、位置等信息。

位置数据可以支持 phrase 查询,词频可以用于相关性打分。

分词是倒排索引的入口

写入 text 字段时,ES 会先经过 analyzer:

text
原始文本 -> character filter -> tokenizer -> token filter -> terms

例如“ES 写入流程”可能被切成:

text
es, 写入, 流程

这些 term 会进入倒排索引。查询 match 时,查询词也会走 analyzer。写入分词和查询分词是否匹配,直接决定能不能搜到。

keyword 字段则不走全文分词,整个值作为一个 term,更适合精确匹配、排序和聚合。

Segment 为什么不可变?

Lucene 把索引数据写成 segment。segment 一旦生成就不可变。

不可变带来的好处是:

  • 查询时不用担心文件被并发修改。
  • 文件系统缓存更稳定。
  • 新旧 segment 可以并存,refresh 后直接打开新 segment。
  • 后台 merge 可以慢慢整理旧 segment。

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

删除会写删除标记,查询返回前过滤掉被删除文档。更新则是“标记旧文档删除 + 写入新文档”。后台 segment merge 时,才会真正清理这些被删除的文档。

refresh、flush、merge 的区别

这三个词很容易混:

  • refresh:让内存 buffer 里的数据生成新 segment,并打开给搜索使用。默认约 1 秒一次,所以 ES 是准实时。
  • flush:把内存和文件系统缓存里的 segment 更完整地提交到磁盘,并清理旧 translog。
  • merge:把多个小 segment 合并成大 segment,同时清理删除标记。

可以这样记:

text
refresh 解决“能不能搜到”
flush   解决“故障恢复成本”
merge   解决“segment 太多和删除垃圾”

Translog 的作用

写入文档时,ES 不会每次都把 Lucene segment 完整提交到磁盘,因为那样太慢。

它会同时写 translog:

text
写入请求 -> memory buffer + translog

如果节点异常重启,ES 可以用已经提交的 segment 加上 translog 重放,恢复最近的写入。

所以 translog 可以理解为 ES 写入链路里的 WAL。它不是用来搜索的,而是用来恢复的。

分片原理

一个 ES index 可以有多个 primary shard。每个 primary shard 本质上都是一个 Lucene 索引。

写入文档时,ES 根据 _routing 决定文档落在哪个主分片:

text
shard = hash(_routing) % primary_shard_count

默认 _routing_id

这也是为什么主分片数创建后不能随便改:如果主分片数变了,路由公式结果就变了,文档应该落到哪个分片也会变。

如果一开始分片数设计不合理,常见做法是新建索引并 reindex。

副本原理

每个主分片可以有多个 replica shard。

副本有两个作用:

  • 高可用:主分片所在节点挂了,副本可以提升为新的主分片。
  • 读扩展:搜索和按 ID 读取可以落到主分片或副本分片。

副本不是越多越好。副本越多,写入时需要复制的目标越多,磁盘占用也越高。常见配置是 1 个副本,然后根据读流量和可用性要求调整。

协调节点做什么?

请求打到任意节点,这个节点就会作为 coordinating node。

按 ID 读取时,它负责路由到目标分片。

搜索时,它负责:

  1. 把 query 发到相关分片。
  2. 收集每个分片的 top results。
  3. 做全局排序和分页。
  4. 再到目标分片 fetch 文档内容。
  5. 组装结果返回客户端。

协调节点压力很容易被低估。深分页、大 size、大聚合都会让协调节点消耗大量内存和 CPU。

打分原理:从 TF-IDF 到 BM25

ES 5 之前默认相似度算法是 TF-IDF,后来默认使用 BM25。

TF-IDF 的核心思想是:

  • TF:词在当前文档出现越多,文档越相关。
  • IDF:词在全局越稀有,区分度越高。

但 TF-IDF 有个问题:词频特别高时,分数可能被拉得过高。BM25 对词频做了饱和处理,词频增加到一定程度后,分数增长会变慢。

可以直观理解为:

text
某个词出现 1 次到 3 次,相关性明显提升;
出现 30 次到 40 次,相关性不应该继续线性暴涨。

BM25 还会考虑文档长度。相同词频下,短文档里的词通常更重要;长文档因为词多,命中某个词不一定代表特别相关。

为什么 filter 更快?

filter context 不计算 _score,只判断是否匹配。

比如状态、租户、时间范围这些条件,本质上是硬过滤,不需要相关性分数,放 filter 更合适:

json
{
  "bool": {
    "must": [
      { "match": { "title": "Elasticsearch" } }
    ],
    "filter": [
      { "term": { "status": "published" } }
    ]
  }
}

filter 更容易被缓存,也不会干扰全文检索的 _score

集群状态和 Master 节点

ES 集群会选出 master-eligible 节点中的一个作为 master。它负责管理集群元数据,例如:

  • 节点加入和离开。
  • 索引创建和删除。
  • Mapping 更新。
  • 分片分配。
  • 集群状态发布。

Master 不负责承载所有查询和写入。生产集群里通常会设置专用 master 节点,避免数据节点高负载影响集群管理。

小结

ES 的核心原理可以串成一条线:

  1. 文档写入时,根据 Mapping 和 Analyzer 生成倒排索引。
  2. Lucene 用不可变 segment 存索引,refresh 后文档可搜索。
  3. translog 负责故障恢复,flush 负责提交,merge 负责整理 segment。
  4. ES 用 shard 把 Lucene 索引分布到多台机器,用 replica 做高可用。
  5. 搜索请求由协调节点分发到多个分片,再合并排序取回文档。
  6. 相关性默认基于 BM25,业务可以用 filter、boost、function_score 调整结果。

把这些原理想通,ES 的很多现象就有了解释:为什么刚写入搜不到、为什么更新成本高、为什么深分页慢、为什么 Mapping 和分词器这么重要。