Uber 近日开源了已在内部使用多年的指标平台 —— M3 ,这是一个基于分布式时序数据库 M3DB 构建的度量平台,可每秒聚合 5 亿个指标,并且以每秒 2000 万笔的速度持续存储这些结果。
Uber 表示,为促进在全球的运营发展,他们需要能够在任何特定时间快速存储和访问后端系统上的数十亿个指标。一直到 2014 年底,Uber 的所有服务、基础设施和服务器都是将指标发送到基于 Graphite 的系统中,该系统将这些资料以 Whisper 档案格式储存到分片 Carbon 丛集。此外,还将 Grafana 用于仪表板,Nagios 用于告警,并通过来源控制脚本发出 Graphite 阈值检查。但由于扩展 Carbon 集群需要手动重新分片的过程,并且由于缺乏副本,任何单一节点的磁盘故障都会导致其相关指标的永久性丢失。简而言之,随着公司的不断发展,这种解决方案无法再满足其需求。
在评估现有的解决方案后,Uber 没有找到能够满足其资源效率或规模目标,并能够作为自助服务平台运行的开源替代方案。因此在 2015 年,M3 诞生。起初,M3 几乎全部采用完全开源的组件来完成基本角色,像是用于聚合的 statsite ,用于时序存储具备 Date Tiered Compaction Strategy 的 Cassandra ,以及用于索引的 ElasticSearch 。基于运营负担,成本效率和不断增长的功能集考虑,M3 逐渐形成自己的组件,功能也超越原本使用的方案。
M3 目前拥有超过 66 亿条时序数据,每秒聚合5亿个指标,并在全球范围内每秒持续存储 2000 万个指标(使用 M3DB),批量写入将每个指标持久保存到不同区域的三个副本中。它还允许工程师编写度量策略,以不同的时间长度和不同粒度对资料进行保存。这使得工程师和数据科学家能以不同的留存规则,精细和智能地存储有不同保留需求的时序数据。
在 Uber,由于很多团队在广泛使用 Prometheus ,如何很好地搭配使用是很重要的事。通过一个 sidecar 组件 M3 Coordinator ,M3 集成了 Prometheus 。该组件会向本地区域的 M3DB 实例写入数据,并将查询扩展至“区域间协调器”(inter-regional coordinator)。
M3协调器是一个协调上游系统(如Prometheus)和 M3DB 之间的读写的服务。它是一个桥梁,用户可以通过上游系统访问 M3DB ,如 Prometheus。
M3DB是一个分布式时间序列数据库,提供可扩展的存储和时间序列的反向索引。它作为一个经济高效、可靠的实时的和长期保留度量值存储和索引进行了优化。
M3 Query是一个分布式查询引擎,用于查询存储在M3DB节点中的瞬时和历史数据,支持多种查询语言。它被设置用来处理低延迟和实时查询和长时间历史查询。
例如,如果你使用带有M3协调器的Prom远程吸入到M3中,则可以使用M3查询而不是Prom查询。这样做的话,可以获得M3查询的所有好处,例如块处理。由于M3查询提供了一个Prom兼容的API,您可以使用一些之前使用的第三方绘图和报警解决方案,例如Grafana。
M3聚合器是一个专用的度量聚合器,它在将度量存储到 M3DB 节点之前提供基于有状态流的下采样。它使用存储在 etcd 中的动态规则。它使用leader选举和聚合窗口跟踪,依赖etcd来管理这种状态,可以可靠的执行最少一次聚合,以便将下采样的指标存储到长期存储中汇总。M3协调器也可以执行此角色,但是M3聚合器可以分片和复制指标,而M3协调器则不行。
与M3DB相似,M3 Aggregator默认情况下支持集群和副本。 这意味着度量值已正确路由到负责聚合每个度量标准的实例,并且您可以配置多个M3 Aggregator副本,以使聚合没有单点故障。
注意: M3聚合器正在进行大量开发,以使其更可用,而无需编写自己的兼容生产者和消费者。
M3DB完全用Go编写,没有任何必需的依赖关系。 对于更大的部署,可以使用etcd集群来管理M3DB集群成员资格和拓扑定义。
项目的一些高层次目标定义如下:
监控支持: M3DB主要用于收集大量监控时间序列数据,以水平可扩展的方式分发存储,并最有效地利用硬件。因此,不经常读取的时间序列不会保存在内存中。
高度可配置: 提供高级别的配置以支持广泛的数据集和运行时环境。
可变的持久性: 为存储时间序列数据的写入和读取端提供可变化的持久性保证保证,使更广泛的应用程序能够使用M3DB。这就是为什么复制主要是同步的,并提供可配置的一致性级别,以实现一致性的写入和读取的原因。 必须能够在使用 M3DB 时,强烈保证将数据复制到一定数量的节点上,并且如果需要的话还可以保证数据的持久性(如同步数据直接落盘,但是可能会有性能损耗)。
M3DB的灵感来自Gorilla和Cassandra,是Uber技术公司以开源形式发布的分布式时间序列数据库。它可以用于长期保存时存储实时度量。
以下是M3的一些特点:
分布式时间序列存储,单个节点使用 WAL 提交日志,并独立持久化每个分片的时间窗口
建立在etcd之上的集群管理
内置同步复制,具有可配置的持久性和读取一致性(one, majority, all等)
M3TSZ float64压缩灵感来自 Gorilla TSZ 压缩,可配置为无损或有损
任意时间精度可配置从秒到纳秒的精度,能够在任何写入操作下切换精度
可配置的无序写入,当前仅限于配置时间窗口的块大小
由于项目需求的性质,主要是为了减少摄取和存储数十亿个时间序列的成本并提供快速可伸缩的读取,因此目前存在一些限制,使得M3DB不适合用作通用时间序列 数据库。
该项目旨在尽可能避免压缩,目前,M3DB仅在可变的压缩时间序列窗口(默认配置为2小时)内执行压缩。 因此,乱序写入 仅限于单个压缩 时间序列窗口 的大小。 因此,当前无法回填大量数据。
该项目还优化了float64值的存储和检索,因此目前尚无法将其用作包含任意数据结构的常规时间序列数据库。
M3DB是一个时间序列数据库,主要设计为水平可扩展,能够处理大量数据,并且吞吐量很高。
M3DB作为时间序列数据库的最大优势之一(与使用更通用的水平可伸缩分布式数据库(如Cassandra)不同)是它能够压缩时间序列数据,从而节省大量内存和磁盘。M3DB中使用了两种压缩算法:M3TSZ
和protobuf
编码。
当值为浮点数时使用 M3TSZ压缩方式。作为 Facebook’s Gorilla paper中描述的流时间序列压缩算法的一个变种,它实现了一个高压缩比。根据不同的工作负载和配置,压缩比数据库会有所不同,但是我们发现,通过 Uber 的生产工作负载,我们可以达到1.45字节/压缩比的数据点。这比标准的 TSZ 提高了40% ,在相同的条件下,TSZ 只提供了2.42字节/压缩比的数据点。
对于更复杂的值类型,M3DB还支持泛型Protobuf消息,只有少数例外。该算法采用混合方法,并根据Protobuf消息中的字段类型使用不同的压缩方案。
M3DB 是一个具有持久存储的持久数据库,但最好通过其内存对象布局和磁盘表示之间的关系来理解它。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PVAVDsFf-1619165868065)(/Users/wuqingzhi/Library/Application Support/typora-user-images/image-20210420120100244.png)]
M3DB 的内存部分是通过对象层次结构实现的:
每个 M3DB 进程只有一个数据库。该数据库拥有多个名称空间。
一个namespace类似于其他数据库中的表。每个名称空间都有一个唯一的名称和一组配置选项,例如数据保留时间和块大小(稍后将详细讨论)。一个名称空间拥有多个shard。
一个Shard实际上与 Cassandra 中的“virtual shard”相同,因为它通过series ID的简单哈希值提供了时间序列数据的任意分布。 一个shard拥有多个series。
一个series表示一个数据点。例如,主机的 CPU 利用率可以表示为 ID 为“ host1.system.CPU.using”( name = system _ CPU _ using,host = host1”)和向量(TIMESTAMP,CPU _ level)元组的序列,就例如host1.system.CPU.using value=1.0 host=xx。在图形中可视化这个示例,x 轴上有一条时间线,y 轴上有 CPU 利用率。一个series拥有一个buffer缓冲区和所有的 内存缓冲块(Cached Blocks)
Buffer 缓冲区是所有尚未写入磁盘的数据都存储在特定series的内存中。这包括对M3DB的新写入和通过引导获得的数据。关于缓冲区的更多细节将在下面解释。在刷新时,缓冲区创建一个数据块以持久化到磁盘。
一个块表示预先配置块大小的压缩单个series 数据流,例如,块可以保存6-8PM 的数据(块大小为2小时)。一个块只有在读请求之后获得缓存才能直接到达series。由于块是压缩格式,因此无法从中读取单个数据点。换句话说,为了读取单个数据点,需要事先解压缩到该数据点的整个块。
虽然内存中的数据库可能很有用(而且M3DB支持在仅内存模式下运行),但为了持久性,需要某种形式的持久性。换句话说,如果没有持久性策略,M3DB就不可能在不丢失所有数据的情况下重新启动(或从崩溃中恢复)。
此外,由于数据量很大,将所有数据保存在内存中的成本变得非常高昂。对于经常遵循“只写一次,从不读”模式的监视工作负载来说尤其如此,在这种模式下,只有不到百分之几的存储数据被读取过。对于这种类型的工作负载,当数据可以持久保存在磁盘上并在需要时检索时,将所有这些数据保存在内存中是一种浪费。
M3DB对持久性存储采取双管齐下的方法,包括将用于灾难恢复的提交日志与用于高效检索的定期刷新(将文件集文件写入磁盘)相结合:
所有写操作都被持久化到一个提交日志(提交日志可以配置为fsync每次写操作,或者可选地将批写操作放在一起,这样速度更快,但在发生灾难性故障的情况下,会留下少量数据丢失的可能性)。提交日志是完全未压缩的,仅用于在数据库关闭(有意或无意)的情况下恢复未刷新的数据,从不用于满足读取请求。
定期(根据配置的块大小)将缓冲区中的所有数据作为不可变的文件集文件刷新到磁盘。这些文件是高度压缩的,可以通过它们的补充索引文件编入索引。查看flushing部分以了解有关后台刷新过程的更多信息。
块大小参数是需要针对特定工作负载进行调优的最重要的变量。较小的块大小意味着更频繁的刷新和更小的内存占用,但它也会降低压缩比,数据将占用更多的磁盘空间。
如果数据库在刷新之间由于任何原因而停止,那么当节点启动备份时,将通过读取提交日志或从负责同一个shard的对等方流式传输数据来恢复这些写入(如果复制因子大于1)。
虽然fileset文件被设计为支持通过series ID进行有效的数据检索,但是与任何必须从磁盘检索数据的查询相关联的成本仍然很高,因为进入磁盘总是比访问主内存慢得多。为了弥补这一点,M3DB支持各种缓存策略,通过在内存中缓存数据,可以显著提高读取性能。
M3DB将查询数据库对象以检查名称空间是否存在,如果存在,则将对series ID进行哈希处理以确定它属于哪个分片。 如果接收写入的节点拥有该shard,则它将在分片对象中查找该series。 如果存在该series,则缓冲区中的编码器会将数据点编码为压缩流。 如果编码器不存在(作为该块的一部分,该series没有写入过),则将分配一个新的编码器,并从该数据点开始压缩M3TSZ流。 还有一些用于处理多个编码器和文件集的其他逻辑,这在缓冲区部分中进行了讨论。
同时,写入操作将附加到提交日志,该提交日志会通过快照过程定期进行压缩。 详细信息在提交日志页面中概述。
注意: 无论在单个节点中写入的成功与否,客户机都会根据配置的一致性级别将写入的成功与失败返回给调用者。
当M3DB客户端调用M3DB的嵌入式thrift服务器上的FetchBatchResult或FetchBlocksRawResult终端时,读取开始。读取请求将包含以下信息:
M3DB将查询数据库对象以检查名称空间是否存在,如果存在,它将hash(series id)以确定它属于哪个shard。如果接收读取的节点拥有该shard,那么M3DB需要确定两件事:
Series是否存在
数据是否存在于缓冲区中、缓存在内存中、磁盘上或这三者的组合中。
确定series是否存在很简单,M3DB在shard对象中查找series。如果它存在,那么series就存在。如果没有的话 那么M3DB查询内存中与查询范围重叠的所有shard和block开始组合的bloom过滤器,以确定磁盘上是否存在该series。
如果序列存在,那么对于请求跨越的每个块,M3DB需要合并来自缓冲区、内存缓存和文件集文件(磁盘)的数据。
让我们假设一个读取给定series的请求值为最后6小时的数据,以及一个配置为块大小为2小时的 M3DB namespace,即我们需要找到3个不同的块。
如果当前时间是晚上8点,那么请求的块的位置可能如下所示:
[2PM - 4PM (fileset file)] - 没有缓存的被刷新的块
[4PM - 6PM (in-memory cache)] - 在内存缓冲区中
[4PM - 6PM (cold write in active buffer)] - 还没刷写的缓存块,存放在冷buffer中
[6PM - 8PM (active buffer)] - 热buffer 也就是说 准备进行放到冷buffer中
然后,M3DB将需要合并:
缓冲区尚未密封的块(位于Series对象的内部查找中)[6PM-8PM]
内存中缓存的块(也位于Series对象的内部查找中)。 由于此块中也有冷写操作,因此在返回之前,会将冷写操作与在缓存的块中找到的数据合并到内存中。 [下午4点-下午6点]
磁盘中的块(将从磁盘中检索出该块,然后将根据当前的缓存策略对其进行缓存)[2PM-4PM]
从缓冲区和内存缓存中检索块很简单,数据已经存在于内存中,并且可以通过hash(series id)轻松访问。从磁盘检索块更复杂。从磁盘检索块的流程如下:
检查内存中的bloom过滤器以确定磁盘上是否可能存在该序列。
如果bloom过滤器返回负数,我们确定序列不在那里,所以返回结果。如果bloom过滤器返回正值,则二进制搜索内存中的索引摘要,以查找我们要搜索的series最近的索引项。
跳转到索引文件中的偏移量,我们在上一步中通过二进制搜索获得,然后开始向前扫描,直到我们确定了要查找的序列ID的索引项,或者我们在索引文件中找到了足够远的位置,从而可以清楚地看到我们要查找的ID不存在(这是可能的,因为索引文件是按ID排序的)
跳转到我们在上一步扫描索引文件时获得的数据文件中的偏移量,然后开始流式传输数据。
一旦M3DB从它们在内存/磁盘中的相应位置检索了这三个块,它将把所有数据传送回客户端。 客户端是否将读取成功返回给调用方取决于配置的一致性级别。
注意: 由于M3DB节点返回压缩的块(M3DB客户端将其解压缩),因此无法为给定的块返回“部分结果”。 如果读取请求的任何部分跨越给定的块,则必须将该块的整体发送回客户端。 实际上,由于M3DB能够实现高压缩率,因此这最终不是什么大问题。
每个series对象都包含一个缓冲区,负责处理所有尚未刷新的数据—新写入和引导数据(用于初始化shard)。为了实现这一点,它保留了每个块开始时间的“bucket”列表,其中包含可变编码器(用于新写入)和不可变块(用于引导数据)的列表。数据库的编码方案M3TSZ设计用于压缩时间序列数据,其中每个数据点的时间戳都大于最后一个编码的数据点。对于度量工作负载,这非常有效,因为每个后续数据点几乎总是在前一个数据点之后。但是,有时会收到无序写入,例如由于时钟偏移。当这种情况发生时,M3DB将为无序的数据点分配一个新的编码器。这些编码器包含在一个bucket中,以及任何被引导的块。
将数据点写入缓冲区时,将根据数据点的时间戳所属的块开始时间选择存储桶列表(例如,16:23:20属于16:00块)。在该列表中,只有一个bucket是活动bucket(version=0),它将被写入该bucket中的编码器。
支持无序写入需要定义一个时间窗口,以now结束,称为Buffer-pass:(now-bufferpass,now)。这是一个名称空间配置选项,它指示将数据点刷新到磁盘的方式:Warm或Cold。时间戳在缓冲区内的数据点的写入超过时间窗口被分类为热写入,而在此之前是冷写入。这种分类称为写类型。对于每种写入类型,都存在一个活动的bucket,其中包含可变编码器,在该bucket中写入该块开始时间的新写入。
刷新时(下面将进一步讨论),bucket中的所有数据都会合并,其版本也会增加—新版本取决于此块以前被刷新的次数。此bucket版本控制允许缓冲区知道哪些数据已被刷新,以便后续刷新不会再次尝试刷新它。它还向清理过程(也将在下面讨论)指示可以逐出该数据。
正如架构一节中所讨论的,写操作在内存中被主动缓冲/压缩,提交日志也在不断地被写入,但最终数据需要以文件集文件的形式刷新到磁盘,以便于高效的存储和检索。
这就是可配置的“块大小”发挥作用的地方。块大小只是一段时间的长度,它指示在将新数据写入刷新到磁盘之前,新数据将在内存中压缩(以流方式)多长时间。以两小时大小的块为例。
如果块大小设置为2小时,则给定shard的所有series的所有写入操作将在内存中一次缓冲两小时。两小时后,所有文件集文件都将生成并写入磁盘,然后可以释放内存中的对象并替换为新block的新对象。旧对象将在随后的勾选中从内存中删除。
如果已存在文件集的名称空间/shard/series/block发生刷新,内存中的数据将与文件集中磁盘上的数据合并。合并后的数据将作为一个单独的文件集刷新。
有两种类型的刷写:热刷写和冷刷写。Warm是在给定的块开始时间对shard进行的第一次刷新,因此它是从shard中所有序列的给定缓冲区生成文件集的刷新。冷刷新只有在执行了热刷新(即磁盘上存在文件集)之后才会发生。热刷新写入所有未刷新的热写类型存储bucket,冷刷新将所有未刷新的冷写类型存储bucket与现有文件集合并,并创建一个新的存储桶。
当内存中存在冷写存储桶时,会发生冷刷新,并且此检查在每个勾选间隔(定义如下)运行
Ticking在后台连续运行,并负责各种任务:
合并给定系列/块起始组合的所有编码器
从内存中删除过期/刷新的系列和块
从文件系统清除过期的数据(文件集/提交日志)
如果一个块有多个编码器,则需要在将数据刷新到磁盘之前将它们合并。为了防止在刷新过程中出现巨大的内存尖峰,我们在后台不断地合并无序的编码器。
根据配置的缓存策略,内存中的对象布局最终可以引用已过期的序列或数据块(已超出保持期)或不再需要内存中的数据块(由于数据被刷新到磁盘或不再需要进行缓存)。后台将标记识别这些结构并从缓存中释放它们。
1、超过保留期限的block的文件集文件
2、已具有文件集文件的块发生刷新。新文件集将是现有文件集的超集,其中包含该块所需的任何新数据,因此不再需要现有文件集。