Lucene作为一个索引和存储引擎,其实现中包含了很多数据存储和检索方面的算法。这里对其中一部分算法做些简单的分析,希望能够对我们在其他领域处理类似问题时提供参考。
批量顺序写入索引数据
和传统的数据库比较,Lucene由于对检索的要求更高:全文检索、高亮、相似度等,其索引处理和存储发的负担要远多于一般的数据库。再加上,Lcuene追求更大的文档处理吞吐量,所以Lcuene没有使用大多数传统的关系数据库的索引结构:B+树,而是使用map这种更利于I/O的结果存储索引。
map的新增操作只需要在map文件的末尾添加记录即可,而在普通的B+树增加记录却可能需要执行seek+update
操作。对磁盘来说,由于需要移动磁头/寻道的概率较低,顺序写的效率要高于随机写,所以map的写效率是优于B+树的。
为了保持磁盘的IO效率,lucene避免对索引文件的直接修改,所有的索引文件一旦生成,就是只读,不能被改变的。其操作过程如下:
- 在内存中保存新增的索引;
- 内存中的索引数量达到一定阈值时,触发写操作,将这部分数据批量写入新文件,我们称为segment;
- 新增的segment生成后,不能被修改;
- update操作和delete操作不会立即导致原有的数据被修改或者删除。
上面的过程执行一段时间后,最终我们会得到大量的segment。为了减少资源占用,也为了提高检索效率,lcuene会自动定期将这些小的segment合并成大的segment,合并过程中,由于map中的数据都是排好序的,所以也不会有随机写操作。通过merge,我们还可以把update和delete操作真正生效,删除多余的数据,节省空间。
Lucene使用的索引结构和管理办法,其出发点是为了保证写入性能,同时又能支持较高效率的检索,这种方法在很多NoSQL数据库中被使用,和B+树对应,这类索引方法我们称为LSM树(log structured merge tree)。LSM在不同的数据中有多种具体实现,差异主要表现在segment的内容组织,segment的合并时机和算法等方面。详细的资料可以参考:log-structured-merge-trees
联合检索
在很多数据库中,如果检索涉及到多个字段,通常检索过程中只会利用其中一个字段的索引。但Lucene能够保证使用所用相关字段的索引,其检索过程如下:
通过各个字段的索引检索出多组文档;
这些文档使用排好序的跳表(skip list)保存;
遍历这些跳表,找出它们之间的交集。
这里举个具体的例子(来自:algorithms-and-data-structures-power-lucene-and-elasticsearch)
假定我们根据三个不同字段检索得到三组文档,表示成下面三个列表:
A:[2, 13, 17]
B:[1, 13, 22]
C: [1, 3, 13]
每个列表提供两个操作:next
和advance
:
next表示得到将列表指针指向一个位置;
advance(m)表示将指针指向下一个值不小于m的位置;
开始遍历时,每个列表的指针指向第一个元素,
A.next -> 2
B.next -> 1
由于B的当前值1小于A的当前值2,所以执行:B.advance(2)
,将B的指针跳向第一个不小于2的位置,这个值就是13。
而A的当前值为2,小于13,所以这次轮到A需要执行跳跃:A.advance(13)
,结果指针也来到13。
A和B的指针都指向13,我们可以对A和C也执行上述操作,导致C的指针也指向13,所以13
就是符合条件的文档。
这个过程中,我们利用到了跳表的快速遍历特性,提供了检索效率。
行存储和列存储
Lucene在存储文档原始数据时,使用行存储格式,每行大小固定(16k), 在行内可存储多个文档。这么设计的用意在于考虑到Lucene面向的文档大部分较小,可以多个文档共存于一行,这样便于减少行数,相应的行索引数量也减少,提高读写效率。
Lucene为了支持数据的排序和聚合需求,还另外提供了列存储(DocValue),它用于存储所有文档的特定字段,当需要按照该字段进行排序、聚合时,和行存储比较,列式的存储更利于批量获取多个文档的字段值。
关于Lucene的文件存储格式,请参考lucene的相关文档
压缩
为了节省空间,lucene在行存储和列存储中存放的都是压缩数据。对于行存储,lucene提供了两种压缩选项:BEST_SPEED
和BEST_COMPRESSION
,对应速度优先和空间优先的不同策略。
而在列存储中,lucene主要通过对数据的编解码实现压缩,这里有些例子:
- Delta Encoding
- 取出字段的最小值;
- 其他字段的值表示成和最小值的差异;
- Table Encoding
- 如果字段的取值不超过256个;将所有的值保存在一个独立的table中;
- 各个字段表示成table的索引
- 在字段值的cardinality不多的情况下适用
Cardinality
Cardinality
是指集合中不同值的个数。按照一般的做法,我们需要遍历集合,将不同的值保存到一个Map中,完成遍历后,map的size就是cardinality。可是这种处理方法不适合大集合,会消耗太多内存。
Lucene采用了另外的方法来统计cardinality,该方法不能够得到精确计算结果,但是能够得到一个近似值。方法的名称为:HyperLogLog++
,计算过程如下:
- 计算字段的hash值;
- 统计hash值末尾开始的连续0的个数,记为m;
- 记录m的最大值;
-
Cardinality = 2
m
原始值 | hash值 | 末尾连续0的个数 |
---|---|---|
12345 | 11001011111101011010 | 1 |
3456 | 10001101001100111000 | 3 |
948 | 01000111110100110100 | 2 |
我们来看个例子:
原始值 | hash值 | 末尾连续0的个数 |
---|---|---|
12345 | 11001011111101011010 | 1 |
3456 | 10001101001100111000 | 3 |
948 | 01000111110100110100 | 2 |
在上面的3个值中,我们观察到末尾连续0的个数最大为3,根据公式,cadinality等于2
3
=8
这个计算过程不需要我们去遍历和记住所有的不同值,只需要一个变量(末尾连续0的个数)就可以了,所以对内存资源占用很少。当然,我们上面的描述是对HyperLogLog
的简化,为了减少结果偏差,HyperLogLog
的实现要比上面的过程稍微复杂一些,但是其本质不变,都是对结果的近似估算。
其实有很多类似的的近似估算方法,它们一般被归类到data sketch
算法,适用于大数据环境中对数据的近似统计。更详细的记录可以参考:skectching-sclaing