时间序列数据:为什么使用关系数据库而不是NoSQL

原文:Time-series data: Why (and how) to use a relational database instead of NoSQL

个人感觉这篇文章有很多数据库存储方面的知识点,所以转载过来,翻译直接使用了Google翻译。

当下,时间序列数据应用程序(例如,数据中心/服务器/微服务/容器监控,传感器/物联网分析,财务数据分析等)正在激增。

因此,时间序列数据库正在流行(这里有33个)。其中大多数都放弃了传统关系数据库的陷阱,并采用了通常所说的NoSQL模型。使用模式类似:最近的一项调查显示,开发人员将NoSQL优先于时间序列数据的关系数据库超过2:1。

img

关系数据库包括: MySQL,MariaDB Server,PostgreSQL。NoSQL数据库包括: Elastic,InfluxDB,MongoDB,Cassandra,Couchbase,Graphite,Prometheus,ClickHouse,OpenTSDB,DalmatinerDB,KairosDB,RiakTS。

通常,采用NoSQL时间序列数据库的原因是按比例缩小的。虽然关系数据库有许多有用的功能,大多数NoSQL数据库都没有(强大的二级索引支持;复杂的谓词;丰富的查询语言; JOIN等),但它们很难扩展。

由于时间序列数据非常快速地堆积起来,许多开发人员认为关系数据库不适合它。

我们采取不同的,有些异端的立场:关系数据库对于时间序列数据可能非常强大。人们只需要解决缩放问题。这就是我们在TimescaleDB中所做的。

两周前我们宣布TimescaleDB时,我们收到了社区的大量积极反馈。但我们也从怀疑论者那里听到,他们发现很难相信人们应该(或可能)在关系数据库(在我们的例子中是PostgreSQL)上构建可扩展的时间序列数据库。

有两种不同的方式来考虑扩展:扩展以便单个机器可以存储更多数据,并进行扩展以便可以跨多台机器存储数据。

为什么两者都很重要?在N个服务器集群中扩展的最常用方法是将数据集分区或分片为N个分区。如果每个服务器的吞吐量或性能受限(即无法扩展),则整体群集吞吐量将大大降低。

这篇文章讨论了扩大规模。(扩展后的帖子将在以后发布。)

特别是,这篇文章解释说:

  • 为什么关系数据库通常不能很好地扩展
  • LSM树(通常在NoSQL数据库中使用)如何不能充分解决许多时间序列应用程序的需求
  • 时间序列数据如何独特,如何利用这些差异来克服扩展问题,以及一些性能结果

我们的动机是双重的:对于任何面临类似问题的人,分享我们所学到的东西; 而对于那些使用TimescaleDB时间序列数据考虑(包括怀疑!),说明我们的一些设计决策。


为什么数据库通常不能很好地扩展:交换内存是非常昂贵的

在单台机器上扩展数据库性能的一个常见问题是内存和磁盘之间的显着成本/性能折衷。虽然内存比磁盘快,但价格要贵得多:比闪存等固态存储成本高出20倍,比硬盘高出100倍。最终,我们的整个数据集将不适合内存,这就是我们需要将数据和索引写入磁盘的原因。

这是关系数据库的一个古老的常见问题。在大多数关系数据库中,表被存储为固定大小的数据页面的集合(例如,PostgreSQL中的8KB页面),系统在其上构建数据结构(例如B树)以索引数据。使用索引,查询可以快速查找具有指定ID(例如,银行帐号)的行,而无需扫描整个表或以某种排序顺序“行走”该表。

现在,如果数据和索引的工作集很小,我们可以将它保存在内存中。

但是如果数据足够大以至于我们无法将所有(类似固定大小的)B树的页面都放在内存中,那么当我们从磁盘读取页面时,更新树的随机部分可能涉及大量的磁盘I / O.进入内存,在内存中修改,然后写回磁盘(当被驱逐以便为其他B树页面腾出空间时)。而像PostgreSQL这样的关系数据库为每个表索引保留了一个B树(或其他数据结构),以便有效地找到该索引中的值。因此,当您索引更多列时,问题会更复杂。

事实上,因为数据库只以页面大小的边界访问磁盘,即使看似很小的更新也可能导致这些交换发生:要更改一个单元,数据库可能需要换出现有的8KB页面并将其写回磁盘,然后在修改之前阅读新页面。

但为什么不使用较小或可变大小的页面?有两个很好的理由:最小化磁盘碎片,以及(在旋转硬盘的情况下)最小化将磁盘头物理移动到新位置所需的“寻道时间”(通常为5-10ms)的开销。

那么固态硬盘(SSD)呢?虽然像NAND闪存驱动器这样的解决方案消除了任何物理“搜索”时间,但它们只能以页面级粒度(今天,通常为8KB)读取或写入。因此,即使更新单个字节,SSD固件也需要从磁盘读取8KB页面到其缓冲区缓存,修改页面,然后将更新的8KB页面写回新的磁盘块。

在PostgreSQL的这个性能图中可以看到交换内存和内存的成本,其中插入吞吐量随表大小而变化并且方差增加(取决于请求是在内存中命中还是需要(可能多次)从磁盘获取)。

img

将吞吐量作为PostgreSQL 9.6.2的表大小的函数插入,在具有基于SSD(优质LRS)存储的Azure标准DS4 v2(8核)机器上运行10个工作程序。客户端将单独的行插入到数据库中(每个行都有12列:时间戳,索引的随机选择的主ID,以及10个额外的数字度量)。PostgreSQL速率开始超过15K插入/秒,但在50M行之后开始显着下降并且开始经历非常高的方差(包括仅100次插入/秒的周期)。


使用Log-Structured Merge Trees输入NoSQL数据库(以及新问题)

大约十年前,我们开始看到许多“NoSQL”存储系统通过Log-structured merge(LSM)树来解决这个问题,它通过仅对磁盘执行更大的仅附加写入来降低进行小写操作的成本。

而不是执行“就地”写入(对现有页面进行小的更改需要从/向磁盘读取/写入整个页面),LSM树将几个新的更新(包括删除!)排队到页面中并将其写为单批到磁盘。特别是,LSM树中的所有写入都是对保存在内存中的已排序表执行的,然后在足够大时将其作为不可变批处理刷新到磁盘(作为“排序字符串表”或SSTable)。这降低了进行小写入的成本。

img

在LSM树中,所有更新首先在内存中写入已排序的表,然后作为不可变批处理刷新到磁盘,存储为SSTable,通常在内存中编入索引。(来源:https//www.igvita.com/2012/02/06/sstable-and-log-structured-storage-leveldb/

这个体系结构 - 已被许多“NoSQL”数据库采用,如LevelDB,Google BigTable,Cassandra,MongoDB(WiredTiger)和InfluxDB - 一开始可能看起来很棒。然而,它引入了其他权衡:更高的内存需求和差的二级索引支持。

更高内存要求:与B树不同,在LSM树中没有单一排序:没有全局索引可以为所有键提供排序顺序。因此,查找密钥的值会变得更复杂:首先,检查内存表中是否有最新版本的密钥; 否则,查看(可能很多)磁盘表以查找与该密钥关联的最新值。为了避免过多的磁盘I / O(如果值本身很大,例如存储在Google BigTable中的网页内容),所有SSTable的索引可能完全保留在内存中,这反过来又会增加内存需求。

二级索引支持不佳:鉴于它们缺少任何全局排序顺序,LSM树自然不支持二级索引。各种系统增加了一些额外的支持,例如通过以不同的顺序复制数据。或者,他们通过将主键构建为多个值的串联来模拟对更丰富谓词的支持。然而,这种方法伴随着在查询时需要在这些密钥中进行更大扫描的成本,因此仅支持具有有限基数的项目(例如,离散值,而不是数字值)。

有一个更好的方法来解决这个问题。让我们从更好地理解时间序列数据开始。

每天接近3B时间序列数据点:为什么DNSFilter用TimescaleDB取代InfluxDB
我们的结果:资源利用率提高10倍,即使请求数量增加30%blog.dnsfilter.com


时间序列数据不同

让我们退后一步,看看关系数据库旨在解决的原始问题。从20世纪70年代中期的IBM开创性的System R开始,关系数据库被用于所谓的在线事务处理(OLTP)。

在OLTP下,操作通常是对数据库中各行的事务更新。例如,考虑银行转帐:用户从一个帐户借记资金并对另一个帐户贷记。这对应于对数据库表的两行(甚至仅两个单元格)的更新。由于银行转帐可以在任意两个帐户之间进行,因此修改后的两行在某种程度上随机分布在表中。

img

时间序列数据来自许多不同的设置:工业机器; 运输和物流; DevOps,数据中心和服务器监控;以及财务应用程序。

现在让我们考虑一些时间序列工作负载的示例:

  • DevOps /服务器/容器监控。系统通常收集有关不同服务器或容器的度量标准:CPU使用率,空闲/已用内存,网络tx / rx,磁盘IOPS等。每组度量标准都与时间戳,唯一服务器名称/ ID和一组标记相关联描述正在收集的内容的属性。
  • 物联网传感器数据。每个物联网设备可以报告每个时间段的多个传感器读数。例如,对于环境和空气质量监测,这可能包括:温度,湿度,气压,声级,二氧化氮,一氧化碳,颗粒物等的测量。每组读数与时间戳和唯一设备ID相关联,可能包含其他元数据。
  • 财务数据。财务计价数据可以包括具有时间戳,证券名称及其当前价格和/或价格变化的流。另一种类型的财务数据是支付交易,其包括唯一的账户ID,时间戳,交易金额以及任何其他元数据。(请注意,此数据与上面的OLTP示例不同:此处我们记录每个事务,而OLTP系统只是反映系统的当前状态。)
  • 车队/资产管理。数据可以包括车辆/资产ID,时间戳,该时间戳处的GPS坐标以及任何元数据。

在所有这些示例中,数据集是涉及将“新数据”插入数据库的测量流,通常是最新的时间间隔。虽然由于网络/系统延迟或者由于更正现有数据的更正,数据可能比生成/加时间更晚到达,但这通常是例外,而不是常态。

换句话说,这两个工作负载具有非常不同的特征:

OLTP写入

  • 主要更新
  • 随机分布(在主键集上)
  • 通常跨多个主键进行交易

时间序列写

  • 主要是INSERT
  • 主要是最近的时间间隔
  • 主要与时间戳和单独的主键相关联(例如,服务器ID,设备ID,安全性/帐户ID,车辆/资产ID等)

为什么这很重要?正如我们将看到的,可以利用这些特性来解决关系数据库中的扩展问题。


一种新方法:自适应时间/空间分块

当以前的方法试图避免对磁盘的小写时,他们试图解决UPDATE到随机位置的更广泛的OLTP问题。但正如我们刚刚建立的那样,时间序列工作负载是不同的:写入主要是INSERTS(不是UPDATES),最近的时间间隔(不是随机位置)。换句话说,时间序列工作负载仅附加。

这很有趣:它意味着,如果数据按时间排序,我们将始终写入数据集的“结束”。按时间组织数据还可以使我们保持数据库页面的实际工作集相当小,并将它们保存在内存中。我们花费较少时间讨论的读取也可能受益:如果许多读取查询是针对最近的间隔(例如,用于实时仪表板),那么这些数据将已经缓存在内存中。

乍一看,似乎按时间编制索引会为我们提供免费的高效写入和读取。但是,一旦我们想要任何其他索引(例如,另一个主键,如服务器/设备ID,或任何二级索引),那么这种天真的方法将使我们回归到为该索引进行随机插入我们的B树。

还有另一种方式,我们称之为“自适应时间/空间分块”。这就是我们在TimescaleDB中使用的内容。

img

TimescaleDB将每个块存储在内部数据库表中,因此索引只会随着每个块的大小而增长,而不是整个超级块。由于插入主要是在最近的时间间隔内,因此插入内存仍然存在,避免了昂贵的磁盘交换。

TimescaleDB不是仅按时间索引,而是通过根据两个维度分割数据来构建不同的:时间间隔主键(例如,服务器/设备/资产ID)。我们将它们称为块,以区别于分区分区通常通过拆分主键空间来定义。因为这些块中的每一个都作为数据库表本身存储,并且查询规划器知道块的范围(在时间和键空间中),所以查询规划器可以立即告知操作的数据属于哪个块。(这既适用于插入行,也适用于修剪执行查询时需要触摸的一组块。)

这种方法的主要好处是,现在我们所有的索引都只构建在这些小得多的块(表)上,而不是代表整个数据集的单个表。因此,如果我们正确调整这些块的大小,我们可以将最新的表(及其B树)完全放入内存中,并避免这种交换到磁盘的问题,同时保持对多个索引的支持。

实现分块的方法

设计这种时间/空间分块的两种直观方法都有很大的局限性:

方法#1:固定持续时间间隔

在这种方法下,所有块可以具有固定的,相同的时间间隔,例如1天。如果每个时间间隔收集的数据量不变,则此方法很有效。然而,随着服务变得流行,它们的基础设施相应地扩展,导致更多的服务器和更多的监控数据。同样,成功的物联网产品将部署更多数量的设备。一旦我们开始向每个块写入太多数据,我们就会定期交换到磁盘(并将发现自己回到正方形)。另一方面,选择太小的间隔开始会导致其他性能下降,例如,在查询时不得不触摸许多表。

img

每个块都有固定的持续时间。然而,如果每次数据量增加,那么最终块大小变得太大而不适合存储器。

方法#2:固定大小的块

使用这种方法,所有块都具有固定的目标大小,例如1GB。写入一个块直到达到其最大大小,此时它变为“关闭”并且其时间间隔约束变得固定。然而,落在块的“关闭”间隔内的后来数据仍将被写入块,以便保持块的时间约束的正确性。

一个关键的挑战是块的时间间隔取决于数据的顺序。考虑数据(甚至是单个数据点)是否“提前”到达数小时甚至数天,可能是由于非同步时钟,或者因为间歇性连接的系统中的延迟变化。这个早期的数据点将延长“开放”块的时间间隔,而后续的准时数据可以驱动块超过其目标大小。此方法的插入逻辑也更复杂和昂贵,降低了大批量写入(例如大型COPY操作)的吞吐量,因为数据库需要确保以时间顺序插入数据以确定何时应创建新块(即使在中间一项行动)。固定或最大大小的块也存在其他问题,包括可能与数据保留策略不一致的时间间隔(“30天后删除数据”)。

img

每个块的时间间隔仅在达到其最大大小时才固定。然而,如果数据提前到达,则会为块创建一个较大的间隔,并且块最终会变得太大而无法放入内存中。

TimescaleDB采用第三种方法,将两种方法的优势结合起来。

方法#3:自适应间隔(我们当前的设计)

块以固定间隔创建,但是间隔根据数据量的变化从块到块进行调整,以达到最大目标大小。

通过避免开放式间隔,这种方法可确保早期到达的数据不会产生太长的时间间隔,从而导致过大的块。此外,与静态间隔一样,它更自然地支持按时指定的保留策略,例如“30天后删除数据”。给定TimescaleDB的基于时间的分块,这些策略通过简单地丢弃数据库中的块(表)来实现。这意味着可以简单地删除底层文件系统中的单个文件,而不需要删除单个行,这需要擦除/使底层文件的部分无效。因此,这种方法避免了底层数据库文件中的碎片,这反过来又避免了抽真空的需要。在非常大的桌子中,这种抽真空可能非常昂贵。

尽管如此,这种方法确保了块的大小适当,以便最新的块可以保留在内存中,即使数据量可能会发生变化。

然后,通过主键进行分区获取每个时间间隔,并进一步将其分成多个较小的块,这些块都共享相同的时间间隔,但就主键空间而言是不相交的。这样可以在具有多个磁盘的服务器(包括插入和查询)以及多个服务器上实现更好的并行化。稍后将详细介绍这些问题。

img

如果每次的数据量增加,则块间隔减小以维持正确大小的块。如果数据提前到达,则将数据存储到“未来”块中以维持正确大小的块。


结果:插入率提高15倍

保持大小合适的大小是我们如何实现超越vanilla PostgreSQL的INSERT结果,Ajay已在他早期的帖子中展示

img

使用前面描述的相同工作负载插入TimescaleDB与PostgreSQL的吞吐量。与vanilla PostgreSQL不同,TimescaleDB保持恒定的插入速率(大约14.4K插入/秒,或144K度量/秒,具有非常低的方差),与数据集大小无关。

在单个操作中将大批量行写入TimescaleDB(而不是逐行)时,这种一致的插入吞吐量也会持续存在。这种批量插入是在更高规模的生产环境中使用的数据库的常见做法,例如,当从诸如Kafka的分布式队列中摄取数据时。在这种情况下,单个时间刻度服务器每秒可以摄取130K行(或1.3M指标),一旦表达到几行100M行,就会大约是vanilla PostgreSQL的15倍。

img

在执行10,000行批量的INSERT时,插入TimescaleDB与PostgreSQL的吞吐量。


概要

关系数据库对于时间序列数据非常强大。然而,交换内存的成本显着影响了它们的性能。但实现Log Structured Merge Trees的NoSQL方法只改变了问题,引入了更高的内存需求和糟糕的二级索引支持。

通过识别时间序列数据不同,我们能够以新的方式组织数据:自适应时间/空间分块。通过将工作数据集保持足够小以适应内存,可以最大限度地减少交换到磁盘,同时允许我们维护强大的主要和辅助索引支持(以及PostgreSQL的完整功能集)。因此,我们能够显着扩展 PostgreSQL,从而使插入率提高15倍。