可扩展 可伸缩的 Oracle 与 PHP

日期:2006-09-21  作者:喜腾小二  来源:PHPChina


了解一般的技术和设计,用于编写与 Oracle 数据库的使用直接相关的可管理、可伸缩的快速 PHP
代码。

在过去九年中,PHP
已经从组装个人网站的小型语言发展到为世界上某些最大和流量最高的网站提供动力。任何高流量网站的三个最重要的设计方面是可伸缩性、性能和可维护性。可伸缩性意味着您的应用程序流量负载可以不断增长,而不会从根本上破坏其工作方式。性能是快速为单个请求提供服务的能力。
可维护性是能够在不造成过多负担的情况下修复、重新调整、扩增或更改应用程序的品质。

利用 PHP
来实现这三个设计目标并不困难,但确实需要预先考虑如何设计和构建您的应用程序。关于编写可管理、可伸缩的快速 PHP
代码的论题范围很广;针对每个论题都有大量的技术和文章。在本文中,我们将讨论那些与使用 Oracle 及 PHP 直接相关的因素。有很多一般(非 Oracle
专用)技术和设计可能非常有用。

我喜欢以一个尖锐的警告作为任何与性能相关的谈话或文章的开始:始终要记住,最后总有一天,快速却不完善的应用程序将毫无价值。性能调整以及对应用程序不利因素的设计提取都很容易分散您的精力。Web
的性质就是这样,经常发布版本的方法非常有效。(发布网站“新版本”的成本很低,因为最终用户始终需要这些代码。)这就允许您延迟对代码的重大调整,直到需要这样做为止。因此,首要目标应该是创建便于重新调整的代码。

创建和管理连接

与 Oracle
数据库最基本的交互之一是连接。要了解连接如何影响您的应用程序的性能和可伸缩性,需要了解连接的生命周期,如图 1 所示。每个步骤所涉及的工作如下:

  • 客户创建连接: 客户创建与 Oracle
    监听器的网络连接,提供其认证证书,并请求会话。
  • 服务器创建一个新会话:在认证之后,服务器为客户创建一个新会话。如果您没有通过 Oracle 多线程服务器(MTS —
    它在可伸缩性及性能问题上臭名昭着)使用共享会话,则此步骤包括服务器为会话创建一个专用进程。该进程通常称为
    影子进程。创建此进程需要不少工作量。除了创建进程的正常开销之外,影子进程在其创建期间还必须临时锁定某些共享系统资源。
  • 客户端执行查询:既然客户端已经具有开放的连接,就可以根据需要来执行查询。
  • 客户端关闭连接:当客户端完成工作后,关闭与服务器的连接。
  • 服务器毁坏会话:与用户会话相关的影子进程被毁坏,任何未提交的事务被回滚。

图 1:连接的生命周期

由于创建新的影子进程的成本相当大,我们应该在必要时努力避开它。达到此目的的最简单方法是使用持续连接。PHP
被设计为一种非会话状态的语言。这意味着在默认情况下,在请求期间创建的任何信息(或例程化的资源)都会在请求结束时被彻底清除并毁坏。对于 Oracle
客户连接,我们希望避免这种行为。

为了使连接能够从一个请求保留到下一个请求,您可以使用以下两种连接变通方法之一:

OCIPLogin($username, $password [, $tnsname])

OCINLogin($username, $password [, $tnsname])

这两个函数都创建持续的服务器连接,尽管 OCINLogin()
将为每个请求创建一个新会话句柄。如果您的应用程序要使用事务,并且您希望将同时发生的事务分散到多个会话中,则可以使用 OCINLogin()。

使用持续连接的一个副作用是您更容易出现进程不足的情况。基于专用 Oracle 数据库运行单个 Apache
Web 服务器(子进程的最大默认数量为 256)时,可能从不会遇到问题。但是如果增加到 4 个 Web 服务器,每个服务器运行 256 个具有持续 Oracle
连接的子进程,现在则要创建 1024 个与 Oracle 数据库的连接,并且很快就会与 Oracle 实例的资源限制发生冲突。

在 Oracle 实例配置文件 (init.ora) 中,有两个可调整的参数:

sessions = NNNNprocesses =
NNNN

这两个参数控制着实例可以支持的最大会话数和最大进程数。如果您需要支持 1024 个同时出现的连接,则需要至少
1024 个会话(因为
OCINLogin()连接和某些递归查询可能在每个连接中需要多个会话),而需要的进程还会更多(因为我们还需要考虑 Oracle
后台进程)。不幸的是,不能任意将这些进程设得很高。Oracle 进程消耗不少的专用内存(在多数系统中每个进程需要 2 到
3MB)。从个体来说,这些进程很小,但当把它们作为一组并与服务器系统全局区 (SGA)
所需要的共享内存相结合时,很快就会让您因为数据库服务器的物理内存限制而感到烦恼。

来自 MySQL 环境的用户可能试图避开持续连接(这是 MySQL 环境中的建议)。由 Oracle
影子进程启动所导致的栓锁和文件争用使得非持续连接的使用效率极低。那么解决方案是什么呢?

  • 应该确定我们的数据库能够支持多少个同时发生的会话,并相应地设置其限制。有些文章详细说明了如何完成此工作,但主要是一个计算过程:计算系统中物理内存的总量,并减去内核、支持程序和
    Oracle 后台进程所使用的内存。然后减去所有被配置为 Oracle
    共享内存的那些内存(共享池和缓冲区高速缓存)。剩下的内存可以用于影子进程。将该数量除以一个影子进程所使用的专用进程内存的平均数量(应该自己测出该数量,因为它根据您所运行的查询性质而变化),则我们得到可支持的进程数量。
  • 对我们的 Web 服务器进行配置,以便使其永远不能创建超过您的进程设置允许数量的连接。其实现方法是将每个
    Web 服务器的 MaxChildren 可调参数设置得足够低,使得所有 Web 服务器总共拥有的子进程数量低于可支持的 Oracle 连接数。这意味着每个
    Apache 实例不再支持 256 个子进程。
  • 重新设计我们的应用程序,使其不会遗漏额外的子进程。我们在后文中还会讨论这个话题。

这样有多重要?作为一个老板,我们即使启用了持续连接,也一直遇到栓锁问题。任何遇到过严重栓锁争用的人都能证实,这是一个极为严重的问题,服务器在很大程度上不响应,因为它花费过多时间来进行锁定操作。我们在调查中发现,尽管在终止前有大量进程为数以百计的请求提供服务,但很多进程只服务于单个请求。这一切是由于我们将
Apache 的
MaxSpareServers设置得太低。我们所使用的负载均衡设备的一些问题导致了“突然爆发”的行为,此时 Web 服务器被多个同时发生的请求所冲击,然后闲置数秒时间。在 Apache
内部,这导致要创建额外的子进程,来为高请求等级提供服务;但当它一旦平息时(几乎立即平息),就会出现大部分目前处于闲置状态的子进程(终止进程并关闭其 Oracle
连接)。总体看来,这与运行非持续进程的效果相似。将
MaxSpareServers设得较高就可消除此问题,并消除了栓锁争用。

执行 SQL

任何 Oracle 客户服务器关系的主要内容是执行查询。这里没有篇幅来谈论对查询的调整 —
那是需要整本书来讨论的主题。相反,我们将集中讨论如何尽可能地使已经调整过的查询高效运行。

使用 PHP 编写良好的 Oracle 应用程序代码的第一步是始终使用绑定
SQL。当我们编写类似以下的查询时:

SELECT * FROM USERS WHERE USERNAME = 'george'

Oracle 必须对该查询进行软分析,查看以前是否曾编译过该查询。在默认情况下,"george"
值被作为文字项,这意味着如果我们使用不同的名字 ('bob') 执行此查询,则 Oracle 将把它看作完全不同的查询。Oracle
在其共享池中保留它执行的每个查询的分析副本,因此如果您使用数千个名字来执行此查询,则在您的共享池中将有数千个该查询的不同副本。即使在轻度活跃的网站上,这也会导致严重的内存碎片以及
ORA-4031 错误增殖。

对这个问题的解决方案是使用绑定 SQL。绑定 SQL 允许我们将 WHERE
子句中的文字值替换为占位符,如下所示:

SELECT * FROM USERS WHERE USERNAME = ':NAME'

在这里,查询只须被完全分析一次(硬分析);以后所有的分析都是所谓的软分析,此时引擎只是简单地从
SGA 中提取已编译的查询。此外,将只有一个单次分析的副本被存储,从而显着减少了不断执行的查询对内存的需求。

现在我们可以执行如下:

$db = OCIPLogin('scott', 'tiger', 'testdb');
$stmt = OCIParse("SELECT * FROM USERS WHERE USERNAME = ':NAME'");
OCIBindByName($stmt, ":NAME", "george");
OCIExecute($stmt);
?>

在 Oracle8i 之前,我们必须手动绑定查询;从 8i 开始,通过设置
init.ora 的参数 "cursor_sharing =
FORCE",我们可以指示优化器为我们完成这一工作。该设置通知优化器查找可以绑定的文字值,并手动执行绑定。自 9i起,我们可以使用设置
“cursor_sharing = SIMILAR”,该设置指示优化器深入查看基表的统计信息,了解自动绑定文字是否有好处(如果某个字段的分布极不均匀,则可能没有好处)。尽管应该启用这些设置(在
8i 中为
FORCE,在 9i 及更高版本中为
SIMILAR),但深入到查询中分析潜在绑定的这种操作对于优化器而言成本很高,因此在可能的情况下,应该手动绑定您的查询。

Oracle 客户服务器在 SQLNet 协议基础上运行,众所周知,这种协议的对话很多。例如,如果您执行一个返回
100
行的查询,则会有分析查询的对话交换、对每个绑定变量的交换、对执行的查询以及对每个提取行的查询。其中每次交换都包括客户端与服务器之间的网络数据包交换(称为一次
往返)。减少往返次数可能具有深刻的性能影响。

解决这个问题的第一种方法是建立客户的预取缓冲区。在网络上传送单独一行的效率极低,因此最好主动将很多行集中在一起,并将它们从本地缓冲区中读出。如果我们将语句的句柄设置如下,则
Oracle 客户库能够自动执行这种服务:

$stmt = OCIParse($db, $query);
OCISetPreFetch($stmt, 1000);
// execute and fetch

这些设置指示 OCI 客户库每次内部缓冲 1000 行,对任何返回超过一行的查询提供积极的回报。1000
是个相当随意的数字 — 在选择预取缓冲区大小时仅有的限制因素是:

  1. 在执行返回之前需要填充缓冲区 — 如果对部分结果集的即时访问非常重要,将不希望缓冲区过于庞大。
  2. 被缓冲的结果集保留在本地内存中,直到被刷新为止 —
    如果您具有非常大的行或非常大的结果集,则不应将缓冲区设置得过大,以防止客户端出现内存不足。

管理与 Oracle 的交互操作

现在您已看到了一些通过 PHP 改进与 Oracle
数据库交互操作方式的技术。对您的程序作出这类结构性更改是管理上的争论焦点,因为它通常需要对代码中的很多地方作出不少更改。解决问题的正确方法是将所有的数据库访问代码封装在一个打包的库中,这样就可以对如何准备和执行查询等内部问题进行更改,而不必审计您的全部编码内容。

我说的是“打包的库”而不是“抽象层”,因为很多所谓的抽象层强调不仅向您隐藏低级数据库调用的细节,而且还隐藏
SQL。它们实施各自的与数据库无关的语法,以便您能够毫不费力地将您的应用程序改到其他数据库中。我认为这种方法存在三个主要问题:

  1. SQL
    是很多开发人员了解的一种功能强大的描述性语言。不让他们使用这种语言并要求他们学习一种新的、灵活性较差的语言是很可笑的。
  2. 每种主要的数据库产品都具有非标准的 SQL
    语法,以便完成特定的任务。放弃这些差异会限制您的灵活性,并使您失去所选择平台的某些价值。如果只考虑它们所支持特性的交集,最终只得到最低程度的通用特性。
  3. 对于多数人而言,更换数据库供应商是很少见的现象,无论您的应用程序是否识别数据库,这都会涉及到相当多的数据移植工作。

用于 PHP 的两种最流行的数据库封装/抽象库是 PEAR::DB(从 http://pear.php.net/ 获取)和 ADODB(从 http://php.weblogs.com/adodb获取)。不管这两种实施方法的流行程度(和品质)如何,,我通常从头开始实施自己的数据库打包的库。我不需要 ADODB 或 PEAR::DB
中的很多高级特性,而保持库的简单性可以使它速度更快并且更易于维护。没有复杂的特性,一个紧密的库可以用大约 100 行代码来完成它。如果提供了 Oracle
数据库和语句句柄中所执行的信息量,则我更喜欢面向对象的封装,尽管我也曾看到过效果良好的程序性封装。

清单 1显示了一个完整的打包的库,它包含一个围绕数据库连接的包 (DB_Oracle) 和一个围绕游标的包
(DB_OracleStatement)。

这些类的目标是使对数据库交互操作的管理变得简单、直接和清晰。以下显示了如何使用它们来执行一个简单的查询:

include_once("DB_Oracle.inc");
$dbh =& new DB_Oracle('scott', 'tiger', 'testdb');
$stmt =& $dbh->prepare("SELECT * FROM users WHERE name = :name");
$stmt->execute(array(':name' => 'george'));
$result = $stmt->fetch();
// ...
?>

由于封装是面向对象的,通过对类进行扩展,可以轻易隐藏所有的连接参数。这对于提供一个简单的、无参数的连接类非常有用,如下所示:

class DB_Oracle_Test extends DB_Oracle {
var $user = "scott";
var $pass = "tiger";
var $tnsname = "testdb";

function DB_Oracle_Test() {}
}

这个新类向程序员隐藏了所有的连接标准,允许透明地更改实例的
TNSNAME 或连接标准。还要注意如何向用户隐藏
OCISetPreFetch()调用。如果您需要删除它,或者需要添加另一个连接修改器,其操作很容易,并且对您的所有连接起作用。这就是使用打包的库的价值。

使您的进程更加高效

在文章的开始部分,我提到我将帮助您不遗漏在 Apache
中为满足可伸缩性需求而减少的子进程。利用更少资源来完成任务的三种最简单方法是:

  1. 更快地完成任务。任何人都会告诉您,增加工作负载的最快方法是更快地完成现有的工作。无论是安装编译器高速缓存、配置代码还是调整数据库查询,您能够从应用程序中取得的任何性能增益都会在可伸缩性方面得到回报。
  2. 将某些任务转包给专家。例如,网站通常包含动态组件(您的 PHP 脚本就是这种组件)和静态组件(图像和非动态 HTML)。访问 Oracle 的
    Apache 实例在服务于静态内容时,您受到严格的约束,这是在浪费时间。可将它外包给专用于处理这类负载的 Web 服务器。
  3. 完全跳过工作。分析应用程序时经常会发现一些数据库驱动的组件,这些组件不必为每个请求而从数据库生成。找到这些组件并尽可能将其重新设计为静态组件,这样会显着提高性能和可伸缩性。

卸载静态内容。如果 Web
应用程序中的页面一般包含九幅图像,则发送到您的 Web
服务器的请求中只有百分之十实际上使用了为其分配的持续连接。换句话说,百分之九十的请求正在浪费有用的(从可伸缩性的观点来看,还是成本很高的)Oracle
连接句柄。您的目标应该是确保只有那些需要 Oracle 连接(或至少需要动态内容)的请求才能接受动态 Web 服务器的服务。这会增加由每个进程所完成的与
Oracle 相关的工作量,而这又会减少生成动态内容所需的子进程数量。

改善这种状况的最简单方法是将您的所有图像卸载到单独的 Web 服务器(或一组 Web
服务器)上。这非常容易。第一步是建立第二个 Web 服务器处理静态请求。虽然您可以为此而使用 Apache,但是还有些专门擅长于为静态数据提供服务的其他 Web
服务器(例如 tux 和 thttpd),它们的结果或许更好。应该建立这个 Web 服务器,为另外子域的请求提供服务。常见做法是将
"
www.example.com" 的图像流量委托管给
"
images.example.com"。某些硬件负载均衡器实际上允许您为同一域中的图像提供服务,并在内部进行分配 - 有关详细信息请查看负载均衡器文档。

一旦建立了域,就应该创建一个全局配置文件,它包含您在整个应用程序中使用的所有全局常数,并应该至少添加下面一行:

define(CDN_URL, "http://images.example.com");

此文件可以手动地包含在每个文件的顶部,或者可以在 php.ini
配置文件中添加下行,从而在每个脚本开始时能够自动运行该文件:

auto_prepend_file = /path/to/config.inc

现在,不管何时在 HTML 中创建图像标记,都应该添加以下代码:


如果您更愿意拥有标记编写库,则可以编写一个图像标记创建函数,如下所示:

function img_tag($local_uri, $attr)
{
$attribute = '';
foreach ($attr as $k=>$v) {
$k = urlencode($k);
$v = urlencode($v);
$attribute .= " $k="$v" ";
}
return "";
}

即使不准备立即使用图像专用网络,还是应该对您的应用程序进行编码,为图像使用单独的基础路径,并简单地将其设置如下:

define(CDN_URL, "http://www.example.com/images");

这允许您在任何时候仅仅更改单行代码,就将整个网站削减为一个替代性的图像服务网络。根据您的网站中静态内容/图像的比例,可以看到服务器资源明显减少甚至极大地减少。在一个客户端,将静态内容从
Apache 移动到专用的系列 thttpd 箱中,可以使它们的总体基础架构减少百分之五十。

为什么要在不必工作的时候而工作呢?对任何查询的最终性能增强是根本不运行查询。很多“动态”Web
页面实际上不是动态的,在短期时间内纯粹是静态的。假设有一个新网站:在新闻项目更新之前,其内容并不改变。无论这种更新是每分钟发生还是每小时发生,在两次更新之间,网站是静态的。这意味着,不必为每个页面请求而在当前新闻项目中进行数据库查找,只需在每个更新周期中作一次查找。

最简单类型的高速缓存是全页随需高速缓存。在这种情况下,当请求到来时,应用程序在高速缓存中查找所需文件。如果存在高速缓存的副本,则将其返回给请求者;否则创建一个高速缓存副本,并将其返回给用户。要刷新高速缓存,只需简单地将旧的高速缓存副本删除即可
- 下一个请求将会自动重新生成高速缓存。

图 2显示了我们希望完成的流程图:当一个请求需要 /archive/123.html 时,Web
服务器应该查看该文件是否实际存在。如果该文件存在,则返回该文件。如果该文件不存在,用户会被重定向到一个 PHP 页面 generate.php,并将页面标识符
"123" 作为参数传递。随后该页面生成页面的高速缓存项。在 Apache 环境中,执行这种高速缓存查找的两种典型方法是使用自定义的 ErrorHandler
或者 mod_rewrite。mod_rewrite 提供更好的灵活性,因此我将实施这种方法。

图 2:全页随需高速缓存流程图

首先,需要在 httpd.conf 中建立重写规则。以下是一个示例片段:

RewriteEngine On
RewriteConf %{REQUEST_FILENAME} ^/archive/[0-9]+.html
RewriteConf %{REQUEST_FILENAME} !-f
RewriteRule ^/archive/([0-9]+).html /generate.php?id=$1

此示例首先启动重写引擎,然后说明将会重写任何与归档模式 (^/archive/[0-9]+.html)
相匹配并且不存在 (!-f) 的被请求文件名,将页面标识符作为参数传递,转到生成页面处。

生成器页面同样很简单:

$id = $_GET['id'];
$dbh =& new DB_Oracle_TestDB;
$cursor =& $dbh->execute("SELECT content FROM news WHERE id = $id");
$result = $cursor->fetch();
if(!$result) {
header("HTTP/1.0 404 Not Found");
exit
}
} else
echo $result['CONTENT'];
$outfile = $_SERVER['DOCUMENT_ROOT']."/archive/$id.html";
if(($fp = fopen($outfile, "w")) === false) {
exit;
}
fwrite($fp, $result['CONTENT']);
fclose($fp);
}
?>

在本示例中,生成器假定新闻表的内容列中包含完全格式化的内容。实际上,您也最可能在脚本中执行某种格式化,使用输出缓冲功能来捕获输出。

结论

您在这里看到的只是关于扩展 Oracle 与 PHP
技术的简短说明。尽管这些示例可能很有用,但我希望您从本文中了解的主要内容是:

  • 严密管理您的 Oracle 连接,以避免资源耗竭。
  • 速度为您带来高效率,这有助于可伸缩性。
  • 在可能的情况下,使用高速缓存技术来完全避免数据库查询。

George Schlossnagle
OmniTI Computer Consulting 的负责人,这家位于马里兰的技术公司专门从事高容量 Web 及电子邮件系统方面的工作。在加入 OmniTI
之前,Schlossnagle 在几家高水平的社区网站主持技术工作,在这些地方他获得了在非常大的企业环境中管理 PHP 的经验。Schlossnagle 经常为
PHP 团队作出贡献。在 PHP 核心以及 PEAR 和 PECL 扩展库中可以发现他的踪迹。

<<<返回技术中心

技术文章

站内新闻

我要啦免费统计