背景
Apache HoraeDB 是蚂蚁集团针对高基数时序数据场景设计并优化的开源时序数据库,后捐献给 Apache 软件基金会。它专门针对需要处理大量时间序列数据的应用场景,如物联网(IoT)、应用性能监控(APM)和金融交易监控等。
在传统的时序数据库中,标签列(InfluxDB 称之为 Tag,Prometheus 称之为 Label)通常通过生成倒排索引来建立索引。然而,人们发现在不同场景中标签的基数变化很大。在某些场景中,标签的基数非常高,存储和检索倒排索引的成本非常高昂。另一方面,分析型数据库常用的扫描加剪枝方法被观察到能够有效处理这些场景。HoraeDB 的基本设计理念是采用混合存储格式和相应的查询方法,以更好地处理时序和分析工作负载的性能。其特点包括高性能的写入和查询能力,尤其在高基数数据环境下表现突出。
本文详细阐述了 HoraeDB 的研发背景、核心设计理念、以及在蚂蚁集团内部落地过程中对查询性能所做的优化工作。
一、主流数据库高基数场景下存在哪些核心问题?
1.1 一些相关概念
时序数据,简单来说,就是基于时间的一系列数据点的集合。在坐标轴中,我们可以将这些数据点按照时间顺序连成一条线,从而形成一条时间序列。
1.1.1 图 - 两条折线分别代表了两台服务器的负载指标
例如图中,这些线条本身并没有太多的描述性,为了区分不同的数据序列,我们通常会使用标签(tag)来标识它们。比如,每条线都有两个标签,分别是 host 和 cluster,通过这些标签,我们可以精确地定位到具体的数据序列。
实际上,时序数据的应用场景是相当广泛的。比如,物联网(IoT)、应用性能管理(APM),以及天气预报、股票市场分析等,这些领域都在广泛地应用时序数据。
时间线可以被理解为一个标签的组合。在底层存储时,时间线扮演了重要的角色。由于时序数据产生的量通常很大,我们会将具有相同时间线的数据聚集在一起,这样便于进行数据压缩和存储。通过将相同时间线的数据放在一起,我们可以快速检索到一条线的所有数据,这大大提高了数据检索的效率。
1.1.2 图 - 时间线示例图
我们可以看到图中左侧展示了带有3个标签的时间线。而右侧则是这些时间线的分布情况。每一个Key代表的是不同的时间线,右侧则展示了每条时间线所对应的数据集。在实际应用中,我们通常会将一个小时内的相同时间线数据汇总到一起,以实现较高的压缩比。
倒排索引是一种高效的检索技术,它允许用户根据输入的条件快速定位到对应的时间线。
例如,如果用户输入了两个标签 metric 和 IP,倒排索引可以帮助我们快速找到所有匹配的时间线。这种技术在搜索引擎中非常常见,而在时序数据库中也有其特定的应用。
为了方便理解倒排索引的逻辑,这里介绍了一个包含两个标签的倒排结构。倒排索引本质上是一个双层映射结构:第一层映射的Key是标签名称,如IP地址或环境名称,对应的value是具体的标签值,例如某服务器的IP。第二层映射则将每个IP关联到一个时间线列表,记录相关事件或数据点。这样的结构允许我们快速定位到特定IP对应的时间线,从而高效地进行数据检索。
1.2 存在的问题
在业界,我们通常所说的“高基数问题”主要指的就是这两个方面的问题:写入性能不佳和查询性能不高效。
在云原生环境中,每一次 Pod 的创建与销毁都可能导致 IP 地址的变化,这会导致时间线的数量级急剧增加,可能达到百万甚至千万级别。这样的高基数不仅会导致倒排索引的体积变得庞大,而且在写入和查询方面都会带来显著的性能问题。写入时,索引的膨胀会降低实时吞吐量;查询时,由于查询可能命中大量时间线,导致需要执行多次 IO 操作,这会严重影响查询效率。
例如上图,用红色线条表示了一个查询所要检索的时间线,图中命中了4条不同的时间线,意味着查询需要对这4条时间线分别执行四次独立的IO操作,如果一个查询命中了数百万条时间线,那么它需要执行的IO操作数量将是巨大的,这样的查询效率无疑是非常低下的。
除了高基数问题,现有的数据库分布式方案也存在不足。许多时序数据库本质上是单机版,面对大数据量和高负载时,缺乏成熟的分布式解决方案或者需要额外付费购买。
例如,某些著名的数据库系统,其分布式版本是商业化的,需要购买才能使用。而对于像 Prometheus 这样的数据库,尽管提供了分布式方案,但在分布式环境下,数据检索和计算下推的效率并不理想,这限制了查询性能。
因此,HoraeDB 的设计初衷就是为了解决这两个核心问题:高基数下的性能退化和分布式方案的不完善。在接下来的分享中,我将详细介绍 HoraeDB 是如何通过其核心设计来应对这些挑战的。
二、应对上述问题,HoraeDB有哪些核心设计?
2.1 高基数解决方案
在高基数场景下,一个庞大的倒排索引往往会给系统带来巨大的开销。面对这一挑战,我们采取了一种直接而有效的策略:去除倒排索引,并探索其他手段以实现高效的数据检索。值得注意的是,业界已经存在一些采用类似策略的解决方案。
具体来说,我们采用了一种结合列式存储和高效scan、多级剪枝的流程。这种方法的本质在于,既然倒排索引的构建成本如此之高,我们便放弃了传统的倒排索引,转而使用基于概率的索引结构来进行高效的数据过滤。
2.1 图 - 查询定位到所需数据的示意图
在这一过程中,我们依赖的是一些如最大值、最小值(max/min)或布隆过滤器(bloomfilter)的索引。这些索引让我们能够快速响应查询请求,通过它们,我们可以迅速定位到所需的数据。
对于时序数据而言,最常见的两个查询条件是数据的起始时间和终止时间。因此,我们对数据进行了基于天的分层排列,通过时间戳,我们可以快速过滤掉不在这个时间范围内的数据。进一步地,根据查询中的其他筛选条件,比如IP地址,结合数据块内记录的最大值和最小值,我们可以更精确地筛选出符合条件的数据。例如,如果查询条件指定了IP地址尾号为1的机器,而我们已经记录了IP地址的最大值和最小值,系统就可以迅速排除不包含该条件的数据块,直接定位到符合条件的数据,从而实现了高效的数据过滤。这种基于列式存储的解决方案,在传统的非关系型数据库中已被广泛采用,而在 HoraeDB 中,我们也采用了类似的策略。
2.2 分布式方案
在设计之初,我们就完全采用了云原生的架构,所有的组件都支持水平扩展。在 HoraeDB Engine 这一主要架构中,它负责处理用户的读写查询操作。这些查询最终会落到底层的存储上,而我们采用的是业界广泛使用的、支持水平扩展的对象存储,如阿里云的 OSS 或 AWS 的 S3。
在分布式集群中,每个 HoraeDB 实例都是独立且独享的,我们采用了 share-nothing 架构,每个实例仅处理它当前负责的数据。当用户的数据量增长时,我们可以动态地增加 HoraeDB 实例,实现水平扩容,来应对数据量的增长。
此外,我们还利用了基于 Raft 协议的 ETCD 来记录表的路由信息,确保数据的高可用性。通过这种方式,HoraeDB 的整个架构,从底层存储到上层的计算节点,都具备了水平扩展的能力,有效解决了分布式存储和计算中的挑战。
三、HoraeDB采用哪些策略优化查询性能?
在我们深入讨论 HoraeDB 的查询优化之前,让我们先来了解 HoraeDB 单机实例的读取路径。每个 HoraeDB 实例都构建在 LSM 系统上,它包含两个主要的内存组件:Memtable,用于承接用户的实时写入;以及 SST ,用于持久化 Memtable 中的数据。当 Memtable 中的数据达到一定阈值后,会 flush 到 SST 中。SST 还负责合并小文件,这一过程称为 compaction,是 LSM 树架构中的典型特性。由于数据同时存在于内存和磁盘中,用户的查询必然涉及这两部分。在后续的分享中,我将重点介绍我们是如何针对这两部分进行优化的。
3.1 优化思路
我们的优化思路可以概括为四个主要环节——
3.2 减少 IO
在 HoraeDB 的 LSM 系统中,Memtable 是用于承接实时写入的关键组件。由于写入操作相对频繁,Memtable 的设计优先考虑了写入效率,通常采用行存储结构,即数据按行顺序追加到 Memtable 中,以最小化写入成本。
然而,在读取操作中,通常不需要访问行中所有列的数据。用户查询可能只涉及100列中的10列,这就导致了读写模式之间的差异,以及在 Memtable 读取时,频繁地将行存储转换为列存储,这种转换对 CPU 的消耗可能成为系统性能的瓶颈。
为了解决这一问题,我们对 Memtable 进行了优化,实现了 Memtable 的分级。最新的数据段是可写的,采用行存储结构,用于承载最近的写入操作。当这个可读写的数据段达到一定的内存大小时,系统会自动将其转换为列存储格式,形成一个不可变的数据块。这样,只有当查询真正需要时,才进行数据格式的转换。
3.2.1 图2 - 读友好的 Memtable ,CPU 火焰图占比从12% 降到 2%
通过这种优化,我们减少了不必要的数据格式转换,直接利用列存储结构进行查询,显著降低了 CPU 的消耗。在一些机器上,我们观察到 CPU 消耗从 12% 降低到了 2% 以内,证明了这种优化的有效性。
SST 面临的问题本质上与 Map 类似,存在 IO 放大的问题,但放大的点有所不同。以一个查询为例,如果查询包含两个筛选条件,比如 IP 和 ENV,SST 中存储了大量数据,我们如何高效地筛选出所需的数据块呢?传统的解决方案依赖于概率性索引结构,如最大值、最小值和布隆过滤器,这些结构对数据的分布有特定要求。如果数据无序,筛选效果将大打折扣,可能导致需要扫描所有 SST,严重放大 IO 操作,进而影响查询性能。
那么,如何提高最大值、最小值和布隆过滤器的筛选效率?我们采取的优化思路是,在 HoraeDB 实例中,我们动态实时统计每张表的查询模式,包括查询频率和查询字段。基于这些统计信息,我们自动对表进行排序。例如,如果用户最常查询某个指标,我们就以该指标为排序键进行排序。这样的排序可以显著提升最大值、最小值和布隆过滤器的优化效果。
以查询尾号为“1”的 IP 为例,如果在 SST 的早期状态下,IP 地址是无序的,那么仅通过最大值和最小值是无法有效过滤数据块的。在下图中,左侧的两个数据块都包含尾号为“1”的 IP,因此无法进行数据库的过滤。
为了解决这个问题,我们在后台动态调整表的排序,比如按照 IP 地址进行排序。排序后,我们可以使用最大值和最小值快速定位到所需数据,而排除其他数据块。这种优化表顺序的方法在业界也是常用的,类似于 Snowflake 中的 Automatic Clustering 技术。这一优化手段在我们早期承接业务时发挥了重要作用。
优化实施前,用户的查询成功率大约只有 60%,即大部分查询因超时而失败。而通过这项特性的上线,用户的查询成功率大幅提升至 90% 以上,意味着大多数查询都能在用户期望的时间内得到及时响应。
3.3 增加缓存
在 HoraeDB 中,缓存是优化读取路径的关键组成部分。通过火焰图分析,我们发现最耗时的步骤是从远端对象存储(如 OSS)拉取数据,这一步骤涉及网络 IO,是明显的性能瓶颈。
数据从远端拉取回来后,接下来的瓶颈是解压操作。为了实现高效的数据存储和压缩比,我们采用了一些 CPU 密集型的解压手段。因此,在查询过程中,解压操作不可避免,且通常是 CPU 密集型的。
为了解决这些问题,我们采取了两个主要的缓存策略:
通过这些缓存策略的实施,我们显著提高了 HoraeDB 的查询性能,尤其是对于频繁访问的数据,大大减少了延迟,提升了用户体验。
3.4 提高并发
除了缓存优化,我们还面临另一个挑战:冷查询或首次查询的处理。这类查询通常不存在于本地磁盘或内存缓存中,因此我们需要其他策略来提升这类查询的性能。
3.4 图 - 未命中 cache 时(首查),IO 导致的性能问题仍然明显
为了解决这个问题,我们采用了提高单个查询并发性的方法。具体来说,我们优化了查询流程,将一次查询操作分配到不同的线程中。对于冷查询,网络 IO 通常是瓶颈,因为需要从远端拉取数据。因此,我们引入了预取机制,通过一个后台线程提前进行数据拉取,同时主线程负责 CPU 密集型的计算工作。这种线程隔离的方法可以避免 CPU 密集型任务影响 IO 密集型任务,从而提高整体查询效率。
此外,我们还实现了对 SST 文件的并发拉取。当系统判断用户需要拉取大量数据(例如 100 M)时,我们会将数据拆分成多个部分,并通过多个后台线程并行拉取。这种方法不仅提高了单个文件的拉取效率,也显著提升了冷查询的处理速度。
通过线程隔离和文件并发拉取这两个策略,我们显著提升了冷查询的处理能力,在线上业务引流过程中,查询性能提高了2到3倍。
3.5 优化分布式查询
上述提到的都是单机版的优化实践,下面重点分享一下真正的难点——分布式查询优化。
为了提升分布式查询性能,我们在 HoraeDB 中引入了分区表的概念,它允许将数据根据特定规则分散存储在多台机器上。目前,我们支持两种分区手段:基于特定标签(如通过哈希)的分区,以及随机(Random)分区策略。
3.5 图 - 一个分区表和其对应的物理子表
用户在初次接触随机分区的概念时,可能会感到疑惑:为什么随机分配的方式会比传统的分片方法更有效?实际上,这取决于具体的应用场景。随机分区特别适用于那些没有明显特性的指标,例如用户的行为追踪(trace)数据,这类数据通常不会表现出明显的热点问题。
如果按照某个标签进行分片,可能会在单个机器上产生热点,导致大量的请求集中在这台机器上。这会导致请求被过度拆分,例如,一个包含100行数据的查询可能会被拆分成100个独立的请求,分别路由到100个不同的表中。这种细小的请求碎片化会使得服务器需要处理大量的小请求,这对服务器来说是不利的,因为它降低了处理效率并可能影响性能。
相反,采用随机分区可以很好地避免这种极端情况的发生。通过随机分区,数据的分布更加均匀,避免了热点的产生,从而优化了数据的写入和查询过程。
挑战:
在 HoraeDB 的早期版本中,父表和子表被视为对等的物理资源表。由于 HoraeDB 采用 share-nothing 架构,表只能在特定的实例中打开,这导致了所有表的查询请求都会集中到一个节点上,从而形成了单机热点。即使子表可能分布在多个机器上,请求的入口点仍然成为瓶颈,因为所有读写请求都必须经过同一个节点。
3.5.1 图1 - 父表作为物理表,在固定节点打开造成单机热点
解决方案:
为了解决这个问题,我们在第一版的优化中引入了虚拟表的概念。我们将父表升级为虚拟表,这样它就可以在集群中的所有节点上打开,而不再是仅限于一个节点。这种设计允许集群中的任何机器来处理父表的读写请求,从而实现了负载均衡,并消除了单机瓶颈。
3.5.1 图2 - 父表作为虚拟表,在所有节点打开
挑战:
在分布式系统中,查询引擎必须将查询条件发送到各个子表,并在父节点汇总计算结果。这种做法很容易导致数据量过大,成为瓶颈,尤其是在处理大型表时,容易造成内存溢出(OOM)的情况,影响服务的稳定性。
解决方案:
为了应对这一挑战,我们采用了计算下推的策略。计算下推意味着将计算任务尽可能地在数据所在的位置执行,而不是将所有数据拉回到中心节点进行处理。例如,在执行求和(sum)操作时,如果子表中各有50万条记录,经过计算下推,最终可能只需要返回一条汇总记录。这种方法极大地减少了数据的移动,降低了网络IO,同时也减少了中心节点需要处理的数据量,从而提高了服务的稳定性。
3.5.2 图1 - 采用计算计算下推的策略降低网络IO
挑战:
在深入讨论分布式查询优化之前,我们需要理解 SQL 查询在传统数据库中的执行过程。这一过程大致分为三个阶段:
对于分区表而言,查询的执行会涉及多个子表。
解决方案:
为了优化这一过程,我们在查询生成阶段引入了计算下推的概念。这意味着,我们将尽可能多的计算任务下推到子表层面执行。例如,当用户对分区表执行带有聚合函数(如 sum)的查询时,系统会根据表分区的数量生成相应数量的子查询,每个子查询都具备计算能力,减少了数据在父表和子表之间的传输。
此外,我们不仅下推了数据,还包括了 Filter(过滤条件)和各种聚合算子,如 count、max、min、avg 等。这样的优化策略显著减少了父表和子表之间的数据传输量,提升了查询效率。
这种优化思路在业界已被广泛应用,许多知名的数据库系统如 Hbase 和 TiDB 都采用了类似的策略,尽管它们可能使用了不同的术语。在我们的实际生产环境中,这些优化措施显著提升了分布式查询的性能,实现了大约2到4倍的效率提升。
四、应用情况
HoraeDB 起初在蚂蚁集团内部孵化,并广泛应用于我们的主营业务中。它支撑着我们的内部监控平台,同时也服务于流计算任务和投资研究场景,帮助进行资产管理和优化。在金融领域,HoraeDB 结合 RMS 监控系统,为银行业务提供支持,展现出其在金融服务行业中的潜力和价值。
自 HoraeDB 开源以来,我们收到了社区广泛的好评和认可。许多社区用户主动接触我们,并在他们的生产环境中部署使用 HoraeDB。作为一个开源产品,所有相关代码均可在 GitHub 上找到。
传送门:
https://github.com/apache/incubator-horaedb
HoraeDB 诞生于蚂蚁集团,并在去年正式捐献给了 Apache 软件基金会。我们希望通过基金会的孵化,不仅能推动 HoraeDB 的进一步发展,也能消除大家对项目持续性的顾虑。即使原团队不再维护,项目也能在社区的支持下继续前进。(全文完)
Q&A:
1、时间线可以到亿级么?
2、缓存解压后的数据块,会不会导致cache利用率不够高?比如解压前可以缓存100个,解压后只能缓存10个了(压缩比为10)
3、动态扩容,新加入的实例怎么能够快速地接入、处理数据?
4、请问OSS拉取数据性能瓶颈是怎么解决的?
以上问题答案,欢迎点击“阅读全文”,观看完整版解答!
本文分享自 TakinTalks稳定性社区 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!