从源码看Lucene的两阶段提交

政采云技术团队.png

土根1.png

什么是二阶段提交

二阶段提交协议(Two-phase Commit Protocol,简称 2PC )是分布式事务的核心协议。在此协议中,一个事务管理器(Transaction Manager,简称 TM)协调 1 个或多个资源管理器(Resource Manager,简称 RM)的活动,所有资源管理器向事务管理器汇报自身活动状态,由事务管理器根据各资源管理器汇报的状态(完成准备或准备失败)来决定各资源管理器是“提交”事务还是进行“回滚”操作。

二阶段提交的具体流程如下:

  1. 应用程序向事务管理器提交请求,发起分布式事务;

  2. 在第一阶段,事务管理器联络所有资源管理器,通知它们准备提交事务;

  3. 各资源管理器返回完成准备(或准备失败)的消息给事务管理器(响应超时算作失败);

  4. 在第二阶段:

    • 如果所有资源管理器均完成准备,则事务管理器会通知所有资源管理器执行事务提交;
    • 如果任一资源管理器准备失败,则事务管理器会通知所有资源管理器进行事务回滚。

Lucene的二阶段提交

Lucene 定义了一个二阶段提交接口 TwoPhaseCommit 。Lucene 的写入的文档提交基于此接口实现。该接口提供了三个方法。

image-20221106201128916

一阶段提交 prepareCommit

完成二阶段提交第一阶段的工作,它会尽可能多的完成更新工作,但又避免完成真实的提交。同时失败时可以轻松地利用 rollback 废弃掉当前阶段完成的所有工作。 实际上 Lucene 在一阶段提交时就已经将 segmnt 持久化到了磁盘,但通过修改文件名的方式巧妙的另 segment 在此时不生效。

一阶段提交流程图

提交前的校验

通过上锁避免多个线程同时 prepareCommit,同一个 segmentInfo 只需提交一次。这里只能避免不能多线程 prepareCommit 。为了实现整个提交流程都是串行,Lucene 通过校验 segmentInfo 是否为空来判断上次 prepareCommit 的 segmentInfo 是否已被最终提交,待提交的 segmentInfo 快照在提交成功或失败回滚后会被清空,因此不为空时意味着上一个线程仍在执行最后的提交。

synchronized(commitLock) {
      ensureOpen(false);
      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "prepareCommit: flush");
        infoStream.message("IW", "  index before flush " + segString());
      }
​
      if (tragedy != null) {
        throw new IllegalStateException("this writer hit an unrecoverable error; cannot commit", tragedy);
      }
​
      if (pendingCommit != null) {
        throw new IllegalStateException("prepareCommit was already called with no corresponding call to commit");
      }
     
      // 后续一阶段提交逻辑
}

flush生成待提交的段

在开始提交前再做一次 flush ,尽可能的让一次 commit 包含更多的段,最大程度让写入的文档快速持久化以确保数据安全。

关于 Lucene 的 flush 流程,在上一篇文章 从源码看 Lucene 的文档写入流程 的末节中已经阐述清楚,这里不再赘述。

组装待提交信息、更新文件计数

1.组装最终会被持久化到磁盘的信息。这里将用户触发 commit 前 set 的提交信息组装到了 segmentInfo 中。

2.拷贝一份 segmentInfo 的快照,到这里哪些 segmentInfo 会被持久化已经确定。

3.为新文件的引用初始化引用计数

  // 组装用户提交信息
  if (commitUserData != null) {
    Map<String,String> userData = new HashMap<>();
    for(Map.Entry<String,String> ent : commitUserData) {
      userData.put(ent.getKey(), ent.getValue());
    }
    segmentInfos.setUserData(userData, false);
  }
  // flush和commit是可以同时进行的,为了避免commit过程中又有新的segmentInfo生成
  // 拷贝一份快照确定需要被commit的segmentInfo
  toCommit = segmentInfos.clone();
​
  pendingCommitChangeCount = changeCount.get();
​
  // 生成所有segmentInfo中所有文档的引用
  filesToCommit = toCommit.files(false); 
  // 为以上文件初始化引用计数
  // Lucene的segment删除策略是“引用计数”,即当一个segment不被引用时(如merge完后的旧segment等),就会被最终删除
  // Lucene中任何被持久化的新段,初始计数都为1
  deleter.incRef(filesToCommit);

segment持久化到磁盘

可能会有人好奇,不是一阶段提交吗,怎么segment就持久化到磁盘了?我们往下看。

  private void write(Directory directory) throws IOException {
​
    long nextGeneration = getNextPendingGeneration();
    String segmentFileName = IndexFileNames.fileNameFromGeneration(IndexFileNames.PENDING_SEGMENTS,
                                                                   "",
                                                                   nextGeneration);
    // io流、文件创建逻辑
  }

从上面可以看出持久化的文件名格式是 pending_segments_N ,这里的N指的是 segment 递增编号。很明显,通过“pending”字眼我们可以可以确定这并不是最终的 segment 文件。

二阶段提交 commit

当所有 pending_segments_N 都保存无误后,开始二阶段提交。二阶段提交成功后,此次文档 commit 才真正完成。

二阶段提交流程图

重命名segment文件

这里将一阶段提交的 pending_segments_N 文件重命名为 segments_N 。即 segment 在硬盘内的最终形态。

final String finishCommit(Directory dir) throws IOException {
    ...
    final String src = IndexFileNames.fileNameFromGeneration(IndexFileNames.PENDING_SEGMENTS, "", generation);
    dest = IndexFileNames.fileNameFromGeneration(IndexFileNames.SEGMENTS, "", generation);
    dir.rename(src, dest);
    dir.syncMetaData();
    ...
    return dest;
  }

执行老segment删除策略

Lucene 提供了多种 segment 删除策略来适应不同的业务场景。

其定义了一个删除策略接口 IndexDeletionPolicy ,并实现了四种删除策略。

KeepOnlyLastCommitDeletionPolicy(默认)

只保留最新提交的 segment 文件。因此该种策略最节约硬盘和内存资源。

/**
   * Deletes all commits except the most recent one.
   */
  @Override
  public void onCommit(List<? extends IndexCommit> commits) {
    // note that commits.size() should normally be 2 (if not called by oninit above):
    int size = commits.size();
    for(int i=0;i<size-1;i++) {
      commits.get(i).delete();
    }
  }
NoDeletionPolicy

不删除任何 segment 文件。此种策略需要大量的硬盘空间来存储历史的 segment ,但同时,用户可以将索引回滚到任意一次提交(有点像代码版本控制工具)。

  @Override
  public void onCommit(List<? extends IndexCommit> commits) {}
SnapshotDeletionPolicy

可在 KeepOnlyLastCommitDeletionPolicy 或 NoDeletionPolicy 策略的基础上,生成存储于内存的最新 segment 快照 lastCommit 。一般与 KeepOnlyLastCommitDeletionPolicy 搭配使用,可实现只持久化最新 segment 的同时,在内存中保留某一时刻的 segment 快照。

  private final IndexDeletionPolicy primary;
​
  @Override
  public synchronized void onCommit(List<? extends IndexCommit> commits)
      throws IOException {
    primary.onCommit(wrapCommits(commits));
    lastCommit = commits.get(commits.size() - 1);
  }
PersistentSnapshotDeletionPolicy

与 SnapshotDeletionPolicy 策略几乎一致,区别是生成的 segment 快照将会持久化到硬盘。

快照文件名格式为:SNAPSHOTS_PREFIX + nextWriteGen

SNAPSHOTS_PREFIX 是固定前缀 snapshots_ ,nextWriteGen 是快照递增计数。

举例:snapshots_1、snapshots_2

synchronized private void persist() throws IOException {
    String fileName = SNAPSHOTS_PREFIX + nextWriteGen;
    IndexOutput out = dir.createOutput(fileName, IOContext.DEFAULT);
    // 文件存储io逻辑
    ...
    nextWriteGen++;
  }

生成segment回滚位点

在这一步暂存了上一次提交的 segment 信息,以便本次提交失败后可以快速回滚。

List<SegmentCommitInfo> createBackupSegmentInfos() {
    final List<SegmentCommitInfo> list = new ArrayList<>(size());
    for(final SegmentCommitInfo info : SegmentInfo) {
      assert info.info.getCodec() != null;
      list.add(info.clone());
    }
    return list;
  }

merge

文档的频繁 commit 会产生很多 segment 文件,为了提高文档的检索、写入速率,Lucene 会在合适的时机将 segment 合并。关于 merge 的细节篇幅较多,将在后续文章中展开。

public enum MergeTrigger {
  /**
   * Merge was triggered by a segment flush.
   */
  SEGMENT_FLUSH,
​
  /**
   * Merge was triggered by a full flush. Full flushes
   * can be caused by a commit, NRT reader reopen or a close call on the index writer.
   */
  FULL_FLUSH,
​
  /**
   * Merge has been triggered explicitly by the user.
   */
  EXPLICIT,
​
  /**
   * Merge was triggered by a successfully finished merge.
   */
  MERGE_FINISHED,
​
  /**
   * Merge was triggered by a closing IndexWriter.
   */
  CLOSING
}

失败回滚 rollback

当一二阶段提交任何一步中产生失败时,执行回滚策略。

失败回滚流程图

  1. 尝试终止第二阶段提交最后可能产生的 merge 事件。
  2. 删除第一阶段提交过程中产生的临时文件 pending_segment_N 。
  3. 将 segmentInfo 回滚到上一次提交时设置的回滚位点。

流程图导出.png

最后

由此可见,当 Lucene 执行 commit 后,写入的文档才被持久化到了硬盘。Lucene 由于自身没有事务日志机制,因此只能通过二阶段提交来实现提交失败时回滚,使文档的提交更高效、安全。第一次提交已经确保了所有 segment 文件都持久化成功,第二次提交时仅需修改文件名即可,在尽可能简化代码的同时也保证了第二次提交的成功率。但在更上层的应用中,如 solr、Elasticsearch ...中,均有事务日志,当提交失败时,还能通过事务日志就行回滚。

参考文献

Lucene源码

Chris 的小屋

分布式事务中的二阶段提交是什么

推荐阅读

Kubernetes弹性扩缩容之HPA和KEDA

从线上死锁分析到 Next-Key Lock 理解(2)

Spring Boot 优雅停机

浅谈大数据指标体系建设流程

Spock单元测试框架简介及实践

招贤纳士

政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有 500 多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

政采云技术团队.png