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 (副本)
- 是什么: 分片的一份或多份拷贝。
- 类比: 数据库的主从复制中的“从库”。
- 解释: 副本的主要作用有两个,我们之前也提到过:
- 高可用性 (High Availability): 副本和它的主分片永远不会被分配在同一个节点上。如果持有主分片的节点宕机,ES 会立即将一个副本“提升”为新的主分片,保证服务不中断,数据不丢失。
- 提升读性能 (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
:你创建索引时设定的主分片数量。
举个例子:
- 你创建了一个
products
索引,并设定它有 3 个主分片。 - 现在,你要存入一个新商品文档,它的
_id
是 “A-123-B-456”。 - ES 会对 “A-123-B-456” 这个
_id
进行哈希计算,假设得到一个数字2096
。 - 然后,ES 计算
2096 % 3
,结果是2
。 - 结论: ES 就知道了,这个文档必须被存到主分片2 (Shard 2) 上。
下次你根据这个 _id
来获取或更新这个文档时,ES 会重复一遍完全相同的计算,再次得到 2
,然后直接去主分片2上找,而不需要去问分片0和分片1,效率极高。
现在,我们来理解第二部分,也是最关键的部分:
“主分片的数量在索引创建时就必须固定,之后不能修改”,为什么不能修改?
我们继续用上面的例子。假设 ES 允许你把主分片数量从 3 修改成 4。
现在,你还是想找那个 _id
是 “A-123-B-456” 的文档。ES 再次执行路由计算:
hash("A-123-B-456")
的结果依然是2096
。- 但是,现在主分片数量变成了 4,所以公式变成了
2096 % 4
。 - 计算结果是
0
!
灾难发生了!
ES 现在认为这个文档应该在主分片0 (Shard 0) 上。但实际上,它当初被存放在了主分片2上。ES 去分片0上找,结果肯定是“找不到”。
如果你修改了主分片的数量,所有之前文档的路由规则就全部失效了,整个索引的数据就陷入了彻底的混乱,你将无法通过 _id
定位到任何一个文档。
这就是为什么主分片数量一旦设定,就不能再修改的根本原因:为了保证路由规则的永久稳定。
那如果我确实需要扩容怎么办?
这是一个非常实际的问题。如果你的索引真的因为数据增长需要更多的分片来分担压力,正确的做法不是去修改现有索引,而是使用 Reindex API
:
- 创建一个新的索引 (比如
products_v2
),并为它设置一个更多的主分片数量(比如 6 个)。 - 使用
Reindex API
,让 Elasticsearch 自动地、高效地将旧索引 (products
) 中的所有数据读取出来,并重新写入到新索引 (products_v2
) 中。 - 在重新写入的过程中,ES 会为每一条数据应用新的路由规则(比如
% 6
),将它们正确地分布到新的 6 个分片中。 - 数据迁移完成后,你可以将你的应用程序指向新的索引
products_v2
,然后安全地删除旧索引。
搜索的流程是什么?
具体来说,它分为几个关键步骤:
- 分析查询 (Analyze Query): 首先,Elasticsearch 会使用与索引时相同的分析器 (Analyzer) 来处理用户的查询语句。例如,用户搜索“华为手机”,分析器会将其处理成词元(Tokens)
["华为", "手机"]
。 - 查找词典 (Term Dictionary Lookup): 接着,系统会拿着处理好的每一个词元(“华为”、“手机”),去那个已经建好的、巨大的词典 (Term Dictionary) 中快速查找。
- 获取文档列表 (Posting List Retrieval): 词典本身不存储文档ID,但它会告诉系统去哪里找到包含了这个词元的文档列表 (Posting List)。
- 找到“华为” -> 获取到它的文档列表,比如
[Doc1, Doc5, Doc10]
。 - 找到“手机” -> 获取到它的文档列表,比如
[Doc1, Doc8, Doc10]
。
- 找到“华为” -> 获取到它的文档列表,比如
- 合并与计算 (Merge & Calculate): 这是非常关键的一步。
- 布尔逻辑: 系统会对这些文档列表进行布尔运算。对于“华为手机”这个查询,默认是 AND 逻辑,所以它会取两个列表的交集,得到最终匹配的文档ID:
[Doc1, Doc10]
。 - 相关性评分 (
_score
): 与此同时(对于match
查询),ES 还会计算每个匹配文档的相关性分数。它会考虑词频(TF,词在一个文档里出现的次数)、逆文档频率(IDF,词在所有文档中是否罕见)等因素。比如,如果 Doc1 的标题就是“华为手机”,而 Doc10 的描述里只提了一句,那么 Doc1 的得分就会更高。
- 布尔逻辑: 系统会对这些文档列表进行布尔运算。对于“华为手机”这个查询,默认是 AND 逻辑,所以它会取两个列表的交集,得到最终匹配的文档ID:
- 返回结果 (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 的标准索引-倒排索引
建立倒排索引主要有两个步骤:
- 分词:将文档内容拆分成一个个独立的词(term)。例如,”Elasticsearch is fast” 会被拆分成 “elasticsearch”, “is”, “fast”。
- 建立映射关系:创建一个“词典(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 是怎么做的?”
- 查询词典: ES 在词典中查找 “quick”,得到文档列表
[Doc 1, Doc 3]
(Posting List) 文件。 - 查询词典: 接着查找 “brown”,得到文档列表
[Doc 1, Doc 2, Doc 3]
(Posting List) 文件。 - 合并结果: ES 对这两个列表进行交集运算,
[Doc 1, Doc 3]
和[Doc 1, Doc 2, Doc 3]
的交集是[Doc 1, Doc 3]
。 - 返回结果: 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 全文搜索能够工作的核心匹配原则:用同样的方式处理输入和查询。
第一步:存入数据(索引时)
- 收到文档: 你给 Elasticsearch 一个文档,比如
{ "title": "我爱打篮球" }
。 - 分析和拆解: Elasticsearch 会查看
title
字段的设置,找到为它配置的分析器(Analyzer)。这个分析器(比如一个标准的中文分词器)接收到 “我爱打篮球” 这个字符串。 - 执行分词: 分析器将字符串拆解成一系列独立的词元(Tokens),也就是
["我", "爱", "打", "篮球"]
。 - 写入倒排索引: Elasticsearch 不会在倒排索引里记录 “我爱打篮球” 这个原始字符串。它会为每一个词元建立索引,将它们分别指向这个文档的ID。最终在倒排索引里形成类似这样的记录:
我
->[文档ID_1]
爱
->[文档ID_1]
打
->[文档ID_1]
篮球
->[文档ID_1]
第二步:进行搜索(查询时)
- 收到查询: 你发起一个
match
查询,搜索 “我爱打篮球”。 - 分析和拆解(用同样的方式):
match
查询是一种分析查询(Analyzed Query)。这意味着 Elasticsearch 会对你的查询词也执行完全相同的分析过程。它会用title
字段在索引时用的那个同一个分析器,将查询词 “我爱打篮球” 也拆解成["我", "爱", "打", "篮球"]
。 - 匹配暗号: 现在,Elasticsearch 不再是去寻找一个叫 “我爱打篮球” 的大海捞针,而是拿着
["我", "爱", "打", "篮球"]
这几个拆解后的词元,去倒排索引里查找。- 它找到所有包含
我
的文档。 - 找到所有包含
爱
的文档。 - …以此类推。
- 最后,它会计算哪些文档同时包含所有这些词元,并根据相关性评分,最终找到
文档ID_1
是最佳匹配。
- 它找到所有包含
ES 查询参数的区别
match
查询:智能的全文搜索
- 核心功能: 这是进行全文搜索的标准和首选方式。它的目标是找到“相关的”文档,并计算相关性得分
_score
。 - 是否分析查询词? 是! 这是它最重要的特点。它会使用字段在 Mapping 中指定的同一个分析器来分析你的查询词。
- 工作流程:
- 你搜索
"华为 手机"
。 match
查询拿到这个字符串,用分析器将它分词成["华为", "手机"]
。- 然后它去倒排索引中查找同时包含这两个词元的文档。
- 你搜索
- 适用场景: 几乎所有的搜索框功能,用户输入的自然语言搜索。最适合用于
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 |
低(慎用) |
关于 prefix
和 wildcard
的区别,以及为什么 Elasticsearch 要单独提供一个 prefix
查询呢?
prefix
: 是专门用于前缀匹配的。wildcard
: 功能更强大,可以进行任意位置的模糊匹配(前缀、后缀、中间)。
从功能效果上来看,使用 wildcard
来做前缀匹配(如 "abc*"
) 和使用 prefix
查询(如 "abc"
) 得到的结果是一模一样的。
那为什么我们还要用 prefix
呢?
因为即使是在做完全相同的前缀匹配工作时,prefix
的效率也比 wildcard
更高。
我们可以用一个“工具箱”的比喻来理解这个性能差异:
prefix
查询 就像一把 专门用来拧六角螺丝的、尺寸完全匹配的扳手。它的设计目标就是做这一件事,因此它的结构简单、贴合度高、发力直接,效率是最高的。wildcard
查询 就像一把 可以调节开口大小的活动扳手。它非常强大,不仅能拧六角螺丝,还能拧四角螺丝,甚至一些不规则形状的螺母。但是,当你用它来拧那个标准的六角螺丝时,你需要先调节开口、确保卡紧,整个操作过程和内部机械结构总会比专用扳手要稍微复杂和慢一点。
深入到技术层面,为什么会这样?
Elasticsearch 底层的 Lucene 搜索引擎对这两种查询的处理方式不同:
prefix
的执行路径(专用通道):Lucene 内部的词典是按字母顺序排序的。当它收到一个prefix
查询时,它有一个高度优化的“捷径”可走。它可以非常快地定位到词典中这个前缀的起始位置,然后简单地顺序向后扫描,直到不符合前缀为止。 这是一个非常直接、开销很小的操作。wildcard
的执行路径(通用通道):当 Lucene 收到一个wildcard
查询时,它会先将这个通配符模式(即使是简单的"abc*"
)编译成一个更通用的内部状态机(Automaton)。 然后用这个状态机去匹配词典中的词元。虽然对于"abc*"
这个简单的模式,最终效果和prefix
一样,但启动和运行这个更“通用”和“强大”的状态机引擎,本身就会带来一点额外的计算开销。
query
和 filter
的区别
核心目的:相关性评分 (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
部分。
- 计算相关性分数 (
性能与缓存 (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
查询想象成一个可以组合不同逻辑条件的容器,它主要有**四种工具(子句)**供您使用:
must
(必须匹配)filter
(必须匹配,但更快)should
(可以匹配)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” 品牌的。
这里我们组合使用 must
和 must_not
。
GET /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "category": "跑鞋" } }
],
"must_not": [
{ "term": { "brand": "Nike" } }
]
}
}
}
分析: must_not
也运行在 filter 上下文中,因为它不计算分数,所以效率也很高。
分析器 (Analyzer)是什么
工作原理:它的工作是把一段原始的、杂乱的文本,加工成一系列规整、统一的、适合搜索的最小单元——词元 (Token)。
固定的流程分别是:
- 字符过滤器 (Character Filter) - 预处理,负责“净化”
- 分词器 (Tokenizer) - 核心工序,负责“拆分”
- 词元过滤器 (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 标签 (e.g.,
经过【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_smart
和ik_max_word
两种模式)
经过【Standard Tokenizer】处理后,我们得到一个词元流:[ The, 2, QUICK, Brown-Foxes, jump, over, the, lazy, dog's, bone ]
(注意:Brown-Foxes
和 dog's
此时还保持原样)
工序 3:词元过滤器 (Token Filter)
它的任务是在词元被拆分之后,对每一个词元进行修改、添加或删除。你可以配置零个或多个词元过滤器,它们会按顺序执行。
- 常见功能:
- Lowercase: 将所有词元转为小写。
- Stop Words: 移除常见的、对搜索意义不大的“停用词”(如
the
,a
,is
)。 - Stemming: 将词语简化为它的词干(e.g.,
running
,ran
都变成run
)。 - Synonym: 添加同义词 (e.g., 遇到
quick
,可以额外添加一个fast
词元)。
让我们依次应用两个词元过滤器:
- 经过【Lowercase Token Filter】处理后,词元流变为:
[ the, 2, quick, brown-foxes, jump, over, the, lazy, dog's, bone ]
- 经过【Stop Words Token Filter】处理后,词元流变为:
[ 2, quick, brown-foxes, jump, over, lazy, dog's, bone ]
(两个the
被移除了)
最终,被写入倒排索引的就是这个最终的词元流。
用 IK 分析器时,遇到英文会怎样?
当您为一个字段指定了 ik_analyzer
,那么无论这个字段里是中文、英文还是数字,都会由 ik_analyzer
来全权处理。它不会自动切换到 standard
分析器。
那么,IK 是如何处理英文的呢?
它的处理逻辑通常是:
- 遇到中文字符: 启用它的核心算法,根据内置的中文词典进行最大匹配或最细粒度的分词。
- 遇到英文字符或数字: 它会切换到一种类似于
standard
分析器的模式。它会把连续的英文字母或数字识别为一个完整的词元(Token),然后根据空格和标点符号进行切分,并且通常会执行转小写的操作。
举个例子:
如果你的文本是 "IK分析器 very good"
使用 ik_analyzer
分析后,得到的词元流会是:[ik, 分析器, very, good]
你可以看到,它既正确地处理了中文的“分析器”,也正确地处理了英文的“ik”、“very”和“good”。
如何为中英文提供各自最优的分析?
虽然 IK 能很好地处理英文,但它毕竟不是专门为英文设计的(比如它缺少英文中很重要的词干提取/Stemming功能,不能把 running
和 run
视为同一个词)。
如果想达到极致的效果,最优的方案是使用 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
字段分词后无法完成这些操作。因此,我们通过fields
为title
额外创建了一个名为raw
的keyword
类型的子字段。这样:- 查
title
字段:进行全文搜索。 - 查
title.raw
字段:进行精确聚合或排序。
- 查
3. description
: text
- 为什么是
text
? 与title
类似,用于全文搜索。 - 为什么要有
"index": true
?index
的默认值就是true
,这里显式写出来是为了强调。如果某个字段你永远不会用作查询条件,可以设置为false
来节省空间和提高写入速度。但description
通常需要被搜索,所以保持true
。
4. price
: scaled_float
- 为什么是
scaled_float
? 对于价格、汇率等需要精确计算的浮点数,标准的float
或double
类型可能会有精度问题。scaled_float
是处理货币的最佳选择。它通过一个scaling_factor
(缩放因子),将浮点数乘以这个因子后,作为整数存储。这里100
表示保留两位小数,6999.00
会被存储为699900
,完全避免了浮点数精度陷阱。
5. tags
: keyword
- 为什么是
keyword
? 标签是原子的,不可分的。我们希望对 “新款”、”旗舰” 这样的标签进行精确过滤和聚合,keyword
是不二之选。ES 会自动处理keyword
类型的数组。
6. on_sale
: boolean
- 为什么是
boolean
? 用于表示true
或false
,最直接,存储和过滤效率也最高。
7. created_at
: date
- 为什么是
date
? 专门用于处理日期时间,支持丰富的日期格式和强大的日期范围查询。 - 为什么要有
format
? 显式指定日期格式,可以加速日期的解析。||
表示“或”,意味着它既能解析"yyyy-MM-dd HH:mm:ss"
格式的字符串,也能解析毫秒级时间戳,增强了兼容性。
8. stock_info
: object
with "enabled": false
- 为什么
"enabled": false
? 这是一个关键的性能优化。假设stock_info
这个对象我们只用来展示,从不按warehouse_id
或quantity
进行搜索。那么设置"enabled": false
就会告诉 ES:“不要为这个对象里的任何字段创建倒排索引”。这会节省大量的存储空间和索引开销。
9. reviews
: nested
为什么是
nested
而不是object
? 这是另一个极其重要的概念。默认的object
类型会“压平”数组对象,导致内部字段的关联性丢失。例如,reviews
数组会被压平成:"reviews.username": ["Alice", "Bob"], "reviews.rating": [5, 4]
这时如果你查询“用户是 Alice 并且评分是 4 的评论”,ES 会错误地返回 true,因为它只知道
Alice
和4
这两个值同时存在于数组中,但不知道它们是否属于同一条评论。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" } }
数据全部写入完成后,再将刷新间隔调回默认值
1s
或null
。
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" } }
警告: 这个设置牺牲了数据的安全性。如果在异步刷盘的间隔内节点宕机,可能会丢失一部分数据。所以,它只适用于可以接受少量数据丢失的场景,比如重新导入日志数据。数据导入完成后,务必改回默认值。
总结:批量导入数据的黄金流程
- 创建索引: 设计好 Mapping,将
number_of_replicas
设置为0
。 - 修改设置: 将
refresh_interval
设置为1
。如果能接受风险,可以调整translog
设置。 - 执行写入: 使用多线程和 Bulk API 并发地写入数据,并监控集群状态。
- 恢复设置: 数据全部写入完成后,将
number_of_replicas
和refresh_interval
恢复到它们的正常值。
查询性能优化
1. 避免深度分页 (Deep Pagination)
- 问题是什么? 在关系型数据库中,
LIMIT 10000, 10
这样的查询可能依然很快。但在分布式系统中,这会成为一个灾难。ES 中使用from
和size
的分页方式,当你请求from: 10000, size: 10
时,ES 必须:- 在每个分片上都找出前
10010
(from + size) 个文档。 - 将所有分片的结果(比如 5 个分片 * 10010 条 = 50050 条数据)汇集到协调节点。
- 协调节点对这 50050 条数据进行重新排序。
- 最后,从排序后的结果中,丢弃前 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 或在索引时预处理数据来满足需求。
- 如果必须用,确保脚本尽可能简单高效,并测试其性能影响。
总结一下查询优化的核心思想:
- 数据说话: 优先处理数据量大的部分。先用
filter
快速过滤掉绝大部分无关数据。 - 避免浪费: 不做多余的工作。避免深度分页的巨大开销,只请求必要的字段。
- 善用索引: 确保你的查询能最大化地利用倒排索引的优势,避免全索引扫描。
脑裂 (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
在 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"]
在 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"]
在 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"]
启动时会发生什么?
- 当你启动这三个节点时,它们会读取这个
cluster.initial_master_nodes
列表。 - 它们会尝试与列表中的其他成员建立联系。
- 一旦它们发现彼此,并且确认当前在线的节点数量达到了这个初始列表的法定人数 (Quorum)(在这个例子里是
(3/2)+1=2
),它们就会开始进行第一次主节点选举。 - 选举成功后(比如 Node-A 当选为 Master),第一个健康的集群就正式形成了。
最关键的一步:“记住”与“抛弃”
一旦集群成功形成,这个 cluster.initial_master_nodes
配置的历史使命就完成了。
- “记住”: 新当选的主节点会把当前集群的成员列表(包含所有节点的详细信息)写入到集群状态 (Cluster State) 中。这个状态会被同步到所有节点上并持久化。从此以后,集群就拥有了自己“记忆”,它知道自己有哪些成员。
- “抛弃”: 从此刻起,所有节点都会忽略
elasticsearch.yml
文件中的cluster.initial_master_nodes
配置。即使你重启节点,它也不会再去看这个配置了。它会信任持久化在自己磁盘上的那个“记忆”(集群状态),并通过discovery.seed_hosts
配置去寻找已知的其他成员。
为什么这个机制如此重要?
- 安全性: 因为这个配置只在第一次启动空集群时生效,所以它能有效地防止你意外地用它来初始化一个已经有数据的、正在运行的集群,从而避免灾难。如果你在一个已经有数据的节点上设置了这个参数并启动,ES 会报错并拒绝启动,保护你的数据。
- 自动化: 它为集群提供了一个安全的“起点”。一旦集群启动并运行起来,后续的成员管理(比如节点的加入、离开、主节点重选)就都进入了我们之前讨论的全自动 Quorum 模式,不再需要人工干预。
后续进来新的节点会发生什么?
后续添加的所有节点已经不属于集群引导了,而是属于节点发现。现在在已经存在的由 A、B、C 组成的集群中添加 Node-D,正确的操作如下:
准备 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"]
- 不要设置
启动 Node-D
直接启动 Node-D 的 Elasticsearch 服务即可。
启动后发生了什么?(自动的“发现”过程)
- Node-D 启动,读取自己的配置,发现
discovery.seed_hosts
中有三个地址。 - 它会尝试联系这几个地址上的节点。
- 比如,它成功联系上了 Node-A(当前的主节点)。
- Node-D 会向 Node-A 发送“加入请求”,并报上自己的集群名 (
cluster.name
)。 - 主节点 Node-A 验证通过后,会欢迎 Node-D 加入集群,并更新集群状态 (Cluster State),将 Node-D 的信息添加进去。
- 最后,主节点 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,满足法定人数。
- 在我们的例子中,总共有 3 个主节点候选人,法定人数是
- 因为满足法定人数,它们就立即开始一轮新的选举。
第 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 (并且是奇数),那么任何一个主节点的重启或宕机,都不会导致整个集群的瘫痪,系统具备了自我修复的能力。
心跳检测是谁和谁检测?
心跳检测不是所有节点相互检测(这在大型集群中会产生巨大的网络风暴),而是一个以主节点为中心的、双向的星型检测模式。
我们可以把这个机制分为两种官方的叫法:
- Follower Checks (主查从)
- 谁做: 由主节点 (Master) 发起。
- 做什么: 主节点会定期地、主动地去
ping
集群中的每一个其他节点(无论是数据节点、协调节点还是其他主节点候选人),问一声“你还在吗?”。 - 目的: 确认集群的所有成员都还健康地活着并且保持在线。
- 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,但它是在后台自动进行的。
- 现在,由于 Node-X 的离开,集群中很多分片(包括被提升为新主分片的那些)的副本数量都少了一个,整个集群的数据冗余度下降了(集群健康状态会变为
第 4 步:恢复完成
- 一旦所有缺失的副本都被重新创建并同步好数据,集群的健康状态就会恢复到
green
。
总结:两种故障的核心区别
- 主节点故障: 核心是**“权力真空”。解决方法是重新选举 (Re-election),恢复集群的控制能力**。
- 普通节点故障: 核心是**“数据丢失风险”。解决方法是分片恢复 (Shard Recovery),包括副本提主 (Replica Promotion)** 和 重新分配副本 (Replica Allocation),恢复数据的完整性和冗余度。这个过程由现有的主节点全权负责,不需要重新选举。