ElasticSearch笔记


ElasticSearch 复习

什么是ElasticSearch

ElasticSearch是一个基于Lucene的开源分布式搜索引擎,由Elastic公司开发。它具有以下特点:

核心特性

  • 分布式搜索引擎:基于Lucene,提供分布式的全文搜索功能
  • RESTful API:通过HTTP使用JSON进行数据交互
  • 实时分析:支持实时数据分析
  • 高可用性:分布式架构确保无单点故障
  • 可扩展性:可以从小规模扩展到PB级数据

应用场景

  • 站内搜索:网站或应用内的搜索功能
  • 日志分析:与Logstash和Kibana组成ELK栈,用于日志收集和分析
  • 数据分析:结构化数据的快速分析
  • 全文检索:文档、商品描述等全文内容的检索
  • 监控系统:用于系统性能指标的存储和分析

主要概念

Elasticsearch 概念 关系型数据库 (MySQL) 类比 一句话解释
Index (索引) Database (数据库) 一个存放一类数据的“库”。
Document (文档) Row (一行数据) 一条可被搜索的记录。
Field (字段) Column (一列) 一条记录中的一个数据项。
Mapping (映射) Schema (表结构定义) 定义每个字段类型的规则。
Cluster (集群) 整个数据库服务 协同工作的整个 ES 服务。
Node (节点) 一个数据库服务器实例 集群中的一台服务器。

1. Document (文档)

  • 是什么: Elasticsearch 中最基本的、可被索引的信息单元
  • 类比: 数据库表中的一行 (Row) 数据。
  • 解释: 它是一个用 JSON 格式表示的数据对象。你存入 ES 的每一条商品信息、每一篇博客、每一条日志,都是一个独立的文档。这是你进行搜索和分析的最小对象。

示例:

{
  "product_id": "A-123-B-456",
  "title": "华为 Mate 60 Pro",
  "price": 6999.00
}

2. Field (字段)

  • 是什么: 组成文档的一个个键值对 (Key-Value Pair)
  • 类比: 数据库表中的一列 (Column)
  • 解释: 每个字段都有一个特定的数据类型(比如 text 用于全文搜索,keyword 用于精确值,integer 用于整数,date 用于日期等)。你在 Mapping 里定义的就是每个字段的类型和规则。

示例: 在上面的文档中,"title": "华为 Mate 60 Pro" 就是一个字段。title 是字段名,"华为 Mate 60 Pro" 是字段值。

3. Index (索引)

  • 是什么: 一个文档的集合。它拥有相似的特征,是 ES 中数据管理的最高层级单位。
  • 类比: 一个数据库 (Database)
  • 解释: 比如,你可以创建一个 products 索引来存放所有商品文档,再创建一个 logs 索引来存放所有日志文档。我们所有的查询、更新、删除操作,都是针对一个或多个索引来进行的。索引的名称必须是小写。

4. Shard (分片)

  • 是什么: 索引的物理拆分。每个分片都是一个功能齐全、独立的“子索引”。
  • 类比: 把一个巨大的数据库表水平分区 (Partitioning) 成多个小表。
  • 解释: 这是 Elasticsearch 实现水平扩展和高并发的核心。当一个索引的数据量太大,单个节点存不下或处理不过来时,ES 会将这个索引拆分成多个分片,并将这些分片分布到不同的节点上。这样,一个查询请求可以同时在多个分片上并行执行,极大地提升了处理能力。
    • 主分片 (Primary Shard): 索引的每个文档都只属于一个主分片。主分片的数量在索引创建时就必须固定,之后不能修改。

5. Replica (副本)

  • 是什么: 分片的一份或多份拷贝
  • 类比: 数据库的主从复制中的“从库”。
  • 解释: 副本的主要作用有两个,我们之前也提到过:
    1. 高可用性 (High Availability): 副本和它的主分片永远不会被分配在同一个节点上。如果持有主分片的节点宕机,ES 会立即将一个副本“提升”为新的主分片,保证服务不中断,数据不丢失。
    2. 提升读性能 (Increase Read Throughput): 查询请求(读操作)可以由主分片或任何一个副本分片来处理,从而将读请求的压力分摊到更多的节点上。

6. Node (节点)

  • 是什么: 集群中的一个服务器实例
  • 类比: 一台运行着 MySQL 实例的服务器
  • 解释: 它是构成集群的单个成员,负责存储数据、参与索引和搜索。每个节点都有自己的名字,并通过 cluster.name 加入到指定的集群中。我们之前讨论的选举和心跳检测,都是在节点之间进行的。

7. Cluster (集群)

  • 是什么:一个或多个节点组成的集合。
  • 类比: 整个高可用的数据库集群
  • 解释: 它将所有节点的数据和计算能力汇集在一起,对外提供统一的服务。你与 ES 的所有交互,都是通过集群中的某个节点进行的。集群负责管理所有索引、分片、副本的健康和分布,确保整个系统的稳定运行。

为什么说“索引的每个文档都只属于一个主分片。主分片的数量在索引创建时就必须固定,之后不能修改。”

我们先来理解第一部分:“索引的每个文档都只属于一个主分片”

这句话意味着,当你保存一个文档时,Elasticsearch 必须有一个确定的、可重复的方法来决定这个文档应该存到哪个主分片里。它不能这次存到分片1,下次又存到分片2,那样就乱套了,永远也找不到数据。

这个决定过程,就叫做 “路由” (Routing)

路由是如何工作的?

Elasticsearch 使用一个非常简单的公式:

shard_number = hash(routing_value) % number_of_primary_shards

我们来解释这个公式里的每一项:

  • routing_value (路由值):默认情况下,这个值就是文档的 _id。你也可以手动指定一个值。
  • hash():一个哈希函数,它能把任意一个字符串(比如 _id)转换成一个固定的数字。
  • %:取余数运算符。
  • number_of_primary_shards:你创建索引时设定的主分片数量。

举个例子:

  1. 你创建了一个 products 索引,并设定它有 3 个主分片
  2. 现在,你要存入一个新商品文档,它的 _id“A-123-B-456”
  3. ES 会对 “A-123-B-456” 这个 _id 进行哈希计算,假设得到一个数字 2096
  4. 然后,ES 计算 2096 % 3,结果是 2
  5. 结论: ES 就知道了,这个文档必须被存到主分片2 (Shard 2) 上。

下次你根据这个 _id 来获取或更新这个文档时,ES 会重复一遍完全相同的计算,再次得到 2,然后直接去主分片2上找,而不需要去问分片0和分片1,效率极高。


现在,我们来理解第二部分,也是最关键的部分:

“主分片的数量在索引创建时就必须固定,之后不能修改”,为什么不能修改?

我们继续用上面的例子。假设 ES 允许你把主分片数量从 3 修改成 4

现在,你还是想找那个 _id 是 “A-123-B-456” 的文档。ES 再次执行路由计算:

  1. hash("A-123-B-456") 的结果依然是 2096
  2. 但是,现在主分片数量变成了 4,所以公式变成了 2096 % 4
  3. 计算结果是 0

灾难发生了!

ES 现在认为这个文档应该在主分片0 (Shard 0) 上。但实际上,它当初被存放在了主分片2上。ES 去分片0上找,结果肯定是“找不到”。

如果你修改了主分片的数量,所有之前文档的路由规则就全部失效了,整个索引的数据就陷入了彻底的混乱,你将无法通过 _id 定位到任何一个文档。

这就是为什么主分片数量一旦设定,就不能再修改的根本原因:为了保证路由规则的永久稳定。


那如果我确实需要扩容怎么办?

这是一个非常实际的问题。如果你的索引真的因为数据增长需要更多的分片来分担压力,正确的做法不是去修改现有索引,而是使用 Reindex API

  1. 创建一个新的索引 (比如 products_v2),并为它设置一个更多的主分片数量(比如 6 个)。
  2. 使用 Reindex API,让 Elasticsearch 自动地、高效地将旧索引 (products) 中的所有数据读取出来,并重新写入到新索引 (products_v2) 中。
  3. 在重新写入的过程中,ES 会为每一条数据应用新的路由规则(比如 % 6),将它们正确地分布到新的 6 个分片中。
  4. 数据迁移完成后,你可以将你的应用程序指向新的索引 products_v2,然后安全地删除旧索引。

搜索的流程是什么?

具体来说,它分为几个关键步骤:

  1. 分析查询 (Analyze Query): 首先,Elasticsearch 会使用与索引时相同的分析器 (Analyzer) 来处理用户的查询语句。例如,用户搜索“华为手机”,分析器会将其处理成词元(Tokens) ["华为", "手机"]
  2. 查找词典 (Term Dictionary Lookup): 接着,系统会拿着处理好的每一个词元(“华为”、“手机”),去那个已经建好的、巨大的词典 (Term Dictionary) 中快速查找。
  3. 获取文档列表 (Posting List Retrieval): 词典本身不存储文档ID,但它会告诉系统去哪里找到包含了这个词元的文档列表 (Posting List)
    • 找到“华为” -> 获取到它的文档列表,比如 [Doc1, Doc5, Doc10]
    • 找到“手机” -> 获取到它的文档列表,比如 [Doc1, Doc8, Doc10]
  4. 合并与计算 (Merge & Calculate): 这是非常关键的一步。
    • 布尔逻辑: 系统会对这些文档列表进行布尔运算。对于“华为手机”这个查询,默认是 AND 逻辑,所以它会取两个列表的交集,得到最终匹配的文档ID:[Doc1, Doc10]
    • 相关性评分 (_score): 与此同时(对于 match 查询),ES 还会计算每个匹配文档的相关性分数。它会考虑词频(TF,词在一个文档里出现的次数)、逆文档频率(IDF,词在所有文档中是否罕见)等因素。比如,如果 Doc1 的标题就是“华为手机”,而 Doc10 的描述里只提了一句,那么 Doc1 的得分就会更高。
  5. 返回结果 (Return Results): 最后,系统根据 _score 从高到低进行排序,然后根据这些文档 ID 去获取完整的文档内容(_source),并将最终排好序的结果返回给用户。

总结一下,更精确的说法是:

  • 直接修改主分片数会导致找不到文档,所以 ES 禁止这样做。
  • 正确的扩容方式(Reindex)是一个“先建新,再搬家,最后换门牌”的过程,它通过创建全新的索引来应用新的路由规则,从而保证在任何时候都不会出现找不到文档的情况。

倒排索引 (Inverted Index) 的工作原理是什么?

倒排索引是 Elasticsearch 实现快速全文搜索的核心数据结构,它的核心思想是“词到文档”的映射。传统的关系型数据库是“文档到词”,即根据一条记录(文档)找到里面的内容(词),而倒排索引反了过来。

而像MySQL 和 MongoDB也是做某种“值到记录”的映射,但是跟ElasticSearch有一个根本性的区别,就是分词

MySQL / MongoDB 的标准索引 (通常是 B-Tree 索引)

  • 索引对象: 字段的 完整值 (Entire Value)
  • 工作方式: 它们为一整个字段的值创建索引。比如你有一个 product_name 字段,值为 “Apple iPhone 15 Pro”。MySQL 会为 "Apple iPhone 15 Pro" 这个完整的字符串建立一个索引条目,指向它所在的行。
  • 查询场景:
    • 精确匹配 (=): 当你 WHERE product_name = 'Apple iPhone 15 Pro' 时,索引效率极高,能瞬间定位。
    • 前缀匹配 (LIKE 'Apple%'): B-Tree 索引也能高效支持。
    • 中间或后缀匹配 (LIKE '%iPhone%'): 这正是它的痛点! 索引完全无法使用,数据库不得不进行全表扫描(或索引扫描),逐行检查 product_name 字段是否包含 “iPhone” 这个子字符串。数据量一大,查询就会变得极慢。

ElasticSearch 的标准索引-倒排索引

建立倒排索引主要有两个步骤:

  1. 分词:将文档内容拆分成一个个独立的词(term)。例如,”Elasticsearch is fast” 会被拆分成 “elasticsearch”, “is”, “fast”。
  2. 建立映射关系:创建一个“词典(term dictionary)”,记录所有文档中出现过的词,并为每个词建立一个列表(Posting List),这个列表包含了所有出现该词的文档ID。

假设我们有三个文档:

  • Doc 1: “The quick brown fox”
  • Doc 2: “The lazy brown dog”
  • Doc 3: “The quick brown dog and fox”

经过分词和索引后,得到的倒排索引会是这样的:

词 (Term) 文档列表 (Posting List)
the [Doc 1, Doc 2, Doc 3]
quick [Doc 1, Doc 3]
brown [Doc 1, Doc 2, Doc 3]
fox [Doc 1, Doc 3]
lazy [Doc 2]
dog [Doc 2, Doc 3]
and [Doc 3]

追问:“那当用户搜索 quick brown 时,ES 是怎么做的?”

  1. 查询词典: ES 在词典中查找 “quick”,得到文档列表 [Doc 1, Doc 3] (Posting List) 文件。
  2. 查询词典: 接着查找 “brown”,得到文档列表 [Doc 1, Doc 2, Doc 3] (Posting List) 文件。
  3. 合并结果: ES 对这两个列表进行交集运算,[Doc 1, Doc 3][Doc 1, Doc 2, Doc 3] 的交集是 [Doc 1, Doc 3]
  4. 返回结果: ES 就知道 Doc 1 和 Doc 3 是符合搜索条件的。

因为词典通常可以被完全加载到内存中,查找速度非常快,而对文档列表的布尔运算(交、并、差)也非常高效,这就是倒排索引能实现秒级甚至毫秒级搜索的根本原因。

这里说的内存指的是JVM堆内存(ElasticSearch是Java应用)和操作系统文件内存

JVM 堆内存 (JVM Heap)

  • 它存放什么?
    • Elasticsearch 应用程序本身的运行时数据。
    • 一些被显式缓存的数据结构,比如 Filter Cache。当你在 filter 上下文中使用查询时,其结果(一个包含匹配文档 ID 的位图)会被缓存到这里,以便后续相同的过滤请求能极速返回。
    • 集群管理、节点通信、聚合计算的中间结果等。

操作系统文件系统缓存 (OS File System Cache / Page Cache)

  • 这是什么? 这是服务器物理 RAM 中,除了被 JVM 堆等应用程序占用的部分之外,由操作系统内核管理的一部分内存。
  • 它如何工作? 现代操作系统(如 Linux)非常智能。当你或一个程序(如 ES)读取磁盘上的文件时,操作系统会把文件的内容复制到这个缓存里。下次你再读取同一个文件时,操作系统会直接从这个高速缓存(RAM)中返回数据,而不是再次去访问慢速的磁盘。这个过程对上层应用是透明的。
  • 它与 ES 的关系? 这正是 Elasticsearch 和其底层库 Lucene 设计的精妙之处!Lucene 被设计成能够极度依赖和善用操作系统的文件系统缓存。倒排索引的各个部分(词典、Posting List 等)都是以特定格式的文件存储在磁盘上的。当 ES 需要查找一个词时,它会向操作系统请求读取索引文件。由于“热”的索引数据(经常被查询的数据)已经被操作系统自动缓存到了文件系统缓存中,这个“读”操作实际上是在访问内存,速度飞快。

数据其实还是存在于磁盘上的,只不过是热点数据或者是说查询过的数据就缓存到了系统内存当中,所以后续的查询效率就会增加。

  • 粒度精细: 缓存的是构成所有查询的基础“积木块”(词的索引信息),而不是成千上万种可能的查询组合。
  • 自动智能: 越是高频的词(比如 ,或者某个领域的热门词汇),它们对应的索引数据就越会“常驻”在内存中,从而让整个搜索系统的平均性能得到巨大提升。

此外,Elasticsearch 全文搜索能够工作的核心匹配原则:用同样的方式处理输入和查询。

第一步:存入数据(索引时)

  1. 收到文档: 你给 Elasticsearch 一个文档,比如 { "title": "我爱打篮球" }
  2. 分析和拆解: Elasticsearch 会查看 title 字段的设置,找到为它配置的分析器(Analyzer)。这个分析器(比如一个标准的中文分词器)接收到 “我爱打篮球” 这个字符串。
  3. 执行分词: 分析器将字符串拆解成一系列独立的词元(Tokens),也就是 ["我", "爱", "打", "篮球"]
  4. 写入倒排索引: Elasticsearch 不会在倒排索引里记录 “我爱打篮球” 这个原始字符串。它会为每一个词元建立索引,将它们分别指向这个文档的ID。最终在倒排索引里形成类似这样的记录:
    • -> [文档ID_1]
    • -> [文档ID_1]
    • -> [文档ID_1]
    • 篮球 -> [文档ID_1]

第二步:进行搜索(查询时)

  1. 收到查询: 你发起一个 match 查询,搜索 “我爱打篮球”。
  2. 分析和拆解(用同样的方式): match 查询是一种分析查询(Analyzed Query)。这意味着 Elasticsearch 会对你的查询词也执行完全相同的分析过程。它会用 title 字段在索引时用的那个同一个分析器,将查询词 “我爱打篮球” 也拆解成 ["我", "爱", "打", "篮球"]
  3. 匹配暗号: 现在,Elasticsearch 不再是去寻找一个叫 “我爱打篮球” 的大海捞针,而是拿着 ["我", "爱", "打", "篮球"] 这几个拆解后的词元,去倒排索引里查找。
    • 它找到所有包含 的文档。
    • 找到所有包含 的文档。
    • …以此类推。
    • 最后,它会计算哪些文档同时包含所有这些词元,并根据相关性评分,最终找到 文档ID_1 是最佳匹配。

ES 查询参数的区别

match 查询:智能的全文搜索

  • 核心功能: 这是进行全文搜索的标准和首选方式。它的目标是找到“相关的”文档,并计算相关性得分 _score
  • 是否分析查询词? 是! 这是它最重要的特点。它会使用字段在 Mapping 中指定的同一个分析器来分析你的查询词。
  • 工作流程:
    1. 你搜索 "华为 手机"
    2. match 查询拿到这个字符串,用分析器将它分词成 ["华为", "手机"]
    3. 然后它去倒排索引中查找同时包含这两个词元的文档。
  • 适用场景: 几乎所有的搜索框功能,用户输入的自然语言搜索。最适合用于 text 类型的字段。
  • 示例:
GET /products/_search
{
  "query": {
    "match": {
      "title": "华为 手机" 
    }
  }
}

term 查询:精确的、未经分析的匹配

  • 核心功能: 查找未经分析的、与你输入内容完全相等的词元。
  • 是否分析查询词? 否! 这是它与 match 最根本的区别。你给它什么,它就拿着什么去倒排索引里找一模一样的词元。
  • “陷阱”说明:
    • 假设一个 text 字段的内容是 "Apple iPhone",经过分析后,索引里存的词元是 ["apple", "iphone"](如果是keyword类型就没事了)。
    • 如果你用 term 查询 "Apple iPhone"绝对会失败,因为索引里根本没有 "Apple iPhone" 这个完整的词元。
    • 如果你用 term 查询 "Apple",也可能会失败,因为索引里的词元是小写的 "apple"term 查询是区分大小写的。
  • 适用场景: 它几乎不用于 text 字段。它被广泛用于精确值的过滤,比如 keyword(标签、ID)、numeric(数字)、boolean(布尔值)和 date 类型的字段。我们之前在 filter 上下文中用的就是它。
  • 示例:
GET /products/_search
{
  "query": {
    "term": {
      "tags": "卫星通话"  // 假设 tags 是 keyword 类型
    }
  }
}

prefix 查询:“以…开头”的查询

  • 核心功能: 查找以你指定的前缀开头的词元。
  • 是否分析查询词? 否! 它将你输入的字符串作为一个完整的前缀。
  • 适用场景: 用于需要“前缀匹配”的场景,比如商品编码、URL、或者简单的输入提示。通常用于 keyword 类型的字段。
  • 示例: 查找所有 product_id 以 “A-123” 开头的商品。
GET /products/_search
{
  "query": {
    "prefix": {
      "product_id": "A-123" 
    }
  }
}

wildcard 查询:通配符查询

  • 核心功能: 允许你使用 (匹配任意多个字符) 和 ? (匹配一个字符)来进行模式匹配。
  • 是否分析查询词? 否! 它将你输入的带通配符的模式作为一个整体进行匹配。
  • 适用场景: 当你不确定部分内容时进行搜索。但因其性能问题,应谨慎使用。
  • 性能警告: 这个查询性能通常较差,因为它需要遍历词典中大量的词元来寻找匹配项。尤其要避免将通配符 放在开头,那将是性能灾难。
  • 示例:
GET /products/_search
{
  "query": {
    "wildcard": {
      "title.raw": "华? Ma*e" // 注意:通常对不分词的 .raw 字段使用
    }
  }
}

总结对比表

查询类型 是否分析查询词? 主要用途 适用字段类型 性能
match 全文搜索、相关性搜索 text
term 精确值过滤、完全匹配 keyword, numeric, boolean 非常高
prefix “以…开头”的查询 keyword 中等
wildcard 通配符模式匹配 keyword (慎用)

关于 prefixwildcard 的区别,以及为什么 Elasticsearch 要单独提供一个 prefix 查询呢?

  • prefix 是专门用于前缀匹配的。
  • wildcard 功能更强大,可以进行任意位置的模糊匹配(前缀、后缀、中间)。

功能效果上来看,使用 wildcard 来做前缀匹配(如 "abc*") 和使用 prefix 查询(如 "abc") 得到的结果是一模一样的。

那为什么我们还要用 prefix 呢?

因为即使是在做完全相同的前缀匹配工作时,prefix 的效率也比 wildcard 更高。

我们可以用一个“工具箱”的比喻来理解这个性能差异:

  • prefix 查询 就像一把 专门用来拧六角螺丝的、尺寸完全匹配的扳手。它的设计目标就是做这一件事,因此它的结构简单、贴合度高、发力直接,效率是最高的。
  • wildcard 查询 就像一把 可以调节开口大小的活动扳手。它非常强大,不仅能拧六角螺丝,还能拧四角螺丝,甚至一些不规则形状的螺母。但是,当你用它来拧那个标准的六角螺丝时,你需要先调节开口、确保卡紧,整个操作过程和内部机械结构总会比专用扳手要稍微复杂和慢一点。

深入到技术层面,为什么会这样?

Elasticsearch 底层的 Lucene 搜索引擎对这两种查询的处理方式不同:

  1. prefix 的执行路径(专用通道):Lucene 内部的词典是按字母顺序排序的。当它收到一个 prefix 查询时,它有一个高度优化的“捷径”可走。它可以非常快地定位到词典中这个前缀的起始位置,然后简单地顺序向后扫描,直到不符合前缀为止。 这是一个非常直接、开销很小的操作。
  2. wildcard 的执行路径(通用通道):当 Lucene 收到一个 wildcard 查询时,它会先将这个通配符模式(即使是简单的 "abc*")编译成一个更通用的内部状态机(Automaton)。 然后用这个状态机去匹配词典中的词元。虽然对于 "abc*" 这个简单的模式,最终效果和 prefix 一样,但启动和运行这个更“通用”和“强大”的状态机引擎,本身就会带来一点额外的计算开销。

queryfilter 的区别

  1. 核心目的:相关性评分 (Scoring) vs. 是/否 (Yes/No)

    query 上下文 (Query Context)

    • 计算相关性分数 (_score)query 的主要任务是判断文档与查询的匹配程度,并计算出一个浮点数类型的 _score。分数越高,代表文档越相关。比如,搜索“篮球鞋”,标题中出现 3 次“篮球鞋”的文档会比只出现 1 次的文档得分更高。
    • 用于全文搜索:当你需要进行全文搜索,并希望最相关的结果排在最前面时,就应该使用 query。典型的例子是网站的搜索框。
    • 常见子句match, multi_match, query_string 等。

    filter 上下文 (Filter Context)

    • 不计算分数filter 只关心“是”或“否”的匹配,它不会计算 _score。对于 filter 来说,所有匹配的文档都是平等的,没有哪个更“好”。
    • 用于精确匹配:当你需要根据精确值进行筛选时,就用 filter。例如,“产品分类是否为‘电子产品’?”、“价格是否小于 100 元?”、“创建日期是否在 2024 年之后?”。
    • 常见子句term, terms, range, exists, bool 查询中的 filter 部分。
  2. 性能与缓存 (Performance & Caching)

    这是两者在技术实现上最重要的区别,直接影响查询性能。

  • filter:非常高效,且可以被缓存
    • 由于 filter 只做简单的“是/否”判断,其计算成本远低于 query
    • 可以被高频缓存:Elasticsearch 会自动缓存 filter 的结果。它会生成一个包含匹配文档的位集(bitset),这个位集可以被存储在内存中(我们之前讨论的 JVM 堆内存中的 Filter Cache)。当下一个查询包含完全相同的 filter 条件时,ES 会直接重用这个缓存的位集,而无需再次扫描倒排索引。这使得 filter 的速度极快。
  • query:计算昂贵,通常不被缓存
    • 相关性分数的计算是一个复杂的过程(涉及 TF/IDF、BM25 等算法),非常消耗 CPU 资源。
    • 由于每次查询的上下文都可能不同,导致分数也不同,所以 query 的结果通常不会被缓存。每一次执行 query 都需要实时计算。
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "description": "轻便 跑鞋" } }  // <--- Query 上下文,需要计算相关性
      ],
      "filter": [
        { "term": { "brand": "Nike" } },             // <--- Filter 上下文,精确匹配,可缓存
        { "range": { "price": { "gte": 500 } } }    // <--- Filter 上下文,范围匹配,可缓存
      ]
    }
  }
}

简单来说,就是“先应用 filter 查询,过滤出来的结果再使用 query 查询”,它会优先执行 filter 部分,利用缓存快速缩小文档范围,然后再对这个小范围的文档执行 query 部分来进行复杂的相关性计算。

让我们把 bool 查询想象成一个可以组合不同逻辑条件的容器,它主要有**四种工具(子句)**供您使用:

  1. must (必须匹配)
  2. filter (必须匹配,但更快)
  3. should (可以匹配)
  4. must_not (必须不匹配)

您可以根据您的需求,任意选用和组合它们。下面我们看几个例子,您就会明白它的灵活性了。


场景1:纯粹的筛选,不需要相关性得分

需求: 找到所有品牌是 “Nike” 并且 价格低于 800 元的商品。

在这里,我们不需要评估“有多好”,只需要一个“是/否”的列表。所以,我们只用 filter

GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term":  { "brand": "Nike" } },
        { "range": { "price": { "lt": 800 } } }
      ]
    }
  }
}

分析: 这个查询效率极高,因为它完全运行在“小筛”(Filter)模式下,可以充分利用缓存。


场景2:纯粹的相关性搜索

需求: 找到描述里同时包含 “夏季”、“透气”和“跑鞋”的商品,并按相关性排序。

在这里,我们不关心硬性指标,只关心匹配度。所以,我们只用 must

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "description": "夏季" } },
        { "match": { "description": "透气" } },
        { "match": { "description": "跑鞋" } }
      ]
    }
  }
}

分析: 这个查询会为所有匹配的文档计算一个 _score


场景3:包含 “或者” (OR) 逻辑的搜索

需求: 寻找品牌是 “Nike” 或者 “Adidas” 的商品。

“或者”逻辑,我们使用 should。当 bool 查询中只有 should 子句时,它表示至少要满足其中一个条件。

GET /products/_search
{
  "query": {
    "bool": {
      "should": [
        { "term": { "brand": "Nike" } },
        { "term": { "brand": "Adidas" } }
      ],
      "minimum_should_match": 1 // 表示should中的条件至少要满足1个
    }
  }
}

场景4:包含 “不包含” (NOT) 逻辑的搜索

需求: 寻找所有 “跑鞋”,但不能是 “Nike” 品牌的。

这里我们组合使用 mustmust_not

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "category": "跑鞋" } }
      ],
      "must_not": [
        { "term": { "brand": "Nike" } }
      ]
    }
  }
}

分析: must_not 也运行在 filter 上下文中,因为它不计算分数,所以效率也很高。

分析器 (Analyzer)是什么

工作原理:它的工作是把一段原始的、杂乱的文本,加工成一系列规整、统一的、适合搜索的最小单元——词元 (Token)

固定的流程分别是:

  1. 字符过滤器 (Character Filter) - 预处理,负责“净化”
  2. 分词器 (Tokenizer) - 核心工序,负责“拆分”
  3. 词元过滤器 (Token Filter) - 后处理,负责“美化”

原始文本: <p>The 2 QUICK Brown-Foxes jump over the lazy dog's bone.</p>


工序 1:字符过滤器 (Character Filter)

它的任务是在文本被拆分之前,对原始字符串进行清理和替换。你可以配置零个或多个字符过滤器。

  • 常见功能:
    • 去除 HTML 标签 (e.g., <p>, <b>)。
    • 替换特殊字符 (e.g., & 替换成 and)。
    • 自定义字符映射 (e.g., 将 :) 替换成 _happy_)。

经过【HTML标签去除过滤器】处理后,文本变为:The 2 QUICK Brown-Foxes jump over the lazy dog's bone.


工序 2:分词器 (Tokenizer)

这是流水线的核心,它负责将经过“净化”的连续字符串,按照一定的规则拆分成独立的词元(Tokens)。一个分析器里必须有且只能有一个分词器。

  • 常见分词器:
    • Standard Tokenizer:默认的分词器,按词的边界(如空格、标点符号)来拆分,对大多数西方语言效果很好。
    • Whitespace Tokenizer:只按空格来拆分。
    • Letter Tokenizer:只保留字母,遇到非字母字符就拆分。
    • ik_analyzer:一个非常流行的开源中文分析器插件,需要单独安装。它基于词典进行分词,支持自定义词典和停用词典,分词粒度更灵活(有 ik_smartik_max_word 两种模式)

经过【Standard Tokenizer】处理后,我们得到一个词元流:[ The, 2, QUICK, Brown-Foxes, jump, over, the, lazy, dog's, bone ](注意:Brown-Foxesdog's 此时还保持原样)


工序 3:词元过滤器 (Token Filter)

它的任务是在词元被拆分之后,对每一个词元进行修改、添加或删除。你可以配置零个或多个词元过滤器,它们会按顺序执行。

  • 常见功能:
    • Lowercase: 将所有词元转为小写。
    • Stop Words: 移除常见的、对搜索意义不大的“停用词”(如 the, a, is)。
    • Stemming: 将词语简化为它的词干(e.g., running, ran 都变成 run)。
    • Synonym: 添加同义词 (e.g., 遇到 quick,可以额外添加一个 fast 词元)。

让我们依次应用两个词元过滤器:

  1. 经过【Lowercase Token Filter】处理后,词元流变为:[ the, 2, quick, brown-foxes, jump, over, the, lazy, dog's, bone ]
  2. 经过【Stop Words Token Filter】处理后,词元流变为:[ 2, quick, brown-foxes, jump, over, lazy, dog's, bone ](两个 the 被移除了)

最终,被写入倒排索引的就是这个最终的词元流。

用 IK 分析器时,遇到英文会怎样?

当您为一个字段指定了 ik_analyzer,那么无论这个字段里是中文、英文还是数字,都会由 ik_analyzer 来全权处理。它不会自动切换到 standard 分析器。

那么,IK 是如何处理英文的呢?

它的处理逻辑通常是:

  1. 遇到中文字符: 启用它的核心算法,根据内置的中文词典进行最大匹配或最细粒度的分词。
  2. 遇到英文字符或数字: 它会切换到一种类似于 standard 分析器的模式。它会把连续的英文字母或数字识别为一个完整的词元(Token),然后根据空格和标点符号进行切分,并且通常会执行转小写的操作。

举个例子:

如果你的文本是 "IK分析器 very good"

使用 ik_analyzer 分析后,得到的词元流会是:
[ik, 分析器, very, good]

你可以看到,它既正确地处理了中文的“分析器”,也正确地处理了英文的“ik”、“very”和“good”。

如何为中英文提供各自最优的分析?

虽然 IK 能很好地处理英文,但它毕竟不是专门为英文设计的(比如它缺少英文中很重要的词干提取/Stemming功能,不能把 runningrun 视为同一个词)。

如果想达到极致的效果,最优的方案是使用 Multi-fields (多字段)

您可以在字段映射(Mapping)中这样设置:

PUT /my_index
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word", 
        "fields": {
          "english": {
            "type": "text",
            "analyzer": "english" 
          }
        }
      }
    }
  }
}

这个设置意味着:

  • title 字段本身使用 ik_max_word 分析器,最适合处理中文和基本的中英文混合搜索。
  • 同时,ES 会额外创建一个 title.english 子字段,这个子字段专门使用 english 分析器(ES内置的、带词干提取等高级功能的英文分析器-Standard Tokenizer)来处理同样的内容。

这样使用:

  • 当你的用户主要用中文搜索时,你查询 title 字段。
  • 当你判断用户的查询是英文时,你可以去查询 title.english 字段。

索引设计讲解

Mapping 就是索引的“模式定义 (Schema)”,它告诉 Elasticsearch 如何处理和索引你文档中的每一个字段。一个精心设计的 Mapping 能带来诸多好处:

  • 节省存储空间
  • 提升索引和查询性能
  • 确保查询的准确性

场景:一个电商商品 (product) 文档

我们先看一个商品文档的例子:

{
  "product_id": "A-123-B-456",
  "title": "华为 Mate 60 Pro 智能手机",
  "description": "这是华为最新款的旗舰手机,拥有强大的卫星通话功能和拍照能力。",
  "price": 6999.00,
  "tags": ["新款", "旗舰", "卫星通话"],
  "on_sale": true,
  "created_at": "2024-09-10 10:00:00",
  "stock_info": {
    "warehouse_id": "WH-SH-01",
    "quantity": 500
  },
  "reviews": [
    { "username": "Alice", "rating": 5, "comment": "太棒了!" },
    { "username": "Bob", "rating": 4, "comment": "信号很好。" }
  ]
}

如果让 ES 自动创建 Mapping (Dynamic Mapping),它能工作,但绝不是最优的。下面是我们手动设计的最佳实践 Mapping

最佳实践 Mapping (product 索引)

PUT /products
{
  "mappings": {
    "properties": {
      "product_id": {
        "type": "keyword" 
      },
      "title": {
        "type": "text", 
        "analyzer": "ik_max_word",
        "fields": {
          "raw": {
            "type": "keyword" 
          }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_max_word",
        "index": true 
      },
      "price": {
        "type": "scaled_float", 
        "scaling_factor": 100
      },
      "tags": {
        "type": "keyword" 
      },
      "on_sale": {
        "type": "boolean" 
      },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" 
      },
      "stock_info": {
        "type": "object",
        "enabled": false 
      },
      "reviews": {
        "type": "nested", 
        "properties": {
          "username": { "type": "keyword" },
          "rating": { "type": "byte" },
          "comment": { "type": "text", "analyzer": "ik_smart" }
        }
      }
    }
  }
}

逐个解释设计思想

1. product_id: keyword

  • 为什么是 keyword? ID、编码、UUID 这类数据,我们只会用它进行精确匹配 (term 查询),而绝不会进行分词搜索或范围查询。keyword 类型专门用于此目的,它将整个 “A-123-B-456” 视为一个不可分割的词元,效率远高于 text。即使它是纯数字,用 keyword 也比用 integer/long 更合适,因为它不参与数学计算。

2. title: text + fields (多字段)

  • 为什么是 text? title 是用来给用户进行全文搜索的,所以必须是 text 类型,并为其指定一个合适的分词器,比如 ik_max_word
  • 简单来说:
    • 进行分词搜索 (通过 text 类型的主字段 title)
    • 又能进行完整字符串的排序和分组 (通过 keyword 类型的子字段 title.raw)
  • 为什么要有 fields.raw? 这是一个极其重要的最佳实践。我们经常有这样的需求:对标题进行精确的聚合 (Aggregation)排序 (Sorting)。但 text 字段分词后无法完成这些操作。因此,我们通过 fieldstitle 额外创建了一个名为 rawkeyword 类型的子字段。这样:
    • title 字段:进行全文搜索。
    • title.raw 字段:进行精确聚合或排序。

3. description: text

  • 为什么是 text?title 类似,用于全文搜索。
  • 为什么要有 "index": true? index 的默认值就是 true,这里显式写出来是为了强调。如果某个字段你永远不会用作查询条件,可以设置为 false 来节省空间和提高写入速度。但 description 通常需要被搜索,所以保持 true

4. price: scaled_float

  • 为什么是 scaled_float? 对于价格、汇率等需要精确计算的浮点数,标准的 floatdouble 类型可能会有精度问题。scaled_float 是处理货币的最佳选择。它通过一个 scaling_factor (缩放因子),将浮点数乘以这个因子后,作为整数存储。这里 100 表示保留两位小数,6999.00 会被存储为 699900,完全避免了浮点数精度陷阱。

5. tags: keyword

  • 为什么是 keyword? 标签是原子的,不可分的。我们希望对 “新款”、”旗舰” 这样的标签进行精确过滤和聚合,keyword 是不二之选。ES 会自动处理 keyword 类型的数组。

6. on_sale: boolean

  • 为什么是 boolean? 用于表示 truefalse,最直接,存储和过滤效率也最高。

7. created_at: date

  • 为什么是 date? 专门用于处理日期时间,支持丰富的日期格式和强大的日期范围查询。
  • 为什么要有 format? 显式指定日期格式,可以加速日期的解析。|| 表示“或”,意味着它既能解析 "yyyy-MM-dd HH:mm:ss" 格式的字符串,也能解析毫秒级时间戳,增强了兼容性。

8. stock_info: object with "enabled": false

  • 为什么 "enabled": false? 这是一个关键的性能优化。假设 stock_info 这个对象我们只用来展示,从不按 warehouse_idquantity 进行搜索。那么设置 "enabled": false 就会告诉 ES:“不要为这个对象里的任何字段创建倒排索引”。这会节省大量的存储空间和索引开销。

9. reviews: nested

  • 为什么是 nested 而不是 object? 这是另一个极其重要的概念。默认的 object 类型会“压平”数组对象,导致内部字段的关联性丢失。例如,reviews 数组会被压平成:

    "reviews.username": ["Alice", "Bob"], "reviews.rating": [5, 4]

    这时如果你查询“用户是 Alice 并且评分是 4 的评论”,ES 会错误地返回 true,因为它只知道 Alice4 这两个值同时存在于数组中,但不知道它们是否属于同一条评论。

  • nested 类型通过为每个数组元素创建独立的隐藏文档来解决这个问题,完美地保留了对象内部字段之间的关联性,让你可以进行精确的内部查询。

写入(索引)性能优化

想象一个场景:您需要将一个包含 1 亿条商品数据的数据库,首次同步到 Elasticsearch 中。如果一条一条地写入,可能需要几天几夜。但通过优化,我们可能将这个时间缩短到几小时甚至几十分钟。

以下是提升写入性能的几个关键手段,按照重要性排序:


1. 使用 Bulk API (批量写入)

  • 这是最最最重要的一条,没有之一。
  • 原理: 每次 HTTP 请求都有网络开销和处理开销。Bulk API 允许你在一次请求中,打包成百上千个独立的 index, create, update, delete 操作。这极大地减少了网络往返次数和请求开销,性能提升是数量级的。
  • 最佳实践:
    • 合理的批次大小: 批次不是越大越好。太大会消耗大量内存,给集群带来压力。一个好的起点是每次批量处理 1000 到 5000 个文档,或者批次的总大小在 5MB 到 15MB 之间。你需要通过实验找到最适合你数据和集群配置的“黄金尺寸”。
    • 并发请求: 如果机器资源充足,可以开启多个线程或进程,同时向 ES 发送 Bulk 请求。但要注意,并发数过多可能会导致 ES 拒绝请求(HTTP 429 错误),需要有相应的重试机制。

2. 优化索引刷新策略 (Refresh Interval)

  • 原理: 之前我们提到,新写入的数据需要经过一次 refresh 操作才能被搜索到。默认情况下,这个操作是每秒执行一次 ("index.refresh_interval": "1s")。在海量数据写入时,每秒一次的 refresh 会产生大量的 I/O 操作和新的分段(Segment),严重拖慢写入速度。
  • 最佳实践:
    • 在开始批量导入数据之前,临时将刷新间隔调大,甚至禁用它。

      PUT /my_index/_settings
      {
        "index": {
          "refresh_interval": "60s" 
        }
      }
      // 或者直接禁用
      PUT /my_index/_settings
      {
        "index": {
          "refresh_interval": "-1"
        }
      }
      
    • 数据全部写入完成后,再将刷新间隔调回默认值 1snull

3. 临时禁用或减少副本 (Number of Replicas)

  • 原理: 每写入一个文档到主分片,这个文档也必须被复制到所有的副本分片上。如果有 2 个副本,那么一次写入操作实际上会触发 3 次(1 主 + 2 副)真实的物理写入。这个复制过程是同步的,会阻塞写入请求的返回。
  • 最佳实践:
    • 对于全新的索引进行首次数据导入时,可以先将副本数设置为 0。

      PUT /my_index/_settings
      {
        "index": {
          "number_of_replicas": 0
        }
      }
      
    • 数据全部写入完成后,再将副本数调回到你期望的值(比如 1 或 2)。

      PUT /my_index/_settings
      {
        "index": {
          "number_of_replicas": 1
        }
      }
      

      ES 会在后台自动开始复制数据,这个过程不会影响你后续的操作。

4. 优化磁盘 I/O 和分段合并 (Translog & Merging)

  • 原理: ES 为了保证数据不丢失,会先将数据写入一个叫 translog 的事务日志中。默认情况下,每次请求都会触发一次 fsync,确保 translog 的数据被刷到磁盘上,这个操作比较耗时。
  • 最佳实践(谨慎使用):
    • 可以将 translog 的刷盘策略从 request(每次请求)改为 async(异步),并增加刷盘间隔。

      PUT /my_index/_settings
      {
        "index": {
          "translog.sync_interval": "30s",
          "translog.durability": "async"
        }
      }
      
    • 警告: 这个设置牺牲了数据的安全性。如果在异步刷盘的间隔内节点宕机,可能会丢失一部分数据。所以,它只适用于可以接受少量数据丢失的场景,比如重新导入日志数据。数据导入完成后,务必改回默认值。

总结:批量导入数据的黄金流程

  1. 创建索引: 设计好 Mapping,将 number_of_replicas 设置为 0
  2. 修改设置:refresh_interval 设置为 1。如果能接受风险,可以调整 translog 设置。
  3. 执行写入: 使用多线程和 Bulk API 并发地写入数据,并监控集群状态。
  4. 恢复设置: 数据全部写入完成后,将 number_of_replicasrefresh_interval 恢复到它们的正常值。

查询性能优化

1. 避免深度分页 (Deep Pagination)

  • 问题是什么? 在关系型数据库中,LIMIT 10000, 10 这样的查询可能依然很快。但在分布式系统中,这会成为一个灾难。ES 中使用 fromsize 的分页方式,当你请求 from: 10000, size: 10 时,ES 必须:
    1. 每个分片上都找出前 10010 (from + size) 个文档。
    2. 将所有分片的结果(比如 5 个分片 * 10010 条 = 50050 条数据)汇集到协调节点。
    3. 协调节点对这 50050 条数据进行重新排序
    4. 最后,从排序后的结果中,丢弃前 10000 条,返回第 10001 到 10010 条。
      这个过程随着 from 值的增大,消耗的内存和 CPU 会急剧增加,非常低效。
  • 解决方案:
    • search_after: 这是官方推荐的、用于深度分页或实时滚动加载的解决方案。它的工作方式是,利用上一页最后一条数据的信息来“定位”下一页的开始位置。它不关心你要跳过多少页,只关心“从哪里开始”。这使得它非常高效,因为每个分片只需要返回 size 数量的文档。
    • scroll API: 适用于需要处理全部查询结果的场景,比如数据导出或数据迁移。它会创建一个查询上下文的“快照”,然后你可以像迭代器一样逐批拉取数据。它不适合用于实时的用户界面分页。

2. 只请求必要的字段 (_source Filtering)

  • 原理: 默认情况下,ES 会返回完整的 _source 字段,也就是你存入的整个 JSON 文档。如果你的文档很大,而前端展示只需要其中几个字段,那么传输这些不必要的数据会浪费网络带宽,并增加 ES 节点的序列化开销。

  • 最佳实践:

    • 在查询时,明确指定你需要的字段。

      GET /products/_search
      {
        "_source": ["product_id", "title", "price"],
        "query": { "match": { "title": "手机" } }
      }
      
    • 如果你完全不需要 _source(比如只关心聚合结果),可以将其禁用:"_source": false

    3. 警惕脚本查询 (Script Queries)

    • 问题是什么? ES 允许你使用脚本(通常是 Painless 语言)来执行非常灵活的查询和聚合。但脚本是在查询时动态执行的,无法利用索引,并且通常比原生查询慢得多。
    • 最佳实践:
      • 能不用就不用。 优先考虑通过改进 Mapping 或在索引时预处理数据来满足需求。
      • 如果必须用,确保脚本尽可能简单高效,并测试其性能影响。

总结一下查询优化的核心思想:

  1. 数据说话: 优先处理数据量大的部分。先用 filter 快速过滤掉绝大部分无关数据。
  2. 避免浪费: 不做多余的工作。避免深度分页的巨大开销,只请求必要的字段。
  3. 善用索引: 确保你的查询能最大化地利用倒排索引的优势,避免全索引扫描。

脑裂 (Split-Brain)

我们先用一个简单的比喻来理解。

想象一个公司只有一个 CEO,所有决策都由他做出,公司井井有条。

突然有一天,公司总部大楼中间的电话线和网络断了,把公司分成了两半(比如A座和B座)。

  • A座的员工联系不上CEO,等了一会儿,他们以为CEO出事了,于是他们从A座的管理层里选举出了一个新的CEO
  • 与此同时,B座的员工和原来的CEO在一起,他们继续正常工作。

现在,这个公司就出现了两个CEO。A座的CEO开始下达指令,B座的CEO也在下达指令,这两个指令可能是完全冲突的。整个公司陷入了混乱,决策不一,数据错乱。

这就是“脑裂”。

在 Elasticsearch 集群中,“CEO” 就是 主节点 (Master Node)。它的职责是管理集群状态,比如创建索引、分配分片、维护节点列表等

脑裂 (Split-Brain) 指的是,由于网络故障(比如交换机故障、网络分区),一个集群被分割成了两个或多个互不通信的“孤岛”。这时,每个“孤岛”都以为对方下线了,于是各自选举出了自己的主节点

最终的结果就是,一个集群内同时存在了多个主节点。这会导致灾难性的后果:

  • 数据不一致: 不同的“孤岛”接收了不同的写请求,数据开始走向不同的“分支”,造成数据丢失或冲突。
  • 集群状态错乱: 每个主节点都在管理分片,可能会做出完全相反的决策。
  • 无法恢复: 一旦网络恢复,这些拥有不同数据和状态的“孤岛”无法自动合并,需要人工干预,过程极其复杂且可能导致数据丢失。

如何防止脑裂?(Quorum/法定人数)

防止脑裂的核心思想很简单:一个决策(比如选举CEO),必须得到“大多数人”的同意才能生效。

这个“大多数人”,在分布式系统中被称为 Quorum (法定人数)

在 Elasticsearch 中,这个机制通过一个关键配置来实现。

  • ES 7.0 之前: 需要手动设置一个参数 discovery.zen.minimum_master_nodes
  • ES 7.0 及之后: 这个过程被极大地简化和自动化了,不再需要手动设置上述参数,但其背后的原理依然是 Quorum。

Quorum 的工作原理:

选举主节点或任何一个主节点要做的决策,都必须获得超过半数的主节点候选人(master-eligible nodes)的投票。

这个“法定人数”的计算公式是:

Quorum = (master_eligible_nodes/2) + 1

假设我们有一个由 3个 有主节点资格的节点(Node-1, Node-2, Node-3)组成的集群。

根据公式,法定人数 Quorum = (3 / 2) + 1 = 1 + 1 = 2

现在,网络发生故障,Node-1 单独成为了一个“孤岛”,Node-2 和 Node-3 在另一个“孤岛”。

  • 孤岛1 (Node-1): 它只有一个节点,数量 1 小于法定人数 2。因此,它无权选举新的主节点,它只能下线自己,静静地等待网络恢复。
  • 孤岛2 (Node-2, Node-3): 它们有两个节点,数量 2 等于法定人数 2。因此,它们有权在它们之间选举出一个新的主节点(比如 Node-2)。

结果: 整个集群在任何时候都只有一个合法的主节点(Node-2)。当网络恢复后,Node-1 会自动发现已经存在主节点,并作为从节点加入回去。脑裂被成功避免了!

集群引导 (Cluster Bootstrapping)

“集群引导”是一个仅在集群生命周期中发生一次的初始化过程。

这个过程是如何工作的?

当你准备启动一个全新的、由多个节点(比如 Node-A, Node-B, Node-C)组成的 ES 集群时,你需要在每一个主节点候选人的配置文件 (elasticsearch.yml) 中,明确地列出这个初始“三人小组”的成员。

这个配置项就是:cluster.initial_master_nodes

  1. 在 Node-A 的 elasticsearch.yml 中:

    # ======================== Cluster ========================
    # 为你的集群设置一个唯一的名字
    cluster.name: my-first-cluster
    
    # ======================== Node =========================
    # 为你的节点设置一个唯一的名字
    node.name: Node-A
    
    # ======================== Network ======================
    # 绑定节点的IP地址
    network.host: 192.168.1.10
    
    # ====================== Discovery ======================
    # 启动新集群时的初始主节点候选人列表 (只在第一次启动时使用)
    cluster.initial_master_nodes: ["Node-A", "Node-B", "Node-C"]
    
    # 用于节点发现的种子主机列表 (集群运行起来后使用)
    discovery.seed_hosts: ["192.168.1.10", "192.168.1.11", "192.168.1.12"]
    
  2. 在 Node-B 的 elasticsearch.yml 中:

    # ======================== Cluster ========================
    # 为你的集群设置一个唯一的名字
    cluster.name: my-first-cluster
    
    # ======================== Node =========================
    # 为你的节点设置一个唯一的名字
    node.name: Node-B
    
    # ======================== Network ======================
    # 绑定节点的IP地址
    network.host: 192.168.1.11
    
    # ====================== Discovery ======================
    # 启动新集群时的初始主节点候选人列表 (只在第一次启动时使用)
    cluster.initial_master_nodes: ["Node-A", "Node-B", "Node-C"]
    
    # 用于节点发现的种子主机列表 (集群运行起来后使用)
    discovery.seed_hosts: ["192.168.1.10", "192.168.1.11", "192.168.1.12"]
    
  3. 在 Node-C 的 elasticsearch.yml 中:

    # ======================== Cluster ========================
    # 为你的集群设置一个唯一的名字
    cluster.name: my-first-cluster
    
    # ======================== Node =========================
    # 为你的节点设置一个唯一的名字
    node.name: Node-C
    
    # ======================== Network ======================
    # 绑定节点的IP地址
    network.host: 192.168.1.12
    
    # ====================== Discovery ======================
    # 启动新集群时的初始主节点候选人列表 (只在第一次启动时使用)
    cluster.initial_master_nodes: ["Node-A", "Node-B", "Node-C"]
    
    # 用于节点发现的种子主机列表 (集群运行起来后使用)
    discovery.seed_hosts: ["192.168.1.10", "192.168.1.11", "192.168.1.12"]
    

启动时会发生什么?

  1. 当你启动这三个节点时,它们会读取这个 cluster.initial_master_nodes 列表。
  2. 它们会尝试与列表中的其他成员建立联系。
  3. 一旦它们发现彼此,并且确认当前在线的节点数量达到了这个初始列表的法定人数 (Quorum)(在这个例子里是 (3/2)+1=2),它们就会开始进行第一次主节点选举。
  4. 选举成功后(比如 Node-A 当选为 Master),第一个健康的集群就正式形成了。

最关键的一步:“记住”与“抛弃”

一旦集群成功形成,这个 cluster.initial_master_nodes 配置的历史使命就完成了

  • “记住”: 新当选的主节点会把当前集群的成员列表(包含所有节点的详细信息)写入到集群状态 (Cluster State) 中。这个状态会被同步到所有节点上并持久化。从此以后,集群就拥有了自己“记忆”,它知道自己有哪些成员。
  • “抛弃”: 从此刻起,所有节点都会忽略 elasticsearch.yml 文件中的 cluster.initial_master_nodes 配置。即使你重启节点,它也不会再去看这个配置了。它会信任持久化在自己磁盘上的那个“记忆”(集群状态),并通过 discovery.seed_hosts 配置去寻找已知的其他成员。

为什么这个机制如此重要?

  1. 安全性: 因为这个配置只在第一次启动空集群时生效,所以它能有效地防止你意外地用它来初始化一个已经有数据的、正在运行的集群,从而避免灾难。如果你在一个已经有数据的节点上设置了这个参数并启动,ES 会报错并拒绝启动,保护你的数据。
  2. 自动化: 它为集群提供了一个安全的“起点”。一旦集群启动并运行起来,后续的成员管理(比如节点的加入、离开、主节点重选)就都进入了我们之前讨论的全自动 Quorum 模式,不再需要人工干预。

后续进来新的节点会发生什么?

后续添加的所有节点已经不属于集群引导了,而是属于节点发现。现在在已经存在的由 A、B、C 组成的集群中添加 Node-D,正确的操作如下:

  1. 准备 Node-D 的配置文件 (elasticsearch.yml)

    • 不要设置 cluster.initial_master_nodes 这个参数是新集群的“出生证明”,而 Node-D 是要加入一个已经“成年”的集群,所以用不上。
    • 设置 discovery.seed_hosts:这个配置项告诉 Node-D 去哪里“敲门”。你需要在这里填上现有集群中部分或全部节点的地址。
    • 确保 cluster.name 与现有集群完全一致。

    Node-D 的 elasticsearch.yml 应该长这样:

    # ======================== Cluster ========================
    # 为你的集群设置一个唯一的名字
    cluster.name: my-first-cluster
    
    # ======================== Node =========================
    # 为你的节点设置一个唯一的名字
    node.name: Node-D
    
    # ======================== Network ======================
    # 绑定节点的IP地址
    network.host: 192.168.1.13
    
    # ====================== Discovery ======================
    # 用于节点发现的种子主机列表 (集群运行起来后使用)
    discovery.seed_hosts: ["192.168.1.10", "192.168.1.11", "192.168.1.12"]
    
  2. 启动 Node-D

    直接启动 Node-D 的 Elasticsearch 服务即可。

启动后发生了什么?(自动的“发现”过程)

  1. Node-D 启动,读取自己的配置,发现 discovery.seed_hosts 中有三个地址。
  2. 它会尝试联系这几个地址上的节点。
  3. 比如,它成功联系上了 Node-A(当前的主节点)。
  4. Node-D 会向 Node-A 发送“加入请求”,并报上自己的集群名 (cluster.name)。
  5. 主节点 Node-A 验证通过后,会欢迎 Node-D 加入集群,并更新集群状态 (Cluster State),将 Node-D 的信息添加进去。
  6. 最后,主节点 Node-A 会将这个包含了新成员的、最新的集群状态广播给集群里的所有节点(包括刚加入的 Node-D)。

至此,Node-D 就成功地、平滑地成为了集群的一员。整个过程完全不需要重启或修改现有的 Node-A, B, C。

过程 集群引导 (Bootstrapping) 节点发现 (Discovery)
目的 从零开始,选举第一个 Master 已存在的集群添加新节点
时机 集群生命周期中仅一次 随时发生
关键配置 cluster.initial_master_nodes discovery.seed_hosts

主节点挂掉重启的过程中,会发生什么?

会的。在 Node-A(主节点)从关闭到重启完成的这段时间里,只要剩余节点满足法定人数 (Quorum),集群就会发起一次新的主节点选举。

整个过程是全自动的,目的就是为了保证集群的服务尽可能不中断。我们来分解一下这个过程的时间线:


主节点重启期间的事件时间线

假设我们有一个由 Node-A (Master), Node-B, Node-C 组成的健康集群。

第 1 步:Node-A (主节点) 开始重启

  • 您在 Node-A 的服务器上执行了重启命令。Node-A 进程关闭,与集群中的其他节点断开连接。

第 2 步:主节点故障被检测

  • 集群中的其他节点(Node-B 和 Node-C)会定期地对主节点进行“心跳检测”(Health Check)。
  • 当它们连续几次都无法收到来自 Node-A 的心跳时,它们就会判定:“我们的主节点失联了!”

第 3 步:触发新一轮选举

  • 此时,Node-B 和 Node-C 发现集群中没有主节点了。
  • 它们会立即检查当前还存活的、有主节点资格的节点数量是否满足法定人数 (Quorum)
    • 在我们的例子中,总共有 3 个主节点候选人,法定人数是 (3/2) + 1 = 2
    • 现在还剩下 Node-B 和 Node-C,数量是 2,满足法定人数
  • 因为满足法定人数,它们就立即开始一轮新的选举。

第 4 步:新的主节点诞生

  • 在 Node-B 和 Node-C 之间,通过选举算法,会有一个胜出者。我们假设 Node-B 被选举为新的主节点
  • 一旦选举成功,Node-B 就会接管主节点的所有职责,开始管理整个集群。
  • 关键点: 从 Node-A 宕机到 Node-B 上位,这个选举过程通常非常快(秒级)。在这段短暂的“权力真空期”,集群可能无法处理需要主节点协调的写操作(比如创建索引),但对于数据的读写操作基本不受影响,因为数据分片(Data Shards)仍然在各自的数据节点上正常工作。

第 5 步:旧主节点 (Node-A) 重启完成并归队

  • 几分钟后,Node-A 的服务器重启完毕,Elasticsearch 服务也启动了。
  • Node-A 启动后,会读取自己的 discovery.seed_hosts 配置,去联系它记忆中的其他集群成员。
  • 它联系上了 Node-B(或者 Node-C),然后它会发现——集群已经有了一个新的主节点 (Node-B)!
  • 此时,Node-A 不会去尝试“抢回”主节点的位置。它会很“谦虚”地认识到自己已经不是 Master 了。
  • 它会向新的主节点 Node-B 发送加入请求,并作为一个普通的从节点 (Follower Node) 重新加入集群。

最终结果

集群恢复到拥有 3 个健康节点的状态,只不过此时的主节点变成了 Node-B,而原来的主节点 Node-A 则变成了一个从节点。整个集群的可用性得到了保障。

总结一下:

这个自动的故障转移和选举机制,正是 Elasticsearch 高可用性的核心体现。只要你保证主节点候选人的数量 >= 3 (并且是奇数),那么任何一个主节点的重启或宕机,都不会导致整个集群的瘫痪,系统具备了自我修复的能力。


心跳检测是谁和谁检测?

心跳检测不是所有节点相互检测(这在大型集群中会产生巨大的网络风暴),而是一个以主节点为中心的、双向的星型检测模式

我们可以把这个机制分为两种官方的叫法:

  1. Follower Checks (主查从)
    • 谁做:主节点 (Master) 发起。
    • 做什么: 主节点会定期地、主动地去 ping 集群中的每一个其他节点(无论是数据节点、协调节点还是其他主节点候选人),问一声“你还在吗?”。
    • 目的: 确认集群的所有成员都还健康地活着并且保持在线。
  2. Leader Checks (从查主)
    • 谁做: 由集群中所有非主节点 (Non-master Nodes) 发起。
    • 做什么: 每一个从节点会定期地、主动地去 ping 当前的主节点,问一声“老板,你还在吗?”。
    • 目的: 确认集群的“大脑”——主节点,依然在线且能够正常响应。如果从节点发现主节点失联,它们就会发起新一轮的选举(如我们之前讨论的)。

一句话总结:主节点负责“点名”检查每个手下,而每个手下也负责时刻关注“老板”的健康状况。


问题2:如果挂掉的是普通节点(比如数据节点)呢?

如果挂掉的是一个普通的数据节点(我们称之为 Node-X),整个过程不会发生主节点选举,而是会触发一套不同的、以数据恢复为核心的流程。

以下是事件的完整时间线:

第 1 步:故障检测 (Fault Detection)

  • 主节点在执行它的“Follower Check”时,发现联系不上 Node-X。在经过几次(可配置的)重试后,主节点会正式将 Node-X 标记为“已掉线 (failed)”。

第 2 步:更新集群状态 (Updating Cluster State)

  • 主节点的第一项行动,就是立即从集群状态中移除 Node-X。
  • 然后,它会把这个不包含 Node-X 的、最新的集群状态广播给所有还存活的节点。至此,整个集群达成共识:Node-X 已经不在了。

第 3 步:数据自动恢复 (Shard Recovery)

  • 这个新的集群状态会触发自动恢复流程。主节点现在需要处理那些“无家可归”的分片(Shards),也就是之前存放在 Node-X 上的分片。
  • 情况A:如果 Node-X 上有主分片 (Primary Shards)
    • 主节点会立即在其他存活的节点上,找到这些主分片对应的副本分片 (Replica Shards)
    • 它会从这些副本中选择一个,并将其提升 (Promote) 为新的主分片。这是一个非常快速的元数据操作,能确保索引几乎立刻恢复可写状态。
  • 情况B:恢复数据冗余度
    • 现在,由于 Node-X 的离开,集群中很多分片(包括被提升为新主分片的那些)的副本数量都少了一个,整个集群的数据冗余度下降了(集群健康状态会变为 yellow)。
    • 主节点会开始在其他健康的节点上,创建新的副本分片,并从对应的主分片那里复制数据。
    • 这个数据复制的过程可能会消耗一定的网络和磁盘 I/O,但它是在后台自动进行的。

第 4 步:恢复完成

  • 一旦所有缺失的副本都被重新创建并同步好数据,集群的健康状态就会恢复到 green

总结:两种故障的核心区别

  • 主节点故障: 核心是**“权力真空”。解决方法是重新选举 (Re-election),恢复集群的控制能力**。
  • 普通节点故障: 核心是**“数据丢失风险”。解决方法是分片恢复 (Shard Recovery),包括副本提主 (Replica Promotion)** 和 重新分配副本 (Replica Allocation),恢复数据的完整性和冗余度。这个过程由现有的主节点全权负责,不需要重新选举

  目录