lucene事务(译与解)

本文分两部份,第一部份为译:是对是对于lucene事务的一篇佳作《Transactional Lucene》的翻译。第二部份为解:是本人对一文中提到一些概念在源码层次的一些理解分析,参考lucene源码版本为4.10.4。《Transactional Lucene》中还提到了多commit在实际生产中的一些妙用,值得参考。

一、译

很多用户并不了解Lucene API的事务语义及其在搜索应用中的用途。对于初学者应当了解的Lucene ACID特性如下:

  • Atomiciy 原子性
    当你在一次IndexWriter session中做操作(增加,删除文档),然后commit,要么你的所有的操作修改都是可见的(commit成功),要么所有的操作修改都不可见(commit失败),绝不会处于某种中间状态。有些方法有它自身的原子操作:如果你调用updateDocument方法,其内在实现是先删除后添加文档,即使你打开了一个近实时(NRT)reader或者使用另一个线程做commit,绝不会出现只有删除而没有添加的情况。与此类似,如果使用addDocuments方法添加一组文档,对于任何reader而言,要么所有的文档可见,要么所有文档不可见。

(对原文未提到的一点补充:通过Indexwriter.getReader获得的Reader是能看到上次commit之后,IndexWriter执行至当前的所有变化的,在解的部份中将对其进行详细说明。)

  • Consistency 一致性
    如果计算机或者OS奔溃,或者jvm挂掉或被杀死,亦或是电源被拔掉了,你的索引都会保持完好。注意,像RAM故障,cpu位翻转或者文件系统损坏之类的问题,还是容易造成索引破坏的。
  • Isolation 隔离性
    当IndexWriter正在做更改的时候,所有更改都不会对当前搜索该索引的IndexReader可见,直到你commit或者打开了一个新的NRT reader。一次只能有一个IndexWriter实例对索引进行更改。
  • Durablity 持久性
    一旦commit操作返回,所有变更都会被写入到持久化存储。如果计算机或者OS奔溃,或者jvm挂掉或被杀死,亦或是电源被拔掉了,所有的变更都已在索引中保存。

Lucene提供了二阶段提交API: 调用prepareCommit方法,完成主要的提交工作(应用缓存的delete操作,写入被缓存中的文档,fsync文件)。如果发生故障(例如,硬盘占满),基本可以肯定它会发生在第一阶段(prepareCommit阶段)。然后,调用commit方法完成事务。

当你调用IndexWriter的close方法时,会自动调用commit方法。如果你想要丢弃自上次commit以来的所有修改,可以调用rollback方法。你甚至可以对一次CREATE进行rollback: 如果你有已经有了一个index,你使用OpenMode.CREATE选项打开一个IndexWriter,然后再调用rollback,index会保持不变。同样,你也可以调用deleteAll方法,然后rollback回来。

注意,仅在一个新的目录上打开一个IndexWriter并不会产生一个空的commit操作,即你无法在这个目录上打开一个IndexReader,直到你完成一次commit。

Lucene自身并未实现事务日志,但在更高层上可以方便实地现。例发,Solr与ElasticSearch就实现了事务日志。

同一索引的多Commit

单个lucene索引可以保存多个commit,这是一个强大的特性,但往往容易被人们所忽视。每次commit都持有该commit被创建的时间点的过引索视图。
这个特点类似于ZFS和新兴的Btrfs这样的现代文件系统的快照和写克隆功能。事实上,Lucene是能够暴露多次commit状态,其内在的原理是:所有的索引分片和文件只会被写一次,正如ZFS和Btrfs上的块。

为了保存多次commit到你的索引中,只需要实现你自己的IndexDeletionPolicy,并将其传递给IndexWriter。Lucene正是通过这个类才知站定哪些commit是要被删除的:IndexWriter会在打开索引和任何一次完成commit的时候调用它。KeepOnlyLastCommitDeletionPolicy是默认的删除机制实现,它会删除掉除了最近一次commit以外的所有commit。如果采用NoDeletionPolicy,那么每一次commit都会保存。

你可以在commit的时候传送userData (Map<String,String>),用于记录关于本次commit的一些用户自定义信息(对Lucene不透明),然后使用IndexReader.listCommits方法获得索引的所有commit信息。一旦你找到了一次commit,你可以在其上找开一个IndexReader对commit点上的索引数据进行搜索。

你也可以基于之前的commit打开一个IndexWriter,回滚之后的所有变动。这有点类似于rollback方法,不同之处在于它允许你在多个commit间rollback,而不仅是回滚当前IndexWriter会话中所做的变动。

当你使用OpenMode.CREATE参数打开一个索引的时候,老的commit仍会保存。你也可使用OpenMode.CREATE,同时还在老的commit上用IndexReader进行搜索。这使得一些有趣的应用情影成为可能,例如,你可以在不影响当前任何打开的reader前提下,在各个commit间做过索引全量重建工作。

组合上这些有意思的事务特征,你可以完成一些很酷的工作:

    • 利用SnapShotDeletionPolicy或者PersistentSnapshotDeletionPolicy实现热备:
    1. deletion policies make it trivial to take a "live" backup of the index without blocking ongoing changes with IndexWriter. 备份可以轻易的实现增量(只要复制新的文件,并去掉删除文件),而你可以减少IO消耗以减轻对于搜索的干扰。

      • 搜索不同版本的目录
        也许你在运行一个商业网站,却要辗转于不同版本的目录之间。在这种情况下,你就可以保存更老的commit,允许用户选择搜索哪个版本的目录。
      • 在同一个初始索引上进行多次索引实验:也许你想要在同一个大的索引上,运行一系列的性能测试实验,例如尝试不的RAM缓存大小或者merge因子。要想如此,你可以在运行完每次测试之后,不要关闭IndexWriter,而使用回滚方法快迅地恢得到初始状态,以备下次测试。
      • 强制merge所有的片段到单一片段中,但依然保存之前的多片段时候的commit。如此,你就可以做多个段vs单一段的性能比较实验。
      • 在NFS文件系统上做索引与搜索:
        由于NFS无法保证当前打开的文件不被删除,你必须使用IndexDeletionPolicy来保存每次提交,直至所有的reader都不再使用该commit(即,重新在一个更新的commit上打开)。一种简单的解决方案是基本于时间的,例如:不删除该commit,直到创建该commit15分钟之后,然后每5分钟重新打开reader一次。不然你在NFS上进行搜索时,将会遇到各种可怕的异常。
      • 分布式commit:
        如果你有其他的资源,需要有lucene索引发生变化的同时提交,你就可以使用二阶段提交API。这样的做法很简单,但容易在第二阶段发生失败; 例如lucene在其第二阶段完成了提交,而数据库在第二阶段中发生了一些错误、奔溃或者是断电,这时,你就可以通过在一个较早的commit上重新打开一个IndexWriter来rollback Lucene的这次commit。
    • 进行实验性的索引更新:也许你只想对索引的一个子集做一次re-indexing操作,但你又不确定这样的操作是否会成功。这种情况下,只要保存老的commit,如果操作失败,就执行rollback,如果成功则删除老的commit。

      • 基于时间的快照:也许你想能够更自由地回滚到1天前,一周前,一个月之前...的索引状态,就保可以根据这些时间点,保存这些commit。

    注意:保存多个版本的commit必然会带来更多的磁盘空间消耗。然而,这些消耗往往会比较小,因为多个commit往往会共享一些索引片段,尤其是那些更大更早的片段。

    二、 解

    对于Lucene事务的原子性、隔离性以及近时实(NRT)搜索

    上文在原子性的描述中提到“要么你的所有的操作修改都是可见的(commit成功),要么所有的操作修改都不可见(commit失败),绝不会处于某种中间状态。” 但没有提到,通过Indexwriter.getReader获得的Reader是能看到上次commit之后IndexWriter执行到当前的所有变化的。

    When you ask for the IndexReader from the IndexWriter, the IndexWriter will be flushed (docs accumulated in RAM will be written to disk) but not committed (fsync files, write new segments file, etc).The returned IndexReader will search over previously committed segments, as well as the new, flushed but not committed segment. Because flushing will likely be processor rather than IO bound, this should be a process that can be attacked with more processor power if found to be too slow.

    还有人们常使用openIfChanged方法来实现近实时搜索。我们来看看Lucene DirectoryReader的标准实现StandardDirectoryReader中的oldOpenIfChanged方法的实现。可以发现所谓的doOpenIfChanged内部也是靠优先尝试从IndexWriter获得DirectoryReader来实现的。

      @Override
      protected DirectoryReader doOpenIfChanged() throws IOException {
        return doOpenIfChanged((IndexCommit) null);
      }
    
      @Override
      protected DirectoryReader doOpenIfChanged(final IndexCommit commit) throws IOException {
        ensureOpen();
    
        // If we were obtained by writer.getReader(), re-ask the
        // writer to get a new reader.
        if (writer != null) {
          return doOpenFromWriter(commit);
        } else {
          return doOpenNoWriter(commit);
        }
      }
    

    现在抛开近实时搜索,不使用该新特性,那么上文中作者提到的原子性特征是完备的。在不考虑merge的情况下,Lucene的每一次commit将内存中积累的索引变更写入到硬盘,形成新的索引分段,包括文档删除操作。这样的好处在于,每一次commit不用去复杂又耗时地修改之前的索引分段,只要累加新文件即可。但这样的后果是越来越多的分片存在,影响查询效率,所以才需要merge机制的存在,不断地去合并分段,这是另话了。

    上文中所提的隔离性就是基于这种机制实现的,非NRT的正常reader打开时,只能获得当前已commit到磁盘的分段文件信息的索引数据,无法得到两次commit的中间状态。 而NRT的reader,可以获得打开时IndexWriter所应用的所有变更,但也无法感知到之后的IndexWriter所做的索引变化,除非重新打开。

    至于“一次只能有一个IndexWriter实例对索引进行更改”,这是靠Lucene实现的排它锁实现的,你将会在你的索引目录下看到write.lock文件的存在。

    Lucene commit

    Lucene多个commit的保存与删除到底是怎么回事?如何基于commit打开索引? 这一系列的问题,还是得需要通过了解lucene如何保存每次commit才能理解。

    我们首先看看一个IndexCommit的子类一般都包含哪些信息。一个IndexCommit实例就代表了一次索引commit。对索引内容的任何变更只有在segments_N文件完成写入之后才可见。

    public abstract class IndexCommit implements Comparable<IndexCommit> {
    
      /**
       * Get the segments file (<code>segments_N</code>) associated 
       * with this commit point.
       */
      public abstract String getSegmentsFileName();
    
      /**
       * Returns all index files referenced by this commit point.
       */
      public abstract Collection<String> getFileNames() throws IOException;
    
      /**
       * Returns the {@link Directory} for the index.
       */
      public abstract Directory getDirectory();
      
      /**
       * Delete this commit point.  This only applies when using
       * the commit point in the context of IndexWriter's
       * IndexDeletionPolicy.
      */
      public abstract void delete();
      
      ...
    }
    

    两个重要的方法getSegmentsFileName与getFileNames。segmentsFileName是与当前commit关联的segments_N文件名。N是一个递增的值,每次commit对应的N都会增大。所以如果保存多个commit的话,自然会出现多个segments_N文件。segments_N具体格式内容在此不作具体解释,总体它记录了lucene索引的所有分段的元数据信息,根据segments_N文件,可以清楚地知道当前commit的索引内容分布于哪些分段之中。getFileNames返回的是当前commit所引用的所有文件。
    如此每次某个commit上打开的IndexReader或IndexWriter就知道应该去加载哪些文件。也正因如此,多个commit能够共享一些老的索引分段,而不至于每个commit占用太大的存储空间。当涉及到commit删除时,由于lucene对索引文件的删除是通过引用计数的方式实现的,只要对commit引用的文件调用一次IndexFileDeleter.decRef(Collection<String> files)方法即可。只有引用计数为0的文件才会真正地被删除。

    Luncene IndexDeletionPolicy

    IndexDeletionPolicy它所能做的是在两个方法时间结点上对IndexCommit做删除。定义如下:

    public abstract class IndexDeletionPolicy {
    
      protected IndexDeletionPolicy() {}
    
    
      public abstract void onInit(List<? extends IndexCommit> commits) throws IOException;
    
      public abstract void onCommit(List<? extends IndexCommit> commits) throws IOException;
    }
    

    onInit方法只在IndexWriter初始化时被调用,onCommit在每次commit操作的时候被调用。commits列表中包含了当前所有的commit点,按从老到新的顺序排列。
    默认实现KeepOnlyLastCommitDeletionPolicy删除上一次commit以外的所有commit相关文件(减少引用计数)。
    SnapshotDeletionPolicy是采用wrapper模式,对现有IndexDeletionPolicy的一层封装。除了onInit和onCommit方法外,它还提供了snapshot和release两个方法。snapshot得到的IndexCommit将不会被删除,直到其被release,所以比较适用于备份的场影,在备份之前调用snapshot,直到备份完成,再调用release。
    SnapshotDeletionPolicy只在内存中保存snapShot信息,如果要保证数据持久化不丢失,可使用PersistentSnapshotDeletionPolicy。

    Lucene二阶段提交的实现

    IndexWirter继承了TwoPhaseCommit接口,实现三个方法:prepareCommit,commit与rollback。

    • prepareCommit,完成二阶段提交第一阶段的工作,它会尽可能多的完成更新工作,但又避免完成真实的提交。你可以轻松地利用rollback废弃掉当前阶段完成的所有工作。 事实上本次commit所产生的段文件,已写入存储。
    • commit方法是完成第二阶段的工作,它只作很少的工作,只有该方满返回,调用者才能确认索引相应操作已完成,并持久化到存储。跟踪代码直至SegmentIfnos的finishCommit方法,可见commit成功的情况下,只做了两件事情,一是在segments_N未填入4byte的校验合,还有就是close写入流完成fsync。新的commit,只有在校验和正确的情况下对IndexReader可见。

      final String finishCommit(Directory dir) throws IOException {
      if (pendingSegnOutput == null) {

      throw new IllegalStateException("prepareCommit was not called");

      }
      boolean success = false;
      final String dest;
      try {

      CodecUtil.writeFooter(pendingSegnOutput);
      success = true;

      } finally {

      if (!success) {
        // Closes pendingSegnOutput & deletes partial segments_N:
        rollbackCommit(dir);
      } else {
        success = false;
        try {
          pendingSegnOutput.close();
          success = true;
        } finally {
          if (!success) {
            // Closes pendingSegnOutput & deletes partial segments_N:
            rollbackCommit(dir);
          } else {
            pendingSegnOutput = null;
          }
        }
      }

      }

    • rollback:废弃掉上次commit以来的所有变更操作。

    20151005首发于3dobe.com
    本站链接:http://3dobe.com/archives/172/

    标签: 搜索引擎, lucene

    添加新评论