Elasticsearch(简称 ES)是一个基于 Apache Lucene 构建的分布式、RESTful 风格的搜索和分析引擎。它支持近乎实时的全文检索、结构化查询、聚合分析和海量数据的水平扩展,是目前最流行的开源搜索引擎,广泛用于站内搜索、日志分析(ELK/EFK 栈)、监控告警与业务数据检索等场景。

本文从搜索引擎的基本问题出发,梳理 ES 的定位、核心概念与底层原理。


为什么需要搜索引擎

传统的关系型数据库(如 MySQL)用 B+ 树索引擅长等值查询与范围查询,但在处理模糊匹配、分词、相关性排序这类需求时力不从心:

搜索引擎的核心是倒排索引(Inverted Index),它彻底改变了检索方式:以"词"为索引单位,快速找到包含该词的所有文档。


核心概念与术语

ES 面向用户的抽象模型非常直观,与关系型数据库存在一一对应关系:

关系型数据库 (MySQL)Elasticsearch
Database(数据库)Index(索引)
Table(表)Type(类型,7.x 起废弃)
Row(行)Document(文档)
Column(列)Field(字段)
Schema(表结构)Mapping(映射)

倒排索引:搜索的核心原理

倒排索引是全文检索的基石。以一段简单的英文文档集合为例:

Doc ID内容
1Elasticsearch is a search engine
2search engine based on Lucene

构建倒排索引的过程:

  1. 分词(Analyze):将文档内容按规则切分成词项(Term),如 searchengine
  2. 归一化(Normalize):统一大小写、去除停用词、还原词干等,如 Searchsearch
  3. 建索引(Index):得到"词项 → 文档列表"的映射表:
TermPosting List(倒排列表)
search[1, 2]
engine[1, 2]
lucene[2]

查询 search 时,只需查找词项表定位到 Posting List,即可快速返回文档 1 和 2,时间复杂度接近 $O(1)$,无需扫描全部文档。

Posting List 的优化

正是这些压缩与加速技术,让"在大规模数据上做毫秒级检索"成为可能。

回答开头的三个问题

回到文章开头 MySQL 的三大痛点,倒排索引 + ES 的组合恰好逐一破解:

D2 Diagram
qtopie.github.io

LIKE '%keyword%' 全表扫描 → 倒排索引定位

MySQL 的 B+ 树只能按前缀匹配走索引,%keyword% 通配符出现在中间,索引直接失效,只能逐行扫描。倒排索引反其道而行:先按"词"组织,再反查文档。查询时在 Term 字典里定位 keyword(FST 查找近似 $O(\text{词长})$),直接拿到包含它的 Posting List——关键词在原文中的位置无关紧要,前面后面是任意内容都能命中。这就是"索引单位从’行’变成’词’“带来的质变。

为什么"前模糊"在倒排索引下也能快速命中?核心在于索引的 key 完全不同

D2 Diagram
qtopie.github.io

边界澄清:这里的"快"是相对 LIKE '%keyword%' 而言——匹配粒度是"整词"。如果需求是真正的任意中缀子串匹配(如要求命中 mykeywordabc 这种词中间的片段),分词后的倒排索引同样不直接支持,需要 ngram 分词器或 wildcard 查询。常规关键词搜索场景下,“整词"粒度已经足够,这也是搜索与数据库的匹配语义差异。

② 无法分词 → 分词流水线

MySQL 的 LIKE 只能整串匹配,无法理解"无线机械键盘"和"机械键盘"是相关的。ES 在索引时通过 Analysis 流水线(Character Filters → Tokenizer → Token Filters)把文本切成 Term:

③ 缺少相关性打分 → BM25

MySQL 只能回答"有没有匹配”,回答不了"哪条更匹配"。ES 用 BM25 算法为每个命中文档打分:

三者综合后每个文档得到一个 _score,按分数倒序返回——这就是"哪条结果更匹配"的答案,也是搜索排序(召回后精排)的基础。

数据结构

在索引元数据(Mapping / Settings)已经建立的前提下,ES 接收到一个 JSON 文档后,数据处理主要经历集群网络路由层Lucene 引擎数据结构拆解层以及内存与磁盘持久化层

Lucene 内部的数据结构构建

文档进入分片后,Lucene 会根据 Mapping 配置,将文档拆解并同步构建多种底层数据结构:

D2 Diagram
qtopie.github.io

每个字段按类型进入对应的结构,下面逐一展开每种结构的原理。

倒排索引(Inverted Index)

适用类型textkeyword 等文本字段。

倒排索引回答的问题是:“哪些文档包含某个词?” 它的构建分三步:

Analysis(分词)

原始文本先通过三段流水线切分为 Term 序列:

D2 Diagram
qtopie.github.io

建立字典与倒排列表

分词后,每个 Term 指向一个 Posting List(倒排列表):

D2 Diagram
qtopie.github.io

Posting List 中除了保存包含该 Term 的 DocID 外,还会记录:

字典压缩:FST

Term 字典全量加载进内存会非常大,Lucene 用 FST(Finite State Transducer,有限状态转换器) 压缩。FST 本质是一个 DAG,公共前缀的 Term 共享同一条路径:

D2 Diagram
qtopie.github.io

以上图中 ancant 共享前缀 an,路径只存一次。FST 查找一个词的时间复杂度近似 $O(\text{词长})$,且天然支持前缀查询(如 an*)和模糊匹配。除了字典查找,FST 还存了每个 Term 在磁盘上的文件偏移,一次定位就能直接读到 Posting List。

Posting List 压缩

Posting List 中的 DocID 是有序整数序列,直接存储空间浪费巨大,Lucene 用两种算法压缩(详见下文 Roaring Bitmap 小节):

倒排索引的存储架构

上面把"分词 → 建立字典 → Posting List → 压缩"按步骤拆开讲了,但它们是层层咬合在一起的。下面以三份商品文档为例,串起"写入 → 落盘 → 查询"的完整链路,看看倒排索引到底是怎么存进磁盘的:

D2 Diagram
qtopie.github.io

这张图把存储拆成三层,对应上面的样例数据:

查询(⑤)match「机械键盘」 分词为 [机械, 键盘],FST 在 $O(\text{词长})$ 内定位到两个 Term 的 Posting List,取交集 [1,2] ∩ [1,2] = [1,2],最终命中 Doc 1 与 Doc 2。

关键:压缩字典是 Segment 粒度的

上面的图简化成了一张"全局字典",实际上每个 Segment 各自持有一份独立的压缩字典(.tim 完整字典 + .tip FST 前缀索引),Segment 之间互不共享

会不会数据倾斜?

Segment 大小真的均匀吗?

稳定状态下是的,但均匀是指"同一量级内",而非严格相等。默认的 Tiered Merge Policy(分层合并) 保证:

对比:倒排索引 vs B+ 树聚簇索引

说到索引,MySQL InnoDB 的 B+ 树聚簇索引是最常被拿来对比的。两者都解决"快速定位数据"的问题,但思路完全不同:

D2 Diagram
qtopie.github.io

核心差异一句话:B+ 树聚簇索引是**“按主键有序的数据本身”(叶子节点直接存整行,数据即索引);倒排索引是“词 → 文档的映射表”**(数据本体存在 _source,索引只负责定位 DocID)。

对比维度B+ 树聚簇索引(InnoDB)倒排索引(Lucene)
组织方式按主键(聚簇键)排序的平衡多叉树Term 字典 + 无序 Posting List
叶子节点存整行数据(数据即索引)存 DocID 集合(不含原始文档)
等值查询主键/唯一键 $O(\log N)$ 定位字典(FST)近似 $O(\text{词长})$ 定位
范围查询极强:键有序,叶子链表顺序扫描,天然支持:Posting List 无序,数值范围要另建 BKD 树
前缀/模糊查询LIKE 'abc%' 用最左前缀,%abc% 全表扫极强:FST 前缀遍历、倒排列表编辑距离(模糊)
全文检索不支持(无分词)天生为全文设计(分词 + 位置 + 偏移)
多条件组合需设计联合索引,受最左前缀约束Posting List 位图交 / 并(Roaring)求交,组合任意
排序 / 聚合按索引键有序则免排序,其他键要临时表 / filesort走独立列存 Doc Values,聚合不碰倒排索引
更新方式原地更新叶子节点(页分裂 / 页合并)写时不可变 Segment,删改靠标记删除 + 后台 Merge
存储膨胀聚簇索引含数据,二级索引需回表一份文档同时进入倒排、Doc Values、_source 等多份结构

一句话选型:数据是结构化、按主键查询 / 区间扫描为主(订单表、流水表)→ B+ 树聚簇索引;数据是非结构化文本、需要搜索 / 模糊 / 聚合分析(搜索系统、日志分析)→ 倒排索引。这也是为什么电商订单用 MySQL、商品搜索用 ES 的底层原因。

Point Values(BKD 树)—— 时间范围与数值检索

适用类型datelongintegerfloatgeo_point 等。

为什么需要 BKD 树?

早期的 ES 把数值也当成字符串塞进倒排索引。比如价格 399.00 会变成一个 Term "399",数值有多少种,字典里就有多少个 Term——导致范围查询要遍历海量 Term,效率极低且索引极度膨胀。

Lucene 6 引入 BKD 树(Block K-D Tree),专门解决数值 / 地理坐标的范围查询

结构特点

BKD 树是 K-D 树与 B 树的结合:内部节点记录切分维度与切分值(一维场景退化为类似 B 树的分块有序树),叶子节点是排好序的 Leaf Block

D2 Diagram
qtopie.github.io

范围查询原理(如 price >= 300, price <= 500):

以日期范围 gte: 2026-01-01, lte: 2026-06-01 为例,日期先转为长整型毫秒值,然后沿 BKD 树逐层剪枝,快速排除半年之外的所有块。

Doc Values(列式存储)

适用类型:除 text 以外的大部分非分析字段(如 keyworddatelong 等)。

为什么需要 Doc Values?

倒排索引是 Term -> DocID,适合回答"哪个文档包含这个词"。但排序、聚合、脚本需要的是相反方向:“给定 DocID,它的字段值是多少?” 如果基于倒排索引做聚合,要把整个倒排列表解压进堆内存(早期 ES 的 Fielddata 机制),数据量大时直接 OOM。

Doc Values 用正排列存解决:写入时就把字段值按列连续存储(DocID -> Field Value):

D2 Diagram
qtopie.github.io

存储与压缩

核心作用:专门用于 Sort(排序)Aggregations(聚合)Script(脚本)。这就是为什么聚合排序走 Doc Values 而不是倒排索引——后者需要解压整个倒排列表进堆内存(Fielddata),容易 OOM。

Roaring Bitmap:Posting List 的压缩与加速

Roaring Bitmap 是 Posting List 的核心压缩算法,同时也在查询执行阶段被用来缓存 filter 上下文的结果集。

为什么不用普通位图?

普通位图每个文档 ID 占 1 bit,几十亿文档就要几百 MB;而纯数组存储又太稀疏。Roaring Bitmap 折中:按高 16 位分块(每 65536 个 DocID 一组),块内再按稀疏度选择最省的容器:

D2 Diagram
qtopie.github.io

为什么快:两个 Posting List 做交集 / 并集时,直接对容器做位运算(AND / OR / XOR),数组场景还能用二分查找,配合 SIMD 指令提速。这正是 bool.filter 多条件过滤"秒出结果"的底层原因。

_source 与 Stored Fields(行存存储)

_source 字段

默认开启,ES 会把传入的原始 JSON 作为一个完整的 Byte 数组(经 LZ4 / ZSTD 算法压缩)直接写入 Stored Fields 集合。GET /index/_doc/id 返回完整文档时,就是直接读取并解压该字段,不需要重新组装。

Stored Fields

如果在 Mapping 中配置了 "store": true,该字段会被单独拆出来存入行存文件,用于只需提取少量字段、而不想解压整个 _source 的场景:

D2 Diagram
qtopie.github.io

行存 vs 列存的取舍_source 与 Stored Fields 是行存(按文档组织,适合取整条 / 少量字段);Doc Values 是列存(按字段组织,适合跨文档聚合)。两者互补,各司其职。

内存到磁盘的流转过程(Segment 生命周期)

文档经过上述处理生成内存结构后,数据会经历以下四个阶段落盘:

D2 Diagram
qtopie.github.io
  1. 写入 Index Buffer 与 Translog:文档同时写入内存中的 Index Buffer 和磁盘上的 Translog(Write-Ahead Log,预写日志,防止宕机丢失)。

  2. Refresh(可被搜索):默认每 1 秒(或 Buffer 满时),Index Buffer 中的数据被写入 OS Page Cache,生成一个新的 In-memory Segment。一旦生成 Segment,该 Segment 内的数据就可以被客户端 Search 到(这就是 ES 近实时搜索 (NRT) 的原因)。

  3. Flush & Commit(持久化落盘):默认每 30 分钟 或 Translog 达到阈值(如 512MB)时,触发 Flush。调用 OS fsync 将 Page Cache 中的 Segments 强制刷入物理磁盘,生成 Lucene Commit Point,同时清空并重置 Translog。

  4. Segment Merge(后台合并):频繁 Refresh 会产生大量小 Segment 文件。ES 后台会有异步线程按照段大小进行 Merge,将小 Segment 合并为大 Segment,同时彻底物理删除已被标记删除(.del)的文档。

一个例子:商品文档如何用上所有数据结构

以电商商品为例,一条商品文档长这样:

{
  "product_id": "P-1001",
  "title": "无线机械键盘 87 键 蓝牙 2.4G 三模热插拔",
  "description": "Gasket 结构,RGB 背光,支持 Win / Mac 双系统切换",
  "brand": "Keychron",
  "category": "电脑外设",
  "price": 399.00,
  "stock": 128,
  "on_sale": true,
  "created_at": "2026-07-01T10:30:00+08:00",
  "tags": ["机械键盘", "无线", "办公"]
}

对应的 Mapping 片段:

{
  "mappings": {
    "properties": {
      "title":       { "type": "text" },
      "description": { "type": "text", "store": true },
      "brand":       { "type": "keyword" },
      "category":    { "type": "keyword" },
      "price":       { "type": "double" },
      "stock":       { "type": "integer" },
      "on_sale":     { "type": "boolean" },
      "created_at":  { "type": "date" },
      "tags":        { "type": "keyword" }
    }
  }
}

写入这条文档时,ES 会同步构建所有数据结构,各自负责不同的查询需求:

数据结构覆盖字段典型查询
倒排索引title(分词后)、brandtags搜索"机械键盘"、精确匹配 brand = Keychron
Roaring BitmapPosting List、filter 上下文缓存bool.filter 快速过滤 + 结果集位图缓存
BKD 树pricestockcreated_at价格区间 price 300~500、上架时间范围
Doc Valuesbrandpricecreated_at按价格排序、按品牌聚合统计销量
_source整条 JSONGET /products/_doc/P-1001 返回完整文档
Stored Fieldsdescriptionstore: true只取描述字段,避免解压整个 _source

再来对应几个真实查询,看看它们分别走了哪条路:

// ① 关键词搜索 → 倒排索引
GET /products/_search
{ "query": { "match": { "title": "机械键盘" } } }

// ② 价格范围 → BKD 树
GET /products/_search
{ "query": { "range": { "price": { "gte": 300, "lte": 500 } } } }

// ③ 聚合统计各品牌数量 → Doc Values
GET /products/_search
{ "aggs": { "by_brand": { "terms": { "field": "brand" } } } }

// ④ 多条件过滤 → Roaring Bitmap(filter 上下文结果集缓存为位图)
GET /products/_search
{ "query": { "bool": { "filter": [ { "term": { "on_sale": true } }, { "terms": { "tags": ["无线", "办公"] } } ] } } }

// ⑤ 只取描述字段 → Stored Fields(无需解压 _source)
GET /products/_doc/P-1001
{ "stored_fields": ["description"] }

补充一点:Roaring Bitmap 其实有两处用武之地——在倒排索引里它和 Frame of Reference 一起负责压缩 Posting List(高基数词项用 FOR,低基数/稀疏时切换为 Roaring Bitmap);在查询执行时,filter 上下文的匹配结果会被缓存成位图,多个 filter 之间用位图 AND/OR 快速求交集,复杂过滤条件秒出结果。

这样一条商品数据从写入到被各种查询命中,六种机制各司其职:倒排索引管"搜得准"、Roaring Bitmap 管"滤得快"、BKD 树管"范围快"、Doc Values 管"排得顺/聚得稳"、_source 与 Stored Fields 管"取得到"


写入与查询流程

写入流程(Write Path)

  1. 客户端将文档请求路由到某个 主分片
  2. 主分片先将操作写入 Translog(预写日志,保证崩溃恢复时不丢数据)。
  3. 文档被写入内存中的 Index Buffer,此时不可被搜索
  4. 当 Index Buffer 满或达到 refresh_interval(默认 1s)时,生成一个新的 Segment 并写入文件系统缓存,此时文档变为可搜索(这就是"近实时"的含义)。
  5. Segment 会定期通过 flush 落盘,并随时间合并成更大的段(Segment Merge)。

查询流程(Search Path)

  1. 查询请求到达协调节点(Coordinating Node)。
  2. 协调节点将请求广播到索引的所有分片(Query Phase)。
  3. 各分片在本地执行检索与打分,返回 Top N 结果。
  4. 协调节点对结果归并排序,再向相关分片取回完整文档(Fetch Phase)返回客户端。

搜索是"两阶段"过程:Query 阶段只汇总各分片的 top-N 文档 ID 与分数,Fetch 阶段才拉取完整数据,以最小化跨节点数据传输量。


为什么"近实时"(Near Real-Time)

ES 的默认 refresh_interval1 秒,即文档写入后大约 1 秒才能被搜索到。这由两个设计决策决定:

若需要更高的实时性(如搜索引擎内刚发布的文章),可以调小 refresh_interval;反之在批量导入数据时可临时调大甚至关闭刷新以提升写入速度。


分布式机制

分片与路由:一篇文档怎么落到分片

写入一条文档时,ES 的协调节点(Coordinating Node)先计算它属于哪个主分片:

D2 Diagram
qtopie.github.io

数据热点与倾斜

分片级倾斜才是真正需要警惕的问题,分两种情况:

常见的应对手段:

手段适用场景
合理设置主分片数创建索引时按数据量预估(一般每分片 20–50GB),主分片数不可改
慎用自定义 routing路由 key 要足够分散;可用 routing_partition_size 把同一路由值摊到多个分片
滚动索引 / ILM时间序列数据按天 / 月滚动索引(Index Lifecycle Management),避免单索引无限膨胀——这是最大的写入热点来源
增加副本分散读负载读热点为主时,副本越多读吞吐越高
forcemerge只读索引合并为少数大段,减少查询扫描的段数
分片 RebalanceES 自动在节点间均衡分配分片,节点加入 / 移除时触发

选型与适用场景

适合使用 ES 的场景

不适合 / 需要权衡的场景


典型应用场景:订单查询

电商平台的订单系统是 ES 的经典落地场景之一。订单数据量大、查询维度多(用户、店铺、状态、时间区间)、且需要复杂的组合过滤与排序,直接用 MySQL 很难扛住。

摘要数据与详情数据分离

订单往往包含两部分差异巨大的数据:

不要在 ES 里存全部字段。 通常只在 ES 中建立"订单索引",存放摘要字段供列表检索、过滤、排序;详情页查询时用订单号回源 MySQL 获取完整详情。

这样做的好处:

冷热数据分离

订单随时间推移,访问热度急剧下降:

常用手段:


典型应用场景:搜索与推荐(召回与精排)

搜索推荐系统通常遵循一个漏斗:召回(Recall)→ 粗排(Coarse Ranking)→ 精排(Fine Ranking)→ 重排。越靠前,候选集越大、对延迟要求越严苛。

ES 最擅长、也最常被用在召回阶段

典型分工:

阶段候选量级主要载体
召回(大召)百万 ~ 亿级Elasticsearch(倒排索引)
粗排万级ES function_score / 轻量模型
精排千级深度模型 / LTR 排序模型
重排百级以内业务规则 / 多样性打散

核心思路:用 ES 解决"把候选捞出来"的海量检索问题,把"谁排在前面"的精细打分交给上层模型,各司其职。


小结