发现问题
最近在使用docValue发现了一个坑,初学者稍不注意很有可能入坑,进而会得出Lucene性能有问题的结论,所以我需要将这个坑填平以正视听。
接到业务方的一个需求,需要在查询结果上按照某一个字段去除重复,假设有以下两条记录:
学号 |
班级id |
班级排名 |
001 |
1 |
1 |
002 |
1 |
2 |
003 |
2 |
1 |
004 |
2 |
2 |
查询结果需要按照班级ID去重,取班级中排名最靠前的一名同学,这样结果应该是这样的:
学号 |
班级id |
班级排名 |
001 |
1 |
1 |
003 |
2 |
1 |
当然这里要说明的是,如果引擎中存储的数据量小,或者索引不需要实时更新的话,不会发生问题。但是当引擎中有海量的数据,比如上千万的索引记录加上同时需要实时更新索引的话,之前在索引上每一个不合理的设置就会被放大,导致查询效率急剧下降。
接到这样的需求,很直接地就想到使用Solr现成的QueryParser collapse :
fq={!collapse field=class_id min=class_ordinar} |
上线之后发现查询响应速度有问题,查询会周期性的变慢,在一个softcommit周期之后查询就会出现一个峰值,RT瞬间飙高到4~5秒才能完成查询。
定位问题
每隔一个solrcommit周期就会发生一次查询超时,直觉告诉我与docValue有关系,因字段class_id在schema设置上必须要开启docValue=true属性,如下:
<field name="class_id" type="string" stored="true" indexed="true" docValues="true"/> |
docValue功能说明:
docValue是一个正排索引,的数据结构就是一个超级大map。Lucene实现方式利用了Linux系统虚拟内存机制,key为docid,value为某列的值。功能是当需要对某列进行排序操作,或者在命中结果集上进行基数统计,分组操作。这些操作都需要利用正排索引通过docid取fieldvalue,像上面说到的按照classid去重需要利用docValue。
以下是一段执行在SearchComponent中的示例代码,里面只把操作docValues相关的片段抽取了出来,生产环境的代码会更复杂。
import org.apache.solr.handler.component.SearchComponent; public class TestDocValueComponent extends SearchComponent { public void process(ResponseBuilder rb) throws IOException { final long start = System.currentTimeMillis(); try { SortedDocValues single = DocValues.getSorted(rb.req.getSearcher().getLeafReader(), "class_id"); } finally { log.info((System.currentTimeMillis() - start) + "ms"); } } }
问题就出在调用getSortedDocValues这个方法上面,来看一下系统调用DocValue的时序图:
从调用的时序图可以看出,最后真正返回docValue为MultiSortedDocValues。它是聚合了各个子reader的聚合代理类。MultiSortedDocValues 的构造函数之一的OrdinalMap这个参数是很重要的,简单说明一下该类的作用,需要先说明一下Lucene的IndexReader类型,实际运行时提供给上层搜索器使用的是一个两层树状结构的数据结构,上层是一个根Reader,将下层的N个子LeafReader通过引用的方式聚合在一起,随着系统不断的有数据更新,下面的LeafReader也会变得越来越多(所以系统查询效率会越来越低,这也是搜索引擎不能当作数据库用的原因, 因为它需要定期作全量数据重建才能保证查询性能不至于变得太低),且每个leafReader都有一个docValue与之对应,根reader也可以取到与之对应的docValue。
嘿嘿,说了半天还是没有说OrdinalMap是干啥用的,稍等一下,还要再说明一个事儿,对于string类型docValue 的实现类SortedDocValues上有一个getOrd()方法,这个方法很有用,查询时需要按照某个string类型的字段排序输出结果时,getOrd方法就要用上,Lucene在通过docid调用getOrd方法就能取得一个排序值(ordinal),两个不同doc对应的string类型的字段大小不需要通过原值进行比大小,只需要通过int型的值比大小就好了,这样就大大提降低了排序过程中IO开销(无论多大的文本字段,与文本内容无关,只与排序的顺序有关),因为docValue在物理存储时已经排好序了。
试想一下,在每个子Reader上都有一个字段值“美丽”,在第一个Reader上doc1的排序是n1,在第二个Reader上doc2的排序为n2,那么在根Reader对应的docValue上(base1 +doc1)和(base2+doc2)所对应顺序应该是多少呢?毫无疑问这个顺序值是同一个,这就需要有一个映射函数帮助了。这时候OrdinalMap就该上场啦,它的作用就是帮子docValue和Root docValue上的ordinal作映射。以下是OrdinalMap构造函数截取:
OrdinalMap(Object owner, TermsEnum subs[], SegmentMap segmentMap, float acceptableOverheadRatio) throws IOException { PackedLongValues.Builder globalOrdDeltas = PackedLongValues.monotonicBuilder(PackedInts.COMPACT); PackedLongValues.Builder firstSegments = PackedLongValues.packedBuilder(PackedInts.COMPACT); final PackedLongValues.Builder[] ordDeltas = new PackedLongValues.Builder[subs.length]; for (int i = 0; i < ordDeltas.length; i++) { ordDeltas[i] = PackedLongValues.monotonicBuilder(acceptableOverheadRatio); } long[] ordDeltaBits = new long[subs.length]; long segmentOrds[] = new long[subs.length]; ReaderSlice slices[] = new ReaderSlice[subs.length]; TermsEnumIndex indexes[] = new TermsEnumIndex[slices.length]; for (int i = 0; i < slices.length; i++) { slices[i] = new ReaderSlice(0, 0, i); indexes[i] = new TermsEnumIndex(subs[segmentMap.newToOld(i)], i); } MultiTermsEnum mte = new MultiTermsEnum(slices); mte.reset(indexes); long globalOrd = 0; while (mte.next() != null) { TermsEnumWithSlice matches[] = mte.getMatchArray(); int firstSegmentIndex = Integer.MAX_VALUE; long globalOrdDelta = Long.MAX_VALUE; for (int i = 0; i < mte.getMatchCount(); i++) { int segmentIndex = matches[i].index; long segmentOrd = matches[i].terms.ord(); long delta = globalOrd - segmentOrd; // We compute the least segment where the term occurs. In case the // first segment contains most (or better all) values, this will // help save significant memory if (segmentIndex < firstSegmentIndex) { firstSegmentIndex = segmentIndex; globalOrdDelta = delta; } while (segmentOrds[segmentIndex] <= segmentOrd) { ordDeltaBits[segmentIndex] |= delta; ordDeltas[segmentIndex].add(delta); segmentOrds[segmentIndex]++; } } // for each unique term, just mark the first segment index/delta where it occurs assert firstSegmentIndex < segmentOrds.length; firstSegments.add(firstSegmentIndex); globalOrdDeltas.add(globalOrdDelta); globalOrd++; } this.firstSegments = firstSegments.build(); this.globalOrdDeltas = globalOrdDeltas.build(); // ordDeltas is typically the bottleneck, so let's see what we can do to make it faster segmentToGlobalOrds = new LongValues[subs.length]; long ramBytesUsed = BASE_RAM_BYTES_USED + this.globalOrdDeltas.ramBytesUsed() + this.firstSegments.ramBytesUsed() + RamUsageEstimator.shallowSizeOf(segmentToGlobalOrds) + segmentMap.ramBytesUsed(); for (int i = 0; i < ordDeltas.length; ++i) { final PackedLongValues deltas = ordDeltas[i].build(); if (ordDeltaBits[i] == 0L) { // segment ords perfectly match global ordinals // likely in case of low cardinalities and large segments segmentToGlobalOrds[i] = LongValues.IDENTITY; } else { final int bitsRequired = ordDeltaBits[i] < 0 ? 64 : PackedInts.bitsRequired(ordDeltaBits[i]); final long monotonicBits = deltas.ramBytesUsed() * 8; final long packedBits = bitsRequired * deltas.size(); if (deltas.size() <= Integer.MAX_VALUE && packedBits <= monotonicBits * (1 + acceptableOverheadRatio)) { // monotonic compression mostly adds overhead, let's keep the mapping in plain packed ints final int size = (int) deltas.size(); final PackedInts.Mutable newDeltas = PackedInts.getMutable(size, bitsRequired, acceptableOverheadRatio); final PackedLongValues.Iterator it = deltas.iterator(); for (int ord = 0; ord < size; ++ord) { newDeltas.set(ord, it.next()); } segmentToGlobalOrds[i] = new LongValues() { @Override public long get(long ord) { return ord + newDeltas.get((int) ord); } }; ramBytesUsed += newDeltas.ramBytesUsed(); } else { segmentToGlobalOrds[i] = new LongValues() { @Override public long get(long ord) { return ord + deltas.get(ord); } }; ramBytesUsed += deltas.ramBytesUsed(); } ramBytesUsed += RamUsageEstimator.shallowSizeOf(segmentToGlobalOrds[i]); } } this.ramBytesUsed = ramBytesUsed; }
以上这段代码逻辑确实有点复杂,内部不去细究,总的思路是遍历每个子segment上的Terms然后构建一个全局的ordinal数据结构,这里的算法负责度是O(termsCount)它的执行时间是随着terms的数量决定的。因此,对于对于散列的字段类型特别要注意,比如一些主键字段类似“用户id”(几乎每条记录的主键值都不相同),如果是一枚举类型的字段就没有问题,比如“用户类型”全部数据集合上也就几十种类型。
因此每当一次softcommit之后,内部的SlowCompositeReaderWrapper.cachedOrdMaps中保存的OrdinalMap对象就会失效,从而需要构建一个新的OrdinalMap,而构建OrdinalMap的时间是依赖于field对应的term的多少来确定的。
如何解决
问题已经明确,那么现在就可以对症下药了,解决这个问题有三个办法:
1.看看真的是否有必要使用string类型的字段,是否可以将字段类型换成数字类型比如long,因为NumericDocValues没有getOrd这样的方法,它直接通过docid取对应field的值,没有构建OrdinalMap对象的过程。
2.如果真的要使用string类型的字段,考虑是否可以最终在处理结果集的时候不在根reader上操作进行操作。就拿facet查询,最终要得到的结果就是统计根据最终值统计该字段值的个数(基数统计)。如果在最终命中结果数可控的情况下,比如最终命中在几千个的情况下可以考虑自己构建一个SearchComponent在process方法中定义一个collector去遍历索引命中的docid(docid都是子reader上的),所以就可以避免使用全局docvalue,因此也可以避免构建OrdinalMap了。
3.如果真的没有办法去改变已有的数据机构,还有一招也可以起到立竿见影的功效,那就是在每次softcommit之后在新的indexReader生效之前,先对docValue进行预热,写一个AbstractSolrEventListener,代码如下:
public class FieldWarmupEventListener extends AbstractSolrEventListener {
public FieldWarmupEventListener(SolrCore core) { super(core); }
@Override public void newSearcher(SolrIndexSearcher newSearcher, SolrIndexSearcher currentSearcher) { try { DocValues.getSorted(newSearcher.getLeafReader(), “order_id”); } catch (IOException e) { throw new IllegalStateException(e); } } } |
在solrconfig中添加一个listener
<listener event="newSearcher" class="com.sit.solrextend.FieldWarmupEventListener"/>
无论使用以上哪种方法,都可以解决使用docvalue引起的性能问题。祝君玩得愉快
相关推荐
**Solr** 是一个高度可扩展的、企业级的搜索引擎平台,它基于 **Lucene** 构建而成,提供了丰富的功能来满足各种复杂的搜索需求。Solr 可以用于构建高性能的应用程序,支持多种数据类型,并且具有强大的全文检索能力...
Solr是一个高性能的全文搜索引擎,基于Apache Lucene开发,使用Java 5编写。它不仅继承了Lucene的强大功能,还提供了更丰富的查询语言以及更好的性能优化。Solr具备高度可配置性和可扩展性,支持通过HTTP请求提交XML...
- 在`D:\WORK`目录下创建一个名为`SolrHome`的文件夹,用于存放Solr的配置文件等:`D:\WORK\SolrHome`。 4. **复制示例配置** - 将`apache-solr-1.4.1\example\solr`目录下的所有内容复制到`SolrHome`文件夹中。...
Solr 是一个基于 Lucene 的搜索服务器, IKAnalyzer 是一个开源的中文分词器,通过将其整合到 Solr 中,可以实现中文搜索的功能。 一、Solr 环境搭建 Solr 环境搭建需要 JRE 环境的支持,因此我们首先需要安装 ...
1.2.5 缓存:Solr内置了多种缓存机制,如查询结果缓存、文档ID到DocValue的缓存等,显著提高了搜索性能。 1.2.6 复制:Solr支持主从复制,确保数据的安全性和高可用性,可以轻松地扩展集群规模。 1.2.7 管理接口:...
- **步骤说明**: 在同一台机器上配置多个Tomcat实例作为Solr服务器。 - **操作详情**: 本例中配置了三台Tomcat服务器,端口分别为80、9888和9008。每台服务器的SolrHome路径不同,确保它们指向不同的目录。 **2. ...
在主函数中,我们创建了一个 Solr 客户端连接,并使用封装的查询方法来执行查询操作。 ```java public static void main(String[] args) throws SolrServerException, IOException { // 创建solr客户端连接 ...
Lucene是一个高性能、全功能的文本搜索库,它被广泛应用于各种规模的应用程序之中。作为一款开源工具,Lucene提供了强大的搜索功能,使得开发者能够轻松地为自己的应用添加搜索功能。 #### 官方网站 - **网址**:...
Solrj是Apache Lucene项目下的一个Java库,专门用于与Apache Solr搜索引擎服务器进行交互。这个名为"UpdateSolrField.rar"的压缩包显然包含了关于如何使用Solrj更新Solr索引的示例代码,特别是针对特定ID的文档进行...