腾讯Elasticsearch海量规模背后的内核优化剖析

背景

Elasticsearch 在腾讯内部广泛应用于日志实时分析、结构化数据分析、全文检索等场景,目前单集群规模达到千级节点、万亿级吞吐,同时腾讯联合 Elastic 公司在腾讯云上提供了内核增强版 ES 云服务。海量规模、丰富的应用场景推动着腾讯对原生 ES 进行持续的高可用、高性能、低成本等全方位优化。本次分享主要剖析腾讯对 Elasticsearch 海量规模下的内核优化与实践,希望能和广大 ES 爱好者共同探讨推动 ES 技术的发展。

目录

本次分享内容主要分为以下几个部分:首先介绍 ES 在腾讯海量规模应用的背景,然后是我们遇到的痛点与挑战,接下来针对这些痛点与挑战,剖析我们对 ES 做的内核优化,最后介绍我们的开源贡献以及未来的规划。

一、ES 在腾讯的海量规模背景

先来看看 ES 在腾讯的主要应用场景。ES 是一个实时的分布式搜索分析引擎,目前很多用户对 ES 的印象还是准实时,实际上在 6.8 版本之后官方文档已经将 near real-time 改为了 real-time: "Elasticsearch provides real-time search and analytics for all types of data." ES 在写入完毕刷新之前,是可以通过 getById 的方式实时获取文档的,只是在刷新之前 FST 还没有构建,还不能提供搜索的能力。 目前 ES 在腾讯主要应用在三个方面:

  • 搜索服务: 例如像腾讯文档基于 ES 做全文检索,我们的电商客户拼多多、蘑菇街等大量的商品搜索都是基于 ES。
  • 日志分析: 这个是 ES 应用最广泛的领域,支持全栈的日志分析,包括各种应用日志、数据库日志、用户行为日志、网络数据、安全数据等等。ES 拥有一套完整的日志解决方案,可以秒级实现从采集到展示。
  • 时序分析: 典型的场景是监控数据分析,比如云监控,整个腾讯云的监控都是基于 ES 的。此外还包括物联网场景,也有大量的时序数据。时序数据的特点是写入吞吐量特别高,ES 支持的同时也提供了丰富的多维统计分析算子。

当然除了上面的场景之外,ES 本身在站内搜索、安全、APM 等领域也有广泛的应用。

目前 ES 在腾讯公有云、专有云以及内部云上面均有提供服务,可以广泛的满足公司内外客户的业务需求。公有云上的使用场景非常丰富,专有云主要实现标准化交付和自动化运维,腾讯内部云上的 ES 都是 PB 级的超大规模集群。

二、痛点与挑战

在这些丰富的应用场景,以及海量的规模背景下,我们也遇到了很多的痛点与挑战。主要覆盖在可用性、性能、成本以及扩展性方面。

  • 可用性: 最常见的问题是节点因高负载 OOM,或者整个集群因高负载而雪崩。这些痛点使我们很难保障 SLA,尤其是在搜索场景, 可用性要求 4 个 9 以上。
  • 性能: 搜索场景一般要求平响延时低于 20 毫秒,查询毛刺低于 100 毫秒。在分析场景,海量数据下,虽然实时性要求没那么高,但请求响应时间决定了用户体验,资源消耗决定了性能边界。
  • 成本: 很多用户都比较关注 ES 的存储成本,因为 ES 确实数据类型较多,压缩比比较低,存储成本比较高,但是优化的空间还是很大的。另外还包括内存成本,ES 有大量的索引数据需要加载到内存提供高性能的搜索能力。那么对于日志、监控等海量场景,成本的挑战就更大。
  • 扩展性: 日志、时序等场景,往往索引会按周期滚动,长周期会产生大量的索引和分片,超大规模集群甚至有几十上百万的分片、千级节点的需求。而目前原生版本 ES 只能支持到万级分片、百级节点。随着大数据领域的飞速发展,ES 最终是要突破 TB 的量级,跨越到 PB 的量级,扩展性就成为了主要的瓶颈与挑战。

三、腾讯 ES 内核优化剖析

ES 使用姿势、参数调优等在社区有很多的案例和经验可以借鉴,但很多的痛点和挑战是无法通过简单的调优来解决的,这个时候就需要从内核层面做深度的优化,来不断完善这个优秀的开源产品。接下来就是本次分享的核心部分,我们来看看腾讯是如何在内核层面对 ES 做优化的。

首先介绍可用性优化部分。总体来说,原生版本在可用性层面有三个层面的问题:

  • 系统健壮性不足: 高压力下集群雪崩,主要原因是内存资源不足。负载不均会导致部分节点压力过载,节点 OOM。我们在这个层面的方案主要是优化服务限流和节点均衡策略。
  • 容灾方案欠缺: ES 本身提供副本机制提升数据安全性,对于多可用区容灾还是需要云平台额外实现。即使有副本机制,甚至有跨集群复制(CCR),但还是不能阻挡用户误操作导致的数据删除,所以还需要额外提供低成本的备份回挡能力。
  • 内核 Bug: 我们修复了 Master 任务堵塞、分布式死锁、滚动重启速度慢等一系列内核可用性相关的问题,并及时提供新版本给用户升级。

接下来针对用户在可用性层面常遇到的两类问题展开分析。一类是高并发请求压垮集群,另一类是单个大查询大挂节点。

先来看第一类场景,高并发请求压垮集群。例如早期我们内部一个日志集群,写入量一天突增 5 倍,集群多个节点 Old GC 卡住脱离集群,集群 RED,写入停止,这个痛点确实有点痛。我们对挂掉的节点做了内存分析,发现大部分内存是被反序列化前后的写入请求占用。我们来看看这些写入请求是堆积在什么位置。

ES high level 的写入流程,用户的写入请求先到达其中一个数据节点,我们称之为数据节点。然后由该协调节点将请求转发给主分片所在节点进行写入,主分片写入完毕再由主分片转发给从分片写入,最后返回给客户端写入结果。右边是更细节的写入流程,而我们从堆栈中看到的写入请求堆积的位置就是在红色框中的接入层,节点挂掉的根因是协调节点的接入层内存被打爆。

找到了问题的原因,接下来介绍我们的优化方案。

针对这种高并发场景,我们的优化方案是服务限流。除了要能控制并发请求数量,还要能精准的控制内存资源,因为内存资源不足是主要的矛盾。另外通用性要强,能作用于各个层级实现全链限流。

限流方案,很多数据库使用场景会采用从业务端或者独立的 proxy 层配置相关的业务规则,做资源预估等方式进行限流。这种方式适应能力弱,运维成本高,而且业务端很难准确的预估资源消耗。

原生版本本身有限流策略,是基于请求数的漏桶策略,通过队列加线程池的方式实现。线程池大小决定的了处理并发度,处理不完放到队列,队列放不下则拒绝请求。但是单纯的基于请求数的限流不能控制资源使用量,而且只作用于分片级子请求的传输层,对于我们前面分析的接入层无法起到有效的保护作用。原生版本也有内存熔断策略,但是在协调节点接入层并没有做限制。

我们的优化方案是基于内存资源的漏桶策略。我们将节点 JVM 内存作为漏桶的资源,当内存资源足够的时候,请求可以正常处理,当内存使用量到达一定阈值的时候分区间阶梯式平滑限流。例如图中浅黄色的区间限制写入,深黄色的区间限制查询,底部红色部分作为预留 buffer,预留给处理中的请求、merge 等操作,以保证节点内存的安全性。

限流方案里面有一个挑战是,我们如何才能实现平滑限流?因为采用单一的阈值限流很容易出现请求抖动,例如请求一上来把内存打上去马上触发限流,而放开一点点请求又会涌进来把内存打上去。我们的方案是设置了高低限流阈值区间,在这个区间中,基于余弦变换实现请求数和内存资源之间的平滑限流。当内存资源足够的时候,请求通过率 100%,当内存到达限流区间逐步上升的时候,请求通过率随之逐步下降。而当内存使用量下降的时候,请求通过率也会逐步上升,不会一把放开。通过实际测试,平滑的区间限流能在高压力下保持稳定的写入性能。

我们基于内存资源的区间平滑限流策略是对原生版本基于请求数漏桶策略的有效补充,并且作用范围更广,覆盖协调节点、数据节点的接入层和传输层,并不会替代原生的限流方案。

接下来介绍单个大查询打挂节点的场景。例如我们在分析场景,做多层嵌套聚合,有时候请求返回的结果集比较大,那么这个时候极有可能这一个请求就会将节点打挂。我们对聚合查询流程进行分析,请求到达协调节点之后,会拆分为分片级子查询请求给目标分片所在数据节点进行子聚合,最后协调节点收集到完整的分片结果后进行归并、聚合、排序等操作。这里的主要问题点是,协调节点大量汇聚结果反序列化后内存膨胀,以及二次聚合产生新的结果集打爆内存。

针对上面单个大查询的问题,下面介绍我们的优化方案。优化方案的要点是内存膨胀预估加流式检查。 我们先来看下原生方案,原生版本是直接限制最大返回结果桶数,默认一万,超过则请求返回异常。这种方式面临的挑战是,在分析场景结果数十万、百万是常态,默认一万往往不够,调整不灵活,调大了内存可能还是会崩掉,小了又不能满足业务需求。

我们的优化方案主要分为两个阶段:

  • 第一阶段:在协调节点接收数据节点返回的响应结果反序列化之前做内存膨胀预估,基于接收到的网络 byte 流大小做膨胀预估,如果当前 JVM 内存使用量加上响应结果预估的使用量超过阈值则直接熔断请求。
  • 第二阶段:在协调节点 reduce 过程中,流式检查桶数,每增加固定数量的桶(默认 1024 个)检查一次内存,如果超限则直接熔断。流式检查的逻辑在数据节点子聚合的过程同样生效。

这样用户不再需要关心最大桶数,只要内存足够就能最大化地满足业务需求。不足之处是大请求还是被拒掉了,牺牲了用户的查询体验,但是我们可以通过官方已有的 batch reduce 的方式缓解,就是当有 100 个分片子结果的时候,每收到部分就先做一次聚合,这样能降低单次聚合的内存开销。上面流式聚合的整体方案已经提交给官方并合并了,将在最近的 7.7.0 版本中发布。

前面介绍了两种比较典型的用户常遇到的可用性问题。接下来对整个可用性优化做一个总结。

首先我们结合自研的优化方案和原生的方案实现了系统性的全链路限流。左图中黄色部分为自研优化,其它为原生方案。覆盖执行引擎层、传输层和接入层。另外我们对内存也做了相关的优化,内存利用率优化主要是针对写入场景,例如单条文档字段数过多上千个,每个字段值在写入过程中都会申请固定大小的 buffer,字段数过多的时候内存浪费严重,优化方案主要是实现弹性的内存 buffer。内存回收策略,这里不是指 GC 策略,主要是对于有些例如读写异常的请求及时进行内存回收。JVM GC 债务管理主要是评估 JVM Old GC 时常和正常工作时常的比例来衡量 JVM 的健康情况,特殊情况会重启 JVM 以防止长时间 hang 死。

可用性优化效果,我们将公有云的 ES 集群整体可用性提升至 4 个 9,内存利用率提升 30%,高压力场景稳定性有大幅提升,基本能保证节点不会 OOM,集群不会雪崩。

下面部分是我们可用性优化相关的 PR。除了前面介绍的协调节点流式检查和内存膨胀预估以外,还包括单个查询内存限制,这个也很有用,因为有些场景如果单个查询太大会影响其它所有的请求。以及滚动重启速度优化,大集群单个节点的重启时间从 10 分钟降至 1 分钟以内,这个优化在 7.5 版本已经被合并了。如果大家遇到大集群滚动重启效率问题可以关注。

接下来介绍性能优化。


性能优化的场景主要分为写入和查询。写入的代表场景包括日志、监控等海量时序数据场景,一般能达到千万级吞吐。带 id 的写入性能衰减一倍,因为先要查询记录是否存在。查询包含搜索场景和分析场景,搜索服务主要是高并发,低延时。聚合分析主要以大查询为主,内存、CPU 开销高。

我们看下性能的影响面,左半部分硬件资源和系统调优一般是用户可以直接掌控的,比如资源不够扩容,参数深度调优等。右半部分存储模型和执行计划涉及到内核优化,用户一般不容易直接调整。接下来我们重点介绍一下这两部分的优化。

首先是存储模型优化。我们知道 ES 底层 Lucene 是基于 LSM Tree 的数据文件。原生默认的合并策略是按文件大小相似性合并,默认一次固定合并 10 个文件,近似分层合并。这种合并方式的最大优点是合并高效,可以快速降低文件数;主要问题是数据不连续,这样会导致我们在查询的时候文件裁剪的能力很弱,比如查询最近一小时的数据,很有可能一小时的文件被分别合并到了几天前的文件中去了,导致需要遍历的文件增加了。

业内典型的解决数据连续性的合并策略,比如以 Cassandra、HBase 为代表的基于时间窗口的合并策略,优点是数据按时间序合并,查询高效,且可以支持表内 TTL;不足是限制只能是时序场景,而且文件大小可能不一致,从而影响合并效率。还有一类是以 LevelDB、RocksDB 为代表的分层合并,一层一组有序,每次抽取部分数据向下层合并,优点是查询高效,但是写放大比较严重,相同的数据可能会被多次合并,影响写入吞吐。

最后是我们的优化合并策略。我们的目标是为了提升数据连续性、收敛文件数量,提升文件的裁剪能力来提高查询性能。我们实现的策略主要是按时间序分层合并,每层文件之间按创建时间排序,除了第一层外,都按照时间序和目标大小进行合并,不固定每次合并文件数量,这样保证了合并的高效性。对于少量的未合并的文件以及冷分片文件,我们采用持续合并的策略,将超过默认五分钟不再写入的分片进行持续合并,并控制合并并发和范围,以降低合并开销。

通过对合并策略的优化,我们将搜索场景的查询性能提升了 40%。

前面介绍了底层文件的存储模型优化,我们再来向上层看看执行引擎的优化。

我们拿一个典型的场景来进行分析。ES 里面有一种聚合叫 Composite 聚合大家可能都比较了解,这个功能是在 6.5 版本正式 GA 发布的。它的目的是为了支持多字段的嵌套聚合,类似 MySQL 的 group by 多个字段;另外可以支持流式聚合,即以翻页的形式分批聚合结果。用法就像左边贴的查询时聚合操作下面指定 composite 关键字,并指定一次翻页的长度,和 group by 的字段列表。那么每次拿到的聚合结果会伴随着一个 after key 返回,下一次查询拿着这个 after key 就可以查询下一页的结果。

那么它的实现原理是怎样的呢?我们先来看看原生的方案。比如这里有两个字段的文档,field1 和 field2,第一列是文档 id 。我们按照这两个字段进行 composite 聚合,并设定一次翻页的 size 是 3。具体实现是利用一个固定 size 的大顶堆,size 就是翻页的长度,全量遍历一把所有文档迭代构建这个基于大顶堆的聚合结果,如右图中的 1 号序列所示,最后返回这个大顶堆并将堆顶作为 after key。第二次聚合的时候,同样的全量遍历一把文档,但会加上过滤条件排除不符合 after key 的文档,如右图中 2 号序列所示。

很显然这里面存在性能问题,因为每次拉取结果都需要全量遍历一遍所有文档,并未实现真正的翻页。接下来我们提出优化方案。

我们的优化方案主要是利用 index sorting 实现 after key 跳转以及提前结束(early termination)。 数据有序才能实现真正的流式聚合,index sorting 也是在 6.5 版本里面引入的,可以支持文档按指定字段排序。但遗憾的是聚合查询并没有利用数据有序性。我们可以进行优化,此时大顶堆我们仍然保留,我们只需要按照文档的顺序提取指定 size 的文档数即可马上返回,因为数据有序。下一次聚合的时候,我们可以直接根据请求携带的 after key 做跳转,直接跳转到指定位置继续向后遍历指定 size 的文档数即可返回。这样避免了每次翻页全量遍历,大幅提升查询性能。这里有一个挑战点,假设数据的顺序和用户查询的顺序不一致优化还能生效吗?实际可以的,逆序场景不能实现 after key 跳转因为 lucene 底层不能支持文档反向遍历,但提前结束的优化仍然生效,仍然可以大幅提升效率。这个优化方案我们是和官方研发协作开发的,因为我们在优化的同时,官方也在优化,但我们考虑的更全面覆盖了数据顺序和请求顺序不一致的优化场景,最终我们和官方一起将方案进行了整合。该优化方案已经在 7.6 合并,大家可以试用体验。

前面从底层的存储模型到上层的执行引擎分别举例剖析了优化,实际上我们在性能层面还做了很多的优化。从底层的存储模型到执行引擎,到优化器,到上层的缓存策略基本都有覆盖。下图中左边是优化项,中间是优化效果,右边是有代表性的优化的 PR 列表。

这里简单再介绍一下其它的 PR 优化,中间这个 translog 刷新过程中锁的粗化优化能将整体写入性能提升 20%;这个 lucene 层面的文件裁剪优化,它能将带 id 写入场景性能提升一倍,当然查询也是,因为带 id 的写入需要先根据 id 查询文档是否存在,它的优化主要是在根据 id 准备遍历查询一个 segment 文件的时候,能快速根据这个 segment 所统计的最大最小值进行裁剪,如果不在范围则快速裁剪跳过,避免遍历文档;最下面的一个 PR 是缓存策略的优化,能避免一些开销比较大的缓存,大幅的降低查询毛刺。

上面这些性能优化项在我们腾讯云的 ES 版本中均有合入,大家可以试用体验。

接下来我们再看成本优化。在日志、时序等大规模数据场景下,集群的 CPU、内存、磁盘的成本占比是 1 比 4 比 8。例如一般 16 核 64GB,2-5 TB 磁盘节点的成本占比大概是这个比例。因此成本的主要瓶颈在于磁盘和内存。

成本优化的主要目标是存储成本和内存成本,我们先来看下存储成本。

我们先来看一个场景,整个腾讯云监控是基于 ES 的,单个集群平均写入千万每秒,业务需要保留至少半年的数据供查询。我们按照这个吞吐来计算成本,1000 万 QPS 乘以时间乘以单条文档平均大小再乘以主从两个副本总共大约 14 PB 存储,大约需要 1500 台热机型物理机。这显然远远超出了业务成本预算,那我们如何才能既满足业务需求又能实现低成本呢?

来看下我们的优化方案,首先我们对业务数据访问频率进行调研,发现最近的数据访问频率较高,例如最近 5 分钟的,一小时的,一天的,几天的就比较少了,超过一个月的就更少了,历史数据偏向于统计分析。

首先我们可以通过冷热分离,把冷数据放到 HDD 来降低成本,同时利用官方提供的索引生命周期管理来搬迁数据,冷数据盘一般比较大我们还要利用多盘策略来提高吞吐和数据容灾能力。最后将超冷的数据冷备到腾讯云的对象存储 COS 上,冷备成本非常低,1GB 一个月才一毛多。

上面这些我们都可以从架构层面进行优化。是否还有其它优化点呢?基于前面分析的数据访问特征,历史数据偏向统计分析,我们提出了 Rollup 方案。Rollup 的目的是对历史数据降低精度,来大幅降低存储成本。我们通过预计算来释放原始细粒度的数据,例如秒级的数据聚合成小时级,小时级聚合成天级。这样对于用户查询时间较长的跨度报表方便展示,查询几天的秒级数据太细没法看。另外可以大幅降低存储成本,同时可以提升查询性能。

我们在 17 年的时候就实现了 Rollup 的方案并投入给了腾讯云监控使用,当然目前官方也出了 Rollup 方案,目前功能还在体验中。

下面介绍一下我们最新的 Rollup 方案的要点。

总体来说 Rollup 优化方案主要是基于流式聚合加查询剪枝结合分片级并发来实现其高效性。流式聚合和查询剪枝的优化我们前面在性能优化部分已经介绍了,我们新的 Rollup 也利用了这些优化,这里不再展开。下面介绍一下分片级并发,及并发自动控制策略。

正常的聚合查询,需要将请求发送给每个分片进行子聚合,在到协调节点做汇聚,两次聚合多路归并。我们通过给数据添加 routing 的方式让相同的对象落到相同的分片内,这样就只需要一层聚合,因为分片数据独立,多个数据对象可以实现分片级并发。 另外我们通过对 Rollup 任务资源预估,并感知集群的负载压力来自动控制并发度,这样对集群整体的影响能控制在一定的范围。右边的图是我们的优化效果,某个统计指标 30 天的存储量,天级的只需要 13 GB,小时级的只需要 250 GB,细粒度的会多一些,总体存储量下降了将近 10 倍。单个集群 150 台左右物理机即可搞定,成本缩减 10 倍。整体写入开销 rollup 资源消耗在 10% 以下。

前面是存储成本优化,下面介绍内存成本优化。

我们通过对线上集群进行分析,发现很多场景堆内内存使用率很高,而磁盘的使用率比较低。堆内存使用率为什么这么高呢?其中的 FST 即倒排索引占据了绝大部分堆内内存,而且这部分是常驻内存的。每 10 TB 的磁盘 FST 的内存消耗大概在 10 GB 到 15 GB 左右。

我们能不能对 FST 这种堆内占用比较大的内存做优化?我们的想法是把它移至堆外(off-heap),按需加载,提升堆内内存利用率,降低 GC 开销,提升单个节点管理磁盘的能力。

我们来看下 off-heap 相关的方案。首先原生版本目前也实现了 off-heap,方案是将 FST 对象放到 MMAP 中管理,这种方式实现简单,我们早期也采用了这种方式实现,但是由于 MMAP 属于 page cache 可能被系统回收掉,导致读盘操作,从而带来性能的 N 倍损耗,容易产生查询毛刺。

HBase 2.0 版本中也实现了 off-heap,在堆外建立了 cache,脱离系统缓存,但只是把数据放到堆外,索引仍然在堆内,而且淘汰策略完全依赖 LRU 策略,冷数据不能及时的清理。

我们的优化方案也是在堆外建立 cache,保证 FST 的空间不受系统影响,另外我们会实现更精准的淘汰策略,提高内存使用率,再加上多级 cache 的管理模式来提升性能。这种方式实现起来比较复杂但收益还是很明显的,下面我们来看一下详细的实现。

我们的方案是通过 LRU cache + 零拷贝 + 两级 cache 的方式实现的。首先 LRU cache 是建立在堆外,堆内有访问 FST 需求的时候从磁盘加载到 cache 中。由于 Lucene 默认的访问 FST 的方式是一个堆内的 buffer,前期我们采用了直接从堆外拷贝到堆内的 buffer 方式实现,压测发现查询性能损耗 20%,主要是堆外向堆内 copy 占了大头。

因此我们有了第二阶段优化,将 Lucene 访问 FST 的方式进行了改造,buffer 里面不直接存放 FST,而存放堆外对象的一个指针,这样实现了堆内和堆外之间的零拷贝,这里的零拷贝和我们说的 linux 中的用户态和内核态的零拷贝是两个概念。这样实现后我们压测发现查询性能还是有 7%的损耗,相较于堆内的 FST 场景。我们有没办法做到极致呢?

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java