核心概念
前一篇笔记我们已经学习了如何搭建ES服务,不过具体操作ES之前我们还得了解一些基本概念,这篇笔记我们将继续介绍ES中的核心架构、模型与底层机制。
Index、Document和Mapping
Index(索引)是ES中数据的逻辑容器。如果你熟悉关系型数据库,可以把Index近似理解为MySQL中的“表”,把其中存储的每一条数据理解为“数据记录行”,不过ES中对这些叫法有自己的定义。
Index 索引:类比MySQL的表,Index是同类文档的集合。例如产品数据可以放在product索引,订单数据放在order索引。
Document 文档:Document是可以存放在Index中的一条数据,以JSON格式存储。
Field 字段:Field是Document中的每一个键值对,类比MySQL的列。
下面是一个典型的Document例子。
{
"_index": "product",
"_id": "1001",
"_source": {
"name": "iPhone 16 Pro",
"price": 9999.00,
"category": "手机",
"description": "苹果最新旗舰手机,搭载A18 Pro芯片"
}
}
Mapping(映射)定义了Index中每个字段的数据类型和索引行为。类比MySQL的表结构定义,它规定了每个字段是什么类型、是否需要被索引、用什么分词器处理等规则。ES支持动态映射(Dynamic Mapping),即在你第一次写入数据时自动推断字段类型,也支持手动定义显式映射(Explicit Mapping),不过实际开发中我们基本不使用动态映射,因为这种隐晦的推断可能带来意外的字段类型错误或字段膨胀。
常见的字段类型对照如下。
| ES 字段类型 | 说明 | 类比 MySQL 类型 |
|---|---|---|
| text | 全文检索字段,会分词 | TEXT |
| keyword | 精确匹配字段,不分词 | VARCHAR |
| integer / long | 整数 | INT / BIGINT |
| double / float | 浮点数 | DOUBLE / FLOAT |
| date | 日期时间 | DATETIME |
| boolean | 布尔值 | TINYINT(1) |
| object / nested | 嵌套对象 | 无直接对应 |
有关映射的更多详细用法我们将在后续章节详细解释。
Cluster和Node
Cluster(集群)是由一个或多个Node(节点)组成的ES运行单元。每个Node是一个独立ES进程,通常运行在一台独立的服务器上。Cluster内的所有Node共同存储数据、协同处理搜索请求。Cluster中的Node根据职责不同分为以下几种角色。
Master Node 主节点:负责集群元数据管理,例如创建或删除索引、追踪节点状态、分配分片等。Master节点不负责具体的数据读写,它是集群的管理者。
Data Node 数据节点:负责存储具体的分片数据,承担数据读写和搜索计算任务,是集群中最消耗CPU和磁盘的角色。
Coordinating Node 协调节点:负责接收客户端请求,将请求分发到对应的Data Node,并将结果汇总返回给客户端。所有节点默认都具备协调功能。
Shard和Replica
ES中一个Index的数据不一定全部存放在一个Node上,数据可以被拆分成多个Shard(分片)分布在不同的Node上存储,这允许ES集群能存储超过一块磁盘容量的数据,并能动态横向扩容。Shard分为两种类型:
Primary Shard 主分片:实际存储数据的分片,写入请求只会到达Primary Shard。
Replica Shard 副本分片:Primary Shard的备份,提供冗余保障和读请求的负载均衡。
数据在ES集群上的一种可能的分布情况如下图所示。product索引被分成了3个Primary Shard,每个Primary Shard在另一个Node上有一份Replica,这样即使某个Node宕机,数据也不会丢失。分片数在创建索引时就需要确定,Primary Shard的数量一旦创建后就不可更改了,因此在设计索引时需要提前根据数据量和集群规模做好规划,至于Replica Shard的数量则可以随时调整。
graph TB
subgraph "ES 集群"
subgraph "Node 0"
P0["product 索引<br/>Primary Shard 0 ★"]
R1["product 索引<br/>Replica Shard 1"]
end
subgraph "Node 1"
P1["product 索引<br/>Primary Shard 1 ★"]
R2["product 索引<br/>Replica Shard 2"]
end
subgraph "Node 2"
P2["product 索引<br/>Primary Shard 2 ★"]
R0["product 索引<br/>Replica Shard 0"]
end
end
ES与Lucene底层存储结构
理解了ES的逻辑概念之后,我们再往下看一层,聊聊ES和Lucene的关系以及底层的数据结构。实际上,Lucene正是ES的底层搜索引擎库。ES本身并没有重新发明全文检索算法,它是基于Apache Lucene构建的,Lucene负责完成最核心的工作:构建倒排索引、执行搜索、打分排序等。ES在Lucene之上做了大量工程化封装,提供了分布式架构、Restful API、集群管理、Mapping、聚合分析等能力,让Lucene的强大搜索能力可以水平扩展、开箱即用。
每个ES的Shard本质上就是一个独立的Lucene实例。当你向ES写入一条文档时,ES先通过路由规则将其路由到对应的Primary Shard,该Shard对应的Lucene实例会继续完成后续的索引构建工作。
Lucene内部使用段(Segment)作为数据存储的基本单元。前面说过一个Shard对应一个Lucene实例,而一个Lucene实例内部会包含多个Segment。每个Segment本质上是一个独立的小倒排索引,Segment是不可变(Immutable)的,一旦写入就不可修改,ES中修改数据实际上的标记删除再重建了对应的Segment,这也是ES不适合高频写入删除操作的底层原因。
更进一步,每个Segment内部包含多种数据结构,它们共同支撑高效的检索和分析。
倒排索引:全文检索的核心数据结构,它记录了每个词项(Term)出现在哪些文档中,并存储了词频、位置等信息,是实现全文检索的基础。
正排存储:按文档ID顺序存储字段值,主要用于排序和聚合分析场景。当你对某个字段做sort或terms aggregation操作时,实际上是在查询正排存储而不是倒排索引。
行存储:存储文档的原始内容,查询结果中的_source字段就来源于此。
列式存储:ES 7.x引入的_doc_values,以列式格式存储,它用于进一步优化聚合分析的性能。
倒排索引
倒排索引(Inverted Index)是全文检索性能的基础,理解它是理解ES工作原理最重要的一步。
正排索引 vs 倒排索引
我们熟悉的关系型数据库使用的是正排索引,正排索引中,给定一个文档(行)可以快速找到其中的内容(列值)。但如果我们反过来考虑,想要实现给定一个关键词找出所有包含它的文档,如果用正排索引就需要全表扫描,性能非常差。而倒排索引反转了这个关系,它记录的是词 -> 文档列表映射。
举例来说,假设我们有3条商品文档。
| 文档 ID | 商品名称 |
|---|---|
| 1 | iPhone 16 Pro 手机 |
| 2 | 华为 Mate 60 手机 |
| 3 | 苹果 iPhone 充电器 |
经过分词和索引构建后,倒排索引大致如下。
| 词项(Term) | 文档列表(Posting List) |
|---|---|
iphone |
[1, 3] |
手机 |
[1, 2] |
华为 |
[2] |
苹果 |
[3] |
充电器 |
[3] |
当用户搜索“iPhone 手机”时,ES先对查询输入分词得到iphone和手机,然后分别在倒排索引中查找对应的文档列表,再对结果取交集或并集,整个过程完全不需要扫描原始数据,因此检索速度极快。
词项词典与FST
倒排索引中,词项(Term)的查找本身也需要高效的数据结构来支撑。Lucene使用FST(Finite State Transducer,有限状态转换器)来存储词项词典。FST是一种压缩的有序数据结构,它可以在极小的内存占用下完成快速的词项前缀查找,这也是ES支持前缀查询(Prefix Query)和模糊查询的底层原因之一。
跳表加速文档列表合并
Posting List(文档列表)中存储的是文档ID的有序序列。当多个词项的Posting List需要做交集运算时(例如bool查询中的must条件),Lucene使用跳表(Skip List)加速这个过程,避免逐一遍历比对。对于数量庞大的Posting List,Lucene还会使用Roaring Bitmap(高效压缩位图)进行压缩存储,进一步降低内存和磁盘占用。
分词流程与Analyzer
分词(Analysis)是将原始文本转化为可供倒排索引存储的词项(Term)的过程。ES中,分词由Analyzer(分析器)完成。
Analyzer的组成
一个Analyzer由三个部分组成,按顺序依次执行。
graph LR
Raw["原始文本<br/>iPhone 16 Pro 手机"]
CT["Character Filter<br/>字符过滤器<br/>(去除HTML标签等)"]
TK["Tokenizer<br/>分词器<br/>(按规则切词)"]
TF["Token Filter<br/>词项过滤器<br/>(转小写、去停用词等)"]
Terms["词项列表<br/>iphone / 16 / pro / 手机"]
Raw --> CT --> TK --> TF --> Terms
Character Filter(字符过滤器):在分词前对原始文本做字符级处理,例如去除HTML标签、将全角字符转半角等。
Tokenizer(分词器):按照指定规则将文本切分为词项,这是Analyzer中最核心的部分。例如英文按空格和标点切分,中文则需要专门的分词器(如 IK)处理。
Token Filter(词项过滤器):对分词结果做进一步处理,例如转小写、去除停用词(“的”、“了”、“a”、“the”等)、同义词扩展等。
内置Analyzer
ES内置了多种Analyzer,常用的有以下几个。
| Analyzer | 说明 |
|---|---|
standard |
默认分析器,按Unicode规则分词并转小写,适合英文 |
simple |
按非字母字符切分,全部转小写 |
whitespace |
仅按空格切分,不做其它处理 |
keyword |
不分词,将整个字段作为一个词项 |
对于中文,内置分析器效果都很差(例如standard会把中文拆成单个字),这也是生产环境通常需要安装IK分词器插件的原因。
验证分词效果
ES提供了_analyzeAPI可以方便地查看分词结果,这在调试Mapping和排查搜索问题时非常实用,我们可以在Kibana中输入以下内容调试。
GET /_analyze
{
"analyzer": "standard",
"text": "iPhone 16 Pro"
}
输出结果如下。
{
"tokens": [
{
"token": "iphone",
"start_offset": 0,
"end_offset": 6,
"type": "<ALPHANUM>",
"position": 0
},
{
"token": "16",
"start_offset": 7,
"end_offset": 9,
"type": "<NUM>",
"position": 1
},
{
"token": "pro",
"start_offset": 10,
"end_offset": 13,
"type": "<ALPHANUM>",
"position": 2
}
]
}
返回结果中可以看到每个词项及其在原始文本中的位置信息。
数据写入流程
很多人初次使用ES时都会惊讶的遇到“刚写进去查不到”问题,惊出一身冷汗,实际上这和ES的数据写入流程设计有关。下面我们继续深入,了解ES中数据写入的完整流程,这对我们理解ES的实时性表现和数据可靠性保障机制很有帮助。
sequenceDiagram
participant Client as Java 客户端
participant CN as Coordinating Node
participant PN as Primary Shard Node
participant RN as Replica Shard Node
Client->>CN: 写入文档请求
CN->>CN: 路由计算,确定 Primary Shard
CN->>PN: 转发写入请求
PN->>PN: 写入内存 Buffer + translog
PN->>RN: 同步写入 Replica
RN->>PN: 确认写入成功
PN->>CN: 返回写入成功
CN->>Client: 响应 200 OK
当写入请求到达后,整体流程分为以下几个阶段。
第一步,路由到Primary Shard:客户端请求首先到达Coordinating Node,它根据路由公式shard = hash(document_id) % number_of_primary_shards计算出该文档应写入哪个Primary Shard,然后将请求转发过去。这也是前面提到Primary Shard数量一旦创建后不可修改的根本原因,分片数变路由结果就变,历史数据就找不到了。
第二步,写入内存Buffer和translog:文档到达Primary Shard所在节点后,先被写入内存Buffer,同时追加写入磁盘上的translog(事务日志)。注意此时文档还不可被搜索到。
第三步,同步Replica:Primary Shard将写入操作同步到所有Replica Shard,Replica确认写入成功后整个写入操作才算完成,客户端得到响应。
Refresh:从写入到可搜索
写入内存Buffer的文档并不能立刻被搜索到,需要经过一次Refresh操作才行。Refresh的作用是将内存Buffer中的数据写入一个新的Lucene Segment,并打开这个Segment使其对搜索可见。ES默认每隔1秒执行一次Refresh,这就是ES所谓“近实时搜索(Near Real-Time,NRT)”中“近”字的由来,写入的数据最多需要等待约1秒才能被搜索到。
如果你的业务需要写入后立即可查,可以手动触发Refresh。
POST /product/_refresh
不过注意频繁触发Refresh会增加Lucene Segment的数量最终影响搜索性能,在高写入量场景下十分不建议每次写入都强制Refresh!
translog:防止数据丢失
Refresh之后数据进入Segment并对搜索可见,但此时数据还在内存或操作系统的Page Cache中,如果机器宕机数据可能丢失。ES通过translog(事务日志)来解决这个问题。每次写入操作发生时,ES都会同步追加写入translog,translog默认是实时落盘的,即使进程崩溃或机器重启,ES在恢复时会重放translog中的操作将未持久化的数据恢复出来,这样数据就不会丢失。
Flush:从内存到磁盘的持久化
Flush是将内存中的数据真正持久化到磁盘的过程。Flush发生时,ES会将内存中所有的Segment写入磁盘,并清空对应的translog。Flush的触发条件有以下几种:
- translog超过一定大小(默认512MB)
- 距离上次Flush超过一定时间(默认30分钟)
- 手动调用
POST /product/_flush
Flush完成后数据才真正安全地落在了磁盘上,即使宕机也不会丢失了。
段合并:Merge
每次Refresh都会产生一个新的Segment,随着时间推移,Segment数量会越来越多,搜索时需要遍历所有Segment性能会下降。实际上,ES也会在后台周期性地执行段合并(Merge)操作,将多个小Segment合并成一个大Segment,同时清理已被标记为删除的文档(ES中的删除操作并非立即物理删除,而是先打上删除标记,待Merge时才真正清除)。段合并是一个IO密集型操作,合并过程在后台进行不会阻塞正常的读写请求,但在写入量很大的集群上,过于频繁的Merge可能对磁盘IO造成较大压力,因此这里可以结合实际情况适当调优。
查询执行流程
理解了写入相关的基础概念,下面我们继续看查询流程。ES的查询分为两个阶段执行:Query阶段和Fetch阶段。
sequenceDiagram
participant Client as Java 客户端
participant CN as Coordinating Node
participant S0 as Shard 0
participant S1 as Shard 1
participant S2 as Shard 2
Client->>CN: 查询请求
Note over CN: Query 阶段开始
CN->>S0: 广播查询请求
CN->>S1: 广播查询请求
CN->>S2: 广播查询请求
S0->>CN: 返回匹配的 doc_id 列表 + 分数
S1->>CN: 返回匹配的 doc_id 列表 + 分数
S2->>CN: 返回匹配的 doc_id 列表 + 分数
Note over CN: 全局排序,取 TopN
Note over CN: Fetch 阶段开始
CN->>S0: 根据 doc_id 获取完整文档
CN->>S1: 根据 doc_id 获取完整文档
S0->>CN: 返回完整文档内容
S1->>CN: 返回完整文档内容
CN->>Client: 返回最终结果
Query阶段:Coordinating Node将查询请求广播给所有相关Shard(Primary或Replica均可),每个Shard在本地独立执行查询,返回符合条件的文档ID列表和相关性分数。Coordinating Node收到所有Shard的结果后进行全局排序,取出TopN条记录的文档ID。
Fetch阶段:Coordinating Node根据上一阶段得到的文档ID向对应的Shard发起请求,获取这些文档的完整内容(_source),最终汇总后返回给客户端。
这种两阶段设计的核心思路是:Query阶段只传输轻量的文档ID和分数,避免全量数据在网络上传输,最终需要展示的那部分数据才在Fetch阶段传输完整内容,大幅降低了网络开销。
读写分离与路由
ES中写入请求只能由Primary Shard处理,但读请求可以由Primary Shard或任意一个Replica Shard来处理。Coordinating Node会通过轮询策略在Primary和Replica之间做负载均衡,这样就实现了一种读写分离机制,这也是Replica存在的第二个价值,即分担读请求的压力。正是因为读请求可以落到Replica,而Replica的数据同步是异步的(写入Primary成功后才同步Replica),所以在极短的时间窗口内,从Replica查询到的数据可能比Primary稍旧,即存在读写一致性问题。一般业务场景下这种延迟可以忽略不计,但在对一致性要求极高的场景下需要注意这一点。