Elasticsearch 5.x 源码分析(14)你一定需要使
很早之前就听说nested
字段的查询效率要慢一个数量级,parent-children
查询要慢2个数量级,一直是将信将疑的,知道最近的一些慢查询的排查终于踩到这坑上来,因此这期想来聊聊nested的那些事儿。
Nested 查询
有时候,我们的索引会有一些子字段,类似于SQL的JOIN,一般我们会用一个nested
字段来建mapping
,比如我厂下面的这两个例子:
- 商品索引,一个商品下可能出现过很多档期,每个档期有起始结束时间,价格,销售城市等等,那么我们就把每一个档期用一个
nested
类型来保存。 - 商品打标签,比如一个商品有上千个标签,每个标签可以有很多参数,标签名,过期时间等等。
那下面是一个可能的查询场景:
熟悉ES的人肯定知道,如果我必须要对nested
类型进行联合查询,那没辙,肯定是必须采用nested
类型,但是这里我要highlight 一点,这种场景的查询往往是很小结果集的,也就是说不管从parent的筛选,还是到nested的筛选,这个最后的结果集往往都是很小的,如果是这样的话,那么这个查询其实没什么毛病,它非常快;这也是我一直的认识,我一直以为,其实nested查询和普通查询性能其实没有说的那么糟糕。
而话又说回来,我们真的是所有查询都是需要用到nested属性吗,或者说,如果我是下面这种场景的查询呢?
- 选出出现过在某些档期的商品
- 选出某个过期日期前的某个标签的商品
图一给出你答案,在一个100W 级别的索引, nested 1000W 级别,结果集超过几十W的量时,那么这个查询足足用了5s+。
上面那个问题: 这个查询我们真的需要用到nested吗?其实现在可以回答,很多情况其实我们是不需要用到nested的,因为我们不需要nested内部之间的联合,往往我们只需要确定的一件事就是这个商品,或者这个文档是否曾经出现过。。。
nested 实现原理
那么我们去看看nested的实现。
NestedAggregator.java
@Override
public LeafBucketCollector getLeafCollector(final LeafReaderContext ctx, final LeafBucketCollector sub) throws IOException {
IndexReaderContext topLevelContext = ReaderUtil.getTopLevelContext(ctx);
IndexSearcher searcher = new IndexSearcher(topLevelContext);
searcher.setQueryCache(null);
Weight weight = searcher.createNormalizedWeight(childFilter, false);
Scorer childDocsScorer = weight.scorer(ctx);
final BitSet parentDocs = parentFilter.getBitSet(ctx);
final DocIdSetIterator childDocs = childDocsScorer != null ? childDocsScorer.iterator() : null;
return new LeafBucketCollectorBase(sub, null) {
@Override
public void collect(int parentDoc, long bucket) throws IOException {
// if parentDoc is 0 then this means that this parent doesn't have child docs (b/c these appear always before the parent
// doc), so we can skip:
if (parentDoc == 0 || parentDocs == null || childDocs == null) {
return;
}
final int prevParentDoc = parentDocs.prevSetBit(parentDoc - 1);
int childDocId = childDocs.docID();
if (childDocId <= prevParentDoc) {
childDocId = childDocs.advance(prevParentDoc + 1);
}
for (; childDocId < parentDoc; childDocId = childDocs.nextDoc()) {
collectBucket(sub, childDocId, bucket);
}
}
};
}
这段是Nested的聚合的核心算法,我们用3部分来看这个算法:
-
BitSet parentDocs = parentFilter.getBitSet(ctx);
通过panrentFilter
也就是当前这个nested
的过滤条件拿到一个被称为是父文档集合的BitSets
,然后拿到childDocs的迭代器。 - 然后在
collect()
里面,这里其实是个算法,可能没有上下文大家很难理解,大家可以把childDocs
理解为一个从nested
id为1 开始,一直到索引末端的一个迭代器,这里面既有nested
的文档,也有parent的文档,举个例子,doc1 有三个nested,那么他们的id排序就是1(parent),2,3,4; doc2 有2个nested, 那么他们排序就是 5(parent),6,7;这个docId我理解就是一个segments的内部编号。那么这个算法就是对所有的nested进行迭代,如果匹配的,就送到步骤3,否则就通过childDocId = childDocs.advance(prevParentDoc + 1);
跳到parentDocs的下一个匹配的parentId去(这里是用调表) - 对所有匹配的parentId下的nested进行迭代并进入
collectBucket()
NestedQueryBuilder.java
@Override
protected Query doToQuery(QueryShardContext context) throws IOException {
ObjectMapper nestedObjectMapper = context.getObjectMapper(path);
if (nestedObjectMapper == null) {
if (ignoreUnmapped) {
return new MatchNoDocsQuery();
} else {
throw new IllegalStateException("[" + NAME + "] failed to find nested object under path [" + path + "]");
}
}
if (!nestedObjectMapper.nested().isNested()) {
throw new IllegalStateException("[" + NAME + "] nested object under path [" + path + "] is not of nested type");
}
final BitSetProducer parentFilter;
Query innerQuery;
ObjectMapper objectMapper = context.nestedScope().getObjectMapper();
if (objectMapper == null) {
parentFilter = context.bitsetFilter(Queries.newNonNestedFilter());
} else {
parentFilter = context.bitsetFilter(objectMapper.nestedTypeFilter());
}
try {
context.nestedScope().nextLevel(nestedObjectMapper);
innerQuery = this.query.toQuery(context);
} finally {
context.nestedScope().previousLevel();
}
// ToParentBlockJoinQuery requires that the inner query only matches documents
// in its child space
if (new NestedHelper(context.getMapperService()).mightMatchNonNestedDocs(innerQuery, path)) {
innerQuery = Queries.filtered(innerQuery, nestedObjectMapper.nestedTypeFilter());
}
return new ESToParentBlockJoinQuery(innerQuery, parentFilter, scoreMode,
objectMapper == null ? null : objectMapper.fullPath());
}
这段和上面的aggregation非常类似,最后会生成一个ESToParentBlockJoinQuery
,在里面会再构造出一个ToParentBlockJoinQuery
这个类虽然长,但是并不难理解,所以我不想在这里把它全贴出来,在这个类里面最引人注目的可能就是子类TwoPhaseIterator
,这个类主要被BlockJoinScorer
用到,顾名思义是用于两阶段的调表,其实本质上来说也就是两个联表间的联动用于找出match
的文档。
这个当然不是重点,这里我要说的反而是下面这段
// NOTE: acceptDocs applies (and is checked) only in the
// parent document space
@Override
public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {
final ScorerSupplier childScorerSupplier = in.scorerSupplier(context);
if (childScorerSupplier == null) {
return null;
}
// NOTE: this does not take accept docs into account, the responsibility
// to not match deleted docs is on the scorer
final BitSet parents = parentsFilter.getBitSet(context);
if (parents == null) {
// No matches
return null;
}
return new ScorerSupplier() {
@Override
public Scorer get(boolean randomAccess) throws IOException {
return new BlockJoinScorer(BlockJoinWeight.this, childScorerSupplier.get(randomAccess), parents, scoreMode);
}
@Override
public long cost() {
return childScorerSupplier.cost();
}
};
}
得出结论
结合这两个nested的查询,我得出下面的断言:
- 其实不管是nested aggregation还是nested query,最后只是两个跳表来确定最终docId或者做collect 落盘而已,这个性能其实和数据量只会平缓线性增长。
- 只要是nested查询,都需要生成 parentBitSet, 这个就是问题的所在,在之前很多文章已经介绍过了,这个cache一旦失效,要重新生成的话其开销好比range,损耗非常大。
- nested目前并没有与其他 filter联通的迹象,至少我是没有发现,也就是说,它貌似是自己顾自己,不像range或者其他那样,当cost很高时,它回去走doc_value,也就是说,快也好,满也好,它都是乖乖地去生成parentBitSets,然后自己走双链表去筛选docId。
优化思路
那么既然这个开销主要还是在减少这个ParentBitSet
生成的开销,因此优化的思路可以是尽可能的不要用nested
类型,如果一定要用来保存作为某些联动的查询,那么对于没有联动需求的查询,可以通过copy_to
的方式拷贝一份到parent专门来做这类的查询和聚合(用空间换性能)
下面是我通过对3个nested
字段做aggregation
转成对外面的的3个字段做aggregation
,对比文章开头的优化
对于80W的结果集来说,已经有了超过50%的提升,而这个提升对于
copy_to
的一点点开销简直性价比太高了!
文章结尾贴出我最近几天前对另外一个超过20亿数据的大索引做的优化,其中也有一些是把nested
字段copy_to
到外面做查询的一些功劳。平均延迟下降明显
希望对你的索引优化提供一些帮助。
本篇完!