开源软件的性能
Zotonic

Arjan Scherpenisse和Marc Worrell

Zotonic简介

Zotonic是一个开源框架,用于进行全栈Web开发,从前端到后端。它由一组核心功能组成,在其之上实现了轻量级但可扩展的内容管理系统。Zotonic的主要目标是使创建高性能网站“开箱即用”变得容易,以便网站从一开始就具有良好的可扩展性。

虽然它与Django、Drupal、Ruby on Rails和Wordpress等Web开发框架共享许多特性和功能,但它的主要竞争优势是Zotonic所使用的语言:Erlang。这种语言最初是为构建电话交换机而开发的,它使Zotonic具有容错性和出色的性能特点。

正如标题所说,本章重点介绍Zotonic的性能。我们将分析选择Erlang作为编程平台的原因,然后检查HTTP请求堆栈,然后深入探讨Zotonic采用的缓存策略。最后,我们将描述我们对Zotonic的子模块和数据库所做的优化。

为什么选择Zotonic?为什么选择Erlang?

Zotonic的第一版工作始于2008年,就像许多项目一样,它源于“解决一个痒处”。Zotonic的主要架构师Marc Worrell在阿姆斯特丹的Mediamatic Lab工作了七年,开发了一个类似于Drupal的CMS,用PHP/MySQL编写,叫做Anymeta。Anymeta的主要模式是它实现了一种“语义网的务实方法”,将系统中的所有内容都建模为通用的“事物”。尽管取得了成功,但它的实现存在可扩展性问题。

Marc离开Mediamatic后,花了几个月时间从头开始设计一个合适的、类似于Anymeta的CMS。Zotonic的主要设计目标是:它必须易于前端开发人员使用;它必须支持实时Web界面的轻松开发,同时允许持久连接和许多短请求;它必须具有明确定义的性能特征。更重要的是,它必须解决早期Web开发方法中限制性能的最常见问题——例如,它必须能够承受“Shashdot效应”(突然涌入的大量访问者)。

经典PHP+Apache方法的问题

一个经典的PHP设置在像Apache这样的容器Web服务器内部运行作为一个模块。对于每个请求,Apache都会决定如何处理请求。当它是一个PHP请求时,它会启动mod_php5,然后PHP解释器开始解释脚本。这会导致启动延迟:通常,这种启动已经需要5毫秒,然后PHP代码还需要运行。这个问题可以通过使用PHP加速器来部分解决,PHP加速器会预编译PHP脚本,绕过解释器。PHP启动开销也可以通过使用像PHP-FPM这样的进程管理器来缓解。

然而,这类系统仍然受到共享无状态架构的困扰。当一个脚本需要数据库连接时,它需要自己创建一个。其他任何可能在请求之间共享的I/O资源也是如此。各种模块具有持久连接来克服这个问题,但PHP中没有针对这个问题的通用解决方案。

处理持久客户端连接也很难,因为这些连接需要为每个请求一个单独的Web服务器线程或进程。在Apache和PHP-FPM的情况下,这无法随着许多并发持久连接而扩展。

现代Web框架的要求

现代Web框架通常处理三类HTTP请求。首先是动态生成的页面:动态提供服务,通常由模板处理器生成。其次是静态内容:不会改变的小文件和大文件(例如,JavaScript、CSS和媒体资产)。第三是持久连接:WebSockets和长轮询请求,用于在页面中添加交互性和双向通信。

在创建Zotonic之前,我们一直在寻找一个软件框架和编程语言,它可以让我们实现我们的设计目标(高性能、开发人员友好)并避开与传统Web服务器系统相关的瓶颈。理想情况下,软件应满足以下要求。

Erlang来解救

据我们所知,Erlang是唯一一个“开箱即用”就能满足这些要求的语言。Erlang VM与它的开放电信平台(OTP)相结合,提供了满足我们所有必要功能的系统,而且现在仍然如此。

Erlang是一种(大多数情况下)函数式编程语言和运行时系统。Erlang/OTP应用程序最初是为电话交换机开发的,以其容错性和并发性而闻名。Erlang采用基于actor的并发模型:每个actor都是一个轻量级的“进程”(绿色线程),进程之间共享状态的唯一方法是传递消息。开放电信平台是一组标准的Erlang库,它们支持容错和进程监控等等。

容错性是其编程模式的核心:让它崩溃是该系统的主要哲学。由于进程不共享任何状态(要共享状态,它们必须彼此发送消息),因此它们的状态与其他进程隔离开来。因此,一个进程崩溃永远不会导致系统崩溃。当一个进程崩溃时,它的监控进程可以决定重新启动它。

让它崩溃还允许你为正常情况编写代码。使用模式匹配和函数保护来确保状态合理意味着需要更少的错误处理代码,这通常会导致干净、简洁、易读的代码。

Zotonic的架构

在我们讨论Zotonic的性能优化之前,让我们看一下它的架构。[图9.1](#figure-9.1)描述了Zotonic最重要的组件。

Figure 9.1 - The architecture of Zotonic

图9.1 - Zotonic的架构

该图显示了HTTP请求经过的Zotonic层。为了讨论性能问题,我们需要知道这些层是什么,以及它们如何影响性能。

首先,Zotonic内置了一个Web服务器,Mochiweb(另一个Erlang项目)。它不需要外部Web服务器。这将部署依赖项降至最低。1

与许多Web框架一样,使用URL路由系统将请求与控制器匹配。控制器以RESTful的方式处理每个请求,这得益于Webmachine库。

控制器是“哑”的,有意地没有太多特定于应用程序的逻辑。Zotonic提供了一些标准控制器,这些控制器对于开发基本的Web应用程序来说通常已经足够了。例如,有一个controller_template,它的唯一目的是通过渲染给定模板来响应HTTP GET请求。

模板语言是对著名的Django模板语言的Erlang实现,称为ErlyDTL。Zotonic的一般原则是模板驱动数据请求。模板决定它们需要哪些数据,并从模型中检索数据。

模型公开函数从各种数据源(如数据库)检索数据。模型向模板公开API,规定了它们的使用方式。模型还负责在内存中缓存其结果;它们决定何时缓存什么以及缓存多长时间。当模板需要数据时,它们会调用模型,就好像它是一个全局可用的变量一样。

模型是一个Erlang包装模块,负责某些数据。它包含必要的函数,以应用程序需要的方式检索和存储数据。例如,Zotonic的中心模型称为m.rsc,它提供对通用资源(“页面”)数据模型的访问。由于资源使用数据库,因此m_rsc.erl使用数据库连接来检索其数据并将其传递给模板,并在可能的情况下对其进行缓存。

这种“模板驱动数据”的方法不同于其他Web框架(如Rails和Django),它们通常遵循更经典的MVC方法,控制器将数据分配给模板。Zotonic遵循一种更少“以控制器为中心”的方法,因此可以通过仅编写模板来构建典型的网站。

Zotonic使用PostgreSQL进行数据持久化。[数据模型:SQL中的文档数据库](#posa.zotonic.db)解释了这种选择的原因。

其他Zotonic概念

虽然本章主要关注Web请求堆栈的性能特征,但了解一些其他概念也很有用,这些概念是Zotonic的核心。

虚拟主机
一个Zotonic实例通常会服务多个站点。它被设计用于虚拟主机,包括域名别名和SSL支持。而且由于Erlang的进程隔离,一个站点崩溃不会影响在同一个VM中运行的其他任何站点。
模块
模块是Zotonic将功能分组在一起的方式。每个模块都在它自己的目录中,其中包含Erlang文件、模板、资产等等。它们可以在每个站点基础上启用。模块可以挂钩到管理系统:例如,mod_backup模块为页面编辑器添加了版本控制,并且还运行了每天的完整数据库备份。另一个模块mod_github,公开了一个webhook,它可以从github拉取、重建和重新加载Zotonic站点,允许进行持续部署。
通知

为了实现代码的松散耦合和可扩展性,模块和核心组件之间的通信通过通知机制完成,该机制充当对特定命名通知的观察者的映射或折叠。通过监听通知,模块可以轻松地覆盖或增强某些行为。调用函数决定是使用映射还是折叠。例如,admin_menu通知是对模块的折叠,允许模块在管理菜单中添加或删除菜单项。

数据模型

Zotonic使用的主要数据模型可以与Drupal的Node模块相比较;“万物皆物”。数据模型由分层分类的资源组成,这些资源使用标记的边连接到其他资源。像它的灵感来源Anymeta CMS一样,这个数据模型松散地基于语义网的原则。

Zotonic 是一个可扩展的系统,在考虑性能时,系统的各个部分都会加起来。例如,您可能会添加一个模块来拦截 Web 请求,并在每个请求上执行一些操作。这样的模块可能会影响整个系统的性能。在本章中,我们将不考虑这个问题,而是专注于核心性能问题。

问题解决:对抗 Slashdot 效应

大多数网站在网络上的某个小地方过着平淡无奇的生活。也就是说,直到他们的页面之一出现在像 CNNBBC 或雅虎这样的热门网站的首页。在这种情况下,该网站的流量可能会在瞬间增加到每秒数十、数百甚至数千个页面请求。

这种突然的激增会使传统的 Web 服务器不堪重负,使其无法访问。术语“Slashdot 效应”以开始这种压倒性推荐的网站命名。更糟糕的是,过载的服务器有时很难重启。因为新启动的服务器有空的缓存、没有数据库连接、经常没有编译的模板等。

许多匿名访问者在同一时间请求完全相同的页面不应该能够使服务器过载。这个问题很容易通过使用像 Varnish 这样的缓存代理来解决,Varnish 会缓存页面的静态副本,并且只偶尔检查页面的更新。

当为每个访问者提供动态页面时,流量激增变得更具挑战性;这些页面无法缓存。使用 Zotonic,我们着手解决这个问题。

我们意识到大多数网站拥有

并决定

缓存热数据

当另一个请求几毫秒前已经获取了数据时,为什么要从外部来源(数据库、memcached)获取数据?我们始终缓存简单的数据请求。在下一节中,将详细讨论缓存机制。

在页面之间共享渲染的模板和子模板

渲染页面或包含的模板时,开发人员可以添加可选的缓存指令。这会将渲染结果缓存一段时间。

缓存启动了我们称之为“备忘”的功能:当模板正在渲染,并且一个或多个进程请求相同的渲染时,后面的进程将被挂起。渲染完成后,所有等待的进程都将收到渲染结果。

仅仅是备忘录(不进行任何其他缓存)就通过大幅减少并行模板处理的数量,带来了巨大的性能提升。

防止服务器启动或重启时过载

Zotonic 有意引入了几个瓶颈。这些瓶颈限制了对使用有限资源或执行成本高(从 CPU 或内存方面来看)的进程的访问。瓶颈目前已针对模板编译器、图像调整大小过程和数据库连接池进行设置。

瓶颈的实现方式是为执行请求的操作创建一个有限的 worker 池。对于 CPU 或磁盘密集型工作(如图像调整大小),只有一个进程处理请求。请求进程将其请求发布到进程的 Erlang 请求队列中,并等待其请求得到处理。如果请求超时,它将直接崩溃。这样的崩溃请求将返回 HTTP 状态 503 服务不可用

等待进程不会占用太多资源,并且如果模板发生更改或热门页面上的图像被替换并且需要裁剪或调整大小,瓶颈将防止过载。

简而言之:繁忙的服务器仍然可以动态更新其模板、内容和图像,而不会过载。同时,它允许单个请求崩溃,而系统本身继续运行。

数据库连接池

再说一句关于数据库连接的话。在 Zotonic 中,一个进程从连接池中获取一个数据库连接,以用于每个查询或事务。这使许多并发进程能够共享数量非常有限的数据库连接。将此与大多数(PHP)系统进行比较,在这些系统中,每个请求在整个请求期间都保持与数据库的连接。

Zotonic 在一段时间不活动后会关闭未使用的数据库连接。始终保持一个连接处于打开状态,以便系统能够始终快速处理传入请求或后台活动。动态连接池将大多数 Zotonic 网站上的打开数据库连接数量大幅减少到一两个。

缓存层

缓存最难的部分是缓存失效:保持缓存数据新鲜并清除陈旧数据。Zotonic 使用具有依赖项检查的中央缓存机制来解决这个问题。

本节将以自上而下的方式描述 Zotonic 的缓存机制:从浏览器向下穿过堆栈到数据库。

客户端缓存

客户端缓存由浏览器完成。浏览器会缓存图像、CSS 和 JavaScript 文件。Zotonic 不允许对 HTML 页面进行客户端缓存,它总是动态生成所有页面。因为它在这方面非常高效(如前一节所述),并且不缓存 HTML 页面可以防止在用户登录、注销或评论发布后显示旧页面。

Zotonic 通过两种方式提高客户端性能

  1. 它允许缓存静态文件(CSS、JavaScript、图像等)。
  2. 它将多个 CSS 或 JavaScript 文件包含在一个响应中。

第一个是通过将适当的 HTTP 标头添加到请求2

Last-Modified: Tue, 18 Dec 2012 20:32:56 GMT
Expires: Sun, 01 Jan 2023 14:55:37 GMT
Date: Thu, 03 Jan 2013 14:55:37 GMT
Cache-Control: public, max-age=315360000

多个 CSS 或 JavaScript 文件被连接成一个文件,用波浪号分隔各个文件,并且只有在文件之间发生更改时才提及路径。

http://example.org/lib/bootstrap/css/bootstrap
  ~bootstrap-responsive~bootstrap-base-site~
  /css/jquery.loadmask~z.growl~z.modal~site~63523081976.css

结尾处的数字是列表中最新的文件的日期戳。使用 {% lib %} 模板标签生成必要的 CSS 链接或 JavaScript 脚本标签。

服务器端缓存

Zotonic 是一个大型系统,其中许多部分以某种方式进行缓存。以下各节将解释一些更有趣的部分。

静态 CSSJS 和图像文件

处理静态文件的控制器对处理这些文件有一些优化。它可以将组合的文件请求分解成单个文件的列表。

控制器检查 If-Modified-Since 标头,并在适当的情况下提供 HTTP 状态 304 未修改

在第一个请求中,它将所有静态文件的内容连接成一个字节数组(一个 Erlang 二进制)。3 然后,这个字节数组以两种形式缓存在中央 depcache 中(请参阅 Depcache):压缩(使用 gzip)和未压缩。根据浏览器发送的 Accept-Encoding 标头,Zotonic 将提供压缩版本或未压缩版本。

这种缓存机制效率很高,其性能类似于许多缓存代理,同时仍然完全由 Web 服务器控制。在 Zotonic 的早期版本以及简单的硬件上(2008 年的四核 2.4 GHz Xeon),我们看到了大约 6000 个请求/秒的吞吐量,并且能够饱和一个千兆以太网连接,请求一个小(约 20 KB)的图像文件。

渲染的模板

模板被编译成 Erlang 模块,之后字节码将保存在内存中。编译后的模板被调用为常规的 Erlang 函数。

模板系统会检测到对模板的任何更改,并在运行时重新编译模板。编译完成后,将使用 Erlang 的热代码升级机制加载新编译的 Erlang 模块。

主页面和模板控制器可以选择缓存模板渲染结果。缓存也可以仅针对匿名(未登录)访问者启用。对于大多数网站来说,匿名访问者会生成所有请求的大部分,而这些页面将不会个性化并且(几乎)完全相同。请注意,模板渲染结果是中间结果,而不是最终的 HTML。此中间结果包含(除其他外)未翻译的字符串和 JavaScript 片段。最终的 HTML 是通过解析此中间结构、选择正确的翻译并收集所有 JavaScript 生成的。

连接后的 JavaScript 以及唯一的页面 ID 被放置在 {% script %} 模板标签的位置。这应该正好位于结束的 </body> 主体标签上方。唯一的页面 ID 用于将此渲染的页面与处理的 Erlang 进程进行匹配,以及在页面上进行 WebSocket/Comet 交互。

与任何模板语言一样,模板可以包含其他模板。在 Zotonic 中,包含的模板通常会内联编译,以消除使用包含文件造成的任何性能损失。

特殊选项可以强制运行时包含。其中一个选项是缓存。可以仅针对匿名访问者启用缓存,可以设置缓存期限,并且可以添加缓存依赖项。这些缓存依赖项用于在显示的任何资源发生更改时使缓存的渲染失效。

缓存模板部分的另一种方法是使用 {% cache %} ... {% endcache %} 块标签,该标签会将模板的一部分缓存一段时间。该标签具有与 include 标签相同的缓存选项,但它具有易于添加到现有模板中的优势。

内存缓存

所有缓存都以内存方式进行,在 Erlang VM 本身中。无需计算机之间或操作系统进程之间的通信即可访问缓存数据。这极大地简化并优化了缓存数据的使用。

作为比较,访问 memcache 服务器通常需要 0.5 毫秒。相比之下,访问同一个进程内的主内存,在 CPU 缓存命中时需要 1 纳秒,在 CPU 缓存未命中时需要 100 纳秒,更不用说内存和网络之间的巨大速度差异了。4

Zotonic 具有两种内存缓存机制:5

  1. Depcache,中央每个站点缓存
  2. 进程字典备忘录缓存

Depcache

 

每个 Zotonic 站点的中央缓存机制是 depcache,它是 dependency cache 的缩写。Depcache 是一个内存中的键值存储,每个存储的键都包含一个依赖项列表。

对于 depcache 中的每个键,我们存储

如果请求一个键,缓存会检查键是否存在、是否已过期,以及所有依赖键的序列号是否都小于缓存键的序列号。如果键仍然有效,则返回其值,否则将从缓存中删除键及其值,并返回 undefined

或者,如果键正在计算中,则将请求进程添加到键的等待列表中。

实现使用了 ETS,即 Erlang Term Storage,一个标准的哈希表实现,它是 Erlang OTP 发行版的一部分。Zotonic 为 depcache 创建了以下 ETS 表格

ETS 表格针对并行读取进行了优化,通常由调用进程直接访问。这可以防止调用进程和 depcache 进程之间的任何通信。

depcache 进程用于

depcache 可能会变得相当大。为了防止它变得太大,有一个垃圾收集器进程。垃圾收集器缓慢地迭代整个 depcache,清除已过期或失效的键。如果 depcache 大小超过某个阈值(默认情况下为 100 MiB),则垃圾收集器会加速并清除遇到的所有项目的 10%。它会一直清除,直到缓存大小低于其阈值。

在多 TB 数据库的领域,100 MiB 可能听起来很小。但是,由于缓存主要包含文本数据,它将足够大,可以容纳大多数网站的热数据。否则,可以在配置中更改缓存的大小。

进程字典备忘录缓存

Zotonic 中的另一个内存缓存范例是进程字典记忆缓存。如前所述,数据访问模式由模板决定。缓存系统使用简单的启发式方法来优化对数据的访问。

此优化中很重要的一点是将数据缓存到处理请求的进程的 Erlang 进程字典中。进程字典是与进程位于同一个堆中的简单键值存储。从本质上讲,它为函数式 Erlang 语言添加了状态。由于这个原因,通常不建议使用进程字典,但在进程内缓存中它很有用。

当访问资源时(请记住,资源是 Zotonic 的中心数据单元),它会被复制到进程字典中。计算结果(如访问控制检查)和其他数据(如配置值)也是如此。

资源的每个属性(如标题、摘要或正文文本)必须在页面上显示时执行访问控制检查,然后从资源中获取请求的属性。缓存所有资源的属性及其访问检查可以极大地加快资源数据使用速度,并消除模板难以预测的数据访问模式带来的许多缺点。

由于页面或进程可以使用大量数据,因此此记忆缓存具有几个压力阀

  1. 当持有超过 10,000 个键时,整个进程字典将被刷新。这可以防止进程字典保存许多未使用的项目,就像在遍历长资源列表时发生的情况一样。特殊 Erlang 变量(如 $ancestors)将被保留。
  2. 记忆缓存必须以编程方式启用。对于每个传入的 HTTP 或 WebSocket 请求和模板渲染,这都会自动完成。
  3. HTTP/WebSocket 请求之间,进程字典会被刷新,因为多个连续的 HTTP/WebSocket 请求共享同一个进程。
  4. 记忆缓存不跟踪依赖关系。任何 depcache 删除操作也将刷新执行删除操作的进程的整个进程字典。

当记忆缓存被禁用时,每个查找都由 depcache 处理。这会导致对 depcache 进程的调用,以及 depcache 和请求进程之间的数据复制。

Erlang 虚拟机

Erlang 虚拟机具有一些在考虑性能时很重要的属性。

进程很便宜

Erlang VM 专门设计用于并行执行许多任务,因此它在 VM 中有自己的多处理实现。Erlang 进程是根据减少计数进行调度的,其中一次减少大致相当于一次函数调用。进程可以一直运行,直到它暂停等待输入(来自其他进程的消息)或执行了固定数量的减少。对于每个 CPU 内核,都会启动一个调度程序,它拥有自己的运行队列。Erlang 应用程序在任何给定时间点都有成千上万甚至数百万个进程在 VM 中运行,这并不罕见。

进程不仅启动成本低,而且内存占用也很低,每个进程 327 个字,在 64 位机器上约为 2.5 KiB。6 这与 Java 的约 500 KiB 和 pthreads 的默认 2 MiB 相比。

由于进程的成本如此低廉,因此任何对请求结果不必要的处理都会被放到一个单独的进程中。发送电子邮件或记录日志都是可以由单独进程处理的任务的例子。

数据复制很昂贵

在 Erlang VM 中,进程之间传递的消息相对昂贵,因为消息会在进程中被复制。由于 Erlang 的每个进程垃圾收集器,需要进行这种复制。防止数据复制很重要;这就是为什么 Zotonic 的 depcache 使用 ETS 表格,可以从任何进程访问。

用于更大字节数组的单独堆

在进程之间复制数据有一个很大的例外。大于 64 字节的字节数组不会在进程之间复制。它们有自己的堆,并且被单独进行垃圾回收。

这使得在进程之间传递大型字节数组变得廉价,因为只复制了对字节数组的引用。但是,它确实使垃圾回收变得更加困难,因为在释放字节数组之前,必须收集所有引用。

有时,会传递对大型字节数组一部分的引用:在对较小部分的引用被垃圾回收之前,无法回收大型字节数组。因此,如果这释放了大型字节数组,则复制字节数组是一种优化。

字符串处理很昂贵

在任何函数式语言中,字符串处理都可能很昂贵,因为字符串通常被表示为整数的链接列表,并且由于 Erlang 的函数式特性,数据无法被破坏性地更新。

如果字符串被表示为列表,则它使用尾递归函数和模式匹配进行处理。这使其成为函数式语言的自然选择。问题是链接列表的数据表示开销很大,而且将列表传递给另一个进程总是涉及复制完整的数据结构。这使得列表成为字符串的非最佳选择。

Erlang 对字符串有自己的折中答案:io-list。Io-list 是包含列表、整数(单个字节值)、字节数组和对其他字节数组一部分的引用的嵌套列表。Io-list 非常易于使用,追加、前缀或插入数据都很便宜,因为它们只需要更改相对较短的列表,而无需进行任何数据复制。7

Io-list 可以按原样发送到“端口”(文件描述符),它将数据结构展平为字节流并将其发送到套接字。

io-list 的例子

 [ <<"Hello">>, 32, [ <<"Wo">>, [114, 108], <<"d">>].

它展平为字节数组

 <<"Hello World">>.

有趣的是,Web 应用程序中的大多数字符串处理都包含

  1. 将数据(动态和静态)连接到结果页面中。
  2. HTML 转义和清理内容值。

Erlang 的 io-list 非常适合第一个用例。第二个用例通过在内容存储到数据库之前对其进行积极的清理来解决。

这两者结合意味着,对于 Zotonic 来说,渲染后的页面只是一个大型字节数组和预清理值的单个 io-list 的连接。

对 Zotonic 的影响

Zotonic 大量使用了一个相对较大的数据结构,即Context。这是一个包含请求评估所需所有数据的记录。它包含

所有这些数据都可以构成一个大型数据结构。将此大型 Context 发送到处理请求的不同进程会导致大量的数据复制开销。

这就是为什么我们试图在单个进程中完成大多数请求处理的原因:接受请求的 Mochiweb 进程。使用函数调用而不是进程间消息调用其他模块和扩展。

有时,扩展是使用单独的进程实现的。在这种情况下,扩展会提供一个接受 Context 和扩展进程的进程 ID 的函数。此接口函数负责高效地向扩展进程发送消息。

Zotonic 还需要在渲染可缓存的子模板时发送消息。在这种情况下,在将 Context 发送到渲染子模板的进程之前,会删除 Context 中的所有中间模板结果以及一些其他不需要的数据(如日志信息)。

我们不太关心传递字节数组,因为在大多数情况下,它们的大小大于 64 字节,因此不会在进程之间复制。

对于服务大型静态文件,可以选择使用 Linux 的 sendfile() 系统调用来将发送文件委托给操作系统。

对 Webmachine 库的更改

Webmachine 是一个实现 HTTP 协议抽象的库。它建立在 Mochiweb 库之上,Mochiweb 库实现低级 HTTP 处理,如接受器进程、标头解析等。

控制器是通过创建实现回调函数的 Erlang 模块来创建的。回调函数的例子包括 resource_existspreviously_existedauthorizedallowed_methodsprocess_post 等。Webmachine 还将请求路径与一系列调度规则进行匹配;分配请求参数并选择正确的控制器来处理 HTTP 请求。

使用 Webmachine,处理 HTTP 协议变得很容易。出于这个原因,我们很早就决定在 Webmachine 之上构建 Zotonic。

在构建 Zotonic 的过程中,遇到了一些 Webmachine 的问题。

  1. 最初,它只支持单个调度规则列表;而不是每个主机(即站点)的规则列表。
  2. 调度规则是在应用程序环境中设置的,并在调度时复制到请求进程。
  3. 一些回调函数(如last_modified)在请求评估期间被多次调用。
  4. 当 Webmachine 在请求评估期间崩溃时,请求记录器不会生成任何日志条目。
  5. 不支持 HTTP 升级,这使得 WebSockets 支持更加困难。

第一个问题(没有调度规则的分区)只是一个麻烦。它使调度规则列表不太直观,更难解释。

第二个问题(为每个请求复制调度列表)被证明是 Zotonic 的一个阻碍。列表可能变得非常大,以至于复制它可能需要处理请求所需的大部分时间。

第三个问题(对同一函数的多次调用)迫使控制器编写者实现他们自己的缓存机制,这是容易出错的。

第四个问题(崩溃时没有日志)使得在生产环境中更难发现问题。

第五个问题(没有 HTTP 升级)阻止我们使用 Webmachine 中为 WebSocket 连接提供的不错的抽象。

上述问题非常严重,以至于我们不得不为了自己的目的修改 Webmachine。

首先添加了一个新选项:调度程序。调度程序是一个实现dispatch/3函数的模块,该函数将请求与调度列表匹配。调度程序还使用 HTTP Host 头部选择正确的站点(虚拟主机)。在测试一个简单的“hello world”控制器时,这些更改使吞吐量提高了三倍。我们还观察到,在具有多个虚拟主机和调度规则的系统上,收益要高得多。

Webmachine 维护两个数据结构,一个用于请求数据,另一个用于内部请求处理状态。这些数据结构相互引用,实际上几乎总是同时使用,因此我们将它们合并成一个数据结构。这使得更容易删除对进程字典的使用,并将新的单个数据结构作为参数添加到 Webmachine 中的所有函数中。这使得每个请求的处理时间减少了 20%。

我们以许多其他方式优化了 Webmachine,这里不再详细描述,但最重要的几点是

数据模型:SQL 中的文档数据库

 

从数据的角度来看,值得一提的是,所有“资源”(Zotonic 的主要数据单元)的属性都被序列化为二进制 blob;“真实”数据库列仅用于键、查询和外键约束。

为属性或需要索引的属性组合(如全文列、日期属性等)添加了单独的“枢纽”字段和表。

当资源被更新时,数据库触发器将资源的 ID 添加到枢纽队列。该枢纽队列由一个单独的 Erlang 后台进程使用,该进程一次在一个事务中索引一批资源。

选择 SQL 使我们能够立即开始工作:PostgreSQL 具有众所周知的查询语言、出色的稳定性、已知的性能、出色的工具,以及商业和非商业支持。

除此之外,数据库不是 Zotonic 中的性能限制因素。如果查询成为瓶颈,那么开发人员的任务就是使用数据库的查询分析器优化该特定查询。

最后,使用任何数据库的黄金性能规则是:不要访问数据库;不要访问磁盘;不要访问网络;访问你的缓存。

基准测试、统计数据和优化

我们不太相信基准测试,因为它们通常只测试系统的一小部分,并不代表整个系统的性能。特别是由于系统具有许多活动部件,而在 Zotonic 中,缓存系统和处理常见访问模式是设计中不可分割的一部分。

一个简化的基准测试

基准测试可能做的事情是显示你首先可以优化系统的地方。

考虑到这一点,我们使用 TechEmpower JSON 基准测试对 Zotonic 进行了基准测试,该基准测试基本上测试了请求调度程序、JSON 编码器、HTTP 请求处理以及 TCP/IP 堆栈。

基准测试是在 Intel i7 四核 M620 @ 2.67 GHz 上执行的。命令是wrk -c 3000 -t 3000 https://127.0.0.1:8080/json。结果如表 9.1 所示。

表 9.1 - 基准测试结果
平台 每秒请求数(x1000)
Node.js 27
Cowboy(/Erlang) 31
Elli(/Erlang) 38
Zotonic 5.5
Zotonic(不带访问日志) 7.5
Zotonic(不带访问日志,带调度程序池) 8.5

Zotonic 的动态调度程序和 HTTP 协议抽象在这种微型基准测试中得分较低。这些问题相对容易解决,解决方案已经计划好

实际性能

对于 2013 年荷兰女王退位和随后新荷兰国王就职,一个使用 Zotonic 构建的全国投票网站。客户要求 100% 的可用性和高性能,能够每小时处理 100,000 票。

该解决方案是一个拥有四个虚拟服务器的系统,每个服务器都拥有 2 GB RAM 并运行他们自己的独立 Zotonic 系统。三个节点处理投票,一个节点用于管理。所有节点都是独立的,但投票节点将每张票与至少其他两个节点共享,因此如果一个节点崩溃,就不会丢失任何票。

一张票会产生大约 30 个 HTTP 请求用于动态 HTML(多种语言)、Ajax 和静态资产(如 css 和 javascript)。需要多个请求来选择三个投票项目并填写选民的详细信息。

在测试时,我们轻松地满足了客户的要求,而无需将系统推到极限。投票模拟在每小时 500,000 次完整投票程序处停止,使用约 400 mbps 的带宽,99% 的请求处理时间低于 200 毫秒。

从上面可以清楚地看出,Zotonic 可以处理流行的动态网站。在真实硬件上,我们观察到更高的性能,特别是对于底层的 I/O 和数据库性能。

结论

在构建内容管理系统或框架时,重要的是要考虑应用程序的整个堆栈,从 web 服务器、请求处理系统、缓存系统到数据库系统。所有部分必须协同工作才能获得良好的性能。

可以通过预处理数据获得更高的性能。预处理的一个例子是在将数据存储到数据库之前对其进行预转义和清理。

为热门数据缓存是一个很好的策略,适用于拥有明确的一组热门页面和大量不太热门页面的网站。将此缓存放在与请求处理代码相同的内存空间中,在速度和简单性方面都比使用单独的缓存服务器更有优势。

处理突然的流行度激增的另一个优化是动态匹配类似的请求并为相同的結果处理一次。如果实施得当,可以避免使用代理,并且所有 HTML 页面都可以动态生成。

Erlang 非常适合构建基于动态 web 的系统,因为它具有轻量级多处理、故障处理和内存管理功能。

使用 Erlang,Zotonic 可以构建一个非常强大且性能良好的内容管理系统和框架,而无需使用单独的 web 服务器、缓存代理、memcache 服务器或电子邮件处理程序。这极大地简化了系统管理任务。

在当前的硬件上,单个 Zotonic 服务器每秒可以处理数千个动态页面请求,从而轻松地为全球大多数网站提供服务。

使用 Erlang,Zotonic 为拥有数十个内核和数千兆字节内存的未来多核系统做好了准备。

致谢

作者感谢 Michiel Klønhammer(Maximonster Interactive Things)、Andreas Stenius、Maas-Maarten Zeeman 和 Atilla Erdődi。

  1. 但是,可以在前面放置另一个 web 服务器,例如,当其他 web 系统在同一个服务器上运行时。但在正常情况下,这不是必需的。有趣的是,其他框架使用的一个典型优化是在应用程序服务器前面放置一个缓存 web 服务器,例如 Varnish,用于提供静态文件,但对于 Zotonic 而言,这并不会显著加快这些请求,因为 Zotonic 也在内存中缓存静态文件。)

  2. 请注意,Zotonic 不会设置 ETag。一些浏览器会通过向服务器发出请求来检查每个文件使用的 ETag。这违背了缓存和减少请求的整体理念。

  3. 字节数组或二进制是 Erlang 的本机数据类型。如果它小于 64 字节,它将在进程之间复制,更大的字节数组将在进程之间共享。Erlang 还使用对这些部分的引用在进程之间共享字节数组的一部分,而不是复制数据本身,因此使这些字节数组成为一种高效且易于使用的数据类型。

  4. 请参阅http://www.eecs.berkeley.edu/~rcs/research/interactive_latency.html 上的“每个程序员都应该知道的延迟数字”。

  5. 除了这些机制之外,数据库服务器还执行一些内存内缓存,但这不在本章的范围之内。

  6. 请参阅https://erlang.org.cn/doc/efficiency_guide/advanced.html#id68921

  7. Erlang 还可以共享字节数组的一部分,并使用对这些部分的引用,从而避免复制数据。对字节数组的插入可以用一个包含三个部分的 io 列表表示:对未更改的头部字节的引用、插入的值和对未更改的尾部字节的引用。