文章
动态脚本语言
作者:Eli White
根据 Digg、TripAdvisor 及其他高流量站点中的真实经验扩展 PHP-MySQL Web 应用程序的窍门。本部分:池化和分片方法。
2011 年 4 月发布
在本文的第 1 部分中,我阐述了将 PHP 和 MySQL 应用程序从单一服务器迁移至可扩展解决方案的基本步骤。虽然大多数 Web 应用程序的扩展永远都不需要超出上篇文章所述的概念,但您最终会发现基本步骤不足以满足自己的需要。
本文将介绍一些有关如何更改设置以支持更多吞吐量、更好地隔离“考虑”和进一步提升可扩展性的更高级话题。
数据库池化是支持“隔离考虑”的一种非常简单的方法。此概念涉及将一组从数据库虚拟分离到多个池中。每个池都会接收特定的查询类别。使用一个基本博客作为例子,我们最终可以实现如下所示的池分配:
图 1 数据库池化
现在一个显而易见的问题是:为什么?既然上述设置中的所有从数据库都是相同的,为何还要考虑这么做?
主要原因是隔离高数据库负载的区域。基本上,您将决定哪些查询可以慢些执行。在本例中,您会看到我们仅留出了两个从数据库用于批处理进程。通常,如果您需要在后台运行任何批处理进程,那么不必担心它们运行的速度有多快。无论它用了 5 秒钟还是 5 分钟,都没有关系。因此,可以让批处理进程占用更少的资源,从而将更多的资源留给应用程序的其余部分。但或许更重要的是,执行大规模批处理进程(这也是未首先实时执行它们的原因)时,通常所执行的查询类型会相当多并且会占用大量资源。
通过将这些进程与它们自己的从数据库相隔离,您的批处理进程启动时实际上不会明显影响 Web 应用程序的性能。(当午夜之后每个人都决定运行 cron 作业来处理后台进程数据时,许多网站都会变慢,这非常令人惊讶。)隔离这一问题后,您的网站会更好地运行。现在,您已经创建了选择性的可扩展性。
此概念同样适用于应用程序的其他方面。以一个典型的博客为例,其主页仅显示博文列表。针对这些博文的任何评论只在一个固定链接页面上显示。检索任何特定博文的评论都可能是一个痛苦的过程,因为其数量是任意的。如果站点允许线型评论,事情会变得更加复杂,因为经常需要查找和组合这些线索。
数据库池化的优势就在于此。以非常快的速度加载主页通常更加重要。包含评论的固定链接页面可以慢一些。当某人打算加载整篇博文时,他们已经提交了请求并且愿意多等一会。因此,如果隔离一组从数据库(上述示例中为四个)使其专用于评论查询,那么您可以保留更大规模的“主”从数据库池在生成主页时使用。另外,您已经隔离了负载并且创建了有选择的可扩展性。您的评论和固定链接页面在高负载下可能会变慢,但主页生成速度始终很快。
对这一池模型应用可扩展性技巧的一种方式是允许动态更改池分配。如果您的固定链接出于某种原因而非常热门,那么您可以将从数据库从主池移至评论池以满足其需求。通过隔离负载,您已经设法为自己提供了更高的灵活性。可以向任何池添加从数据库、在池之间移动从数据库,从而最终实现当前流量水平下所需的性能。
还可以从 MySQL 数据库池化中获得另一个好处,即查询缓存的命中率更高。MySQL(及大多数数据库系统)内置了查询缓存。该缓存存放最近查询的结果。如果再次执行同一查询,那么会迅速返回缓存的结果。
如果您有 20 个从数据库并且在一行中两次执行同一查询,那么只有 1/20 的几率命中同一个从数据库而获取缓存的结果。但是,通过将某些查询类发送给一组更少的服务器,可以显著增加缓存命中的几率,从而提高性能。
您将需要在代码中处理数据库池化 — 对第 1 部分中的基本负载平衡代码的自然扩展。下面我们将讨论如何扩展该代码以处理任意数据库池:
<?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'),
'primary' =>
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'),
'batch' =>
array('mysql:dbname=MyDB;host=10.1.2.12'),
'comments' =>
array('mysql:dbname=MyDB;host=10.1.2.27',
'mysql:dbname=MyDB;host=10.1.2.28'),
);
// Static method to return a database connection to a certain pool
public static function getConnection($pool) {
// Make a copy of the server array, to modify as we go:
$servers = self::$config[$pool];
$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) {
// Couldn’t connect to this server, so remove it:
unset($servers[$key]);
}
}
// If we never connected to any database, throw an exception:
if (!$connection) {
throw new Exception("Failed Pool: {$pool}");
}
return $connection;
}
}
// Do something Comment related
$comments = DB::getConnection('comments');
. . .
?>
可以看到,所需的更改非常少。这当然是有目的的。知道需要池化之后,原始代码在设计时便考虑了可扩展性。用于随机选择读取从数据库“池”的任何代码首先都应该能够扩展,使其能够方便地理解可以从多个池进行选择。
当然,第 1 部分中的评论同样适用于此代码:您可能希望将逻辑封装在更大的数据库抽象层中、添加更好的错误报告并且可能还要扩展所提供的特性。
到目前为止,我们已经介绍了实现可扩展性的所有十分简单的步骤。希望您已经找到了有效的解决方案。为什么这么说?因为从编码的角度来说,下面的步骤会非常费神。我知道这点是因为我在 Digg 工作时采取了这些步骤。
如果您需要进一步扩展,那么通常是因为几个原因。所有这些原因都反映了您代码中的各种难点或瓶颈。一个原因可能是您的表变得异常庞大,包含数千万或数亿行数据,无法迅速完成查询。也可能是因为您的主数据库被写入流量所淹没。您可能遇到与这些情景类似的其他一些难点。
一种可能的解决方案是对数据库进行分片。分片 是描述许多知名 Web 2.0 站点所使用的一种策略的常用术语,但也有许多人使用术语分区。事实上,分区是在一台计算机中将各个表拆分成多个部分。MySQL 对此提供了某些形式的支持,要求每个分区都有相同的表模式,这使得数据分区对于 PHP 应用程序是透明的。
那么什么是分片?我归纳的最简单的定义如下:“将表或数据库分解为多个部分。”
实际上也正是如此。您将所有内容分解成更小的数据分片。例如,您可以将去年较少访问的博客评论移至一个单独的数据库。这使您可以更加轻松地进行扩展,大多数查询所查找的数据会变少,从而提高响应速度。通过分解为使用多个主数据库,还可以帮助您扩展写入吞吐量。但这会增加编程的成本。您需要在 PHP 中处理更多数据逻辑,无论这是访问多个表以获取相同数据的需要,还是为了模拟 SQL 中不再支持的联接操作的需要。
在深入讨论各种形式的分片及其优势(和缺点)之前,我应该说明一些事情。整个这部分都将讨论手动分片以及如何在 PHP 代码中控制它。作为一名程序员,我喜欢使用这种控制,因为它将为我提供终极灵活性。过去,它始终是致胜的解决方案。MySQL 可以通过联合和集群化等特性为您完成一些形式的分片。我建议您深入了解这些特性,以确定他们是否可以为您提供帮助,而不增加 PHP 负载。现在,我们将讨论各种形式的手动分片。
垂直分片 是指将表的各列移至不同表的做法。(许多人甚至都不认为这种做法是分片,而仅仅是通过规范化表实现的良好数据库设计。)可以采用各种有用的方法进行这种分片,经常使用的是将较少使用或经常为空的列移至辅助表中。这通常可将所引用的重要数据保存在一个较小的表中。建立这个较小的表之后,系统可以更快地执行查询,并且整个表均可调入内存的几率会大大增加。
通常,一个好的经验是将 WHERE 语句中所有从未使用的数据移入辅助表中。其理念是您希望确保执行查询的主表尽可能小且高效。知道所需的行之后,随后可以从辅助表中读取数据。
users 表可能是一个例子。该表中可能包含多列,这些列引用了较不常用但需要保存的项。这些项可能是用户的全名或地址,您需要保存对它们的引用,但从来不会在网站上显示它们或搜索它们。因此,将这些数据移出主表会非常有帮助。
原始表如下所示:
其垂直分片的版本如下所示:
应该注意到,垂直分片通常在同一数据库服务器上执行。您通常不会将辅助表移至另一台服务器以节省空间,但您会将较大的表分解为较小的部分并确保主查询表更加高效。找到 Users 表中感兴趣的信息行之后,您可以通过 UsersExtra 表中的高效 ID 查找获取其他信息(如果需要的话)。
现在,如果垂直分片是将表的列分解为更多的表,那么水平分片 就是将某个表的行分解为不同的表。现在,此操作的唯一目标是将超大的表(千百万行)分解为便于使用的块。
如果手动完成此任务(即将共享表分解到多个数据库中),那么会对您的代码库产生影响。您不能在一个位置查找特定数据,而是需要查找多个表。在此操作中尽量减少对代码影响的窍门是确保用于分解表的方法与在代码中访问数据的方法相匹配。
许多常见方法都可用于手动执行水平分片。其中一种方法是基于范围,即前 100 万个行 ID 存放在第一个表中,接下来的 100 万个行 ID 存放在第二个表中,依此类推。只要您始终知道自己所需的行 ID,这种方法就是有效的,因此可以建立选择正确表的逻辑。这个方法的一个缺点就是超过阈值时还需要创建一些表。这意味着要么在您的应用程序中构建表创建代码,要么始终确保拥有一个“空闲”表,并建立相应的流程来提醒何时使用它。
降低动态创建新表的需求的另一种常见解决方案是使用交错行。例如,这种方法将对行号使用模数函数并将它们平均划分到预先设定数量的表中。因此,如果您使用三个表来完成此任务,那么第一行将位于表 1 中,第二行将位于表 2 中,第三行将位于表 3 中,第四行又将位于表 1 中。
采用这种方法时,您仍然需要知道 ID 才能访问数据,但是消除了创建新表的顾虑。与此同时也产生了一些成本:如果您选择分成三个表,最后这些表变得过大,那么您又要面对再次重新分配数据的痛苦处境。
除基于 ID 的分片之外,还有一些最有效的分片方法可以切断与数据访问方式密切匹配的一些不同的数据点。例如,在编写典型博客或新闻应用程序时,您通常都知道所尝试访问的任何文章的日期。大多数博客固定链接都在其中存储了月份和年份,例如:http://myblog.example.com/2011/02/Post。这意味着,您无论任何时候准备读入帖子,您都已经知道年份和月份。因此,您可以根据日期对数据进行分区,为各年份建立单独的表,或者为各个年份/月份的组合建立单独的表。
这意味着您同时还需要了解其他访问所需的逻辑。在您网站的主页中,可能希望列出最近的五篇博文。为此,您可能需要访问两个表(或更多)。您首先尝试从当前年份/月份表中读取五篇博文,如果不足五篇,则可以逆时开始迭代,查询每个表直到找到所有需要的博文。
在这种基于日期的设置中,仍然需要动态创建新表,或者预先建立新表等待填充。在此情景中,您至少知道需要新表的确切时间,并且可以预先建立它们以满足未来数年的需求,或者通过 cron 作业按计划建立它们。
此外还可以使用其他数据列进行分区。下面,我们将探索使用数据进行水平分区的另一种可能。对于 Users 表的情况,在我们的应用程序中我们认为在访问特定用户信息时始终知道 username,这或许因为他们首先需要登录并将信息提供给我们。因此,我们可以根据 username 对表进行分片,将字母表的不同部分放入不同的表中。因此,一个简单的双表分片如下所示:
可以在同一个数据库中使用这种水平分片来提高表的可管理性。但是,这种策略大多适用于需要将大量数据划分到多个数据库中的情况。这出于存储考虑或吞吐量考虑。在这种情况下,完成分片之后,您可以轻松将各表移至它们自己的包含从数据库池的主数据库中,不但能提高读取吞吐量,而且还能提高写入吞吐量。
现在,我们已经讨论了按行和按列来分解表的思路。接下来,我们将讨论应用程序级分片,即将数据库中的表显式移至不同的服务器中(不同的主/从设置)。
这样做的唯一原因是扩展写入吞吐量以及通过隔离查询来减少服务器总体负载。当然,与所有分片相同,您需要重写代码以了解在何处存储数据以及如何访问拆分后的数据。
关于应用程序级分片的一个非常有趣的地方是,如果您已经实现了“池”代码(如前所示),那么也可以使用相同的代码来访问应用程序分片。只需要为每个主数据库建立一个单独的池并为每个分片的从数据库组建立单独的池。代码需要请求连接正确的池以访问存储了数据的分片。与之前唯一的区别在于,您需要额外留意查询,因为每个池都不是相同数据的副本。
在设法划分数据库时,一种最有效的办法是以应用程序访问数据的方式的自然划分为基础。将相关表保存在同一个服务器上,这样您就能够通过同一个连接快速访问类似数据(或许还能联接这些相关表)。
作为例子,我们来分析下面的典型博客数据库结构以及如何对其进行分片:
图 3 将相关表放在一起
可以看到,我们已经将这个表拆分为了两个基于应用程序的分片。一个包含 Users 表和 Profiles 表。另一个包含博客的 Entries 表和 Comments 表。相关数据放在一起。通过一个数据库连接,您仍然可以读取核心用户信息和他们的配置文件。通过另一个连接,您可以读取博客条目及它们的相关评论,并且可以根据需要联接这两个表。
总的来说,所有这些方法(池化和各种形式的分片)都可以应用于您的系统,帮助其扩展到当前限制以外。应该保守地使用这些方法,并且仅在需要时才使用。
虽然设置系统实现 MySQL 数据库池化实际上非常简单,但分片却是另外一回事,我认为只有在没有其他选择时才能尝试这种方法。每种分片实现都需要额外的编码工作。用于从概念上分解数据库或表的逻辑都需要转换为代码。您最后将无法执行数据库联接或者只能以受限的方式来实现联接。您将无法执行搜索所有数据的单一查询。此外,您还需要将所有数据库访问封装在理解数据实际存在于何处的逻辑的更加抽象的层中,以便以后能够根据需要再次进行更改。
鉴于此,只需完成与应用程序的需求和基础架构直接相关的工作。基于 MySQL 的网站可能需要采取这一步骤来应对数据或流量水平超过可管理大小时的情况。但不能随意采取这一步骤。