Graphite

Graphite1执行两个相当简单的任务:存储随时间变化的数字并绘制它们。多年以来有许许多多的软件能做到同样的事情,Graphite能脱颖而出是因为它以易使用并且可扩展的网络服务的形式提供这个功能。向Graphite输送数据的协议简单到你能在几分钟内学会该怎么做(并不是因为你会想这么做,而是因为这是检测简单性的有效方法)。绘制图形和检索数据点容易的像获取一个网址。这使得Graphite和其它软件的结合非常自然并且让用户能够在Graphite的基础上建造出更强大的软件。Graphite最常见的用途是建造基于网络的用于监视和分析的仪表盘。Graphite诞生在高流量的电子商务环境下,它的设计也体现这一点。可扩展性和实时数据访问是主要目标。

让Graphite得已实现这些目标的部件包括一个专门的数据库及其存储结构、一个优化I/O操作的缓存机制和一个简单但有效的Graphite服务器聚集的方法。比起简单的描述Graphite现在是如何工作的,我更愿意解释Graphite最初是如何实现的(相当天真)、我遇到了什么问题以及我是如何解决这些问题的。

7.1. 数据库:存储时间序列数据

Graphite完全用Python写成,它由三个主要部分构成:一个名为 whisper 的数据库,一个名为 carbon 的后端守护进程,以及一个显示图形并提供基本UI的前端web应用。虽然 whisper 是专门为Graphite写的,但是它也可以被单独使用。从设计上来说,它和RRDtool使用的RRD数据库十分相似,只存储时间序列数字数据。我们通常将数据库视为服务器进程,客户端应用通过sockets与之交互。但是 whisper 是一个被应用用来操作和获取存储在特殊格式文件中数据的数据库,这一点与RRDtool十分相似。最基本的 whisper 操作是 create 用于创建一个新的 whisper 文件, update 用于将新的数据点写到文件中,以及 fetch 用于获取数据点。

图7.1: **whisper** 文件的基本结构                                  

像图7.1中展示的那样, whisper 文件由一个包含各种原数据的header节以及跟在其后的一个或多个archive节构成。每个archive都是一系列的连续数据点,这些数据点的格式为 (timestamp, value) 对。每当一个 updatefetch 操作被执行时, whisper 根据timestamp以及archive配置来确定数据在文件中被写入或读取的位置。

7.2. 后端:简单的存储服务

Graphite的后端是一个名为 carbon-cache 的守护进程,简称 carbon 。它基于一个名为Twisted的高度可扩展的Python事件驱动I/O框架。Twisted使得 carbon 能够低开销的与大量客户端交互并处理大量数据。图7.2展示了 carbonwhisper 以及web应用之间的数据流:客户端应用收集数据并将其发送给Graphite后端 carbon ,然后 carbon 通过 whisper 存储数据。这些数据则被web应用用来生成图形。

图7.2:数据流                                  

carbon 主要的功能是存储客户端提供的度量的数据点。在Graphite术语中,一个度量是任何随时间变化的可测量的数量(比如一个服务器的CPU使用率以及产品的销量)。一个数据点就是一个 (timestamp, value) 对,其与某个时间点上对一个特定度量的测量值相一致。度量由他们的名字唯一确定,而它们的名字和其数据点一样由客户端应用提供。比较常见的客户端应用类型是监控代理,其功能是收集系统或应用度量并向 carbon 收集到的数据用于简易存储和可视化。Graphite中的度量有简单分层的名字,这和文件系统路径名十分相似,唯一的区别是用于分层的是点而不是斜杠或反斜杠。 carbon 接受任何合法的名字并且为每个度量创建一个 whisper 文件用于存储其数据点。 whisper 文件存储在 carbon 的数据目录下,其路径名由度量的名字映射而来,例如 servers.www01.cpuUsage 映射到 …/servers/www01/cpuUsage.wsp

每当一个客户端应用想要发送数据点给Graphite时,它都需要与 carbon 建立一个TCP连接,这个连接通常使用20035端口。所有的数据发送都由客户端完成; carbon 在连接过程中不发送任何东西。客户端以简单的纯文本格式发送数据点,而这个连接可能会被保持以备后续使用。这个格式是每个数据点一行,每行包含度量名、值以及一个Unix时间戳,它们之间以空白分割。比如客户端也许会发送如下数据:

‘’servers.www01.cpuUsage 42 1286269200

products.snake-oil.salesPerMinute 123 1286269200

[one minute passes]

servers.www01.cpuUsageUser 44 1286269260

products.snake-oil.salesPerMinute 119 1286269260’’

从顶层来看, carbon 所做的所有事情就是监听这个格式的数据并通过 whisper 将其尽可能快的存储到磁盘上。稍后我们将会讨论一些在典型磁盘下用于保证可扩展性并且获取最佳表现的小技巧的细节。

7.3. 前端:请求式图形

Graphite的web应用允许用户通过一个简单的基于URL的API来请求定制图形。绘图参数通过一个HTTP GET请求的查询字符串确定,一个PNG图形将会被返回给用户。例如如下URL:

‘’http://graphite.example.com/render?target=servers.www01.cpuUsage&width=500&height=300&from=-24h''

为度量 servers.www01.cpuUsage 及其过去24小时的数据请求了一个500×300的图形。事实上,只有target参数是必须的;其他参数都是可选的,如果省略的话将会使用默认值。

Graphite支持很多显示选项以及数据操作函数,调用这些函数只需要通过一个简单的功能语法。比如我们可以按照以下格式为前例中的度量绘制一个有10个点的移动平均图:

target=movingAverage(servers.www01.cpuUsage,10)

考虑到复杂的表达式和计算,函数可以嵌套。

下面是另一个例子,它给出了当天的销量累计,其度量为每分钟的销量:

‘’target=integral(sumSeries(products.*.salesPerMinute))&from=midnight’’

sumSeries 函数计算出匹配 products.*.salesPerMinute 的度量之和的时间序列。随后 integral 计算出累计和而不是每分钟的数量。由此不难想象应该如何构建一个网站UI来查看和操作图形。像图7.3中展示的那样,Graphite有它自己的Composer UI,这个UI能随着使用者点击各个选项通过Javascript来修改图形的URL参数。

图7.3:Graphite的Composer界面                               

7.4. 仪表盘

从一开始Graphite就作为创建基于网站的仪表盘的工具被使用。URL API使得这成为一个非常自然的使用方法。创建一个仪表盘就和创建一个满是如下标签的HTML页面一样简单:

<img src="../http://graphite.example.com/render?parameters-for-my-awesome-graph">

但是,不是每个人都喜欢手动处理URL,因此Graphite的Composer UI提供了一个点击式的方法来创建一个图形,通过这个方法用户只需要复制粘贴URL就可以达到目的。当Graphite和另外一个允许快婿创建网站页面的工具(如wiki)结合使用时,创建仪表盘将会简单到非技术用户也能轻松搞定。

7.5. 一个明显的瓶颈

我的用户一开始创建仪表盘,Graphite很快就遇到了性能上的问题。我研究了网站服务器日志来查看是什么请求让它陷入了泥沼。结果表明问题出在绘图请求的数目上。由于一直在渲染图形,web应用遇到了CPU瓶颈。我发现Graphite收到了大量相同的请求,而这是由仪表盘造成的。

想象一下已有一个包含10张图的仪表盘,它每分钟刷新一次。每当一个用户在浏览器中打开这个仪表盘,Graphite每分钟就需要多处理10张图。这个代价很快就变得十分的高昂。
一个简单的解决方案是每个图形只绘制一次,而每当用户发出请求时就返回给他们这个图形的复制品。Django网站框架(Graphite基于其建造)提供了一个极佳的快速缓存机制,它可以使用多种后端比如分布式内存对象缓存系统。分布式内存对象缓存系统3本质上就是一个作为网络服务提供的哈希表。客户端应用可以像操作普通的哈希表一样获取和设置键值对。使用分布式内存对象缓存系统最主要的好处是一个高代价请求(如渲染一个图形)的结果可以被快速存储并且在处理接下来的请求的时候被取出。为了避免一直返回同一张图,该系统可以被配置为每隔一段时间清空缓存的图形。即使这个间隔只有几秒,其为Graphite减轻的负担也是巨大的,因为重复的请求太频繁了。

另一个造成大量渲染请求的情况是用户在Composer UI中改变显示选项和应用函数。用户每次改变某些东西,Graphite都必须重新绘制图形。每个请求都包含了相同的数据,因此将基本数据存储到分布式内存对象缓存系统中也是十分重要的。这使得UI能够保持对用户的响应,因为获取数据的步骤被跳过了。

7.6. 优化I/O

想象一下你有60,000个度量需要发送到你的Graphite服务器上,并且每个度量每分钟都有一个数据点。要知道每个度量在文件系统中都有自己的 whisper 文件。这意味着 carbon 每分钟需要对60,000个不同的文件进行一次写操作。只要 carbon 能够每1ms完成一次写操作,这些数据就能够处理完。这确实不难达到,但假如你每分钟有600,000个度量需要更新,或者你的度量每秒钟更新一次,或者你仅仅不能提供足够快的存储,不管是哪种情况,假定数据到来的速度超过了你的存储能够提供的写操作的速度,这个问题应该如何解决?
现在大多数的硬盘都有缓慢的寻找时间4,这个缓慢的寻找时间是指和写入一系列连续的数据相比,在两个不同位置进行I/O操作的延时。这就意味着我们进行越多的连续写操作,效率就越高。但是如果我们需要在成千上万的文件中经常进行写操作,并且每次写操作数据量都十分小(一个 whisper 数据点仅有12字节),那么我们的硬盘就肯定会花大量的时间在寻找上。

在写操作速率有一个相对较低顶点的前提下,唯一让数据点流通量超过这个速率的办法就是在单个写操作内写多个数据点。由于 whisper 将连续的数据点连续的存放在硬盘上,所以这是可行的。因此我为 whisper 增加了一个名为 update_many 的函数,其功能是获取一个度量的一系列数据点然后将它们压缩到一个写操作内。尽管这使得每次写操作数据量变大,但是写十个数据点(120字节)和写一个数据点(12字节)在时间上的区别是可以忽略的。在不明显影响时间的前提下,这个函数会尽可能多的获取数据点。

随后我在 carbon 中实现了一个缓冲机制。每个到来的数据点都被映射到一个基于其度量名的队列中并添加到队尾。另一个线程不停的遍历所有的队列,取出所有数据点并将它们通过 update_many 写到对应的 whisper 文件中。现在我们回到之前的例子上,假如我们有600,000个每分钟更新一次的度量,我们的存储设备仅能支持每1ms一次写操作,那么每个队列就会平均保有10个数据点。这个策略唯一消耗的资源就是内存,而这个是相对较充裕的,因为每个数据点都只有十几个字节。

这个策略动态的缓冲尽可能多的数据点以维持数据点到来的速率,保证其不超过存储设备能提供的I/O操作速率。这个方法一个明显的优点是它增加了一定的弹性以解决临时的I/O速率降低问题。如果系统需要进行其它Graphite之外的I/O工作,那么写操作的速率很有可能就会降低,这个时候的 carbon 队列就会增长。队列越大,写的越多。因为所有的数据点流通量等于写操作速率乘以每次写操作的平均大小,所以只要还有足够的内存用于存放队列, carbon 可以继续运行。 carbon 的队列机制如图7.4所示。

图7.4: **carbon** 的队列机制                               

7.7. 保证实时

缓冲数据点是一个很棒的优化 carbon 的I/O的方式,但是很快我的用户就发现了一个相当麻烦的副作用。回到我们之前的例子,我们有600,000个每分钟更新一次的度量,并且我们的存储设备只能提供每分钟60,000次写操作。这意味着我们在任意时间都有将近10分钟的数据被保存在 carbon 的队列中。对用户来说,这就意味着他们向Graphite的web应用请求的图形会确实最近10分钟的数据:这无疑非常糟糕。

幸运的是解决方案是十分直接的。我为 carbon 添加了一个socket监听器,它为访问缓冲的数据点提供了一个查询接口,然后我修改了Graphite的web应用,使得它每次获取数据时都调用这个接口。接着web应用就把它从 carbon 中获取的数据和从磁盘中获取的数据结合起来,于是图形就是实时的了。就算在我们的例子中数据点是每分钟更新一次因此不算严格意义上的实时,但是事实上,图形中的数据点只要可以被访问到就会马上被 carbon 获取,因此它是实时的。

7.8. 内核、缓存以及灾难性错误

现在不难看出,Graphite的一个关键特点是其性能取决于它的I/O延时。之前我们一直假设我们系统的I/O延时始终较低,平均每次写操作1ms,但这是一个巨大的需要一些深入分析的假设。大部分硬盘根本没有那么快的速度;即使是磁盘阵列中的磁盘,随机访问的延迟也很可能超过1ms。但是即使你在一个老旧的笔记本电脑上测试向硬盘中写1KB的数据的速度,你也会发现写操作系统调用会在远小于1ms的时间内返回,这是为什么呢?

当软件有不一致的或不符合预期的性能特性时,其原因通常是缓冲或缓存。在目前的情况下,我们两个方面都要处理。写操作系统调用严格意义上并不会把你的数据写到硬盘上,它仅仅将数据放到一个缓冲区中,内核会在之后将其中的数据写到硬盘中。这就是调用写操作返回的速度极快的原因。就算在缓冲区中的内容被写到硬盘中之后,这些数据也会被缓存以备接下来的读取。当然,缓冲和缓存都需要内存。

聪明的内核开发者认为使用当前空闲的用户空间内存是一个比永久分配内存更好的主意。事实证明这是一个非常有效的性能促进方法,而且这也解释了为什么不管你为一个系统添加了多少内存,空闲内存都会在不多的I/O操作之后几乎降到0。如果你的用户空间应用没有在使用那些内存,那么很可能就是你的内核在使用它们。这个方法不好的地方就是当用户空间应用确定需要为自己分配更多的内存时,这些空闲内存就可能被从内核中拿走。而内核除了让出内存没有别的选择,从而就会丢失那块内存中所有的缓冲数据。

那么所有的这些对Graphite意味着什么呢?我们刚刚强调了 carbon 对始终较低的I/O延时的依赖,并且我们也知道写操作系统调用能够迅速返回仅仅只因为数据只是被复制到一个缓冲中。那么当系统中没有足够的内存来让内核继续缓冲写操作数据时会发生什么呢?写操作会并发从而变得极其缓慢!这会导致 carbon 的写操作速率极大的降低,进一步导致 carbon 的队列增长,而这又需要更多的内存,使得内核陷入恶性循环中。最后,这种情况通常会导致 carbon 用光内存或者被愤怒的系统管理员杀死进程。

为了避免这种灾难,我向 carbon 中添加了几种特性,包括可配置的队列中数据点数目限制以及各种 whisper 操作的速率限制。这些特性可以避免 carbon 失控,并且使得如丢失数据点或者拒绝接受更多数据点这样不好的影响更少。但是这些设置的合适的值是系统相关的,并且需要不少的测试来调整。它们是很有用的,但是不能从根本上解决问题,这需要更多的硬件。

7.9. 集群

从用户的角度来看,让多个Graphite服务器表现的像一个系统一样应该不是那么困难。Web应用的用户交互主要由两个操作构成:寻找度量和获取数据点(通常以图的形式获取)。Web应用的寻找和获取操作被隐藏在一个类库中,这个类将它们的实现从其它代码中抽象出来,这两个操作可以很轻松的通过HTTP请求处理器进行远程调用。

find 操作会在 whisper 数据的本地文件系统中寻找匹配一个用户指定模式的文件,就像 *.txt 匹配与其有相同扩展名的文件一样。 find 返回的结果是一系列 Node 对象,构成一个树状结构,每个 Node 对象都来源于 Node 的子类 BranchLeaf 。路径与分支节点一致, whisper 文件与叶节点一致。这种抽象层次使得系统可以很轻松的支持包括RRD文件5在内的不同种类的基础存储以及gzip压缩的 whisper 文件。

Leaf 接口定义了一个 fetch 方法,其实现取决于叶节点的类型。如果是 whisper 文件,那么这个方法就是 whisper 库的 fetch 函数加上一层简单的封装。当添加了集群支持时, find 函数会被扩展,从而可以通过HTTP向web应用配置指定的其它Graphite服务器进行远程 find 调用。这些从HTTP调用中获取的节点数据会被封装成RemoteNode对象,这个对象与 NodeBranchLeaf 接口相一致。这使得集群对于web应用的其它代码变得透明。远程叶节点的 fetch 方法被实现为从节点的Graphite服务器获取数据点的HTTP调用。

这些调用在web应用之间的使用方式和客户端调用的使用方式一样,除了一个额外的参数用来指定该操作应该在本地执行并且不在集群中再分配。当web应用需要渲染一个图时,它会执行 find 操作来定位指定的度量并且在每个服务器上调用 fetch 来获取对应的数据点。不管数据是在本地服务器、远程服务器或者两者都有,这个流程都是有效的。如果一个服务器宕机了,远程调用很快就会超时,并且该服务器会在一段时间内被标记为停止服务,在这期间不会有远程调用指向它。从用户角度来看,无法被访问的服务器上的所有数据都会在图上缺失,除非这些数据在集群中的另一个服务器上有备份。

7.9.1. 集群效率的简单分析

一个绘图请求中开销最大的部分就是渲染图形。每个渲染操作都由单个服务器执行,因此添加更多的服务器可以有效的提高渲染图形的性能。但是,事实上很多请求最后都会向集群中的其它服务器执行 find 调用,这意味着我们的集群会共享大部分的前端负载而不是分散它。不过由于每个 carbon 请求都是独立执行的,因此在这一方面我们已经实现了一个有效的分散后端负载的方法。这无疑是极好的,因为大部分时间后端瓶颈会远在前端瓶颈之前达到,而且很明显前端不会因为这个方法而横向扩展。

为了使前端更有效的扩展,web应用执行的远程 find 调用的数量必须减少。同样的,最简单的解决方案还是缓存。上文中我们提到了分布式内存对象缓存系统被用于缓存数据点和绘制的图形,它同样可以用来缓存 find 请求的结果。由于度量的位置不太可能经常改变,因此它通常可以被缓存更长的时间。但是为 find 请求的结果设置较长的缓存时间的代价就是新添加的度量在用户看来可能会比较慢。

7.9.2. 在集群中分散度量

Graphite的web应用在整个集群中都是几乎一样的,因此它在每个服务器上都执行完全相同的任务。但是 carbon 的角色可以在不同服务器上有所区别,它取决于你选择的要发送给每个请求的数据。通常会有很多不同的客户端发送数据给 carbon ,因此连接客户端的配置和Graphite集群布局是一件非常烦人的事情。应用度量可能需要发送到一个 carbon 服务器,而事务度量可能需要发送到多个 carbon 服务器以保证冗余性。

为了简化对这种情况的管理,Graphite添加了一个额外的名为 carbon-relay 的工具。它的工作非常简单;它像标准 carbon 守护进程(实际上叫 carbon-cache )一样从客户端接受度量数据,但它并不存储这些数据,而是通过一个规则集对度量名进行检测,从而决定应该把这些数据发送到哪个 carbon-cache 服务器上。每条规则都由一个正则表达式和一个目标服务器列表构成。对于每个收到的数据点,规则集都会按顺序检测,第一个正则表达式和度量名匹配的规则会被使用。这样一来,客户端需要做的所有事情就是把数据发送到 carbon-relay ,而它会把这些数据都发送到正确的服务器上。

从某种意义上来说, carbon-relay 提供了复制功能,不过更精确的说这应该叫输入复制,因为它并不涉及到同步问题。如果一个服务器临时宕机,那么在这段时间内数据点就会缺失,但功能任然正常。此外,有不少管理脚本将再同步过程交给系统管理员控制。

7.10. 设计反思

设计Graphite的经验再次证明了我的一个观点,那就是可扩展性与底层性能相关性不大,相反,它是整体设计的产物。我在整个过程中遇到了很多瓶颈,但每次我都是寻找设计上的改进而不是性能上的加速。我曾经被问到过很多次为什么我要用Python写Graphite而不是Java或者C++,而我的回答始终是我还没有遇到过另一种语言可以像Python这样提供对性能真正的需求。在[[http://aosabook.org/en/bib1.html#bib:knuth:goto|[Knu74]]], Donald Knuth曾说过一句很有名的话“过早的最优化是万恶之源”。只要我们还坚信我们的代码可以继续以不平凡的方式进化,那么所有的最优化6从某种意义上来说都是过早的。

Graphite一个最大的优点同时也是最大的缺点就是它只有很少一部分是真正按照传统观念设计的。随着问题增加,Graphite也在一个一个的克服障碍逐渐进化。很多时候这些障碍都是可以预见的,并且有各种看起来十分合理的前瞻性的解决方案。但是避免解决你还没有遇到的问题是有用的,不管你有多可能马上就会遇到这个问题。其原因是相比于优秀策略的理论,仔细研究失败能让你学到更多。问题解决是由我们手中的经验数据和我们的知识以及直觉共同驱动的。我发现充分的质疑你的智慧可以迫使你更加全面的检视你的经验数据。

比如,当我刚开始写 whisper 我坚信它会由C语言写成以提高速度,而我的Python实现只是一个原型。如果我不是赶时间的话我很有可能会完全丢弃掉Python实现。而最后I/O瓶颈远比CPU瓶颈更早达到,因此Python较低的效率在实践中几乎无关紧要。

但是就像我说的,Graphite的进化方式也是它一个巨大的缺点。从结果来看,接口并不能很好的适应这种逐渐发展的过程。一个好的接口应该是一致的并且能通过约定来最大化可预测性。按照这种标准来看,Graphite的URL API目前是一个低于平均水平的接口。选项和功能随时间推移被添加到系统中,有时候会达成局部的一致性,但总体上缺乏整体的一致性。解决这个问题唯一的办法是通过接口的版本控制,但是这也有缺点。新的接口被设计出来之后,之前的那个仍然很难摆脱,于是便作为进化的包袱遗留下来,就像人的阑尾一样。它可能看上去足够无害,直到有一天你的代码得了阑尾炎(i.e.和旧接口相关的一个BUG)于是你必须进行手术。如果我在早期需要更改Graphite的一个部分,那么我就要花更大的精力在设计外部接口上,因为我需要提前想好而不是一点点的改进它们。

Graphite另一个造成了一些挫折的部分是不灵活的分层度量命名模型。虽然它十分简单并且在大多数情况下十分方便,但是它也导致了一些复杂的请求变得十分困难甚至无法表示。当我刚开始考虑创造Graphite时,我就十分明确我想要的是一个用于创建图7的可以人工编辑的URL API。尽管我对Graphite提供了这个功能感到十分高兴,但是由于过分简单的语法使得复杂表达式变得十分庞大,我担心这个要求让API陷入了沉重的负担中。分层使得检测度量的主关键字变得十分简单,因为一个路径从本质上来说就是树上的一个主关键字节点。其副作用就是所有的描述数据(i.e.列数据)都必须直接嵌入到路径中。一个可能的解决方案是保持分层模型并添加一个单独的元数据数据库,使得用户能通过特殊语法对度量进行更高级的选择。

7.11. 开源

回顾Graphite的发展过程,它作为项目走了很远,而我也因为它走了很远,深入的程度至今仍让我感到惊讶。Graphite开始于一个只有几百行代码的小程序。渲染引擎则开始于一次实验,而这个实验仅仅只是看看我是否能写出来一个渲染引擎。 whisper 是在一个周末的课程上写出来的,仅仅是出于要在严格的上市时间前解决一个show-stopper问题的绝望。 carbon 被重写的次数已经多到我不想记了。当我在2008年被允许在开源许可下发布Graphite时,我根本没期待有多少回应。几个月后它在一片CNET的文章中被提到,而这篇文章又被Slashdot选中了,于是这个项目马上就火了起来并且一直活跃到现在。如今有几十个大中型公司使用Graphite。Graphite社区也十分活跃并且人数一直在增长。但它还远不是一个完成的项目,还有很多十分炫酷的实践性工作要做,而这使得它保持有趣并且充满潜力。

脚注

  1. http://launchpad.net/graphite

  2. 序列化对象可以发送到另一个端口,比纯文本格式更加有效。这个只有在流量非常大时才需要使用。

  3. http://memcached.org

  4. 和传统硬盘相比,固态硬盘有极快的寻找时间。

  5. RRD文件实际上是分支节点,因为他们包含多个数据源;一个RRD数据源是一个叶节点。

  6. Knuth特质底层代码优化,而不是指如改进设计等宏观优化。

  7. 这要求图片开源。任何人都可以查看图片的URL来理解或修改它。