突破 ES 引擎局限性在用户体验场景中的优化实践

中间件

上文回顾: ES 慢上游响应问题优化在用户体验场景中的实践

在介绍了用户体验管理平台(简称VoC)在针对 ES 慢上游响应场景下的优化实践后,本文继续介绍第二个痛点问题——ES 引擎局限性的性能优化实践。

痛点介绍

Elasticsearch 是一个分布式、RESTful 风格的搜索和数据分析引擎,VoC 平台的用户反馈数据均通过 ES 进行存储与查询,而上文提到的 VoC 功能中反馈变化趋势、反馈重点问题、反馈趋势维度对比,本质上都是围绕在时间、标签或是其他一些数据筛选项维度上的 ES 聚合或嵌套聚合。同时,ES 提供的聚合是一种 Bucket 聚合算法,Bucket 聚合按照一定的规则,将文档分配到不同的桶中,达到分类的目的,Bucket 聚合支持嵌套,也就是在桶里再次分桶。然而,ES 为了保证自身在面对海量数据与复杂查询条件时也能够稳定的运行,针对 Bucket 聚合设置了 max_bucket_size 限制,一旦本次查询生成的桶数目超出了该限制,ES 会立刻终止本次聚合并抛出异常,以免桶数过多超出内存限制最终触发 OOM。

我们可以来计算这样一个场景,统计周期为 6 个月、小时时间粒度下的标签对比维度反馈量趋势中一共有多少个桶:仅按时间相关的限制我们可以得到 6 个月、小时粒度的点共有 63024=4320个,标签如果按 100 个计算,我们需要 ES 提供的聚合桶数就是 43.2 万个,但是实际上目前我们 ES 集群限制的聚合桶数仅仅只有 2 万个、标签数目也远不止 100 个。这就意味着大时间区间、小时间粒度、多对比维度这三个条件,任意一个足够极端时,我们的接口都有可能出现查询慢、ES 触发 Bucket 超限甚至 OOM 的异常情况。

下文以搜索引擎无法替换(存量数据太大,数据迁移成本高)、ES 查询性能配置无法修改(ES 集群配置有限且公司对部分参数配置有限制)、SkyNet 性能无法优化(后续逐步优化,不在本次实践进行)为前提设计方案进行性能优化,即在最坏的场景下寻找到最优解,降低后续进一步优化的压力。

ES 引擎局限性优化

上文有提到,ES 的局限性本质上是 Bucket 限制的问题,因此突破 ES 引擎局限性的关键在于如何在满足 ES Bucket 限制的前提下更高效地进行 ES 查询。

优化措施一:查询拆分

既然一次查询产生的桶数会超出 ES 聚合能力的上限,那最简单的方案就把请求拆分,拆分后的请求每一个都能够满足 ES 的性能限制,多个请求得到的数据在进行二次整合即可。但这就引入了另一个问题,请求该以什么标准进行拆分?

picture.image

对于对比维度枚举已知且数量不大的场景,我们可以按照对比维度进行请求拆分,每次只进行单个枚举值下的是时间直方图聚合,Query DSL 如下所示:


          
请求1GET /***/_search
          
{
          
  "query": {
          
    "term": {
          
      "FIELD": {
          
        "value": "VALUE_1"
          
      }
          
    }
          
  },
          
  "aggs": {
          
    "eredar_create_timestamp_ms": {
          
      "date_histogram": {
          
        "field": "TIME_FIELD",
          
        "interval": "day",
          
        "time_zone": "+08:00",
          
        "order": {
          
          "_key": "asc"
          
        },
          
        "min_doc_count": 0
          
      }
          
    }
          
  }
          
}
          

          

          
请求2GET /***/_search
          
{
          
  "query": {
          
    "term": {
          
      "FIELD": {
          
        "value": "VALUE_2"
          
      }
          
    }
          
  },
          
  "aggs": {
          
    "eredar_create_timestamp_ms": {
          
      "date_histogram": {
          
        "field": "TIME_FIELD",
          
        "interval": "day",
          
        "time_zone": "+08:00",
          
        "order": {
          
          "_key": "asc"
          
        },
          
        "min_doc_count": 0
          
      }
          
    }
          
  }
          
}
          

      

对于对比维度为复合条件的,例如一个映射标签对应了不用渠道下的多个原始标签的场景,可以按照映射标签进行拆分,Query 中定义映射标签的符合条件即可,Query DSL 如下所示:


          
请求1GET /***/_search
          
{
          
  "query": {**复合条件1**},
          
  "aggs": {
          
    "eredar_create_timestamp_ms": {
          
      "date_histogram": {
          
        "field": "TIME_FIELD",
          
        "interval": "day",
          
        "time_zone": "+08:00",
          
        "order": {
          
          "_key": "asc"
          
        },
          
        "min_doc_count": 0
          
      }
          
    }
          
  }
          
}
          

          

          
请求2GET /***/_search
          
{
          
  "query": {*复合条件2**},
          
  "aggs": {
          
    "eredar_create_timestamp_ms": {
          
      "date_histogram": {
          
        "field": "TIME_FIELD",
          
        "interval": "day",
          
        "time_zone": "+08:00",
          
        "order": {
          
          "_key": "asc"
          
        },
          
        "min_doc_count": 0
          
      }
          
    }
          
  }
          
}
          

      

显然方案一的实现十分简单,是针对 Bucket 聚合限制最直接粗暴的解决方案,可以保证每次请求都满足 ES 性能限制要求。但是,这个方案也存在以下几个明显问题需要解决:

  • 将 ES 的查询压力转换成了 Http 请求压力,虽然这里也采用了多线程处理,但是多次请求产生的时延仍导致最终接口的响应时间没有明显的下降,部分场景(对比维度数目过大)甚至有恶化;
  • 需要排序的场景,拆分后的查询无法利用 ES 提供的排序能力,产生额外逻辑负担;
  • 对于无法预先掌握对比维度枚举的场景,无法直接做请求拆分。

优化措施二:m_search 引入

为了应对查询拆分带来的大量请求压力,我们采用 ES 中的 m_search 代替 Search,将多个 Search 请求合并为一个 m_search 请求,极大地减少了拆分后的请求数量。Query DSL 如下所示:


          
GET /***/_msearch
          
{}
          
{
          
  "query": {
          
    "term": {
          
      "FIELD": {
          
        "value": "VALUE_1"
          
      }
          
    }
          
  },
          
  "aggs": {
          
    "eredar_create_timestamp_ms": {
          
      "date_histogram": {
          
        "field": "TIME_FIELD",
          
        "interval": "day",
          
        "time_zone": "+08:00",
          
        "order": {
          
          "_key": "asc"
          
        },
          
        "min_doc_count": 0
          
      }
          
    }
          
  }
          
}
          
{}
          
{
          
  "query": {
          
    "term": {
          
      "FIELD": {
          
        "value": "VALUE_2"
          
      }
          
    }
          
  },
          
  "aggs": {
          
    "eredar_create_timestamp_ms": {
          
      "date_histogram": {
          
        "field": "TIME_FIELD",
          
        "interval": "day",
          
        "time_zone": "+08:00",
          
        "order": {
          
          "_key": "asc"
          
        },
          
        "min_doc_count": 0
          
      }
          
    }
          
  }
          
}
      

通过引入 m_search 方法,可以有效地降低拆分后请求数量激增带来的时延,整个流程仍能保证一次请求即可完成全部所需数据的查询,接口响应速度得到有效提升。但是 m_search 一次性处理多个查询的底层运行逻辑实际上是每个查询独立运行,这就会导致全量反馈数据会被遍历多次,这个环节会严重浪费 ES 资源,仍存在较大的优化空间。

优化措施三:Filters 聚合

为了解决 m_search 中多次查询中数据实际上是进行了多次遍历这个痛点,我们试图寻找到一种能够一次遍历就能完成全部聚合的方法,这种想法看起来兜兜转转又回到了问题的原点,我们怎么能在一次请求中既满足 ES 的性能瓶颈限制又能够尽可能多的聚合出数据呢?

回过头来再分析查询拆分后的每一个查询的特点,目标数据的过滤是依赖 Query 中的 Terms 完成的,Terms 语句限制了待聚合的数据只有该对比维度枚举下的数据,如果在 Terms 中将数据范围限制为多个对比维度枚举下的数据范围,那么在聚合逻辑中就没办法去分辨数据到底属于哪一枚举。因此,基于 Query 中的 Terms 来约束数据并想要一次查询完成多个对比维度枚举的聚合通过这种方式看起来是不可能的。

picture.image

调研后发现,Filters 聚合可以很好地解决这个问题:Filters 聚合是一种多过滤聚合,将过滤条件下移到聚合过程中,基于多个过滤条件来对当前文档进行过滤聚合,Query DSL 如下所示:


          
GET /***/_search
          
{
          
  "query": {**其他过滤条件**},
          
  "aggs": {
          
    "FIELD": {
          
      "filters": {
          
        "filters": {
          
          "FIELD_1": {**对于FIELD_1的过滤条件**},
          
          "FIELD_2": {**对于FIELD_2的过滤条件**}
          
        }
          
      },
          
      "aggs": {
          
        "eredar_create_timestamp_ms": {
          
          "date_histogram": {
          
            "field": "TIME_FIELD",
          
            "interval": "day",
          
            "time_zone": "+08:00",
          
            "order": {
          
              "_key": "asc"
          
            },
          
            "min_doc_count": 0
          
          }
          
        }
          
      }
          
    }
          
  }
          
}
      

通过将对比维度枚举间不同的过滤条件从 Query 下沉到 Aggs 中,可以让 ES 在聚合过程中也能清楚地知道一条数据属于哪一个对比维度枚举,同时由于 Filters 聚合支持 Bool 类的复合查询结构,这种聚合的扩展性与适用性和 Query 中的 Terms 相比是不分伯仲的。

那么基于 Filters 聚合再考虑到 ES 性能瓶颈的问题,仍然使用到了查询拆分和 m_search 的方案:对于一次复杂场景的指标趋势图计算,首先计算时间维度上的桶数目,然后根据这个桶数目动态地调整每次查询要聚合出的对比维度数目,例如此时统计周期为 6 个月、小时时间粒度下,那么每个请求中的 Filter 聚合就不超过 4 个,按照这种拆分方式进行请求拆分,再通过 m_search 一次性请求即可。这里为了避免上述流程对简易聚合场景反而产生性能劣化,对不触发性能瓶颈问题的接口请求仍然走原有的 Search 逻辑。

picture.image

动态调整 Filters 聚合的使用,在保证不触发 ES 性能限制的前提下,最大化了 m_search 中每一个请求能够提供的对比维度聚合能力,充分利用每一块资源,做到了 小请求保速度、大请求保稳定 ,将原有一些极端场景下无法完成的聚合变为可能,成功拓展了平台的极端场景分析能力。至此,对比维度枚举已知场景的痛点问题已解决。

优化措施四:DFS 转 BFS(字节跳动特征服务管理)

在不知道目标对比维度有哪些枚举值时,Filters 聚合完全没办法发挥作用,而通过 Terms 来进行聚合时,ES 中的 Terms 聚合会基于我们的数据动态构建桶,但是我们并不知道这次聚合到底生成了多少桶,一旦使用嵌套聚合,就很可能产生大量的分组,最终导致 Bucket 超限或是 OOM 情况的发生。

结合 VoC 平台功能分析,实际上我们提供的看板数据一般来说只有 TOP 5 或 TOP 10 的趋势,此外的聚合数据对于 VoC 来说其实是无效数据。再分析 ES 的聚合算法发现。ES 的聚合是默认 DFS 的,也就是说在某对比维度反馈量 TOP 10 趋势图接口下的 ES 聚合逻辑如下所示:

picture.image

默认情况下,ES 会先根据嵌套聚合定义的聚合逻辑构建出完整的树,然后再去剪枝掉无关节点。在我们 TOP 10 的场景中,就意味着实际上除了 TOP 10 枚举及其子聚合,其余枚举及其子聚合桶的构建浪费了大量的内存与计算性能,所以针对这种不知道目标对比维度有哪些枚举值的场景,可以改用特征服务管理(BFS) 来进行性能优化。

BFS 下的查询主要分为两步:首先进行 TOP 10 问题的聚合,此时为单个字段聚合,不添加嵌套,目的是过滤出反馈量 TOP 10 的枚举值;此时就从不知道目标对比维度有哪些目标枚举转换为了上文中我们已经完成性能优化的场景,再进行 Filter 聚合&查询拆分即可。实际上 ES 有控制 DFS 或 BFS 的 Terms 参数 collect_mode,但经过实验该参数并没有按照预期效果生效,这一点还需要在后续的实践过程中进一步进行探索。从 DFS 到 BFS 的转变,解决了对比维度枚举不明确场景下的聚合查询,也保证了接口的稳定性与可用性。

picture.image

最终方案总览

综上,针对 ES 引擎局限性的解决方案如下图所示:

picture.image

最终方案是上述四种方案在不同场景下组合运用的结果:

  • 非对比场景:无需嵌套聚合,直接走最简单的 Search 逻辑,保证效率;
  • 对比维度有限且单次查询满足 ES 性能限制场景:Search 查询即可,减少不必要的复杂逻辑;
  • 对比维度有限且单次查询超出 ES 性能限制场景:通过 Filter 聚合基础上的查询拆分与 m_search,将查询拆分成满足 ES 性能限制且聚合效率最大化的多个查询,最后二次计算整合查询结果即可,保证极端场景下接口的可用性与高效性;
  • 对比维度无限场景:单次聚合获取 TOP 枚举值,利用 DFS 转 BFS 思想,将场景转换为对比维度有限场景,降低场景复杂度,减少不必要的性能开销。

整体收益分析

  • 长统计周期&小时间粒度&多对比维度下反馈量趋势

优化前极端场景(例如 6 个月统计周期、小时时间粒度、六级业务标签对比)下处于不可用状态,必然触发 ES 的 OOM 或是 2min 接口超时。优化后极端场景也能稳定响应,平均耗时 14s(优化前即使不做任何对比维度聚合也需要 5s 左右)。普通场景优化前后耗时如下所示,响应速度提升 86.6%:

picture.image

  • 对比维度枚举未知场景下反馈量趋势

优化前极端场景下同样处于不可用状态。优化后极端场景也能稳定响应,平均耗时 5.8s。普通场景优化前后耗时如下所示,响应速度提升 7%。这里提升不明显的主要原因是基本场景下的优化前的聚合查询是一次性完成的,优化后因为 BFS 的原因是分两次进行查询的。

picture.image

  • 关键指标

用户进入VoC 平台首先看到的就是关键指标中的两个指标:反馈总量和反馈变化趋势,这里的性能分析是屏蔽了请求参数缓存进行的,目的是在于测试非高频场景下的性能优化结果:

反馈总量接口响应随着时间周期的增大性能提升更为明显,且基本稳定在 3.2s 左右;反馈趋势接口性能优化接近 80%,提升显著,但是当时间周期增加到 6 个月时性能会发生劣化,推测跟 Filters 聚合的过滤条件下沉有关,这一部分还需要在后面的实践中进一步分析确认。

picture.image

  • 预缓存效果

以上收益分析均建立在 VoC 接口未添加预缓存的条件下进行,目的是为了验证低频查询条件下的页面性能,实际上添加预缓存后的接口响应均在 ms 数量级内,这里就不再详细分析。

总结思考

  • 场景决定方案

ES 引擎局限性解决过程中,不同方案在不同场景下的收益是截然不同的,并非简单的就性能差、复杂的就性能好,要针对场景,具体问题具体分析,拒绝差不多思维,也拒绝过度设计。

  • 居安思危,保持思考

Filter 聚合的使用极大地提升了接口的性能,然而这种 Aggs 中的过滤实际上是对每一个桶的单独过滤,原本的 Query 中的过滤对于一次查询是全局过滤,这种聚合过滤导致的过滤条件下沉可能会造成聚合上的性能下降。虽然目前经过实验对比,下沉前后的 ES 响应速度基本一致,查阅相关资料显示 ES 有对这种下沉场景做了性能优化,但是随着数据量进一步增大以及对比场景愈发复杂,能否保证 Filters 聚合性能不劣化还有待考证。

  • 追求极致

日常需求开发中总是说要保证敏捷也要保证质量,但实际上有关质量我们更多地是着重在稳定、可用而非性能。虽然经过这次优化,接口的性能得到了很大程度的提升,但是很多复杂场景的接口响应耗时仍无法突破 1s 大关。性能的劣化非一日之功,优化也非一蹴而就,只有在日常需求中保持性能上追求极致的意识,才不用一直背负性能优化的历史债务。

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论