注册
登录
新闻动态
其他科技
返回
时间序列数据库的工作原理——以及它们不适用的地方
作者:
糖果
发布时间:
2024-04-17 12:17:42 (9天前)
来源:
time-series-database/
在我之前的文章中,我们探讨了为什么 Honeycomb 被实现为分布式列存储。然而,同样有趣的是,为什么 Honeycomb没有以其他方式实现。因此,在这篇文章中,我们将深入探讨时间序列数据库 (TSDB)的主题以及为什么 Honeycomb 不能仅限于 TSDB 实现。 如果您使用过传统的指标仪表板,那么您就使用了时间序列数据库。尽管指标的生成成本很低,但从历史上看,它们的存储成本非常高。因此,TSDB 现在无处不在,因为它们专门针对指标存储进行了优化。但是,有些事情不是它们设计来处理的: - 它们不能有效地存储高基数数据。 - 写入时聚合会丢失原始数据的上下文,从而难以回答新问题。 - 如果没有将预先汇总的指标相互关联的数据,您的调查可能会走上错误的道路。 相比之下,我们的分布式列存储针对存储原始、高基数数据进行了优化,您可以从中导出上下文跟踪。这种设计足够灵活和高性能,我们可以使用相同的后端支持指标和跟踪。但是,对于特定类型的数据而言,时间序列数据库却并非如此。 但是你必须先了解规则,然后才能打破它们。那么是什么让 TSDB 打勾呢?虽然细节取决于具体的实现,但我们将重点关注Facebook Gorilla,它特别有影响力。通过研究白皮书介绍的压缩算法,我们将了解 TSDB 的基本设计。然后,这为我们对尝试依赖 TSDB 进行可观察性的现实世界问题的讨论提供了信息。 ##### 指标、时间序列数据库和 Facebook Gorilla 服务监控的最基本单位是metric 。指标实际上只是捕获有关环境的一些重要信息的数字。它们对于系统级诊断特别有用:CPU 利用率百分比、可用内存字节数、发送的网络数据包数量等。但是这个想法非常简单,以至于应用程序级洞察也经常生成数据:请求延迟的毫秒数、错误响应的数量、特定于域的测量等(正如我们将看到的,普通指标实际上并不像对后一种类型的数据很有用。) 重要的是,指标会随着时间而变化。您不能只读取一次 CPU 使用率并永久了解系统的健康状况。相反,您希望以固定的收集间隔(例如,每 15 秒)经常读取读数,记录每个指标的值以及测量时间。存储这些数据是Prometheus、InfluxDB和Whisper等时间序列数据库发挥作用的地方。 甲时间序列是数据点,其中每个点是一对的序列:时间标记和数值。时间序列数据库为每个指标存储一个单独的时间序列,允许您查询和绘制随时间变化的值。 乍一看,这似乎并不复杂。TSDB 可以将每个点存储在全部 16 个字节中:一个 64 位Unix 时间戳和一个 64 位双精度浮点数。例如,CPU 统计数据的时间序列可能如下所示: Unix 时间戳 CPU使用率 (%) 1600000000 35.69 1600000015 34.44 1600000030 32.19 1600000044 53.94 1600000060 37.56 时间戳是自 Unix 纪元以来的整数秒,随着时间的推移自然会增加。在这里,我们可以猜测有一个 15 秒的收集间隔,尽管在现实世界中肯定会有一些抖动,时钟会在这里或那里稍微偏离。CPU 使用率是一个浮点数,如果系统健康,它可能会徘徊在相同的值附近,但可能会出现剧烈的峰值。其他指标可能可以表示为整数,但使用浮点类型更为通用。 然而,这种简单的表示在规模上存在问题。不仅指标多产,而且在现代分布式系统中生成这些指标的服务数量之多意味着 TSDB 可能同时维护数百万个活动时间序列。突然,这 16 个字节开始增加。较少的系列可以保存在内存中,因此由于写入和查询必须访问磁盘,性能会受到影响。然后长期存储变得更加昂贵,因此可以保留的数据更少。 因此,TSDB 优化的一个主要途径是压缩,减少表示相同数据所需的位数。由于时间序列具有非常具体的结构,我们可以利用专门的编码。特别是,Facebook Gorilla引入了两种压缩方案,它们影响了其他几个 TSDB,包括 Prometheus(参见PromCon 2016 演讲)和 InfluxDB(至少1.8 版)。分析这两种方案——一个用于时间戳,一个用于值——将使我们对 TSDB 解决的问题类型有一个大致的了解。 ##### 优化时间序列数据库 要压缩时间戳,首先要注意的是它们总是在增加。一旦我们知道初始时间戳,我们就知道每个后续时间戳都会与前一个时间戳发生偏移。这产生了增量编码,它存储差异而不是原始值。例如,我们之前的 CPU 时间序列的每个时间戳具有以下增量: Unix 时间戳 三角洲 1600000000 —— 1600000015 15 1600000030 15 1600000044 14 1600000060 16 我们仍然必须将第一个时间戳存储为 64 位整数,但增量要小得多,因此我们不需要使用那么多位。 但是我们可以更进一步,注意到增量几乎都相同。事实上,由于指标的周期性,我们预计增量等于收集间隔(此处为 15 秒)。但是,仍然存在抖动的可能性,因此我们不能仅仅假设增量总是相同的精确值。 意识到这一点,我们可以通过将 delta 编码应用于我们的 delta 编码来进一步压缩时间戳,为我们提供delta-of-deltas 编码: Unix 时间戳 三角洲 三角洲的三角洲 1600000000 —— —— 1600000015 15 —— 1600000030 15 0 1600000044 14 -1 1600000060 16 +2 在存储第一个时间戳的所有 64 位之后,然后是第二个时间戳的第一个增量,我们可以只存储增量的增量。除了抖动的情况外,我们预计 delta-of-deltas 为 0,它可以存储在单个位中。对于 Facebook,他们发现 96% 的时间戳都是这种情况!甚至由于抖动引起的差异预计很小,这使我们能够实现积极的压缩比。 类似的策略也可用于度量值。但是,我们没有允许简单增量的相同约束。原则上,减去任意两个任意浮点数只会产生另一个浮点数,它需要相同数量的位进行编码。相反,我们转向 IEEE 754 标准指定的双精度浮点数的二进制表示。 64 位分为三部分:符号(1 位)、指数(11 位)和分数(52 位)。符号位告诉我们数字是正数还是负数。指数和分数的工作方式几乎与以 10 为底的科学记数法相同,其中数字 123.456 的小数部分为 0.123456,指数为 3,因为 0.123456 × 10^3 = 123.456。您可以将指数视为数量级,而分数是数字的“有效载荷”。 在单个时间序列中,值很可能具有相同的符号和数量级,因此测量之间的前 12 位可能相同。例如,CPU 使用率始终是 0 到 100 之间的正数。分数值也可能保持相对接近,但波动可能更大。例如,只要系统运行良好,CPU 使用率可能会徘徊在相同的窄带附近,但它也可能激增或崩溃。 因此,我们可以在连续值之间进行按位异或,而不是像增量编码那样减去后续值。前 12 位通常相同,因此它们的 XOR 将为零。还会有一定数量的尾随零——如果二进制表示很接近,则更多。为了紧凑起见使用十六进制表示法,我们的示例 CPU 使用率指标具有以下 XOR 结果: CPU使用率 (%) 十六进制表示 与之前的异或 35.69 0x4041d851eb851eb8 —— 34.44 0x40413851eb851eb8 0x0000e00000000000 32.19 0x40401851eb851eb8 0x0001200000000000 53.94 0x404af851eb851eb8 0x000ae00000000000 37.56 0x4042c7ae147ae148 0x00083ffffffffff0 为什么要纠结这个?因为不是存储 XOR 结果的所有 64 位,我们可以使用其可预测的形状来设计自定义编码。例如,考虑二进制值,它由 12 个前导零、7 个有效位 ( ) 和 45 个尾随零组成。我们可以分别对每个“块”进行编码:0x000ae000000000001010111 - 我们可以将数字 12 写为仅使用 4 位,而不是将 12 个前导零写为。0000000000001100 - 必须完整地写出有效位,但位数是可变的。所以我们写下有效位的数量,然后是位本身。这里是(7) 后跟总共 10 位。1111010111 - 上面编码的信息暗示了尾随零的数量。我们知道全宽是 64,并且 64 – 12 – 7 = 45。所以我们不需要为最后一个块编码任何其他东西。 因此,我们可以用 4 + 10 + 0 = 14 位来表示这个值,而不是 64 位。实际上,这并不是最终结果。我们可能需要额外的位来分隔块,还有其他技巧可以挤出几个位。但是,总体思路还是一样的。 但是,正如我们从示例中看到的,这种压缩方案具有更多的可变性。前三个异或结果有很多零,因此它们压缩了很多。最后一个结果仍然有很多前导零,但没有那么多尾随零,因此压缩较少。最终,整体压缩率实际上取决于指标的各个值。 尽管如此,通过结合两种压缩算法,Facebook 使用的 Gorilla 数据库看到数据点从 16 字节减少到平均 1.37 字节!由于数据很小,内存中可以容纳更多点,从而在更长的时间范围内提高查询性能。当值写入磁盘时,这也意味着更便宜的长期保留。 除了压缩,还有其他主题需要考虑,例如在时间序列持久化时有效地使用磁盘 I/O。但这仍然为我们提供了 TSDB 正在解决的问题的关键:以尽可能少的浪费存储大量同质的、带时间戳的数字数据点。如果您想了解更多优化主题,请查看 Fabian Reinartz 在 PromCon 2017 上的演讲“大规模存储 16 字节”。然而,就我们的目的而言,这种理解为我们讨论在野外使用 TSDB 提供了立足点。 ##### 标记与高基数 现在我们可以跟踪我们的数据点,让我们考虑如何实际使用它们。假设我们有一个用 Python 编写的电子商务应用程序,使用Flask为各种 API 端点提供服务。一个非常合理的应用程序级指标可能是用户看到的错误数量。使用类似StatsD 的东西,我们可以设置一个错误处理程序来增加一个度量: 导入统计数据 统计数据 = 统计数据。统计客户端() @应用程序。错误处理程序(HTTPException ) def handle_error ( e ) : 统计数据。incr (“错误” ) # ... 在幕后,StatsD 守护进程维护一个计数器,记录我们在收集间隔期间看到的错误数量。在每个间隔结束时,将值写入TSDB 中的错误时间序列,然后将计数器重置为 0。因此,计数器实际上是每个间隔的错误率。如果我们看到它呈上升趋势,则可能出现问题。 但究竟出了什么问题?这个单一的计数器并没有告诉我们太多。这可能是一些导致 HTTP 500 错误的可怕错误,或者可能是电子邮件活动中的链接断开导致 HTTP 404 错误增加,或任何其他事情。为了拆分这个指标,供应商通常会提供标签或标签——可以附加到时间序列的键值元数据片段。虽然 vanilla StatsD 不支持标记,但像Prometheus这样的客户端支持。例如,我们可以像这样附加一个 HTTP 状态标签: 从prometheus_client导入计数器 错误=计数器(“错误” ,“每个时间间隔的HTTP异常” ,[ “状态” ]) @应用程序。错误处理程序(HTTPException ) def handle_error ( e ) : 错误。标签(电子代码)。公司() # ... 尽管 Prometheus 计数器的工作方式与 StatsD 计数器略有不同,但标签在支持它们的 TSDB 中的工作方式基本相同。最终,数据库将为每个标签值存储一个单独的时间序列。因此,对于每个可能的状态代码,都会有一个具有自己数据点的不同时间序列。该时间序列将从包含不同的数据系列,让它们可以单独图表,我们可以看到每个状态代码的相应错误率。该系列代表这些其他系列的总和。 errors{status=500}errors{status=404}errors 不过,为什么要停在那里?HTTP 状态代码并不是唯一区分错误的东西。对于我们假设的电子商务网站,我们可能会容忍API 与API不同的错误率。我们可以为请求路径添加另一个标签,以获取尽可能多的上下文:/checkout/reviews 从prometheus_client导入计数器 错误=计数器(“错误” ,“每个时间间隔的HTTP异常” ,[ “状态” ,“路径” ]) @应用程序。错误处理程序(HTTPException ) def handle_error ( e ) : 错误。标签(e.code,request.path )。公司() # ... 那么时间序列将与序列不同。但它不止于此:TSDB 必须为每个独特的状态和路径组合存储一个单独的时间序列,包括和。假设有 10 个可能的错误代码和 20 个可能的 API 端点。然后我们将存储 10 × 20 = 200 个不同的时间序列。errors{status=500,path=/checkout}errors{status=500,path=/reviews}errors{status=404,path=/checkout}errors{status=404,path=/reviews} 这还不算太离谱。到目前为止,我们添加的标签的可能值相对较少。也就是说,它们的基数很低。但是,请考虑具有高基数(许多可能的值)的标签。例如,用户 ID 标签可能很方便,因为错误场景可能仅适用于特定用户。但是用户 ID 的数量可能非常多,这取决于站点的受欢迎程度。假设有 1,000,000 个用户。仅使用这三个标签,TSDB 就必须存储 10 × 20 × 1,000,000 = 200,000,000 个不同的时间序列。使用无界基数的标签会变得更加危险,比如 HTTP 用户代理字符串,它可以是任何东西。 TSDB 擅长他们的工作,但设计中并未内置高基数。错误的标签(或者只是标签太多)会导致存储需求的组合爆炸。这就是为什么供应商通常会根据标签数量收费,甚至以有限数量的价值组合来限制某些计划。使用 TSDB,您无法根据一些最重要的区别来标记指标。相反,您必须使用粗粒度的数据,最多使用少量的低基数标签。 ##### 聚合与原始数据 虽然标签可以使时间序列数据库存储过多的数据,但也有一些方式会使 TSDB 无法存储足够的数据。这与指标的生成方式有关。 假设我们过去曾遇到过与具有大购物车尺寸的用户相关的性能问题 - 即用户在结账时拥有的商品数量。如果我们再次遇到同样的问题,为此跟踪一个指标是合理的。但是我们不能像以前那样使用计数器,因为购物车大小不是累积的。不同的用户在不同的时间有不同的购物车大小,即使在一个收集间隔内也是如此。假设我们有一个 30 秒的收集间隔,并且我们在 1600000000 – 1600000030 间隔期间看到以下结帐: 时间戳 推车尺寸 1600000003 3 1600000007 14 1600000010 15 1600000011 9 1600000017 26 1600000017 5 1600000026 3 这些是技术上带有时间戳的数字,但它们都发生在不同的时间,分布在不可预测的分布中(甚至可能同时发生)。由于 TSDB 依赖于时间增量的规律性,我们希望等到收集间隔结束时将单个数字写入时间序列。也就是说,我们需要一个以某种方式代表数据点总数的聚合数字。计数器是将值相加的一种类型的聚合。但这并不能告诉我们典型用户购买了多少商品。相反,我们可能会选择记录每个时间间隔的平均购物车大小。 为了跟踪这些指标,库通常会提供直方图函数: @应用程序。路线(“/结帐” ) 定义结帐(): 购物车 = get_cart () 统计数据。直方图(“cart_size” ,len (cart )) # ... 从广义上讲,客户将在每个收集间隔期间维护一个购物车尺寸列表。在间隔结束时,从该列表计算聚合并将其写入 TSDB,然后在下一个间隔清除该列表。每个聚合都有自己的时间序列。它们可能包括: - cart_size.sum = 75 - cart_size.count = 7 - cart_size.avg = 10.71 - cart_size.median = 9 - cart_size.p95 = 22.7 - cart_size.min = 3 - cart_size.max = 26 但无论我们计算哪种聚合,事实仍然是我们在写入时丢弃了原始数据点。你将永远无法读回它们。例如,虽然您可以看到平均购物车大小为 10.71,但数据点也可能是或或或任何数量的其他可能性。除了平均值之外,您还可以计算其他聚合,以尝试获得更清晰的图片,例如标准偏差。但归根结底,获得完整分辨率的唯一方法是手头有原始数据,这不适合存储在 TSDB 中。[3, 14, 15, 9, 26, 5, 3][10, 8, 8, 13, 13, 13, 10][7, 11, 12, 16, 16, 9, 4] 对于您提前知道想要的指标,这可能无关紧要。我们想要平均值,所以我们记录平均值。但是当有人问出你没有预料到的问题时,这绝对很重要。假设我们看到平均购物车大小在 10 左右徘徊,但我们仍然遇到与大型购物车相关的中断。“大”到底有多大?也就是说,只有 10 件以上的购物车的平均尺寸是多少?超过 20 个项目的异常值有多少?拥有只有 1 或 2 件商品的特别小手推车的人是否会拖累平均值?等等。 根据我们保存到数据库的平均值,这些问题都无法回答。我们扔掉了原始的原始数据,所以我们不能反算。唯一要做的就是为未来的数据创造一个新的指标。这使我们进入一种反应模式,不断对未知的未知事物一时兴起。很可能没有人有先见之明来保存正确的聚合,让我们回答这些关于购物车大小的重要问题,更不用说在我们半夜被传呼时调试一些新的故障模式了。 ##### 相关性与可观察性 让我们想象一下这样的失败场景。假设今天是黑色星期五,这是我们电子商务网站一年中最重要的一天。到了中午,警报开始响起。没关系; 这就是为什么我们将主动警报连接到我们的时间序列。因此,我们开始进行故障排除并打开指标仪表板。 errors 果然,图表显示API上的 HTTP 500 错误令人不安地增加。是什么原因造成的?好吧,我们之前已经看到过购物车尺寸的性能问题。这段有问题的代码可能会导致错误似乎是合理的,因此我们查阅时间序列进行检查。/checkoutcart_size.avg 与系列相比,看起来购物车的大小和错误率都在同步上升,这似乎非常可疑。假设在手,我们开始重现一个场景,其中大型购物车触发某种错误。errors{status=500,path=/checkout} 除了这个推理是有缺陷的。在客户支持票进来之前,我们可能会浪费一个小时来研究这个兔子洞,因为用户无法在结账时应用特定的优惠券代码——这是一个与购物车大小无关的错误。由于是黑色星期五,购物车尺寸呈上升趋势,同时由于特殊的黑色星期五优惠券代码已损坏,错误也在增加。 正如他们所说,相关性并不意味着因果关系。人类非常擅长模式匹配,即使模式是巧合的。因此,当我们眯着眼睛看图表并试图将事物排列起来时,我们的大脑可能会发现一种虚假的相关性。例如,您可能听说过全球变暖是由于缺乏海盗而引起的。毕竟,随着海盗数量呈下降趋势,全球平均气温呈上升趋势! 当然,并不是每一个相关性都是坏的。然而,我们仍然冒着追逐这些红鲱鱼的风险,因为 TSDB 缺乏适当的上下文——这是他们自己设计的缺陷。我们无法用用户提交的优惠券代码标记错误,因为文本输入的基数实际上是无限的。我们也无法深入到聚合中找到一个单独的数据点,告诉我们在小推车尺寸上仍然会发生错误。 这个例子说明了整体缺乏可观察性:通过你在外部看到的东西来推断系统内部运作的能力。这就是为什么在尝试解决可观察性问题时,Honeycomb 不能仅限于 TSDB 实现。 ##### 蜂窝不一样 为了使时间序列数据库非常擅长他们的工作,已经做了很多工作。生成指标总是很便宜,而且借助 TSDB 的强大功能,您将能够轻松绘制长时间内的汇总趋势图表。但是当谈到可观察性时,有几种方法会使数字时间序列数据无法捕获足够的上下文。 这就是 Honeycomb 后端设计的动机,它优化了存储原始数据和快速查询。因此,在缺少 TSDB 的领域,分布式列存储确实大放异彩: - 存储要求不依赖于数据的基数。因此,您可以将高基数字段附加到您的事件,而无需额外费用。 - 原始数据在写入时存储,聚合仅在查询时计算。没有任何东西被丢弃,使您能够绘制粗粒度的聚合图表以及深入到细粒度的数据点。 - 通过将数据按关系组织成跟踪,您不再需要依赖关联不同的图来尝试观察系统的行为。 另外,我们可以从宽事件中推导出度量,而宽事件不能从度量中推导出来。确实,Honeycomb 不会像 TSDB 那样擅长压缩单个时间序列。但是,我们可以在每个收集间隔结束时将所有指标打包在一起,并将它们作为一行写入我们的列存储: 时间戳 中央处理器 mem_used mem_free 网络输入 网络输出 ... … … … … … … … 因为跟踪已经为您提供了如此多的上下文,所以您可能会发现自己对应用程序级行为的指标依赖较少。尽管如此,指标对于系统级诊断很有用,可以了解您的机器是否健康、规划基础设施容量等。因此,值得拥有一个支持跟踪和指标的后端。 关键不在于 TSDB 不好而列存储很好。与以往一样,工程是关于权衡的。但是现在我们已经了解了 TSDB 是如何工作的,以及它们是如何工作的,我们可以在更好地理解这些权衡是什么的情况下做出决定。如果您想了解更多信息,请查看我们关于Honeycomb Metrics的文档。通过免费的企业帐户试用开始使用 Honeycomb Metrics !
收藏
举报
1 条回复
动动手指,沙发就是你的了!
登录
后才能参与评论