注:本文基于Lucene 8.2.0 版本。

本文讨论Lucene底层索引数据存储。对于绝大数多人来说了解Lucene的上层概念足矣,无需关注底层的存储格式。所以本文虽然是讨论底层数据存储的,但也不会深入到具体的数据结构、压缩算法等。如果你有兴趣,可以查看对应版本的Lucene Java doc(8.2.0版本的链接已经附在文末)。另外,如果你对index、document、term、segment、term vector、norm等上层概念还不清楚,建议先阅读该系列文章的前几篇。

索引文件格式

不论是Solr还是ES,底层index的存储都是完全使用Lucene原生的方式,没有做改变,所以本文会以ES为例来介绍。需要注意的是Lucene的index在ES中称为shard,本文中提到的index都指的是Lucene的index,即ES中的shard。先来看一个某个index的数据目录:

index-file

可以看到一个索引包含了很多文件,似乎很复杂。但仔细观察之后会发现乱中似乎又有些规律:很多文件前缀一样,只是后缀不同,比如有很多_3c开头的文件。回想一下之前文章的介绍,index由若干个segment组成,而一个index目录下前缀相同表示这些文件都属于同一个segment

那各种各样的后缀又代表什么含义呢?Lucene存储segment时有两种方式:

  • multifile格式。该模式下会产生很多文件,不同的文件存储不同的信息,其弊端是读取index时需要打开很多文件,可能造成文件描述符超出系统限制。
  • compound格式。一般简写为CFS(Compound File System),该模式下会将很多小文件合并成一个大文件,以减少文件描述符的使用。

我们先来介绍multifile格式下的各个文件:

  • write.lock:每个index目录都会有一个该文件,用于防止多个IndexWriter同时写一个文件。
  • segments_N:该文件记录index所有segment的相关信息,比如该索引包含了哪些segment。IndexWriter每次commit都会生成一个(N的值会递增),新文件生成后旧文件就会删除。所以也说该文件用于保存commit point信息。

上面这两个文件是针对当前index的,所以每个index目录下都只会有1个(segments_N可能因为旧的没有及时删除临时存在两个)。下面介绍的文件都是针对segment的,每个segment就会有1个。

  • .siSegment Info的缩写,用于记录segment的一些元数据信息。
  • .fnmFields,用于记录fields设置类信息,比如字段的index option信息,是否存储了norm信息、DocValue等。
  • .fdtField Data,存储字段信息。当通过StoredField或者Field.Store.YES指定存储原始field数据时,这些数据就会存储在该文件中。
  • .fdxField Index.fdt文件的索引/指针。通过该文件可以快速从.fdt文件中读取field数据。
  • .docFrequencies,存储了一个documents列表,以及它们的term frequency信息。
  • .posPositions,和.doc类似,但保存的是position信息。
  • .pay:Payloads,和.doc类似,但保存的是payloads和offset信息。
  • .timTerm Dictionary,存储所有文档analyze出来的term信息。同时还包含term对应的document number以及若干指向.doc, .pos, .pay的指针,从而可以快速获取term的term vector信息。。
  • .tipTerm Index,该文件保存了Term Dictionary的索引信息,使得可以对Term Dictionary进行随机访问。
  • .nvd, .nvmNorms,这两个都是用来存储Norms信息的,前者用于存储norms的数据,后者用于存储norms的元数据。
  • .dvd, .dvmPer-Document Values,这两个都是用来存储DocValues信息的,前者用于数据,后者用于存储元数据。
  • .tvdTerm Vector Data,用于存储term vector数据。
  • .tvxTerm Vector Index,用于存储Term Vector Data的索引数据。
  • .livLive Documents,用于记录segment中哪些documents没有被删除。一般不存在该文件,表示segment内的所有document都是live的。如果有documents被删除,就会产生该文件。以前是使用一个.del后缀的文件来记录被删除的documents,现在改为使用该文件了。
  • .dim,.diiPoint values,这两个文件用于记录indexing的Point信息,前者保存数据,后者保存索引/指针,用于快速访问前者。

上面介绍了很多文件类型,实际中不一定都有,如果indexing阶段不保存字段的term vector信息,那存储term vector的相关文件可能就不存在。如果一个index的segment非常多,那将会有非常非常多的文件,检索时,这些文件都是要打开的,很可能会造成文件描述符不够用,所以Lucene引入了前面介绍的CFS格式,它把上述每个segment的众多文件做了一个合并压缩(.liv.si没有被合并,依旧单独写文件),最终形成了两个新文件:.cfs.cfe,前者用于保存数据,后者保存了前者的一个Entry Table,用于快速访问。所以,如果使用CFS的话,最终对于每个segment,最多就只存在.cfs, .cfe, .si, .liv4个文件了。Lucene从1.4版本开始,默认使用CFS来保存segment数据,但开发者仍然可以选择使用multifile格式。一般来说,对于小的segment使用CFS,对于大的segment,使用multifile格式。比如Lucene的org.apache.lucene.index.MergePolicy构造函数中就提供merge时在哪些条件下使用CFS:

  /**
   * Default ratio for compound file system usage. Set to <tt>1.0</tt>, always use 
   * compound file system.
   */
  protected static final double DEFAULT_NO_CFS_RATIO = 1.0;

  /**
   * Default max segment size in order to use compound file system. Set to {@link Long#MAX_VALUE}.
   */
  protected static final long DEFAULT_MAX_CFS_SEGMENT_SIZE = Long.MAX_VALUE;

  /** If the size of the merge segment exceeds this ratio of
   *  the total index size then it will remain in
   *  non-compound format */
  protected double noCFSRatio = DEFAULT_NO_CFS_RATIO;
  
  /** If the size of the merged segment exceeds
   *  this value then it will not use compound file format. */
  protected long maxCFSSegmentSize = DEFAULT_MAX_CFS_SEGMENT_SIZE;

  /**
   * Creates a new merge policy instance.
   */
  public MergePolicy() {
    this(DEFAULT_NO_CFS_RATIO, DEFAULT_MAX_CFS_SEGMENT_SIZE);
  }
  
  /**
   * Creates a new merge policy instance with default settings for noCFSRatio
   * and maxCFSSegmentSize. This ctor should be used by subclasses using different
   * defaults than the {@link MergePolicy}
   */
  protected MergePolicy(double defaultNoCFSRatio, long defaultMaxCFSSegmentSize) {
    this.noCFSRatio = defaultNoCFSRatio;
    this.maxCFSSegmentSize = defaultMaxCFSSegmentSize;
  }

接下来让我们使用ES做一些操作来具体感受一下。

一些例子

首先在ES中创建一个索引:

PUT nyc-test
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "refresh_interval": -1
  }
}

这里设置1个shard,0个副本,并且将refresh_interval设置为-1,表示不自动刷新。创建完之后就可以在es的数据目录找到该索引,es的后台索引的目录结构为:<数据目录>/nodes/0/indices/<索引UUID>/<shard>/index,这里的shard就是Lucene的index。我们看下刚创建的index的目录:

-> % ll
总用量 4.0K
-rw-rw-r-- 1 allan allan 230 10月 11 21:45 segments_2
-rw-rw-r-- 1 allan allan   0 10月 11 21:45 write.lock

可以看到,现在还没有写入任何数据,所以只有index级别的segments_Nwrite.lock文件,没有segment级别的文件。写入1条数据并查看索引目录的变化:

PUT nyc-test/doc/1
{
  "name": "Jack"
}

# 查看索引目录
-> % ll
总用量 4.0K
-rw-rw-r-- 1 allan allan   0 10月 11 22:20 _0.fdt
-rw-rw-r-- 1 allan allan   0 10月 11 22:20 _0.fdx
-rw-rw-r-- 1 allan allan 230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan   0 10月 11 22:19 write.lock

可以看到出现了1个segment的数据,因为ES把数据缓存在内存里面,所以文件大小为0。然后再写入1条数据,并查看目录变化:

PUT nyc-test/doc/2
{
  "name": "Allan"
}

# 查看目录
-> % ll
总用量 4.0K
-rw-rw-r-- 1 allan allan   0 10月 11 22:20 _0.fdt
-rw-rw-r-- 1 allan allan   0 10月 11 22:20 _0.fdx
-rw-rw-r-- 1 allan allan 230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan   0 10月 11 22:19 write.lock

因为ES缓存机制的原因,目录没有变化。显式的refresh一下,让内存中的数据落地:

POST nyc-test/_refresh

-> % ll
总用量 16K
-rw-rw-r-- 1 allan allan  405 10月 11 22:22 _0.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:22 _0.cfs
-rw-rw-r-- 1 allan allan  393 10月 11 22:22 _0.si
-rw-rw-r-- 1 allan allan  230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan    0 10月 11 22:19 write.lock

ES的refresh操作会将内存中的数据写入到一个新的segment中,所以refresh之后写入的两条数据形成了一个segment,并且使用CFS格式存储了。然后再插入1条数据,接着update这条数据:

PUT nyc-test/doc/3
{
  "name": "Patric"
}

# 查看
-> % ll
总用量 16K
-rw-rw-r-- 1 allan allan  405 10月 11 22:22 _0.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:22 _0.cfs
-rw-rw-r-- 1 allan allan  393 10月 11 22:22 _0.si
-rw-rw-r-- 1 allan allan    0 10月 11 22:23 _1.fdt
-rw-rw-r-- 1 allan allan    0 10月 11 22:23 _1.fdx
-rw-rw-r-- 1 allan allan  230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan    0 10月 11 22:19 write.lock

# 更新数据
PUT nyc-test/doc/3?refresh=true
{
  "name": "James"
}

# 查看
-> % ll
总用量 32K
-rw-rw-r-- 1 allan allan  405 10月 11 22:22 _0.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:22 _0.cfs
-rw-rw-r-- 1 allan allan  393 10月 11 22:22 _0.si
-rw-rw-r-- 1 allan allan   67 10月 11 22:24 _1_1.liv
-rw-rw-r-- 1 allan allan  405 10月 11 22:24 _1.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:24 _1.cfs
-rw-rw-r-- 1 allan allan  393 10月 11 22:24 _1.si
-rw-rw-r-- 1 allan allan  230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan    0 10月 11 22:19 write.lock

可以看到,再次refresh的时候又形成了一个新的segment,并且因为update,导致删掉了1条document,所以产生了一个.liv文件。但前面的这些流程中,segments_N文件也就是segments_2一直没有变过,这是因为一直没有Lucene概念中的commit操作发生过。ES的flush操作对应的是Lucene的commit,我们触发一次Lucene commit看下变化:

# 触发Lucene commit
POST nyc-test/_flush?wait_if_ongoing

# 查看目录
-> % ll
总用量 32K
-rw-rw-r-- 1 allan allan  405 10月 11 22:22 _0.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:22 _0.cfs
-rw-rw-r-- 1 allan allan  393 10月 11 22:22 _0.si
-rw-rw-r-- 1 allan allan   67 10月 11 22:24 _1_1.liv
-rw-rw-r-- 1 allan allan  405 10月 11 22:24 _1.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:24 _1.cfs
-rw-rw-r-- 1 allan allan  393 10月 11 22:24 _1.si
-rw-rw-r-- 1 allan allan  361 10月 11 22:25 segments_3
-rw-rw-r-- 1 allan allan    0 10月 11 22:19 write.lock

# 查看segment信息
GET _cat/segments/nyc-test?v

index    shard prirep ip        segment generation docs.count docs.deleted  size size.memory committed searchable version compound
nyc-test 0     p      10.8.4.42 _0               0          2            0 3.2kb        1184 true      true       7.4.0   true
nyc-test 0     p      10.8.4.42 _1               1          1            2 3.2kb        1184 true      true       7.4.0   true

触发Lucene commit之后,可以看到segments_2变成了segments_3。然后调用_cat接口查看索引的segment信息也能看到目前有2个segment,而且都已经commit过了,并且compound是true,表示是CFS格式存储的。当然Lucene的segment是可以合并的。我们通过ES的forcemerge接口进行合并,并且将所有segment合并成1个segment,forcemerge的时候会自动调用flush,即会触发Lucene commit:

POST nyc-test/_forcemerge?max_num_segments=1

-> % ll
总用量 60K
-rw-rw-r-- 1 allan allan  69 10月 11 22:27 _2.dii
-rw-rw-r-- 1 allan allan 123 10月 11 22:27 _2.dim
-rw-rw-r-- 1 allan allan 142 10月 11 22:27 _2.fdt
-rw-rw-r-- 1 allan allan  83 10月 11 22:27 _2.fdx
-rw-rw-r-- 1 allan allan 945 10月 11 22:27 _2.fnm
-rw-rw-r-- 1 allan allan 110 10月 11 22:27 _2_Lucene50_0.doc
-rw-rw-r-- 1 allan allan  80 10月 11 22:27 _2_Lucene50_0.pos
-rw-rw-r-- 1 allan allan 287 10月 11 22:27 _2_Lucene50_0.tim
-rw-rw-r-- 1 allan allan 145 10月 11 22:27 _2_Lucene50_0.tip
-rw-rw-r-- 1 allan allan 100 10月 11 22:27 _2_Lucene70_0.dvd
-rw-rw-r-- 1 allan allan 469 10月 11 22:27 _2_Lucene70_0.dvm
-rw-rw-r-- 1 allan allan  59 10月 11 22:27 _2.nvd
-rw-rw-r-- 1 allan allan 100 10月 11 22:27 _2.nvm
-rw-rw-r-- 1 allan allan 572 10月 11 22:27 _2.si
-rw-rw-r-- 1 allan allan 296 10月 11 22:27 segments_4
-rw-rw-r-- 1 allan allan   0 10月 11 22:19 write.lock


GET _cat/segments/nyc-test?v

index    shard prirep ip        segment generation docs.count docs.deleted  size size.memory committed searchable version compound
nyc-test 0     p      10.8.4.42 _2               2          3            0 3.2kb        1224 true      true       7.4.0   false

可以看到,force merge之后只有一个segment了,并且使用了multifile格式存储,而不是compound。当然这并非Lucene的机制,而是ES自己的设计。

最后用图总结一下:

Lucene-Index-Files-Format.png

本文就介绍到这里,对于绝大多数使用者来说,只需要知道Lucene索引后台存储的组织逻辑和层次,以更好的使用Lucene及基于Lucene的产品即可。

References