发现问题
最近在使用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引起的性能问题。祝君玩得愉快