概述
数据分类
搜索引擎是对数据的检索,所以我们先从生活中的数据说起。我们生活中的数据总体分为两种:
- 结构化数据 也称作行数据,是由二维表结构来逻辑表达和实现的数据,严格地遵循数据格式与长度规范,主要通过关系型数据库进行存储和管理。指具有固定格式或有限长度的数据,如数据库,元数据等。如果要更细致的区分的话,XML、HTML 可划分为半结构化数据。因为它们也具有自己特定的标签格式,所以既可以根据需要按结构化数据来处理,也可抽取出纯文本按非结构化数据来处理。
- 非结构化数据 又可称为全文数据,不定长或无固定格式,不适于由数据库二维表来表现,包括所有格式的办公文档、XML、HTML、Word 文档,邮件,各类报表、图片和咅频、视频信息等。
根据两种数据分类,搜索也相应的分为两种:
- 结构化数据搜索 具有特定的结构,所以我们一般都是可以通过关系型数据库(MySQL,Oracle 等)的二维表(Table)的方式存储和搜索,也可以建立索引。
- 非结构化数据搜索
对全文数据的搜索主要有两种方法:
- 顺序扫描 通过文字名称也可了解到它的大概搜索方式,即按照顺序扫描的方式查询特定的关键字。
- 全文检索 将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对此有一定结构的数据进行搜索,从而达到搜索相对较快的目的。 这种方式就构成了全文检索的基本思路。这部分从非结构化数据中提取出的然后重新组织的信息,我们称之为索引。 这种方式的主要工作量在前期索引的创建,但是对于后期搜索却是快速高效的。
Lucene
是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎。以 Lucene 为基础建立的开源可用全文搜索引擎主要是 Solr 和 Elasticsearch。
倒排索引
Lucene 能实现全文搜索主要是因为它实现了倒排索引的查询结构。
为了创建倒排索引,我们通过分词器将每个文档的内容域拆分成单独的词(我们称它为词条或 Term),创建一个包含所有不重复词条的排序列表,然后列出每个词条出现在哪个文档。

- 词条(Term):索引里面最小的存储和查询单元,对于英文来说是一个单词,对于中文来说一般指分词后的一个词。
- 词典(Term Dictionary):或字典,是词条 Term 的集合。搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。
- 倒排表(Post list):一个文档通常由多个词组成,倒排表记录的是某个词在哪些文档里出现过以及出现的位置。
- 每条记录称为一个倒排项(Posting)。倒排表记录的不单是文档编号,还存储了词频等信息。
- 倒排文件(Inverted File):所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件被称之为倒排文件,倒排文件是存储倒排索引的物理文件。
倒排索引主要由两个部分组成:词典、倒排文件。词典和倒排文件是分两部分存储的,词典在内存中,倒排文件存储在 1 磁盘上。
ELK
- Elasticsearch 是一个实时分布式搜索和分析引擎,使用 Java 开发并使用 Lucene 作为其核心来实现所有索引和搜索的功能,但是它的目的是通过简单的 RESTful API 来隐藏 Lucene 的复杂性,从而让全文搜索变得简单
- Logstash 是 ELK 的中央数据流引擎,用于从不同目标(文件/数据存储/MQ)收集的不同格式数据,经过过滤后支持输出到不同目的地(文件/MQ/redis/elasticsearch/kafka 等)
- Kibana 可以将 elasticsearch 的数据通过友好的页面展示出来,提供实时分析的功能。

核心概念
ElasticSearch 是面向文档的非关系型数据库,来看看它与关系型数据库的对比:

文档 Document
索引和搜索数据的最小单位是文档
类型 Type(已废弃:影响打分)
类型是文档的逻辑容器,就像关系型数据库一样,表格是行的容器。类型中对于字段的定义称为映射,比如 name 映 射为字符串类型。文档是无模式的,它们不需要拥有映射中所定义的所有字段,比如新增一个字段,elasticsearch 会自动的将新字段加入映射,但是这个字段的不确定它是什么类型,elasticsearch 会猜
索引 Index
索引是映射类型的容器,elasticsearch 中的索引是一个非常大的文档集合。索引存储了映射类型的字段和其他设置。 然后它们被存储到了各个分片上了。 我们来研究下分片是如何工作的。

一个集群至少有一个节点,而一个节点就是一个 elasricsearch 进程,节点可以有多个索引默认的,如果你创建索引,那么索引将会有个 5 个分片 ( primary shard ,又称主分片 ) 构成的,每一个主分片会有一个副本 ( replica shard ,又称复制分片 )。下图是一个有 3 个节点的集群,可以看到主分片和对应的复制分片都不会在同一个节点内,这样有利于某个节点挂掉 了,数据也不至于丢失。

实际上,一个分片是一个 Lucene 索引,一个包含倒排索引的文件目录,倒排索引的结构使得 elasticsearch 在不扫描全部文档的情况下,就能告诉你哪些文档包含特定的关键字。
倒排索引
elasticsearch 使用的是一种称为 倒排索引 的结构,采用 Lucene 倒排索引作为底层。这种结构适用于快速的全文搜索, 一个索引由文档中所有不重复的列表构成,对于每一个词,都有一个包含它的文档列表。 例如,现在有两个文档, 每个文档包含如下内容:
Study every day, good good up to forever # 文档1包含的内容
To forever, study every day, good good up # 文档2包含的内容
为了创建倒排索引,我们首先要将每个文档拆分成独立的词 (或称为词条或者 tokens),然后创建一个包含所有不重复的词条的排序列表,然后列出每个词条出现在哪个文档 :

现在,我们试图搜索 to forever,只需要查看包含每个词条的文档

两个文档都匹配,但是第一个文档比第二个匹配程度更高。如果没有别的条件,现在,这两个包含关键字的文档都将返回。
再来看一个示例,比如我们通过博客标签来搜索博客文章。那么倒排索引列表就是这样的一个结构 :

如果要搜索含有 python 标签的文章,那相对于查找所有原始数据而言,查找倒排索引后的数据将会快的多。只需要 查看标签这一栏,然后获取相关的文章 ID 即可。完全过滤掉无关的所有数据,提高效率!
在 elasticsearch 中,索引被分为多个分片,每份分片是一个 Lucene 的索引。所以一个 elasticsearch 索引是由多个 Lucene 索引组成的。别问为什么,谁让 elasticsearch 使用 Lucene 作为底层呢! 如无特指,说起索引都是指 elasticsearch 的索引。
集群
ES 集群由一个或多个 Elasticsearch 节点组成,每个节点配置相同的 cluster.name 即可加入集群,默认值为 “elasticsearch”。确保不同的环境中使用不同的集群名称,否则最终会导致节点加入错误的集群。一个 Elasticsearch 服务启动实例就是一个节点(Node)。节点通过 node.name 来设置节点名称,如果不设置则在启动时给节点分配一个随机通用唯一标识符作为名称。
发现机制
为什么只需设置相同的 cluster.name,不同的 Elasticsearch 节点就能自动连接在一起?这归功于 Elasticsearch 的内置发现模块——Zen Discovery。它肩负着两大核心职责:发现集群中的节点以及选举 Master 节点。
Zen Discovery 提供单播和基于文件的发现,并且可以扩展为通过插件支持云环境和其他形式的发现。
工作原理与通信
Zen Discovery 与底层的 Transport 模块深度集成,节点之间通过 Ping 机制来互相探测。在默认情况下,Elasticsearch 采用单播(Unicast)发现模式。这种设计是为了防止节点无意中加入不属于它的集群,确保数据的安全性。因此,只有在同一台机器上运行的节点才会默认自动组成集群。
跨机器集群的构建
若要在多台机器上组建集群,你需要配置一个单播列表,告知 Elasticsearch 应该尝试连接哪些节点。这里有一个关键的设计细节:你无需将集群中所有节点的地址都列入清单。
只要新节点能联系上清单中的任何一个成员,它就能获取整个集群的全局状态,进而定位并联系 Master 节点,最终完成加入。因此,这个列表只需要包含少数几个可靠的“种子节点”即可作为集群的入口。
启动与发现:建立连接的第一步
当一个 Elasticsearch 节点启动时,它首先需要通过 Ping(探测)机制来寻找集群中的其他伙伴。这个过程依赖于配置文件的指引
- 配置入口:在
elasticsearch.yml文件中,通过discovery.zen.ping.unicast.hosts设置单播列表。
discovery.zen.ping.unicast.hosts: ["host1", "host2:port"]
- 最佳实践:通常只需要列出 3 个 Master 候选节点(Master-eligible nodes)即可作为“种子节点”,无需列出所有节点。
- 探测逻辑
- 如果设置了上述列表,节点会优先 Ping 列表中的 Host。
- 如果列表为空(开发模式),它会尝试扫描本地(localhost)的几个端口,以发现同一台机器上运行的其他节点。
- 响应信息:Ping 的响应不仅包含对方的基本信息,还会告知对方当前认为谁是 Master 节点。
选主机制:基于规则的确定性选举
Elasticsearch 的选主(Zen Discovery)与 Zookeeper 等基于“投票数”的机制不同。由于它需要支持任意规模(1-N)的集群,它采用了一种基于确定性规则的筛选方式:
- 选举规则
- 优先认同:节点首先检查是否有现成的 Master。如果各节点已经汇报了同一个 Master,则直接认可。
- 字典排序:如果没有现成 Master,节点会在所有候选节点中,按照 ID 的字典序排序,直接选取第一个作为 Master。
- 达成共识:只要所有节点获取的信息是对等的,遵循这套相同的规则,它们选出的 Master 就一定是一致的。
- 角色分工
- 当选 Master:等待连接的节点数达到规定阈值后,开始对外提供服务。
- 未当选:直接加入已选出的 Master 构成的集群。
脑裂防护:通过 Quorum 确保数据一致
分布式系统最大的风险在于“信息不对等”。如果网络发生分区,不同的节点群可能各自选出一个 Master,导致脑裂(Split-Brain),即数据产生分叉且无法合并。
造成原因:
- 网络问题:集群间的网络延迟导致一些节点访问不到 Master,认为 Master 挂掉了从而选举出新的 Master,并对 Master 上的分片和副本标红,分配新的主分片。
- 节点负载:主节点的角色既为 Master 又为 Data,访问量较大时可能会导致 ES 停止响应(假死状态)造成大面积延迟,此时其他节点得不到主节点的响应认为主节点挂掉了,会重新选取主节点。
- 内存回收:主节点的角色既为 Master 又为 Data,当 Data 节点上的 ES 进程占用的内存较大,引发 JVM 的大规模内存回收,造成 ES 进程失去响应。
优化措施:
- 适当调大响应时间,减少误判。通过参数 discovery.zen.ping_timeout 设置节点状态的响应时间,默认为 3s,可以适当调大。如果 Master 在该响应时间的范围内没有做出响应应答,判断该节点已经挂掉了。调大参数(如 6s,discovery.zen.ping_timeout:6),可适当减少误判。
- Quorum(法定人数)机制:我们需要在候选集群中的节点的配置文件中设置参数 discovery.zen.munimum_master_nodes 的值。这个参数表示在选举主节点时需要参与选举的候选主节点的节点数,默认值是 1,官方建议取值 (master_eligibel_nodes/2)+1,其中 master_eligibel_nodes 为候选主节点的个数。这样做既能防止脑裂现象的发生,也能最大限度地提升集群的高可用性,因为只要不少于 discovery.zen.munimum_master_nodes 个候选节点存活,选举工作就能正常进行。当小于这个值的时候,无法触发选举行为,集群无法使用,不会造成分片混乱的情况。
- 角色分离:主节点和数据节点进行角色分离,这样可以减轻主节点的负担,防止主节点的假死状态发生,减少对主节点“已死”的误判。
节点的角色
每个节点既可以是候选主节点也可以是数据节点,通过在配置文件 ../config/elasticsearch.yml 中设置即可,默认都为 true。
node.master: true //是否候选主节点
node.data: true //是否数据节点
数据节点负责数据的存储和相关的操作,例如对数据进行增、删、改、查和聚合等操作,所以数据节点(Data 节点)对机器配置要求比较高,对 CPU、内存和 I/O 的消耗很大。通常随着集群的扩大,需要增加更多的数据节点来提高性能和可用性。
候选主节点可以被选举为主节点(Master 节点),集群中只有候选主节点才有选举权和被选举权,其他节点不参与选举的工作。
主节点负责创建索引、删除索引、跟踪哪些节点是群集的一部分,并决定哪些分片分配给相关的节点、追踪集群中节点的状态等,稳定的主节点对集群的健康是非常重要的。

一个节点既可以是候选主节点也可以是数据节点,但是由于数据节点对 CPU、内存核 I/O 消耗都很大。所以如果某个节点既是数据节点又是主节点,那么可能会对主节点产生影响从而对整个集群的状态产生影响。
因此为了提高集群的健康性,我们应该对 Elasticsearch 集群中的节点做好角色上的划分和隔离。可以使用几个配置较低的机器群作为候选主节点群。它们不存数据,只负责轻量级的集群管理工作。
主节点和其他节点之间通过 Ping 的方式互检查,主节点负责 Ping 所有其他节点,判断是否有节点已经挂掉。其他节点也通过 Ping 的方式判断主节点是否处于可用状态。
虽然对节点做了角色区分,但是用户的请求可以发往任何一个节点,并由该节点负责分发请求、收集结果等操作,而不需要主节点转发。这种节点可称之为协调节点,协调节点是不需要指定和配置的,集群中的任何节点都可以充当协调节点的角色。
集群模式可以是:单机多节点 & 多机单/多节点 eg. 1 台机器 = 1 个集群,节点是 ES 进程(非生产) N 台机器 = 1 个集群,节点是 机器中的 ES 进程(生产)
集群健康状态
要检查群集运行状况,我们可以在 Kibana 控制台中运行以下命令 GET /_cluster/health,得到如下信息:
{
"cluster_name" : "wujiajian",
"status" : "yellow",
"timed_out" : false,
"number_of_nodes" : 1,
"number_of_data_nodes" : 1,
"active_primary_shards" : 9,
"active_shards" : 9,
"relocating_shards" : 0,
"initializing_shards" : 0,
"unassigned_shards" : 5,
"delayed_unassigned_shards" : 0,
"number_of_pending_tasks" : 0,
"number_of_in_flight_fetch" : 0,
"task_max_waiting_in_queue_millis" : 0,
"active_shards_percent_as_number" : 64.28571428571429
}
集群状态通过 绿,黄,红 来标识:
- 绿色:集群健康完好,一切功能齐全正常,所有分片和副本都可以正常工作。
- 黄色:预警状态,所有主分片功能正常,但至少有一个副本是不能正常工作的。此时集群是可以正常工作的,但是高可用性在某种程度上会受影响。
- 红色:集群不可正常使用。某个或某些分片及其副本异常不可用,这时集群的查询操作还能执行,但是返回的结果会不准确。对于分配到这个分片的写入请求将会报错,最终会导致数据的丢失。
当集群状态为红色时,它将会继续从可用的分片提供搜索请求服务,但是你需要尽快修复那些未分配的分片。
分片 Shards
ES 支持 PB 级全文搜索,当索引上的数据量太大的时候,ES 通过水平拆分的方式将一个索引上的数据拆分出来分配到不同的数据块上,拆分出来的数据库块称之为一个分片。
这类似于 MySQL 的分库分表,只不过 MySQL 分库分表需要借助第三方组件而 ES 内部自身实现了此功能。
在一个多分片的索引中写入数据时,通过路由来确定具体写入哪一个分片中,所以在创建索引的时候需要指定分片的数量,并且分片的数量一旦确定就不能修改。
分片的数量和下面介绍的副本数量都是可以通过创建索引时的 Settings 来配置,ES 默认为一个索引创建 5 个主分片, 并分别为每个分片创建一个副本。
PUT /myIndex
{
"settings": {
"number_of_shards" : 5,
"number_of_replicas" : 1
}
}
ES 通过分片的功能使得索引在规模上和性能上都得到提升,每个分片都是 Lucene 中的一个索引文件,每个分片必须有一个主分片和零到多个副本。
副本 Replicas
副本就是对分片的 Copy,每个主分片都有一个或多个副本分片,当主分片异常时,副本可以提供数据的查询等操作。
主分片和对应的副本分片是不会在同一个节点上的,所以副本分片数的最大值是 N-1(其中 N 为节点数)。
对文档的新建、索引和删除请求都是写操作,必须在主分片上面完成之后才能被复制到相关的副本分片。ES 为了提高写入的能力这个过程是并发写的,同时为了解决并发写的过程中数据冲突的问题,ES 通过乐观锁的方式控制,每个文档都有一个 _version (版本)号,当文档被修改时版本号递增。
一旦所有的副本分片都报告写成功才会向协调节点报告成功,协调节点向客户端报告成功。

从上图可以看出为了达到高可用,Master 节点会避免将主分片和副本分片放在同一个节点上。假设这时节点 Node1 服务宕机了或者网络不可用了,那么主节点上主分片 S0 也就不可用了。幸运的是还存在另外两个节点能正常工作,这时 ES 会重新选举新的主节点,而且这两个节点上存在我们所需要的 S0 的所有数据。
我们会将 S0 的副本分片提升为主分片,这个提升主分片的过程是瞬间发生的。此时集群的状态将会为 Yellow。为什么我们集群状态是 Yellow 而不是 Green 呢?虽然我们拥有所有的 2 个主分片,但是同时设置了每个主分片需要对应两份副本分片,而此时只存在一份副本分片。所以集群不能为 Green 的状态。
如果我们同样关闭了 Node2 ,我们的程序依然可以保持在不丢失任何数据的情况下运行,因为 Node3 为每一个分片都保留着一份副本。
如果我们重新启动 Node1 ,集群可以将缺失的副本分片再次进行分配,那么集群的状态又将恢复到原来的正常状态。如果 Node1 依然拥有着之前的分片,它将尝试去重用它们,只不过这时 Node1 节点上的分片不再是主分片而是副本分片了,如果期间有更改的数据只需要从主分片上复制修改的数据文件即可。
小结
- 将数据分片是为了提高可处理数据的容量和易于进行水平扩展,为分片做副本是为了提高集群的稳定性和提高并发量。
- 副本是乘法,越多消耗越大,但也越保险。分片是除法,分片越多,单分片数据就越少也越分散。
- 副本越多,集群的可用性就越高,但是由于每个分片都相当于一个 Lucene 的索引文件,会占用一定的文件句柄、内存及 CPU。
- 并且分片间的数据同步也会占用一定的网络带宽,所以索引的分片数和副本数也不是越多越好。
映射 Mapping
映射是用于定义 ES 对索引中字段的存储类型、分词方式和是否存储等信息,就像数据库中的 Schema ,描述了文档可能具有的字段或属性、每个字段的数据类型。
只不过关系型数据库建表时必须指定字段类型,而 ES 对于字段类型可以不指定然后动态对字段类型猜测,也可以在创建索引时具体指定字段的类型。
对字段类型根据数据格式自动识别的映射称之为动态映射(Dynamic Mapping),我们创建索引时具体定义字段类型的映射称之为静态映射或显示映射(Explicit Mapping)。
在讲解动态映射和静态映射的使用前,我们先来了解下 ES 中的数据有哪些字段类型?之后我们再讲解为什么我们创建索引时需要建立静态映射而不使用动态映射。ES(v6.8)中字段数据类型主要有以下几类:

Text 用于索引全文值的字段,例如电子邮件正文或产品说明。这些字段是被分词的,它们通过分词器传递 ,在被索引之前将字符串转换为单个术语的列表。分析过程允许 Elasticsearch 搜索单个单词中每个完整的文本字段。文本字段不用于排序,很少用于聚合。
Keyword 用于索引结构化内容的字段,例如电子邮件地址,主机名,状态代码,邮政编码或标签。它们通常用于过滤,排序,和聚合。Keyword 字段只能按其确切值进行搜索。
通过对字段类型的了解我们知道有些字段需要明确定义的,例如某个字段是 Text 类型还是 Keyword 类型差别是很大的,时间字段也许我们需要指定它的时间格式,还有一些字段我们需要指定特定的分词器等等。
如果采用动态映射是不能精确做到这些的,自动识别常常会与我们期望的有些差异。所以创建索引的时候一个完整的格式应该是指定分片和副本数以及 Mapping 的定义,如下:
PUT my_index
{
"settings" : {
"number_of_shards" : 5,
"number_of_replicas" : 1
},
"mappings": {
"_doc": {
"properties": {
"title": { "type": "text" },
"name": { "type": "text" },
"age": { "type": "integer" },
"created": {
"type": "date",
"format": "strict_date_optional_time||epoch_millis"
}
}
}
}
}
shard、index、segment、doc 的关系
| 层级 | 名称 | 关系 | 定义与作用 | 说明 | 举例 |
|---|---|---|---|---|---|
| 1 | Cluster | 1 个集群 → 包含 N 个 Node | 整个 Elasticsearch 服务 | 1 个 | |
| 2 | Node | 1 个节点 → 持有若干个 Shard | 一台物理机/虚拟机/容器,运行一个 ES 进程 | Node A、B、C | |
| 3 | Index | 1 个 Index → 包含多个 Shard | 用户看到的“索引”,类似 MySQL 里的一张表(table) | 用户创建、删除的对象; 对外提供统一的 API 接口 | 1 个 logs-2025.12 索引 |
| 4 | Shard | 1 个 Index → 包含 N 个 Primary Shard + M 个 Replica Shard | 真正存放数据的最小工作单元。数据是按文档 _id 哈希分散到不同 Primary Shard 上 | 真正干活的单元; 每个 shard 是一个完整的 Lucene 实例; 数据不会跨 shard 存储(除非用了 routing) | 1. Primary Shard:5 个平均分布在 3 个节点上(比如 A:2 个, B:2 个, C:1 个); 2. Replica Shard:每个 primary 对应 1 个 replica,落在另外的节点上,保证不和 primary 同节点 3. 总共 10 个 shard,3 个节点平均每台持有 3~4 个 shard |
| 5 | Segment | 1 个 Shard → 包含多个 Segment(几十到几千个) | Lucene 的不可变索引文件。Shard 里的所有数据实际上是由这些 segment 拼起来的 | 不可变文件;一次 bulk 或一次 flush 产生一个新 segment; 查询时把本 shard 内所有 segment 结果合并返回 | 每个 Shard 内部几十~几百个 segment |
| 6 | Document | 1 个 Segment → 包含成千上万条 Document | 一条 JSON 文档,也就是用户最底层的一条记录 | 一条 JSON 记录;物理上分散在各个 shard 的各个 segment 里 | 总共 10 亿条 doc 均匀分散到 10 个 shard 的所有 segment 中 |
1.场景假设:
- 索引名称:articles-2025.12
- 分片设置:5 individual primary shards + 1 replica(共 10 个 shard)
- 集群:3 个节点(Node-1、Node-2、Node-3)
- 你用 _bulk 发送 1000 条文章文档(总 payload ≈ 8MB)
| step | where | what | 关键细节 |
|---|---|---|---|
| 1 | 客户端 → 任意 Node(协调节点) | 客户端把 1000 条的 _bulk 请求发给集群中任意一个节点(比如 Node-1) | 普通的 HTTP 请求 |
| 2 | 协调节点(Node-1) | 解析整个 bulk payload,把 1000 条操作拆成列表 对每条文档计算:shard = hash(_id) % 5 把 1000 条按目标 primary shard 分成 5 组(例如:shard0:210 条、shard1:180 条 …) | “路由”过程 |
| 3 | 协调节点并行转发 | 把属于同一个 primary shard 的操作一次性转发给持有该 primary 的节点 例如:shard0 在 Node-2 → 发 210 条过去 shard3 在 Node-1 本机 → 直接本地处理 | 网络内部请求,极快 |
| 4 | 每个 Primary Shard 所在节点 | 收到属于自己的那部分 bulk 子请求后: 1. 先写入内存的 Translog(事务日志,顺序追加,fsync 可控) 2. 把文档放入内存的 In-Memory Buffer(Lucene 的内存索引结构) | 此时返回 200 已成功(默认 refresh_interval=1s) |
| 5 | 协调节点收集所有 primary 结果 | 等所有 5 个 primary 都返回成功后,协调节点才给客户端返回 200 OK | 这一步只要 primary 成功就算写入成功 |
| 6 | 后台异步复制到 Replica | 每个 primary 成功后,异步把这批操作并行复制给自己的 replica shard(也在其他节点) replica 同样走 内存 buffer + translog | 保证高可用 |
| 7 | 内存 Buffer 积累 | 1000 条文档现在都在各自 shard 的内存 buffer 中(每个 shard 平均 200 条左右) | 默认 1 秒后会 refresh |
| 8 | Refresh(默认每 1 秒一次) | 每个 shard 把内存 buffer 中的文档做一次 refresh: → 把内存索引结构变成一个可搜索但还没落盘的 segment(仍在 OS cache) | 此时搜索一定能搜到这 1000 条 |
| 9 | Flush(默认 5GB translog 或 30 分钟) | 当满足 flush 条件时: 1. 触发一次 refresh 2. 把当前所有内存中的 segment 真正写入磁盘,生成新的 .cfs 文件 3. 清空 translog(因为已经落盘) | 真正“落盘” |
| 10 | 产生新的 Segment 文件 | 这次 bulk + 之前积累的文档一起,可能会在每个 shard 里产生 1~2 个新 segment 例如 shard0 新增 _a1.cfs(包含这 210 条 + 之前未 flush 的) | 磁盘上真正出现了新的 segment 文件 |
| 11 | Translog 清空或 checkpoint | flush 完成后,旧 translog 被清空或新开文件 即使机器宕机也能靠 translog 恢复到最后一次 flush 后的所有操作 | 保证持久性 |
| 12 | 后台 Segment Merge | 过一段时间(分钟到小时),Lucene 后台会把小 segment 合并成大 segment,物理删除被删除/更新的旧版本文档 | 回收空间、提升查询性能 |
完整追踪「一次搜索请求」从客户端发出到返回结果的全路径:
| step | where | what | 关键细节 |
|---|---|---|---|
| 1 | 客户端 → 任意 Node(协调节点) | 客户端发送 GET /articles-2025.12/_search?q=标题: 人工智能&size=10 | 可以是任意节点,通常走负载均衡 |
| 2 | 协调节点(比如 Node-1) | 1. 解析 DSL,确定需要访问 articles-2025.12 2. 查看集群状态,知道这个索引有 5 个 primary + 5 个 replica 3. 负载均衡,决定本次查询让哪些 shard 参与(默认所有 5 个 primary + 部分或全部 replica) | 通常会让全部 10 个 shard 都参与,以获得最高吞吐和容错 |
| 3 协调节点并行散射(Scatter) | 同时向持有这 10 个 shard 的节点发起内部查询请求(多线程并发,同一节点上的多个 shard 也是并行执行 | 例如: Node-1 本机执行本地 shard2(P) + shard4(R)、Node-2 执行 shard0(P) + shard1(R) + shard3(R)、Node-3 执行 shard1(P) + shard0(R) + … | |
| 4 每个参与的 Shard 本地执行查询(Query Phase) | 1. 把查询 DSL 翻译成 Lucene 查询 2. 对本 shard 内当前所有 segment(包括刚 refresh 出的内存 segment)并行执行搜索 3. 每个 segment 独立返回命中的 doc ID + 打分 4. 本 shard 内部把所有 segment 的结果合并、排序,取本 shard 最匹配的前 N 条(默认是 from+size,比如 10 条) | 这一步只返回 doc ID + score,不返回完整 _source | |
| 5 每个 Shard 把结果返回给协调节点 | 每个 shard 返回:自己的 shard 编号 + 前 10 条(doc ID + score + sort 值) 总共 10 个 shard → 协调节点收到 10 × 10 = 100 条候选文档 | 网络传输量非常小 | |
| 6 协调节点全局合并(Gather Phase) | 协调节点把 100 条候选文档重新全局排序,取前 10 条 | 这一步决定了最终返回给用户的顺序 | |
| 7 协调节点发起 Fetch Phase | 协调节点再次并行向「真正持有这 10 条文档的 shard」发起取源请求(fetch _source、highlight、stored fields 等) 注意:这 10 条文档可能分布在不同的 shard 上 | 例如第 1 名在 shard0,第 2 名在 shard3 … | |
| 8 各个相关 Shard 返回完整文档 | 每个 shard 直接从自己的 segment 文件里读取完整 JSON _source(已经在 OS cache 中,极快)并返回 | 这才是真正读取大文本的阶段 | |
| 9 协调节点收到全部 10 条完整文档 | 协调节点做最后的包装(加 highlight、_score、排序等) | ||
| 10 协调节点返回最终 JSON 给客户端 | 客户端收到最终 10 条结果 | 整个过程通常 5~50ms 完成 |
写索引原理
下图描述了 3 个节点的集群,共拥有 12 个分片,其中有 4 个主分片(S0、S1、S2、S3)和 8 个副本分片(R0、R1、R2、R3),每个主分片对应两个副本分片,节点 1 是主节点(Master 节点)负责整个集群的状态。

- 写索引是只能写在主分片上,然后同步到副本分片。这里有四个主分片,一条数据 ES 是根据什么规则写到特定分片上的呢?
- 这条索引数据为什么被写到 S0 上而不写到 S1 或 S2 上?那条数据为什么又被写到 S3 上而不写到 S0 上了?
首先这肯定不会是随机的,否则将来要获取文档的时候我们就不知道从何处寻找了。实际上,这个过程是根据下面这个公式决定的:
shard = hash(routing) % number_of_primary_shards
Routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。Routing 通过 Hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到余数。这个在 0 到 number_of_primary_shards-1 之间的余数,就是我们所寻求的文档所在分片的位置。
这就解释了为什么我们要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
由于在 ES 集群中每个节点通过上面的计算公式都知道集群中的文档的存放位置,所以每个节点都有处理读写请求的能力。在一个写请求被发送到某个节点后,该节点即为前面说过的协调节点,协调节点会根据路由公式计算出需要写到哪个分片上,再将请求转发到该分片的主分片节点上。

假如此时数据通过路由计算公式取余后得到的值是 shard=hash(routing)%4=0,则具体流程如下:
- 客户端向 ES1 节点(协调节点)发送写请求,通过路由计算公式得到值为 0,则当前数据应被写到主分片 S0 上。
- ES1 节点将请求转发到 S0 主分片所在的节点 ES3,ES3 接受请求并写入到磁盘。
- 并发将数据复制到两个副本分片 R0 上,其中通过乐观并发控制数据的冲突。一旦所有的副本分片都报告成功,则节点 ES3 将向协调节点报告成功,协调节点向客户端报告成功。
存储原理
上面介绍了在 ES 内部索引的写处理流程,这个流程是在 ES 的内存中执行的,数据被分配到特定的分片和副本上之后,最终是存储到磁盘上的,这样在断电的时候就不会丢失数据。
具体的存储路径可在配置文件 ../config/elasticsearch.yml 中进行设置,默认存储在安装目录的 Data 文件夹下。建议不要使用默认值,因为若 ES 进行了升级,则有可能导致数据全部丢失:
path.data: /path/to/data //索引数据
path.logs: /path/to/logs //日志记录
分段存储
索引文档以段的形式存储在磁盘上,何为段?索引文件被拆分为多个子文件,则每个子文件叫作段,每一个段本身都是一个倒排索引,并且段具有不变性,一旦索引的数据被写入硬盘,就不可再修改。
在底层采用了分段的存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。
段被写入到磁盘后会生成一个提交点,提交点是一个用来记录所有提交后段信息的文件。一个段一旦拥有了提交点,就说明这个段只有读的权限,失去了写的权限。相反,当段在内存中时,就只有写的权限,而不具备读数据的权限,意味着不能被检索([[#ES 段在JVM内存中只能写,在硬盘才能读,查询效率不是很低?]])。
段的概念提出主要是因为:在早期全文检索中为整个文档集合建立了一个很大的倒排索引,并将其写入磁盘中。
如果索引有更新,就需要重新全量创建一个索引来替换原来的索引。这种方式在数据量很大时效率很低,并且由于创建一次索引的成本很高,所以对数据的更新不能过于频繁,也就不能保证时效性。
索引文件分段存储并且不可修改,那么新增、更新和删除如何处理呢?
- 新增,新增很好处理,由于数据是新的,所以只需要对当前文档新增一个段就可以了。
- 删除,由于不可修改,所以对于删除操作,不会把文档从旧的段中移除而是通过新增一个 .del 文件,文件中会列出这些被删除文档的段信息。这个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。
- 更新,不能修改旧的段来进行反映文档的更新,其实更新相当于是删除和新增这两个动作组成。会将旧的文档在 .del 文件中标记删除,然后文档的新版本被索引到一个新的段中。可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就会被移除。
段被设定为不可修改具有一定的优势也有一定的缺点,优势主要表现在:
- 不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题。
- 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
- 其它缓存 (像 Filter 缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
- 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和需要被缓存到内存的索引的使用量。
段的不变性的缺点如下:
- 当对旧数据进行删除时,旧数据不会马上被删除,而是在 .del 文件中被标记为删除。而旧数据只能等到段更新时才能被移除,这样会造成大量的空间浪费。
- 若有一条数据频繁的更新,每次更新都是新增新的标记旧的,则会有大量的空间浪费。
- 每次新增数据时都需要新增一个段来存储数据。当段的数量太多时,对服务器的资源例如文件句柄的消耗会非常大。
- 在查询的结果中包含所有的结果集,需要排除被标记删除的旧数据,这增加了查询的负担
延迟写策略
介绍完了存储的形式,那么索引写入到磁盘的过程是怎样的?是否是直接调 Fsync 物理性地写入磁盘?答案是显而易见的,如果是直接写入到磁盘上,磁盘的 I/O 消耗上会严重影响性能。那么当写数据量大的时候会造成 ES 停顿卡死,查询也无法做到快速响应。如果真是这样 ES 也就不会称之为近实时全文搜索引擎了。
为了提升写的性能,ES 并没有每新增一条数据就增加一个段到磁盘上,而是采用延迟写的策略。每当有新增的数据时,就将其先写入到内存中,在内存和磁盘之间是文件系统缓存。当达到默认的时间(1 秒钟)或者内存的数据达到一定量时,会触发一次刷新(Refresh),将内存中的数据生成到一个新的段上并缓存到文件缓存系统上,稍后再被刷新到磁盘中并生成提交点。
这里的内存使用的是 ES 的 JVM 内存,而文件缓存系统使用的是操作系统的内存。
新的数据会继续的被写入内存,但内存中的数据并不是以段的形式存储的,因此不能提供检索功能([[#数据写入时,在JVM内存不是段的形式存储的,不能查询,为什么?]])。由内存刷新到文件缓存系统的时候会生成新的段,并将段打开以供搜索使用,而不需要等到被刷新到磁盘。
在 Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 Refresh (即内存刷新到文件缓存系统)。
默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch 是近实时搜索,因为文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。
我们也可以手动触发 Refresh,POST /_refresh 刷新所有索引,POST /nba/_refresh 刷新指定的索引。
Tips:尽管刷新是比提交轻量很多的操作([[#刷新 refresh 和提交 commit 的区别]]),它还是会有性能开销。当写测试的时候, 手动刷新很有用,但是不要在生产>环境下每次索引一个文档都去手动刷新。而且并不是所有的情况都需要每秒刷新。
可能你正在使用 Elasticsearch 索引大量的日志文件, 你可能想优化索引速度而不是>近实时搜索。这时可以在创建索引时在 Settings 中通过调大 refresh_interval = ”30s” 的值 , 降低每个索引的刷新频率,设值时需要注意后面带上时间单位,否则默认是毫秒。当 refresh_interval=-1 时表示关闭索引的自动刷新。
虽然通过延时写的策略可以减少数据往磁盘上写的次数提升了整体的写入能力,但是我们知道文件缓存系统也是内存空间,属于操作系统的内存,只要内存都存在断电或异常情况下丢失数据的危险。为了避免丢失数据,Elasticsearch 添加了事务日志(Translog),事务日志记录了所有还没有持久化到磁盘的数据。

- 一个新文档被索引之后,先被写入到内存中,但是为了防止数据的丢失,会追加一份数据到事务日志中。不断有新的文档被写入到内存,同时也都会记录到事务日志中。这时新数据还不能被检索和查询。
- 当达到默认的刷新时间或内存中的数据达到一定量后,会触发一次 Refresh,将内存中的数据以一个新段形式刷新到文件缓存系统中并清空内存。这时虽然新段未被提交到磁盘,但是可以提供文档的检索功能且不能被修改。
- 随着新文档索引不断被写入,当日志数据大小超过 512M 或者时间超过 30 分钟时,会触发一次 Flush。内存中的数据被写入到一个新段同时被写入到文件缓存系统,文件系统缓存中数据通过 Fsync 刷新到磁盘中,生成提交点,日志文件被删除,创建一个空的新日志。
通过这种方式当断电或需要重启时,ES 不仅要根据提交点去加载已经持久化过的段,还需要工具 Translog 里的记录,把未持久化的数据重新持久化到磁盘上,避免了数据丢失的可能。
段合并
由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。每一个段都会消耗文件句柄、内存和 CPU 运行周期。更重要的是,每个搜索请求都必须轮流检查每个段然后合并查询结果,所以段越多,搜索也就越慢。
Elasticsearch 通过在后台定期进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档不会被拷贝到新的大段中。合并的过程中不会中断索引和搜索。

段合并在进行索引和搜索时会自动进行,合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中,这些段可以是内存中未提交的段和磁盘上已提交的段。
合并结束后老的段会被删除,新的段被 Flush 到磁盘,同时写入一个包含新段且排除旧的和较小的段的新提交点,新的段被打开可以用来搜索。
段合并的计算量庞大, 而且还要吃掉大量磁盘 I/O,段合并会拖累写入速率,如果任其发展会影响搜索性能。Elasticsearch 在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。
ES 段在 JVM 内存中只能写,在硬盘才能读,查询效率不是很低?
“段(Segment)在内存中只写、落盘后只读”不是缺点,而是 Elasticsearch 能做到“10 万 QPS、毫秒级查询”的根本原因

“只读”可以让 ES 做很多“作弊”式的极致优化

因为落盘后永不修改,ES 敢把所有能想的优化都堆上,MySQL 不敢,因为它要支持随时 UPDATE。

写确实比 MySQL 慢一点,但 ES 用“内存 + 批量刷盘”完美解决:
- 你写的数据先到内存 Buffer(极速)
- 每秒 refresh 一次 → 变成一个新的只读段(1 秒延迟,可接受)
- 旧段后台慢慢 merge(对查询零影响)
所以写性能虽然不是最强,但“近实时 + 毫秒级查询”完美兼得。
数据写入时,在 JVM 内存不是段的形式存储的,不能查询,为什么?
Elasticsearch 是“近实时”(Near Real-Time)
写入请求
│
├─→ 进 JVM Buffer + Translog 内存
│
├─→ Translog 立刻 fsync (写入)磁盘,index.translog.sync_interval 可调 ← 防丢
│
│
└─→ 等待(默认最多 1 秒)
│
▼
refresh:生成新只读 Segment → 先写入 OS Page Cache → 清空 JVM Buffer
相当于 Lucene commit,真正可搜的起点
│
▼
操作系统异步刷盘(fsync)
│
▼
等待一段时间
│
▼
merge:小 Segment 合并成大 Segment,清理被删除文档(tombstone),后台异步触发
│
▼
等待一段时间
│
▼
flush:默认 30 分钟一次或者手动触发,强制 commit+fsync 所有文件+清 translog
刷新 refresh 和提交 commit 的区别
| 操作 | 触发方式 | 产生新 Segment | 是否清空 JVM Buffer | 是否截断 Translog | 是否 commit | 目的 | 命令 |
|---|---|---|---|---|---|---|---|
| refresh | 每 1s 自动 或 手动_refresh | 是 | 是 | 否 | 是 | 让刚写的数据立刻能搜 | POST /_refresh |
| commit | Lucene 底层原子操作 | 持久化 | 取决于调用者 | 否 | —— | 一次性把内存结构写成磁盘文件(原子操作) | 不直接调 |
| flush | 30min 自动 或 translog 太大 或 手动 _flush | 是(因为会触发一次 commit) | 是 | 是 | 是 | 清内存 + 截断 translog + 彻底落盘 | POST /_flush |
| merge | 后台持续自动 或 手动_forcemerge | 是(合并成更少更大的) | 否 | 否 | 普通 merge 不 commit | 减少 segment 数量 + 清理被删文档 | POST /_forcemerge |
flush = refresh(commit)+ 截断 translog + 确保所有 segment 文件 fsync 到磁盘
性能优化
存储设备
磁盘在现代服务器上通常都是瓶颈。Elasticsearch 重度使用磁盘,你的磁盘能处理的吞吐量越大,你的节点就越稳定。这里有一些优化磁盘 I/O 的技巧:
- 使用 SSD。就像其他地方提过的, 他们比机械磁盘优秀多了。
- 使用 RAID 0。条带化 RAID 会提高磁盘 I/O,代价显然就是当一块硬盘故障时整个就故障了。不要使用镜像或者奇偶校验 RAID 因为副本已经提供了这个功能。
- 另外,使用多块硬盘,并允许 Elasticsearch 通过多个 path.data 目录配置把数据条带化分配到它们上面。
- 不要使用远程挂载的存储,比如 NFS 或者 SMB/CIFS。这个引入的延迟对性能来说完全是背道而驰的。
- 如果你用的是 EC2,当心 EBS。即便是基于 SSD 的 EBS,通常也比本地实例的存储要慢。
内部索引优化

Elasticsearch 为了能快速找到某个 Term,先将所有的 Term 排个序,然后根据二分法查找 Term,时间复杂度为 logN,就像通过字典查找一样,这就是 Term Dictionary。
现在再看起来,似乎和传统数据库通过 B-Tree 的方式类似。但是如果 Term 太多,Term Dictionary 也会很大,放内存不现实,于是有了 Term Index。就像字典里的索引页一样,A 开头的有哪些 Term,分别在哪页,可以理解 Term Index 是一棵树。这棵树不会包含所有的 Term,它包含的是 Term 的一些前缀。通过 Term Index 可以快速地定位到 Term Dictionary 的某个 Offset,然后从这个位置再往后顺序查找。
在内存中用 FST 方式压缩 Term Index,FST 以字节的方式存储所有的 Term,这种压缩方式可以有效的缩减存储空间,使得 Term Index 足以放进内存,但这种方式也会导致查找时需要更多的 CPU 资源。
对于存储在磁盘上的倒排表同样也采用了压缩技术减少存储所占用的空间。

调整配置参数
- 给每个文档指定有序的具有压缩良好的序列模式 ID,避免随机的 UUID-4 这样的 ID,这样的 ID 压缩比很低,会明显拖慢 Lucene。
- 对于那些不需要聚合和排序的索引字段禁用 Doc values。Doc Values 是有序的基于 document=>field value 的映射列表。
- 不需要做模糊检索的字段使用 Keyword 类型代替 Text 类型,这样可以避免在建立索引前对这些文本进行分词。
- 如果你的搜索结果不需要近实时的准确度,考虑把每个索引的 index.refresh_interval 改到 30s 。如果你是在做大批量导入,导入期间你可以通过设置这个值为 -1 关掉刷新,还可以通过设置 index.number_of_replicas: 0 关闭副本。别忘记在完工的时候重新开启它。
- 避免深度分页查询建议使用 Scroll 进行分页查询。普通分页查询时,会创建一个 from+size 的空优先队列,每个分片会返回 from+size 条数据,默认只包含文档 ID 和得分 Score 给协调节点。
- 如果有 N 个分片,则协调节点再对(from+size)×n 条数据进行二次排序,然后选择需要被取回的文档。当 from 很大时,排序过程会变得很沉重,占用 CPU 资源严重。
- 减少映射字段,只提供需要检索、聚合或排序的字段。其他字段可存在其他存储设备上,例如 Hbase,在 ES 中得到结果后再去 Hbase 查询这些字段。
- 创建索引和查询时指定路由 Routing 值,这样可以精确到具体的分片查询,提升查询效率。路由的选择需要注意数据的分布均衡。
JVM 调优
- 确保堆内存最小值( Xms )与最大值( Xmx )的大小是相同的,防止程序在运行时改变堆内存大小。Elasticsearch 默认安装后设置的堆内存是 1GB。可通过 ../config/jvm.option 文件进行配置,但是最好不要超过物理内存的 50% 和超过 32GB。
- GC 默认采用 CMS 的方式,并发但是有 STW 的问题,可以考虑使用 G1 收集器。
- ES 非常依赖文件系统缓存(Filesystem Cache),快速搜索。一般来说,应该至少确保物理上有一半的可用内存分配到文件系统缓存。
基础操作
IK 分词器
IK 分词算法
- ik_smart:最少切分
- ik_max_word:最细粒度切分
分词器切分结果:
GET _analyze
{
"analyzer":"ik_smart",
"text": "梦想家"
}
编辑自定义词库
进入 elasticsearch/plugins/ik/config 目录,新建一个 my.dic 文件,编辑内容
你好 中国
修改 IKAnalyzer.cfg.xml(在 ik/config 目录下)
<properties>
<comment>IK Analyzer 扩展配置</comment> <!-- 用户可以在这里配置自己的扩展字典 -->
<entry key="ext_dict">my.dic</entry> <!-- 用户可以在这里配置自己的扩展停止词字典 -->
<entry key="ext_stopwords"></entry>
</properties>
修改完配置重新启动 elasticsearch,再次测试
Rest 风格
基本 的 Rest 命令说明:
| method | url 地址 | 描述 |
|---|---|---|
| PUT | localhost:9200/索引名称/类型名称/文档 id | 创建文档(指定文档 id ) |
| POST | localhost:9200/索引名称/类型名称 | 创建文档(随机文档 id ) |
| POST | localhost:9200/索引名称/类型名称/文档 id/_update | 修改文档 |
| DELETE | localhost:9200/索引名称/类型名称/文档 id | 删除文档 |
| GET | localhost:9200/索引名称/类型名称/文档 id | 通过文档 id 查询文档 |
| POST | localhost:9200/索引名称/类型名称/_search | 查询所有数据 |
索引基本操作
字段类型
- 字符串:text、keyword
- 数值类型:long、integer、short、byte、double、float、half_float
- 日期类型:date
- 布尔值类型:boolean
- 二进制类型
- binary
- …
操作类型
- PUT 新增
PUT /test2
{
"mappings": {
"properties": {
"name":{
"type":"text"
},
"age":{
"type":"long"
},
"birthday":{
"type":"date"
}
}
}
}
- UPDATE
想要修改文档里的字段信息呢?我们可以选择 UPDATE 也可以 选择 PUT 进行覆盖。例如我可以像下图中的例子,将之前 test3 索引中的 1 号文档中的 name 字段修改后,重复提交,发现更新成功,但是注意 version 版本号已经变成了 2。

但是注意这种方法有弊端,如果我们在 PUT 的过程中,遗漏了字段,那么数据就会被新数据覆盖!所以,修改数据不建议使用 PUT 覆盖的方式!
我们使用 POST 命令,在 id 后面跟 _update ,要修改的内容放到 doc 文档 (属性) 中即可。
可以发现此时更新之后的 version 变成了 3。所以,一旦索引被创建了之后,所有的修改都可以通过版本号看到变化。

- DELETE
DELETE 索引
其他命令
GET _cat/health来获取集群的一个健康状态- GET cat/indices?v 获取到当所有索引的状态健康情况,分片,数据储存大小等等
文档基本操作
全量的 DSL 查询语句请参见 Query DSL
创建索引
PUT /my_index
{
"settings": {
"number_of_shards": 1
},
"mappings": {
"properties": {
"productName": {
"type": "text",
"analyzer": "ik_smart"
},
"size": {
"type": "keyword"
}
}
}
}
导入数据
POST /my_index/_bulk
{"index":{}}
{"productName":"2017秋装新款文艺衬衫女装","size":"L"}
{"index":{}}
{"productName":"2017秋装新款文艺衬衫女装","size":"M"}
{"index":{}}
{"productName":"2017秋装新款文艺衬衫女装","size":"S"}
{"index":{}}
{"productName":"2018春装新款牛仔裤女装","size":"M"}
{"index":{}}
{"productName":"2018春装新款牛仔裤女装","size":"S"}
{"index":{}}
{"productName":"2017春装新款休闲裤女装","size":"L"}
{"index":{}}
{"productName":"2017春装新款休闲裤女装","size":"S"}
简单查询
- 返回想要部分的字段:仅返回 name 和 desc 属性
GET alice/user/_search
{
"query":{
"match": {
"name": "爱丽丝"
}
},
"_source":["name","desc"]
}
排序查询
- 可排序字段:数字、日期、ID
GET alice/user/_search
{
"query":{
"match": {
"name": "爱丽丝"
}
},
"sort": [
{
"age":
{
"order": "asc"
}
}
],
"from":0,
"size":1
}
分页查询
GET alice/user/_search
{
"query":
{"match_all": {}
},
"from":0, # 从第n条开始
"size":4 # 返回n条数据
}
布尔查询
- must(相当于 and)
// name为alice,并且age是25岁
GET alice/user/_search
{
"query":{
"bool": {
"must":[
{
"match":{
"name":"爱丽丝"
}
},
{
"match":{
"age":25
}
}
]
}
}
}
- should(相当于 or)
// name为爱丽丝或 age 为25
GET alice/user/_search
{
"query":{
"bool": {
"should":[
{
"match":{
"name":"爱丽丝"
}
},
{
"match":{
"age":25
}
}
]
}
}
}
- must_not(相当于 not)
// 年龄不是 25
GET alice/user/_search
{
"query":{
"bool": {
"must_not":[
{
"match":{
"age":25
}
}
]
}
}
}
- Filter
// `name` 为爱丽丝,`age` 大于 24 的数据
GET alice/user/_search
{
"query":{
"bool":{
"must": [
{
"match": {
"name": "爱丽丝"
}
}
],
"filter": [
{
"range": {
"age": {
"gt": 24
}
}
}
]
}
}
}
filter 条件过滤查询,过滤条件的范围用 range 表示,其余操作如下 :
- gt 表示大于
- gte 表示大于等于
- lt 表示小于
- lte 表示小于等于
短语检索
// 查询 tags 文档
PUT /alice/user/6
{
"name":"大数据老K",
"age":25,
"desc":"技术成就自我!",
"tags":["男","学习","技术"]
}
// 查询`tags`中包含“男”的数据,也可以写多个标签 "tags":"男 学习",满足其一返回
GET alice/user/_search
{
"query":{
"match":{
"tags":"男"
}
}
}
精确查询
term 查询是直接通过倒排索引指定的词条进程精确查找的
分词
- term ,不经过分词,直接查询精确的值
- match,会使用分词器解析!(先分析文档,然后再通过分析的文档进行查询!)
keyword 字段类型不会被分析器分析
查询多个精确值
PUT testdb/_doc/3
{
"t1":"22",
"t2":"2021-03-01"
}
PUT testdb/_doc/4
{
"t1":"33",
"t2":"2021-03-01"
}
// 以下效果都一样,两个记录均被查出
GET testdb/_search
{
"query": {
"bool":{
"should": [
{
"term": {
"t1":"22"
}
},
{
"term": {
"t1":"33"
}
}
]
}
}
}
GET testdb/_doc/_search
{
"query":{
"terms":{
"t1":["22","33"]
}
}
}
高亮显示
GET alice/user/_search
{
"query":{
"match": {
"name": "爱丽丝"
}
},
"highlight":{
"fields": {
"name": {}
}
}
}
// 自定义高亮标签:pre_tags中定义标签的前缀,post_tags中定义后缀
GET alice/user/_search
{
"query":{
"match": {
"name": "爱丽丝"
}
},
"highlight":{
"pre_tags": "<b class='key' style='color:red'>",
"post_tags": "</b>",
"fields": {
"name": {}
}
}
}
排序
相关性评分
在 Elasticsearch 中,相关性评分是搜索功能的核心,它决定了搜索结果的质量和排序。了解 Elasticsearch 是如何计算相关性评分的,特别是 TF-IDF 和 BM25 算法,对于优化搜索性能和结果至关重要。
TF-IDF
一个词在当前文档中出现得越频繁(TF 高),且在所有文档中出现得越稀少(IDF 高),这个词对当前文档的重要性 / 代表性就越高。
TF-IDF(Term Frequency-Inverse Document Frequency)是一种经典的信息检索算法,用于评估一个词语对于一个文件集或一个语料库中的其中一份文件的重要程度。它由两部分组成:
- TF(Term Frequency):词频,即词语在文档中出现的次数。计算公式为:TF = (词语在文档中出现的次数) / (文档中总词语数)。
- IDF(Inverse Document Frequency):逆文档频率,即词语在文档集合中的普遍重要程度。计算公式为:IDF = log((文档集合中文档总数) / (包含词语的文档数 + 1))。
优缺点:
TF-IDF 算法简单高效,但它也有明显的局限性。例如,它没有考虑文档长度和搜索词位置等因素,且对于高频词容易过度强调。
BM25
BM25(Best Matching 25)算法是对 TF-IDF 算法的改进和扩展,它在计算相关性得分时引入了更多因素,如文档长度和搜索词位置等。BM25 算法的主要目的是提高检索结果的质量,特别是在处理大规模文档集合时。
对于一个查询 (包含关键词 )和一篇文章 ,BM25 最终得分为所有关键词对文档的贡献之和:
简单看:
这里的关键变量是:
- :当前文档的长度。
- :所有文档的平均长度。
- :长度归一化系数(通常设为 0.75)。当 时,分母中的 小于 1。这会缩小分母,使得分数值变大。同样词频情况下,长文本会被惩罚。所以 决定了长文本被惩罚的力度,越小惩罚越小,如果 ,则完全不考虑长度(退化为类似 TF-IDF 的逻辑)。所以,长文本适合小 ,短文本适合大 。
- :词频饱和度(通常设为 0.75)。(通常取 1.2 到 2.0)决定了词频收益的上限。避免了高频词的过度强调。
- 为什么短文本在 BM25 上占优势:在一个长文本里,关键词出现 1 次到 5 次,分数提升很快;但从 10 次到 100 次,分数几乎不再增长。长文本词频天然很高,但由于 的存在,词频高是有天花板的。一旦达到饱和点,长文本就再也无法通过增加词频来对抗长度惩罚了。然而长文本和短文本拥有相同的 导致短文本高频文本容易得高分。
然而,BM25 算法也有其复杂性,需要调整多个参数以达到最佳效果。BM25 核心逻辑是:惩罚“水分”大的长文本,奖励“干货”密的短文本。
在 ES 中的应用
在 Elasticsearch 5.0 版本之前,默认使用的是 TF-IDF 算法进行相关性评分。从 5.0 版本开始,Elasticsearch 默认使用 BM25 算法,因为它在实际应用中表现更为优秀。
为了深入了解 Elasticsearch 是如何计算文档与查询的相关性得分的,可以使用 _explain API。这个 API 可以返回每个查询项在文档上的得分解释,包括各个组成部分(如子查询、因子、标准化等)及其对总评分的具体贡献:
GET /my_index/_search
{
"explain": true,
"query": {
"match": {
"text": "this is the first document"
}
}
}
在电商网站中,用户可以输入关键词“手机”进行搜索。Elasticsearch 会根据索引中所有包含“手机”的文档,并使用 BM25 算法计算它们的相关性得分。得分高的文档会排在搜索结果的前面,从而提高了用户体验。
LTR
Learning to Rank(学习排序)是机器学习的一个分支,核心目标是通过训练数据学习排序模型,对检索结果集进行最优排序,而非依赖 Elasticsearch 原生的 TF-IDF、BM25 等相关性算法。
其他知识
- 第三方插件
- 大数据分析: [用户画像]标签数据存储 Elasticsearch: 权威指南 | Elastic
- SEO 美团搜索中查询改写技术的探索与实践