欢迎来到皮皮网网首页

【unreal源码图】【按键跳转源码】【母版页源码】lucene源码 segment

来源:分发授权源码 时间:2024-12-28 17:26:57

1.Lucene解析 - IndexWriter
2.Elasticsearch之存储原理
3.Elasticsearch学习总结之二:lucene的源码segment
4.lucene 全文检索原理和流程
5.Lucene SegmentReader:深入分析 I
6.Elasticsearch为啥这么快

lucene源码 segment

Lucene解析 - IndexWriter

       在上篇文章我们介绍了Lucene的基本概念,接下来本文将深入探讨Lucene的源码核心组件之一,即IndexWriter,源码让我们一起来探索数据写入和索引构建的源码过程。

       IndexWriter作为Lucene中用于数据写入的源码核心类,提供了数据写入的源码unreal源码图简洁流程,主要分为三个步骤。源码通过这个类,源码我们可以轻松地将数据写入并构建索引,源码其设计理念在于为普通用户提供了低门槛的源码使用体验,同时高级用户也能通过配置参数实现性能优化和功能定制。源码

       IndexWriter配置提供了关键参数,源码供高级用户进行性能调优和功能定制。源码这些核心参数包括但不限于缓存大小、源码分段策略、源码写入模式等,为用户提供灵活性。

       为了更深入理解IndexWriter,本节将介绍其提供的主要操作接口。这些接口包括添加文档、按键跳转源码更新文档、删除文档和提交操作,它们构成IndexWriter的核心功能。接下来,我们将通过一系列图示和解释,解析IndexWriter的内部数据处理流程。

       在IndexWriter内部,数据处理流程被高度优化,以支持多线程并发写入。通过引入DocumentsWriterPerThread(DWPT)机制,每个线程都有独立的空间进行数据处理,这大大提高了并发性能。DWPT内部包含一个内存缓冲区,缓冲区内的数据最终会被flush到不同的独立segment文件中。

       并发模型的设计使得多线程写入性能显著提升,尤其是针对新增文档的场景,提高了数据写入效率。对于删除文档操作,Lucene采用了一种特殊的母版页源码交互方式来降低锁的开销,使得整个流程更加高效。

       在搜索场景中,全量构建索引时,数据写入主要为新增文档;而在增量索引阶段,会涉及大量更新和删除操作。最佳实践是将具有相同唯一主键Term的文档分配给相同的线程进行处理,以避免跨线程冲突。

       添加和更新操作涉及将文档写入内存缓冲区,随后通过特定流程进行处理。删除操作则通过构建全局和独立的删除队列,以及更新live docs位图来实现,确保数据的有效管理和回收。

       flush操作用于将内存缓冲区中的数据持久化到文件,该过程由FlushPolicy自动触发或通过IndexWriter手动执行。commit动作则强制执行数据flush,并生成commit点,确保在搜索时可以访问已提交的数据。

       merge操作则用于合并segment文件,提高查询效率和回收已被删除的set 集合源码文档。此过程在segment flush时自动触发或通过IndexWriter强制执行。

       IndexingChain概念揭示了Lucene内部索引构建的链式流程。它涉及多个不同类型的索引构建,如倒排、store fields、doc values和point values。这些索引类型根据其功能和需求,使用不同的数据结构和存储方式。

       通过Codec配置,用户可以为不同类型的索引选择不同的编码和解码实现,支持了索引构建的灵活性和可扩展性。Codec参数的配置允许用户优化索引构建性能,满足特定应用需求。

       总结而言,本文从全局视角深入探讨了IndexWriter的配置、接口、并发模型、核心操作的数据路径以及索引链的概念。接下来的c crm 源码文章将继续深入研究索引链中每种不同类型索引的构建流程,包括memory-buffer的实现、索引算法以及数据存储格式等。

Elasticsearch之存储原理

        倒排索引被写入磁盘后是不可变的,ES解决不变性和更新索引的方式是使用多个索引,利用新增的索引来反映修改,在查询时从旧的到新的依次查询,最后来一个结果合并。

        ES底层是基于Lucene,最核心的概念就是Segment(段),每个段本身就是一个倒排索引。

        ES中的Index由多个段的集合和commit point(提交点)文件组成。

        提交点文件中有一个列表存放着所有已知的段,下面是一个带有1个提交点和3个段的Index示意图:

        Doc会先被搜集到内存中的Buffer内,这个时候还无法被搜索到,如下图所示:

        每隔一段时间,会将buffer提交,在flush磁盘后打开新段使得搜索可见,详细过程如下:

        下面展示了这个过程完成后的段和提交点的状态:

        通过这种方式,可以使得新文档从被索引到可被搜索间的时间间隔在数分钟,但是还不够快。因为磁盘需要 fsync ,这个就成为性能瓶颈。我们前面提到过Doc会先被从buffer刷入段写入文件系统缓存(很快),那么就自然想到在这个阶段就让文档对搜索可见,随后再被刷入磁盘(较慢)。

        Lucene支持对新段写入和打开,可以使文档在没有完全刷入硬盘的状态下就能对搜索可见,而且是一个开销较小的操作,可以频繁进行。

        下面是一个已经将Docs刷入段,但还没有完全提交的示意图:

        我们可以看到,新段虽然还没有被完全提交,但是已经对搜索可见了。

        引入refresh操作的目的是提高ES的实时性,使添加文档尽可能快的被搜索到,同时又避免频繁fsync带来性能开销,依靠的就是文件系统缓存OS cache里缓存的文件可以被打开(open/reopen)和读取,而这个os cache实际是一块内存区域,而非磁盘,所以操作是很快的,这就是ES被称为近实时搜索的原因。

        refresh默认执行的间隔是1秒,可以使用 refreshAPI 进行手动操作,但一般不建议这么做。还可以通过合理设置 refresh_interval 在近实时搜索和索引速度间做权衡。

        index segment刷入到os cache后就可以打开供查询,这个操作是有潜在风险的,因为os cache中的数据有可能在意外的故障中丢失,而此时数据必备并未刷入到os disk,此时数据丢失将是不可逆的,这个时候就需要一种机制,可以将对es的操作记录下来,来确保当出现故障的时候,已经落地到磁盘的数据不会丢失,并在重启的时候可以从操作记录中将数据恢复过来。elasticsearch提供了translog来记录这些操作,结合os cached segments数据定时落盘来实现数据可靠性保证(flush)。

        文档被添加到buffer同时追加到translog:

        进行 refresh 操作,清空buffer,文档可被搜索但尚未 flush 到磁盘。translog不会清空:

        每隔一段时间(例如translog变得太大),index会被flush到磁盘,新的translog文件被创建,commit执行结束后,会发生以下事件:

        下面示意图展示了这个状态:

        translog记录的是已经在内存生成(segments)并存储到os cache但是还没写到磁盘的那些索引操作(注意,有一种解释说,添加到buffer中但是没有被存入segment中的数据没有被记录到translog中,这依赖于写translog的时机,不同版本可能有变化,不影响理解),此时这些新写入的数据可以被搜索到,但是当节点挂掉后这些未来得及落入磁盘的数据就会丢失,可以通过trangslog恢复。

        当然translog本身也是磁盘文件,频繁的写入磁盘会带来巨大的IO开销,因此对translog的追加写入操作的同样操作的是os cache,因此也需要定时落盘(fsync)。translog落盘的时间间隔直接决定了ES的可靠性,因为宕机可能导致这个时间间隔内所有的ES操作既没有生成segment磁盘文件,又没有记录到Translog磁盘文件中,导致这期间的所有操作都丢失且无法恢复。

        translog的fsync是ES在后台自动执行的,默认是每5秒钟主动进行一次translog fsync,或者当translog文件大小大于MB主动进行一次fsync,对应的配置是 index.translog.flush_threshold_period 和 index.translog.flush_threshold_size 。

        当 Elasticsearch 启动的时候, 它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。

        translog 也被用来提供实时 CRUD 。当你试着通过ID来RUD一个Doc,它会在从相关的段检索之前先检查 translog 中最新的变更。

        默认 translog 是每5秒或是每次请求完成后被 fsync 到磁盘(在主分片和副本分片都会)。也就是说,如果你发起一个index, delete, update, bulk请求写入translog并被fsync到主分片和副本分片的磁盘前不会反回状态。

        这样会带来一些性能损失,可以通过设为异步fsync,但是必须接受由此带来的丢失少量数据的风险:

        flush 就是执行commit清空、干掉老translog的过程。默认每个分片分钟或者是translog过于大的时候自动flush一次。可以通过flush API手动触发,但是只会在重启节点或关闭某个索引的时候这样做,因为这可以让未来ES恢复的速度更快(translog文件更小)。

        满足下列条件之一就会触发冲刷操作:

        整体流程:

        删除一个ES文档不会立即从磁盘上移除,它只是被标记成已删除。因为段是不可变的,所以文档既不能从旧的段中移除,旧的段也不能更新以反映文档最新的版本。

        ES的做法是,每一个提交点包括一个 .del 文件(还包括新段),包含了段上已经被标记为删除状态的文档。所以,当一个文档被做删除操作,实际上只是在 .del 文件中将该文档标记为删除,依然会在查询时被匹配到,只不过在最终返回结果之前会被从结果中删除。ES将会在用户之后添加更多索引的时候,在后台进行要删除内容的清理。

        文档的更新操作和删除是类似的:当一个文档被更新,旧版本的文档被标记为删除,新版本的文档在新的段中索引。

        该文档的不同版本都会匹配一个查询,但是较旧的版本会从结果中删除。

        通过每秒自动刷新创建新的段,用不了多久段的数量就爆炸了,每个段消费大量文件句柄,内存,cpu资源。更重要的是,每次搜索请求都需要依次检查每个段。段越多,查询越慢。

        ES通过后台合并段解决这个问题。ES利用段合并的时机来真正从文件系统删除那些version较老或者是被标记为删除的文档。被删除的文档(或者是version较老的)不会再被合并到新的更大的段中。

        可见,段合并主要有两个目的:

        ES对一个不断有数据写入的索引处理流程如下:

        合并过程如图:

        从上图可以看到,段合并之前,旧有的Commit和没Commit的小段皆可被搜索。

        段合并后的操作:

        合并完成后新的段可被搜索,旧的段被删除,如下图所示:

        注意:段合并过程虽然看起来很爽,但是大段的合并可能会占用大量的IO和CPU,如果不加以控制,可能会大大降低搜索性能。段合并的optimize API 不是非常特殊的情况下千万不要使用,默认策略已经足够好了。不恰当的使用可能会将你机器的资源全部耗尽在段合并上,导致无法搜索、无法响应。

Elasticsearch学习总结之二:lucene的segment

       在深入学习Elasticsearch之后,我们继续探讨其底层关键技术之一:lucene的segment。首先,我们要理解LSM(Log Structured Merge Trees)的原理,它是一种被广泛应用在HBase、Cassandra等产品中的文件结构策略,旨在提高写操作的吞吐量,通过消除随机更新来优化性能。

       LSM的核心思想在于将数据写入过程转化为顺序操作,避免随机写入的性能瓶颈。LSM树包含三个关键组件:内存中的MemTable,它有序存储最近更新的数据,通过WAL保证数据可靠性;当MemTable达到一定大小,会变为Immutable MemTable,继续写操作并为持久化做准备;最后是SSTable(Sorted String Table),磁盘上的有序数据结构,通过索引和布隆过滤器提高查找效率。

       LSM树的设计虽然极大地提高了写性能,但可能导致冗余存储和读取时的复杂性。为此,Elasticsearch中的Segment借鉴了LSM的思想,但对查询性能有所妥协。Segment是Lucene中独立的索引单元,包含完整的正向和反向索引,可以独立搜索,但数据一旦写入就不可更改,提供的是近实时而非实时查询。

       Elasticsearch的Segment设计是为了平衡数据处理和搜索速度,它将数据缓冲在内存中,直到达到刷新周期才写入Segment。这样可以减少IO操作,但可能导致数据丢失。为保证数据可靠性,Elasticsearch使用Translog记录所有操作,但只有在配置的条件满足时才将数据同步到磁盘,因此需要理解Translog和fsync参数的配置以确保数据持久性。

lucene 全文检索原理和流程

       Lucene, 作为Java的高效全文检索库,其核心原理是通过创建索引和搜索索引两个流程,实现对非结构化数据的快速查找。在大数据背景下,索引技术至关重要,它将数据结构化,通过分词、语言处理和索引构建,提高查找效率。

       索引创建流程包括:将文档导入Lucene,分词器对文档进行处理,去除停用词并生成词元;接着,词元通过语言处理组件进行词形还原等操作,形成词(Term)。这些词(Term)被传给索引组件,构建字典并进行排序,形成倒排索引。

       存储方面,Lucene支持将索引数据存储在本地,且具有层次结构,包含正向信息(文档中的词频)和反向信息(词与文档的关系)。段(Segment)控制策略通过设置MaxMergeDocs和MinMergeDocs影响性能,而搜索过程则涉及查询分析、语言处理、在倒排索引中查找相关文档、权重计算和向量空间模型的应用,以找出与查询语句最相关的结果。

       具体来说,搜索流程包括:解析用户输入的查询语句,通过词法分析和语法处理转化为搜索请求;在索引中找到与查询词相关的文档链表,合并并排除无关文档;根据词的权重和向量空间模型计算文档间的相关性,最后按照相关性排序返回结果。

Lucene SegmentReader:深入分析 I

       Lucene的SegmentReader深入解析中,核心是理解和管理索引的多个段(segment_N文件)及其元数据(segments.gen)。索引通常由多个小段组成,这些段会在后台通过merge操作合并成大段以优化查询效率。每个段文件(如segment_3)代表了某个索引提交时刻的状态,其内容由SegmentInfo中的文件列表定义。

       在IndexWriter#flush后,新的文档会被添加到一个新的segment_N文件中,并将所有相关段的元信息写入该文件。同时,segments.gen文件会更新最大段号。选择哪个段进行查询时,通常会选择包含最多文档的段,且其元信息中包含了其他段的信息。

       SegmentReader的创建流程分为两种情况:如果只有一个段,会直接实例化SegmentReader;若有多段,则会创建MultiSegmentReader,内部包含多个SegmentReader实例。每个SegmentInfo都会生成一个SegmentReader对象,负责读取段的文件信息。SegmentReader的初始化过程虽然对于大索引可能耗时,因此建议尽量减少索引的频繁打开和关闭。下面是这部分的详细过程:

       Lucene索引通过segments.gen和segment_N文件管理多个段,每个段文件记录一个提交时刻的状态。flush操作时,新文档会增加到新的segment_N,同时更新元信息和最大段号。选择段时,倾向于选择包含最多文档的。

       SegmentReader的创建涉及单段或多段场景:单段直接实例化,多段则用MultiSegmentReader聚合。每个SegmentInfo对应一个SegmentReader,负责读取文件信息。SegmentReader初始化是耗时操作,应避免频繁打开关闭索引。

Elasticsearch为啥这么快

       æ€è€ƒå‡ ä¸ªé—®é¢˜ï¼š

        这里有篇 文章 讲解的很形象:

        这是集群cluster。

        这是节点Node:就是个机器。

        由一个或者多个节点,多个绿色小方块组合在一起形成一个ElasticSearch的索引。

        在一个索引下,分布在多个节点里的绿色小方块称为分片:Shard。

        一个分片就是一个Lucene Index。

        在Lucene里面有很多小的Segment,即为存储的最小管理单元。

        我们分别从Node维度、Shard维度、Segment维度来阐明为啥Elasticsearch这么快。

        多节点的集群方案,提高了整个系统的并发处理能力。

        路由一个文档到一个分片中:当索引一个文档的时候,文档会被存储到一个主分片中。 Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?实际上,这个过程是根据下面这个公式决定的:

        routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。这就解释了为什么我们要在创建索引的时候就确定好主分片的数量,并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。

        确定了在哪个分片中,继而可以判定其在哪个节点上。

        那么主分片数确定的情况下,如果做集群扩容呢?下图是一种主分片的扩容办法,开始设置为5个分片,在单个节点上,后来扩容到5个节点,每个节点有一个分片。也就是说单个分片的容量变大了,但是数量并不增加。

        节点分为主节点 Master Node、数据节点 Data Node和客户端节点 Client Node(单纯为了做请求的分发和汇总)。每个节点都可以接受客户端的请求,每个节点都知道集群中任一文档位置,所以可以直接将请求转发到需要的节点上。当接受请求后,节点变为「协调节点」。从这个角度,整个系统可以接受更高的并发请求,当然搜索的就更快了。

        以更新文档为例:

        Elasticsearch 中使用的这种方法假定冲突是不可能发生的,并且不会阻塞正在尝试的操作。因为没有阻塞,所以提升了索引的速度,同时可以通过 _version 字段来保证并发情况下的正确性:

        控制在我们索引中的文档只有现在的 _version 为 1 时,本次更新才能成功。

        可以设置分片的副本数量来提升高并发场景下的搜索速度,但是同时会降低索引的效率。

        在底层采用了分段的存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。

        怎样在保留不变性的前提下实现倒排索引的更新?即用上文提到的 _version ,创建更多的索引文档。实际上一个 UPDATE 操作包含了一次 DELETE 操作(仅记录标志待Segment Merge 的时候才真正删除)和一次 CREATE 操作。

        为了提升写索引速度,并且同时保证可靠性,Elasticsearch 在分段的基础上,增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录。

        Segment在被refresh之前,数据保存在内存中,是不可被搜索的,这也就是为什么 Lucene 被称为提供近实时而非实时查询的原因。

        但是如上这种机制避免了随机写,数据写入都是 Batch 和 Append,能达到很高的吞吐量。同时为了提高写入的效率,利用了文件缓存系统和内存来加速写入时的性能,并使用日志来防止数据的丢失。

        LSM-Tree 示意图如下,可见 Lucene 的写入思想和 LSM-Tree 是一致的:

        终于说到倒排索引了,都说倒排索引提升了搜索的速度,那么具体采用了哪些架构或者数据结构来达成这一目标?

        如上是Lucene中实际的索引结构。用例子来说明上述三个概念:

        ID是文档id,那么建立的索引如下:

        Name:

        Age:

        Sex:

        可见为每个 field 都建立了一个倒排索引。Posting list就是一个int的数组,存储了所有符合某个term的文档id。实际上,除此之外还包含:文档的数量、词条在每个文档中出现的次数、出现的位置、每个文档的长度、所有文档的平均长度等,在计算相关度时使用。

        假设我们有很多个 term,比如:

        如果按照这样的顺序排列,找出某个特定的 term 一定很慢,因为 term 没有排序,需要全部过滤一遍才能找出特定的 term。排序之后就变成了:

        这样我们可以用二分查找的方式,比全遍历更快地找出目标的 term。这个就是 term dictionary。有了 term dictionary 之后,可以用 logN 次磁盘查找得到目标。

        但是磁盘的随机读操作仍然是非常昂贵的(一次 random access 大概需要 ms 的时间)。所以尽量少的读磁盘,有必要把一些数据缓存到内存里。但是整个 term dictionary 本身又太大了,无法完整地放到内存里。于是就有了 term index。term index 有点像一本字典的大的章节表。比如:

        A 开头的 term ……………. Xxx 页

        C 开头的 term ……………. Yyy 页

        E 开头的 term ……………. Zzz 页

        如果所有的 term 都是英文字符的话,可能这个 term index 就真的是 个英文字符表构成的了。但是实际的情况是,term 未必都是英文字符,term 可以是任意的 byte 数组。而且 个英文字符也未必是每一个字符都有均等的 term,比如 x 字符开头的 term 可能一个都没有,而 s 开头的 term 又特别多。实际的 term index 是一棵 trie 树:

        例子是一个包含 "A", "to", "tea", "ted", "ten", "i", "in", 和 "inn" 的 trie 树。这棵树不会包含所有的 term,它包含的是 term 的一些前缀。通过 term index 可以快速地定位到 term dictionary 的某个 offset,然后从这个位置再往后顺序查找。

        现在我们可以回答“为什么 Elasticsearch/Lucene 检索可以比 mysql 快了。Mysql 只有 term dictionary 这一层,是以 b-tree 排序的方式存储在磁盘上的。检索一个 term 需要若干次的 random access 的磁盘操作。而 Lucene 在 term dictionary 的基础上添加了 term index 来加速检索,term index 以树的形式缓存在内存中。从 term index 查到对应的 term dictionary 的 block 位置之后,再去磁盘上找 term,大大减少了磁盘的 random access 次数。

        实际上,Lucene 内部的 Term Index 是用的「变种的」trie树,即 FST 。FST 比 trie树好在哪?trie树只共享了前缀,而 FST 既共享前缀也共享后缀,更加的节省空间。

        一个FST是一个6元组 (Q, I, O, S, E, f):

        例如有下面一组映射关系:

        可以用下图中的FST来表示:

        这篇文章讲的很好: 关于Lucene的词典FST深入剖析

        想想为啥不用 HashMap,HashMap 也能实现有序Map?耗内存啊!牺牲了一点性能来节约内存,旨在把所有Term Index都放在内存里面,最终的效果是提升了速度。如上可知,FST是压缩字典树后缀的图结构,她拥有Trie高效搜索能力,同时还非常小。这样的话我们的搜索时,能把整个FST加载到内存。

        总结一下,FST有更高的数据压缩率和查询效率,因为词典是常驻内存的,而 FST 有很好的压缩率,所以 FST 在 Lucene 的最新版本中有非常多的使用场景,也是默认的词典数据结构。

        Lucene 的tip文件即为 Term Index 结构,tim文件即为 Term Dictionary 结构。由图可视,tip中存储的就是多个FST,

        FST中存储的是<单词前缀,以该前缀开头的所有Term的压缩块在磁盘中的位置>。即为前文提到的从 term index 查到对应的 term dictionary 的 block 位置之后,再去磁盘上找 term,大大减少了磁盘的 random access 次数。

        可以形象地理解为,Term Dictionary 就是新华字典的正文部分包含了所有的词汇,Term Index 就是新华字典前面的索引页,用于表明词汇在哪一页。

        但是 FST 即不能知道某个Term在Dictionary(.tim)文件上具体的位置,也不能仅通过FST就能确切的知道Term是否真实存在。它只能告诉你,查询的Term可能在这些Blocks上,到底存不存在FST并不能给出确切的答案,因为FST是通过Dictionary的每个Block的前缀构成,所以通过FST只可以直接找到这个Block在.tim文件上具体的File Pointer,并无法直接找到Terms。

        回到上面的例子,给定查询过滤条件 age= 的过程就是先从 term index 找到 在 term dictionary 的大概位置,然后再从 term dictionary 里精确地找到 这个 term,然后得到一个 posting list 或者一个指向 posting list 位置的指针。然后再查询 sex=Female 的过程也是类似的。最后得出 age= AND sex=Female 就是把两个 posting list 做一个“与”的合并。

        这个理论上的“与”合并的操作可不容易。对于 mysql 来说,如果你给 age 和 gender 两个字段都建立了索引,查询的时候只会选择其中最 selective 的来用,然后另外一个条件是在遍历行的过程中在内存中计算之后过滤掉。那么要如何才能联合使用两个索引呢?有两种办法:

        Elasticsearch 支持以上两种的联合索引方式,如果查询的 filter 缓存到了内存中(以 bitset 的形式),那么合并就是两个 bitset 的 AND。如果查询的 filter 没有缓存,那么就用 skip list 的方式去遍历两个 on disk 的 posting list。

        用一个例子来说明如何使用 skip list 的思路来做合并(参考 Lucene学习总结之七:Lucene搜索过程解析(5) ):

        Advance操作是什么?就是 skip list 提供的快速跳跃的特性。

        另外一方面,对于一个很长的 posting list,比如:

        [1,3,,,,,,,]

        我们可以把这个 list 分成三个 block:

        [1,3,] [,,] [,,]

        然后可以构建出 skip list 的第二层:

        [1,,]

        1,, 分别指向自己对应的 block。这样就可以很快地跨 block 的移动指向位置了。

        Lucene 自然会对这个 block 再次进行压缩。其压缩方式叫做 Frame Of Reference 编码。示例如下:

        考虑到频繁出现的 term(所谓 low cardinality 的值),比如 gender 里的男或者女。如果有 1 百万个文档,那么性别为男的 posting list 里就会有 万个 int 值。用 Frame of Reference 编码进行压缩可以极大减少磁盘占用。这个优化对于减少索引尺寸有非常重要的意义。当然 mysql b-tree 里也有一个类似的 posting list 的东西,是未经过这样压缩的。

        因为这个 Frame of Reference 的编码是有解压缩成本的。利用 skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的 block 的过程,从而节省了 cpu。

        这也可以看到,Lucene 为了省内存真是做到了极致。

        Bitset 是一种很直观的数据结构,对应 posting list 如:

        [1,3,4,7,]

        对应的 bitset 就是:

        [1,0,1,1,0,0,1,0,0,1]

        每个文档按照文档 id 排序对应其中的一个 bit。Bitset 自身就有压缩的特点,其用一个 byte 就可以代表 8 个文档。所以 万个文档只需要 .5 万个 byte。但是考虑到文档可能有数十亿之多,在内存里保存 bitset 仍然是很奢侈的事情。而且对于个每一个 filter 都要消耗一个 bitset,比如 age= 缓存起来的话是一个 bitset,<=age< 是另外一个 filter 缓存起来也要一个 bitset。

        所以秘诀就在于需要有一个数据结构:

        可以很压缩地保存上亿个 bit 代表对应的文档是否匹配 filter;

        这个压缩的 bitset 仍然可以很快地进行 AND 和 OR 的逻辑操作。

        Lucene 使用的这个数据结构叫做 Roaring Bitmap。

        其压缩的思路其实很简单。与其保存 个 0,占用 个 bit。还不如保存 0 一次,然后声明这个 0 重复了 遍。

        为什么是以为界限?程序员的世界里除了外,也是一个经典值,因为它=2^-1,正好是用2个字节能表示的最大数,一个short的存储单位,注意到上图里的最后一行“If a block has more than values, encode as a bit set, and otherwise as a simple array using 2 bytes per value”,如果是大块,用节省点用bitset存,小块就豪爽点,2个字节我也不计较了,用一个short[]存着方便。

        在 Lucene 7.0之后,Lucene 针对 bitset的稠稀性,采用不同的存储方式:当 bitset比较稀疏时,直接存储DocID;当 bitset 稠密时,则直接存储 bitset 的Bits数据。根据数据的分布情况不同,采用适当的结构不仅可以提高空间的利用率,还能提高遍历的效率。

        Elasticsearch/Lucene 为了提升索引和搜索的效率,从上层到底层,使用了各种巧妙的数据结构和设计,靠优秀的理论加极致的优化,做到查询性能上的极致。