Lucene底层原理和优化经验分享(2)-Lucene优化经验总结

来源:互联网 发布:淘宝话费充值不到账 编辑:程序博客网 时间:2024/04/28 00:15

  系统优化遵从木桶原理:一只木桶能盛多少水,并不取决于最高的木板,而取决于最短的那块木板。Lucene优化也一样,找到性能瓶颈,找对解决方法,才能事半功倍,本文将从三方面阐述我们的Lucene优化经验:
  1. 找准方向 -> Lucene性能瓶颈分析。
  2. 找对方法 -> Lucene代码架构分析。
  3. 方法落地 -> 优化经验总结。

1. Lucene性能瓶颈分析

  上篇Lucene底层原理分析了Lucene索引结构:内存+磁盘,打开索引库时只有tip和fdx文件会被加载到内存中,tip为FST的前缀索引,fdx为正向文件索引,其他文件tim、doc、fdt都放在硬盘,一次完整的检索过程与索引文件的交互过程如图:
这里写图片描述 
  整个流程至少发生三次随机IO:
  1. 读后缀词块
  2. 读倒排表
  3. 取文档(如果文档号跳跃性很大或者因为打分完全乱序,那么会发生更多次随机IO,极端情况就是取多少文档就发生多少次随机IO)
  当前机械硬盘随机IO响应时间平均在10ms左右,远大于CPU+内存计算时间,而且这只是针对一个查询条件,若多个查询条件、跨多列、甚至模糊查询,随机IO请求更多,因此Lucene查询性能瓶颈主要集中磁盘IO性能上,尤其随机IO性能。所以我们的优化方向就是:
  1. 减少IO请求。
  2. 顺序IO代替随机IO。

2. Lucene代码架构

  上一节分析了Lucene性能瓶颈,这一节分析Lucene代码架构,找到从哪里下手去实现优化。
  Lucene从4.0版本后,代码全面模块化,并开放了很多接口,包括索引格式接口Codec、打分接口Similarity、文档收集接口Collector,开发者想基于Lucene再开发,不再需要侵入式修改源代码,而是基于接口,插件式修改。我们结合业务场景和开放接口自定义了Lucene检索模式。
  Lucene检索大致时序图:
  这里写图片描述   
  1. APP解析用户查询生成查询条件Query。
  2. IndexSearcher重写Query并生成Weight。
  3. Weight会生成Scorer,Scorer创建相应查询条件的倒排表迭代器。
  4. 调用scoreALl(),遍历所有文档ID,依次传给传给Collector。
  5. Collector得到文档ID后,调用打分模块Similarity得到文档分值,并根据分值和文档收集器具体实现决定是否返回。Lucene默认的收集器TopScoreDocCollector,会根据用户定义的文档数如100,返回分值前100的文档ID。
  
  我们对Lucene的修改主要在图中标红的文档收集过程,一是屏蔽打分,二是修改文档收集模式,下一节会详细阐述。

3. 优化经验总结

  基于底层原理和代码架构,我们知道了需要做什么和怎么做:IO、IO、还是IO,以下我们全文检索系统的主要优化方案:

3.1单盘优化

解决问题:
  硬盘随机IO性能低。
解决方案:
  1. 将原先的Raid5拆分,改用单盘,因为Raid5随机读写性能 < n*单盘。
  2. 将索引文件tim、doc使用固态硬盘SSD存放,正向文件fdt使用机械硬盘,这样综合了SSD随机读写性能高,机械硬盘成本低、存储空间大的优点。
  3. 对同一磁盘上索引库进行统一管理,单线程处理对同一硬盘上索引库的检索请求,防止同一硬盘多库之间同时访问降低磁盘性能。这里可以根据实际测试情况调整具体线程数,但线程数不宜过多。
  

3.2布隆过滤器

解决问题:
  有些单词不在索引库里,但还需要进索引库查询,发起不必要的IO请求。

解决方案:
  使用布隆过滤器,预先判断单词是不是在该索引库里。布隆过滤器原理很简单,对一单词哈希,并映射到相应bit,设置为1,判断时同样做哈希,并去相应bit位取值,若为1,则可能存在,进库查询,若为0,则肯定不存在,不需进库查询。
这里写图片描述

  对Lucene实现布隆过滤器有两种方式:
  1. 在应用层,Lucene之外实现。
  2. 改写Lucene的Codec接口,添加布隆过滤器功能,使用布隆过滤器预先过滤查询条件。
  后来我们经过测试,选用了第一种方案,因为布隆过滤器十分消耗内存、加载时间很长,而且我们同一索引库为提高性能,复制到多个硬盘上,所以如果布隆过滤器放在Lucene里,相同过滤器会被加载多次,会浪费相当多的内存,所以我们在Lucene之外做了布隆过滤器,同一索引库共享一个布隆过滤器,节约了内存。
  

3.3屏蔽打分/排序机制

解决问题:
  一次测试发现,同样的条件,精确查询速度还没有模糊查询速度快
这里写图片描述
  研究源代码发现,Lucene会对分词列的精确查询条件进行打分。打分是搜索引擎重要一部分,倒排索引只能回答是不是的问题,打分能够评判查询条件和文档的匹配度,提高检索质量。Lucene打分过程集成了多种经典模型,如TF-IDF、VSM,如图:
  这里写图片描述
  1. coord 一个document满足几个查询,满足多的分值高。
  2. queryNorm,查询归一化,它的意义是让同一文档但不同查询的打分结果有可比较。
  3. tf-idf,tf是term在文档中出现次数,idf逆文档频率是term在多少个文档中出现过除以总文档数。
  4. getBoost,查询时赋的权重。
  5. 归一化,主要三个因素文档权重、field权重、文档长度,这个很重要,因为这个需要单独加载nvm文件,而且在打开库时不会加载,而是在第一次查询时会加载,因此才会造成查询时间的巨大差异。
  这里不详细阐述,只说下它的几个基本原则:
  1. 一个文档符合的查询条件越多分越高。
  2. 一个文档关键词出现次数越多分越高,文档内容越多分越低。
  3. 一个查询词在越多文档中出现权重越低。
  有兴趣的可查看LuceneAPI文档TFIDFSimilarity类说明:
  http://lucene.apache.org/core/4_10_3/core/index.html
  打分会消耗额外IO、需要更多CPU计算、加载整个倒排表,拖累了查询速度,特别实在文档数非常多的情况下。而对模糊查询,Lucene不会进行打分,所以反而更快。在我们的业务场景下,我们不需要TF-IDF这种打分方式,所以我们完全屏蔽了打分这个过程,大大提高了检索速度。
解决方案:
  1. 实现EmptySimilarity,去掉所有计算过程,打分过程完全为空。

public class EmptySimilarity extends Similarity {    private static long ZERO=0;    @Override    public long computeNorm(FieldInvertState state) {        return ZERO;    }    @Override    public SimWeight computeWeight(float queryBoost,            CollectionStatistics collectionStats, TermStatistics... termStats) {        return new EmptySimWeight();    }    @Override    public SimScorer simScorer(SimWeight weight, AtomicReaderContext context)            throws IOException {        return new EmptyScorer();    }    public class EmptySimWeight extends SimWeight {        @Override        public float getValueForNormalization() {            return ZERO;        }        @Override        public void normalize(float queryNorm, float topLevelBoost) {        }    }    public static class EmptyScorer extends SimScorer {        @Override        public float score(int doc, float freq) {            return ZERO;        }        @Override        public float computeSlopFactor(int distance) {            return ZERO;        }        @Override        public float computePayloadFactor(int doc, int start, int end,                BytesRef payload) {            return ZERO;        }    }}

  2. 自定义Collector,结果数满足了抛异常退出,防止读入多余倒排表。

public class SimpleCollector extends Collector implements Iterable<Integer> {    private final List<Integer> hitList;    private final int numHits;    private int docBase;    public SimpleCollector(int numHits) {        if(numHits<0)            throw new IllegalArgumentException("numHits should > 0");        this.numHits = numHits;        this.hitList = new ArrayList<Integer>(numHits);    }    @Override    public void collect(int doc) throws IOException {        if(hitList.size()<numHits)        {            hitList.add(docBase+doc);        }        else{            //若结果满了抛异常退出            throw new HitListFullException();        }    }    public int size(){        return hitList.size();    }    @Override    public void setScorer(Scorer scorer) throws IOException {        //ignore scorer    }    @Override    public void setNextReader(AtomicReaderContext context) throws IOException {        //因为是分段的,所以需要记载每个段起始文档号        this.docBase=context.docBase;    }    @Override    public boolean acceptsDocsOutOfOrder() {        //接受乱序,提高性能,因为最后要自己排序        return true;    }    @Override    public Iterator<Integer> iterator() {        Collections.sort(hitList);        return hitList.iterator();    }    public static class HitListFullException extends RuntimeException{        public HitListFullException()        {            super("HitList already full");        }    }}

  使用如下:

    IndexSearcher indexSearcher = new IndexSearcher(                DirectoryReader.open(FSDirectory                        .open(new File("/index/lucene_test"))));        //使用空打分器        indexSearcher.setSimilarity(new EmptySimilarity());        SimpleCollector simpleCollector=new SimpleCollector(2);        try {            indexSearcher.search(query, simpleCollector);        } catch (HitListFullException e) {            //e.printStackTrace();            // ignore        }        System.out.println(simpleCollector.size());        //遍历文档号        for(int hit:simpleCollector)        {            indexSearcher.doc(hit);        }        indexSearcher.getIndexReader().close();

3.4 取结果优化

解决问题:
  上面的测试条件还有一个问题,就是他们取同样数量的文档数,时间却差了很多。
这里写图片描述

  原因就是因为模糊查询不打分,所以文档ID是顺序的,为顺序IO读方式,而打分后文档ID完全乱序,为随机IO读方式。
解决方案:
  1. 自定义Collector,按文档ID升序排序且结果数满足立即退出。
  2. 多任务合并取结果操作,这样相同ID的文档只会取一次。
  

3.5解决Query被转成Filter

解决问题:
  我们有一个组合条件:

select * from indexdb where Time > 20170104 AND Time < 20170105 AND Protocol = 'TCP' AND Content ='not exist'

  这里需要合并多个查询条件的倒排表,Lucene在合并倒排表时,并不会一次性读出所有倒排表,而是将倒排表抽象成迭代器,延迟获取,而且如果有一个AND条件查询结果为空,它就直接返回,不会读任一倒排表。这里Content查询结果为空,但这个查询还是很久才返回,debug跟踪Lucene源代码发现,Lucene会对Query查询重写来优化性能,这里的Time条件因为匹配到词数太多,而被Lucene改写成Filter,Filter一个特点就是会读出符合查询条件的所有倒排表,并做成BitSet,所以查询时间都消耗在了读倒排表上。
解决方案:
  1. 去掉了CapTime条件,改由应用层去做,按时间预先分库。
  2. 调整子查询顺序,将匹配结果更少的放前面。
  3. 留心Lucene的重写机制,有时候重写过的查询条件不一定符合我们预期。
  

3.6索引库大小性能比较

解决问题:
  Lucene一个索引库多大合适?
解决方案:
  这里涉及到Lucene索引结构设计:Lucene是分段的。分段是指Lucene接收到索引请求后,会先放缓存,缓存满后才会写到磁盘中去,变成一个Segment,Segment创建好了之后就不会再修改,每个Segment相当于一个功能完整的小索引库,它包含之前说的所有索引文件。当然这样会导致索引库中有很多段,所以Lucene后台会有合并线程定期去合并小的段。
  段数越少,检索时随机IO次数请求就越少,段结果合并操作越少。如果只有一个段,那么一个查询条件就需要加载一个后缀词块,但有10个段,就需要分别加载10个段的后缀词块和倒排表,再合并10个段的查询结果。分库本质上跟分段是一样的,调整库大小,减少库数量,就是减少段数来提高性能。
  库大小测试结果:
  总共575G的索引库,我们分为6个100g的库和71个10g的库来分别测试
  打开库测试

库类型 打开时间(s) 库内存占用(g) 大库 11 1.3 小库 18 2.2

  查询测试

库类型 查询条件 查询时间(ms) 大库 content=’trump’ 1100 小库 content=’trump’ 5700

  可以看出大库相比小库不管再打开时间、内存占用、查询效率上都有着很大优势,所以在条件允许下,尽量把库调大。但也需注意两个问题:
  1. 合并大库是有成本的。
  2. 库越大,分发成本越高,容错率越低。

总结

 以上就是我们对Lucene的一些优化经验,回顾起来就是三点:
  1. 认清业务需求。
  2. 分析底层原理,找出性能瓶颈。
  3. 研究代码架构,找到优化切入点。
 这也是我们对其他开源项目的使用方法,知其然更只知其所以然。
 
 谢谢。

2 0