es 源码分析&集群原理

主要分析几个问题:

主分片如何确定的:比如3分片2副本,实际会有3*(2+1) = 9个分片,主节点创建的时候会根据一定规则分到不同节点,比如同一分片ID不能在同一节点等规则。

写入数据中如何进行数据同步: 写到主分片所在的节点,主分片所在节点在同步到副本分片所在节点

查询是如何进行的,多个分片如何汇总结果:遍历所有住分片,请求数据,请求完成之后进行结果合并。

1. 环境准备

​ 以集群方式启动, 三个节点,1m1d1默认(1个master节点,1个data 节点,1个默认多角色节点), 参考前面。

2. 集群启动&节点角色

​ es 集群只能有一个主节点,即使配置了多个master,只是说可能成为备选matser,真正被选为master 的只有一个节点。 关于角色信息如下:

# 查看节点角色
GET /_cat/nodes?v
---
	m:master-eligible备选主节点(Master Node)。不直接处理数据写入和查询,它们的主要任务是维护集群的元数据和状态,如索引的创建、删除、分片分配等。
	带*的m: 主节点,负责集群级别的操作,如维护集群状态、处理节点加入/离开等。
	d:数据节点(Data Node)。存储索引的数据,并执行搜索、索引和获取操作。
	i:可以执行索引操作的节点。包括 索引、集群的聚合信息查看等,比如kibana 的monitor 面板就需要该角色的节点加入。
	在这个表格中,每个节点可能有一个或多个角色,例如m、d或di。

主节点负责维护心跳、同步分片的路由信息给其他节点;
带*的m 是可以升为主节点的,也被称为候选节点; 
d是data 节点,负责存储分片数据。

	任何角色的节点都会接收客户端的请求,接收到之后会根据索引计算分片信息然后转给对应的节点处理;candidate 会多一个选主的功能;master 多一个维护索引信息以及心跳信息的功能。

1. node 维护节点信息

org.elasticsearch.node.Node 维护了每个节点启动的角色信息、数据目录、日志目录、插件等启动的参数信息用于后续使用。

2. 集群选主

​ Discovery模块负责发现集群中的节点,以及选择主节点。ES支持多种不同Discovery类型选择,内置的实现有两种:Zen Discovery和coordinator。

1. Coordinator

​ ES 7.x 重构了一个新的集群协调层Coordinator,他实际上是 Raft 的实现,但并非严格按照 Raft 实现,而是做了一些调整。

参考: org.elasticsearch.discovery.DiscoveryModule, 可以看到默认转给了Coordinator 类

  1. org.elasticsearch.cluster.coordination.Coordinator#startElectionScheduler 方法

如果是master 角色,启动选主任务

  1. 调用到org.elasticsearch.cluster.coordination.PreVoteCollector.PreVotingRound#start 发起投票请求给广播的节点

  2. org.elasticsearch.cluster.coordination.PreVoteCollector.PreVotingRound#handlePreVoteResponse 处理选举结果,可以看到也是有term 值

2. zen

​ 它假定所有节点都有一个唯一的ID,使用该ID对节点进行排序。任何时候的当前Leader都是参与集群的最高ID节点。该算法的优点是

易于实现。但是,当拥有最大ID的节点处于不稳定状态的场景下会有问题。例如,Master负载过重而假死,集群拥有第二大ID的节点被

选为新主,这时原来的Master恢复,再次被选为新主,然后又假死。

​ ES 通过推迟选举,直到当前的 Master 失效来解决上述问题,只要当前主节点不挂掉,就不重新选主。但是容易产生脑裂(双主),

为此,再通过“法定得票人数过半”解决脑裂问题。

3. 数据存储以及操作过程源码分析

1. 存储以及更新机制

1. es 数据存储

1、基本概念

​ 正向索引(Forward Index):传统的索引形式,即文档到词汇的映射。每个文档有一个唯一的ID,索引中记录了每个文档包含的所有词汇及其位置信息。这种方式在查询时效率较低,因为它要求遍历所有文档来寻找匹配的词汇。
​ 倒排索引(Inverted Index):与正向索引相反,它是词汇到文档ID的映射。每个词汇项(Term)都关联着一个文档列表,这个列表记录了包含该词汇的所有文档的ID。这种方式极大地提高了查询效率,尤其是在处理大量文档和复杂查询时。

2、存储结构

包含:

(1). 单词词典(term dictionary)

记录所有文档的单词,一般比较大,记录单词到倒排列表的关联信息。单词词典一般用b+ tree 实现,存储在内存中。

(2). 倒排列表

记录了出现过某个单词的所有文档列表以及单词在该文档中出现的位置信息以及频率(做关联性算分),每条记录称为一个倒排项,倒排列表存在磁盘中,主要包含:

文档ID、单词频率(term frequence)、位置、偏移

单词词典和倒排列表组成结构如下:

2. 倒排索引更新策略

​ 搜索引擎需要处理的文档集合往往都是动态几何,在建好初始的索引后,不断有新文档进入系统,同时原来的文档集合内有些文档可能被删除或更改。

​ 动态所有通过在内存维护临时索引,可以实现对动态文档和实时搜索的支持。

​ 服务器内存总是有限的,随着新加入系统的文档越来越多,临时索引消耗的内存也会随之增加。

​ 当最初分配的内存将被使用完时,要考虑将临时索引的内容更新到磁盘索引,以释放内存。

索引基本思想:

  • 倒排索引是对初始文档集合建立的索引结构,一般单词词典存在内存,对应的倒排列表存在磁盘文件
  • 临时索引是在内存中实时建立的倒排索引,结构和前面的倒排索引一样,区别在于单词词典和倒排列表都在内存
  • 新文档进入系统,实时解析文档并将其加入临时索引
  • 删除文档列表用来存储已经被删除的文档ID,形成一个文档ID列表
  • 修改文档可以认为是旧文档先删除,然后系统在增加一篇新的文档。通过这种间接方式实现对内容更改的支持。

倒排索引四种更新策略:

  • 完全重建:当新增文档到达一定数量,将新增的文档和原来的老文档整合,然后对所有文档重建索引,新索引建立完成后老索引被遗弃。(全部重建)
  • 再合并:新增文档进入系统时,解析文档,之后更新内存中维护的临时索引,文档中出现的每次单词,在其倒排列表末尾追加倒排表列表项。一旦临时索引将内存消耗光,进行一次索引合并。相当于增量索引和目前现有的索引进行比较然后进行构建,生成新的索引。
    缺点:对老索引中的很多单词,尽管其在倒排列表并未发生任何变化,也需要将其从老索引中取出来并写入新索引中,这样对磁盘消耗是没必要的
  • 原地更新策略:对再合并改进,在原地合并倒排表,提前分配一定的空间给未来插入,如果提前分配的空间不足需要迁移。
  • 混合策略:将单词根据不同性质进行分类,不同类别的单词,对其索引采取不同的更新策略
1. 根据单词的倒排列表长度进行区分
2. 常见的单词可能在多个文档出现,倒排列表就昌;相反就短;
3. 长倒排列表单词按照原地更新策略;短倒排列表单词按照再合并策略

3. 流程

1. 写入

类比hbase:

  • 名词类比hbase

Shard-region-分片 (一个索引会有多个分片)

lucene-buffer-缓存 (系统缓存)

segment-memstore (段-)

写出segment-生成storefile

  • Trans log - hlog (等价于redis的aof文件)
  • shard和replication 路由规则

每个index 由多个shard 分片组成,每个shard 有一个主分片和多个副本分片,副本个数可配置。

写入时会根据_routing 计算shard 分片。index request 写入的时候可以设置使用哪个键进行路由,未指定时使用id。 shardId = (hash(id)) % 分片数量。

写入时请求由接收请求的节点计算后转发给primary shard,primary shard 执行成功后,再从primary shard 将请求同时发给多个replication shard。

写入具体流程:

​ 为什么叫es是准实时的?NRT,near real-time,准实时。默认是每隔1秒refresh一次的,所以es是准实时的,因为写入的数据1秒之后才能被看到。可以通过es的restful api或者java api,手动执行一次refresh操作,就是手动将buffer中的数据刷入os cache中,让数据立马就可以被搜索到。

get /index/_refresh
  
---
RefreshResponse user = restHighLevelClient.indices().refresh(new RefreshRequest("user"), RequestOptions.DEFAULT);  

​ es 数据其实也可能会丢失数据,有可能有5秒的数据,停留在buffer、translog os cache、segment file os cache中,有5秒的数据不在磁盘上,此时如果宕机,会导致5秒的数据丢失,可以修改translog 每次写入都刷盘可以确保数据不丢失这样性能会比较差。

--- 数据写入过程
1. 先写入buffer,在buffer里的时候数据是搜索不到的;同时将数据写入translog日志文件

2. 如果buffer快满了,或者到一定时间,就会将buffer数据refresh到一个新的segment file中,但是此时数据不是直接进入segment file的磁盘文件的,而是先进入os cache的。这个过程就是refresh。segment 会越来越多,定期或者segment 到达一定数量会执行merge,将多个segment file 合并成大的segment file。
	每隔1秒钟,es将buffer中的数据写入一个新的segment file,每秒钟会产生一个新的磁盘文件,segment file,这个segment file中就存储最近1秒内buffer中写入的数据,但是如果buffer里面此时没有数据,那当然不会执行refresh操作,每秒创建换一个空的segment file,如果buffer里面有数据,默认1秒钟执行一次refresh操作,刷入一个新的segment file中。操作系统里面,磁盘文件其实都有一个东西,叫做os cache,操作系统缓存,就是说数据写入磁盘文件之前,会先进入os cache,先进入操作系统级别的一个内存缓存中去,只要buffer中的数据被refresh操作,刷入os cache中,就代表这个数据就可以被搜索到了。

3. 只要数据进入os cache,此时就可以让这个segment file的数据对外提供搜索了

--- translog 文件保障数据不被丢失
	新的数据不断进入buffer和translog,不断将buffer数据写入一个又一个新的segment file中去,每次refresh完buffer清空,translog保留。随着这个过程推进,translog会变得越来越大。当translog达到一定长度的时候,就会触发mit操作。
(1)写mit point;
(2)将os cache数据fsync强刷到磁盘上去;
(3)清空translog日志文件。将现有的translog清空,然后再次重启启用一个translog,此时mit操作完成。默认每隔30分钟会自动执行一次mit,但是如果translog过大,也会触发mit。整个mit的过程,叫做flush操作。我们可以手动执行flush操作,就是将所有os cache数据刷到磁盘文件中去。
--- translog日志文件的作用
	执行mit操作之前,数据要么是停留在buffer中,要么是停留在os cache中,无论是buffer还是os cache都是内存,一旦这台机器死了,内存中的数据就全丢了。
	所以需要将数据对应的操作写入一个专门的日志文件,translog日志文件中,一旦此时机器宕机,再次重启的时候,es会自动读取translog日志文件中的数据,恢复到内存buffer和os cache中去。
	translog其实也是先写入os cache的,默认每隔5秒刷一次到磁盘中去,所以默认情况下,可能有5秒的数据会仅仅停留在buffer或者translog文件的os cache中,如果此时机器挂了,会丢失5秒钟的数据。但是这样性能比较好,最多丢5秒的数据。也可以将translog设置成每次写操作必须是直接fsync到磁盘,但是性能会差很多。 (类比redis 的aof,也不是每个操作都存存aof 文件,那样性能会非常差,可以自己选择90s、1s等时间)

补充: 关于segment

Lucene(ES底层的搜索引擎库)用于存储和检索数据的基本单位。以下是关于segment的一些关键点:

	数据存储:Segment是不可变的、磁盘上的数据结构,包含了文档的倒排索引和其他用于搜索的数据结构。每个文档在被索引时,会被写入到一个新的或现有的segment中。
	写入流程:当数据被写入Elasticsearch时,首先存储在内存缓冲区中,之后按照配置的时间间隔(默认每秒)或达到一定大小时,这些数据会被刷新(refresh)到一个新的segment中,并对搜索可见。这个过程保证了数据的近实时可搜索性。
	段合并:为了优化查询性能和管理磁盘空间,Elasticsearch会定期执行segment的合并操作。小的segment被合并成更大的segment,过程中会删除那些已被标记为删除的文档(如通过更新或删除操作标记的文档)。合并减少了索引的segment数量,从而减少了查询时需要读取的文件数量。
	持久化与恢复:Segment是Elasticsearch持久化数据的主要方式。一旦数据被写入segment,即使Elasticsearch关闭或重启,数据也不会丢失。此外,segment也支持数据恢复过程,当新节点加入集群或数据丢失时,可以通过复制segment来恢复数据

2. 修改

将原来的doc标识为deleted状态,然后新写入一条数据

3. 删除

​ es 实际是逻辑删除,执行删除时,es并不会立即将文档从磁盘物理删除,而是在文档的元数据中添加一个标记,表明该名单已经被删除。意味着稳定仍然存在于索引中,只是搜索结果不会返回。

​ 真实的删除发生在段合并,es 会定时合并索引中的多个小段(segments) 成更大的段,以优化存储和提高查询性能。在合并的过程中,被标记位删除的文档将不会包含在新的段中,从而实现物理删除。(也可以自己执行foecemerge API 强制合并段以加速空间回收)

mit的时候会生成一个.del文件,里面将某个doc标识为deleted状态,那么搜索的时候根据.del文件就知道这个doc被删除了

4. 查询

​ 节点收到请求之后, 拿到索引、索引的分片,然后遍历主分片信息查询后合并结果。有点类似于召回、排序的思想。先多路召回数据,然后合并结果集进行排序。也有点类似于map、reduce 的思想。

2. 操作过程&源码分析

​ 在处理过程中,主节点和data节点是一样的。也会接收处理请求。所有客户端发送请求都是轮询的,确保每个阶段收到请求的机会是一样的。

1. 创建索引

​ 节点收到请求之后, 将请求转发给主节点,由主节点进行索引的分配、分配完成通知节点创建索引并且记录分片信息。

1、创建一个简单的user 索引,2分片2副本

PUT /user
{
  "settings": {
    "index": {
      "number_of_shards": 2,
      "number_of_replicas": 1
    }
  },
  "mappings": {
    "properties": {
      "name": {"type": "text"},
      "email": {"type": "keyword"},
      "age": {"type": "integer"}
    }
  }
}

DELETE /user

---
curl -X PUT -H 'Content-Type:application/json' -d '{"settings":{"index":{"number_of_shards":2,"number_of_replicas":1}},"mappings":{"properties":{"name":{"type":"text"},"email":{"type":"keyword"},"age":{"type":"integer"}}}}' localhost:9200/user

2、源码跟踪

​ 需要注意,创建索引的请求打到某个节点。 需要自己用 /_cat/master 查看主节点,然后debug。

(1). 入口 org.elasticsearch.action.admin.indices.create.TransportCreateIndexAction#masterOperation

可以看到入口进来的时候,可分配的节点以及原有的分片信息是维护在clusterState 内部的:

(2).经过一系列操作调用到:org.elasticsearch.cluster.metadata.MetadataCreateIndexService#onlyCreateIndex

(3). 继续调用: org.elasticsearch.cluster.metadata.MetadataCreateIndexService#buildAndValidateTemporaryIndexMetadata

1. IndexMetadata tempMetadata = tmpImdBuilder.build(); 创建索引元数据
  调用到: org.elasticsearch.cluster.metadata.IndexMetadata#IndexMetadata, 会记录分片数以及副本数量
  this.totalNumberOfShards = numberOfShards * (numberOfReplicas + 1);

(4). 继续调用会调用到: org.elasticsearch.cluster.routing.allocation.AllocationService#reroute 重新路由, 对未分配的分片数量进行分配到节点

  调用到: org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator#allocate 
  - 获取balancer
  - org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.Balancer#allocateUnassigned 分配未分配的分片,大致思路是: 遍历所有未分配的分片,调用决策者判断该分片是否能分配到该节点。比如:
	ShardsLimitAllocationDecider(限制每个节点的分片数量)、SameShardAllocationDecider(避免同一份数据在同一节点上存在副本)等。    

(5). 调用org.elasticsearch.cluster.service.ClusterService#submitStateUpdateTasks 通知数据节点创建目录以及相关资源

3、 创建完之后可以查看分片的分片信息以及到数据节点的目录查看创建的文件夹

# 查看分片信息
GET /_cat/shards/user
---
user 1 r STARTED 0 226b 127.0.0.1 node3
user 1 p STARTED 0 226b 127.0.0.1 node2
user 0 p STARTED 0 226b 127.0.0.1 node3
user 0 r STARTED 0 226b 127.0.0.1 node2

# 查看设置,可以看到索引的ID 等信息
GET /user/_settings
---
{
  "user" : {
    "settings" : {
      "index" : {
        "routing" : {
          "allocation" : {
            "include" : {
              "_tier_preference" : "data_content"
            }
          }
        },
        "number_of_shards" : "2",
        "provided_name" : "user",
        "creation_date" : "1717124126260",
        "number_of_replicas" : "1",
        "uuid" : "_OwTVBXaSveXWt97mByc7Q",
        "version" : {
          "created" : "7170899"
        }
      }
    }
  }
}

# 到目录查看具体的文件夹 (可以看到以uuid 命名的文件夹,下面有对应分片ID 的目录以及日志等信息)
(base) xxx@58deMacBook-Pro indices % pwd
/Users/xxx/Desktop/es_file/es-7.17.8/2/data/nodes/0/indices
(base) xxx@58deMacBook-Pro indices % ls | grep _OwTVBXaSveXWt97mByc7Q
_OwTVBXaSveXWt97mByc7Q
(base) xxx@58deMacBook-Pro indices % ls _OwTVBXaSveXWt97mByc7Q
0	1	_state
(base) xxx@58deMacBook-Pro indices % ls _OwTVBXaSveXWt97mByc7Q/0
_state		index		translog

4、总结:

1. 请求会打到master 节点,master 校验分片副本信息
	分片(主、副): 比如2分片1副本, 会有4个分片(2主2副)
	org.elasticsearch.cluster.metadata.IndexMetadata 负责索引元数据维护,包含副本、分片等信息
2. 根据数据节点进行分配节点
org.elasticsearch.cluster.routing.allocation.AllocationService 负责分片的分配以及处理
3. 转发给data 节点进行创建对应的目录等信息

2. 创建文档

​ 节点收到请求之后, 自己计算应该在的主分片,然后根据主分片所在节点发给对应的节点;节点处理完主分片再分发给副本节点进行备份。 最后汇总处理结果。

1、 代码

POST /user/_doc
{
  "name": "myname is zhangsan",
  "email": "xxx@qq.com",
  "age": 20
}

---
curl -X POST -H 'Content-Type:application/json' -d '{"name":"myname is zhangsan","email":"xxx@qq.com","age":20}' localhost:9200/user/_doc/

2、源码

对应方法: org.elasticsearch.action.bulk.TransportShardBulkAction#dispatchedShardOperationOnPrimary

过程如下:

1、客户端发起写入请求:客户端会轮询集群节点进行选择发送请求。 发送请的时候可以自己指定routing 值,服务端会根据该值计算分片,如果未指定会用ID 进行计算。

2、服务端节点收到请求:(客户端选的哪个节点,服务端就是哪个节点接收请求)

org.elasticsearch.action.bulk.TransportBulkAction.BulkOperation#doRun 开始处理

(0). 构造IndexRequest 对象:org.elasticsearch.action.index.IndexRequest#process, 如果ID 为空,自己生成ID

	public void process(Version indexCreatedVersion, @Nullable MappingMetadata mappingMd, String concreteIndex) {
        if (mappingMd != null) {
            // might as well check for routing here
            if (mappingMd.routing().required() && routing == null) {
                throw new RoutingMissingException(concreteIndex, type(), id);
            }
        }

        if ("".equals(id)) {
            throw new IllegalArgumentException("if _id is specified it must not be empty");
        }

        // generate id if not already provided
        if (id == null) {
            assert autoGeneratedTimestamp == UNSET_AUTO_GENERATED_TIMESTAMP : "timestamp has already been generated!";
            assert ifSeqNo == UNASSIGNED_SEQ_NO;
            assert ifPrimaryTerm == UNASSIGNED_PRIMARY_TERM;
            autoGeneratedTimestamp = Math.max(0, System.currentTimeMillis()); // extra paranoia
            String uid;
            if (indexCreatedVersion.onOrAfter(Version.V_6_0_0_beta1)) {
                uid = UUIDs.base64UUID();
            } else {
                uid = UUIDs.legacyBase64UUID();
            }
            id(uid);
        }
    }

(1)、根据routing 计算主分片信息, 拿到主分片信息然后转发到主分片所在的节点

1. org.elasticsearch.cluster.routing.IndexRouting.Unpartitioned#shardId 计算得到一个分片ID
        @Override
        public int shardId(String id, @Nullable String routing) {
            return hashToShardId(effectiveRoutingToHash(routing == null ? id : routing));
        }
2. org.elasticsearch.cluster.routing.RoutingTable#shardRoutingTable(java.lang.String, int) 根据分片ID拿到索引的分片信息,包含主分片、副本分片以及所在的node
    public IndexShardRoutingTable shardRoutingTable(String index, int shardId) {
        IndexRoutingTable indexRouting = index(index);
        if (indexRouting == null) {
            throw new IndexNotFoundException(index);
        }
        return shardRoutingTable(indexRouting, shardId);
    }
    public static IndexShardRoutingTable shardRoutingTable(IndexRoutingTable indexRouting, int shardId) {
        IndexShardRoutingTable indexShard = indexRouting.shard(shardId);
        if (indexShard == null) {
            throw new ShardNotFoundException(new ShardId(indexRouting.getIndex(), shardId));
        }
        return indexShard;
    }
3. org.elasticsearch.action.bulk.BulkShardRequest#writeTo 将请求转发给主分片所在节点

(2)、主分片处理完后再次将结果转给副本分片所在的节点

异步成功响应接口汇总处理结果。

3. 查询文档

​ 节点收到请求之后, 拿到索引、索引的分片,然后遍历主分片信息查询后合并结果。有点类似于召回、排序的思想。先多路召回数据,然后合并结果集进行排序。也有点类似于map、reduce 的思想。

​ 在查询前后都有一些过滤器。 对于已经删除的数据,实际是做了个标记,lucene 底层查询的时候会过滤掉已经删除的文档。

源码:

1、 执行查询入口:org.elasticsearch.action.search.TransportSearchAction#doExecute

2、获取索引、以及分片信息:org.elasticsearch.action.search.TransportSearchAction#executeSearch

3、org.elasticsearch.action.search.AbstractSearchAsyncAction#run 遍历所有的分片, 进行获取结果

4、构造异步查询对象:org.elasticsearch.action.search.TransportSearchAction#searchAsyncAction

5、然后调用start 方法, start 方法内部调用run方法, 调用到:

org.elasticsearch.action.search.AbstractSearchAsyncAction#run: 遍历所有的分片,进行在分片进行查询和合并结果集

            for (int i = 0; i < shardsIts.size(); i++) {
                final SearchShardIterator shardRoutings = shardsIts.get(i);
                assert shardRoutings.skip() == false;
                assert shardIndexMap.containsKey(shardRoutings);
                int shardIndex = shardIndexMap.get(shardRoutings);
                performPhaseOnShard(shardIndex, shardRoutings, shardRoutings.nextOrNull());
            }

6、进行分片查询:org.elasticsearch.action.search.AbstractSearchAsyncAction#performPhaseOnShard

7、 查询完成到 org.elasticsearch.action.search.AbstractSearchAsyncAction#onShardResult 合并结果集

继续调用调用到: org.elasticsearch.action.search.QueryPhaseResultConsumer#consumeResult 消费结果

8、调用到: org.elasticsearch.action.search.FetchSearchPhase#innerRun 合并结果集

调用到: org.elasticsearch.action.search.QueryPhaseResultConsumer#reduce 合并结果集

补充: 关于删除文档的过滤

	在Elasticsearch中,查询过程中过滤掉已删除文档的功能是在Lucene库的层次实现的,而不是直接在Elasticsearch的高层级Java代码中。当文档被标记为删除时(例如,通过删除请求),它们实际上并未立即从磁盘上移除,而是被标记在.del文件中。在后续的搜索过程中,Lucene的索引读取器(IndexReader)会自动忽略这些被标记为已删除的文档。
	具体到Elasticsearch中,虽然没有直接的Java类完全负责这个过滤过程(因为它深入到Lucene的内部机制),但Elasticsearch在构建搜索请求、处理响应时,会间接利用Lucene的这一机制。例如,org.elasticsearch.search.internal.SearchContext类在准备搜索上下文时,会与Lucene的索引读取器交互,后者在搜索时自然排除了被标记的删除文档。
	总结来说,虽然没有特定的Elasticsearch类直接实现“过滤删除文档”的逻辑,但这一行为是集成在Lucene的索引和搜索流程中,并通过Elasticsearch与Lucene的交互间接实现。

4. 关于es 分词

1. 简单使用

1、不带filter

package qz.es;

import org.apache.lucene.analysis.TokenFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;

import java.io.IOException;
import java.io.StringReader;

/**
 * @author qiaoliqiang
 * @date 2024/6/7
 * @description todo
 */
public class AnalyzerTest {

    public static void main(String[] args) throws Exception {
        String text = "Hello, world! This is a test. 123@example.com";
        // 初始化StringReader,准备输入文本
        StringReader reader = new StringReader(text);

        // 创建StandardTokenizer实例
        StandardTokenizer tokenizer = new StandardTokenizer();
        // 获取CharTermAttribute,用于获取分词结果
        CharTermAttribute termAtt = tokenizer.addAttribute(CharTermAttribute.class);
        // 开始分词
        tokenizer.setReader(reader);
        tokenizer.reset();

        while (tokenizer.incrementToken()) {
            String token = termAtt.toString();
            System.out.println(token);
        }

        tokenizer.end();
        tokenizer.close();
        reader.close();
    }
}
---
Hello
world
This
is
a
test
123
example.com  

2、增加过滤器

package qz.es;

import org.apache.lucene.analysis.TokenFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;

import java.io.IOException;
import java.io.StringReader;

/**
 * @author qiaoliqiang
 * @date 2024/6/7
 * @description todo
 */
public class AnalyzerTest {

    public static void main(String[] args) throws Exception {
        String text = "Hello, world! This is a test. 123@example.com";
        // 初始化StringReader,准备输入文本
        StringReader reader = new StringReader(text);

        // 创建StandardTokenizer实例
        StandardTokenizer tokenizer = new StandardTokenizer();
        // 获取CharTermAttribute,用于获取分词结果
        CharTermAttribute termAtt = tokenizer.addAttribute(CharTermAttribute.class);
        // 开始分词
        tokenizer.setReader(reader);

        // 增加过滤器 (模拟限制最多5个词)
        TokenFilter filter = new TokenFilter(tokenizer) {

            int count = 0;

            int max = 5;

            @Override
            public boolean incrementToken() throws IOException {
                if ((++count) > max) {
                    return false;
                }
                return input.incrementToken();
            }
        };


        filter.reset();
        while (filter.incrementToken()) {
            String token = termAtt.toString();
            System.out.println(token);
        }

        filter.end();
        filter.close();
        filter.close();
    }
}
---
Hello
world
This
is
a

2. Es 分词过程

在文档写入过程中会最多调用到lucene 相关类,进行处理以及存储。(这里是同步分词操作的,也就是如果阻塞,会导致客户端也阻塞等待)

org.apache.lucene.index.DefaultIndexingChain#processDocument: 遍历处理可以索引的字段,然后分词以及构建单词表等操作。

调用链如下:

5. 关于client和服务端交互

​ es 客户端配置的是es 集群的连接,对于客户端来说,node 是平等的。client 会采用轮询的方式遍历集群节点进行请求分发(每个节点收到请求的机会是一样的),es 服务端收到请求会根据操作的索引以及主分片所在的节点转发给对应的节点。当集群有节点挂掉或新增,es 会自动同步相应的节点。

​ 底层是异步http 协议和es server 进行交互,未采用netty,底层采用apache的httpclient 相关包。

​ 服务端实例直接通信使用了netty以及nio。

1. 客户端代码

package qz.es;

import org.apache.http.HttpHost;
import org.elasticsearch.action.admin.indices.create.CreateIndexRequest;
import org.elasticsearch.action.admin.indices.create.CreateIndexResponse;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.builder.SearchSourceBuilder;

import java.io.IOException;
import java.util.Map;

public class ClientTest {

    // 集群节点地址
    static RestClientBuilder builder = RestClient.builder(
            new HttpHost("localhost", 9200, "http"),
            new HttpHost("localhost", 9201, "http"),
            new HttpHost("localhost", 9202, "http")
    );

    static RestHighLevelClient restHighLevelClient = new RestHighLevelClient(builder);

    public static void main(String[] args) throws Exception {
        try {
            createIndex();
        } catch (IOException e) {
            e.printStackTrace();
            // 异常处理
        } finally {
            if (restHighLevelClient != null) {
                restHighLevelClient.close();
            }
        }
    }

    private static void createIndex() throws IOException {
        for (int i = 0; i < 5; i++) {
            // 定义索引设置和映射
            CreateIndexRequest request = new CreateIndexRequest("user1" + i);
            request.settings(Settings.builder()
                    .put("index.number_of_shards", 1)
                    .put("index.number_of_replicas", 1)
                    .build());

            // 添加映射(Mapping),这里以一个简单字段为例
            XContentBuilder mappingBuilder = XContentFactory.jsonBuilder()
                    .startObject()
                    .startObject("properties")
                    .startObject("exampleField")
                    .field("type", "text")
                    .endObject()
                    .endObject()
                    .endObject();
            request.mapping(mappingBuilder.toString());

            // 执行创建索引请求
            CreateIndexResponse createIndexResponse = restHighLevelClient.indices().create(request, RequestOptions.DEFAULT);
            // 检查响应
            if (createIndexResponse.isAcknowledged()) {
                System.out.println("索引创建成功: " + createIndexResponse.index());
            } else {
                System.out.println("索引创建失败");
            }
        }
    }

    private static void index() {
        for (int i = 0; i < 20; i++) {
            // 准备文档内容
            String jsonString = "{\"name\":\"myname is zhangsan\",\"email\":\"xxx@qq.com\",\"age\":20}";
            // 创建IndexRequest实例
            IndexRequest indexRequest = new IndexRequest("user", "_doc")
                    .source(jsonString, XContentType.JSON);
            try {
                // 执行索引操作
                IndexResponse indexResponse = restHighLevelClient.index(indexRequest, RequestOptions.DEFAULT);
                // 输出响应信息,如文档ID、是否创建成功等
                System.out.println("Indexed with ID: " + indexResponse.getId());
                System.out.println("Created: " + indexResponse.getResult());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }


    private static void matchAll() throws Exception {
        // 创建SearchRequest,可以指定Index,也可以不指定。不指定查询所有
        SearchRequest searchRequest = new SearchRequest("user");
        // SearchRequest searchRequest = new SearchRequest(defaultIndex);
        SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
        searchSourceBuilder.query(QueryBuilders.matchAllQuery());
        // 设置精准计数、查询的大小
        searchSourceBuilder.trackTotalHits(true).size(2);
        searchRequest.source(searchSourceBuilder);

        for (int i = 0; i < 20; i++) {
            SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
            SearchHits hits = searchResponse.getHits();
            long numHits = hits.getTotalHits();
            System.out.println("numHits " + numHits);

            SearchHit[] searchHits = hits.getHits();
            for (SearchHit hit : searchHits) {
                System.out.println("======");
                Map<String, Object> sourceAsMap = hit.getSourceAsMap();
                System.out.println(sourceAsMap);
            }
            Thread.sleep(5 * 1000);
        }
    }

}

2. 源码跟踪

会调用到 org.elasticsearch.client.RestClient#performRequestAsync 获取节点,然后组装请求信息进行发生请求。

posted @ 2024-06-13 19:19  QiaoZhi  阅读(112)  评论(0编辑  收藏  举报