Lucene源码系列(三十二):索引文件删除策略

1,439 阅读12分钟

在上一篇文章中我们介绍了IndexCommit粒度的索引删除策略,本文要介绍的是文件粒度的索引文件删除策略。因为在索引的生命周期过程中,会产生一些无用的文件,比如:

  • liv文件的更新,同一个segment中如果多次有删除命中,并且有多次的commit,则liv文件会更新,如果索引删除策略是KeepOnlyLastCommitDeletionPolicy,则只会保留最新版本的liv,需要删除旧版本的liv文件。
  • fnm,dvd,dvm文件的更新,同一个segment中如果多次更新字段的docValues,则fnm,dvd,dvm文件都会更新,如果索引删除策略是KeepOnlyLastCommitDeletionPolicy,则只会保留最新版本的fnm,dvd,dvm文件,旧版本的文件都需要被删除。
  • 如果某个段内的数据都被删除了,则这个segment也需要删除,segment相关的文件都需要删除。
  • KeepOnlyLastCommitDeletionPolicy的索引删除策略会删除旧的segments_N
  • merge的时候会把旧segment的所有文件都删除

以上这些所谓的无用的文件的产生逻辑,目前如果你还不清楚没关系,以后都会慢慢介绍到。目前只需要知道的就是这些无用的索引文件都需要删除来减少空间资源的占用。那Lucene中是怎么判断哪些文件可以删除呢?

这个要从索引的管理说起,当通过IndexWriter新建一个索引,或者打开一个旧索引时,IndexWriter会用SegmentInfos来跟踪当前索引的状态,SegmentInfos只会记录各个segment引用的文件名称集合,并为引用的文件增加引用计数,而随着索引生命周期的进行,SegmentInfos也会不断变化,当有文件不被SegmentInfos引用时,就减少文件的引用计数,如果引用计数为0则文件可以删除。

这些文件的删除就是通过IndexFileDeleter来管理的。本文的主要目的就是尽量把IndexFileDeleter的核心逻辑说明白,为什么说尽量?因为有些逻辑让我比较疑惑,甚至觉得是Lucene的作者对某些场景并没有清晰的定位和处理。我会按我的理解进行说明,也欢迎大家一起讨论。

注意:本文源码基于lucene-core-9.1.0

成员变量

// key是文件名,值是文件的引用信息
private Map<String, RefCount> refCounts = new HashMap<>();

// 当前索引目录中的所有commit,如果使用KeepOnlyLastCommitDeletionPolicy的话,只会保留一个
private List<CommitPoint> commits = new ArrayList<>();

// 保存上一次以非commit的方式进行checkpoint的所有文件
private final List<String> lastFiles = new ArrayList<>();

// 索引删除策略选出来的待删除的commit
private List<CommitPoint> commitsToDelete = new ArrayList<>();

// 原始的索引目录
private final Directory directoryOrig;

// 封装过的索引目录,会先判断锁是否有效,然后再执行操作
private final Directory directory;

// 索引删除策略
private final IndexDeletionPolicy policy;

// 标记初次加载索引的时候,是否索引有变化
final boolean startingCommitDeleted;

// 当前索引目录中最新的一次commit,这个变量没用
private SegmentInfos lastSegmentInfos;

// IndexFileDeleter所属的IndexWriter,一个IndexWriter只有一个IndexFileDeleter
private final IndexWriter writer;

内部类

CommitPoint

本文我们看到IndexCommit的第二个实现类:CommitPoint。CommitPoint中我们只关注一个方法:

public void delete() {
  if (!deleted) {
    deleted = true;
    commitsToDelete.add(this);
  }
}

删除commit就是把它加入commitsToDelete,commitsToDelete和IndexFileDeleter的成员变量commitsToDelete是同一个,所以CommitPoint的删除动作就是把自己加入删除列表,等待真正的删除。

RefCount

RefCount是一个记录文件引用计数的工具类,逻辑简单:

private static final class RefCount {
  // 引用计数对应的文件名
  final String fileName;
  // 是否初始化过,初始化过的至少引用次数为1
  boolean initDone;

  RefCount(String fileName) {
    this.fileName = fileName;
  }
  // 引用计数
  int count;

  // 新增引用计数  
  public int IncRef() {
    if (!initDone) {
      initDone = true;
    } else {
      assert count > 0
          : Thread.currentThread().getName()
              + ": RefCount is 0 pre-increment for file \""
              + fileName
              + "\"";
    }
    return ++count;
  }

  // 减少引用计数  
  public int DecRef() {
    return --count;
  }
}

获取文件的引用计数

如果指定的文件在refCounts中没有找到,则创建一个初始化引用计数为0的RefCount。

private RefCount getRefCount(String fileName) {
  RefCount rc;
  if (!refCounts.containsKey(fileName)) {
    rc = new RefCount(fileName);
    refCounts.put(fileName, rc);
  } else {
    rc = refCounts.get(fileName);
  }
  return rc;
}

增加引用计数

// 对segmentInfos相关的所有文件增加引用计数
void incRef(SegmentInfos segmentInfos, boolean isCommit) throws IOException {
  for (final String fileName : segmentInfos.files(isCommit)) {
    incRef(fileName);
  }
}

// 对files集合的所有文件增加引用计数
void incRef(Collection<String> files) {
  for (final String file : files) {
    incRef(file);
  }
}

// 对指定的文件增加引用计数
void incRef(String fileName) {
  RefCount rc = getRefCount(fileName);
  rc.IncRef();
}

减少引用计数

// 对files集合的所有文件减少引用计数
void decRef(Collection<String> files) throws IOException {
  // 引用计数为0的文件都先放在  toDelete 中
  Set<String> toDelete = new HashSet<>();
  Throwable firstThrowable = null;
  for (final String file : files) { // 遍历所有的文件,减少引用计数,如果计数变为0,则加入toDelete
    try {
      if (decRef(file)) {
        toDelete.add(file);
      }
    } catch (Throwable t) {
      firstThrowable = IOUtils.useOrSuppress(firstThrowable, t);
    }
  }

  // 删除所有引用计数为0的文件  
  try {
    deleteFiles(toDelete);
  } catch (Throwable t) {
    firstThrowable = IOUtils.useOrSuppress(firstThrowable, t);
  }

  if (firstThrowable != null) {
    throw IOUtils.rethrowAlways(firstThrowable);
  }
}

// 减少指定文件的引用计数
private boolean decRef(String fileName) {
  RefCount rc = getRefCount(fileName);
  if (rc.DecRef() == 0) { // 引用计数为0,从refCounts中删除
    refCounts.remove(fileName);
    return true;
  } else {
    return false;
  }
}

// 对segmentInfos相关的所有文件减少引用计数
void decRef(SegmentInfos segmentInfos) throws IOException {
  assert locked();
  decRef(segmentInfos.files(false));
}

删除commit

private void deleteCommits() throws IOException {

  int size = commitsToDelete.size();

  if (size > 0) { // 如果有待删除的commit
    Throwable firstThrowable = null;
    for (int i = 0; i < size; i++) { // 遍历所有的commit
      CommitPoint commit = commitsToDelete.get(i);
      try {
        // 减少commit相关文件的引用计数,如果为0会删除文件  
        decRef(commit.files);
      } catch (Throwable t) {
        firstThrowable = IOUtils.useOrSuppress(firstThrowable, t);
      }
    }
    commitsToDelete.clear();

    // 通过向前移位的方式把commits中已经删除的commit去掉
    size = commits.size();
    int readFrom = 0;
    int writeTo = 0;
    while (readFrom < size) {
      CommitPoint commit = commits.get(readFrom);
      if (!commit.deleted) {
        if (writeTo != readFrom) {
          commits.set(writeTo, commits.get(readFrom));
        }
        writeTo++;
      }
      readFrom++;
    }

    while (size > writeTo) {
      commits.remove(size - 1);
      size--;
    }

    if (firstThrowable != null) {
      throw IOUtils.rethrowAlways(firstThrowable);
    }
  }
}

checkpoint

索引目录中的文件如果需要保留,则都必须通过checkpoint来进行增加引用计数。

checkpoint主要做三件事:

  • segmentInfos相关的文件都增加引用次数
  • 如果当前是一次commit的话,则使用索引删除策略选择待删除的commit,并执行删除
  • 如果当前不是一次commit的话,则对上一次非commit的checkpoint的所有文件引用次数减一,并记录当前的文件列表
public void checkpoint(SegmentInfos segmentInfos, boolean isCommit) throws IOException {
  long t0 = System.nanoTime();

  // 对segmentInfos关联的索引文件增加引用计数
  incRef(segmentInfos, isCommit);

  if (isCommit) { // 如果是一次commit
    commits.add(new CommitPoint(commitsToDelete, directoryOrig, segmentInfos));
    // 调用索引删除策略
    policy.onCommit(commits);
    // 删除索引删除策略选出的commit
    deleteCommits();
  } else { // 如果不是一次commit
    try {
      // 对上一次执行checkpoint作用的所有文件的引用计数减一 
      decRef(lastFiles);
    } finally {
      lastFiles.clear();
    }

    // 保存此次checkpoint的作用的所有文件
    lastFiles.addAll(segmentInfos.files(false));
  }
}

构造函数

在介绍构造函数的逻辑之前先重点解释下构造函数中的一个参数:segmentInfos。这个索引信息并不一定代表的是当前索引目录中索引的最新状态,它可能是某个历史快照,因为Lucene支持从某个指定的IndexCommit信息来重新打开索引。但是我觉得Lucene的这个特性设计不是特别明确,一般如果是从某个快照恢复数据,则这个时间点之后的数据应该是不可见的,但是Lucene中的实现并没有保证这一点,所以我不知道这个特性的应用场景。

IndexFileDeleter的构造函数是对IndexWriter刚刚打开的索引目录中的文件进行引用计数初始化逻辑,重点是统计所有文件的引用计数,删除无用文件。这里分为两种情况,一种是新建索引,这时候索引目录为空,不需要进行什么处理。所以构造函数的核心逻辑还是在处理第二种情况:打开旧索引。

因为这个构造函数的逻辑比较长,也难理解,所以我们一起分段来看:

第1步:初始化文件的引用计数

构造函数的一开始是遍历所有的文件,初始化相关文件的引用计数为0,并加入refCounts。后面判断文件是否可以删除,都是从refCounts中寻找引用计数为0的文件。这里有个比较有疑问的地方,我觉得可能是bug。要说明这个问题,需要先了解在lucene的实现中,被管理的索引文件有哪些,从源码看一共有三类:

  • 各个段的索引数据文件,满足正则表达式:_[a-z0-9]+(_.*)?\..*
  • 以"segments"开头的文件,其实就是各个segments_N文件,记录段信息
  • 以"pending_segments"开头的文件,这是段flush的时候会生成,在成功完成flush的时候会生成segments_N。

除了这些文件之外,其他文件都没有被管理,这就是问题所在,比如快照的文件:它是以"snapshots_"开头的。如果新打开的索引并没有使用快照的索引删除策略,那这个文件其实没用,但是永远不会被删除。

接着通过寻找segments_N文件,从segments_N中读取SegmentInfos,对SegmentInfos的所有文件引用计数加1,注意这里只对segments_N关联的文件增加计数,所以有了第一次的checkpoint,这个下面会介绍。一个segments_N对应了一次commit,所以会把segments_N封装成CommitPoint加入commits,等待全部处理完成之后,交给索引删除策略处理。

初始化文件的引用计数的源码如下:

  // 获取segmentInfos的segments_N文件名
  final String currentSegmentsFile = segmentInfos.getSegmentsFileName();

  this.policy = policy;
  this.directoryOrig = directoryOrig;
  this.directory = directory;

  CommitPoint currentCommitPoint = null;

  if (currentSegmentsFile != null) { // 如果存在segments_N文件
    // 索引文件名的匹配器  
    Matcher m = IndexFileNames.CODEC_FILE_PATTERN.matcher("");
    for (String fileName : files) { // 遍历所有的文件
      m.reset(fileName);
      if (!fileName.endsWith("write.lock")
          && (m.matches()
              || fileName.startsWith(IndexFileNames.SEGMENTS)
              || fileName.startsWith(IndexFileNames.PENDING_SEGMENTS))) {

        // 为文件初始化引用计数为0
        getRefCount(fileName);

        // 如果是segments_N文件,需要额外处理一个commit的信息 
        if (fileName.startsWith(IndexFileNames.SEGMENTS)) { 
          SegmentInfos sis = SegmentInfos.readCommit(directoryOrig, fileName);

          final CommitPoint commitPoint = new CommitPoint(commitsToDelete, directoryOrig, sis);
          // 找到了segmentInfos参数对应的commit
          if (sis.getGeneration() == segmentInfos.getGeneration()) {
            currentCommitPoint = commitPoint;
          }
          // 把找到的commit加入commits,等待索引删除策略处理  
          commits.add(commitPoint);
          // 增加 commit 相关的索引文件的计数
          incRef(sis, true);
          // 寻找最新的一个commit,但是并没有用
          if (lastSegmentInfos == null
              || sis.getGeneration() > lastSegmentInfos.getGeneration()) {
            lastSegmentInfos = sis;
          }
        }
      }
    }
  }
  // 处理特殊情况,currentSegmentsFile文件存在,但是没有找到,需要显式在加载一次
  // 一般不会出现这种情况,Lucene的源码注释说可能出现在网络文件服务器的情况
  if (currentCommitPoint == null && currentSegmentsFile != null && initialIndexExists) {
    SegmentInfos sis = null;
    try {
      sis = SegmentInfos.readCommit(directoryOrig, currentSegmentsFile);
    } catch (IOException e) {
      throw new CorruptIndexException(
          "unable to read current segments_N file", currentSegmentsFile, e);
    }
    currentCommitPoint = new CommitPoint(commitsToDelete, directoryOrig, sis);
    commits.add(currentCommitPoint);
    incRef(sis, true);
  }

第2步:第一次checkpoint

    // 在当前版本中,只有NRT的情况isReaderInit为真
    if (isReaderInit) {
      // Incoming SegmentInfos may have NRT changes not yet visible in the latest commit, so we have
      // to protect its files from deletion too:
      checkpoint(segmentInfos, false);
    }

按Lucene的注释说,这个涉及到NRT的机制,NRT就是Lucene的近实时策略,也就是索引的变化对客户端的可见性是近实时的。近实时的一个特点就是会对当前索引进行无条件flush,但是并不进行commit,因此segmentInfos中会有新flush的段信息,但是没有segments_N文件,而这部分新flush的文件就没有第1步:初始化文件的引用计数处理,需要一次checkpoint来进行补充。

为了说明这种情况,我们看一个例子:

public class FirstCheckpointInIndexFileDeleter {
    private static final Random RANDOM = new Random();

    public static void main(String[] args) throws IOException {
        Directory directory = FSDirectory.open(new File("./data").toPath());
        WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        indexWriterConfig.setIndexDeletionPolicy(NoDeletionPolicy.INSTANCE);
        // 必须设置
        indexWriterConfig.setCommitOnClose(false);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);

        indexWriter.addDocument(getDoc(RANDOM.nextInt(10000), RANDOM.nextInt(10000)));
        // 第一次commit
        indexWriter.commit();

        indexWriter.addDocument(getDoc(RANDOM.nextInt(10000), RANDOM.nextInt(10000)));
        // 第二次commit
        indexWriter.commit();

        DirectoryReader reader = DirectoryReader.open(indexWriter);
        // 新增一个文档,但是没有commit
        indexWriter.addDocument(getDoc(RANDOM.nextInt(10000), RANDOM.nextInt(10000)));

        // NRT的方式重新打开一个reader,这时会先flush然后更新segmentInfos信息,但是不会生成segments_N
        reader = DirectoryReader.openIfChanged(reader, indexWriter);
        // 通过最新的reader获取一个commit,注意这个commit中包含了一个段信息是不存在segments_N文件的
        IndexCommit indexCommit = reader.getIndexCommit();
        IndexWriterConfig newConf = new IndexWriterConfig(analyzer);
        // 以indexCommit来开启一个IndexWriter
        newConf.setIndexCommit(indexCommit);
        newConf.setIndexDeletionPolicy(NoDeletionPolicy.INSTANCE);
        // 关闭当前IndexWriter,否则新的IndexWriter无法作用同一个索引目录
        indexWriter.close();
        // 打开一个新的IndexWriter,可以看到存在没有segments_N的索引文件
        IndexWriter newIndexWriter = new IndexWriter(directory, newConf);
    }

    private static Document getDoc(int... point) {
        Document doc = new Document();
        IntPoint intPoint = new IntPoint("point", point);
        doc.add(intPoint);
        return doc;
    }
}

15行进行了第一次commit,结果如下:

checkpoint1_commit1.png

19行进行了第二次commit,结果如下:

checkpoint1_commit2.png

21行我们基于当前的索引信息获取一个DirectoryReader,它可以获取当前的索引信息。23行我们新增了一个文档,但是没有进行flush或者commit。26行以NRT的方式重新打开reader,这个操作会进行flush,把23行新增的文档生成一个segment,结果如下:

checkpoint1_reopen.png

28行我们基于当前的reader创建一个IndexCommit,这时候以当前的索引状态去创建IndexWriter时,如果没有进行checkpoint的话,则_2.cfe,_2.cfs,_2.si三个文件引用计数就会是0,会在下一步流程中被删除。因为第1步的操作只会为存在segments_N的段文件进行计数。

第3步:删除所有引用计数为0的文件

Set<String> toDelete = new HashSet<>();
for (Map.Entry<String, RefCount> entry : refCounts.entrySet()) { // 遍历refCounts集合
  RefCount rc = entry.getValue();
  final String fileName = entry.getKey();
  if (0 == rc.count) { // 如果引用计数为0
    if (fileName.startsWith(IndexFileNames.SEGMENTS)) {
      throw new IllegalStateException(
          "file \"" + fileName + "\" has refCount=0, which should never happen on init");
    }
    // 加入  toDelete
    toDelete.add(fileName);
  }
}
// 删除 toDelete中的文件
deleteFiles(toDelete);

第4步:执行索引删除策略,获取待删除的commit列表

这是IndexDeletionPolicy#onInit方法唯一被调用的地方。

policy.onInit(commits);

第5步:第二次checkpoint

这次的checkpoint官方的注释是:

Always protect the incoming segmentInfos since sometime it may not be the most recent commit

从注释的意思上说是,这是为了处理构造函数中的参数segmentInfos并不是当前索引目录中最新的状态,也就是不是一个最新的commit对应的segmentInfos。

分两种情况:

情况一:索引删除策略是NoDeletionPolicy,这种情况,索引都不会被删除,有无第二次的checkpoint都无所谓。

情况二:索引删除策略是KeepOnlyLastCommitDeletionPolicy,在目前版本的实现中,不管以哪个IndexCommit打开索引,只会保留索引目录中最新的一个commit,这种情况segmentInfos也会被删除,也没必要checkpoint。

不仅如此,第二次的checkpoint不仅没用,也会造成问题,看一个例子:

public class SecondCheckpointInIndexFileDeleter {
    private static final String FIELD_NAME = "test";
    private static final FieldType FIELD_TYPE = new FieldType();
    static {
        FIELD_TYPE.setStoreTermVectorPositions(true);
        FIELD_TYPE.setStoreTermVectorOffsets(true);
        FIELD_TYPE.setStoreTermVectors(true);
        FIELD_TYPE.setIndexOptions(IndexOptions.DOCS);
        FIELD_TYPE.setStored(true);
    }

    public static void main(String[] args) throws IOException {
        Directory directory = FSDirectory.open(new File("./data").toPath());
        WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        PersistentSnapshotDeletionPolicy deletionPolicy = new PersistentSnapshotDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy(), directory);
        indexWriterConfig.setIndexDeletionPolicy(deletionPolicy);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);

        // doc0
        indexWriter.addDocument(getDoc("a b c d"));
        // 第一次commit
        indexWriter.commit();

        // doc1
        indexWriter.addDocument(getDoc("a c d"));
        // 第二次commit
        indexWriter.commit();
        // segments_2被快照引用
        IndexCommit indexCommit = deletionPolicy.snapshot();

        // doc2
        indexWriter.addDocument(getDoc("d o m"));
        // 会删除doc0和doc1
        indexWriter.deleteDocuments(new Term(FIELD_NAME, "a"));
        // 第三次commit,只有segment_3有效,因为segments_1和segments_2是空段,会被删除
        indexWriter.commit();
        indexWriter.close();

        IndexWriterConfig newConfig = new IndexWriterConfig(analyzer);
        // 以segments_2打开索引
        newConfig.setIndexCommit(indexCommit);
        IndexWriter newIndexWriter = new IndexWriter(directory, newConfig);
    }

    private static Document getDoc(String value) {
        Document doc = new Document();
        doc.add(new Field(FIELD_NAME, value, FIELD_TYPE));
        return doc;
    }
}

21行第一次commit,segment1中只有一个doc0:

checkpoint1_commit1.png

28行第二次commit,segment2中有doc0和doc1。因为使用的KeepOnlyLastCommitDeletionPolicy索引删除策略,所以segments_1被删除了,结果如下:

checkpoint2_commit2.png

37行第三次commit的时候,35行doc0和doc1两个文档被删除,理论上segments_2因为没有数据了也应该被删除,但是因为在30行我们对segments_2创建了快照,借助快照的性质,这两个段的文件被保存下来,结果如下:

checkpoint2_commit3.png 以快照的commit重新打开索引,使用的索引删除策略还是KeepOnlyLastCommitDeletionPolicy,我们直接看结果:

checkpoint2_reopen.png

需要做2点说明:

  1. 其中snapshots_0文件永远不会被删除,原因就是第1步中解释过的
  2. 另外segments_2文件被删除了,但是它关联的6个文件也没有被删除,这就是第二次checkpoint造成。因为经过第1步,这6个文件的引用计数是1,经过了第二次的checkpoint之后,引用计数变成2,经过索引删除策略的处理,segments_2需要删除,它相关的文件的引用计数都减1,这6个文件引用计数变成了1,并且因为这个segment_2已经被删除了,除非引用重新打开索引,否则这些文件都不会被删除。

上面说明的第二次的checkpoint的问题,我觉得是Lucene没有把IndexWriter设置IndexCommit的场景设计清楚,一般来说,如果指定从某个时间点的数据恢复,则这个时间点之后的数据应该是不可见,所以要把这个IndexCommit之后的数据清楚才合理。但是按现有的实现逻辑,好像这个用来打开索引的IndexCommit并没有特别的优先级。

第6步:删除commit

deleteCommits();

遍历commitsToDelete中的所有IndexCommit,减少相关文件的计数,如果为0则删除。

总结

关于文中描述的一些疑惑点,希望有朋友参与讨论下,也是一个参与源码提交的机会。