介绍
Lucene是一种高性能、可伸缩的信息搜索(IR)库,在2000年开源,最初由鼎鼎大名的Doug Cutting开发,是基于Java实现的高性能的开源项目。Lucene采用了基于倒排表的设计原理,可以非常高效地实现文本查找,在底层采用了分段的存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。我们所熟知的Elasticsearch,Solr都是基于Lucene工具包进行开发的全文搜索引擎,因此理解Lucene也可以帮助我们更好的理解Elasticsearch原理。
核心术语
Lucene为什么可以实现全文检索主要是因为它实现了倒排索引的查询结构,下面是关于Lucene的核心术语:
如图所示,倒排索引中主要有两部分:词典和倒排文件。词典和倒排表是Lucene中很重要的两种数据结构,是实现快速检索的重要基石。词典和倒排文件是分两部分存储的,词典在内存中而倒排文件存储在磁盘上。
图中的词典包含五个词条(Term):rick,wang,works,on,csdn。在lucene中会记录每个词条出现在哪些文档中,并且将这些文档的编号存储成一个有序链表(倒排表)1->5>8>12... 基于这种索引方式,lucene可以很方便的从海量文档中返回匹配到的检索词。
如何理解倒排索引?
举个例子,当我们在翻一本书的时候最先看到的一定是目录页,根据目录里每个标题找到对应的篇幅文章,这种就是典型的KV关系,标题就是Key,文章内容是Value。那么如果我们想找到文章里包含"CSDN"关键字的文章在哪些标题里呢?如果在RDBMS中,我们只能用模糊查询遍历所有记录,非常消耗性能又特别的龟速。
而Lucene以倒排表存储索引,同样是刚才的文章,在录入文章的时候,Lucene会先对文章做分词识别出所有的词条(Term),然后记录这些词条出现在的文章编号,当文章多了时候,同样的词条会出现在多个文章中,对于同一个词条,它所出现过的文章编号就会组成一个有序链表(倒排表),这样又是一个KV关系,Key就是我们的词条(Term),而Value是倒排表,当我们想查询"CSDN"这个单词出现过的文章的时候我们会找到一个"CSDN"-->"2,3,8,11" 这样的KV关系,即表示"CSDN"关键词在编号为2,3,8,11的文章中出现过,只要将这四个文章返回就达到了我们的目的,因此基于倒排索引的Lucene不需要去遍历索引全部的数据就可以将我们的检索内容返回。
检索方式
Lucene基于倒排表存储索引,因此在查找的过程中只需要在词典中找到检索的词条,然后根据词条找到对应的倒排列表。然后根据下面四种查询方式对结果做交并差集等操作即可返回我们想要的结果。
1.单关键字查询
根据输入的单个词条(Term)进行查询,只需要在词典中查到该词条的倒排列表即可返回结果。
2.AND
查询同时包含多个词条的文档,取交集。如:首先查询词条A的倒排列表[1,2,3],然后查询词条B的倒排列表[2,3,4],将两个倒排列表做交集取[2,3],就是即包含词条A又包含词条B的文档结果集。
3.OR
查询包含这些词条的文档,取并集。如:首先查询词条A的倒排列表[1,2,3],然后查询词条B的倒排列表[2,3,4],将两个倒排列表做并集取[1,2,3,4],就是包含词条A或包含词条B的文档结果集。
4.NOT
查询包含某个词条且不包含另一个词条的文档,取差集。如:首先查询词条A的倒排列表[1,2,3],然后查询词条B的倒排列表[2,3,4],将AB两个倒排列表做差集取[1],就是包含词条A且不包含词条B的文档结果集。
分段存储
早期的Lucene中当写入数据时会为整个文档集合建立一个很大的倒排索引,并将其写入磁盘中,如果索引有更新,就需要重新全量创建一个索引来替换原来的索引。这种方式在数据量很大时效率很低,并且由于创建一次索引的成本很高,所以对数据的更新不能过于频繁,也就不能保证实效性。
现在,Lucene中引入了段(Segment)的概念(将一个索引文件拆分为多个子文件,其中每个子文件称为段[Segment]),每个段都是一个独立的可被搜索的数据集,并且段具有不变性,一旦索引的数据被写入硬盘,就不可修改。
在分段的思想下,对数据写操作的过程如下:
段不可变性的优点:
段不可变性的缺点:
为了提升写的性能,Lucene并没有每新增一条数据就增加一个段,而是采用延迟写的策略,每当有新增的数据时,就将其先写入内存中,然后批量写入磁盘中。若有一个段被写到硬盘,就会生成一个提交点,提交点就是一个用来记录所有提交后的段信息的文件。一个段一旦拥有了提交点,就说明这个段只有读权限,失去了写权限;相反,当段在内存中时,就只有写数据的权限,而不具备读数据的权限,所以也就不能被查询了。因此严格意义上来说,Lucene或者Elasticsearch并不能被称为实时的搜索引擎,只能被称为准实时的搜索引擎。
写索引的流程:
通过上面的流程中可以看到,当内存中的数据还没有持久化到磁盘中的时候如果集群出现故障,那么内存中的数据就会丢失无法恢复,因此在Elasticsearch中新增了事务日志用以保证数据安全。
段合并策略
上面我们说到,数据每次从内存持久化到磁盘中都会新增一个段(Segment),且这个持久化机制可以根据时间(默认是一秒)以及数据量进行触发。时间久了,在索引中会存储大量的段,非常影响服务器的稳定性以及查询的性能。因此在Lucene中会根据段的大小将段进行分组,然后将同一组的段进行合并,且主要只对中小段进行合并,既可以避免大段合并消耗过多资源也可以很好的控制索引中段的数量。
段合并的主要参数:
Elasticsearch
Elasticsearch是使用Java编写的一种开源搜索引擎,它在内部使用Luence做索引与搜索,通过对Lucene的封装,提供了一套简单一致的RESTful API。Elasticsearch也是一种分布式的搜索引擎架构,可以很简单地扩展到上百个服务节点,并支持PB级别的数据查询,使系统具备高可用和高并发性。
核心概念
副本越多,集群的可用性就越高,但由于每个分片都相当于一个Lucene的索引文件,会占用一定的文件句柄、内存及CPU,并且分片间的数据同步也会占用一定的网络带宽,因此索引的分片数和副本数也不是越多越好。
节点类型
tribe:
one:
cluster.name: cluster_one
two:
cluster.name: cluster_two
为了集群的稳定性,我们应该对Elasticsearch集群中的节点做好角色上的划分和隔离。如将候选主节点和数据节点分离,可以减轻主节点的负担,防止出现"脑裂"的情况。
集群状态
3C和脑裂
1.共识性(Consensus)
共识性是分布式系统中最基础也最主要的一个组件,在分布式系统中的所有节点必须对给定的数据或者节点的状态达成共识。虽然现在有很成熟的共识算法如Raft、Paxos等,也有比较成熟的开源软件如Zookeeper。但是Elasticsearch并没有使用它们,而是自己实现共识系统zen discovery。zen discovery模块以“八卦传播”(Gossip)的形式实现了单播(Unicat):单播不同于多播(Multicast)和广播(Broadcast)。节点间的通信方式是一对一的。
2.并发(Concurrency)
Elasticsearch是一个分布式系统。写请求在发送到主分片时,同时会以并行的形式发送到备份分片,但是这些请求的送达时间可能是无序的。在这种情况下,Elasticsearch用乐观并发控制(Optimistic Concurrency Control,OCC)来保证新版本的数据不会被旧版本的数据覆盖。
乐观并发控制是一种乐观锁,另一种常用的乐观锁即多版本并发控制(Multi-Version Concurrency Control),它们的主要区别如下:
3.一致性(Consistency)
Elasticsearch集群保证写一致性的方式是在写入前先检查有多少个分片可供写入,如果达到写入条件,则进行写操作,否则,Elasticsearch会等待更多的分片出现,默认为一分钟。
下面三种设置判断是否允许写操作:
其中,对“大部分”的计算公式为int((primary+number_of_replicas)/2)+1。
Elasticsearch集群保证读写一致性的方式是,为了保证搜索请求的返回结果是最新版本的文档,备份可以被设置为sync(默认值),写操作在主分片和备份分片同时完成后才会返回写请求的结果。这样,无论搜索请求至哪个分片都会返回最新的文档。但是如果我们的应用对写要求很高,就可以通过设置replication=async来提升写的效率,如果设置replication=async,则只要主分片的写完成,就会返回写成功。
脑裂
在Elasticsearch集群中主节点通过ping命令来检查集群中的其他节点是否处于可用状态,同时非主节点也会通过ping来检查主节点是否处于可用状态。当集群网络不稳定时,有可能会发生一个节点ping不通Master节点,则会认为Master节点发生了故障,然后重新选出一个Master节点,这就会导致在一个集群内出现多个Master节点。当在一个集群中有多个Master节点时,就有可能会导致数据丢失。我们称这种现象为脑裂。
“脑裂”问题可能有以下几个原因造成:
如何避免脑裂:
为了避免脑裂现象的发生,我们可以从原因着手通过以下几个方面来做出优化措施:
事务日志TransLog
上面提到,Lucene为了加快写索引的速度,采用了延迟写入的策略。虽然这种策略提高了写入的效率,但其最大的弊端是,如果数据在内存中还没有持久化到磁盘上时发生了故障,就可能丢失数据。为了避免丢失数据,Elasticsearch添加了事务日志(Translog),事务日志记录了所有还没有被持久化磁盘的数据。
Elasticsearch写索引的具体过程如下:
有关于Translog和Flush的一些配置项:
#当发生多少次操作时进行一次flush。默认是 unlimited。
index.translog.flush_threshold_ops
#当translog的大小达到此值时会进行一次flush操作。默认是512mb。
index.translog.flush_threshold_size
#在指定的时间间隔内如果没有进行flush操作,会进行一次强制flush操作。默认是30m。
index.translog.flush_threshold_period
#多少时间间隔内会检查一次translog,来进行一次flush操作。es会随机的在这个值到这个值的2倍大小之间进行一次操作,默认是5s。
index.translog.interval
Refresh会将JVM内存中的可写不可读的数据以段格式缓存至操作系统的内存中变成可读不可写的状态,同时清空JVM中的内存准备接受新数据,因为该段数据仍在操作系统内存中还未持久化到磁盘内,仍有停机丢数据的风险,所以事务日志暂时不做清空处理。
Flush会将操作系统内存中缓存的段通过fsync函数刷新至磁盘内并生成提交点。因为此时该段数据以及持久化至磁盘内,所以会将事务日志删除并创建一个空的日志。
当Elasticsearch重启时,不仅会根据提交点加载已持久化的段数据,还会从事务日志(Translog)中把(如果有)未持久化的数据重新持久化到磁盘内。
因为内存中的数据还会继续写入,所以内存中的数据并不是以段的形式存储的,是检索不到的。总之,Elasticsearch是一个准实时的搜索引擎,而不是一个实时的搜索引擎。
路由Route
上面提到在Elasticsearch集群中每个索引有N个分片,每个分片会有0到多个副本分片,且分片的数量一旦定下之后就无法修改,在Elasticsearch7.x之后默认分片数量是1,在7.x之前默认分片数量为5。单个索引最大分片数1024。
在集群模式下ES根据路由公式将数据写入不同节点的不同主分片内,主分片异步sync数据给副本分片。路由公式如下:
shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id ,也可以设置成自定义的值。routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到余数 。这个在 0 到 主分片数-1 之间的余数,就是我们所寻求的文档所在分片的位置。
这也解释了为什么我们要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
由于在ES集群中每个节点通过上面的计算公式都知道集群中的文档的存放位置,所以每个节点都有处理读写请求的能力。在一个写请求被发送到某个节点后,该节点即为前面说过的协调节点,协调节点会根据路由公式计算出需要写到哪个分片上,再将请求转发到该分片的主分片节点上。