https://blog.csdn.net/njpjsoftdev/article/details/54133548
系统优化遵从木桶原理:一只木桶能盛多少水,并不取决于最高的木板,而取决于最短的那块木板。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'
1
这里需要合并多个查询条件的倒排表,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. 研究代码架构,找到优化切入点。
这也是我们对其他开源项目的使用方法,知其然更只知其所以然。
谢谢。
微信公众号: 架构师日常笔记 欢迎关注!