核心概念

前一篇笔记我们已经学习了如何搭建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顺序存储字段值,主要用于排序和聚合分析场景。当你对某个字段做sortterms 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稍旧,即存在读写一致性问题。一般业务场景下这种延迟可以忽略不计,但在对一致性要求极高的场景下需要注意这一点。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。