介绍
Lucene是一种高性能、可伸缩的信息搜索(IR)库,在2000年开源,最初由鼎鼎大名的Doug Cutting开发,是基于Java实现的高性能的开源项目。Lucene采用了基于倒排表的设计原理,可以非常高效地实现文本查找,在底层采用了分段的存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。我们所熟知的Elasticsearch,Solr都是基于Lucene工具包进行开发的全文搜索引擎,因此理解Lucene也可以帮助我们更好的理解Elasticsearch原理。
核心术语
Lucene为什么可以实现全文检索主要是因为它实现了倒排索引的查询结构,下面是关于Lucene的核心术语:
- 词条(Term):索引里面最小的存储和查询单元,对于英文来说是一个单词,对于中文来说一般指分词后的一个词。
- 词典(Term Dictionary):或字典,是词条Term的集合。搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。
- 倒排表(Post list):一个文档通常由多个词组成,倒排表记录的是某个词在哪些文档里出现过以及出现的位置。每条记录称为一个倒排项(Posting)。倒排表记录的不单是文档编号,还存储了词频等信息。
- 倒排文件(Inverted File):所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件被称之为倒排文件,倒排文件是存储倒排索引的物理文件。
- 段(Segment):索引中最小的独立存储单元。一个索引文件由一个或者多个段组成。在Luence中的段有不变性,段一旦生成,在其上只能有读操作,不能有写操作。
如图所示,倒排索引中主要有两部分:词典和倒排文件。词典和倒排表是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在索引文件新增一个.del的文件,用来专门存储被删除的数据id。当查询时,被删除的数据还是可以被查到的,只是在进行文档链表合并时,才把已经删除的数据过滤掉。被删除的数据在进行段合并时才会被真正被移除。
- 更新:更新的操作其实就是删除和新增操作的组合(delete & insert),先在.del文件中标记旧数据的删除,再在新段中添加一条更新后的数据。
段不可变性的优点:
- 不需要锁:因为数据不会更新,所以不用考虑多线程下的读写不一致情况。
- 可以常驻内存:段在被加载到内存后,由于具有不变性,所以只要内存的空间足够大,就可以长时间驻存,大部分查询请求会直接访问内存,而不需要访问磁盘,使得查询的性能有很大的提升。
- 缓存友好:在段的声明周期内始终有效,不需要在每次数据更新时被重建。
- 增量创建:分段可以做到增量创建索引,可以轻量级地对数据进行更新,由于每次创建的成本很低,所以可以频繁地更新数据,使系统接近实时更新。
段不可变性的缺点:
- 删除:当对数据进行删除时,旧数据不会被马上删除,而是在.del文件中被标记为删除。旧数据只能等到段更新时才能真正地被移除,这样会有大量的空间浪费。
- 更新:更新数据由删除和新增这两个动作组成。若有一条数据频繁更新,则会有大量的空间浪费。
- 新增:由于索引具有不变性,所以每次新增数据时,都需要新增一个段来存储数据。当段的数量太多时,对服务器的资源(如文件句柄)的消耗会非常大,查询的性能也会受到影响。
- 过滤:在查询后需要对已经删除的旧数据进行过滤,这增加了查询的负担。
为了提升写的性能,Lucene并没有每新增一条数据就增加一个段,而是采用延迟写的策略,每当有新增的数据时,就将其先写入内存中,然后批量写入磁盘中。若有一个段被写到硬盘,就会生成一个提交点,提交点就是一个用来记录所有提交后的段信息的文件。一个段一旦拥有了提交点,就说明这个段只有读权限,失去了写权限;相反,当段在内存中时,就只有写数据的权限,而不具备读数据的权限,所以也就不能被查询了。因此严格意义上来说,Lucene或者Elasticsearch并不能被称为实时的搜索引擎,只能被称为准实时的搜索引擎。
写索引的流程:
- 暂驻内存:新数据被写入时,并没有被直接写到硬盘中,而是被暂时写到内存中。(Lucene默认是一秒钟,或者当内存中数据量达到一定阶段时,再批量提交到磁盘中,默认提交时间和数据量的大小是可以通过参数控制的。通过延时写的策略,可以减少数据往磁盘上写的次数,从而提升整体的写入性能,降低磁盘压力。),此时该内存中的数据不能被检索到。
- 持久化:在达到触发条件以后,会将内存中缓存的数据一次性写入磁盘中,并生成提交点,此时该段数据可以被检索到。
- 释放内存:释放内存并等待新的数据写入。
通过上面的流程中可以看到,当内存中的数据还没有持久化到磁盘中的时候如果集群出现故障,那么内存中的数据就会丢失无法恢复,因此在Elasticsearch中新增了事务日志用以保证数据安全。
段合并策略
上面我们说到,数据每次从内存持久化到磁盘中都会新增一个段(Segment),且这个持久化机制可以根据时间(默认是一秒)以及数据量进行触发。时间久了,在索引中会存储大量的段,非常影响服务器的稳定性以及查询的性能。因此在Lucene中会根据段的大小将段进行分组,然后将同一组的段进行合并,且主要只对中小段进行合并,既可以避免大段合并消耗过多资源也可以很好的控制索引中段的数量。
段合并的主要参数:
- mergeFactor:每次合并时参与合并的最少数量,当同一组的段的数量达到该值时开始合并,默认值为10。
- SegmentSize:段的实际大小,单位为字节。
- minMergeSize:小于该值的段会被分到一组,这样可以加速小片段的合并。
- maxMergeSize:若有一段的文本数量大于该值,就不再参与合并,因为大段合并会消耗更多的资源。
Elasticsearch
Elasticsearch是使用Java编写的一种开源搜索引擎,它在内部使用Luence做索引与搜索,通过对Lucene的封装,提供了一套简单一致的RESTful API。Elasticsearch也是一种分布式的搜索引擎架构,可以很简单地扩展到上百个服务节点,并支持PB级别的数据查询,使系统具备高可用和高并发性。
核心概念
- Cluster:集群,由一个或多个Elasticsearch节点组成,通过ZenDiscovery进行注册发现。
- Node:节点,组成Elasticsearch集群的服务单元,同一个集群内节点的名字不能重复。通常在一个节点上分配一个或者多个分片。
- Shards:分片,当索引上的数据量太大的时候,我们通常会将一个索引上的数据进行水平拆分,拆分出来的每个数据库叫作一个分片。在一个多分片的索引中写入数据时,通过路由来确定具体写入那一个分片中,所以在创建索引时需要指定分片的数量,并且分片的数量一旦确定就不能更改。分片后的索引带来了规模上(数据水平切分)和性能上(并行执行)的提升。每个分片都是Luence中的一个索引文件,每个分片必须有一个主分片和零到多个副本分片。
- Replicas:备份也叫作副本,是指对主分片的备份。主分片和备份分片都可以对外提供查询服务,写操作时先在主分片上完成,然后分发到备份上。ES为了提高写入的能力这个过程是并发写的,同时为了解决并发写的过程中数据冲突的问题,ES通过乐观锁的方式控制,每个文档都有一个 _version (版本)号,当文档被修改时版本号递增。一旦所有的副本分片都报告写成功才会向协调节点报告成功,协调节点向客户端报告成功。当主分片不可用时,会在备份的分片中选举出一个作为主分片,所以备份不仅可以提升系统的高可用性能,还可以提升搜索时的并发性能。但是若副本太多的话,在写操作时会增加数据同步的负担。
- Index:索引,由一个和多个分片组成,通过索引的名字在集群内进行唯一标识。
- Type:类别,指索引内部的逻辑分区,通过Type的名字在索引内进行唯一标识。在查询时如果没有该值,则表示在整个索引中查询。(Elasticsearch7.x之后库表合一移除了type,type默认都是"_doc"了)。
- Document:文档,索引中的每一条数据叫作一个文档,类似于关系型数据库中的一条数据通过_id在Type内进行唯一标识。
- Settings:对集群中索引的定义,比如一个索引默认的分片数、副本数等信息。
- Mapping:类似于关系型数据库中的表结构信息,用于定义索引中字段(Field)的存储类型、分词方式、是否存储等信息。Elasticsearch中的mapping是可以动态识别的。如果没有特殊需求,则不需要手动创建mapping,因为Elasticsearch会自动根据数据格式识别它的类型,但是当需要对某些字段添加特殊属性(比如:定义使用其他分词器、是否分词、是否存储等)时,就需要手动设置mapping了。一个索引的mapping一旦创建,若已经存储了数据,就不可修改了。
- Analyzer:字段的分词方式的定义。一个analyzer通常由一个tokenizer、零到多个filter组成。比如默认的标准Analyzer包含一个标准的tokenizer和三个filter:Standard Token Filter、Lower Case Token Filter、Stop Token Filter。
副本越多,集群的可用性就越高,但由于每个分片都相当于一个Lucene的索引文件,会占用一定的文件句柄、内存及CPU,并且分片间的数据同步也会占用一定的网络带宽,因此索引的分片数和副本数也不是越多越好。
节点类型
- 主节点(Master Node):也叫作主节点,主节点负责创建索引、删除索引、分配分片、追踪集群中的节点状态等工作。Elasticsearch中的主节点的工作量相对较轻。用户的请求可以发往任何一个节点,并由该节点负责分发请求、收集结果等操作,而并不需要经过主节点转发。通过在配置文件中设置node.master=true来设置该节点成为候选主节点(但该节点不一定是主节点,主节点是集群在候选节点中选举出来的),在Elasticsearch集群中只有候选节点才有选举权和被选举权。其他节点是不参与选举工作的。
- 数据节点(Data Node):数据节点,负责数据的存储和相关具体操作,比如索引数据的创建、修改、删除、搜索、聚合。所以,数据节点对机器配置要求比较高,首先需要有足够的磁盘空间来存储数据,其次数据操作对系统CPU、Memory和I/O的性能消耗都很大。通常随着集群的扩大,需要增加更多的数据节点来提高可用性。通过在配置文件中设置node.data=true来设置该节点成为数据节点。
- 客户端节点(Client Node):就是既不做候选主节点也不做数据节点的节点,只负责请求的分发、汇总等,也就是下面要说到的协调节点的角色。其实任何一个节点都可以完成这样的工作,单独增加这样的节点更多地是为了提高并发性。可在配置文件中设置该节点成为客户端节点: node.master=false; node.data=false;
- 协调节点(Coordinating Node):协调节点是一种角色,而不是真实的Elasticsearch的节点,我们没有办法通过配置项来配置哪个节点为协调节点。集群中的任何节点都可以充当协调节点的角色。当一个节点A收到用户的查询请求后,会把查询语句分发到其他的节点,然后合并各个节点返回的查询结果,最好返回一个完整的数据集给用户。在这个过程中,节点A扮演的就是协调节点的角色。由此可见,协调节点会对CPU、Memory和I/O要求比较高。
- 部落节点(Tribe Node):(Elasticsearch7.x中已移除)。部落节点可以跨越多个集群,它可以接收每个集群的状态,然后合并成一个全局集群的状态,它可以读写所有集群节点上的数据,在配置文件中通过如下设置使节点成为部落节点:
tribe:
one:
cluster.name: cluster_one
two:
cluster.name: cluster_two
为了集群的稳定性,我们应该对Elasticsearch集群中的节点做好角色上的划分和隔离。如将候选主节点和数据节点分离,可以减轻主节点的负担,防止出现"脑裂"的情况。
集群状态
- Green:绿色,健康。所有的主分片和副本分片都可正常工作,集群100%健康。
- Yellow:黄色,预警。所有的主分片都可以正常工作,但至少有一个副本分片是不能正常工作的。此时集群可以正常工作,但是集群的高可用性在某种程度上被弱化。
- Red:红色,集群不可正常使用。集群中至少有一个分片的主分片及它的全部副本分片都不可正常工作。这时虽然集群的查询操作还可以进行,但是也只能返回部分数据(其他正常分片的数据可以返回),而分配到这个分片上的写入请求将会报错,最终会导致数据的丢失。
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),它们的主要区别如下:
- 乐观并发控制(OCC):是一种用来解决写-写冲突的无锁并发控制,认为事务间的竞争不激烈时,就先进行修改,在提交事务前检查数据有没有变化,如果没有就提交,如果有就放弃并重试。乐观并发控制类似于自选锁,适用于低数据竞争且写冲突比较少的环境。
- 多版本并发控制(MVCC):是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每一个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。这样在读操作不用阻塞操作且写操作不用阻塞读操作的同时,避免了脏读和不可重复读。
3.一致性(Consistency)
Elasticsearch集群保证写一致性的方式是在写入前先检查有多少个分片可供写入,如果达到写入条件,则进行写操作,否则,Elasticsearch会等待更多的分片出现,默认为一分钟。
下面三种设置判断是否允许写操作:
- One:只要主分片可用,就可以进行写操作。
- All:只有当主分片和所有副本都可用时,才允许写操作。
- Quorum(k-wu-wo/reng,法定人数):是Elasticsearch的默认选项。当有大部分的分片可用时才允许写操作。
其中,对“大部分”的计算公式为int((primary number_of_replicas)/2) 1。
Elasticsearch集群保证读写一致性的方式是,为了保证搜索请求的返回结果是最新版本的文档,备份可以被设置为sync(默认值),写操作在主分片和备份分片同时完成后才会返回写请求的结果。这样,无论搜索请求至哪个分片都会返回最新的文档。但是如果我们的应用对写要求很高,就可以通过设置replication=async来提升写的效率,如果设置replication=async,则只要主分片的写完成,就会返回写成功。
脑裂
在Elasticsearch集群中主节点通过ping命令来检查集群中的其他节点是否处于可用状态,同时非主节点也会通过ping来检查主节点是否处于可用状态。当集群网络不稳定时,有可能会发生一个节点ping不通Master节点,则会认为Master节点发生了故障,然后重新选出一个Master节点,这就会导致在一个集群内出现多个Master节点。当在一个集群中有多个Master节点时,就有可能会导致数据丢失。我们称这种现象为脑裂。
“脑裂”问题可能有以下几个原因造成:
- 网络问题:集群间的网络延迟导致一些节点访问不到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),可适当减少误判。
- 选举触发:我们需要在候选集群中的节点的配置文件中设置参数 discovery.zen.munimum_master_nodes的值,这个参数表示在选举主节点时需要参与选举的候选主节点的节点数,默认值是1,官方建议取值 (master_eligibel_nodes/2) 1,其中 master_eligibel_nodes为候选主节点的个数。这样做既能防止脑裂现象的发生,也能最大限度地提升集群的高可用性,因为只要不少于discovery.zen.munimum_master_nodes个候选节点存活,选举工作就能正常进行。当小于这个值的时候,无法触发选举行为,集群无法使用,不会造成分片混乱的情况。
- 角色分离:即是上面我们提到的候选主节点和数据节点进行角色分离,这样可以减轻主节点的负担,防止主节点的假死状态发生,减少对主节点“已死”的误判。
事务日志TransLog
上面提到,Lucene为了加快写索引的速度,采用了延迟写入的策略。虽然这种策略提高了写入的效率,但其最大的弊端是,如果数据在内存中还没有持久化到磁盘上时发生了故障,就可能丢失数据。为了避免丢失数据,Elasticsearch添加了事务日志(Translog),事务日志记录了所有还没有被持久化磁盘的数据。
Elasticsearch写索引的具体过程如下:
- 内存缓存且记录日志:当有数据写入时,为了提升写入的速度,并没有数据直接写在磁盘上,而是先写入到内存中,但是为了防止数据的丢失,会追加一份数据到事务日志里。
- Refresh:当达到默认的时间(1秒钟)或者内存的数据达到一定量时,会触发一次Refresh。将JVM中的数据以段的格式缓存到文件系统缓存(操作系统内存)中。
- Flush:当日志数据的大小超过512MB或者时间超过30分钟时,需要触发一次Flush。将文件系统缓存中的段数据同步至磁盘中。
有关于Translog和Flush的一些配置项:
代码语言:javascript复制#当发生多少次操作时进行一次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数据给副本分片。路由公式如下:
代码语言:javascript复制shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id ,也可以设置成自定义的值。routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到余数 。这个在 0 到 主分片数-1 之间的余数,就是我们所寻求的文档所在分片的位置。
这也解释了为什么我们要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
由于在ES集群中每个节点通过上面的计算公式都知道集群中的文档的存放位置,所以每个节点都有处理读写请求的能力。在一个写请求被发送到某个节点后,该节点即为前面说过的协调节点,协调节点会根据路由公式计算出需要写到哪个分片上,再将请求转发到该分片的主分片节点上。