依赖Lucene的电话号码查询优化
某天的生产环境服务器突然报告有cpu load超负荷的报警,然后赶紧查看查询日志,发现里里面有大量类似这样的查询:
{q=(customer_mobile:/[0-9]{7}7785/+OR+code:7785)&distrib=false&_stateVer_=search4card:1494&start=0&fentityid:123456&rows=20&wt=javabin&version=2&_route_=123456&single.slice.query=true} hits=1 status=0 QTime=694
很明显有一个很特殊的查询条件customer_mobile:/[0-9]{7}7785/,意思是需要查询手机号码后四位为7785的记录,从日志上观察到响应时间还挺长的,平均都需要600毫秒起。
经验告诉我,CPU load飙高是因为这个查询条件造成的,然后联系了业务团队,先把这个查询CASE关掉,随后服务器cpu load也随之恢复了正常。
那么,现在需要对查询进行优化,使用正则式匹配依赖了lucene4之后引入的FST来存储term,一定程度上优化了Lucene上的模糊匹配性能,但,本质上肯定比依赖倒排链的查询效率低了几个数量级。
改成使用倒排链也比较简单:
<fieldType name="mobile" class="solr.TextField" > <analyzer type="index"> <tokenizer class="solr.WhitespaceTokenizerFactory"/> <filter class="solr.NGramFilterFactory" minGramSize="2" maxGramSize="11" /> </analyzer> <analyzer type="query"> <tokenizer class="solr.WhitespaceTokenizerFactory"/> </analyzer> </fieldType>
使用ngram分词可以很好地解决模糊匹配的问题,但是这没法解决,需要匹配的关键词在电话号码中出现的位置进行过滤,比如“7785”在电话号码中间位置出现,这样的记录是需要过滤掉的,但是光用NGram没法实现此功能。
解决办法是在构建索引时,倒排链上附加上Term所在词条位置出现的偏移位置(OffsetAttribute),构建TokenStream流时,添加OffsetAttribute属性值,正好它能设置startOffset和endoffset,
private final OffsetAttribute offsetAttribute; public AllWithPositionNGramTokenFilter(TokenStream input, int minGram, int maxGram) { super(input, minGram, maxGram); this.offsetAttribute = this.addAttribute(OffsetAttribute.class); } @Override protected void appendAttribute(char[] curTermBuffer, int curTermLength, int start, int end) { this.offsetAttribute.setOffset(start, end); }
接下来需要将索引的offset写入开关(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS)打开,需要在Schema的fieldType上去设置属性值。经过试验这个开关始终没有成功,最后打算,还是在代码里面写死吧,虽然粗暴但是很有效:
public class TextFieldWithOffset extends TextField { protected IndexOptions getIndexOptions(SchemaField field, String internalVal) { return IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS; } }
只要在方法getIndexOptions返回DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS,构建索引时就会拿到TokenStream中的Offset属性存到倒排索引中,schema中的fieldtype改成如下:
<fieldType name="mobile" class="com.dfire.tis.solrextend.fieldtype.common.TextFieldWithOffset" sortMissingLast="true" omitNorms="true" autoGeneratePhraseQueries="false" > <analyzer type="index"> <tokenizer class="solr.WhitespaceTokenizerFactory"/> <filter class="com.dfire.tis.solrextend.fieldtype.s4card.AllWithPositionNGramTokenFactory" minGramSize="2" maxGramSize="5" /> </analyzer> <analyzer type="query"> <tokenizer class="solr.WhitespaceTokenizerFactory"/> </analyzer> </fieldType>
你会发现之前ngram的实现改成了AllWithPositionNGramTokenFactory,是的因为Solr默认实现的Offset的值和term的实际位置偏移量是不一致的,所以需要略微修改。
完成以上工作之后,就能写一个测试,试着写一条手机到索引中,看看offset是否正常生成。
public static final EmbeddedSolrServer server; public static final File solrHome; static { solrHome = new File("D:/solr/solrhome"); server = new EmbeddedSolrServer(solrHome.toPath(), "s4card"); } public void testAddSpan() throws Exception { long ver = 20171201001007l; SolrInputDocument doc = new SolrInputDocument(); doc.setField("customer_mobile", "15868113480"); server.add(doc); server.commit(); SolrCore core = server.getCoreContainer().getCore("s4card"); RefCounted<SolrIndexSearcher> s = core.getNewestSearcher(true); this.mobileOffsetView(s); } public void kindCardId(RefCounted<SolrIndexSearcher> s) throws Exception { SolrIndexSearcher searcher = s.incref().get(); PostingsEnum postings = MultiFields.getTermPositionsEnum(searcher.getIndexReader(), "customer_mobile", new BytesRef("480")); int docid = -1; while ((docid = postings.nextDoc()) != DocIdSetIterator.NO_MORE_DOCS) { int start = 0; System.out.println(postings.freq()); if((start = postings.nextPosition()) != Spans.NO_MORE_POSITIONS) { System.out.println("post start:" + start + ",startOffset:" + postings.startOffset() + ",endOffset:" + postings.endOffset()); } } s.decref(); }
Ok,现在我们就可以写一个自定义QueryParser了,以下代码为了突出重点作了删掉,详细的可以下载:
public class MoblieQParserPlugin extends QParserPlugin { public QParser createParser(String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { String fieldName = localParams.get("f"); int startPos = localParams.getInt("start_pos", 2); SpanTermQuery tq = new SpanTermQuery(new Term(fieldName, qstr)); final MobileSpanPositionCheckQuery fquery = new MobileSpanPositionCheckQuery(tq, startPos, StringUtils.length(qstr)); return new QParser(qstr, localParams, params, req) { @Override public Query parse() throws SyntaxError { return fquery; } }; } private class MobileSpanPositionCheckQuery extends SpanPositionCheckQuery { private final int startPos; private final int end; public MobileSpanPositionCheckQuery(SpanQuery match, int startPos, int length) { super(match); this.startPos = startPos; this.end = startPos + length; } 。。。。。 @Override protected AcceptStatus acceptPosition(Spans spans) throws IOException { TermSpans termsSpan = (TermSpans) spans; PostingsEnum posting = termsSpan.getPostings(); if (posting.startOffset() >= end) { return AcceptStatus.NO_MORE_IN_CURRENT_DOC; } else if (posting.startOffset() == startPos) { return AcceptStatus.YES; } else { return AcceptStatus.NO; } } 。。。。。。 } }
在QP中告诉Query需要设置起始的Offset通过acceptPosition函数可以实现doc的二次过滤(第一次是按照Term命中,第二次在第一次的结果集上通过offset进一步过滤)。
将这个QPPlugin配置到solrconfig中:
<queryParser name="termPos" class="com.s4card.MoblieQParserPlugin" />
最后,就是客户端调用啦,通过这样的query语句就能找到手机末四位为3480的记录了,如果刚开始学Lucene的同学可能会说,“这个查询写法为啥这么复杂,数据库SQL的like查询写法多简单”,那么我要提醒你,哪怕有上亿记录筛选也能达到毫秒级响应速度哦(这个是大数据时代尤为重要的),而且还能根据关键字出现位置进行过滤,这又是数据库查询无法实现的。
{!termPos f=customer_mobile start_pos=7}3480
Java查询代码为:
SolrQuery q = new SolrQuery(); q.setQuery("{!termPos f=customer_mobile start_pos=7}3480"); QueryResponse result = server.query(q); System.out.println("numFound:" + result.getResults().getNumFound());
至此,代码发布上线,电话后缀查询的RT恢复到毫秒级别,CPUload也恢复正常了,依赖倒排索引优化电话号码查询的案例就介绍到这里。还等什么赶紧去尝试吧。在实际的开发中,我们完全可以通过这个优化案例举一反三,org.apache.lucene.util.Attribute的扩展除了OffsetAttribue还有PostingAttribute,CharTermAttribute等,依赖他们能实现更多杀手级的使用方式。