Elasticsearch 5.x 源码分析(14)你一定需要使用nested 类型吗?

很早之前就听说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部分来看这个算法:

  1. BitSet parentDocs = parentFilter.getBitSet(ctx);通过panrentFilter也就是当前这个nested的过滤条件拿到一个被称为是父文档集合的BitSets,然后拿到childDocs的迭代器。
  2. 然后在collect()里面,这里其实是个算法,可能没有上下文大家很难理解,大家可以把childDocs理解为一个从nestedid为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去(这里是用调表)
  3. 对所有匹配的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的查询,我得出下面的断言:

  1. 其实不管是nested aggregation还是nested query,最后只是两个跳表来确定最终docId或者做collect 落盘而已,这个性能其实和数据量只会平缓线性增长。
  2. 只要是nested查询,都需要生成 parentBitSet, 这个就是问题的所在,在之前很多文章已经介绍过了,这个cache一旦失效,要重新生成的话其开销好比range,损耗非常大。
  3. 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到外面做查询的一些功劳。平均延迟下降明显

希望对你的索引优化提供一些帮助。

本篇完!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容

  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 31,789评论 2 89
  • 从小我就爱看电影,那时候电影就是我了解世界的最形象的窗口。在生活中只有两点一线的时候,我对外面的世界一无所知,那时...
    洛瑶918阅读 196评论 0 1
  • 今天是8月1号,是建军节,首先祝我们的祖国繁荣昌盛,人民幸福安康。 最近一段时间没有提具体要求,生话...
    瑶昱阅读 74评论 0 0
  • 今天给大家推荐一本书《别把郁闷带回家》,达三著,里面有一些观点挺有意思,我今天就做一个搬运工,与大家一同欣赏...
    向日葵永远向着太阳阅读 715评论 1 2