开源应用架构(第一卷)
石墨

克里斯·戴维斯

石墨1 执行两个非常简单的任务:存储随时间变化的数字并将其绘制成图表。多年来,已经编写了许多软件来执行这些相同的任务。使石墨独一无二的是,它将此功能作为网络服务提供,该服务易于使用且高度可扩展。向石墨馈送数据的协议非常简单,您可以在几分钟内手动学习如何使用它(不是说您真的想这样做,但它是一个衡量简单性的不错试金石)。渲染图表和检索数据点就像获取 URL 一样简单。这使得将石墨与其他软件集成变得非常自然,并使用户能够在石墨之上构建强大的应用程序。石墨最常见的用途之一是构建用于监控和分析的基于 Web 的仪表板。石墨诞生于一个高流量的电子商务环境,其设计也反映了这一点。可扩展性和实时访问数据是关键目标。

使石墨能够实现这些目标的组件包括一个专门的数据库库及其存储格式、一个用于优化 I/O 操作的缓存机制,以及一个简单但有效的石墨服务器集群方法。我不会仅仅描述石墨今天是如何工作的,而是会解释石墨最初是如何实现的(相当天真地),我遇到了什么问题,以及我是如何设计解决方案的。

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

石墨完全用 Python 编写,由三个主要组件组成:一个名为 whisper 的数据库库、一个名为 carbon 的后端守护进程,以及一个渲染图表并提供基本 UI 的前端 Web 应用程序。虽然 whisper 是专门为石墨编写的,但它也可以独立使用。它的设计与 RRDtool 使用的循环数据库非常相似,并且仅存储时间序列数值数据。通常我们将数据库视为客户端应用程序通过套接字与其通信的服务器进程。但是,whisper 就像 RRDtool 一样,是一个应用程序用来操作和检索存储在特殊格式文件中的数据的数据库库。最基本的 whisper 操作是 create 用于创建新的 whisper 文件,update 用于将新的数据点写入文件,以及 fetch 用于检索数据点。

[Basic Anatomy of a whisper File]

图 7.1:whisper 文件的基本结构

图 7.1所示,whisper 文件由包含各种元数据的标头部分和一个或多个存档部分组成。每个存档都是一系列连续的数据点,它们是 (时间戳,值) 对。当执行 updatefetch 操作时,whisper 会根据时间戳和存档配置确定应将数据写入或读取到的文件中的偏移量。

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

石墨的后端是一个名为 carbon-cache 的守护进程,通常简称为 carbon。它构建在 Twisted 之上,Twisted 是一个用于 Python 的高度可扩展的事件驱动 I/O 框架。Twisted 使 carbon 能够高效地与大量客户端通信,并以低开销处理大量流量。图 7.2 显示了 carbonwhisper 和 Web 应用程序之间的数据流:客户端应用程序收集数据并将其发送到石墨后端 carbon,后者使用 whisper 存储数据。然后,Web 应用程序可以使用此数据生成图表。

[Data Flow]

图 7.2:数据流

carbon 的主要功能是存储客户端提供的指标的数据点。在石墨术语中,指标是任何可以随时间变化的可衡量数量(例如服务器的 CPU 利用率或产品的销售数量)。数据点只是一个 (时间戳,值) 对,对应于特定时间点特定指标的测量值。指标由其名称唯一标识,每个指标的名称及其数据点由客户端应用程序提供。一种常见的客户端应用程序类型是监控代理,它收集系统或应用程序指标,并将收集的值发送到 carbon 以便于存储和可视化。石墨中的指标具有简单的分层名称,类似于文件系统路径,只是使用点而不是斜杠或反斜杠来分隔层次结构。carbon 将尊重任何合法名称,并为每个指标创建一个 whisper 文件来存储其数据点。whisper 文件存储在 carbon 的数据目录中,位于镜像每个指标名称中点分隔层次结构的文件系统层次结构中,因此(例如)servers.www01.cpuUsage 映射到 …/servers/www01/cpuUsage.wsp

当客户端应用程序希望将数据点发送到石墨时,它必须建立与 carbon 的 TCP 连接,通常在端口 20032 上。客户端负责所有通信;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. 前端:按需图表

石墨 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 参数;所有其他参数都是可选的,如果省略则使用您的默认值。

石墨支持各种显示选项以及遵循简单函数语法的数

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

函数可以嵌套,允许复杂的表达式和计算。

这是另一个示例,它使用每分钟销售产品的指标给出当天的销售额累计总计

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

sumSeries 函数计算一个时间序列,该时间序列是与模式 products.*.salesPerMinute 匹配的每个指标的总和。然后 integral 计算运行总计而不是每分钟计数。从这里,不难想象如何构建一个用于查看和操作图表的 Web UI。石墨附带了自己的 Composer UI,如图 7.3所示,它使用 Javascript 在用户单击可用功能菜单时修改图表的 URL 参数来实现此目的。

[Graphite's Composer Interface]

图 7.3:石墨的 Composer 界面

7.4. 仪表板

自创建以来,石墨一直被用作创建基于 Web 的仪表板的工具。URL API 使其成为一个自然的用例。制作仪表板就像制作一个充满如下标签的 HTML 页面一样简单

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

但是,并非每个人都喜欢手动制作 URL,因此石墨的 Composer UI 提供了一种点击式方法来创建图表,您可以从中简单地复制粘贴 URL。当与允许快速创建网页的另一个工具(如维基)结合使用时,这变得足够简单,非技术用户可以非常轻松地构建自己的仪表板。

7.5. 一个明显的瓶颈

一旦我的用户开始构建仪表板,石墨很快就开始出现性能问题。我调查了 Web 服务器日志以查看哪些请求导致了它的下降。很明显,问题是图表请求的数量过多。Web 应用程序是 CPU 密集型的,不断渲染图表。我注意到有很多相同的请求,并且仪表板是罪魁祸首。

假设您的仪表板中有 10 个图表,并且页面每分钟刷新一次。每次用户在浏览器中打开仪表板时,石墨都必须每分钟处理 10 个请求。这很快就会变得昂贵。

一个简单的解决方案是只渲染每个图表一次,然后向每个用户提供它的副本。Django Web 框架(石墨构建在其上)提供了一个优秀的缓存机制,可以使用各种后端,例如 memcached。Memcached3 本质上是一个作为网络服务提供的哈希表。客户端应用程序可以像普通哈希表一样获取和设置键值对。使用 memcached 的主要好处是可以非常快地存储昂贵请求(如渲染图表)的结果,并在以后检索以处理后续请求。为了避免永远返回相同的陈旧图表,可以将 memcached 配置为在短时间后使缓存的图表过期。即使这只有几秒钟,它也极大地减轻了石墨的负担,因为重复请求非常普遍。

另一个导致大量渲染请求的常见情况是用户在 Composer UI 中调整显示选项和应用函数时。每次用户更改内容时,石墨都必须重新绘制图表。每次请求都涉及相同的数据,因此将基础数据也放入 memcache 中是有意义的。这使得 UI 对用户保持响应,因为跳过了检索数据的步骤。

7.6. 优化 I/O

假设您有 60,000 个指标发送到您的石墨服务器,并且每个指标每分钟有一个数据点。请记住,每个指标在文件系统上都有自己的 whisper 文件。这意味着 carbon 必须每分钟对 60,000 个不同的文件执行一次写入操作。只要 carbon 能够每毫秒写入一个文件,它就应该能够跟上。这并不是太牵强,但假设您有 600,000 个指标每分钟更新,或者您的指标每秒更新一次,或者您根本买不起足够快的存储。无论如何,假设传入数据点的速率超过了您的存储可以承受的写入操作速率。这种情况应该如何处理?

如今的大多数硬盘驱动器都有较慢的寻道时间4,即在两个不同位置执行 I/O 操作之间的延迟,相比之下,写入连续的数据序列要快得多。这意味着我们执行的连续写入越多,吞吐量就越大。但是,如果我们有成千上万个需要频繁写入的文件,并且每次写入都很小(一个 whisper 数据点只有 12 字节),那么我们的磁盘肯定会在大部分时间用于寻道。

假设写入操作的速率有一个相对较低的上线,那么提高数据点吞吐量的唯一方法是在单个写入操作中写入多个数据点。这是可行的,因为whisper将连续的数据点连续地排列在磁盘上。因此,我在whisper中添加了一个update_many函数,该函数接收单个指标的一系列数据点,并将连续的数据点压缩到单个写入操作中。即使这使得每次写入都更大,但写入十个数据点(120字节)与写入一个数据点(12字节)所需的时间差异可以忽略不计。需要写入相当多的数据点,才会使每次写入的大小开始明显影响延迟。

接下来,我在carbon中实现了缓冲机制。每个传入的数据点都会根据其指标名称映射到一个队列,然后附加到该队列。另一个线程反复遍历所有队列,并为每个队列提取所有数据点,并使用update_many将它们写入相应的whisper文件。回到我们的示例,如果我们有600,000个指标每分钟更新一次,而我们的存储只能维持每毫秒1次写入,那么这些队列平均每个队列将保存大约10个数据点。这唯一消耗的资源是内存,由于每个数据点只有几个字节,所以内存相对充足。

此策略动态地缓冲尽可能多的数据点,以维持可能超过存储可以承受的I/O操作速率的传入数据点的速率。这种方法的一个优点是,它增加了一定程度的弹性来处理临时的I/O速度下降。如果系统需要执行Graphite之外的其他I/O工作,那么写入操作的速率可能会下降,在这种情况下,carbon的队列将简单地增长。队列越大,写入就越大。由于数据点的整体吞吐量等于写入操作的速率乘以每次写入的平均大小,因此只要有足够的内存用于队列,carbon就能跟上。carbon的排队机制如图7.4所示。

[Carbon's Queueing Mechanism]

图 7.4:Carbon的排队机制

7.7. 保持实时性

缓冲数据点是优化carbon I/O的一个好方法,但很快我的用户就注意到一个相当麻烦的副作用。再次回顾我们的示例,我们有600,000个指标每分钟更新一次,我们假设我们的存储每分钟只能处理60,000次写入操作。这意味着在任何给定时间,carbon的队列中将大约有10分钟的数据。对于用户来说,这意味着他们从Graphite Web应用程序请求的图表将缺少最近10分钟的数据:这不好!

幸运的是,解决方案非常简单。我只是在carbon中添加了一个套接字监听器,它提供了一个用于访问缓冲数据点的查询接口,然后修改Graphite Web应用程序,以便每次需要检索数据时都使用此接口。然后,Web应用程序将从carbon检索到的数据点与从磁盘检索到的数据点结合起来,瞧,图表就变成了实时的。当然,在我们的示例中,数据点更新到分钟级别,因此并非完全“实时”,但一旦carbon接收到每个数据点,它就能立即在图表中访问,这确实是实时的。

7.8. 内核、缓存和灾难性故障

现在可能很明显,Graphite自身性能所依赖的一个关键系统性能特征是I/O延迟。到目前为止,我们假设我们的系统具有始终较低的I/O延迟,平均每次写入约为1毫秒,但这是一个很大的假设,需要更深入的分析。大多数硬盘驱动器根本没有那么快;即使在RAID阵列中使用数十个磁盘,随机访问的延迟也极有可能超过1毫秒。但是,如果您尝试测试即使是旧笔记本电脑将整个千字节写入磁盘的速度,您会发现写入系统调用在不到1毫秒的时间内返回。为什么?

每当软件具有不一致或意外的性能特征时,通常是缓冲或缓存导致的。在这种情况下,我们同时处理这两者。写入系统调用不会从技术上将您的数据写入磁盘,它只是将其放入一个缓冲区,然后内核稍后将该缓冲区写入磁盘。这就是为什么写入调用通常返回如此之快的原因。即使缓冲区已写入磁盘,它通常也会被缓存以供后续读取。这两种行为,缓冲和缓存,当然都需要内存。

内核开发人员,他们是非常聪明的人,决定最好利用任何当前空闲的用户空间内存,而不是直接分配内存。事实证明,这是一个非常有用的性能增强器,它也解释了为什么无论您向系统添加多少内存,在进行适度的I/O后,它通常最终几乎没有“空闲”内存。如果您的用户空间应用程序没有使用该内存,那么您的内核可能正在使用它。这种方法的缺点是,一旦用户空间应用程序决定需要为自身分配更多内存,内核就可以从内核中获取此“空闲”内存。内核别无选择,只能放弃它,丢失可能存在的任何缓冲区。

那么这一切对Graphite意味着什么?我们刚刚强调了carbon对始终较低的I/O延迟的依赖,我们也知道写入系统调用之所以返回很快,仅仅是因为数据被复制到缓冲区中。当内核没有足够的内存来继续缓冲写入时会发生什么?写入变成同步的,因此非常缓慢!这会导致carbon写入操作速率急剧下降,从而导致carbon的队列增长,进而消耗更多内存,进一步饿死内核。最终,这种情况通常会导致carbon耗尽内存或被愤怒的系统管理员杀死。

为了避免这种灾难,我在carbon中添加了一些功能,包括可配置的队列数据点数量限制和各种whisper操作的速率限制。这些功能可以保护carbon不受失控的影响,而是施加不太严厉的影响,例如丢弃一些数据点或拒绝接受更多数据点。但是,这些设置的正确值是特定于系统的,需要进行大量的测试才能调整。它们很有用,但并没有从根本上解决问题。为此,我们需要更多的硬件。

7.9. 集群

使多个Graphite服务器从用户的角度来看就像一个系统,这并不难,至少对于一个简单的实现来说是这样。Web应用程序的用户交互主要包括两个操作:查找指标和获取数据点(通常以图表的形式)。Web应用程序的查找和获取操作被隐藏在一个库中,该库将其实现从代码库的其余部分抽象出来,并且它们也通过HTTP请求处理程序公开,以便于远程调用。

find操作搜索whisper数据本地文件系统中与用户指定模式匹配的内容,就像*.txt这样的文件系统通配符匹配具有该扩展名的文件一样。作为树结构,find返回的结果是Node对象的集合,每个对象都派生自NodeBranchLeaf子类。目录对应于分支节点,whisper文件对应于叶子节点。这种抽象层使它很容易支持不同类型的底层存储,包括RRD文件5和gzip压缩的whisper文件。

Leaf接口定义了一个fetch方法,其实现取决于叶子节点的类型。对于whisper文件,它只是一个围绕whisper库自身获取函数的薄包装器。当添加集群支持时,find函数被扩展为能够通过HTTP向Web应用程序配置中指定的其他Graphite服务器发出远程查找调用。这些HTTP调用结果中包含的节点数据被包装为RemoteNode对象,这些对象符合通常的NodeBranchLeaf接口。这使得集群对Web应用程序代码库的其余部分是透明的。远程叶子节点的fetch方法被实现为另一个HTTP调用,以从节点的Graphite服务器检索数据点。

所有这些调用都在Web应用程序之间进行,就像客户端调用它们的方式一样,只是多了一个额外的参数,指定操作应该只在本地执行,而不是在整个集群中重新分配。当Web应用程序被要求渲染一个图表时,它执行find操作来定位请求的指标,并对每个指标调用fetch来检索其数据点。无论数据是在本地服务器、远程服务器还是两者上,这都有效。如果服务器宕机,远程调用会很快超时,并且服务器会被标记为一段时间内无法使用,在此期间不会向其发出进一步的调用。从用户的角度来看,丢失的服务器上的任何数据都将从他们的图表中消失,除非该数据在集群中的另一台服务器上进行了复制。

7.9.1. 集群效率的简要分析

图表请求中最昂贵的部分是渲染图表。每个渲染都由单个服务器执行,因此添加更多服务器确实有效地提高了渲染图表的容量。但是,许多请求最终将find调用分发到集群中的每个其他服务器的事实意味着我们的集群方案共享了大部分前端负载,而不是将其分散。但是,我们目前所取得的成果是,一种有效的方法来分配后端负载,因为每个carbon实例都是独立运行的。这是一个良好的开端,因为大多数情况下,后端在前端成为瓶颈之前很久就已成为瓶颈,但显然,前端不会使用这种方法进行水平扩展。

为了使前端更有效地扩展,Web应用程序发出的远程find调用次数必须减少。同样,最简单的解决方案是缓存。就像memcached已经被用来缓存数据点和渲染的图表一样,它也可以用来缓存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 而不是 Java 或 C++ 编写 Graphite,我的回答始终是,我还没有遇到真正需要其他语言所能提供的性能的情况。在 [Knu74] 中,Donald Knuth 著名地指出,过早优化是万恶之源。只要我们假设我们的代码将以非平凡的方式继续发展,那么所有优化6 从某种意义上说都是过早的。

Graphite 最大的优势和劣势之一是,它实际上很少以传统意义上进行“设计”。总的来说,Graphite 随着问题的出现,逐渐地、一步步地发展起来。很多时候,障碍是可预见的,各种先发制人的解决方案似乎很自然。但是,即使您很快就会遇到问题,避免解决尚未遇到的问题也可能很有用。原因是,您可以从仔细研究实际故障中学到更多东西,而不是从理论上推测更优越的策略。解决问题既受我们手头经验数据的影响,也受我们自身知识和直觉的影响。我发现,充分怀疑自己的智慧可以迫使你更彻底地查看你的经验数据。

例如,当我第一次编写 whisper 时,我确信它必须用 C 重写才能提高速度,而我的 Python 实现只能作为原型。如果我不赶时间,我很有可能完全跳过 Python 实现。然而事实证明,I/O 比 CPU 更早成为瓶颈,因此 Python 的效率较低在实践中几乎无关紧要。

但是,正如我所说,演化方法也是 Graphite 的一大弱点。事实证明,接口并不适合逐渐演化。一个好的接口是一致的,并使用约定来最大化可预测性。根据这一标准,在我看来,Graphite 的 URL API 目前是一个次优接口。选项和功能随着时间的推移而被添加,有时形成了小的前后一致的区域,但总体上缺乏全局的一致性。解决此类问题的唯一方法是对接口进行版本控制,但这也有缺点。一旦设计了一个新的接口,旧的接口仍然难以消除,就像人类的阑尾一样,作为进化包袱徘徊不去。它看起来可能足够无害,直到有一天你的代码患上阑尾炎(即与旧接口相关的错误),你被迫进行手术。如果我早点更改 Graphite 中的一件事,那将是更加注意设计外部 API,提前思考而不是一点一点地发展它们。

Graphite 的另一个令人沮丧的方面是分层指标命名模型的灵活性有限。虽然它对于大多数用例来说非常简单且非常方便,但它使某些复杂的查询变得非常困难,甚至不可能表达。当我第一次想到创建 Graphite 时,我从一开始就知道我想要一个可供人工编辑的 URL API 来创建图表7。虽然我仍然很高兴 Graphite 今天提供了这一点,但我担心此要求给 API 带来了过于简单的语法,这使得复杂表达式变得笨拙。层次结构使确定指标的“主键”的问题变得非常简单,因为路径本质上是树中节点的主键。缺点是所有描述性数据(即列数据)都必须直接嵌入到路径中。一个潜在的解决方案是维护分层模型并添加一个单独的元数据数据库,以使用特殊语法启用更高级的指标选择。

7.11. 开源化

回顾 Graphite 的发展历程,我仍然对它作为项目取得的进步以及它带给我的进步感到惊讶。它最初是一个只有几百行代码的个人项目。渲染引擎最初是一个实验,只是为了看看我是否可以编写一个。whisper是在一个周末出于绝望而编写的,目的是在关键发布日期之前解决一个阻止程序的问题。carbon已经被重写了多次,我都不想记了。一旦我被允许在 2008 年根据开源许可证发布 Graphite,我从未真正期待过什么反应。几个月后,它在 CNET 的一篇文章中被提及,并被 Slashdot 选中,该项目突然起飞,并且一直活跃至今。如今,数十家大型和中型公司都在使用 Graphite。社区非常活跃,并且在不断发展。它远非一个成品,还有很多很酷的实验性工作正在进行,这使得它工作起来很有趣,并且充满了潜力。

脚注

  1. http://launchpad.net/graphite
  2. 还有另一个端口可以通过它发送序列化对象,这比纯文本格式更有效。这仅在流量非常大的情况下才需要。
  3. https://memcached.org.cn
  4. 与传统的硬盘驱动器相比,固态硬盘通常具有极快的寻道时间。
  5. RRD 文件实际上是分支节点,因为它们可以包含多个数据源;RRD 数据源是叶子节点。
  6. Knuth 特指底层代码优化,而不是宏观优化,例如设计改进。
  7. 这迫使图表本身开源。任何人都可以简单地查看图表的 URL 来理解或修改它。