作者:Eli White
根据 Digg、TripAdvisor 及其他高流量站点中的真实经验扩展 PHP-MySQL Web 应用程序的窍门。
2011 年 4 月发布
创建 Web 应用程序(实际上是编写核心代码)通常是项目的初始侧重点。这很有趣、令人激动,并且也是您在项目中受驱动要完成的任务。但是,人们总是希望自己的应用程序可以应对比最初预期更高的流量。在这种情况下,人们经常会开始思考如何扩展他们的网站。
理想情况下,您首次编写代码时就应该思考如何扩展您的应用程序。这并不是说您在面对未知未来的早期开发过程中就应该投入大量精力。谁知道未来会发生什么?谁知道您的应用程序是否需要可扩展性工作才会达到流量水平?但我们希望您能从此处学到最重要的经验,即了解未来需要通过哪些工作进行扩展。了解这点之后,您可以在项目的各个阶段只做需要的工作,避免“让自己陷入编码困境”,从而导致难以开展下一步可扩展性工作。
我曾任职于多家公司,也参与了许多项目,这些项目都需要随时间的推移应对海量 Web 流量。其中包括 Digg、TripAdvisor 和 Hubble Space Telescope 项目。在本文(分为两部分)中,我将与大家分享我学到的一些经验和教训,并指导您分步完成扩展应用程序的标准过程。
进行深入讨论之前,我们应先讨论性能和可扩展性的异同点。
在 Web 应用程序中,性能是指向最终用户提供数据(页面)的速度。当人们谈论提高应用程序性能时,他们讨论的通常是将生成内容的时间从 500 毫秒缩短至 300 毫秒。
相反,可扩展性是指支持应用程序随流量增长而增强的能力。从理论上说,无论向其发送的流量有多高,可扩展的应用程序都可通过增加容量来应对该流量。
可扩展性和性能显然是相互关联的。当提高应用程序的性能时,要扩展的资源越少,扩展就越轻松。同样,如果某个应用程序为了提供足够高的性能而需要为每个用户配备一台 Web 服务器,那么不能将其称为具有可扩展性,因为您无法提供这么多服务器。
您首先应了解 Web 服务器和 PHP 设置中所有最易实现的目标。您可以看到一些非常简单的操作便可立即提高性能,可能会降低当前的扩展需求,至少可以简化扩展。
第一件个简单的操作就是安装操作码缓存。PHP 是一种脚本语言,因此在每次请求时都会重新编译代码。在您的 Web 服务器中安装操作码缓存则可以绕开这一限制。可以认为操作码缓存位于 PHP 和服务器计算机之间;在首次编译 PHP 脚本之后,操作码缓存会记住经过编译的版本,以后的请求只提取已经过编译的版本。
可使用的操作码缓存有许多种。Zend Server 附带了内置的操作码缓存,Microsoft 也提供了适用于 Windows 计算机的操作码缓存,即“WinCache”。一种最流行的操作码缓存是开源产品 APC。这些产品中的任何一种的安装都非常简单,并且这样做可立即为您带来明显的性能改善。
下一步,您需要评估是否可以删除任何 Web 页面的动态特性。Web 应用程序经常会有一些 PHP 生成的页面,但它们实际上很少发生更改。例如,“常见问题解答”页面或新闻稿。对生成的这些页面进行缓存并提供缓存的内容,这样 PHP 无需执行任何任务,从而节省了大量 CPU 周期。
可以采用多种方式完成此任务。其中一种方式是通过 PHP 预先生成 HTML 页面并直接将 HTML 页面提供给最终用户。这或许可在夜间进行,这样对 Web 页面的任何更新实际上都可以达到实时效果,只不过计划上有所延误。实现此任务非常简单,只需从命令行运行 PHP 脚本,将输出传递给 .html 文件,然后更改应用程序中的链接。
但是,另一种方式可以更轻松地完成此任务:实现动态缓存。基本上,整个脚本的输出都将捕获到文件系统的缓冲区、内存/缓存或数据库等实体中。以后针对该脚本的所有请求只需读取缓存的副本。一些模板化系统(如 Smarty)会自动完成这一任务,一些不错的非正式程序包也可以为您完成这一任务(例如 jpcache)。
您可以非常轻松地编写一种最简单版本的代码来完成此任务:注入到典型 PHP 页面顶部的以下代码会为您完成这一任务(显然需要替换超时和缓存目录以满足您的需求)。将其封装成一个库函数,您可以根据需要轻松地缓存页面。这样,它就仅根据 URL 和 GET 参数简单地缓存页面。如果页面变更基于会话、POST 数据或 Cookie,则需要将它们添加为所创建文件的特有内容。
<?php $timeout = 3600; // One Hour $file = '/tmp/cache/' . md5($_SERVER['REQUEST_URI']); if (file_exists($file) && (filemtime($file) + $timeout) > time()) { // Output the existing file to the user readfile($file); exit(); } else { // Setup saving and let the page execute: ob_start(); register_shutdown_function(function () use ($file) { $content = ob_get_flush(); file_put_contents($file, $content); }); } ?>
创建 Web 应用程序时,每个人都从一台可处理一切任务的服务器开始。这非常完美,也是通常所采用的方式。在扩展 Web 应用程序以应对更多流量时,首先需要完成的一个步骤是配备多台 Web 服务器来处理请求。PHP 尤为适合这种方式的水平扩展,只需根据需要添加更多 Web 服务器即可。这将通过负载平衡来处理,从根本上讲,负载平衡只是一个具有中心点的概念,所有请求都传输到中心点,然后将这些请求分发给各个 Web 服务器。
图 1 负载平衡
从运营上实现负载平衡有多种选择。它们大多可分为三种不同类别。
第一种是软件平衡器。它是可以安装在标准计算机(通常基于 Linux)上的软件,将为您处理所有负载平衡。每个软件平衡器还随带了自己的附加特性,如内置页面输出缓存、gzip 压缩等。实际上,此类别中的大多数流行选件都是具有反向代理模式的多用途软件/Web 服务器,您可以启用反向代理模式来实现负载平衡。这包括 Apache 本身、Nginx 和 Squid。此外还有一些更小型的专用负载平衡软件,如 Perlbal。您甚至可以在 DNS 服务器中实施一种非常简单的负载平衡,即使用 DNS 循环让每个 DNS 请求以不同的 IP 地址响应,但其灵活性显然有所欠缺。
第二种是硬件平衡器。这是可以购买的物理机器,专用于应对非常高的流量并且内置了自定义软件。两个较为知名的版本是 Citrix Netscaler 和 F5 BigIP。硬件解决方案通常提供许多定制的优势,并且还可充当防火墙和安全屏障。
最后一种是全新的基于云的解决方案。由于托管给云提供商,您显然不能安装基于硬件的解决方案,因而只能使用软件解决方案。但是,大多数云提供商都有自己的跨实例实现负载平衡的内置机制。某些情况下,这些机制虽然不够灵活,但更易于设置和维护。
最后,所有解决方案(无论是软件还是硬件)通常都提供了许多相同的特性。能够操作或缓存传入的数据、能够通过随机选择或者通过查看各从属计算机上的运行状况表来实现负载平衡,等等。我建议您了解适用于托管环境的选件,并从中找出最合适的选件。您可以从网络上获取任何解决方案的设置指南,其中涵盖了所有基础知识。
如本文前面所述,仅完成所需任务是非常重要的,但同时也要在心理上为下一步工作做好准备。第一次构建应用程序时,您很少会建立负载平衡,但您应记住确保以后完成这一步骤。
首先,如果您使用任何类型的本地内存缓存解决方案(例如 APC 提供的解决方案),那么您需要编写代码,不要假定只有一个缓存。配备多台服务器之后,存储在一台计算机上的数据只能通过该计算机访问。
同样,单一文件系统也有相同的缺陷。使用多台服务器之后,PHP 会话将不能再存储在文件系统上,因此您需要为它们寻找另一种解决方案(例如数据库)。我还看到过存储上载文件并希望稍后读回文件的代码。因此,您需要假定不应在文件系统上存储任何内容。
现在,如果您觉得快速开发初始应用程序非常必要,仍然可以使用之前列出的方法。重要的是封装任何“单个计算机”相关代码。确保其位于单一函数中,或者至少在一个类中。然后,开始实现负载平衡时,只需对新解决方案的一处进行更新。
现在,您已经为 Web 服务器赋予了良好的可扩展性。大多数 Web 应用程序都发现了自己的下一个瓶颈:数据库本身。MySQL 非常强大,但您最终会遇到与 Web 服务器相同的问题,即,只有一个数据库是不够的。为此,MySQL 附带了一个内置解决方案,称作主从复制。
在主从设置中,您将配备一台服务器作为主服务器,即数据的真正信息库。然后,您将设置另一台 MySQL 服务器,将其配置为主服务器的从服务器。主服务器上发生的所有操作都将在从服务器上重放。
图 2 主从设置
采用此方式完成配置之后,您将确保代码与“正确的”数据库通信,具体取决于需要采取的操作。任何时间需要更改服务器上的数据时(更改、删除和插入命令),您都将连接至主服务器。对于读取访问,您将连接至从服务器。这非常简单,如下所示:
<?php $master = new PDO('mysql:dbname=mydb;host=127.0.0.2', $user, $pass); $master->exec('update users set posts += 1'); $slave = new PDO('mysql:dbname=mydb;host=127.0.0.3', $user, $pass); $results = $slave->query('select id from posts where user_id = 42'); ?>
采用这种方法实现可扩展性有两个好处。首先,您设法将数据库负载分成了两部分。因此,立即降低了各服务器的负载。这通常不是一种平均的划分;大多数 Web 应用程序的读取负载都非常高,因此从服务器将承担更多负载。但这引出了第二个要点:隔离。现在,您已经隔离了两种不同类型的负载,即写入和读取。这将使您能够更加灵活地采取接下来的可扩展性步骤。
这种方法实际上只有一个主要缺陷(除了确保始终与正确的 MySQL 数据库通信以外),那就是从数据库延迟。从数据库不会立即拥有与主数据库相同的数据。如前所述,任何更改数据库的命令基本上都会在从数据库上重放。这意味着,主数据库上进行更新之后,从数据库需要一段时间(希望这非常短)才能有所反映。通常,这段时间为几毫秒,但如果您的服务器过载,那么从数据库的延迟会大大增加。
对代码的主要更改是您无法对主数据库写入内容然后立即尝试将其读回。如果您使用 SQL 数字操作、采用默认值或者触发器就位,那么这是一种常见的做法。发起写入命令之后,您的代码可能不知道服务器上的值,因此希望将其读回以便继续处理。一个常见的例子如下:
<?php $master->exec('update users set posts += 1'); $results = $slave->query('select posts from users'); ?>
即使可以实现最快的设置,也无法达到预期的效果。无法在执行查询之前在从数据库上重放更新,您将获得错误的发帖数量。因此,在此类情况下,您需要寻找变通方法。在最坏的情况下,您可以在主数据库上查询该数据,但这有悖于“隔离考虑”的初衷。更好的解决方案是设法“估计”数据。例如,在显示用户新添加一个帖子的示例中,只需先读取发帖数量,然后在向用户回显值之前手动添加一。如果由于同时添加了多个帖子恰巧导致错误,那么错误仅限于这一个 Web 页面。对于其他读取值的用户来说,数据库是正确的。
现在,下一个逻辑步骤是通过增加更多从数据库来水平扩展 MySQL 数据库的容量。一个主数据库可以应对任意数量的从计算机 — 在一些极端限制内。虽然很少有系统会遇到这种情况,但理论限制是一个主数据库无法处理大量从数据库。
图 3 多个 MySQL 从数据库
我之前说过,使用主从配置的好处之一是隔离考虑。再加上大多数 Web 应用程序生成每个页面时添加数据的操作要少于访问数据的需求,因此其读取负载较高。这意味着您的可扩展性考虑通常在读取访问上。这正是添加更多从数据库所带来的问题。添加的每一个从数据库都会增加数据库容量,就像为 PHP 添加更多 Web 服务器一样。
现在,这将给您造成潜在的影响。如何确定连接哪个数据库呢?当您只有一个数据库时,做出决定很简单。即使只有一个从数据库时,这也非常简单。但是要正确平衡数据库负载,您需要连接许多不同服务器。
人们可以部署两种主要解决方案来解决此问题。第一个解决方案是为您拥有的每台 Web 服务器配备一个专用 MySQL 从数据库。这是一种最为简单的解决方案,因此也是较常部署的解决方案。您可以将其视为如下方式:
图 4 为每台 Web 服务器配备专用从数据库
在上图中,您可以看到一个采用三台 Web 服务器的负载平衡器。每台 Web 服务器都与它自己的从数据库通信,而这些从数据库都从一个主数据库获取数据。要确定 Web 服务器访问哪个数据库非常简单,因为这与单一从数据库情景没有任何区别 — 您只需将每台 Web 服务器配置为连接到不同的数据库作为其从数据库即可。
但是,实际部署此解决方案时,通常还需要另外一个步骤:配备一台计算机同时充当 Web 服务器和从数据库。这将极大地简化局面,因为在连接从数据库时将直接连接到“localhost”,从而实现了内置的平衡。
但是,这种简单的方法也有一个非常大的缺点,因此始终令我避之大吉:您最终会将 Web 服务器的可扩展性与 MySQL 从数据库绑定在一起。隔离考虑仍然是扩展任务中的一件大事。对 Web 服务器和从数据库的扩展需求通常是相互独立的。您的应用程序可能只需要三台 Web 服务器,但如果数据库操作密集的话,则需要 20 个从数据库。或者,在 PHP 和逻辑密集的情况下,可能需要 3 个从数据库和 20 台 Web 服务器。若将两者绑定在一起,则需要同时添加一台新 Web 服务器和一个新的从数据库。这还意味着要增加维护开销。
将 Web 服务器和从数据库结合到同一台计算机上还意味着您会过度利用每台计算机的资源。此外,您无法获取满足各服务需求的特定硬件。通常,数据库计算机的需求(大量和快速 I/O)不同于 PHP 从服务器的需求(高速 CPU)。
针对上述问题的解决方案是切断 Web 服务器与 MySQL 从数据库之间的关系,使两者之间的连接实现随机化。在这种情况下,随机(或者采用所选算法)送达某 Web 服务器的各个请求会选择连接不同的从数据库。
图 5 随机化 Web 服务器和从数据库之间的连接
这种方法允许单独扩展 Web 服务器和从数据库。它甚至还支持智能算法,即根据最适合应用程序的逻辑来计算要连接哪个从数据库。
另外还有一种好处没有讨论:稳定性。通过随机连接,可以使某个从数据库停机,这样您的应用程序便会忽略它,选择另一个从数据库进行连接。
通常,需要在应用程序中编写代码来选择要连接的数据库。可以通过一些方式来避免此任务,例如,将所有从数据库置于负载平衡器后面,然后与负载平衡器相连。但是,在 PHP 代码中建立逻辑可赋予程序员最大的未来可扩展性。以下示例显示了完成此任务的一些基本数据库选择代码:
<?php class DB { // Configuration information: private static $user = 'testUser'; private static $pass = 'testPass'; private static $config = array( 'write' => array('mysql:dbname=MyDB;host=10.1.2.3'), 'read' => array('mysql:dbname=MyDB;host=10.1.2.7', 'mysql:dbname=MyDB;host=10.1.2.8', 'mysql:dbname=MyDB;host=10.1.2.9') ); // Static method to return a database connection: public static function getConnection($server) { // First make a copy of the server array so we can modify it $servers = self::$config[$server]; $connection = false; // Keep trying to make a connection: while (!$connection && count($servers)) { $key = array_rand($servers); try { $connection = new PDO($servers[$key], self::$user, self::$pass); } catch (PDOException $e) {} if (!$connection) { // We couldn't connect. Remove this server: unset($servers[$key]); } } // If we never connected to any database, throw an exception: if (!$connection) { throw new Exception("Failed: {$server} database"); } return $connection; } } // Do some work $read = DB::getConnection('read'); $write = DB::getConnection('write'); . . . ?>
当然,您可能希望(并且应该)对以上代码进行各种增强,然后再应用于生产。您可能希望记录各数据库连接故障。不应将配置存储为静态类变量,因为无法在不更改代码的情况下更改配置。此外,在此设置中,所有服务器都平等对待。可以考虑增加服务器“权重”的理念,这样可为一些服务器分配较少的流量。最后,您可能希望将此逻辑封装到更大的数据库抽象类中,从而为您提供更大的灵活性。
按照本文中的步骤,您应该可以顺利地为 PHP 应用程序建立一个可扩展的架构。实际上,解决方案并非只有一个,也就是说没有万全之策。同样,应用程序解决方案或框架也不是只有一个。每个应用程序都需要解决不同的瓶颈问题和不同的扩展问题。
除本部分讨论的方法外,本文的第 2 部分将讨论关于扩展 MySQL 数据库的更高级的话题。