Zoie是LinkedIn开源的基于lucene的实时检索系统,对于它的介绍及初步使用可参考我的上一篇文章“使用Zoie构建实时检索系统”。在初步研究并理解了Zoie的源码实现后,本文分析一下Zoie的实现。 


实时检索的核心原理 
通常的检索系统中,建索引和查询是分开的,即建索引是离线的,新的索引会以一定频率(比如每隔5分钟)供查询端使用。对于一些站内检索来说,这种延迟性使得:不需要建索引的速度足够快(只要能跟的上提交频率就行),查询的效果不必完全精确。而要取得实时检索效果,典型的思路是:建索引和查询是在一个进程内,这样每一次的添加索引都会被下一次的查询用到,但这里面的细节还是需要好好琢磨解决的,下面就给出Zoie的基于Lucene的解决方案:索引分两种,ram index和disk index。建索引的过程是:首先建立ram index,因为是内存操作,这个过程通常较快,建完后会重新打开IndexReader,使查询端能看到最新的索引;当内存中的索引文档数达到阈值(10000)或者间隔时间达到阈值(自定义),一个后台线程就将ram index合并到disk index里去,完成后清空已经无用的ram index,并重新打开disk index的IndexReader供查询使用(这里面有个autowarm IndexReader的过程)。特别指出的是,Zoie的ram index有两个,这使得当一个ram index在和disk index做合并操作时(这个过程可能会很耗时),另一个ram index仍能提供建索引的操作。对于查询,使用的索引就包括两个ram index和一个disk index,所以只要索引在内存里建好,就能查询到最新的数据。 

实现概览 
下面简要说明Zoie的核心接口和类。 

ZoieSystem:这个类是对外的核心类,它提供了诸多方法供外界使用,但它本身就像个Facade,封装了其成员的一系列方法。 

DataConsumer:顾名思义,这个接口是用来消费数据也就是建索引的。实时建索引时,ZoieSystem默认使用的DataConsumer是RealtimeIndexDataLoader。在consume数据时,RealtimeIndexDataLoader主要是将数据转换成内部结构后交给另一个DataConsumer即RAMLuceneIndexDataLoader真正在内存里建索引,之后如果当前处理的索引数达到阈值,RealtimeIndexDataLoader会notify LoaderThread,而LoaderThread会调用DiskLuceneIndexDataLoader来合并索引。 

DiskSearchIndex和RAMSearchIndex:这两个类是Zoie操作索引结构的,比如获取或打开指定目录的IndexReader、IndexWriter,更新索引写盘等操作。 

DataProvider:这个结构表示数据提供者。查看Zoie代码,发现如果在索引的过程中程序挂掉,内存中的索引就有可能丢失,解决这个问题的方法可以是,在DataProvider端做控制,最直接的,当重启程序时,重放之前一段时间的数据即可(因为Zoie能做到定期刷数据,所以可计算出需要回放的时间点)。 

建索引的过程 
上面已经对建索引过程做了一些说明,下面配上Zoie wiki上的图再形象化些。分析它的实现时,有个RAM需要重点关注,它包含了两个RAMSearchIndex(Ram A和Ram B)和一个DiskSearchIndex对象成员,并且Ram A和Ram B也同时扮演Ram writable和Ram readable,建索引时用的是Ram writable,查询时用的是Ram readable。通过下面的图可以看到,Ram A和Ram B有个交换和清空的过程:1)RAM交换发生在Ram A要合并到Disk Index前,把A的数据挪到Ram B,使新的Ram A开始接收处理客户端建索引请求,而Ram B不再接收数据而专心合并索引。2)在合并索引完成后,Ram B就需要清空了。 

删除数据 
Zoie没有提供删除索引的接口,它认为每一次的提交或者是add或者是update。在建索引时,Zoie先将document的uid映射成docid,如果发现docid已存在,就需要标记删除该doc。lucene里表示删除标记的文件是xx.del,Zoie当然会最终将标记更新到这个文件,但因为索引结构有两个Ram index和一个disk index,并且不能每一次标记删除就更新disk index,所以Zoie在两种SearchIndex对象里记录了删除标记。当建索引,Zoie同时更新三个SearchIndex内存索引的删除标记,而在查询时会过滤掉被删除的doc。Zoie还提供了expungeDeletes方法来清除disk index中垃圾索引数据,这个操作因为耗时长而适合在凌晨进行,但查看Zoie的代码,这个操作只提供了通过JMX手动实现而没有自动执行的时机。 

ZoieMergePolicy 
Zoie的索引合并策略实现可以说是它的很大亮点。lucene中默认使用的MergePolicy是LogByteSizeMergePolicy,这个MergePolicy在选择合并的segment时,是计算segment的总的字节大小。这种方式的一个缺陷是,像用户profile这种如果update操作多的话(每次update会有一次delete操作),会使得一些segment看起来很大,实际上其中有效的索引数据会很少,这些无用索引数据会给查询带来负担。ZoieMergePolicy在计算索引大小时就去除了已删除的doc,使计算更加精确,下图是Zoie给出的两种MergePolicy的性能对比,随着时间的增长,因为被标记delete的doc越来越多,LogByteSizeMergePolicy的查询性能就下降的很厉害了。但是,如果每天低峰期做一次expungeDeletes操作,并且每天提交的delete操作不多的话,LogByteSizeMergePolicy的问题也不是很大。还有一点,Zoie对segment的数量处理上,默认是最多大段10个、小段20个(可通过合并引子控制),通常段数保持在十几个,因为段数比较多,查询时的性能会受些影响,好处是一些旧的大段不会被频繁合并。 

总结 
上面是对Zoie的实现的简要分析,如有理解不准确的误人之处,敬请指出并谅解。