Lucene源码解析——DocValue存储方式

1,671 阅读27分钟

什么是DocValue?

讲完第一章的行式存储StoredField,接下来讲列式存储的DocValue。

什么叫列式存储?它和行式存储的区别是什么?一图以示之,如果我们要存储这个具有3个field,4个doc的表格数据,行式存储就是像上一章StoredField那样,在磁盘中按照一个一个doc来落盘,每个doc下的field紧密存储;而列式存储则是在磁盘中按照一个一个field来落盘,每个field下按照docid顺序来存储值:

image.png

那为什么需要区分行式存储和列式存储呢?主要是有两方面的原因:性能存储成本

先说性能。行式存储的优势就是根据docID可以很方便地把它的原文field信息捞出来,仅需要访问一次磁盘即可。但如果我想根据fieldC做排序、筛选或者统计怎么办?比如我想知道fieldC下value的总和?如果只用行式存储就需要读完几乎整个磁盘,而其中有用到的就只有field C的数据,众所周知,磁盘的读取速度是很慢的,因此在对于某个field进行排序、筛选的情况下,行式存储是不具备优势的。这时候就要引入列式存储, 列式存储的思想很简单, 既然你要统计fieldC, 那我就只读取fieldC的部分,如此一来大大减少了读取时间。

再说存储成本, 比如fieldC是score, 用于内部打分用的,只会用于统计、排序、筛选,不会用于呈现,并且doc1,doc2,doc3,doc4的score分别是34,30,24,32。在列式存储的情况下,可以很方便地减去最小值24后后变成10,6,0,8,接着用最大值的bits数来将它们进行紧密编码,只需要4bit*4 = 16bits,也就是2字节。但是如果用行式存储由于它没有全局的信息,所以只能看到一个单一的值,因此最多只能采用vint编码,每个数字至少需要1个字节,共需要4字节完成编码。

Lucene总共有五类DocValue,分别是SortedDocValues, SortedSetDocValues, NumericDocValues, SortedNumericDocValues, BinaryDocValuesNumericDocValues是针对数值类型进行的存储, SortedDocValue是针对字节类型来进行的存储。而SortedSetDocValue 和SortedNumericDocValues 相比SortedDocValue 和NumericDocValues 而言可以让一个field具有多个值。

注意了这里的sorted并不是说将field value排序后,存储value -> docid的映射(这个就是mysql的索引了!),而是另外一层意思, 对于SortedNumericDocValue来说就是一个field中的多个值是有序的, 而对于SortedDocValues来说,这个sorted是指将字符串按照字典顺序排序转成的value,比如doc1:apple, doc2:banana, doc3: add , 那最后存储成1,2,0。

之所以在这里说这个sorted的含义是有一次刻骨铭心的经历, 当时leader让我实现float类型下sorted doc value的磁盘存储,让我去借鉴借鉴lucene的做法。在我们的项目中,sorted_doc_value其实是存储的是排序后的value->docid的映射,在内存中可以用红黑树或者跳表来直接搞定,所以我就一直以为sorted doc value是这个意思,去翻lucene源码愣是觉得哪里不对劲,最后发现原来是两者对sorted 的定义不一样导致的问题。

那问题又来了,lucene有没有提供value->docid 类似于mysql索引的机制?实际上,lucene提供了一个sort Map可以让docuement在入库的时候按照某个field的顺序重新排序入库, 从而达到一样的提前截断的效果,但这么做只能对某一个字段起作用, 无法对多个字段同时生效, 因此其实它并没有在内部维护一个value->docid映射的机制。

五类DocValue索引详解

Demo

先看着Demo,这个demo相当于把所有的DocValues类型都囊括进来了


    public static void MAIN() throws IOException {
        // 0. Specify the analyzer for tokenizing text.
        //    The same analyzer should be used for indexing and searching
        StandardAnalyzer analyzer = new StandardAnalyzer();

        // 1. create the index
        Directory directory = FSDirectory.open(Paths.get("tempPath"));

        IndexWriterConfig config = new IndexWriterConfig(analyzer);

        IndexWriter w = new IndexWriter(directory, config);
        addDoc(w, "Lucene in Action", "193398817", -5, new int[]{1,2}, new String[]{"los angles", "beijing"});
        addDoc(w, "Lucene for Dummies", "55320055Z", 4, new int[]{5,1}, new String[]{"shanghai", "beijing"});
        addDoc(w, "Managing Gigabytes", "55063554A", 12, new int[]{0, 1, 2}, new String[]{"shenzhen", "guangzhou"});
        addDoc(w, "The Art of Computer Science", "9900333X", 2, new int[]{10, 4, 3}, new String[]{"shanghai", "los angles"});
        addDoc(w, "C++ Primer", "914324235", 11, new int[]{0, 5, 2, 3}, new String[]{"beijing", "shenzhen"});
        addDoc(w, "I like Lucene", "fdsjfa2313", 1, new int[]{0, 1, 2, 4}, new String[]{"nanjing", "tianjin"});
        addDoc(w, "Lucene and C++ Primer", "fdsfaf", 10, new int[]{0, 1, 2}, new String[]{"shenzhen", "guangzhou"});
        addDoc(w, "C++ api", "411223432", 2, new int[]{0, 11, 2}, new String[]{"shenzhen", "shanghai"});
        addDoc(w, "C++ Primer", "914324236", 50, new int[]{3,2,6,1}, new String[]{"beijing"});

        w.close();

        // 2. query
        String querystr =  "lucene";

        // the "title" arg specifies the default field to use
        // when no field is explicitly specified in the query.
        // Query q = new TermQuery(new Term("title", querystr));
        // BooleanQuery query = new BooleanQuery();
        MatchAllDocsQuery q = new MatchAllDocsQuery();

        //sort
        SortField visitSort = new SortedNumericSortField("visit", SortField.Type.INT, true);
        Sort sort = new Sort(visitSort);

        // 3. search
        int hitsPerPage = 10;
        IndexReader reader = DirectoryReader.open(directory);
        IndexSearcher searcher = new IndexSearcher(reader);
        TopDocs docs = searcher.search(q, hitsPerPage, sort);
        ScoreDoc[] hits = docs.scoreDocs;

        // 4. display results
        System.out.println("Found " + hits.length + " hits.");
        for(int i=0;i<hits.length;++i) {
            int docId = hits[i].doc;
            Document d = searcher.doc(docId);
            System.out.println((i + 1) + ". " + d.get("isbn") + "\t" + d.get("title") + "\t" + d.get("visit"));
        }

        // reader can only be closed when there
        // is no need to access the documents any more.
        reader.close();
    }

    private static void addDoc(IndexWriter w, String title, String isbn, int visit, int [] sale_list, String []locations) throws IOException {
        Document doc = new Document();
        doc.add(new StoredField("visit", visit));
        doc.add(new TextField("title", title, Field.Store.YES));

        // use a string field for isbn because we don't want it tokenized
        doc.add(new StringField("isbn", isbn, Field.Store.YES));
        doc.add(new SortedDocValuesField("title",new BytesRef(title)));
        if (!title.equals("C++ api")){
            doc.add(new NumericDocValuesField("visit", visit));
        }
        for (int sale : sale_list){
            doc.add(new SortedNumericDocValuesField("sale", sale));
            doc.add(new SortedNumericDocValuesField("sale", sale));
        }

        for (String location: locations){
            doc.add(new SortedSetDocValuesField("city", new BytesRef(location)));
        }

        w.addDocument(doc);
    }
}

处理逻辑

和StoredField是一致的,核心都是从DefaultIndexingChain作为出发点开始的,之前我们提到过,DefaultIndexingChain是以field为基本单位来进行处理的,所以这里首先需要把该field对应的docValue处理类先找到,共五个类分别处理五个docValues类型, 每个docValuesWriter都会调用各自的flush方法走入各自的逻辑, 但最终殊途同归都会由Lucene70DocValuesConsumer进行处理,在处理的过程中有写函数是可以复用的,比如writeValues,实际上就是对写入数值类型values的一种封装..其它没有办法复用的过程都用others来代替了。 其实个人认为这里lucene有点过度封装的行为,简单来说这里就是为了获取不同docValueWriter,对每个docValue施加不同的写入方法而已, 但它这里写的属实的绕。接下来我会详细地讲解每个类型的落盘方式 image.png

NumericDocValues

先说几个DocValue中最简单的NumericDocValues,之前提到过,docValue实际上保存的就是在某个字段内,docid->value的信息, 当这个value是数值类型的时候,就是NumericDocValues

落盘方式

存储任何类型的数据,都会至少包括两种文件,一种是Meta文件, 另一种是data文件, 对于docValue 来说Meta文件的后缀是.dvm, value文件的后缀是.dvd, 一般是通过.dvm获取元信息再去.dvd中还原出初始信息。

压缩算法

在讲具体的落盘方式之前,首先要讲讲存储doc value有哪些压缩算法, 也就是说,给定一组无序的long 类型的value, 如何将它们落盘,并取得良好的压缩比和压缩速度:

  1. 直接存储 最简单直观的方法就是直接用一个kv 磁盘存储库来存储docid 和docvalue的映射。也就相当于不压缩。

  2. 字典编码存储 如果这组values有大量重复的情况, 那我就直接把其中unique值写入到meta信息里面,然后在data里面只记录它们的编号, 比如对于"城市"这个字段来说,总共就几十个值, 那我就没必要都写入一遍,直接把"上海"标记为1, "北京"标记为2,把这些原始信息写入到meta信息里即可。注意,进行编码前可以对unique值排个序。

  3. 压缩编码存储 对于大多数情况,其实值域都是不相同的,是不适用于case2 的。那么,假设我们的doc value是[6, 15, 12, 3, 9, 12, 21],值域在[3,21]的范围, 直观上我们可以直接将它减去最小值, 缩小它的值域为[0, 18], 从而变成[3, 12, 9, 0, 6, 9, 18], 然后每位数用最大值18的bits数5 bits(10010)来压缩这些数值 ,最终耗费的bits数为35bits,但有没有更好的办法呢?很明显,可以再除以它们的最大公约数3,进一步压缩它们的值域嘛,除以3以后会变成[1,4,3,0,2,3,6], 这时候每位数用最大值6的bits数3(110), 最终耗费的bits数为21bits 。所以归纳出来的方法就是:对于任意一组无序整数,对于每个num,(num-min)/gcd 即可获得最大的压缩比, 每个value需要耗费的总bits数是(max-min)/gcd , 这个数字我们且叫它bitPerValue

这时候考虑另外一个问题, 如果这个数组很长, 那么它的最大公约数就极有可能是1, max-min 的值也很有可能会很高, 从而起不到什么压缩效果, 解决这个问题的办法就是分block存储,保证每个block最多含有N个数字,这样也能起到很好的压缩效果。

更进一步地说, 这其中还有一个优化方向。 之前我们说过, 当bitPerValue为(max-min)/gcd 时,压缩比可以打到最大, 但是检索时不仅需要压缩比,更需要关注的是压缩速率。通过实验显示, 当bitPerValue数为一些固定的常数时,解压和压缩的速度可以达到最优,因此Lucene在实现DirectWriter和DirectReader的时候, 限定了bitPerValue只能为如下这些数字:

final static int SUPPORTED_BITS_PER_VALUE[] = new int[] {
    1, 2, 4, 8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64
};

为什么呢? 道理很简单, 试想我们如果要压缩这组数字:[121, 68, 23, 25], 注意DirectWriter处理的对象都是Long类型的:

image.png bitsRequired是7, 总共编码耗费4个byte,这时候如果你想要读取第二个数字68, 那么需要跨越两个字节才可以读取到,对于docValue这种需要频繁读取的正排信息,这明显是一种很大的性能浪费。另外,在编码的时候,这种跨byte的编码方式,也会让一个数字被处理两次执行多次循环。

但是如果我们采用的BitsRequired是8,情况如下:

image.png

此时的读取和写入性能都会比较好,同时也并不会损失过多的压缩率。

注意这些常数中, 1 2 4 8 16 24 这些我们都很好理解, 因为它们刚好是一个字节占用Bit数的整数, 但可以思考一下,为什么12 20 28也是这些常数中的一员?明明这些也是需要跨byte读取和写入的啊。

借用一下这篇博客 的讲解。比如我们来编码[69, 25, 261, 23] 这四个数字:

image.png

考虑一下, 如果采取BitsRequired=9的编码,当你需要取25这个数字的时候, 你需要计算出来,这个数字的首地址在第二个bit, 读取261的时候,你需要计算出来它的首地址在第三个字节, 读取69的时候首地址在第四个字节,每个数字都要算一次首地址,直到第九个数值才不需要计算首地址。

但是,如果采用bitsRequired=12的编码方法, 当你需要取25的时候, 它的首地址一定在第五个字节, 取261的时候不需要计算,取69的时候首地址一定在第五个字节, 而后每隔两个数算一次首地址。

为了尽量少计算首地址的位置,加快编码和解码的效率,同时兼顾压缩比,所以当bitsRequired为9的时候,我们最终以12个Bits为一个单位进进行编码解码。下图是在区间(8, 16)之间,各个bitPerValue的取值对应的平均读取一个数值需要计算首地址的次数,同样来源是他的博客

image.png

.dvm

image.png Header : 头文件,都有的,涵盖了本次采用的编码版本等信息。

DocsWithField : 是指含有这个字段的文档有哪些,因为lucene是schema free的,也就是说,文档A可以有price这个字段,但文档B可以不一定有, 这和mysql不一样,mysql在存储时需要指定一个schema,所有字段都需要包含这个schema。 具体而言分为三种情况讨论: image.png

  1. 没有任何文档包含这个字段 此时先写入一个-2, 再写入一个0来代表这种情况。

  2. 所有文档都包含这个字段 这是最普遍的一种情况,比如把es作为日志收集系统的时候,所有docs都会有timestamp这个字段,此时会先写入-1, 再写入0来表示这种情况。

  3. 部分文档包含这个字段 Lucene会把包含这个字段的doc放入一个bitsets中, 然后将这个bitset写入到.dvd里即可, .dvm则负责记录在.dvd文件中的offset和length。

NumValues: 指的是value的数量,在NumericDocValue的情况下,这个就是包含该field的文档的数量, 如果在SortedNumericDocValue的情况下,这个数值是指各文档内包含value数量的总和,这个数值用一个long类型来写入。

NumBitsPerValueMetaData 这个名字是我创造出来的, 比较拗口。回归到问题本质,现在的问题是,如何对同类型的一组value进行编码? 有这么几种方式:第一,如果所有的value都是一致的, 那我们只需要记录一个值即可;第二种情况:如果value有大量重复的情况, 那我就直接把这些枚举值写入到meta信息里面,然后在data里面只记录它们的编号, 比如对于"城市"这个字段来说,总共就几个值, 那我就没必要都写入一遍,直接把"上海"标记为1, "北京"标记为2,把这些原始信息写入到meta信息里, 然后存储值只存储1 2 3 这些;第三种方式是如果不是这些重复情况,那就要采用压缩编码的方式(也就是我之前提到的压缩算法章节);

对于上面几种方式而言,lucene 选择分情况采纳, 根据预估所需要用的bits数量来决定究竟采用哪种方式,而meta信息里面只会记录它采用的方式是哪一种: image.png

  1. 所有的value值都保持一致 此时直接写入-1。

  2. 采用词典编码(也就是情况2) 先计算唯一的值有多少个,如果这个值的个数小于这组value的最大值-最小值/gcd所得到的值,那么就采用词典编码, 此时先写入独立值的个数,然后将独立值依次写入。

  3. 采用非词典编码(也就是情况3) 如果唯一值的个数大于这组value的最大值-最小值/gcd所得到的值, 那就采用非词典编码, 采用block 存储时,写入-16, 采用非block存储时写入-1。block存储的概念也之前也讲过了,是为了优化max-min/gcd这种编码方法时减少max-min,增加gcd值的一个很好的方法。什么时候采用block存储呢? 当采用block存储比采用非block存储节省大约10%空间的时候,采用block存储。这个预估的space怎么估算出来的?我们只要在遍历这组数据计算gcd时顺便记录一下, 如果不用block,全局的max-min所需要的bits数, 还有用block(即每隔NUMERIC_BLOCK_SIZE 16384个数字)时统计一下每个blcok下max-min的bits数的总和即可估算算出。

min 压缩编码公式(num-min)/gcd里面的min值。当存储的docValue都一致时,这个就代表那个值。

gcd 同理, 压缩编码公式(num-min)/gcd里面的gcd值

startOffset 标记在.dvd中的位置起始值

dataLength

在.dvd中从startOffset后的dataLength 个字节就是这个field下所有docValue的存储的值。

.dvd

image.png

这里Header和Footer就不再赘述了,之前都说过。 DocIDField是用roaringBitMap(在lucene这里是IndexedDISI类)来存的这个field对应的docID有哪些,具体的细节因为在存储Norm时也会用到,所以这里不选择细讲。主要讲一下FieldValues怎么存的

FieldValues

image.png

这个图要结合着之前.dvm的示意图来看: image.png

所以也是分几种情况讨论:

** 所有数值一致 ** 此时不会写入.dvd文件,.dvm文件的min值就是这个值

** 采用词典编码时 ** 也就是当doc Values的unique数量-1所需要的bits值 < 这组docValue的(max-min)/gcd 所需要的Bits值时,满足词典编码的情况(读者可以思考一下,为什么是这个情况,可以从最后编码需要的字节数来进行考虑)。

例如我们编码[-5, 4, 12, 2, 11,1, 10]这组数据,unique数字数量为7,减去1以后就是6, bitsRequired是3, 但是为了更快的解码编码速度,bitsRequired变成4(这个没看懂可以看之前的 “压缩算法”小节), 如果采用(max-min)/gcd ,也就是17,bitsRquired=5, 为了更快的解码编码速度, bitsRquired为8。 满足词典编码的条件。

首先对它进行一个排序操作, 变成了[0,3,6,2,5,1,4], 这时候问题就转变成了对这些数字进行编码, 采用packed4 也就是每个数字占4个bit, 最后用四个字节最终的结果, 每个字节的数字为[3,98,81,64],这些数字就是我们的FieldValues了,而编号对应的初始数值放在了.dvm文件中。

image.png

非词典编码时, singleblock 也就是当doc Values的unique数量-1所需要的bits值 >= 这组docValue的(max-min)/gcd 所需要的Bits值时,满足非词典编码。

举个例子: 当需要编码的docValues是[6,15,12,3,9,12,21] 的时候, bitsRquired(unique_num - 1) 是4, (max - min)/gcd也是4, 此时满足非词典编码的条件。首先将这些docValues经过v-min/gcd转化出[1,4,3,0,2,3,6] 然后将packed4,将它们进行编码作为fieldValues。

image.png

非词典编码时, multiblock 当采用multiBlock的时候,(之前在说.dvm的时候说过什么时候采用Multiblock,这个是预估出来的),每隔16384个doc分出一个block,每个block下有两种情况:数据完全一致的情况, 和数据不完全一致的情况。

当数据完全一致的情况下, 写入一个0, 再写入这个完全一致的数字。比如这个BLOCK2:

image.png

当数据不完全一致的情况下, 那我们需要重新计算一下,在这个block局部内的bitsPerValue, min, 然后把bitsPerValue, min,还有这个block的packedValue写进去,附上这个packedValue的结束位置,示意图如下:

image.png

SortedNumericDocValues

其实这个类的名字起的很怪,叫NumericMultiDocValues更加合适,因为它能够实现让一个field拥有多个值的功能, 场景的话也很多, 比如说我们在存储一个城市某一天的温度的时候,它可能有早中晚不同时间段的温度,所以值就可能为[20, 25, 18], 从而可以在Query的时候,可以指定想要某个时间段的温度。

SortedNumericDocValues的落盘方式和NumericDocValue非常相似,原因在于它的fieldValue仍然是拍平了存储的, 比如说doc1 的docValue是[3,2,4], doc2 的docValue是[1,2], doc3的docValue是[0,8], 它fieldValue就会存储为[2,3,4,1,2,0,8], 然后再存储一下边界[0,3,5,7]就能还原出来了。因为这个边界实际上是一个递增的数组, 所以可以引出一个DirectMonotonicWriter来存储这种递增数组。

DirectMonotonicWriter

之前我们讲过一个无序的数组的压缩存储,基本思想就是(value-min/gcd)将值域变小,然后算出bitsRequired用DirectWriter来进行pack压缩, 那对于有序的数组来说,有什么好的压缩办法呢?比如给定一个数组[0,3,5,7,11, 14,15, 17, 19], 一个很朴素的想法是存储它们的差值[0,3,2,2,4,3,1,2,2]然后再用之前存储无序数组的压缩方法不就好了?

这是一种最简单的情况,如果数组是这样:[0, 300, 500, 700, 1100, 1400, 1500, 1700, 1900]怎么办?它们的差值就会变成[0,300,200,200,400,300,100,200,200] ,问题就在于要存储的数还是太大了!按照这种方式存我们需要bitsRequired(400) * 9 = 81 bits

有些同学受到之前的启发可能会说,那就同时除以一个最大公约数100吧,那就可以变成[0,3,2,2,4,3,1,2,2],这么想太过于天真了,真实数据的分布不会那么漂亮的,倘若如果有任何一个数字+1,它们会变成[0,301,200, 200, 400, 300, 100, 200, 100], gcd就会瞬间退化成1,所以除以gcd这种方法只有在数据非常完美的情况下才会起到作用, 那还有什么办法呢?

DirectMonotonicWriter是这么计算的:

  1. 计算增长平均值avgInc
  2. 根据avgInc缩放数据
  3. 计算最小值min,进行无符号处理
  4. 最后进行packedInt存储

python代码长这样:

In [94]: def convert(nums):
    ...:     avgInc = (nums[-1] - nums[0])/len(nums)
    ...:     for i in range(0, len(nums)):
    ...:         expected = avgInc * i
    ...:         nums[i] -= expected
    ...:     min_num = nums[0]
    ...:     for i in range(1, len(nums)):
    ...:         min_num = min(min_num, nums[i])
    ...:     for i in range(0, len(nums)):
    ...:         nums[i] -= min_num

我举个例子:

image.png

关注几个性质:

  1. 缩放后的结果是个较优结果,并非是一个最优结果。
  2. 计算中必须要用Long来计算,否则精读受损。
  3. 对于增长速率较为恒定的数组有比较好的压缩率。也就是说,需要尽量让expected接近0。
  4. DirectMonotomicWriter同样也采用分组的思想分批进行压缩,保证每一组内的压缩率得到提高。

说完这个就可以清楚SortedNumericDocValue比NumericDocValue多了什么了,实际上就是要多存一个某个field下的所有doc的docValue的数量, 只是我们这里存的是边界(数量数组累加后的结果就是边界结果),为什么要存边界,而不是存数量?docValue的读取和存储是非常快的,如果存的是count, 最后还需要转化成边界,多出了很多开销。最重要的是, 这里假设了数据的分部满足之前提到的性质3: 增长速率恒定, 也就是我们认为大多数某个field下的doc Value数组的长度是恒定的,这满足大多数的场景需求。 比如你的SortedDocValue存的是早中晚的气温, 那数组长度就是3, 最后需要压缩的数组就是[3,6,9,12] 这种增长速度永远是3的数组, 这种情况下采用DirectMonotonicWriter是最适合不过的了

.dvm

meta文件存储格式如下

dvm-SortedNumericDocValue (1).png 其实就是比之前的NumericDocValue多了一个DocValueCountMetaData.

NumDocsWithField: 意思是指有多少doc含有这个字段, 注意一下哈, 如果这组docValue的值的数量和这个值相同, 那说明什么问题? 每个doc下这个field只有一个值,也就是调用者把这个SortedNumericDocValue当NumericDocValue用了, 这时候后面这些offset, blockShift等字段都不需要存在了。

offset: 描述了docValueCount在.dvd文件中的位置。 明确来说是start_offset.

DIRECT_MONOTONIC_BLOCK_SHIFT): 这是一个DirectMonotonicWriter初始化的时候用到的常数值,目前写死的,是16.用来初始化DirectMonotonicWriter的Buffer的大小(大小不是16,是 1<<16, 不然怎么叫SHIFT.)

Min :之前在DirectMonotonicWriter提到的最小值。

AvgInc: 之前在DirectMonotonicWriter提到的平均增长度, 注意这个数字本来是float类型的, 所以要转成int类型(不是强转, 是把内存内容复制到一个Int类型中去);

DataLength: 就是DocValueCount信息在data文件的长度;

BitsRequired: 每个数字需要占多少bit位。

.dvd

dvd跟Numeric的区别就仅仅在于多了一个DocsWtihCount, 这部分就是用DirectMonotonicWriter写入的文件, 是个packedInt的数组。

dvd-SortedNumericDocValue.png

SortedDocValues

思考

之前说完了数值类型的docValue的存储方式后,接下来会说一下字符类型的。想一下最朴素存字符类型的方法是什么?比如给定doc0 - doc4 的字符集["ab", "abcde", "cba", "nba", "abc"], 最朴素的想法就是把它们one by one的拼接起来:["ababcdecbanbaabc"]然后存offset数组[0, 2, 7, 10, 13], 这个offset数组用之前的monotonicWriter压缩一下完事。

这个方法挺好的,但不是最好的,但问题有两个:

  1. 在于字符集没有得到压缩, 但一旦采用常规的压缩方法比如LZ4这些,就会导致效率受到损伤,而且读的时候必须一整块全部读出来, 这消耗太大了。
  2. 另外就是如果有两个word完全相同,相当于重复存了两次, 尤其是如果这个word很长的情况下,这么存是存在非常严重的浪费问题的!

对于问题1: 我们考虑,这里面有相同"ab"前缀的词很多,是否可以利用这一点来压缩?

对于问题2: 可以借鉴之前存NumericDocValue的经验, 将每个单词按照字典顺序用一个序号替代,这样如果重复的value很多, 开销只是多用了一个序号,而不用重复存储原始的值。

存储方法

基于以上的策略,我们可以开始讲Lucene是如何存SortedDocValue了,这不是一个小话题。

demo里面我们从doc0到doc8 的docValue如下["Lucene in Action", "Lucene for Dummies", "Managing Gigabytes", "The Art of Computer Science", "C++ Primer", "I like Lucene", "Lucene and C++ Primer", "C++ api", "C++ Primer"],

它们的termID分别是[0,1,2,3,4,5,6,7,4]

按照字典序排序后,DocValue 顺序如下: ["C++ Primer", "C++ api", "I like Lucene", "Lucene and C++ Primer", "Lucene for Dummies", "Lucene in Action", "Mananging Giagabytes", "The Art of Computer Science"]

我们用sortedValues来表示按照字典序排序后termID的顺序:[4,7,5,6,1,0,2,3] 这里下标就是字典大小顺序Ord,值就是termID, 此时我们就建立了一个Ord -> TermID的映射关系。

ordMap 来表示每个termID在字典顺序里的index, 比如term0 的对应的是5, 所以此时ordMap 就是[5,4,6,7,0,2,3,1]这里我们建了一个TermID->Ord的关系。

注意这两个数组非常重要,后面会经常用到。

个人认为docValue的存储格式是所有存储格式中最复杂的,但一步一步剖解也不难,计算机领域就是这样,从外表上看是一个非常复杂的系统,但其本质上就是由一个个最简单的与非门累积构造起来的, 只要沉下心慢慢想,并不存在什么非常难理解的概念。

.dvd

先看data文件 dvd-SortedFieldDocValue.png

以后header和footer都不会再赘述, 格式都一样。DocIDField之前也讲过, 本质上就是存储docID的bitset。因为lucene是schema free的,所以并不一定所有的doc都有这个字段。

剩下来的看上去很庞杂,但实际上只存储了三样东西: OrdMap, TermDict, TermIndex

OrdMap

就是图上画的Ords, 之前说过,这个本质上就是存储的是termID->Ord的关系。这个ord是指将term排序后的ord,为什么要存储这个关系呢?本质上就是因为TermDict存储的其实是顺序的term, 给定一个TermID,需要知道它在TermDict的位置才能在TermDict里面找到。OrdMap是个数组,下标是termID,值是Ord,所以这里直接采用DirectWriter来写,算出最大值需要的Bits数后,依次用该bits表示就行了。

TermDict

这个是整个sortedDocValue的精髓,存储该字段下所有的term,因为这里的term都是顺序的,所以采取前缀压缩。 在编码时会每16个value为一组形成一个block, 每个block下第一个value原封不动记录其长度和值,而后的15个value每个都去跟前一个value 去进行对比,算出相同前缀的长度PrefixLength, 以及不同后缀的长度suffixLength, 并且插入其不同的后缀值suffixValue。有需要注意的一点就是,当两者的长度都小于16的时候,PrefixLength和suffixLength是通过与操作放在一个byte下面的,用来节省字节 。如果有一个超过了长度>=16那就将这部分的长度-15的值通过vint编码放在后面。

另外BlockIndex用来记录每个block相对于第一个block在.dvd中的偏移,由于是递增的数列,使用DirectMonotonicWriter 进行存储。作用是当你想访问任意第n个block的数据的时候,可以读取BlockIndex迅速定位到对应的Block中。

这部分的代码位于Lucene70DocValuesConsumer.java中,贴一下代码:

private void addTermsDict(SortedSetDocValues values) throws IOException {
  final long size = values.getValueCount();
  meta.writeVLong(size);
  meta.writeInt(Lucene70DocValuesFormat.TERMS_DICT_BLOCK_SHIFT);
  // 这里addressBuffer实际上就是BlockIndex
  RAMOutputStream addressBuffer = new RAMOutputStream();
  meta.writeInt(DIRECT_MONOTONIC_BLOCK_SHIFT);
  long numBlocks = (size + Lucene70DocValuesFormat.TERMS_DICT_BLOCK_MASK) >>> Lucene70DocValuesFormat.TERMS_DICT_BLOCK_SHIFT;
  DirectMonotonicWriter writer = DirectMonotonicWriter.getInstance(meta, addressBuffer, numBlocks, DIRECT_MONOTONIC_BLOCK_SHIFT);

  BytesRefBuilder previous = new BytesRefBuilder();
  long ord = 0;
  long start = data.getFilePointer();
  int maxLength = 0;
  TermsEnum iterator = values.termsEnum();
  for (BytesRef term = iterator.next(); term != null; term = iterator.next()) {
    // 如果在block的第一个元素, 就原封不动写进去
    if ((ord & Lucene70DocValuesFormat.TERMS_DICT_BLOCK_MASK) == 0) {
      writer.add(data.getFilePointer() - start);
      data.writeVInt(term.length);
      data.writeBytes(term.bytes, term.offset, term.length);
    } else {
      // 第一个元素后的15个元素需要计算与上个值相等的前缀 以及不等的后缀写入
      final int prefixLength = StringHelper.bytesDifference(previous.get(), term);
      final int suffixLength = term.length - prefixLength;
      assert suffixLength > 0; // terms are unique

      data.writeByte((byte) (Math.min(prefixLength, 15) | (Math.min(15, suffixLength - 1) << 4)));
      if (prefixLength >= 15) {
        data.writeVInt(prefixLength - 15);
      }
      if (suffixLength >= 16) {
        data.writeVInt(suffixLength - 16);
      }
      data.writeBytes(term.bytes, term.offset + prefixLength, term.length - prefixLength);
    }
    maxLength = Math.max(maxLength, term.length);
    previous.copyBytes(term);
    ++ord;
  }
  writer.finish();
  meta.writeInt(maxLength);
  meta.writeLong(start);
  meta.writeLong(data.getFilePointer() - start);
  start = data.getFilePointer();
  addressBuffer.writeTo(data);
  meta.writeLong(start);
  meta.writeLong(data.getFilePointer() - start);

  // Now write the reverse terms index
  writeTermsIndex(values);
}

TermIndex

有了TermDict和ords可以通过一个termID来找到这个term在哪里了,但有一个问题,如果给定一个term, 怎么去快速定位它是否存在? 这就又需要对这些term来一个索引。 这里作者借鉴了跳表的方式,每隔n个term就取一个term出来(当前版本的n是1024),算出它与上一个term相同的前缀+后缀的第一个字节。最后PrefixValueIndex和BlockIndex的作用是一样的,记录每个prefixValue相对与第一个value的偏移, 采用MonotonicDirectWriter来进行存储。

举个例子说, 第1023个term是"hello", 第1024个term是"hellowen" 这时候TermIndex记录的第一个值就是"hellow", 第2047个term是"hex" 第2048个term是"hexical", 那此时第二个值记录的是"hexi", 比如现在给你个term "hehe" 在不在里面, 因为"hehe" > "hellow" 小于 "hexi" , 所以就可以知道这个term在第1024个term到第2048个term之间, 于是就可以锁定在第64个block到第128个block中间二分查找。

(个人认为1024这个数字设置的有点大,尤其对于中文场景下, 设置成256会更快一点但会消耗更多存储空间)

TermIndex代码如下:

private void writeTermsIndex(SortedSetDocValues values) throws IOException {
  final long size = values.getValueCount();
  meta.writeInt(Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_SHIFT);
  long start = data.getFilePointer();

  long numBlocks = 1L + ((size + Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) >>> Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_SHIFT);
  RAMOutputStream addressBuffer = new RAMOutputStream();
  DirectMonotonicWriter writer = DirectMonotonicWriter.getInstance(meta, addressBuffer, numBlocks, DIRECT_MONOTONIC_BLOCK_SHIFT);

  TermsEnum iterator = values.termsEnum();
  BytesRefBuilder previous = new BytesRefBuilder();
  long offset = 0;
  long ord = 0;
  for (BytesRef term = iterator.next(); term != null; term = iterator.next()) {
    if ((ord & Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) == 0) {
      writer.add(offset);
      final int sortKeyLength;
      if (ord == 0) {
        // no previous term: no bytes to write
        sortKeyLength = 0;
      } else {
        sortKeyLength = StringHelper.sortKeyLength(previous.get(), term);
      }
      offset += sortKeyLength;
      data.writeBytes(term.bytes, term.offset, sortKeyLength);
    } else if ((ord & Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) == Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) {
      previous.copyBytes(term);
    }
    ++ord;
  }
  writer.add(offset);
  writer.finish();
  meta.writeLong(start);
  meta.writeLong(data.getFilePointer() - start);
  start = data.getFilePointer();
  addressBuffer.writeTo(data);
  meta.writeLong(start);
  meta.writeLong(data.getFilePointer() - start);
}

.dvm

看完.dvd再看dvm就比较简单了, .dvm就是对.dvd元信息的一种描述:

dvm-SortedFieldValue.png

FieldNumber

同之前的sortedNumericDocValue类似,就是field序号

DocValueType

这里就是sorted。

DocIDIndex

和SortedNumericDocValue一样

NumDocsWithField

拥有这个field的docs的数量

OrdIndex

这里实际上就是装的.dvd的ord的元信息,分为两种情况

  1. 当unique value个数<=1 的时候,填3个零

image.png

  1. 当value个数>1的时候,填入元数据numBitsPerDoc, 以及Ord在.dvd的startOffset和Length

TermDictMeta

size

代表有多少个元素

BLOCK_SHIFT

1向左移位BLOCK_SHIFT的值就代表一个block有多少阈值,这里这个值是4, 也就是代表一个block装16.

DIRECT_MONOTONIC_BLOCK_SHIFT

只要用MONOTONIC_WRITER都会有这个参数, 始化byte buffer的大小,buffer数组用来存放BlockIndex。

DirectMonotonicWriterMeta 之前在sortedNumericDocValue里面也提到过,当我们要描述一个递增的数列的时候,会用这个来表示, 这个MetaData记录一些元信息。 这个writer主要是用来记录BlockIndex的值的。

max_length 最大term的长度

BlockMeta 代表block信息从哪里开始, 有多长。

BlockIndexMeta 代表BlockIndex信息从哪里开始,有多长。

TermIndexMeta

对TermIndex的一个描述信息,TermIndex本质上是为了加速Term查找诞生的一种数据结构。

TERMS_DICT_REFERSE_INDEX_SHIFT

就是每隔多少个term进行依次记录。当前每隔1024进行一次记录(我个人认为这个数字偏大), 这个shift就是其实是10, 1<<10 = 1024。

DirectMonotonicWriterMeta

用来记录PrefixValue的相对地址,这也是一个递增的数列。

PrefixValueMeta

prefixValue在.dvd中从哪里开始, 有多长。

PrefixValueIndexMeta

prefixValueIndexMeta在.dvd中从哪里开始,有多长。

小结

总而言之言而总之, Lucene采用了先排序,排序后进行前缀压缩, 并维护一个Ord与原termID的map来对docValue进行存储的,对docValue大小进行比较的时候也是用ord进行比较,而不会用原本的值来进行比较。

SortedSetDocValues

理解了单值域的SortedDocValue, 那多值域SortedSetDocValues就很好理解了。跟之前SortedNumericDocValue很类似,.dvm 和.dvd 都只多了一个地方用来表示值域的区间。

对比一下SortedSetDocValue其实值多了一个OrdAddress字段而已,看下dvd文件落盘格式:

dvd-SortedSetDocValue.png

OrdAddress

之前说过,Ord实际上就是OrdMap,存储了termID->Ord的一个映射关系。 但由于在SortedSetDocValues中,我们是把所有doc的termID都累在一起放进去的, 所以无法区分哪个termID是哪个doc的,需要用OrdAddress来另外告诉我们:第1个-第3个是doc1 的,第3个-第5个是doc2, 第5个-第9个是doc3的的这些信息,在这个例子里面,OrdAddress记录的就是[3,5,9],很明显这又是一个递增数列,所以这里还是用了DirectMonotonicWriter来记录这些,非常简单。既然用了DirectMonotonicWriter,那跟之前一样肯定要记一下相关metaData, 所以.dvm格式如下:

dvm-SortedSetDocValue.png

SingleValue

如果所有判定发现所有的值都是单值的,这里就退化成SingleValue, 那这里写入0, 其它数据结构跟之前SortedDocValue一样保持不变,但如果发现有非单值的,这里写入1, 在numDocsWithField后面补上之前的OrdAddress的metaData.

OrdsAddressMeta

之前我们说过OrdAddress是用DirectMonotonicWriter来写的,所以它的Meta信息也就无外乎BLOCK_SHIFT, Min, AvgInc, Length, BitsRequired这些。

BinaryDocValues

先前我们说过,对于字符串型的docValue我们往往用sortedDocValue把它转成ord排序后用前缀压缩来存, 那我们有没有朴实无华一点的方法呢?比如直接存储这些字符串的内容?用的时候直接拿出来比不就得了。没错,binaryDocValue就是这么一种思想,当你的term足够短的时候, 用binaryDocValue的开销甚至是要低于SortedDocValue的。

由于binaryDocValue没有做额外的前缀压缩、去重等操作,所以编码结构要比之前几个简单的多,这部分的代码位于:

public void addBinaryField(FieldInfo field, DocValuesProducer valuesProducer) throws IOException {
  meta.writeInt(field.number);
  meta.writeByte(Lucene70DocValuesFormat.BINARY);

  BinaryDocValues values = valuesProducer.getBinary(field);
  long start = data.getFilePointer();
  meta.writeLong(start);
  int numDocsWithField = 0;
  int minLength = Integer.MAX_VALUE;
  int maxLength = 0;
  for (int doc = values.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = values.nextDoc()) {
    numDocsWithField++;
    BytesRef v = values.binaryValue();
    int length = v.length;
    data.writeBytes(v.bytes, v.offset, v.length);
    minLength = Math.min(length, minLength);
    maxLength = Math.max(length, maxLength);
  }
  assert numDocsWithField <= maxDoc;
  meta.writeLong(data.getFilePointer() - start);

  if (numDocsWithField == 0) {
    meta.writeLong(-2);
    meta.writeLong(0L);
  } else if (numDocsWithField == maxDoc) {
    meta.writeLong(-1);
    meta.writeLong(0L);
  } else {
    long offset = data.getFilePointer();
    meta.writeLong(offset);
    values = valuesProducer.getBinary(field);
    IndexedDISI.writeBitSet(values, data);
    meta.writeLong(data.getFilePointer() - offset);
  }

  meta.writeInt(numDocsWithField);
  meta.writeInt(minLength);
  meta.writeInt(maxLength);
  if (maxLength > minLength) {
    start = data.getFilePointer();
    meta.writeLong(start);
    meta.writeVInt(DIRECT_MONOTONIC_BLOCK_SHIFT);

    final DirectMonotonicWriter writer = DirectMonotonicWriter.getInstance(meta, data, numDocsWithField + 1, DIRECT_MONOTONIC_BLOCK_SHIFT);
    long addr = 0;
    writer.add(addr);
    values = valuesProducer.getBinary(field);
    for (int doc = values.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = values.nextDoc()) {
      addr += values.binaryValue().length;
      writer.add(addr);
    }
    writer.finish();
    meta.writeLong(data.getFilePointer() - start);
  }
}

.dvd

dvd-BinaryDocValue (1).png

Header, DocIDData, Footer实际上跟之前一样,不再赘述了。

TermsValue 实际上就是把字符值(当然可能不是string,是Binary) 原封不动地写入进去, 不去重,也不排序,也不压缩。每个Terms写完会有一个相对于起始点的地址偏移,同样把这些地址进行DirectMonotonicWriter编码就形成了TermsIndex 部分

.dvm

dvm-BinaryDocValue.png

实际上说的就是.dvd的meta信息。TermValueMeta的Offset是指TermsValue在.dvd的起始位置,Length是其长度。

总结

本章篇幅比较长,重点在于理解用来存储数值的NumericDocValue的压缩存储方式和SortedDocValue、BinaryDocValue用来存储字符的压缩存储方式,DirectMonotonicWriter在其中反复被使用到,是本章最重要的编码方式。另外对于多值存储来说,实际上只要多存储一个OrdAddress来告诉我们哪个doc含有哪些value即可。