第七部分:Drupal社区

第七部分:Drupal社区

第27章 Drupal规模化

本章作者 Károly Négyesi

要定义“规模化”这个术语,让我们以一个小咖啡馆为例。当它刚开业的时候,因为小,店主料理一切事务:她负责点单、准备饮料、一手端上咖啡一手收钱。一段时间过后,咖啡馆生意变得好起来,店主因此雇了一个咖啡师和一个服务员。现在服务员负责点单和收钱,咖啡师拿到点单的纸条、准备饮料并端给顾客。在这里你应该注意到的是,当一个人来做每件事的时候,钱和饮料的交易是同时发生的。但是现在顾客是先付钱,最后才拿到饮料。在拿到饮料之前交钱,这其中是否包含某种风险呢?如果这时突然失火,顾客交的钱可能换不回来任何东西。然而,没有人真的会为这种可能性而担忧;为了更快得到咖啡,他们会愿意冒这么极小的一点风险。通过把“收钱”和“上咖啡”这两个动作分离开来,小店可以更快地服务更多的顾客。它可以雇佣任意人数的咖啡师和收银员以更好地满足客流。这就是规模化:用这样一种方式来容纳客流以致顾客较多的时候也不会减慢流程。

但是要注意的是,增加咖啡师并不会让付钱和拿到咖啡之间的时间有任何缩短。店主会确保始终有一位空闲的咖啡师,但你还是要等待他为你准备饮料。然而,如果店里雇佣狡猾的红色机器人来更快地制作咖啡,将有助于缩短这个等待时间。这就是性能:Web应用程序在接收一个请求到完成它之间的时间。

性能是重要的,因为人们容易放弃太慢的网站。规模化使得网站能应付大流量的情况,但规模化本身并不会让网站变快,让网站变快的是性能。然而,如果你有足够多的访客,即使是最快的网站,也最终不得不让某些访客等待,然后才能开始服务他们的请求。

现在我已经定义了规模化和性能,我将在本章的后续部分告诉你,为什么你应该关心他们。然后,我将讨论Drupal 7中的一些规模化可选方案。该讨论将主要集中在数据库这块,因为数据库对于Drupal规模化是整体上的。Drupal不总是可以随心所欲地规模化,但是我将要讨论到的更改能让Drupal的规模化更加高效。

你需要关心规模化吗?

当你刚开始着手构建一个网站时,处理大流量通常不是你的重心所在。大部分人所关心的是先引来流量。毕竟只有拥有大型成功网站的人才需要担心流量问题。没有哪个网站一开始就有数百万用户。然而,你可能恰恰在一段时间内就可以获得那么多甚至更多。如果你在花大气力做网站营销,那么就有理由为用户增长而谋划了。即使你只是在整个互联网上有那么一个固定百分比的流量,你也可能会迎来惊喜;新的设备,甚至新类型的设备,让越来越多的人花越来越多的时间去浏览网站。当成功就在撞击大门时,你会做什么?你的网站是否能适应呢?

如果你的网站不能适应访客的增长,你将会处于痛苦不堪的境地:本应到来的成功,将会被抓狂的用户所取代。等待页面加载,有时还可能根本就接收不到网站的响应,不管是新进用户还是长期粉丝,无不如此。雪上加霜的是,通常网站的编码方式造成增加额外的硬件也帮不上太多忙;在这种情况下,网站就必须重构了,通常还是从头再来。当然,这种情况不是一夜之间发生的,而且通常发生这种情况时,公司正忙于其他事情:增长。简而言之,更多的流量几乎意味着更多的潜在业务,所以与此同时,公司要尽力应付新需求所带来的必要增长,而这时网站方面又突然需要重大重构。当这种事情发生时,网站可能每天都会崩溃。公司或组织眼看着成功在望,却要艰难地维持网站的运转,这是我们不愿意看到的一幕。

但还有另外一个陷阱:在网站生命周期的开端就沉迷于规模化,以致为它耗去功能开发方面的宝贵资源。Drupal的模块化机制就一直提供针对这些问题的解决方案,Drupal 7更是在这方面上升到了一个新的高度。

缓存

缓存是指临时存储一些处理过的数据。它可以是结构化数据或是包含HTML格式化文本的字符串。用缓存数据来提供服务,比从多个数据表中读取和处理数据更快,但另一方面,缓存数据是不可编辑的,所以不可能把它处理成另外一种格式。因此,原始数据必须保留在数据库中。现在你有了多个数据副本,原始数据和缓存之间就有可能会失去同步。在这种情况下,缓存数据是“陈旧的”。有时这样没问题;如果你一天出产几篇文章,更新的内容在原本发布日期后的几分钟内不被匿名用户看到可能并没有多大关系。这是规模化方面的另外重要一课:理论服从实践。规模化永远是在寻求折衷方案,对于一个给定的网站,这只是哪种折衷更能接受的问题而已。

提示:注意用语“对于一个给定的网站”。没有放之四海而皆准的良方妙药,规模化永远都是因网站而异的,虽然有些实践经验适用于很多类似的网站。

Drupal可以利用缓存来存储整个页面,即页面的HTML,以供匿名用户访问。到admin/config/development/performance页面启用这个功能很简单,而且在最简单的共享主机上也能工作。注意该功能仅对匿名用户起作用,但常见的是,网站的大部分流量都来自匿名用户。默认情况下,提交新的内容会删除这部分缓存,但是可以设置一个最小缓存生命周期。还是那句话,因站而异。

提示:在共享主机上还有一个快于内建页面缓存的可选方案,Boost模块(drupal.org/project/boost)。该模块可以为你的匿名访客提供页面服务,完全绕过PHP。

让我们来看看开发者可以如何利用缓存来存储一个慢查询。假设你有一个非常慢的函数叫作very_slow_find(),下面就是如何利用缓存:

<?php
$cache
= cache_get('very_slow');
if (
$cache) {
   
$very_slow_result = $cache->data;
}
else {
   
// Run the very slow query.
   
$very_slow_result = very_slow_find();
   
cache_set('very_slow', $very_slow_result);
}
?>

首先,你试图读取缓存。如果成功,就使用缓存中的数据;如果缓存不存在,你就运行你的查询并把结果存储在缓存中。采用这种方法,那个极慢的查询很少有必要发生,不过需要注意前面提到的陈旧缓存。在该例中,very_slow是缓存ID(cid)或者叫缓存键,$very_slow_result是你所存储的数据。

缓存被广为使用,并且被存储于各种不同的箱子中。把所有数据都存储在一起固然可行,但是把它们分开存储在一种Drupal称作“箱子”的东西中有两个好处:一个箱子中的内容可以与其他箱子中的内容分开来,在必要时废弃以避免陈旧数据;并且,每个箱子可以设置不同的机制来存储数据。(我将在本章稍后讲解一些可用的存储机制示例)在使用Drupal的缓存API提供的cache_getcache_set以及其他函数的时候,你不用担心哪个存储机制可用。这就是为何可插拔的子系统如此重要:你可以选择不同的存储机制而无需修改任何代码。

Drupal默认使用数据库来存储缓存数据——不是因为这是最佳方案,而是因为Drupal知道它们在那里。所幸还有替代方案。下面首先你将看到一个更多关于辅助开发的例子,它还展示了如何配置可插拔子系统;然后你将看到一些性能和规模化的解决方案。

在开发过程中禁用缓存

对于开发目的而言,我建议使用最简单的缓存实现:无。有一种缓存实现等同于黑洞:缓存写入和清除程序不做任何事情,读取则永远是失败的。Drupal在安装过程中就使用这种假缓存,因为还没有任何关于何处缓存存储的信息可用。这种假缓存在开发过程中也非常有用。需要提醒的是,多步骤(因此还有AJAX)表单,需要一个工作缓存,当所有缓存都用黑洞方式处理时,它们将无法工作。要用Drupal自身的假缓存来让缓存短路,在settings.php中加入下面三行,settings.php文件在你站点的/sites文件夹下,通常它会是(相对于你的Drupal安装根目录下的)/sites/default/settings.php

    <?php
    $conf
['cache_backends'][] = 'includes/cache-install.inc';
   
$conf['cache_class_cache_form'] = 'DrupalDatabaseCache';
   
$conf['cache_default_class'] = 'DrupalFakeCache';
   
?>

   

这么做了之后,复杂的数据机构,如主题注册表,将会在每个页面加载时重建(加入类和菜单项仍然需要到admin/config/development/performance页面执行明确缓存清空)。这让站点明显变慢,但却简化了开发者的生活,因为对于代码所做的修改会直接在Drupal的行为上反映出来。

settings.php中的$conf数组是指定Drupal配置信息的总中央。虽然有些配置选项有UI界面并把状态存储在数据库中,但有太多选项以致于不能为它们一一提供UI界面,并且大多数不便提供UI的选项,没有必要提供给一般用户。使用$conf而不使用UI界面的另一个理由是,有些选项在数据库可用之前就是必须的。缓存是两点兼而有之:它不是用户需要从UI界面设置的东西——大部分人永远没有这个必要——并且它可能在数据库加载之前就被使用。大部分人与默认的缓存实现相安无事,而缓存又可能在数据可用之前被使用。然而,因为数据库还不可用,缓存设置比平时要更复杂一些。你需要指定文件(在$conf['cache_backends']中),而不仅仅是所用的类(如$conf['cache_default_class']中)。对于其它设置,大多数时候指定类就够了,因为Drupal会从数据库中读取到包含该类的文件所在的位置。

memcached

接下来的解决方案在共享主机上将无法工作,你需要控制你的主机环境以达到优秀的性能和规模化。

memcached是将缓存存储在内存中并允许通过网络访问的一个独立程序。做为一个独立程序没什么特别,Drupal使用的数据库(如MySQL)也是这样的应用程序。memcached的与众不同之处在于,它的数据仅存储在内存中,这使得它非常非常快。使用该程序来取代数据库缓存对于Drupal的性能大有裨益。并且不仅仅是Drupal——这是一个非常成熟的解决方案,在每个大型网站中都有实际应用。

注意memcached不仅仅是一个性能解决方案,同时也是非常易于规模化的:你只需根据需要,在任意多个服务器上,启动任意多个它的实例,并配置Drupal来使用它们。memcached无需任何设置,因为每个独立的memcached实例不需要彼此了解。与它们每个进行对话的是Drupal。这和MySQL非常不同,它的主从服务器模式(master-slave)需要作明确的配置。

memcached由三部分组合而成:应用程序本身、一个PHP扩展和一个Drupal模块。memcached可从memcached.org获得,网站上有安装和配置的详细介绍。如前所述,你需要添加一个PHP扩展以让PHP和memcached进行通信。有两个名字易混淆的PHP扩展:“memcache”和“memcached”,即使专家对于它们谁更好也是各持己见。我谨慎推荐更新一些的“memcached”,它的PHP扩展可以通过以下命令安装:

pecl install memcached

你也可以按照操作系统特定的方式来安装,比如在Debian或Ubuntu上可以如下:

apt-get install php5-memcached

第三点也是最后一点,你通过安装Memcache模块(drupal.org/project/memcache)来让Drupal使用memcached。项目页面有大量文档,在此不再赘述。

Varnish

规模化工具集的另一个重要部分是Varnish。Varnish是一个存储页面和提供页面服务的外部程序。普通页面缓存需要一个到达web服务器的请求,顺序是:引导Drupal、加载页面、然后由Drupal发送请求。Boost模块提供一个更快的解决方案,因为现在请求只需到达web服务器,而Drupal并未启动。Varnish则更快,因为它自己处理请求。在匿名页面服务方面,它确实提供了非常快并且大范围的规模化解决方案。它以“Varnish让网站飞起来”为座右铭,并一直为此而努力。Varnish应用程序地址在www.varnish-cache.org/,与Drupal的整合方案在drupal.org/project/varnish

数据库方面

至此,你已经看到了一些可插拔子系统是如何配置的,你还简要地回顾了各种不同的提供快速匿名页面服务的解决方案。仅通过采用上述解决方案,你就可以使你的站点在性能方面(感谢memcached)和对匿名访客规模化方面(感谢Varnish)有良好的表现。然而,如今是社会化网站的天下,它们需要的是服务已登录用户。问题变得棘手起来。虽然memcached确实为你赢得一些性能,还是有很多问题需要克服。你需要退回一步,去了解网站是如何操作、存储和读取数据,所遭遇的问题,以及解决这些互相之间不太相关问题的全新解决方案。

从更高的层面看,多数网站都执行着相同的动作:收集数据(要么是用户或管理员通过浏览器表单输入的,要么是从另一个网站聚合来的),存储数据到数据库,然后再把数据显示给用户。显示和更改数据的操作通常称为创建(Create)、读取(Read)、更新(Update)和删除(Delete)四大操作,简称CRUD。一个典型的网站会使用某种SQL数据库,并在其上执行这些操作。

正如你马上将要看到的那样,对于一个网站所面临的诸多问题,SQL,特别是目前流行的各种SQL实现(包括MySQL/MariaDB/Drizzle、PostgreSQL、Oracle和微软的SQL Server),并不一定是最适用的。可是既然SQL不是最优的,为什么它却如此流行呢?同样,既然SQL如此流行和管用,另辟蹊径真的是个好主意吗?简而言之,大家一直在用这些SQL数据库,何必大惊小怪?

问得好。我们就来看看所谓的“一直”到底有多久。今天所用的大部分数据库都起源于上世纪七十年代末。确实,像MySQL或PostgreSQL这样的数据库或许并没有多少UNIREG(MySQL的祖先)、Postgres甚至INGRES(PostgreSQL的祖先)的遗留代码。就像亚伯拉罕·林肯用的斧子的传说,光荣地承载着一段家族通过使用而赋予的历史。几个世纪过去,斧柄被换了五次,斧头被换了三次。SQL数据库的设计初衷及其导致的局限性却不像更改代码一样那么容易。亚伯拉罕·林肯的斧子仍然是一把斧子。(此段中的比喻源自蒂姆·波顿电影《吸血鬼猎人林肯》——译者注。)

从这些数据库被构造的年代至今,像CUP速度、可用内存及可用磁盘空间这类因素已经增长了百万倍,磁盘速度却并未紧跟上来。并且,尽管任务也同时在增长,却没有几个增长了百万倍。最后,操作系统也变得更加复杂起来。

所有这些都意味着一些新的、以前不可想象的设计方案是必不可少的,并且具有重大意义。因为现在磁盘空间丰富而磁盘速度是制约因素,你可以把注意力集中在让数据库变得更快而不是更小。当有如此之多的廉价存储可用时,牺牲磁盘空间来获取性能看上去是个绝好的买卖。你可以假定内存能满足整个数据库,如果不能,问题由操作系统处理。这种设计方案影响的不仅仅是数据库:比方说,Varnish就引入了这些先进的编程范式,这也是它如此快的原因之一。

内存记忆

第一块个人电脑硬盘是希捷科技公司于1980年生产的ST-506(希捷科技公司,原文为“Shugart Technologies”,此处为原文错误。希捷科技的创始人虽然姓Shugart,但公司名称一直都是“Seagate Technology”,并未使用过“Shugart Technologies”这个名字。——译者注),它有5MB空间,和今天的DVD驱动器一样大,价值1500美元。所以,按1980年的美元市值计,1GB价值30万美元;按今天的美元市值计,已将近一百万。今天最快的硬盘可以存储300GB,价值不到300美元(这样1GB不到1美元),并且就像典型的笔记本电脑硬盘一样小。慢些的硬盘可大到3000GB(3TB),按照有些2TB的硬盘售价为80美元来算,当前硬盘每GB的最低价大约为0.04美分。

然而,不是每个方面的改变都如此之大:ST-506的寻道时间为170毫秒,1981年生产的ST-412的寻道时间为85毫秒,而今天一般硬盘的寻道时间为8到10毫秒,绝对最快的硬盘的寻道时间为3毫秒——连百倍的增长都不到。(寻道时间是指驱动器到达数据存储位置所耗费的时间。)一旦有的话,ST-506每秒读取半兆字节,而今天的典型硬盘则可能每秒读取一百多兆字节——速度仅提高了区区200倍。

快速闪存设备通过消除寻道时间,并让纯读取速度增加大约两倍,使得这一现状得以改善。对这些新设备来说,前面的路还很长:它们的可靠性还不能与硬盘相匹敌,而它们仍然比主要的内存要慢大约一百倍。

在1980年,64K内存对个人电脑来说是最大的,且价值400美元,这使得每兆字节价值6200美元(以1980年美元市值计)。今天,64G(100万倍)的服务器内存价值不到2000美元,这样每兆字节价值大约3美分,便宜了大约100百倍。

1980年最快的微处理器每秒运行一到两百万条指令(英特尔iAPX423,摩托罗拉68000)。今天,速度纪录高于万亿次浮点运算(IBM POWER7)——提高了一百万倍。商品化的处理器刚出现时价值200到350美元,如今仍在这个价格范围。

除了在磁盘空间和内存变得廉价充足之前所做的设计方案之外,SQL的基础知识也同样举足轻重。SQL基于数据表,数据表有列。比方说,你可能有一个用户资料的数据表,包含一个名字的列和一个姓氏的列,这意味着每一个用户资料将拥有一个名字和一个姓氏——每列都是精确的一个。即使只是在西方文化中,这种严格结构的不足之处也不难发现。例如,有个名叫Hillary Diane Rodham Clinton的人想要注册该怎么办呢?或是某人用他的网名作为合法名字,因此只有名字而没有姓呢?更好的存储方式是,你可能还需要一个用户名字的数据表,它有一个用户资料ID的列和一个名字的列,从而让存储任意多个你想要的名字成为可能。你马上将会看到这种方法的弊端。在Drupal 7中,你可以轻松地让一个“名字”字段变成多值文本域(multiple-value text field),不过这种作法并不漂亮,尽管Drupal把丑陋都藏起来了。

名字有上述问题大家都知道了,而要找出几乎每种数据的问题也并不难。例如,假设你在描述汽车,那么你有一个叫作汽车的数据表,并且有一个叫作马力的列,存储数字类型,一个叫作变速箱级数的列,也存储数字,一个叫作颜色的列,存储字符串。总之,汽车有一个固定马力的引擎,对吗?(哦,不一定,有的汽车既有汽油发动机又有电力发动机,它们当然有不同的马力级别,可记住,你只能输入一个数字。)对于变速箱级数,无级变速箱就不太好用数字来存储了。而对于颜色,列表是无穷尽的:两种颜色的小汽车普遍存在,还有遇热变色的喷漆可以让你的汽车变成一个变色龙。这个问题可以通过存储复杂的数据结构而非单个字符串或数字来解决。

所有这些都表明,你不能把所有数据都用SQL存储在一个单独的数据表中。可这又有什么大不了的呢?要回答这个问题,让我们来看看数据库是如何找到数据的。

索引

索引

你怎样在一本烹饪书中找到一个菜谱?当然是查找目录。按字母排序的菜谱固然便于查找,可是你会去记那个用西班牙香肠和牛油果做的美味沙拉的名字吗?即便你会,烹饪书也一般都是按照某些其他的主题,如季节或场合来排序的,所以就算是记住菜谱的确切名字,也不会有多大帮助。因此,让我们来建立一个基于原料的索引,它包含原料所出现位置的页码,如下:

    牛油果
      40, 60, 233
    西班牙香肠
      50, 60, 155

这样有点帮助了,但是要找到任何东西你都需要把每个列出的菜谱过一遍,因为没有任何信息告诉你哪些页面上有些什么。让我们来创建一个先按原料再按名字的字母顺序索引,如下:

    牛油果
      牛油果酱
        40
      玉米饼汤
        233
      暖风西班牙香肠凯撒色拉
        60
    西班牙香肠
      黑豆西班牙香肠卷
        50
      墨西哥风味炒鸡蛋
        155
      暖风西班牙香肠凯撒色拉
        60

这个索引虽然有用,但它只适用于当你在查找原料或原料和名字的情况。当你要按菜谱名字列出包含某种原料的菜谱时,它也有用。然而,如果你要查找一个名字,它就变得毫无用处了。比方说,如果你要找到墨西哥风味炒鸡蛋,你需要先浏览到牛油果,再是培根菜谱,再到西班牙香肠——比把整个书翻一遍好不了多少。

记住,你一开始是浏览你的烹饪书来查找一道既包含牛油果和又包含西班牙香肠的菜谱的,你要怎样建立一个让这种查找简单而快速的索引呢?如果你以菜谱名字开始索引,那么你需要浏览每个单独的菜谱才能找到它们,那不是一个好主意。如果你像你的第一个索引那样做,你可以轻松找到所有包含牛油果和所有包含西班牙香肠的菜谱。假设你有1000道牛油果菜谱和1000道西班牙香肠菜谱,但只有一道菜是两者均有的,为了找到单独的一个你需要比较2000个数字。要让这种操作变快,你其实需要一个类似于下面的索引:

    牛油果
      培根
        30, 37, 48
      西班牙香肠
        60
    培根
      牛油果
        30, 37, 48
      西班牙香肠
        70

那简直太可怕了。它不仅仅是存储需求会非常高,更重要的是,在现实生活中,维护类似这样的数据很快就会变得不切实际。就算一道菜谱平均不过8种原料,你能创建8*7=56个数据对以致于每次更改都要更新56个索引条目吗?!(即使你不同时存储“牛油果-培根”和“培根-牛油果”,仅仅将它减半并不解决任何问题,实际上在查询过程中还会产生一些问题,因为“原料-名字”索引不能反过来用。)这种方式恰恰和SQL存储和索引数据的方式相同,而这也恰恰是SQL规模化中的问题:你不能在大部分跨多表查询中使用索引——而由于SQL的严格你又要被迫使用多个表。让我们来看一个Drupal例子!

在Drupal中,节点有与之相关的评论。节点存储在一个数据表(Node)中,而因为一个节点可以有多个相关评论,评论存储在一个单独的表(Comment)中。如果你要显示最近十个被评论过的“页面”节点,你将面临类似的问题:如果你有一个基于创建日期的索引,那么你可以有一个按创建日期排序的评论列表以及它们所属的节点,而如果你有一个基于节点类型的索引,你可以有一个“页面”节点的列表,但是,假如最后二十万条评论全部都是针对“新闻”节点的,并且你有五万个页面,那么,你要么先将二十万条评论过一遍,以发现它们中没有一个是“页面”,要么你需要将五万个页面过一遍以找到每个的最新评论。那显然不够快。

通过把节点类型存储在评论表中,这个例子可以轻松地得到解决,因为节点类型不会改变。但是,如果你要把节点的更改信息和评论一起处理,那么你将被迫把节点的更改信息存储在评论表中,一次节点编辑可能触发评论表中数百行的更新。这种作法叫作“去规范化(Denormalization)”,且被广泛使用。然而,它应该亮起红旗:你在一个什么样的系统上工作啊?难道它非得抛开最基本的理论(规范化)才能表现良好?对于这个问题,我稍后将展示一个极好的解决方案,但是现在还是让我们继续来列举SQL的问题吧。

SQL中的NULL值

SQL中的NULL值

NULL是一个极妙的颠覆常规逻辑的结构;它的使用充满矛盾,它甚至在数据库中都不是始终如一的。NULL用来发出这样一种信号:某些数据丢失或无效,并且它永远都不等于、大于或小于任何东西。不管你对它做什么,操作的结果都是NULL。你需要一个特殊的IS NULL操作符来判断NULL值,如下:

mysql> SELECT 0 > NULL, 0 = NULL, 0 < NULL, 0 IS NULL, NULL IS NULL;
+----------+----------+----------+-----------+--------------+
| 0 > NULL | 0 = NULL | 0 < NULL | 0 IS NULL | NULL IS NULL |
+----------+----------+----------+-----------+--------------+
|     NULL |     NULL |     NULL |         0 |            1 |
+----------+----------+----------+-----------+--------------+

这就是所谓的三值逻辑。语句既不为真也不为假,而是NULL。NULL列导致很多奇怪的问题。Drupal(提醒你,并非出于有意的决定)没有太多的NULL列,从而侥幸避免了这种疯狂。唉,这不是一贯的,有时你还是被迫掉进NULL的坑里。

NULL比只是三值逻辑甚至更加颠覆常识,比如:

CREATE TABLE test1 (a int, b int);
CREATE TABLE test2 (a int, b int);
INSERT INTO test1 (a, b) VALUES (1, 0);
INSERT INTO test2 (a, b) VALUES (NULL, 1);
SELECT test1.a test1_a, test1.b test1_b, test2.a test2_a, test2.b test2_b
FROM test1
LEFT JOIN test2 ON test1.a=test2.a
WHERE test2.a IS NULL;

+---------+---------+---------+---------+
| test1_a | test1_b | test2_a | test2_b |
+---------+---------+---------+---------+
|       1 |       0 |    NULL |    NULL |
+---------+---------+---------+---------+

test2表中显然没有(NULL,NULL)这么一行。但是,左联接使用它来显示所谓的反联接(anti-joins)的结果。它们用来从第一个表中选择那些第二个表中没有与之匹配的行。

现在,如果1 = NULL为真,它应该找到了你插入到test2中的那单独一行,它没有那么做。记住,1 = NULL,既不是真也不是假,它就是NULL。但是仅从查询结果来看,这绝对不明显;总之,结果包含一个为NULL的test2_a,却没有执行规定的test1.a=test2.a联接不是吗?不,它确实执行了联接,但是在这个特例中,联接的部分并不需要为真。查询结果中有用NULL标记出来的丢失数据,这和联接无关。如果你发现这使人迷惑,它就是如此。你不仅需要与三值逻辑共存,还要对左联接给予豁免。

在我展示这些问题的答案之前,还有一个数据库架构细节需要涵盖。目前的SQL实现侧重于“事务(Transactions)”(该术语来源于财务交易,但它可以指数据库所执行的任何单位的工作。)你将马上会看到,这种侧重的效果与网站用户的期望是背道而驰的——因为网站用户的期望与银行用户的期望是不相同的。

ACID和BASE之间的一致性、可用性和分区宽容度(CAP)

事务(Transaction)的典型例子是汇款给别人。这是一个极为复杂的过程,但是在当天结束的时候,你期望你的帐户余额减少了你汇出的金额,而收款人的余额增加了该金额(减去汇费)。你同样期望,一旦银行说“你汇出了钞票”,它就确实汇出了,而不管实际的汇款需要用时多久(有个叫作SWIFT的汇款耗时简直是长得太荒谬了)。还有一个期望是,如果钱从你的帐户中消失了,它就会出现另一方的帐户中,而不管银行的计算机系统出了什么状况。你当然不想如果某台电脑崩溃,你的钱就消失得无影无踪了。基于这些期望,数据库的一组属性集合足以使事务正常工作——它们一般缩写为ACID:

  • 原子性(Atomic:):要么整个事务成功,要么整个不成功。
  • 一致性(Consistency):数据库在事务之间处于一个一致的状态中。比方说,如果一条记录指向另一条记录,而到事务结束时这个指向是无效的,那么整个事务就必须回滚。
  • 隔离性(Isolation):在其他事务结束之前,事务看不到被它们更改的数据。
  • 持久性(Durability):一旦数据库系统通知用户事务成功,数据就永不丢失。

另一方面,大部分的Web应用程序需要的是完全不同的一组属性,它们被巧妙地命名为BASE:(BASE更常见的解释是:Basically Available, Soft-state, Eventual consistency,和本文此处所述略有出入。——译者注。)

  • 基本可用(Basically Available):用户都有一种愚蠢的期望,就是当他们把浏览器指向一个网页时,就会有某些信息出现。这是你期望发生的,即便系统的某些部分宕机了也一样。这听上去微不足道,事实却并非如此;有很多系统只要一台服务器宕了,整个系统就宕了。
  • 可伸缩(Scalable):添加更多的服务器使得服务更多的客户成为可能。不是创建一个巨大的怪物服务器,而是添加更多的服务器,这更具有前瞻性,而且也通常更便宜。重申一下,这是用户的期望之一:信息不仅仅是出现,而且还要快速地出现。(注意这项用户期望不仅要求伸缩性,还同时要求性能。)
  • 最终一致(Eventually Consistent):如果数据最终在所有副本出现的地方变为可用,这就足够了(如上一点所述,你可以有很多副本)。这里的期望是,信息要快速出现才会显得及时。 当你在Craigslist上发布一个广告,它不会马上出现在列表和搜索结果中,这不是太大的问题;但如果一个广告需要几天才出现,就没有人会去用那个网站了。

关于这里的最后一点,如果你有一个像ACID属性列表那样的一致性系统,它也是“最终一致的”,因为它遵循一个更强的要求:数据立即对所有副本可用。(这一点我会稍后回来详细讲解。)

如果有个数据库系统能够既是ACID又是BASE,那就太棒了,可是,唉,那其实是不可能的。记住,BASE一般与由大量服务器组成的系统有关。如果你要求所有的写入都非常一致,那么每次写入都要经过每台服务器,而你则必须等到所有的服务器都完成写入并且回应这个事实。现在,如果你有分布在全球各个数据中心的服务器,那么网络问题是意料之中的。让我们假设有一个横跨大西洋的连接发生故障——这叫作网络分区。如果你需要一致性,那么系统就必须和横跨大西洋的连接一起发生故障——即使其它所有的服务器都是工作的——因为在欧洲的写入无法到达美国,反之亦然,所以那些部分将产生不一致数据。你需要放弃一些东西:要么放弃BASE中的可用性(A),系统宕机以获取一致性;要么放弃ACID中的一致性(C),你屹立不倒而系统不一致。换句话说,在一致性、可用性和分区宽容度这三者中,你只能选择两者而不能三者兼而有之。至此,我只是为数据库定义过一致性这个术语,所以是时候来给它一个一般性的定义了:在任何时间点,不管哪台服务器应答了一个请求,所有服务器都会给出同样的答案。可用性,如上所述,意思是即使某些服务器宕机了,整个系统仍能正常工作。最后,分区宽容度是指两台服务器之间的通信可以丢失,而系统仍能正常工作。你看后面两点,尽管多么“软性”,但当你需要它们和一致性并存时,它们又摆出一副很难搞定的样子了。

我已经展示了CAP的三个部分不能同时工作,而我也可以展示它们中的任何两个都可以轻松共存。如果你确保网络的各部分永不失效,那么提供一致性和可用性就不是问题。尽管听上去不大可能,Google的BigTable实际上正是一个这样的系统,有超过60个Google项目在使用它。如果你抛开可用性,那么另外两个需求也能轻松满足:只要你不打开宕掉的服务器,即使部分网络停止工作了,数据还是会保持一致。最后,如果你不关心一致性,你大可不必从一台服务器往另一台服务器复制数据——这种系统自然不必关心网络分区(因为它根本就没有用到网络),并且只要你到达一个工作的服务器,它总能应答一些东西。虽然后面两个例子有点极端,不能用来描述一个有用的系统,但是这里要重申的是,尽管一致性、可用性和分区宽容度三者不能共存,但它们中的任何两者都可以。

让我们重新回忆一下咖啡店的故事:为了能够雇佣更多的咖啡师(规模化),你需要接受顾客在短时间内付了钱而没有咖啡这个事实(最终一致而不是马上一致)。而且人们甚至在对待Web应用程序时也接受这点。如果你的评论需要过一会儿(即使是几分钟)才能显示给全世界每个人,并无伤大雅。当然,你期望它能马上显示给评论的发布者,或者至少在数据一旦发往服务器并且回复已经到达的时候。但是对于其他人,从互联网上加载网页总是要耗费一些时间的,所以,对于那些刚开始加载页面的人们来说,服务器是在“发送”按钮按下前还是按下后显示那个新的评论,几乎无关紧要。更重要的是网页总能响应(可用的),而不管有多少人在同时浏览(伸缩性)。

所以,虽然SQL数据库大多是兼容ACID的,但注重BASE的数据库更适合Web应用程序。不仅如此,下面的这些数据库还把翻天覆地的硬件变化和操作系统的可能性考虑其中,以形成更加适应这些目的的数据库版本。依据这些理论而构建的数据库先驱之一是CouchDB,发布于2005年,Cassandra于2008年向公众发布,而MongoDB首次发布于2009年。

同样是在2009年,这些个新的非关系数据库系统们有了一个琅琅上口的别号——“NoSQL”。当然这只是个流行词,因为用SQL来驱动一个不兼容ACID的数据库也是可能的(实际上MySQL就在不兼容ACID的情况下使用SQL有十多年了),但是这些数据库的特性集和SQL所要求的是如此不同,因而没有意义去尝试。比方说,这些数据库中没有一个提供数据表联接(JOIN)功能。

由于MongoDB是很多Drupal任务的最佳选择,我现在就来对它进行详细讨论。

MongoDB

MongoDB的入门超级简单:在浏览器中打开try.mongodb.org即可。它提供一个教程,让你用该数据库来玩,而无需下载任何东西。如果你想在自己的电脑上使用,你可以从mongodb.org/downloads下载,并且可随时运行;没有复杂的配置文件要写。Drupal的整合项目在drupal.org/project/mongodb

MongoDB惊人地适合于Drupal 7,尽管很多Drupal 7的开发是先于MongoDB的。它是一个为Web而设计的数据库,所以它和全球最好的用来制作网站的软件Drupal匹配得如此之好也就不足为奇了。

MongoDB存储的基本上是JSON编码的文档(实际差别很小)。一个文档粗略等同于一条SQL记录。任意多个文档组成一个集合,粗略等同于一个SQL数据表。最后,数据库包含集合,和MySQL数据库包含数据表非常类似。

一个MySQL数据表只能有固定的记录,而一个MongoDB集合可以存储任何类型的文档,比如这样:

{ title: 'first document', length: 255 },
{ name: 'John Doe', weight: 20 }

如此等等,任何东西都可以。没有CREATE TABLE命令,因为一个文档和另一个文档可以大不相同。

还记得在SQL中存储名字的问题吗?在这里不是问题了!

db.people.insert({ name: ['Juan', 'Carlos', 'Alfonso', 'Víctor', 'María', 'de', 'Borbón', 'y', 'Borbón-Dos', 'Sicilias'], title: 'King of Spain'})

如果你想要得到名叫Carlos的人的一个文档列表,你可以运行db.people.find({name: 'Carlos'})。它会找到西班牙国王,不管Carlos在名字列表的何处。

还有,属性可以比仅仅数字和字符串要多得多。

db.test.insert({
  'title': 'This is an example',
  'body': 'This can be a very long string. The whole document is limited to a number of megabytes.',
  'votes': 56,.
  'options':
    {
      'sticky': true,
      'promoted': false,
    },
  'comments': [
    {
      'title': 'first comment',
      'author': 'joe',
      'published': true,
    },
    {
      'title': 'first comment',
      'author': 'harry',
      'homepage': 'http://example.com',
      'published': false,
    }
  ]
});

所以,文档有属性(上面例子中的title, body等等),并且属性可以有任何类型的值,如字符串(title)、数字(votes)、布尔值(options,sticky)、数组(comments)和更多对象(也叫作子文档,options就是一个例子)。文档能有多复杂是没有限制的。不过,文档有尺寸上的限制(曾经很长一段时间里是4MB;目前是16MB,但计划会增加到32MB);对于大部分网页来说,这个限制不是问题。仍就这个例子来说,一篇文章能有多少评论?几千条的空间是有的。如果不够,实际的正文(它从来不会被查询)可以单独存储。

因此同SQL相比,主要的优点是,无需事先指定数据表结构,其实没有固定的结构,值也可以是复杂的结构体。

针对上面的文档,还有一些更有趣的查找命令:

db.test.find({title: /^This/});
db.test.find({'options.sticky': true});
db.test.find({comments.author': 'joe'});

正如你所见,find命令使用的是同一个插入的JSON文档。第一个find命令使用正则表达式来查找标题以“This”打头的文章,第二个显示了在对象内部查找有多容易,第三个显示了对象数组也同样容易。对所有这三个查询进行索引也是可能的。

如果你看一看Drupal 7的带有字段的实体(Entity),它实际上是包含数组的对象。你可以把它们整个存储和索引到MongoDB里面。这正是最至关重要的:在SQL中,尽管你可以把相同结构的实体(比如相同类型的节点)存储在一个单独的数据表中,但如果你需要跨越这些类型进行查询(比如,显示一个用户收藏的所有近期内容,不管它们是文章还是照片),那么你就不能索引这个查询。记住,要快速执行一个这样的查询,你需要去规范化(denormalize);换句话说,在一张单独的表中保存一份你需要一起查询的数据的副本。这难于维护并且更新缓慢,因为每条数据都有很多副本。在MongoDB中,你可以轻松做到这个跨内容类型的查询,因为所有节点都可以待在一个单独的表中,你只要在‘favorite’和‘created’上建立一个索引就万事大吉了。

总而言之,SQL是不能存储复杂的结构体的。尽管MongoDB不是针对该问题的唯一解决方案,但相比SQL使用多表和去规范化的方法,它实现和维护起来都要简单得多。这个问题在Drupal 6中使用CCK时就存在,而随着Drupal 7中实体和字段API的引入,它已经变成了一个重要的问题。

要使用MongoDB作为默认的字段存储引擎,把以下代码:

$conf['field_storage_default'] = 'mongodb_field_storage';

加到(sites/default或相应的sites目录下的)settings.php文件中。如果你是从代码创建字段,设置字段数组的‘storage’键同样可行。用户界面上不提供该选择,所以如果你完全使用用户界面,就不能按字段来选择存储方式。

尽管MongoDB最好的方面可能是作为一个极佳的存储引擎而存在,可它甚至还不止于此。它还能解决其它问题。再次重申,它并非唯一的解决方案,但确是非常优秀的一个。

首先,让我们来看一些与写入有关的更多特性。比如像这样来增加我们的测试文档中的投票数:

o = db.test.findOne({nid: 12345678});
o.votes++;
db.test.save(o);

这个代码尽管工作,却不怎么漂亮,而且容易导致争用情况(race condition)。争用情况是开发者的灾星,因为它极难复制却会产生神秘的错误。让我们来看一个例子。下面是当两个用户试图投票时可能会依次发生的事:

  • 用户A运行o = db.test.findOne({nid: 12345678});-- 票数为56。
  • 用户A运行o.votes++
  • 用户B运行o = db.test.findOne({nid: 12345678});-- 票数为56。
  • 用户A运行db.test.save(o);把票数设置为57。
  • 用户B运行o.votes++
  • 用户B运行db.test.save(o);把票数设置为57。

在MongoDB中,你可以替换成:

db.test.update({nid: 12345678}, {$inc : { votes : 1 }});

  • 用户A运行该命令,票数加1,为57
  • 用户B运行该命令,票数加1,为58

这是一个原子操作,不可能发生由于争用情况而产生的错误了。注意更新操作同样是JSON文档的形式;有特殊的更新操作符,如上面所示的$inc,但语法还是一样的。

你也可以指定一个多文档更新

db.test.update({nid: {'$in': [123, 456]}, {$inc : { votes : 1 }}, {multiple:1});

尽管单个文档会原子地递增(无争用情况),多文档更新却不是原子的。我在前面列出事务属性的时候提到过,在事务的情况中,原子性意味着,要么所有文档更新,要么没有文档更新。在这就不是这种情况了,因为MongoDB中没有事务:有可能一个文档更新失败了,却对其它成功更新的文档没有任何影响。MongoDB还缺少事务所需的另一项性能,叫作隔离性:当一个文档已经对每个客户更新了,其它文档可能还拥有它们的旧数据。

按文档原子递增使得MongoDB成为存储各种统计数据的理想选择。例如,你可能需要存储并显示一个节点被查看的次数。首先,给节点添加一个数字字段,叫作‘views’,接下来,你会看到一小段代码,接收节点ID作为参数,并把views字段的值增加1。注意,为了阻止浏览器或代理缓存它,这段代码发送一个和Drupal一样的头部,最后显示一个1x1的透明GIF。

<?php
if (!empty($_GET['nid']) && $_GET['nid'] == (int) $_GET['nid']) {
 
define('DRUPAL_ROOT', getcwd());
  require_once
DRUPAL_ROOT . '/includes/bootstrap.inc';
 
drupal_bootstrap(DRUPAL_BOOTSTRAP_CONFIGURATION);
 
  require_once
DRUPAL_ROOT . '/sites/all/modules/mongodb/mongodb.module';
 
$find = array('_id' => (int) $_GET['nid']);
 
$update = array('$inc' => array('views.value' => 1));
 
mongodb_collection('fields_current', 'node')->update($find, $update);
}

header('Content-type: image/gif');
header('Content-length: 43');
header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
header("Expires: Sun, 19 Nov 1978 05:00:00 GMT");
header("Cache-Control: must-revalidate");
printf('%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c ', 71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 128, 0, 0, 0, 0, 0, 0, 0, 0, 33, 249, 4, 1, 0, 0, 0, 0, 44, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 68, 1, 0, 59);
?>

现在你要做的就是把它保存为Drupal根目录下的stats.php,并在node.tpl.php中加入一个<img src="<?php print base_path() . 'stats.php?nid=' . $node->nid; ?>"/>。通过这种方式来统计节点的查看次数,即使页面是缓存或者Varnish提供的,查看也会被统计。

这会扼杀站点的性能吗?要视情况而定。它确实造成每次有人加载页面都动用到PHP,这可能是可接受的,也可能是不可接受的。对于大多数站点来说,没问题。如果站点用它来工作,MongoDB不会产生问题。鉴于MongoDB是在内存中操作数据库,偶尔写回磁盘,因为写入仅仅发生在内存中,所以要快很多。同时,它尽力在原地更新数据,所以索引无需做没必要的更新。

2011年的新特性是即使使用单个服务器时也不丢失写入的功能。对MongoDB最大的抱怨之一是,因为它只是偶尔才写回数据,所以如果服务器崩溃,数据可能会丢失。以前,通过使应用程序等待写入被复制到另一台服务器,寄望于两台服务器不会同时崩溃,这种情况得以缓解。但是现在,如果mongod是以-dur选项启动,则写入不丢失。

日志、会话和队列

Drupal还有其它有着大量写入操作的部分:日志(Watchdog,亦见直译为“看门狗”,本文使用“日志”这个译法。——译者注)和会话。会话子系统维持用户的登录状态;所以它需要在每个页面加载时执行写入。MongoDB的快速写入使得这不是问题。一旦你运行了mongod,并且安装了Drupal模块,只要加入:

$conf['session_inc'] = DRUPAL_ROOT
. '/sites/all/modules/mongodb/mongodb_session/mongodb_session.inc';

到(sites/default或相应的sites目录下的)settings.php中,MongoDB就会接管会话,再次让站点加速。

日志(Watchdog)有点棘手,因为如果某个原因导致有大量的错误消息(比如大规模蠕虫爆发试图获取不存在的URL),SQL数据表可能会增长到一个尺寸,以致唯一可行的操作是完全丢弃(TRUNCATE)它。删除旧行可能都无法招架写入的洪流,而且写入还有点慢。传统作法是使用系统日志(syslog),就是一个文本文件,所以查询起来不是最简单的。使用MongoDB,你可以指定一个集合只应保留最后的N个文档,然后自动重来,覆盖掉旧的消息,所以永远不会有多于指定数目的消息,让查询轻松而方便。同时,Drupal的日志实现把不同的消息放到不同的集合里,所以对于每个消息,都不会有多于指定数目的消息被记录。例如,“评论被创建”的消息不会被php错误消息挤掉。要使用这个功能,启用mongodb_watchdog,并禁用dblog模块。

最后,Drupal 7有一个消息队列,它也可以用MongoDB来实现。有很多队列,但是如果你已经为字段存储、日志和会话部署了MongoDB,那么使用MongoDB的快速写入来取代SQL是够便利的了。只要添加:

$conf['queue_default_class'] = 'MongoDBQueue';

settings.php中你就搞定了。你已经看到如何利用MongoDB作为字段存储引擎、存储会话数据、记录日志消息以及作为队列机制。把它用作缓存也是可能的,但memcache用于那个目的更好(因为它在伸缩性方面做得更加细致)。

MongoDB中的Null值

至此你已经看到MongoDB是如何解决SQL问题的。让我们来看看它能否改善前面所讨论的SQL的NULL带来的怪异现象(你在做这个的同时会学习到更多的MongoDB知识,并看到更多的MongoDB查询方面的例子)。

MongoDB处理NULL的方式比SQL稍许理智一些,但它一定还是有它自己的怪处。下面的查找是完全合法的,它同样显示NULL值需要特殊的操作符:

db.test.find({something:null});

这将会找到something有NULL值的文档。非常简单。NULL是自成一派的,与任何其它类型比较的结果都是false,因为MongoDB是严格类型的,不会为你作类型转换。所以,把一个数字同NULL作比较将永远是false,但把同样的数字和字符串相比较是true。这其实不是一个问题——只是一个你需要留意的地方。马上会有例子显示。至少MongoDB没有引入三值逻辑;比较的结果只能是true或false,不会是NULL。

不过,对于NULL有一个警告:不存在的值是当作NULL来处理的。

> db.test.drop()
> db.test.insert({a:1});
> db.test.insert({something:null});
> db.test.insert({something:1});
> db.test.find({something:null});
{ "a" : 1 }
{ "something" : null }#

要找到你真正想找的,这样做:

> db.test.find({something:{$in: [null], $exists:true}});
{ "something" : null }

新的操作符是$in和$exists。下面是将NULL和1作比较的例子:

> db.test.find({something: {$gte: null }});
{ "a" : 1 }
{ "something" : null }

我们再次看到,第一个文档不包含something属性,所以当作比较时,它是匹配的。第二个文档包含一个something: NULL的组合,并且操作符是“大于等于”($gte),所以它也匹配了。你有一个something为1的文档,但它不匹配——因为NULL不是零。

小结

如你所见,关于规模化的思考在建站早期不一定是最优先考虑的。但是,在你真正开始头痛之前就早做打算总是值得的。本章介绍了你为什么应该尽早关注规模化,以及Drupal 7中有哪些可用的技术来应对规模化。主要关注焦点集中在数据库方面,因为它们对于Drupal中的规模化是绝对整体上的。缓存之类的技术当然也是有用的,所以你也对它们进行了一番审视。总而言之,本章中所介绍的更改将带你的Drupal网站朝着高效规模化的道路迈进。

提示:可到dgd7.org/scale上面更快获取不定期更新、讨论及资源。