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

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

什么是StoredField?

之前我们提到过,Lucene作为一个检索引擎主要保存七种类型的数据: PostingList,TermDict,StoredField ,DocValue, TermVector, Norms,PointValue, 前四种是所有检索引擎都会保存的数据,后三种是Lucene特有的数据,实际上,StoredField就是我们所说的正排数据,它是一种行式存储,类似于mysql中的行数据,StoredField承担存储最原始的数据的角色重要性不言而喻,之所以先讲这块也是因为它最简单,但是在讲StoredField的时候会包含很多Lucene的基础知识点,彻底搞明白了StoredField,也就基本搞懂了index的流程, 以及一些编码方式(比如vint, packedInt, delta存储等技巧)

本章主要要搞清两个问题: 1.各数据类型是如何存储的? 2.整体storedField落盘是如何压缩的?

Demo example

扒代码最好还是自己写个最简单的demo,通过debug模式来看它的处理流程,下面是最简单的Lucene demo:

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.*;
import org.apache.lucene.index.*;
import org.apache.lucene.search.*;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;


public class demo {

    public static void main(String[] args) throws IOException{
        MAIN();
    }

    public static void MAIN() throws IOException {
        // 指定analyzer
        StandardAnalyzer analyzer = new StandardAnalyzer();

        // 指定目录
        Directory directory = FSDirectory.open(Paths.get("tempPath"));
        // 指定config
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        // 建立IndexWriter
        IndexWriter w = new IndexWriter(directory, config);
        // 创建document
        Document doc = new Document();
        doc.add(new TextField("title", "Lucene in action", Field.Store.YES));
        doc.add(new StringField("isbn", "193398817", Field.Store.YES));
        doc.add(new StoredField("visit", 5));
        doc.add(new SortedNumericDocValuesField("visit", 5));
        // 添加document到indexWriter
        w.addDocument(doc);
       // 落盘flush
        w.close()

}
}

存储数据的调度过程

存储数据的相关调用逻辑如下


开源项目和公司里的代码不同的是,开源项目往往每做一件事,调用层级都很深,非常符合软件工程的哲学,一般有七八层调用层级,在公司里写代码一般调用层级在4-5层,往往把一些步骤混在一起来写, 记得百度的一名优秀的工程师章淼说过,“我们自以为用了面向对象的语言,就能写出面向对象地代码,但实际上大多数人都是忠实的面向过程的拥簇者“,所以学习开源代码还是有利于教我们如何构建一个复杂工程代码的。

整个调用链自上而下,大概可以分三层, 前四个步骤是逻辑调用层,IndexWriter类将整个Document作为参数调用addDocument方法, IndexWriter下的DocWriter再调用对应的updateDocument去更新文档,最后从线程池种拉出一个DocWriterPerThread对象来执行最终的updateDocument逻辑,再这一层实际上并没有什么实质性地发生

中间一层是索引链的处理逻辑,DefaultIndexingChain是一个非常核心的类,负责对当前文档个建索引的核心操作,它定义了什么时候该写倒排拉链,什么时候写DocValue,什么时候写入StoredField 等。 processDocument 是整个索引链个入口方法,它会负责将整个文档按照Field拆开,分别调用下面的processField方法:

DefaultIndexingChain.processDocument源码

@Override
  public void processDocument() throws IOException {

    // How many indexed field names we've seen (collapses
    // multiple field instances by the same name):
    // 这个document 有多少field,记住同名地field只算一次
    int fieldCount = 0; 
    // 这个doc 的版本,每次更新就叠加+1
    long fieldGen = nextFieldGen++;

    termsHash.startDocument();

    startStoredFields(docState.docID);
    try {
      for (IndexableField field : docState.doc) {
        fieldCount = processField(field, fieldGen, fieldCount);
      }
    } finally {
      if (docWriter.hasHitAbortingException() == false) {
        // Finish each indexed field name seen in the document:
        //依次处理所有term
        for (int i=0;i<fieldCount;i++) {
          fields[i].finish();
        }
        finishStoredFields();
      }
    }

    try {
      termsHash.finishDocument();
    } catch (Throwable th) {
      // Must abort, on the possibility that on-disk term
      // vectors are now corrupt:
      docWriter.onAbortingException(th);
      throw th;
    }
  }

真正的索引链核心执行逻辑还是在processField里面:

DefaultIndexingChain.processField源码

private int processField(IndexableField field, long fieldGen, int fieldCount) throws IOException {
    String fieldName = field.name();
    IndexableFieldType fieldType = field.fieldType();

    PerField fp = null;

    if (fieldType.indexOptions() == null) {
      throw new NullPointerException("IndexOptions must not be null (field: \"" + field.name() + "\")");
    }

    // Invert indexed fields:
    // 在该Field上面建倒排表
    if (fieldType.indexOptions() != IndexOptions.NONE) {
      fp = getOrAddField(fieldName, fieldType, true);
      boolean first = fp.fieldGen != fieldGen;
      fp.invert(field, first);

      if (first) {
        fields[fieldCount++] = fp;
        fp.fieldGen = fieldGen;
      }
    } else {
      verifyUnIndexedFieldType(fieldName, fieldType);
    }

    // Add stored fields: 存储该field的storedField
    if (fieldType.stored()) {
      if (fp == null) {
        fp = getOrAddField(fieldName, fieldType, false);
      }
      if (fieldType.stored()) {
        String value = field.stringValue();
        if (value != null && value.length() > IndexWriter.MAX_STORED_STRING_LENGTH) {
          throw new IllegalArgumentException("stored field \"" + field.name() + "\" is too large (" + value.length() + " characters) to store");
        }
        try {
          storedFieldsConsumer.writeField(fp.fieldInfo, field);
        } catch (Throwable th) {
          docWriter.onAbortingException(th);
          throw th;
        }
      }
    }
    // 建docValue
    DocValuesType dvType = fieldType.docValuesType();
    if (dvType == null) {
      throw new NullPointerException("docValuesType must not be null (field: \"" + fieldName + "\")");
    }
    if (dvType != DocValuesType.NONE) {
      if (fp == null) {
        fp = getOrAddField(fieldName, fieldType, false);
      }
      indexDocValue(fp, dvType, field);
    }
    if (fieldType.pointDataDimensionCount() != 0) {
      if (fp == null) {
        fp = getOrAddField(fieldName, fieldType, false);
      }
      indexPoint(fp, field);
    }

    return fieldCount;
  }

我们本章之关心中间存储原始数据的那部分,在索引链里面,不管你是创建倒排表还是写DocValue,还是写StoredField,都需要有对应的consumer来代替进行完成, 而不是由这个类自己完成, 而之所以叫DefaultIndexingChain也正是因为,作者想表达的意思是,使用者完全可以自己定义一个索引链来决定索引的过程, 而索引的细节并不由索引链负责, 这样可以有效地完成解耦。

DefaultIndexingChain 里面, 写入termVector的实际上是由termHash类负责, 写入StoredField是由StoredFieldsConsumer来负责。而StoredFieldsConsumer里面又嵌着一层StoredFieldWriter, 最终写入是由这个类来写入的, 为什么又要嵌一层Writer? 因为还有一部分的consumer类最终执行的writer是由consumer对应的documentWriterPerThread来决定的,为什么需要这么做? 因为Lucene发展到现在已经有八个版本了,每个版本的写入StoredField的逻辑都不尽相同,因为Lucene不希望把最后写入的逻辑和调度的逻辑耦合起来,希望使用者可以自己通过docWriterPerThread的codec来指定究竟使用哪个版本,使用者可以在调用层的IndexWriterConfig来指定最终编码是使用新版本的StoredFieldWriter 还是老版本的,非常灵活。而Lucene7默认使用的是writer是CompressingStoredFieldWriter, 这个类在codec里面的compressing包可以找到。

以上其实都是调度逻辑,并不是真正的写入逻辑,真正的写入逻辑就需要从CompressingStoredField里面一探究竟了。

各类型storedField的编码结构

CompressingStoredField中,含有一个对象 bufferedDocs,这个实际上就是最终落盘的字节流,它是一个GrowableByteArrayDataOutput, 可以理解为是一个C++里的vector, 支持自动扩容等,这个类继承自抽象类DataOutput ,同时在方法定义了写入各种底层基本类型的数据的细节。记住,这个阶段它不落盘, 只是在内存里面,需要到flush阶段才会落盘。

int 类型

调用的是GrowableByteArrayDataOutputwriteZInt方法,这里的Z是zigzag encode的意思,也就是说,输入一个int 类型的数值,先将其zigzag编码, 而后进行vint编码:

vint编码

在讲zigzag编码之前,有必要先把vint编码弄清楚,才能理解清楚为什么要对int进行zigzag编码,vint 中的v叫variant, 变长的,可变的, 也就是这种编码可以根据数值的大小来采用合适的字节数量来进行编码。

试想一下: 如果我们要写入一个int类型的数值20,那是怎样的?

一个Int需要4个字节32个Bit, 其中三个字节都是浪费的。

而vint 只需要一个字节, 开头第一个字节是标记位,代表后续不需要更多字节来编码,如果这个字节开头是1, 那么代表后续一个字节继续为这个数字的编码,比如编码整型数字200:



每次编码都会取后面的7位,如果取完以后,左边还不全为0, 就需要标记为置位为1, 因此vint200 的第一个字节就是11001000。 而上面需要编码的数字经过第一轮以后就变成了00000000 00000000 00000000 00000001, 取后7位后,剩余的位数全为0, 停止编码, 把刚才取的后7位编码中vint中, 且标记为位0, 第二个字节 就是000000001,最终,只需要两个字节即可完成编码。

代码如下:

void DataOutput::writeVint(int i) {
        while ((i & ~(0x7f))!= 0){
            writeByte(char((i&0x7f)|0x80));
            i >>= 7;
        }
        writeByte(char(i));
    }

zint编码

刚才说到的vint编码有一个劣势,就是对于负数仍然会耗费大量的字节,因为负数采用的是补码编码方法,复习一下计算机基础知识,比如对于-5而言,原码是10000000 00000000 00000000 00000101, 反码是11111111 11111111 11111111 11111010, 补码就是11111111 11111111 11111111 11111011, 之所以采用补码来表示负数的方法,是为了便于正数和负数的运算能够产生正确结果

所以每次都需要进行4次编码才能编码到符号位,示意图如下(见encode int -5):



为了编码一个负数,甚至花费了比之前还多一个字节,这非常不值当, 解决这个问题的方法就是采用zigzag编码方法: 1. 根据符号位生成一个Mask, 如果是正数,就全是0, 如果是负数就全是1 2. 把负数向前移动1位 3. 将mask和刚才移动过的负数做^异或操作, 也就是如果两个位数相同,为0,如果不同则为1, 最后转为00000000 00000000 00000000 00001001

如果是正数,其实zigzag编码就是把符号位挪到了最后而已。

具体代码也非常简单:

int zigZagEncode(int i){
    return (i<<1) ^ (i>>31);
}

而后再采用vint方法编码, 就只要1个字节即可。

实际业务里,有大量的数值都是用一个字节即可表示的小数值, 用zint 编码即可以保证减少字节数量,也可以适应偶发的大数字的情况, 代价只是多出一个flag标志位。google protobuf 也才用了这种压缩方式来压缩传输的数据。

long类型

看到long类型就应该能想到, 在一些编程语言中, long和dateTime类型是等价的,所以long类型除了用了存储超大型整数, 它更多地被用于表示时间,因此lucene采用writeTLong来对其进行编码,这里面的T就是指的timestamp, 一起看下lucene是怎么编码long类型的数据的:



对于输入,假定了四种情况,每一种情况都需要采取不同的格式头。当该数值能够被1000*60*60*24所整除时,说明该时间戳的精度为天级别;;当该数能够被1000*60*60所整除时,说明该时间戳精度为小时级别;能够被1000整除时为秒级别精度, 其他情况下不压缩。每种情况对应的Header头都不相同,用前两位来表示其精度, 11为天,10为小时,01为秒, 00为其他。对于需要编码的数字本身,需要除以其本身的精度来进行压缩,精度越低,压缩后的数值就越小。

那Header的其他6位也不能随便浪费,首先需要对数值进行zigzag编码,这个编码方式之前也提到了,主要是避免符号位对后续vlong编码造成的影响,接着将编码的后五位放到header的后五位中, 如果原编码去除后五位后,仍然有非零数值, 就需要继续进行编码;相反,如果去除后五位后,全为0,则把HEADER的第三位置位1, 代表编码结束(这个比较反直觉,因为在vint vlong编码里面,1 代表有后续编码) 。

后续的字节编码规则和vint编码一致,同样也是每次只编7位数字,首位数字用来表示后续是否有编码。

综上,由于我们对需要编码的内容有先验知识存在,也就是已知需要编码的内容大概率是日期时间,所以可以利用这一点来尽可能地以最小代价完成编码。

Float类型

在讲float类型之前, 先复习一下IEEE编码中float类型的编码规则:



第一位是符号位,然后八个Bit代表指数位,最后23位代表尾数部分,举个例子:12.25f

  1. 首先将其转换为二进制,整数部分12很简单,大家都很熟悉: 1100, .25部分的转化是01,因为: 2^(3) + 2^(2) + 2^(-2) = 12.25, 所以二进制表示是1100.01
  2. 转为科学记数法表示, 也就是底数部分是1.10001, 指数部分是3(小数部分向左浮动了三位),因为底数部分的小数点部分恒为1, 所以这部分可以不参与编码
  3. 现在,符号为是0, 指数位是3, 但是我们现在考虑的是指数位为整数的情况, 如果指数位为负数怎么办?比如二进制表示是0.000101 那小数点非但不能向左移动,还要向右移动4位为1.01,指数位就是负数了,所以需要把它+127 全部归一成正数, 有些同学可能会问了, 那我就在指数部分加一个符号位不就行了, 问题在于这么编码会出现-0 和0 的情况,也就是两种编码表示同一种意思的现象,所以需要归一化到[0,255] 间, 3+127=130, 二进制表示就是10000010。尾数部分由于一开始的1不参与编码,所以只编小数点后的部分,就是1000100 00000000 00000000
  4. 最后把符号为、指数位和尾数位拼接起来行程最终的编码:



接下来可以讲讲lucene写入Float类型时的规则了, 相关方法是writeZFloat:

  1. 首先将float类型转成int类型,例如12.25 转成12
  2. 判断原float类型的值是否和这个int类型的值相同, 如果相同,并且该数值在[-1,125]之间,就可以采用一个字节进行编码。具体规则是将高位置成1, 低位将该int类型的值+1转成二进制进行编码, 比如说如果float的值是12.0, 最终编码就是10001101
  3. 如果不满足第一种情况, 但它仍然是一个正数,则满足第二种情况,比如12.25就满足的是第二种情况,直接按照IEEE格式进行编码:0 10000010 1000100 00000000 00000000 也就是不压缩
  4. 否则进入第三种情况,也就是例如-12.25这种情况, 那需要补充一个全为1的byte来表示这种情况: 11111111 1 10000010 1000100 00000000 00000000, 为什么需要塞入一整个额外的byte来表示这种情况,直接用-12.25的编码1 10000010 1000100 00000000 00000000不香吗?香,但是这种编码跟第一种编码格式上有冲突,在解码的时候你没有办法判断它属于第一种情况还是第三种情况。由于第一种情况首个byte的最大值顶多也是11111110,所以可以很好地跟第三种情况区分开来。

整理如下:


这种压缩方法的思想就是:尝试降低精度, 如果降低精度后仍然和原来一样,那就用低精度来表示,在这个场景下就是假设我们输入的数据大多是小数值,且小数位为0,可以把它当做一个普通的int来编码,但一旦出现小数位,lucene并不能做进一步的压缩, 甚至当它是小数时,还会比直接写入多浪费一个byte出来。因此在设计压缩系统时,先验知识是非常重要的,输入数据的分布、特征等,都对其最终的压缩比有非常大的影响,lucene作为一个通用搜索引擎,并不能假设用户的输入是符合什么规律的,因此只能做一个折衷,但我们如果希望设计一个更加符合业务方需求的搜索引擎, 是有必要考虑用户的输入是怎样的,进而对其采用合适的压缩方案。

源码如下:

static void writeZFloat(DataOutput out, float f) throws IOException {
    int intVal = (int) f;
    // 这步的目的是把float用IEEE编码转成二进制表示,并将其二进制表示转成int,因为我们底层的数组只有writeInt方法,没有writeFloat方法, java也没有C++ 的memcpy那么灵活的用法
    final int floatBits = Float.floatToIntBits(f);

    if (f == intVal
        && intVal >= -1
        && intVal <= 0x7D
        && floatBits != NEGATIVE_ZERO_FLOAT) {
      // 第一种情况,小数位为0,且在[-1,125]之间
      out.writeByte((byte) (0x80 | (1 + intVal)));
    } else if ((floatBits >>> 31) == 0) {
      // 其他正数float: 4 bytes
      out.writeInt(floatBits);
    } else {
      // 负数float : 5 bytes
      out.writeByte((byte) 0xFF);
      out.writeInt(floatBits);
    }
  }

Double类型

double类型和float类型非常相似,只有一种区别,那就是double类型还多了一种情况, 如果将double类型的数值转成float精度两者仍然相等,那么就用float来进行编码,并且在前面增加一个0b11111110作为标记字节,思想还是一样的:尝试降低精度,试试降低后是否和原来一样,如果一样的话,就用低精度来表示。

String 类型

在说如何编码string之前, 我们需要搞清楚一个事情,就是java的编码是以什么来编码的,在java内,一个char并不是像C++那样的一个字节,而是两个字节,因此它采用的是utf16编码,即每个字符用两个字节或者四个字节来表示,这不同于我们gcc默认都是用的utf8来表示, 那么在落盘的时候,我们是按照Utf8 来写还是按照utf16写呢? 用utf-8,因为占用的字符更少, 在utf-8中,一个字母或者数字仅占1 byte, 一个汉字占3 byte, 但是在utf16, 一个字母活数字就需要占2 byte, 一个汉字也是2 byte, 这么看好像用utf16更能节约空间,但别忘了主流的开源软件都是为西方人服务的,他们大多数的预料还是以英文为主,所以采用的仍然是Utf8。在这点上,如果我们需要开发一个中文通用检索引擎,可以考虑用GBK 来进行编码(怀疑百度大量采用gbk编码就是考虑到成本更低,反正不索引韩文日文阿拉伯文网页) | | 中文 | 英文 | | ----- | ------------------ | ----- | | utf8 | 3字节(少数4字节) | 1字节 | | utf16 | 2字节(少数4字节) | 2字节 | | gbk | 2字节 | 1字节 |

既然lucene采用的是utf8但java默认编码又是utf16,那就自然不能直接将输入的string里的的char byte直接写进去,要先进行utf16向utf8的转变。

好,现在有个问题出现了,你初始化这个新字符数组的时候,需要给它多长?

因为utf8对于每个Utf16字符来说,可能分配1个字节,可能分配2个字节,可能分配3个字节,如果分配少了,那么后期扩容的开销是很大的(原理是开辟一个更大的数组然后把原来的数组直接拷贝过去);如果分配的太多了,那挺浪费的;如果你先准确计算一遍这么个utf16的字符串转utf8需要多少个字节来编,那挺耗CPU的。

lucene给出的策略是, 我先通通给够,按最大的三个字节来算,如果这个数字比较小(不超过65536,也就是64k),那给多了我就认赔,无所谓,毕竟64k也不大,并且这64k我是放在一个额外的数组里面(叫scratchBytes,如果是我取名,可能叫tempBytes吧), 这样下次有其他字节要编码,就都先编码到这个scratchBytes里面,然后再写入到目的数组里面,从而达到复用的效果; 但如果这个数字太大了超过65536, 那我就一定要先算出你究竟需要多少字节,然后再按需扩容,最后再写进去,这里面本质上是进行了两次类似的运算,第一次运算是为了算出精确的转码需要的位数值, 第二次运算是为了将转码后的值写入。


写入utf8编码的字节前,还需要先用vint写入一下字节的数量,否则读取的时候不知道边界在哪儿。

源码

public void writeString(String string) throws IOException {
    // 计算最大需要的长度,其实就是string.length()*3
    int maxLen = UnicodeUtil.maxUTF8Length(string.length());
    if (maxLen <= MIN_UTF8_SIZE_TO_ENABLE_DOUBLE_PASS_ENCODING)  {
      // 这个字符串足够小,因此我们为了避免两次运算而不需要内存
      // string is small enough that we don't need to save memory by falling back to double-pass approach
      // this is just an optimized writeString() that re-uses scratchBytes.
      // 建一个scratchBytes,避免每次都新开内存。
      if (scratchBytes == null) {
        scratchBytes = new byte[ArrayUtil.oversize(maxLen, Character.BYTES)];
      } else {
        scratchBytes = ArrayUtil.grow(scratchBytes, maxLen);
      }
      int len = UnicodeUtil.UTF16toUTF8(string, 0, string.length(), scratchBytes);
      writeVInt(len);
      writeBytes(scratchBytes, len);
    } else  {
      // use a double pass approach to avoid allocating a large intermediate buffer for string encoding
      int numBytes = UnicodeUtil.calcUTF16toUTF8Length(string, 0, string.length());
      writeVInt(numBytes);
      bytes = ArrayUtil.grow(bytes, length + numBytes);
      // 注意一下,这边这个length是一个私有成员变量,是指的当前bytes数组已使用的所有数量, bytes.length是开辟的bytes数组的长度,其中有部分还没使用。
      length = UnicodeUtil.UTF16toUTF8(string, 0, string.length(), bytes, length);
    }
  }

bytes类型

如果你想写入一个二进制类型,那就用这个,直接写入bytes二进制是没有任何多余的压缩操作的,直接将内容追加到数组后面即可,当然,如果需要扩容的话,需要先对bytes数组扩容,关于数组扩容后面会有专门的章节来讲解, 和C++ vector不同的是, C++ vector总是以两倍的速度来增长, 而lucene采用的是每次增长1/8这么一个保守的策略,希望以更多CPU运算来避免不必要内存浪费(就即便这样,lucene仍然是内存杀手)。

@Override
  public void writeBytes(byte[] b, int off, int len) {
    final int newLength = length + len;
    if (newLength > bytes.length) {
      bytes = ArrayUtil.grow(bytes, newLength);
    }
    System.arraycopy(b, off, bytes, length, len);
    length = newLength;
  }

storedField元信息

我们知道,最后落盘是一串bytes数组,那我以后拿到一个bytes数组,怎么还原回来呢?这就需要一些meta data来告诉我们,哪个部分是field1, 哪个部分是field2, 每个field的类型是什么。

实际上每次在写入一个storedField具体信息之前, 都要写8字节的元信息,该元信息记录field序号、 以及field类型,其中field类型只占3bit, field序号竟然需要占61bits, 代码如下:

// bits是field类型, info.number是序号
final long infoAndBits = (((long) info.number) << TYPE_BITS) | bits;
bufferedDocs.writeVLong(infoAndBits);

其中field类型如下定义:

| field类型 | 编码 | | --------- | ---- | | int | 0x02 | | long | 0x04 | | float | 0x03 | | double | 0x05 | | bytes | 0x01 | | string | 0x00 |

info number 的意思是这是第几个field,比如你一共就3个field, title, isbn, visit, 那它们的info number就是0,1,2。由于是用vlong写进去的,所以正常还是只用一个字节来表示这个meta信息。

所以最后一个document的storedField的格式是类似这样的:



一个metainfo跟着一个value , 紧接着下一个metainfo跟着另一个value..

整体落盘的压缩格式

什么时候落盘?

落盘也就是我们说的flush, 对于StoredField有两种落盘的时机: 1. 在处理完一个文档执行finishDocument时,会检查文档数量是否超过128篇或者内存中的bufferedDocs长度超过16384,也就是16k的时候,会发生落盘。

private boolean triggerFlush() {
    return bufferedDocs.getPosition() >= chunkSize || // chunks of at least chunkSize bytes
        numBufferedDocs >= maxDocsPerChunk;
  }
  1. 第二个时机是在我们关闭最后关闭indexWriter的时候,就必须要commit了,这时候再不落盘,那存了个寂寞。

落盘格式

Lucene7版本对于storedField来说,总共需要写入两种文件, 一个是.fdt 文件,包含落盘的数据文件,还有一个是.fdx文件(field index ),是对.fdt(这个我猜是field data) 的一个索引文件。 以后介绍倒排存储和列式存储docValue的时候,也会有类似的落盘格式出现。在介绍.fdt 和.fdx之前,先介绍几个概念: chunk 是指一堆文档的集合, 每128个doc会形成一个chunk, 或者存储的实体数据超过16k也会形成一个chunk。

block 又是1024个chunk所组成一个集合。

slice 也是指一批文档的集合

很多第一次读源码的同学会疑惑,为什么要分块存储?因为采用了一些压缩算法,这些算法会在小规模文档集上获得很好的压缩比,继续往下读就知道了。

.fdt 数据文件

先上实际落盘的存储结构图:


记住,这种文件是每个segment生成一个, 不是所有的数据里面只有一个,segment可以理解成一个小型索引。

1.首先是写入HEAD部分,HEAD部分由HEADER, SegmentID, suffix, 组成。其中HEADER由一个写死的MagicNo, 一个Lucene50StoredFieldFastData的字符串(这是默认的配置,其实可以选择配置成最大压缩比的, 但在采取最大压缩比的情况下,各种参数也会变化, 这里不做过多介绍),以及一个代表version的字段组成;SegmentID本是一个16个byte的随机数, suffix可以不用管,通常都是空(设计这个的目的可能是为了便于后续版本的拓展)。

这部分代码在CompressingStoredFieldWriter初始化的代码里面:

public static void writeIndexHeader(DataOutput out, String codec, int version, byte[] id, String suffix) throws IOException {
    if (id.length != StringHelper.ID_LENGTH) {
      throw new IllegalArgumentException("Invalid id: " + StringHelper.idToString(id));
    }
    writeHeader(out, codec, version);
    out.writeBytes(id, 0, id.length);
    BytesRef suffixBytes = new BytesRef(suffix);
    if (suffixBytes.length != suffix.length() || suffixBytes.length >= 256) {
      throw new IllegalArgumentException("suffix must be simple ASCII, less than 256 characters in length [got " + suffix + "]");
    }
    out.writeByte((byte) suffixBytes.length);
    out.writeBytes(suffixBytes.bytes, suffixBytes.offset, suffixBytes.length);
  }
  1. 写入Chunk的元信息, chunkSize是指一个chunk装多少字节的数据,是一个固定值, 16384, 如果配置的format是采用最大压缩比的Lucene50StoredFieldsHigh, 那这个数值是61440。 PackIntVersion 是指PackInt版本, 目前是2。这两个部分按照道理也是属于Header的一部分,因为它们并不会怎么改变。
  2. 写入Chunk 信息, 一个chunk里面有128个doc或者当数据量达到指定的16384时, 也会形成一个chunk,对于单个chunk而言, 首先要写入元信息:

DocBase是指的当前chunk的最小docNo, 第一个块chunk内这个数值就是0,第二块chunk这个数值是128(在满足128doc触发flush的逻辑下);

NumBufferedDocs就是这个block有多少个doc, 把这个数向左移一位,多出来的那位去标识是否slice, slice的判定如下:

final boolean sliced = bufferedDocs.getPosition() >= 2 * chunkSize;

如果当前的chunk的数据超过了两倍的chunkSize, 那么sliced就是true,哎?这里有个疑问,刚才我们说触发flush的阈值不是只要chunk数据超过了chunkSize就会触发flush,超出部分下一次就会形成一个新chunk啊,那为什么还会出现在一个chunk内文档数据超过两倍阈值的情况? 试想一下,如果我们单篇文档特别特别长, 比如直接把红楼梦一整本书当作一个doc建进去了,那此时bufferedDocs就会很大, 这时候不仅要触发flush,还需要触发slice。

DocFieldCount, 是个int数组,指的是当前chunk内,每个文档的field的个数, DocLength 也是个int数组,是指当前chunk内,每个Doc数据占用的长度。

既然是存储int数组, Lucene有什么好的压缩方法呢?

有的, 如果这个数组内所有的数值都是一样的, 那首先写入标志位0,再写入这个数组的第一个数值即可。(写DocFieldCount的时候经常命中这种情况, 因为schema是固定的,每个doc的fieldCount不会变化, 这一点也告诉我们别乱动es的文档结构, 字段数量不一样会带来存储成本的额外开销);

如果这个数组内数值不一样 ,这会麻烦一点,会用到PackedInt压缩,举个特别简单的例子, 比如说,存储数组[4, 2, 8, 10], 如果正常存, 需要4 * 4 = 16 bytes, 但其实里面的有效位很少, 每个数字其实只需要就可以表示4bits就可以表示, 4表示为0100, 2表示为0010, 8 可以表示为1000, 10表示为1010,总共只需要16个bit, 2byte就可以。所以在存储这种int数组的时候, 第一步需要计算它最大的数值需要多少个bits, 然后将每个数值用计算出的bitsRequired表示,最后拼装(pack)起来即可。说到这里,把文档分块存储的好处之一就可以体会到了,如果我们有10w个文档,大多数文档的storedField占用长度都在400多字节左右,只有一个文档的占用长度达到1000000字节, 这就会直接导致在存储文档长度的时候,被迫采用最大数值占用的bitsRequired造成很大的浪费,分块存储可以一定程度上减轻这个影响。

相关源码:

private static void saveInts(int[] values, int length, DataOutput out) throws IOException {
    assert length > 0;
    if (length == 1) {
      out.writeVInt(values[0]);
    } else {
      boolean allEqual = true;
      for (int i = 1; i < length; ++i) {
        if (values[i] != values[0]) {
          allEqual = false;
          break;
        }
      }
      if (allEqual) {
        out.writeVInt(0);
        out.writeVInt(values[0]);
      } else {
        long max = 0;
    // 计算最大值所需要的bits
        for (int i = 0; i < length; ++i) {
          max |= values[i];
        }
        final int bitsRequired = PackedInts.bitsRequired(max);
        out.writeVInt(bitsRequired);
        final PackedInts.Writer w = PackedInts.getWriterNoHeader(out, PackedInts.Format.PACKED, length, bitsRequired, 1);
        for (int i = 0; i < length; ++i) {
          w.add(values[i]);
        }
        w.finish();
      }
    }
  }

然后是doc data信息, 这部分之前讲过的, 就是一个fieldNoAndType紧贴着一个value如此这么排列下去。注意,最后这些docData会用LZ4压缩算法(todo: 以后有空应该会专门讲讲这个算法的原理)最后再压一下,也就是做了双层压缩,本身fieldValue就已经是被编码压缩过了,这里会再次做一次压缩。 另外,如果之前slice=true的话,会以16384个byte为一个slice单位分别进行压缩。

这部分的总体代码逻辑如下:

private void flush() throws IOException {
    indexWriter.writeIndex(numBufferedDocs, fieldsStream.getFilePointer());

    // transform end offsets into lengths
    final int[] lengths = endOffsets;
    for (int i = numBufferedDocs - 1; i > 0; --i) {
      lengths[i] = endOffsets[i] - endOffsets[i - 1];
      assert lengths[i] >= 0;
    }
    final boolean sliced = bufferedDocs.getPosition() >= 2 * chunkSize;
    writeHeader(docBase, numBufferedDocs, numStoredFields, lengths, sliced);

    // compress stored fields to fieldsStream
    if (sliced) {
      // big chunk, slice it
      for (int compressed = 0; compressed < bufferedDocs.getPosition(); compressed += chunkSize) {
        compressor.compress(bufferedDocs.getBytes(), compressed, Math.min(chunkSize, bufferedDocs.getPosition() - compressed), fieldsStream);
      }
    } else {
      compressor.compress(bufferedDocs.getBytes(), 0, bufferedDocs.getPosition(), fieldsStream);
    }

    // reset
    docBase += numBufferedDocs;
    numBufferedDocs = 0;
    bufferedDocs.reset();
    numChunks++;
  }

4.最后写尾部信息,NumChunks是指有多少个chunks,NumDirtyChunks是指有多少没有完成压缩的chunk,这个数字在后面的segment merge里面会有用。 接着是footer,footer里首先是一个写死的magicNumber, 然后是0, 最后是写入一个checkSum的信息,其实就是对写入的所有信息做一个签名校验,这个是用来检验文件是否有被篡改或者破损的。

.fdx 索引文件

同样先存储结构图:



  1. 和.fdt一样,都是先写Header部分, 这部分不多细说,主要看下面chunkIndex部分
  2. 如果chunk的数量达到了1024个, 就要开始进行生成一个block进行写入,写入的方法是CompressingStoredFieldsIndexWriter里面的writeBlocks. 一个block本质上记载的是1024个chunk的元信息。blcok中首先会写入BlockChunks也就是该block下chunk的数量。然后写入两个很重要的数组, DocBasesDeltasStartPointersDeltas

DocBaseDelta是记录每个chunk下的Docs的数量, StartPointersDelta是记录当前block下bufferDocs的指针位置和上一个block的bufferDocs指针位置的差值,这个bufferDocs之前介绍过,就是存储真正文档值得一个数组。

在编码这两个数组的时候采用了一个差值算法:

比如说,我们有docBaseDelta数组:[128, 76, 75, 102, 100, 120]

一、首先计算平均值,四舍五入计算出来是100(为了好算取个整),avg=100 二、 计算一个delta数组,这个delta数组的规则如下(为了说明白意思,用了python代码):

base = 0
delta_array = []
for i in range(len(array)):
    delta = base - avg * i
    delta_array.append(delta)
    base += array[i]

算下来delta数组如下[0, 28, 4, -21, -19, -19]

三、对于这个delta数组,采用之前的packedInt来进行压缩, 也就是先找到最多需要的bits数量bitsRequired,然后把这些数用bitsRequired进行编码,紧密连接后形成新的数组。注意这里面的负数要先用之前讲过的zigzagEncode算法处理一下才可以,否则负数前置位全是1。

请大家思考一个问题: 首先,为什么这里计算delta数组不能采用[(array[i] - avg) for i in range(len(array))] 这种算法呢? 其次,这种编码的好处是显而易见的,如果数组的方差较小,delta数值也就比较小,可以节约更多的空间,但是为什么之前.fdt里对 DocFieldCount以及DocLength进行编码的时候,没有采用这种办法呢,而是直接packedInt编码了呢?

欢迎大家和我讨论这两个问题的答案。

  1. 最后和.fdt一样,写入footer, 先写一个0, 再写maxPointer, 接着写一个magicNum, 再写一个0, 最后对整个文件写个checksum,保证文件非损坏。

总结

  1. 在写入storedField时,会先把数据存储到bufferedDocs数组里,每种数据类型都有各自相应的为了节约内存而涉及的编码格式, vint, zigzagEncode这些都是后续会经常见到的编码方式
  2. 在落盘时,分为两种落盘文件,.fdt记录数据文件,.fdx是.fdt的索引, 几个概念, doc是field的集合, chunk是doc的集合, block是chunk的集合。 doc到chunk的阈值有两个,一个是达到128个doc数量,第二个是bufferedDocs达到16384长度;chunk到block的阈值是1024。
  3. 对于无序int数组的编码方法,先计算delta,再用packedInt编码。
编辑于 2021-10-31 15:51