Facebook TSDB论文翻译

本文为Facebook官方论文的翻译,原文地址http://www.vldb.org/pvldb/vol8/p1816-teller.pdf


概要

大型互联网服务一般以出现故障及时响应和保持高可用性为目标。为了提供正常稳定的服务,通常要每秒从大量系统中监控和分析数以千万计的数据(性能数据和业务数据)。一个特别高效的解决方案是用TSDB对这些数据进行存储和查询。

设计TSDB时的一个关键挑战是如何在性能、扩展性、稳定性这几者之间做出恰当的平衡。本篇论文主要介绍Gorilla,Facebook的内存级TSDB,我们认为做为监控系统来说,用户更关心数据的聚合分析,而不是具体某个时间点的数据;在快速检测和诊断一个正在发生的问题的根本原因时,最近的数据会比旧的数据更有价值。Gorilla针对高可用的读写做了优化,发生故障时甚至会以丢失少量最近写入的数据为代价来保证整体的可用性。为了提升查询性能,我们专门使用了一些压缩技术,例如delta-of-delta编码的时间戳、浮点型值的XOR压缩运算,这些技术让Gorilla减少了10倍左右的存储空间。存储空间的大幅减少使得我们可以将数据存储在内存中,与传统的基于Hbase的TSDB相比,查询耗时缩短了73倍,吞吐量提高了14倍。性能上的提升可以扩展出更多监控和排查问题的工具,例如时间序列关联搜索,数据更加丰富和密集的可视化工具。Gorilla也非常优雅的解决了单节点故障,让整个集群没有额外的运维开销。

 

1. 介绍

大型互联网服务旨在保持高可用性和对用户及时响应,即使是在出现意外故障的情况下。随着业务的增长,要支持更为庞大的全球化业务就需要将“少量系统、数百机器”扩展到上千个单独的系统,运行在数千甚至更多的机器上,通常还要跨越多个不同地域的数据中心。

运维这些大规模服务的一个重要前提是能够非常精准的监控系统的运行状况和性能,当问题出现时能够快速定位和诊断。Facebook使用时间序列数据库(TSDB)来存储系统的各项指标数据,并提供快速查询功能。后来在监控和运维Facebook服务时我们遇到了一些技术上的瓶颈,于是我们设计了Gorilla,一个内存级的TSDB,它每秒能存储千万级的数据(例如 cpu load, error rate, latency等),并能毫秒级返回基于这些数据的查询。

写入

我们对TSDB最核心的要求是能够保障数据写入的高可用性。由于我们有几百个系统、大量数据维度,数据的写入量很容易就会达到每秒千万级。相反,数据的读取量通常少好几个量级,这是因为读取主要是由一些自动化的监控系统发起,这些系统只会关心相对重要的数据,数据可视化系统也会占用一些读取量,它们将数据加工后通过数据面板、图表等方式呈现出来,另外,当需要去定位解决一个线上问题时,也会人为的发起一些数据读取操作。

状态转换

我们希望当系统的运行状况发生重大变化时能够在第一时间发现问题,例如新版本发布、某个线上变更引发异常、网络故障,或者其它一些原因。因此我们的TSDB需要具备在很短的时间内细粒度聚合计算的能力。这种在几十秒内迅速检测到系统状态变化的能力是非常有价值的,基于它就可以在故障扩散之前自动的做快速修复。

高可用

如果因为网络分区或者其它故障引发数据中心之间断连,每个系统当前连接的数据中心都应该具备将数据写入到本网络域内TSDB服务器的能力,并且在需要时可以在这些数据上做查询检索。

容错

我们希望写操作能同时复制到多个节点上,当某些数据中心或不同地理位置的节点发生不可预知的灾难时,也能承受损失。

 

Gorilla是Facebook研发的新的TSDB,在设计上实现了上文描述的几个点。Gorilla采用write-through cache方式将最近据记录到监控系统。我们的目标是让大多数查询在10ms内返回。

Gorilla的设计思路遵循一个观点,我们认为人们在使用监控系统时不会关注于孤立的数据点,而是更在意整体的聚合分析。此外,这些系统不会存储任何用户数据,所以传统的ACID特性并非TSDB的核心需求。但是,Gorilla在任何时候都要保障大部分的写都能成功,即使是面对那些可能使整个数据中心都无法访问的重大故障时也要如此。还有一点,最近的数据比旧的数据更有价值,从运维工程师的角度来说,排查某个系统或服务现在正在发生故障比排查一个小时前出的故障更容易给出直觉上的判断。Gorilla对高可用的读写做了优化,即使在发生故障时,也会以丢失少量数据为代价来保证整体的可用性。

技术上的挑战来自于高性能写入、承载的数据总量、实时聚合能力,以及稳定性方面的需求。我们依次解决了这些问题。为了实现前两个需求,我们分析了在Facebook使用很广并且也用了很久的监控系统– ODS时间序列数据库。我们注意到ODS中至少有85%的查询来自过去26小时采集的数据。进一步分析后我们决定将基于磁盘的数据库替换为基于内存的数据库,这样就能提供最好的服务。另一方面,将内存数据库做为磁盘存储的缓存,可以实现内存级的写入速度并拥有基于磁盘的数据持久性。

截止到2015年春天,Facebook的监控系统产生了超过20亿个唯一的时间序列计数器,每秒增加约1200万个数据点,这意味着每天会增加1万亿个数据点。每个数据点16个字节,每天就需要16TB的内存空间,这对于实际的部署而言是巨大的资源消耗。我们通过重用现有的基于XOR的浮点型数据压缩算法以流的方式解决了这个问题,平均每个点的压缩到1.37字节,缩小了12倍。

我们在多个不同地域的数据中心部署了Gorilla实例,并在实例之间做数据传输,但不会试图去保证数据的一致性,基于这种方式实现了稳定性方面的需求。数据读取会路由到最近且可用的Gorilla实例上。请注意,这种设计方式是基于我们对实际业务的理解,因为我们认为个别数据的丢失对整体的数据聚合不会有太大的影响,除非两个Gorilla实例之间存在重大的数据不一致。

Gorilla目前部署在Facebook的生产环境中,工程师们把它当做日常的实时数据工具,并协同其它监控和分析系统(例如Hive、Scuba)一起检测和诊断问题。

 

2. 背景和需求

2.1 ODS

Facebook的基础设施由数以百计个分布在不同数据中心的系统组成,如果没有一个监控系统来跟踪它们的健康和性能,要操作和管理它们是非常困难的。“操作数据存储”(ODS)是Facebook监控系统的一个重要部分。ODS包括一个时间序列数据库(TSDB),一个查询服务,以及一个检测和报警系统。ODS的TSDB是在HBase存储系统之上建立的,相关描述见文末引用[26]。图1整体展示了ODS的协作方式,时间序列数据由Facebook主机上的服务产生,通过ODS的写入服务收集,最终写入到HBase中。
Facebook TSDB论文翻译

ODS的时间序列数据有两类使用者。第一类使用者是那些依赖报表系统做数据分析的工程师,系统提供的图表以及其它可视化的交互分析都要从ODS中获取数据;第二类使用者是我们的自动化报警系统,它从ODS中读取计数器,将其与系统健康、性能、诊断指标等预设阈值进行比较,在必要时给oncall的工程师们和自动修复系统发出警报。

 

2.1.1 监控系统读取数据时的性能问题

2013年初,Facebook的监控团队逐渐意识到HBase时间序列存储系统难以扩展支撑未来的读取负载。在展现各种用于分析的图表时,平均的查询耗时是可以接受的,但是对于自动化系统来说,由于会产生大量的查询操作,排在末段的查询需要等前面的操作完成,时间的叠加导致它们可能需要等待数秒才会被执行到,系统就会被阻塞住。另外,如果对几千个时间序列进行中频次查询要花掉几十秒的时间,人们也会对自己使用方式产生怀疑;对稀疏数据集做并发更高的查询可能会超时,这是因为HBase的数据存储已经将策略调整为写入优先。虽然基于HBase的TSDB比较低效,但是我们也不能立刻就全部换掉,因为ODS上的HBase存储占用了2PB的数据。Facebook的数据仓库解决方案Hive也存在问题,它比ODS的查询延时还要高几个数量级,而查询的延时和效率恰恰是我们最关心的。

 

接下来我们将注意力放在了内存级的缓存上。ODS先前使用了一个简单的read-through cache读取方式,但它主要针对的是图表系统,它里面的多个显示面板会共享相同的时间序列数据。一个特别麻烦的问题是,当需要查询最近的数据时,如果缓存miss了就会直接将请求打到HBase存储上。我们也考虑了单独基于memcache的write-through缓存方案,最终还是没有采纳,这是因为将新数据添加到已有的时间序列上时需要先读再写,这会对memcache服务器产生极大的压力。我们需要一个更有效的解决方案。

 

2.2 Gorilla的需求

基于多方面的考虑,我们确定了下列需求:

  • 支撑20亿个通过字符串key唯一标识的时间序列
  • 每分钟能增加7亿个数据点(时间戳和值)
  • 内存能存储26个小时的数据
  • 高峰时每秒能支撑40000次以上的查询
  • 1毫秒内能读取成功
  • 时间序列支持15秒的间隔粒度(即每分钟每个时序序列有4个点)
  • 数据存储在2个不同的副本中(容灾能力)
  • 即使某个服务器挂了也能持续提供读取
  • 能快速扫描内存中的所有数据
  • 每年能支撑最少两倍的业务增长

本文的第3节,简单比较了其它几个TSDB,在第4节我们详细介绍Gorilla的实现,4.1首次讲述新的时间戳和数据值的压缩方法,4.4介绍当出现区域性的故障时Gorilla怎样保障高可用。第5节介绍围绕Gorilla打造的新工具。论文会以第6节介绍我们在开发和部署Gorilla方面的经验来结束。

 

3. 和其它TSDB比较

有很多已出版的书刊或论文里详细介绍了通过数据挖掘技术来高效的搜索、分类、聚合大量时间序列数据。它们系统的描述了时间序列数据的很多种使用方式,从数据收集到分类,再到异常检测,再到索引时间序列。然而详细描述系统如何实时收集和存储大量时间序列的示例比较少。Gorilla的设计专注于打造生产环境下可靠的实时监控,从其它TSDB的比较中脱颖而出。Gorilla有一个比较独特的设计思想,面对故障时保障读写的高可用比保障老数据的可用性具有更高的优先级。

由于Gorilla从一开始就被设计成将所有数据都存储在内存中,因此在内存结构上也不同于现有的TSDB。如果将Gorilla看作一个中间存储,用来在基于磁盘的TSDB上层的内存中存储时间序列数据,那么Gorilla可以以一个write-through cache方式用在任意的TSDB上(通过相对简单的修改)。Gorilla在数据写入速度和水平扩展能力上与已有方案类似。

 

3.1 OpenTSDB

OpenTSDB基于HBase,和ODS中用来存储long term数据的HBase存储层非常相似。两个系统都依赖相似的表结构,在优化和水平扩展上也有比较近似的方案结论。但是,我们发现在支撑构建更高级监控工具的高查询量时,要比基于磁盘的存储所能提供的查询更快。

和OpenTSDB不同,ODS HBase存储层会定时的将老数据进行聚合以节省空间,这导致ODS中的老数据相比更近的数据时间间隔粒度更大,而OpenTSDB永久保存全量数据。我们发现从成本较低的长时间片查询以及空间的节省上来说,数据精度的丢失是可以接受的。

OpenTSDB还有一个更丰富的数据模型来唯一识别时间序列,每个时间通过一组任意的k/v对来标识,也称为tags。Gorilla使用单个字符串key来标识时间序列,并依赖更高级的工具来提取和识别时间序列元数据。

 

3.2 Whisper(Graphite)

Graphite是一个RRD数据库,它用Whisper内置的格式将时间序列数据存储在本地磁盘上,这个格式假设时间序列数据是按固定时间间隔产生的,不支持间隔跳动时间序列。Gorilla在对固定时间间隔的数据处理上效率更高,并且能支持任意和不断变化的时间间隔。Whisper中的每个时间序列都存储在单独的文件中,一定时间之后新的数据会覆盖老的数据。Gorilla使用了类似的方式,只不过最近的数据是存储在内存中。但是,由于Graphite/Whisper采用的是磁盘存储,对于Gorilla要解决的问题来说,查询耗时还是太高了。

 

3.1 InfluxDB

InfluxDB是一个新的开源时间序列数据库,和OpenTSDB相比有更丰富的数据模型,时间序列中的每一个事件都可以包含完整的元数据,尽管这样更具灵活性,但是和只在数据库中保存时间序列相比,必然导致更大的磁盘占用率。

InfluxDB还包含一些可以扩展的代码,允许用户的这些代码上将它水平扩展为分布式存储集群,而不需要像OpenTSDB那样还有运维HBase/Hadoop集群的开销。在Facebook,我们已经有专门的团队来支持HBase设施,将他们用在ODS不需要投入大量额外的资源。和其它系统一样,InfluxDB将数据保存在磁盘上,这也导致查询速度比存储在内存中慢。

 

4. Gorilla架构

Gorilla是一个基于内存的TSDB,在监控数据写入HBase存储时,起到一个write-through cache的作用。存储在Gorilla的监控数据是一个简单的3元组字符串key,时间戳是64位整型,值是双精度浮点型。Gorilla采用了一种新的时间序列压缩算法,可以按照时间序将数据从16字节压缩到平均1.37字节,缩小12倍。此外,我们专门设计了Gorilla的内存数据结构,在保持对单个时间序列进行时间段查找的同时也能快速和高效的进行全数据扫描。

监控数据中定义的key用来唯一标识一个时间序列,通过对基于key的数据进行分片,每个时间序列数据集都会被映射到一台单独的Gorilla主机上。因此,我们可以通过简单的扩展主机并调整分片算法将新的时间序列数据映射到新的主机上,从而达到扩展Gorilla的目的。Gorilla 18个月前在生产环境运行时,26小时内的全量时间序列数据占用1.3TB的内存,均匀分布在20台机器上。在那之后,我们必须将集群的规模扩为两倍来应对两倍的数据增长,现在每个Gorilla集群有80台机器在运行。扩展的过程很简单,这是因为无状态的架构有非常好的水平扩展能力。

Gorilla将时间序列数据写到不同地域的主机中,这样就能容忍单节点故障,网络切换,甚至是整个数据中心故障。在检测到故障时,所有读取操作会failed over到备用节点,以确保用户不会感知到任何中断。

 

4.1 时间序列压缩

在评估创建新内存级时间序列数据库的可行性时,我们考虑了几种现有的压缩方案,以减少存储上的开销。我们认为仅适用于整型数据的压缩技术不能满足存储双精度浮点型数据的需求;其它的一些技术作用于完整的数据集,但不支持对存储在Gorilla中的数据流进行压缩;我们还发现数据挖掘领域会使用有损的时间序列近似技术,这样会更适合用内存来存储,但是Gorilla更关注于保持数据的完整性。

我们受到了从科学计算中推导出来的浮点型压缩方法的启发,该方法利用与前面值的XOR比较来生成一个差值编码。

Gorilla对时间序列中的数据点进行压缩,不会有额外的跨时间序列压缩。每个数据点是一对64位的值,代表那个时间的时间戳和值。时间戳和值根据前面值分别进行压缩。整体的压缩方案见图2,图里展示了时间戳和值是如何在压缩块中运算的。
Facebook TSDB论文翻译

图2-a表明时间序列数据是由时间戳和值组成的数据流,Gorilla按照时间分区将数据流压缩到数据块中。这里先定义了一个由基线时间构建的Header(图例中从2点开始),然后将第一个值进行了简单的压缩存储,图2-b是通过delta-of-delta压缩后的时间戳,这个在4.1.1节会做更详细的描述。图中给出的delta of delta值为-2,用2位来存储header(‘10’),7位来存储值,总位数只有9位。图2-c显示了XOR压缩后的浮点值,4.1.2节有更详细的描述。通过将浮点值与前面的值进行XOR操作,我们发现只有一个有意义的位。用两位编码header(‘11’),编码中有11个前置0,一个有意义的位,其实际值为(‘1’),一共用14位进行存储。

 

4.1.1 时间戳压缩

我们分析了ODS中存储的时间序列数据,决定对Gorilla的压缩方案做优化。我们发现绝大部分ODS数据在固定的时间间隔产生,例如每60秒记录一条数据的时间序列普遍存在,偶尔有一些数据有提前或推迟1秒生产出来,但在入口一般都是有约束的。

相比于存储整个时间戳,我们只存储的“差值的差值”,这样会更高效。如果某个时间序列后续的时间与前面时间的差值分别为60,60,59,61,那么“差值的差值”是用当前的时间戳差值前去前一个差值,那么计算出的“差值的差值”为0,-1,2。图2给出了示例。

接下来我们用下面的规则对“差值的差值”做可变长的编码:

1. 数据块的header存储了一个开始的时间戳t-1,这里对齐到2点钟;第一个时间戳t0,用14位存储与t-1的差值
2. 对于时间戳tn:
  1. 计算差值的差值为:D = (tn – tn-1) – (tn-1 – tn-2)
  2. 如果D=0,用一个单独的位来存储’0’
  3. 如果D在[-63,64]之间,存’10’,后面为值(7位)
  4. 如果D在[-255,256]之间,存’110’,后面为值(9位)
  5. 如果D在[-2047,2048]之间,存’1110’,后面为值(12位)
  6. 其它情况存’1111’,后面用32位存D的值

 

这些不同取值范围是从生产环境真实的时间序列中采样出来的,每个值都能选择合适的范围以达到最好的压缩比。虽然一个时间序列可能有时会丢失部分数据,但是它现存的数据很可能都是以固定的时间间隔产生的。举个例子,假设在丢失了一个数据点后的差值为60,60,121,59,那么差值的差值就是0,61,-62。61和-62适配最小的取值范围,就会更少的位数来编码。下一个取值范围[-255, 256]也很有用,当每4分钟产生一条数据时,如果丢失了某条数据仍然可以适配这个取值范围。

图3展示了Gorilla中时间戳最终值的分布情况,我们发现有96%的时间戳都能被压缩到1个单独的位来存储。
Facebook TSDB论文翻译

 

4.1.2 值压缩

除了对时间戳做压缩外,Gorilla也对值进行了压缩,3元组字符串中的数据值为双精度浮点型。我们使用的压缩方案和现在已有的浮点型数据压缩算法类似,文末的参考文献[17]和[25]有相关描述。

通过分析ODS的数据发现,大多数时间序列内相邻数据点的值不会有明显的变化,此外,很多数据来源只会存储整型的值。这就使得我们可以将文末参考文献[25]的昂贵方案调整为更简单的实现,仅用将当前值和前面的值做比较。如果值接近,那么浮点型数据的符号位,指数位和尾数部分的前几位,会是完全相同的,基于这点,我们对当前值和前序值使用一个简单的XOR运算,而不是像时间戳那样用差值编码的方案。

我们用下面的规则对XOR后的值进行可变长编码:

1. 第一个值不做压缩
2. 如果与前序值的XOR结果为0(即值相同),仅用一位存储,值为’0’
3. 如果XOR结果非0,控制位的第一位存’1’,接下来的值为下面两种之一
   a) 控制位’0’ — 有意义的位(即中间非0部分)的数据块被前一个数据块包含,例如,与前序值相比至少有同样多的         前置0和同样多的尾部0,那么就可以直接的数据块中使用这些信息,并且仅需要存储非0的XOR值。
   b) 控制位’1’ — 用接下来的5位来存储前置0的数量,然后用6位存储XOR中间非0位的长度,最后再存储中间非0位

 

使用XOR运算编码对时间序列高效的存储压缩方案在图2有直观的展现。

图5是Gorilla中实际的数据分布情况,大约有59%值只用了1位存储,也就是当前值和前序值完全一样;28.3%控制位为’10’(上面提到的规则a),平均占用26.6位;余下12.6%的控制位为’11’,平均占用36.9位,位数多是因为对前置0和中间非0位的长度编码需要额外13位。
Facebook TSDB论文翻译

这种压缩算法同时使用了前序值和前序XOR值,这样会使最终的结果值具有更好的压缩率,这是因为一段连续XOR值的前置0和尾部0个数往往非常接近,见图4。这种算法对整型的压缩效果更好,整型值经过XOR运算后的中间段位的位置一般在整个时间序列中对齐的,意味着大多数XOR值有相同个数的尾部0。

 Facebook TSDB论文翻译

我们的编码方案有一个折衷是压缩算法的时间跨度,在更长的时间跨度上使用同样的编码能够获得更好的压缩比,但是这个跨度上的短时间区间查询可能需要在数据解码上消耗额外的计算资源。图6是存储在ODS中的时间序列在不同数据块大小下的平均压缩率,可以看出块大小超过两个小时以上后,数据的压缩率收益是逐渐减少的,一个两小时时长的块可以将每个点的数据压缩到1.37字节。

Facebook TSDB论文翻译 

4.2 内存数据结构

Gorilla实现中主要的数据结构是一个时间序列Map (TSmap),图7提供了这个数据结构的整体概览。TSmap包含一个C++标准库中的vector,里面是指向时间序列的指针;还包含一个map,key为时间序列的名称,不区分大小写并保留原有大小写,值是和vector中一样的指针。vector可以实现全数据分页查询,而map可以支撑指定时间序列的定长时间段查询,要满足快速查询的需求必须要具备定长时间段查询的能力,同时也要满足有效的数据扫描。

 Facebook TSDB论文翻译

C++的指针可以在扫描数据时仅用几微秒就能将整个vector或其中的几页拷贝,避免对新写入到数据流的数据产生影响。被删掉的时间序列在vector中为“墓碑状态”,它的索引会被放置到一个空间的池中,当产生新的时间序列时会复用它。“墓碑状态”实际上是将一段内存标记为’dead’,并准备好被重用,而不会实际将资源释放到底层系统。

在TSmap上有一个读-写自旋锁来保护对map和vector的访问,每个时间序列上也有一个1字节的自旋锁,通两个锁保证了并发的能力。对于每个单独的时间序列来说写的量相对校少,所以读和写也只有非常少的锁争用。

 

如图7所示,分片唯一标识(shardId)与TSmap之间的映射存在ShardMap中,它也是一个vector,存储了TSmaps的指针,它使用了和TSmap一样对大小写不敏感的hash算法将时间序列名称映射到各个分片,hash后的值在 [0,shard数量)区间内。由于系统中分片的数量是恒定的,并且总量在几千以内,所以存储空指针的额外开销基本上可以忽略。和TSmaps一样,ShardMap有一个自旋锁来处理并发访问。

由于数据已经划分为分片,单个map可以保持足够小(约100条记录),C++标准库中的unordered-map有足够好的性能,没有锁争用的问题。

 

时间序列的数据结构有两个重要组成部分,一部分是一系列关闭的数据块,块中的数据超过2小时;另一部分是一个开放的数据块,存最近的数据。开放块是个append-only字符串,新的时间戳和值压缩后追加这个字符串上。每个块只存储2小时的压缩数据,当数据写满后块会变为关闭,一旦块关闭了就不能再对其做修改,除非将它从内存中剔除。关闭后,会根据使用的slab总大小分配出一个新的块来存数据,这是因为每次开放块在关闭时实际用掉的空间都不一样,我们发现使用这种方式在整体上会减少Gorilla产生的内存碎片。

时间范围查询关联的数据块被会拷贝出来直接读到调用端,返回给客户端的是整个数据块,使得解压过程在Gorilla外完成。

 

4.3 磁盘数据结构

Gorilla的目标之一是能应对单机故障。Gorilla通过将数据存储在GlusterFS来实现持久化,GlusterFS是一个分布式文件系统,三复本存储,兼容POSIX。HDFS或者其它分布式文件系统也同样很容易应对单机故障,我们同时也考虑了单主机数据库比如MySQL和RocksDB,不过还是决定不使用这类数据库,因为我们的持久化使用场景不需要数据库层面的查询语言。

一台Gorilla主机能存储多个数据分片,每个分片上维护着一个文件目录,每个文件目录包括4种类型的文件:key列表,append-only日志,完整的块文件,checkponit文件。

Key列表中的值是时间序列名和一个整型标识的简单映射,整型标识就是内存中vector的下标。新的key追加在这个列表中,Gorilla会定期对每个分片上的key做扫描,以便重写到文件。

当数据流入到Gorilla时也会被存储到一个日志文件中,时间戳和值用前面4.1节描述的格式压缩。但是每个分片上只有唯一的一个append-only日志文件,因此数据会交叉跨越多个时间序列。和内存编码不同的是,每个时间戳和值还要加上32位的整型ID做标记,所以相比之下每个分片上的日志文件会增加明显的存储开销。

Gorilla不提供ACID特性,同样,上面提到的日志文件也不是WAL日志,数据被刷到磁盘之前会先到缓存区,最多到64K,一般会包含1到2秒的数据。虽然在正常退出系统时缓冲区的数据会刷到磁盘,但是当发生异常崩溃时可能会导致少部分数据丢失。相比传统的WAL日志带来的收益,我们觉得这个取舍是值得的,因为可以以更快速率将数据刷到磁盘,也能支撑更加高可用的数据写入。

每隔两小时, Gorilla将数据块中的压缩数据拷贝到磁盘,这种格式的数据远小于日志文件中的数据。 每两小时的数据有一个完整的数据块文件,它有两部分:一组连续的64KB数据块,它们直接从内存中复制而来,以及一系列由<时间序列ID,数据块指针>组成的值对。一旦某个块文件完全刷到磁盘,Gorilla会刷下checkpoint文件并将相应的日志删除,checkpoint文件用来标记一个完整的数据块什么时候被刷到磁盘。如果在遇到进程崩溃时块文件没有被成功刷到磁盘,那么在新的进程启动时对应的checkpoint文件是不存在的,因此这个时候每次启动新的进程时除了读取块文件之外,还会从日志文件中读取checkpoint之后的数据。

 

4.4 故障处理

对于容错,我们选择优先考虑单节点故障,大规模的感知不到当机时间的临时性故障,以及区域性故障(比如整个区域网络中断)。这是因为单节点故障发生比较频繁,而大规模的,区域性故障已经成为整个Facebook比较关注的问题,需要有应对自然或人为灾害的能力。我们对待故障的处理方式有一个另外的好处,那就是可以将滚动式的软件升级模拟成一组可控的单节点故障,对这种情况做优话意味着我们可以轻而易举并且很频繁的做代码推送。对于其它故障我们选择折衷处理,如果故障会引起数据丢失,将优先考虑最近数据的可用性而不是老数据,这是因为对历史数据的查询可以依赖已有的Hbase TSDB,一些自动化系统检测时间序列的变化对部分数据仍然有用,只要有最新的数据产生就会有新老数据比较。

Gorilla通过在不同的数据中心中维护两个完全独立的实例,来确保在数据中心故障或网络分区情况下的高可用性。一笔数据写入会流入到每个Gorilla实例,而不会尝试去保证数据的一致性,这就使得大规模故障比较容易处理。当整个区域出现故障时,查询会指向到其它可用节点,直到之前的节点已经备份了26小时的数据。这对于处理真实的或模拟的大规模故障非常重要,举个例子,区域A的Gorilla实例完全挂掉了,对这个区域实例的写入和读取会失败,失败的读取会透明地路由到健康的区域B中的实例。如果故障持续了很久(超过1分钟),数据将从区域A中删除,请求不再会被重试。发生这种情况时,区域A上的所有读都会被拒绝,直到区域A的集群重新健康运行26小时,这种处理方式在故障发生时可以手动或自动执行。

 

在每个域内都有一个基于Paxos算法名为ShardManager的系统为节点分配分片,当某个节点发生故障时,ShardManager会将这个节点的分片分发给集群中的其它节点。分片在节点之间迁移时,写入的数据先缓存在客户端缓冲区,缓冲区可以保存1分钟的数据,超过1分钟的数据将会被丢弃,以方便为更新的数据留出空间。我们发现大多数情况下这个时长足够用来重新分片,而对于需要消耗更长时间的情况,最新的数据优先级也更高,因为数据越新从直观上看对操作自动检测系统越有用。当区域A的一台主机α崩溃或者由于其它任何原因提供不了服务,写入操作至少会缓冲1分钟,这时Gorilla集群会尝试重启这台主机。如果集群内的其它主机是健康的,故障主机的分片会在30秒或更少的时间内发生迁移,以确保没有数据丢失。如果分片迁移的动作没有及时发生,数据的读取将会被指向到区域B中的Gorilla实例上,这个操作可以通过手动或自动完成。

当分片被分配到某台主机时,会从GlusterFS读取全部数据,这些分片在调整之前可能是属于同一主机。新主机从GlusterFS读取和处理完整可用的数据大约需要5分钟时间,这是因为系统中存储的shard数量和总数据量的原因,每个分片标志着16GB的磁盘存储,这些数据分布在不同的物理机上,几分钟就可以从GlusterFS中读取出来。当主机正在读取分片数据时,也会接受新的数据写入,新的写入会被放到一个缓冲队列,队列中的数据会被尽可能快的处理。分片数据处理完成后立刻开始消费缓冲队列,将数据写到这台新的主机上。回到前面区域A中的主机α崩溃的例子:当α崩溃时,它的分片被重新分配给同集群的主机β,一旦β被分配了这些分片就开始接受数据写入,因此从内部来看没有数据丢失。如果Gorilla的主机α能够以一个更可控的方式中断服务,那么在它停服之前就能安全的所有数据都刷到磁盘上,所以对于规模化的软件升级来说也不会有数据丢失。

 

在我们这个例子中,如果主机α在数据刷盘成功之前挂掉,数据就会丢失。实际中这种情况很少发生,即使发生了通常也仅会丢失几秒钟的数据。这种处理方式是我们的一种权衡,它可以让集群能有更高的写入吞吐量,并且在故障停机之后能够更快接收最新的数据。此外,我们也对这种情况有监控,在故障发生后能够将读切到更健康的节点。

要注意的是,当节点故障时有些分片可能有部分数据不可读,要等到新的节点将这些分片的数据完全从磁盘读取出来。查询可能只返回部分数据(块文件的读取顺序按时间从近到远)并在结果中标记为部分数据。

当处理数据读取的客户端库从区域A的Gorilla实例上接收到一个“部分的”结果时,它会从区域B的实例中再次读取那些受影响的时间序列,如果区域B有完整的数据,就使用B的这份数据。如果A和B都只有部分数据,会把这两部分数据都返回给调用者,并在结果里面做打个标,标明是因为某些错误导致数据不完整。接下来调用者可以决定这些数据是否有足够的信息量来继续处理请求,或者可以认为本次处理失败。我们做出这样的选择是因为Gorilla最常用于自动化系统来检测时间序列的数据变化状况,即使只有部分数据,这些系统也可以运行得很好,只要这些数据是最近最新的。

 

将读取从不正常的主机自动转发到正常运行的主机意味着用户可以免受重启和软件升级的影响,我们发现升级软件的版本时不会导致数据丢失,并且在没有人工干预的情况下所有的读取也能继续成功执行这就使得Gorilla从单机故障到区域性故障都能够透明的提供读取服务。

最终,我们仍然使用我们的HBase TSDB做long-term storage。如果内存中所有的数据丢失,我们的工程师们仍然可以在更加持久的存储系统中继续处理数据分析和专门的查询,并且一旦服务重启并开始接受新的数据写入,Gorilla就可以继续进行实时数据检测。

 

5. Gorilla上的新工具

Gorilla的低延时查询特性推动产生了一些新的分析工具。

 

5.1 关联引擎

首先是一个运行在Gorilla上的时间序列关联引擎,关联搜索可以让用户对大量时间序列做交互式,蛮力搜索,目前限制在每次100万个时间序列。

关联引擎将测试时间序列和大的时间序列集做比较来计算皮尔森产品-时间相关系数(PPMCC)。PPMCC具有在相同形状走势的时间序列之间找到他们的关联性的能力,无论时间序列是什么样的规模。这大大有助于通过自动化方式分析故障的根本原因,并回答“当服务挂掉时发生了什么”。我们发现这种方法能够带来比较满意的结果,并且比本文末尾引用的文献中描述的类似方法实现起来更简单。

要计算PPMCC,测试时间序列需要和全量时间序列一起分布在每台Gorilla主机上,然后各个主机各自计算出前N个有关联关系的时间序列,根据与测试数据相比的PPMCC绝对值排序,并将时间序列值返回。在将来,我们希望Gorilla在时间序列数据的监控上能拓展出更先进的数据挖掘技术,例如文末引用的文献[10,11,16]中描述的分类归并和异常检测技术。

5.2 图表

低延时的查询还能扩展出更大查询量级的工具。举个例子,与监控团队无关的工程师们创建了一个新的可视化数据界面,它要展示大量线型图表,而这些图表数据本身就是从大量时间序列中化简计算来的。这种数据可视化方式让用户能够快速直观的浏览大批量数据,以发现有异常数据值以及与时间相关的异常现象

5.3 聚合

最近,我们将在后台对数据做汇总叠加的程序从一组map-reduce任务中迁移到了Gorilla上直接执行。回想前面对ODS的介绍,ODS对老数据进行基于时间的聚合(或汇总叠加)压缩,这种压缩是有损的,会让数据之间的间隔度更大,类似于Whisper使用的压缩格式。在Gorilla之前,map-reduce任务运行在HBase集群上,先读取出过去一小时的全部数据,进行计算,然后输出到一张新的低粒度的表中。现在,一个后台定时程序每隔两小时扫描全量数据,再生成新的数据到低粒度表中。我们之所以更换实现方案是因为在Gorilla中做全数据扫描是非常高效的,方案的更改减少了HBase集群的负载,我们再也不用将所有高密度的数据写到磁盘,并在HBase上执行开销昂贵的全表扫描。

 

6. 经验

6.1 容错

我们接下来介绍过去6个月发生的几个预期内和预期外的事件,这些事件在一定程度上影响了Facebook站点的可用性,这里我们只限于讨论对Gorilla有影响的事件,因为其它问题超出了本文的范畴。

网络中断。3起预期外的发生在部分机器的类似网络中断/故障事件,网络中断被自动检测到,Gorilla自动将读重定向到未受影响的区域,没有任何服务中断。

应对计划内的灾难。1起计划内的大型消防演练,模拟某个后端存储所在处的网络全部中断。根据上面描述的做法,Gorilla将读切到未受影响的区域,一旦故障区域被恢复,手动从日志文件拉取故障时间段的数据,从而使故障区域提供的数据面板可以向最终用户展示预期内的数据

配置变更和代码推送。有6次配置变更和6次代码发布需要在重启指定区域内的Gorilla。

Bug。一个带有重大bug的发布部署到了某个区域,Gorilla马上将负载转移到其它区域继续提供服务,直到bug解决。在输出的数据中,只有极小的数据准确性问题。

单节点故障。有5次单机故障(与上面说的bug无关),没有引起数据丢失,无需修复。

过去6个月,Gorilla没有出现任何引发检测异常和报警问题的事故。自从Gorilla推出以来,只有1次事件影响了实时监控。在任何时候 ,持久化存储为所有与监控相关的查询扮演备份的角色。

 

6.2 排查和修复网站故障

关于Facebook如何使用时间序列数据来支撑业务监控的例子,可以看看最近一个依靠监控数据来快速检测和修复的问题,我们在SREcon15中首次对外介绍了这次事件。

一个神秘的问题导致网站错误率出现高峰,错误率上升几分钟后在Gorilla可以观察到异常,这时由监控系统发出一个异常警报,几分钟后警报通知到相关的技术团队。然后,辛苦的问题修复工作开始了,一组工程师缓解了这个问题,其它人开始寻找问题的根源。通过使用基于Gorilla构建的工具,包括前面第5节介绍的时间序列关联搜索,他们发现将发布的二进制包拷贝到Facebook web服务器这个常规流程出了问题,导致整个网站内存使用率下跌,见图10。问题的检测,各种各样的调试和故障原因分析,依赖于在Gorilla高性能查询引擎上构建的时间序列分析工具。

 Facebook TSDB论文翻译

自从约18个月前推出以来,Gorilla已经帮助Facebook的工程师们识别和排查出了几个类似的生产环境问题。通过将前90%的查询的响应速度降到10ms,Gorilla也提升了开发人员的工作效率。另外,现在85%的监控数据都来自Gorilla,只有少量查询会打到HBase TSDB上,这也让HBase集群负载变得更低。

 

6.3 经验教训

重点考虑最近的数据而不是历史数据。Gorilla在优化和设计定位上比较独特,虽然必须表现得非常可靠,但是它不需要ACID规则来为数据做保障。事实上,我们发现最近的数据在可用性上比过去的数据更为重要,这让我们在设计上做了比较有意思的权衡,例如在从磁盘读取出老数据之前保持Gorilla在数据读取上的可用性。

读取的延时。高效的压缩和内存级数据结构极大的加快了数据读取的速度,并且促进增加了很多使用场景。当Gorilla推出时ODS每秒支撑450次查询,很快Gorilla就超过了它,目前每秒处理超过5000次常规查询业务,峰值时达到每秒40000的查询,如图9所示。低延时的读取鼓励我们的用户在Gorilla之上构建更高级的数据分析工具,如第5节的描述。
Facebook TSDB论文翻译

高可用性胜过资源使用效率。容错能力是Gorilla的一个重要设计目标,它需要具备在不影响数据可用性的情况下承受单机故障的能力。此外,提供的服务还必须能够承受可能影响到整个区域的灾难性事件。基于这个目标,我们在内存中保存两份冗余的数据副本,即使这样会影响资源的使用效率。

 

我们发现开发一个可靠的,有容错能力的系统是整个项目中最耗时的部分。虽然开发团队在非常短时间内就开发出了一个高性能、数据压缩存储的内存级TSDB原型,但是接下来通过几个月的努力工作才让它具备容错能力。不过当系统的生命力在面临真实或模拟的故障挑战时,容错能力带来的好处是显而易见的。一个可以安全重启,升级,能随时新增节点的系统总能让技术人员从中受益。容错能力也让我们能够以较低的运维成本有效扩展Gorilla,同时为我们的客户提供高度可靠的服务。

 

7. 接下来的工作

我们希望通过几种方式来扩展Gorilla。一种方向是在Gorilla内存存储和HBase存储之间增加一个更大的基于闪存的二级存储。这个存储用来存放每两小时生成一次的经过数据压缩之后的分片,但是总容量会比26小时更长,我们发现闪存可以存储约2周的全量无损的、Gorilla格式压缩后的数据,数据时段拉长对工程师们排查问题是很有用的。图8是初步的性能测试结果。

 Facebook TSDB论文翻译

在创建Gorilla之前,ODS依赖HBase背后的存储做为实时数据存储:在数据写入到ODS存储后很短时间,需要被用于读取操作,这给HBase的磁盘I/O带来了很大的压力。现在Gorilla充当最近数据的write-through缓存,在数据发送到ODS存储后的26小时内都不用从HBase读取。我们正在利用这个特点重新调整数据写入链路,让数据在写入到HBase之前多等待一段时间,这个优化应该会对HBase更有效果,但是目前这个方向还处于早期,没有相当的对比数据。

 

8. 总结

Gorilla是我们在Facebook开发和部署的一个新的内存时间序列数据库,Gorilla做为一个write-through cache,用来收集所有Facebook系统上过去26小时的监控数据。在这篇文章中,我们介绍了一种新的压缩方案,让我们能够每分钟高效的存储700万个数据点的监控数据。此外,与磁盘级的TSDB相比,Gorilla使我们生产环境的查询耗时缩短了70多倍。基于Gorilla创建了一些新的监控工具,包括报警、自动修复以及一个在线异常检查器。Gorilla已经部署运行了18个月,在这期间经历了两次翻倍扩容,而没有太多运维上的工作,这证明我们的解决方案具有可扩展性。我们还通过几次大规模模拟真实线上故障的演练验证了Gorilla的容错能力 — Gorilla在这些事件中仍然保证了读写的高可用,帮助网站在故障中恢复。

上一篇:缓存失效瞬间高并发访问导致数据库负载升高的问题处理


下一篇:HiStore:阿里巴巴海量数据场景下的OLAP解决方案