欢迎关注 猩猩程序员 公众号
随着Datadog的持续扩展,我们摄取和存储的指标的数量、复杂性和基数都在以数量级的速度稳步增长。这种增长推动了我们核心时序数据库的边界——这个内部系统负责存储原始指标数据并实时为客户查询提供服务。与任何面临流量增长的系统一样,随着时间的推移,我们遇到了新的性能挑战,特别是在高基数工作负载、日益复杂的查询和突发流量模式下。
设计一个新的存储引擎从来都不是一个轻率的决定。这是一项极其复杂的工作,对性能、可靠性和运营风险都有深远的影响。因此,我们总是在构建下一代系统的同时,尽可能地推动现有系统的极限。但随着数据量和客户需求的不断增长,即使是这些优化也只能带我们走这么远。我们总是需要重新构想我们的架构——从根本上重新思考摄取、存储和查询执行,以跟上我们的增长步伐。接下来描述的是一个始于15年前的系列中的第6代系统。
结果是一个新系统——一个专门用Rust构建的实时时序数据库,专为高吞吐量和低延迟而设计。在这篇文章中,我们将介绍我们如何设计它,我们在过程中做出的工程决策,以及我们如何在峰值规模下实现60倍的摄取性能提升和5倍的查询速度提升。
Datadog指标平台概述
Datadog的指标平台由多个子系统组成——包括摄取和丰富管道、实时和长期存储,以及查询和告警服务。我们在之前的博客文章中介绍了数据流和查询语义。下图概述了高级架构;每个子系统都很复杂,都有自己的性能约束和优化权衡。在这篇文章中,我们将专注于实时存储——特别是时序存储引擎。
指标平台的高级概述。
我们时序存储引擎设计的一个关键方面是,其实时存储被分为两个独立部署的服务:
RTDB:一个实时时序数据库,将原始指标数据存储为<timeseries_id, timestamp, value>元组,进行聚合,并提供最新的指标数据 索引数据库:一个管理指标标识符及其标签的数据库,将它们存储为<timeseries_id, tags>元组
在上游,存储路由器根据负载将传入的指标分布到RTDB节点上。在下游,指标查询服务使用路由器的信息连接到适当的RTDB节点和索引节点,从每个节点获取结果,并将它们组合起来。这种架构如下图所示:
指标存储架构的高级概述。
当查询到达RTDB节点时,它以组列表的形式出现。每个组指定一组时序ID,使用与整体查询相同的时间范围和聚合设置将其聚合为单个输出时序。RTDB节点查找这些键的所有相关时序,在时间范围内执行请求的分组聚合,并将结果返回给指标查询服务。
如下图所示,RTDB节点是一个复杂的系统,由几个关键组件协同工作组成:
- 用于摄取传入数据的摄取子系统
- 用于写入和读取数据的存储引擎
- 用于数据持久性的快照模块
- 用于检索数据的查询(GRPC)执行层
- 用于在负载下管理资源使用的节流器
这些组件在节点内的共享控制平面下运行,该控制平面协调它们的交互并提供管理接口。
实时时序数据库(RTDB)节点的高级概述。
接下来,我们将介绍我们的存储引擎是如何演进的,以及这些部分是如何在当前系统中结合在一起的。
我们如何构建第6代实时指标存储
第1代:Cassandra用于快速写入,查询灵活性有限
我们的第一代系统建立在Cassandra之上,受到OpenTSDB等系统和其他运行在HBase上的系统的启发,这在当时被广泛采用。它为我们提供了强大的写入可扩展性和熟悉的操作模型,但很快就清楚地表明,该系统无法支持我们在告警和分析方面所需的实时查询的广度或复杂性。它在高效返回大型数据集方面也存在困难。
这些限制导致了我们基于Redis构建的第二代系统。
第2代:Redis用于快速读取和操作控制
Redis带来了重大进步:它快速、灵活且易于理解,为我们服务了几年。但它带来了工程权衡。我们选择不依赖Redis的内置集群来保证可靠性,因此我们必须自己管理许多独立的实例。Redis的单线程特性也限制了我们在为实时流量提供服务的同时对数据进行快照以确保持久性的能力。
我们还遇到了罕见但严重的故障模式,通常与负载下的内存管理和线程相关。此外,持续的(反)序列化和跨边界通信引入了成本和性能低效——由于Redis没有针对内存布局、磁盘I/O或CPU使用进行优化,这些操作在规模上变得越来越昂贵。
尽管如此,Redis为我们提供了宝贵的操作可见性,并塑造了我们对专用系统所需内容的理解——一个具有更紧密集成、对I/O的完全控制以及整个堆栈更好效率的系统。
第3代:MDBM用于高效的内存映射I/O
我们的下一个存储引擎建立在MDBM之上,这是一个内存映射的键值存储。这种设计通过mmap利用操作系统的页面缓存按需加载数据库页面,有效地将磁盘上的数据视为内存中的结构。这种方法通过简化我们的存储交互在早期为我们提供了良好的服务。然而,随着我们的使用变得更加密集,它开始在读取和写入方面都遇到性能限制。
已发表的研究表明,在数据库系统中依赖内存映射I/O可能会引入微妙的性能和正确性问题。事实上,许多最初使用mmap的系统在遇到这些挑战后最终都转向了显式管理I/O。我们的经验是相似的:尽管MDBM的简单性是一个优势,但其性能无法扩展以满足我们最大的工作负载。
第4代:基于Go的B+树用于可扩展性能
为了满足我们最大工作负载的需求,我们用Go编写的定制B+树存储引擎替换了MDBM。这是我们向每核心线程执行模型迈出的第一步,Go的调度器对此提供了相当好的支持。这一转变在吞吐量和延迟方面都带来了重大的性能改进,并为我们提供了一个基础,随着需求的发展,我们可以更积极地进行优化。
第5代:引入分布指标和RocksDB
与此同时,我们引入了DDSketch数据类型来支持分布指标,允许客户准确估计百分位数。然而,我们基于Go的引擎针对标量浮点数进行了优化,不容易扩展到这种新的数据类型。为了高效支持sketches,我们集成了RocksDB作为DDSketch数据的存储引擎,利用其灵活性和高性能。
为什么我们需要统一引擎
随着时间的推移,我们发现自己运行着两个并行的时序数据库:一个用于标量指标,另一个用于分布指标。虽然两个系统都单独工作得很好,但它们带来了额外的复杂性、重复的工作和不同的性能特征。到那时,我们已经获得了足够的两个系统的操作经验来定义更清晰的需求集合——以及将它们统一到一个更有能力的平台中的信心。
第6代:统一的基于Rust的引擎,用于吞吐量、简单性和规模
为了整合这些系统,我们从头开始用Rust构建了一个新的时序数据库。这种语言为我们提供了低级控制和性能,而不牺牲安全性,我们已经在基础设施的其他部分看到了Rust的强劲结果。
一个次要目标是模块化:我们希望将新系统构建为可重用的组件,其他团队可以在Datadog中使用。
在其核心,我们的新RTDB采用日志结构合并树(LSM树)架构。LSM树非常适合像我们这样的写入密集型工作负载——它们在内存中缓冲写入,并定期将排序文件刷新到磁盘。
我们还采用了早期分片,不仅用于存储,而且贯穿整个系统。每个数据单元——由时序键标识——被分配给一个分片,这是一个负责键空间一部分的自包含单元。我们使用简单的哈希方案——例如,取键的一部分位——来一致地将键映射到分片。这种设计影响一切:
- 摄取管道是分片的:我们并行地按分区摄取和处理数据。
- 缓存是分片的:每个分片管理自己的热数据缓存。
- 存储文件:每个分片写入自己的文件。
- 查询处理是分片的:每个分片可以并行执行其时序子集的查询,然后聚合部分结果。
为什么跨层分片很重要
这个级别的分片确保了所有CPU核心的均匀负载分布,保持摄取和查询流量的平衡,并在重负载下导致更一致的性能。高基数数据自然分散,避免热点。在每个分片内,更简单的并发模型消除了跨线程同步,减少了复杂性和潜在的瓶颈。
每个分片几乎像单租户系统一样运行。这种设计的一个有趣的副作用是查询模式影响并行性。跨越许多时序的广泛查询利用所有分片并提高吞吐量,而狭窄的查询只针对少数分片。由于均匀分布,即使在最坏情况下,争用仍然很小。
为高吞吐量摄取而设计
在我们的部署中,我们在专用CPU核心上运行存储分片。例如,在32核机器上,我们可能将24个核心专用于存储分片,为其他任务(如数据摄取和查询协调)保留其余核心。
如下图所示,RTDB的摄取管道使用Tokio实现异步Rust任务。我们生成N个摄取工作器(每个Kafka分区一个)和M个存储分片(通常每个核心一个)。每个摄取工作器持续读取传入数据流,并使用消息传递将这些指标路由到适当的存储分片。
实时时序数据库(RTDB)摄取管道。
摄取和存储任务之间的通信通过一组MPSC(多生产者,单消费者)通道进行——我们使用tachyonix——每个存储分片一个通道。每个摄取任务都可以发送到每个存储任务的通道,但一旦数据被发送,分片就拥有它。
在我们的典型配置中,存储分片的数量大于摄取工作器的数量。这是因为分片是基于硬件并行性选择的(例如,每个CPU核心一个),而摄取工作器取决于我们消费的Kafka分区数量。
这种每核心分片、异步工作器模型是RTDB的基础。它为我们提供了分区并发:每个核心独立处理一部分数据。在写入数据的关键路径上不需要锁或原子操作。因此,我们避免了围绕并发控制的大量复杂性。
我们依靠协作调度来确保公平性。如果存储任务运行时间过长,它会定期让出,以便其他任务可以运行,这防止任何单个任务饿死其余任务。这种方法还使系统其他部分的逻辑更简单,正如我们将在存储引擎内部看到的那样。
重负载下的节流
即使有完美的分片,流量激增或昂贵的查询也可能使节点过载。为了保持系统稳定,RTDB采用基于许可和基于调度的节流器。
基于许可的节流
基于许可的节流器通过立即拒绝或减慢会危及稳定性的工作来充当看门人。我们跟踪几个条件,例如:
- 摄取延迟(如果节点落后太远)
- 内存使用
- 太多并发查询
如果超过任何阈值,我们相应地丢弃或拒绝负载。
基于成本的查询节流
我们还实现了基于成本的查询节流器,这是一种基于调度的机制。成本节流器不是直接拒绝查询,而是将传入查询排队,并根据优先级和估计成本(例如,要扫描的数据大小)调度它们的执行。该系统使用CoDel(受控延迟)算法来管理延迟,根据当前负载和排队延迟在FIFO(先进先出)和LIFO(后进先出)调度之间切换。
这种动态调度确保高优先级或快速查询仍然能够通过,昂贵的查询不会占用所有资源。
用于持久存储的快照
另一个重要组件是快照系统,它为RTDB提供备份和恢复功能。快照定期捕获数据库状态的时间点副本。我们使用快照进行操作备份,并在节点死亡时进行快速恢复。如果需要恢复或克隆节点,我们可以获取最新快照并将其加载到新实例上,而不是从头开始重放整个指标积压。这个系统对于数据持久性和快速灾难恢复至关重要——它让我们能够在几分钟内恢复大量时序数据,这在我们运营的规模下是必不可少的。
介绍Monocle:高性能时序存储引擎
新时序数据库的核心是Monocle,我们用Rust构建的定制存储引擎。Monocle被设计为一个独立的库,可以被多个系统使用。
在底层,Monocle使用LSM树架构并遵循每分片工作器设计:每个存储工作器(分片)管理自己的LSM树实例,摄取数据,服务查询,并处理后台任务(如压缩)。每个存储工作器在单线程Tokio调度器上运行。
最近的写入被缓冲在内存结构(memtable)中,而频繁访问的数据从内存缓存中提供服务。在磁盘上,数据以类似于静态排序表的自定义文件格式存储,为高效的基于时间的查找进行组织和索引。后台压缩过程随着时间的推移合并和重组这些文件,以保持读取性能和存储空间的优化。
Monocle的内部组件。
Monocle不仅支持简单的标量指标,还支持分布数据类型(如DDSketch),甚至更复杂的对象(如HyperLogLog计数器)。
现在,让我们放大Monocle设计的几个有趣方面:memtable、系列缓存和压缩策略。
我们如何通过memtables简化写入路径
在典型的LSM中——包括RocksDB——传入的写入进入memtable,一个内存缓冲区。memtable通常是一个支持新写入和读取的有序结构。当memtable填满或老化时,它变为只读并异步刷新到磁盘。一旦刷新完成,该memtable的内存被回收,新的写入进入新的memtable。
在我们的系统中,我们通过完全避免跨线程争用来简化写入路径。每个工作线程管理自己的私有memtable——没有其他线程写入它。这种每线程分片模型消除了同步的需要——没有锁,没有原子操作,也没有要管理的指针密集型数据结构。
当memtable填满时,拥有线程独立地密封并将其刷新到磁盘。这种协作调度确保刷新不会垄断CPU资源,其他分片继续不受影响地运行。因为只有一个线程曾经接触给定的memtable,我们得到强排序保证,没有部分写入的并发读取风险。
我们还广泛压缩生成的LSM树文件以减少存储需求。我们的初始设计受到Gorilla(Facebook的内存时序数据库)的启发。后来,我们采用了基于SIMD(单指令,多数据)的编解码器,这减少了文件大小并大幅提高了压缩和解压缩性能。
通过统一系列缓存减少查询延迟
时序存储中的另一个挑战是单个时序的数据可能最终分散在许多文件中。读取时间范围可能需要接触多个文件,这会产生大量I/O和CPU开销来合并结果。为了缓解这个问题,我们实现了一个统一的系列缓存,位于存储引擎文件之上(如下图所示)。
传统上,系统可能依赖OS页面缓存或文件级别的块缓存。然而,朴素的每文件缓存无法理解更高级别的模式——例如两个相邻文件可能都包含同一时序的点——并且可能导致冗余数据被拉入内存。它还使驱逐复杂化——有用的数据可能仅仅因为分散在多个文件中而被删除,它们之间没有协调。
我们的系列缓存通过按时序缓存数据采用更全面的方法。我们在侵入式链表数据结构中为每个时序组织缓存点。侵入式链表是一种链表类型,其中节点直接嵌入在被存储的对象中,而不是将这些对象包装在单独的列表节点结构中。每个这样的节点存储该时间片内该系列的所有点——加上一些元数据。
该设计基于两个关键观察:大多数查询针对最近的数据,大多数存储的数据从未被查询。所以我们希望我们的缓存优先考虑新鲜度和相关性。我们按时间分段,以便我们可以独立驱逐系列的旧段,我们在每个时间窗口内使用LRU(最近最少使用)策略来保持最近访问的数据在内存中。在实践中,这意味着许多活跃系列的最近几小时数据可能被缓存,但较旧的数据只有在被积极查询时才会被缓存。
统一系列缓存的工作原理。
系列缓存以两种方式填充。
按需:当查询读取时间范围时,它从磁盘拉取的任何数据都会插入到缓存中。 主动:当memtable刷新新数据时,我们立即将这些新数据点填充到缓存中——仅适用于已经缓存的时序。
这种方法对于频繁访问最近数据的工作负载特别有益。这样,最近的点在内存中可用,无需初始查询未命中。我们还在刷新期间仔细协调缓存更新,以避免与并发读取的冲突——确保我们既不重复插入也不覆盖缓存中的数据。
在服务查询时,如果请求的数据在缓存中,我们从内存返回它。如果某些部分未缓存,我们为该部分访问磁盘。我们的查询引擎使用批处理流接口来平滑地合并缓存数据和磁盘数据。它可以遍历时间范围,根据需要从缓存和磁盘文件中提取数据,而无需将所有内容一次加载到内存中。如果多个查询同时请求相同的数据,我们的系统将同步,以便只有一个线程从磁盘加载缺失的数据——其他线程等待并重用结果。
这种系列缓存设计显著减少了查询延迟和I/O负载。通过缓存整个时序段,我们避免了旧设计中存在的大量重复合并工作和磁盘读取。它还为未来的增强奠定了基础,例如在内存中压缩缓存数据以提高效率,或根据查询模式调整缓存策略——例如,为某些高优先级指标保持缓存热度。统一缓存为我们提供了一个实施此类策略的中心位置。
通过分层压缩提高性能
与大多数基于LSM树的数据库一样,RTDB必须持续压缩磁盘文件以保持性能。压缩合并多个文件,丢弃被覆盖或删除的数据,并防止文件数量无限增长。适当的压缩提高了读取性能和空间效率。
大多数系统——如RocksDB——使用分级压缩,其中每个级别包含具有非重叠键范围的文件。这意味着任何读取都必须咨询O(#级别)。分级压缩保持读取放大较低——每个键在每个级别最多只会驻留在一个文件中——但它会产生许多频繁的合并操作来维护这些非重叠不变量。
我们采用了不同的方法:分层压缩。在这个模型中,我们允许层内键范围的一些重叠。每个层可以累积几个文件。当层中的文件数量超过阈值时,该层中的所有文件都会被合并,结果被推送到下一层。这样,我们不会在文件重叠时立即持续合并文件——我们批量合并,频率较低。
分层压缩显著减少了重写入负载的写放大和CPU开销,因为与分级方案相比,我们执行的总压缩操作更少。它以潜在增加的读放大为代价支持写吞吐量,因为在查询时,您可能必须检查多个重叠文件。有关压缩设计空间的详细分析,请阅读《构建和分析LSM压缩设计空间》。
为了解决该权衡的读取方面,RTDB利用指标的面向时间特性:
- 我们在内存中存储每个文件的最小和最大时间戳,允许我们在查询期间跳过不相关的文件。
- 我们在每个文件上使用内存概率过滤器(如布隆过滤器)来快速测试文件是否可能包含系列键。
这些技术减少了我们必须为任何给定查询打开和读取的文件数量。
这种压缩设计很好地适合我们的使用模式:大多数查询命中最近的数据,我们的写入在许多系列中大致均匀。因此,拥有一些重叠的历史文件不是大问题——我们获得更快的刷新和更简单的压缩逻辑。
在实践中,每个分片最终每层有少数文件,这些文件只是偶尔合并。由于基于时间的修剪和过滤器,查询很少为重叠付出性能代价。通过倾向于指标数据的时间局部性,RTDB将本来可能是通用权衡的东西转变为我们工作负载的性能优势。
通过共享基数树缓冲区减少聚合开销
当RTDB节点接收到查询时,它通常必须将来自许多时序的数据聚合为一个或多个输出。每个查询定义时序ID组,这些组(在查询的时间范围和聚合设置上)被组合以产生每组单个输出时序。在Datadog的规模下高效执行这些分组聚合需要快速处理和仔细的内存使用。我们原始的RTDB设计试图通过为每个工作线程提供自己的聚合缓冲区来满足这一需求,从而避免任何跨线程同步。
然而,这种每工作器缓冲区方法对于像DDSketch这样的分布指标会崩溃,DDSketch每个时序可以使用多达65,536个bin(64位计数器)。在许多工作器之间复制这些bin很快变得不切实际。例如,如果30个工作器每个聚合100个时序——每个系列跨越24小时,每分钟一个DDSketch——旧设计总共会消耗大约2,100 GiB的内存,这显然是不可行的。
为了克服这些问题,我们构建了一个单一的共享聚合缓冲区,遵循几个关键原则:
线程安全的共享缓冲区:所有工作器现在聚合到一个共享缓冲区中,我们通过最小锁定使其线程安全。因为每个时序包含许多点,不同线程很少需要同时更新同一系列中完全相同的点。我们通过将八个连续点分组到由单个互斥锁保护的缓存行对齐块中来利用这种模式。这种细粒度锁定将争用限制在每个小块内,并允许并行更新,同步开销很小。
内联小DDSketch bins:生产数据显示大约75%的DDSketch实例有四个或更少的bin。我们通过直接在sketch的摘要结构内存储多达四个bin计数来优化这种常见情况。这避免了大多数sketch的堆分配,使小sketch的聚合更快、更节省内存。
基数树聚合缓冲区:每个sketch存储多达65,536个bin呈现了速度和空间之间的权衡。一个选择是65,536个计数器的固定数组,它提供常数时间访问,但浪费大量内存。另一个选择是只保留非空bin的列表(bin索引和计数的对),它使用与bin数量成比例的内存,但会产生动态分配和查找的开销。我们的分析显示bin使用通常是稀疏的,并且在某些范围内聚集。基于此,我们实现了一个四级基数树(如下图所示)——受Linux内核虚拟内存映射的启发——每级扇出为16。这种结构提供接近数组的查找速度,同时只为实际存在的bin分配内存,实现了高性能和高效的内存使用。
将bins压缩到u32s:DDSketch bin计数是64位整数(u64),但在实践中大多数计数从未接近32位限制。我们通过在聚合缓冲区中将计数存储为u32值来节省内存,只有在它们溢出u32范围时才将它们提升为64位。Rust的内置溢出检查使得检测何时需要升级到更大计数器变得简单。
共享基数树聚合缓冲区的工作原理。
结果,共享基数树缓冲区将聚合内存使用量减少了数个数量级,并将吞吐量提高了约70%。
生产中的变化:性能和推出
经过广泛测试后,我们在生产中为分布指标推出了RTDB。结果是立竿见影的:
- 成本效率提高2倍
- 高基数查询速度提高5倍
- 摄取速度提高60倍
过去需要几分钟的极端查询异常值现在是例行公事,在几秒钟内返回结果。实时摄取管道现在可以以显著更多的余量吸收流量峰值。
我们通过在传统存储引擎旁边运行新的RTDB来执行仔细的迁移,以验证正确性和性能。在迁移期间,上游服务事件导致大量流量激增被定向到数据库。旧引擎努力跟上,但RTDB能够吸收整个激增并服务所有查询而没有任何延迟。这不仅让我们对RTDB的原始性能有信心,也对其在突然负载峰值下的弹性有信心。
为重用而构建的系统
Rust重写和我们模块化设计的另一个重大胜利是组件在Datadog中的更广泛采用。我们将Monocle及其支持组件设计为可重用模块。因此,摄取管道已经在另一个指标摄取服务中重新利用,快照模块现在由多个内部数据存储系统共享。
这种重用验证了方法:构建一个强大的组件一次,在许多上下文中使用它。它减少了重复工作,提高了一致性,并增加了公司范围的可靠性。
展望未来:更智能的路由和集成索引
故事并没有在这里结束。随着Datadog的发展,我们指标基础设施的需求也在增长。数据量和指标基数正在大幅增加,我们客户的规模和查询的复杂性也是如此。
我们正在积极改进的一个领域是查询负载平衡。今天,我们使用受Google的Slicer启发的系统在存储节点之间分布指标。它在稳定状态下工作良好,但仍然依赖一些静态分片分配。我们现在正在转向更动态的查询负载平衡系统,这将允许指标路由自动适应突然的峰值或变化。这将进一步提高系统在突发、大容量流量下的弹性。
我们还在重新思考指标平台中索引和时序存储之间的分离。从一开始,我们就分离了存储什么(时序值)和如何找到它(指标名称和标签的索引)的关注点。这在当时是正确的权衡——从单个数据库处理高基数时序数据是困难的。关键问题一直是:你索引什么,你留下什么不索引以保持系统可扩展?
我们现在正在重新审视这个选择。新技术和更高效的数据结构已经出现,我们在问:我们能统一这些系统吗?统一的方法可以简化架构并提高可维护性。
在Rust中构建我们的时序数据库并为高基数规模重新架构它在可靠性、性能和可维护性方面都得到了回报。同样重要的是,我们以模块化的方式构建系统,使其他团队和用例受益。
我们的旅程表明,通过仔细的设计和对工作负载的清晰理解,您可以获得两全其美:快速写入和快速读取,丰富的功能和简单性。我们对下一步感到兴奋——随着我们的客户扩展和发展,使平台更加自适应和统一。请继续关注我们继续发展Datadog的指标平台。
如果这种类型的工作让您感兴趣,请考虑申请加入我们的工程团队。我们正在招聘!
致谢
这篇文章的存在离不开Datadog工程团队的集体专业知识和协作。虽然列出的作者起草了最终草稿,但许多其他人在塑造技术方向、贡献代码和推动实施方面发挥了关键作用。
我们还要感谢多年来工程师们的贡献,他们的基础工作使这成为可能,包括:Alexandra Bueno、Christian Damianidis、Clement Tsang、Colin Nichols、David Kwon、Eli Hunter、Evan Bender、Hippolyte Barraud、Jason Moiron、Jun Yuan、Kol Crooks、Laxmi Sravya Rudraraju、Mathias Thomas、Matt Perpick、Nicole Danuwidjaja、Pawel Knap、Ram Kaul、Shang Wang、Shijin Kong和Xiangyu Liu。