目录
Translog
FST/FOR/RBM算法解析
FST
FOR(Frame of Reference):
RBM(Roaring Bitmaps)-(for filter cache)
Translog
es是近实时的存储搜索引。近实时,并不能保证被立刻看到。数据被看到的时候数据已经作为一个提交点,被写入到了文件系统中(这个过程称为refresh)。因为一次写入的成本相对比较大,所以用攒一波批量提交的方式,写入性能会更好。不管这些数据都是在堆内存中还是在文件系统中(Filesystem Cache),如果发生断电,或者JVM的崩溃,则这部分数据一定会丢失。为了防止数据丢失,这部分数据会被写入到traslog中一份。当然这个写入translog的代价远小于作为一个提交点写入到分片中(lucene实例中)的代价小。commit 是一个开销比较大的操作,因此不可能每次写入或删除都调用 commit,为了保证数据的可靠性,ES 引入了 Translog。ES 每次调用 Lucene 的接口写入或删除数据后,都会将操作日志记录到 Translog 中防止意外断电或程序奔溃导致数据丢失。写入时机如下:
2. 设置
- 同步间隔(index.translog.sync_interval)
Translog 的日志每次都会写入到操作系统的缓存中,只有执行 fsync 刷盘后才是安全的。因此 ES 会每隔一段时间执行 fsync 刷盘。默认时间间隔是 5s,最低不能低于 500ms。注:改参数只针对异步落盘方式才生效。- 刷盘方式(index.translog.durability)
Translog 的刷盘方式有两种:同步(request)和异步(async)
ES 默认使用的是 request,即每次写入、更新、删除操作后立刻执行 fsync 落盘。
如果使用异步的方式,则根据同步间隔周期性的刷盘。
两种方式各有千秋,同步刷盘具备更强数据可靠性保障,但同时带来更高的 IO 开销,性能更低。异步刷盘牺牲了一定的可靠性保障,但是降低了 IO 的开销,性能更佳,因此需要根据不同的场景需求选择合适的方式。- 大小阈值 (index.translog.flush_threshold_size)
我们不可能让 translog 的大小无限增长。translog 的大小过大会带来如下问题:占用磁盘空间;节点恢复需要同步 translog 回放日志,如果太大会导致恢复时间过长。因此,需要设定一个阈值,当日志量达到该阈值时,触发 flush,生成一个新的提交点,提交点之前的日志便可以删除了(具体能否删除还需结合日志的保留时长和保留大小而定)。- 日志保留大小(index.translog.retention.size)
该设置项控制 translog 保留的大小。translog 保留的目的是为了在副本故障恢复时,提高恢复速度(主保留 translog,从恢复时,只需将 global checkpoint 后的日志发送给从就 ok 了)- 日志保留时长(index.translog.retention.age)
同上,满足任一条件即可- 日志 generation 阈值大小(index.translog.generation_threshold_size)
为了避免 translog 越来越大,增加恢复的时间。在 translog 达到阈值时,会执行 flush,触发 lucene commit,并滚动 translog 生成新的文件,当前 generation 前的操作数据都会提交到 lucene 持久化,恢复时,只需恢复当前 translog 中的操作即可。
FST/FOR/RBM算法解析
我们初步了解的倒排索引的结构组成部分: term index ,term dictionary , postings list;
es 底层针对每个组成部分都对应的一些算法操作来保证性能;
term dictionar
ES 为了能快速查找到 term,将所有的 term 排了一个序,二分法查找。类似 MySQL 的索引方式的,直接用 B+树建立索引词典指向被索引的数据。
term index
Term Dictionary 是分词以后得到的词条,数据量是很大的,不可能无节制的保存到内存中,容易内存溢出;而磁盘 io 那么慢。ES 默认可是会对全部 text 字段进行索引,必然会消耗巨大的内存,为此 ES 针对索引进行了深度的优化。在保证执行效率的同时,尽量缩减内存空间的占用。于是用到 term index;
Term index 从数据结构上分类算是一个字典树。这是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
这棵树不会包含所有的 term,它包含的是 term 的一些前缀(这也是字典树的使用场景,公共前缀)。通过 term index 可以快速地定位到 term dictionary 的某个 offset,然后从这个位置再往后顺序查找。就想上图所表示的。
lucene 在这里还做了两点优化,一是 term dictionary 在磁盘上面是分 block 保存的,一个 block 内部利用公共前缀压缩,比如都是 Ab 开头的单词就可以把 Ab 省去。二是 term index 在内存中是以 FST-有效状态机(finite state transducers)的数据结构保存的。
FST
但是它是k:v结构的,能够根据索引快速查询,查询速度不会超过O(索引长度);
算法明细:
依次保存 : "cat":5, "deep":10, "do":15, "dog":2, "dogs":8
-
录入步骤及解释如下:
1.从cat开始录入,查询开头是否有重合前缀,发现没有,则第一条边c的权重设置为cat对应value,然后继续插入a和t,因为cat整体value为5,所以a和t边权重为0,最后t指向一个末尾结点,该节点会被标记为末尾(因为前缀树的话会有很多重合,需要标记哪里有结尾)然后该节点会包含当前结束的索引项(cat)在所有路径权重和和初始权重的差值(这里为5-(5-0-0)=0)
2.开始录入deep,查询开头是否有重合前缀,发现没有,则依照步骤1的方法直接录入,从起点到第一个节点的路径权重为depp的value->10,然后同样,最后的末尾结点包含两个东西,一个末尾结点标记,一个余下的权重,前者为True,后者为0,图示如下
3.开始录入do,查询开头是否有重合前缀,发现有,但是权重10小于当前词(do)的权重,所以不做处理,但是把temp权重(用于计算余下的权重)设置为5,因为有5没有分配,继续往下走,查询是否有前缀重合,发现没有,则新建结点,然后将该条路径的权重设置为5,然后末尾结点同样包含两个东西-->一个标记一个权重余量,此处为0,图示如下:
-
4.开始录入dog,查询开头是否有重合前缀,发现有,然后比对权重,发现当前权重2<10,按照之前的分配方式不行,所以重新设置第一个权重为2,这样会导致之前的权重分配方案失败,怎么办呢,计算改变的量,此处为8(10-2),则需要下一个的出路径权重全部加上8(当然,如果下一个节点就有被标记为末尾结点,则末尾结点的余量也要加上响应的值),这时deep中的第一个e的权重从0变成8(符合条件,所以不需要继续传播),然后do中的o权重本来为5,现在变为5+8=13,然后此时分配到了dog的o,dog中o的权重余量为0,0小于当前权重13,所以还需要继续重新赋值,所以o被重置权重为0,又因为下一个节点被标记为结尾节点,所以此节点的末尾权重余量从0变为13-->(0+13),然后继续传播,查询是否有前缀,发现没有(g),然后新建结点(因为到末尾,所以是末尾结点)设置中间路径权重为0(因为dog的余量就为0),且末尾结点的权重余量亦为0。至此dog加入完毕,目前状态图如下
-
5.开始录入dogs,查询是否有前缀,发现有,2<8,此时不需要重新赋值权重,但是dogs余量变为6,继续传播-->查询是否有前缀?发现有,o,当前权重0<6,不需要重新分配,继续传播-->查询是否有前缀,发现有,g当前权重0<6,不需要重新分配,继续传播-->查询是否有前缀,发现没有,新建结点s,设置路径权重为6,并新建末尾结点,结点包含两个东西,一个末尾标记,一个权重余量,前者为true,后者为0,最终状态如图所示:
-
它最终的数据结构图示如下图所示
FST 有两个优点:
- 空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间。
- 查询速度快。O(len(str)) 的查询时间复杂度。
postings list
在 lucene 中,要求 postings lists 都要是有序的整形数组。但是数组也会非常大,占用很大的磁盘空间;需要对数组中的内容压缩;以及压缩以后仍然需要支持联合查询;
汇总在实际使用中,postings list 的痛点:
- postings list 如果不进行压缩,会非常占用磁盘空间,
- 联合查询下,如何快速求交并集(intersections and unions)
对于如何压缩,有两种算法:
FOR(Frame of Reference):
算法会把所有的文档分成很多个 block,每个 block 正好包含 256 个文档,然后单独对每个文档进行增量编码,计算出存储这个 block 里面所有文档最多需要多少位来保存每个 id,并且把这个位数作为头信息(header)放在每个 block 的前面。这个技术叫 Frame of Reference。
postings lists 是有序的整形数组,这样就带来了一个很好的好处,可以通过 增量编码(delta-encode)这种方式进行压缩。
比如现在有 id 列表 [73, 300, 302, 332, 343, 372]
,转化成每一个 id 相对于前一个 id 的增量值(第一个 id 的前一个 id 默认是 0,增量就是它自己)列表是[73, 227, 2, 30, 11, 29]
。在这个新的列表里面,所有的 id 都是小于 255 的,所以每个 id 只需要一个字节存储。
上图也是来自于 ES 官方博客中的一个示例(假设每个 block 只有 3 个文件而不是 256)。
FOR 的步骤可以总结为:
进过最后的位压缩之后,整型数组的类型从固定大小 (8,16,32,64 位)4 种类型,扩展到了[1-64] 位共 64 种类型。
通过以上的方式可以极大的节省 posting list 的空间消耗,提高查询性能。不过 ES 为了提高 filter 过滤器查询的性能,还做了更多的工作,那就是缓存。
RBM(Roaring Bitmaps)-(for filter cache)
使用过ES以后,肯定知道filters ;可以使用 filters 来优化查询,filter 查询只处理文档是否匹配与否,不涉及文档评分操作,查询的结果可以被缓存。
对于 filter 查询,es 提供了 filter cache 这种特殊的缓存,filter cache 用来存储 filters 得到的结果集。缓存 filters 不需要太多的内存,它只保留一种信息,即哪些文档与 filter 相匹配。同时它可以由其它的查询复用,极大地提升了查询的性能。
我们上面提到的 Frame Of Reference 压缩算法对于 postings list 来说效果很好,但对于需要存储在内存中的 filter cache 等不太合适。
filter cache 会存储那些经常使用的数据,针对 filter 的缓存就是为了加速处理效率,对压缩算法要求更高。
对于这类 postings list,ES 采用不一样的压缩方式:
首先 postings list 是 Integer 数组,具有压缩空间。
假设有这么一个数组,我们第一个压缩的思路是什么?用位的方式来表示,每个文档对应其中的一位,也就是我们常说的位图,bitmap。
它经常被作为索引用在数据库、查询引擎和搜索引擎中,并且位操作(如 and 求交集、or 求并集)之间可以并行,效率更好。
具体逻辑:将 doc id 拆成高 16 位,低 16 位。对高位进行聚合 (以高位做 key,value 为有相同高位的所有低位数组),根据低位的数据量 (不同高位聚合出的低位数组长度不相同),使用不同的 container(数据结构) 存储。
- len<4096 ArrayContainer 直接存值
- len>=4096 BitmapContainer 使用 bitmap 存储
分界线的来源:value 的最大总数是为2^16=65536
. 假设以 bitmap 方式存储需要 65536bit=8kb
,而直接存值的方式,一个值 2 byte,4K 个总共需要2byte*4K=8kb
。所以当 value 总量 <4k 时,使用直接存值的方式更节省空间。
空间压缩主要体现在:
- 高位聚合 (假设数据中有 100w 个高位相同的值,原先需要
100w*2byte
,现在只要1*2byte
) - 低位压缩
缺点就在于位操作的速度相对于原生的 bitmap 会有影响。
联合查询
- 如果查询有 filter cache,那就是直接拿 filter cache 来做计算,也就是说位图来做 AND 或者 OR 的计算。
- 如果查询的 filter 没有缓存,那么就用 skip list 的方式去遍历磁盘上的 postings list。
因为这个 FOR 的编码是有解压缩成本的。利用 skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的 block 的过程,从而节省了 cpu。