部署 PHP 系列,第 3 部分: 加速用于 Oracle 的 PHP 代码运行速度
作者:Ilia Alshanetsky
加速 PHP 应用程序的简单有效技巧的指南
|
www.jiedichina.com 南京捷帝科技
在过去的 10 年中,PHP 已经赢得了开发人员的芳心,它即使不是 Web 开发的最流行脚本语言,也是最流行脚本语言之一。 现在它支持着 2000 多万个站点,其范围从小型主页到大型公司电子商务应用程序。 PHP 的爆炸式增长很大程度上得益于它大量易用内置功能和出色的文档,这些都大大简化了开发。 遗憾的是,这种简单性已麻痹了很多开发人员,使他们对于性能自感满意,但虽然 PHP 能很迅速地随取随用,如果没有进行正确调整的话它可能会很慢。
在本文中,我将介绍一些用来加速 PHP 应用程序的最简单最有效的技术,其中包括但不限于使用脚本缓存、Web 服务器和 PHP 的正确配置与调整以及基准测试与监测。 有了这些信息,您就有了在借助硬件升级或购买之前尽可能地加速 PHP 代码的方法。
使用操作码缓存
提高 PHP 性能的最简单有效的方法之一就是使用操作码缓存。 这个工具允许消除在脚本执行中的某种低效。
为了更好地了解这种低效以及所提供的解决方案,我们快速查看一下 PHP 在执行脚本时所经历的过程。 与任何脚本语言一样,代码在执行前都会被分析并从可供人读的格式转换为一系列可由机器识别的指令。 在 PHP 中,这一过程是由 Zend Engine Parser 执行的,它将一个脚本转换为所谓的操作码。 然后这些操作码被传递给执行器,它解译这些指令并执行所要求的操作。 但是,大部分应用程序常常加载不同的 PHP 脚本库、类等,因此对脚本的所有外部组件及其子组件的编译和执行的过程会重复进行。
在大型乃至中型的应用程序中,这个过程都要花费相当长的时间。 例如,如果一个应用程序使用 PEAR 数据库抽象层、DB 来连接到 Oracle 数据库,那么至少要加载 4 个脚本来执行这一操作,需要分析超过 150KB 的代码 — 不包括应用程序逻辑。 PEAR 库极少变化,而且甚至应用程序逻辑代码在被推向生产时一般也会保持相当地稳定,然而对于每一单个请求仍需对其及其所包含文件进行重分析。 对于一个脚本来说,在无需进行任何更改即可服务数百乃至数千个页面的情形并不罕见。 对于执行相对较少数量代码的大型脚本来说,这个过程尤为不利。
再次以 PEAR 数据库抽象层为例,连接和执行一个或两个查询可能只涉及到所加载的 150 多 KB 代码中的 10-15KB。 在这种情况下,由 Zend Compiler 执行的分析工作占用的时间会远远超过实际代码执行的时间。
这种低效正是操作码缓存所力图消除的。 通过缓存共享内存内生成的操作码,编译步骤只需进行一次。 操作码缓存通过环绕编译器并有效承担生成操作码的责任起作用。 当 Zend 引擎请求加载一个脚本时,其名称被传递到操作码缓存 — 它使用一个 stat() 系统调用来确定关于此文件的信息。 所使用的信息的前几位是索引节点(索引节点是特殊的文件系统标识符,它对于在特定设备、ala 分区上的每个不同文件是唯一的)和设备号,它们为每个文件创建一个唯一的标识符。 这使操作码缓存能够区分名称相同的文件,而无需为完成同样的任务执行缓慢的路径解析操作。
然后,在哈希表内查找识别项以确定是否在先前遇到过这一特定文件;如果遇到过,则是在什么时候。 如果存在缓存命中,则将文件的修改时间(通过 stat() 调用返回的 mtime 参数提供)与存储在哈希表中的时间相比较。 如果它们相同,则意味着我们有一个有效的缓存数据;它是从共享内存中获得的,并准备用于执行器。 否则,如果它们不同或存在缓存未命中,则调用 Zend Parser 在此文件上生成操作码。 然后基于与修改时间关联的索引节点和设备号键缓存结果,随后将其传递给执行器。
如在标准代码执行期间一样,对每个包含的文件重复此过程。 所不同的是,在操作的前几分钟,大部分脚本会将其操作码缓存在内存中而不再要求进行重分析 — 现在,脚本执行所涉及的唯一开销是一个十分快速的 stat 调用和一些基本的正常化例程以使共享内存的操作码可用。 与整个分析过程相比,其涉及的开销可忽略不计,这使脚本执行大大加快。
操作码缓存的其他好处。消除分析步骤并非使用操作码缓存获得的唯一性能改进。 其他好处还包括减少文件 I/O,这是因为 PHP 在大多数情况下将只检索索引节点,而非从磁盘读取整个文件系列;文件内容可以以缓存的操作码形式从内存获得。 这是一个明显的好处,因为内存的速度远超过磁盘的速度,从而使数据访问的速度大大加快。 这还意味着,由于磁盘上的总负载减少,因此其他涉及到磁盘的操作(如写入日志文件、创建文件)以及由脚本自身进行的操作将会有所加快。
另一可能的好处是由操作码数组的优化或缩减所带来的代码执行加快。 默认情况下,Zend Compiler 力图尽可能快地将脚本转换为操作码以减少开销,从而不会生成一组用于执行器的理想指令。 由于要花更多时间才能做到这一点,因此在大多数情况下,通过更快的执行得到的好处会被极慢的分析过程所抵消,这使整个脚本执行减慢而非加快。 不过,在使用操作码缓存时,每次修改只发生一次编译过程,因此在第一个请求上多花一点时间来改进操作码是很有意义的,这是因为所有后续请求都可以从中受益。 (其最有帮助的领域就是字符串处理领域,在这一领域中,快速的数据重排将带来显著的改善。任何实际的 PHP 脚本都会使用字符串,因此操作码缓存在这里会十分有用。)
通过消除对每个请求的分析过程、减少文件 I/O、优化操作码数组,缓存能极大提高任何脚本的性能。 大多数应用程序的执行平均会快 30-40%,有一些甚至会快 200-300%。 通常,较高的数字显示那些每次请求都加载大量代码却只使用其中一小部分的脚本。
操作码缓存最吸引人的地方就是它们非常易于部署,完全不需要更改任何代码。 它们通常不需要任何生僻的库或程序包。 操作码缓存的唯一限制是只能在这样的环境中成功部署 — 使用的 PHP SAPI 是一个 Web 服务器模块,如可用于 Oracle HTTP 服务器或 Apache 的环境或 FastCGI。 原因是,操作码存储在共享内存内,为了防止共享内存段被系统释放,必须有一个过程始终附加在其上。 在前述的 SAPI 环境中,对共享内存段的控制由 PHP 维持,当该段在第一次由 Web 服务器加载而初始化时,PHP 对其进行初始化。 后者意味着,如果 Web 服务器后台程序重新启动,则操作码缓存将被有效清除,并需要重新缓存所有脚本。
操作码缓存的实现。相当一部分可选操作码缓存实现方法都是开放源代码或封闭源代码的。 开放源代码解决方案包括 Alternative PHP Cache (APC),它是在 PHP 许可(Apache 许可的另一种形式)下发布的,可以从 PECL 库获得,这使它被有些人认为安装最简单; Turck MMCache 最近由一组德国开发人员发展为 eAccelerator,它基于 GPL 提供。 在封闭源代码的前沿是 PHP Accelerator,它是第一个可用于 PHP 的免费操作码缓存;还有 Zend Platform,它包括一个操作码缓存和优化器。
不同缓存解决方案的速度大致相同 — 尽管大多数测试显示,Turck MMCache 凭借其经高度调整的缓存机制及高级操作码优化器以 5-10% 的优势高居榜首。 它的主要缺点在于,无论是它或其分支 eAccelerator 都没有完全稳定的 PHP 5 支持 — 这限制了对 PHP 4 的安全生产级使用。APC 和 Zend Platform 不相上下,有时这个优势明显一些,有时那个优势明显一些。 不过差异是如此微小(不超过 1%),以至于基本上可以认为它们的速度相同。 这种波动是 APC 未实现操作码优化器而 Zend Platform 实现了的缘故 — 可以得出这样的结论:APC 有一个稍快一些的缓存机制,但 Zend 的优化器在大多数情况下都可以弥补这一点。
APC 的主要优势在于易于安装,只要作为根用户运行 pear install apc 即可。 这个命令将下载最新的稳定版 APC 源代码并对其进行编译,其结果生成一个 APC Zend 模块,然后就可以通过 php.ini 加载这个模块。APC 的另一个优势是其活跃的社区,其中包括像 Yahoo! 这样鼎鼎大名的成员,它们始终致力于修复和改进代码。
另一方面,ZPS 已经提供对 PHP 5 的稳定支持,因此在本文撰写时,它是唯一可以声称能够做到这一点的操作码缓存。 它还提供一个非常方便的接口来控制缓存,这使它成为一个用户友好的解决方案。 不过,它的价格的确有点高,这可能使它与一些开发人员无缘了。
按性能衡量的话,垫底的是 PHP Accelerator,这可能是由于从 2003 年 1 月起就没进行任何开发的缘故吧。不过,它提供与 PHP 4.3.X 版的二进制兼容。
部署。部署操作码缓存的过程很简单。 不同实现的第一步有所不同,但第二步对所有操作码缓存都一样,它涉及通过 extension=apc.so 指令将缓存模块加载到 PHP 中。 由于所有操作码缓存都有效修改脚本分析过程,并且需要获得对一个共享内存段的控制,它们必须在 PHP 初始化过程期间启动。 这意味着缓存的启用和加载只能在 php.ini 内完成而不是由 dl() 函数或 Web 服务器配置完成。 除了加载扩展之外,通常还可以指定一些附加的选项(如分配给缓存使用的共享内存块的大小);对于 APC,这可以通过 apc.shm_size 指令完成。 其他垂手可得的指令可以提供一种方法来使某些脚本和/或脚本目录免于缓存。 (例如,在 APC 中这个任务是通过 apc.filters 配置选项完成的。)
所有操作码缓存的最后一个步骤需要重新启动 Web 服务器后台程序以重新加载 PHP、加载缓存模块,以及初始化用于缓存的共享内存块。 总的来说,整个部署过程只需短短 5 分钟 — 其中包括 3 分钟喝咖啡小憩的时间。
Web 服务器配置调整
由于 PHP 主要用作一种 Web 脚本语言,因此它通常充当一个 Web 服务器(如 Oracle HTTP 服务器)的一个模块。 由于它与服务器的密切联系,您可以通过配置调整获得数种提高速度的方法。
移除标头。一个这样的优化措施涉及移除每个请求内所包含的“Sever”广告标头。 对于 Oracle,这个标头可能由这 95 个字节组成:
除了对 Web 服务器的详细描述之外,此标头通常还包括关于不同的已加载模块(如 mod_php 或 mod_gzip)的信息。
那么您可能会问,这个标头到底有什么用呢?是为了提供内容吗? 令人惊讶的是,答案是“毫无作用”。 浏览器不在乎您在使用哪种 Web 服务器软件,它会完全忽略这个标头。 所提供的信息对用户来说是完全透明的,且只有在他们选择检查作为 HTTP 通信的组成部分的响应标头时才可见。
该标头的存在意味着每个单个请求发送 X 数量的不必要数据,从而减少了总的网络 I/O 并堵塞了出站通道。 这随之就导致了 Web 站点所有用户的页面加载速度降低。
通过禁用这个标头或缩小其大小(可以通过在 Web 服务器的配置文件中将 ServerSignature 设置为 Off 来完成),即可重新获得了被浪费的带宽。 虽然这一优化看起来可能微不足道,但它会随时间慢慢累积。 一个大型站点每天服务 100 多万个请求是常有的事,而这些请求会为每个页面(包括静态文件,如图像)发送标头。 因此,减少这 95 个字节每个月大约就可以节约 2.7GB 的流量。
.htaccess 配置。下面的其他几个优化指令能帮助您减少磁盘上的负载 — 在大多数情况下这都是系统最慢的部分。 在默认情况下,Oracle HTTP 服务器(还有它基于的 Apache)允许您将一个 .htaccess 文件放置在目录内来对每个目录设置不同的配置选项。 在其需要服务一个请求时,Web 服务器将按顺序遍历文档根目录上层的所有目录查找这个文件,以尝试加载其指定的配置更改。
对于可通过 http://www.site.com/a/b/c/d/script.php 访问的文件,执行不少于 5 次的 .htaccess 检查(对每个目录执行一次)即可找到这个文件。 在一个热门站点上,这转换为每秒数百个 stat() 调用。 如果文件可用,则需要将其打开、分析,并要将其配置更改应用于每一个请求,从而导致更大的性能损失。 有点耳熟是吧?
不过,您可以很容易地进行一些更改来避免这一性能损失,同时又保持灵活性以保持每个目录一组不同配置指令。 开始时,将 AllowOverride 设置为 none 禁用 .htaccess 配置改写。 这一更改将禁用对主配置文件 httpd.conf 中指定的配置指令的改写,并阻止 Web 服务器查找每个目录的配置文件。 为保持先前设定的现有每目录配置选项,将 .htaccess 文件直接转为主配置文件(或其包含文件之一),然后通过 httpd.conf 的 Directory 指令将其限制在一个特定目录中。这个配置参数能够只为一个特定目录及其子目录设置配置选项,其方式与 .htaccess 配置参数方式相同。
ErrorDocument 404 /app/404.php
例如,上述配置演示了如何使用 Directory 指令来转到应用程序特定的 ErrorDocument 指令,将一个自定义 404 页面从 .htaccess 指定到一个 httpd.conf 文件。这种优化的一个主要缺点是,为了便于配置更改,需要重新启动 Web 服务器,这是因为主配置文件及其包含项只在服务器初始化时读取。 相反,对于每个请求 .htaccess 文件都会被分析,这使配置更改能够立即生效。
删除符号链接验证。通过减少打开其数据要被提供的文件或目录所涉及的操作数,可以进一步减少文件 I/O。 默认情况下,Web 服务器将通过执行一个 lstat() 系统调用来检查路径组件是否为符号链接。 这意味着,对于 /home/user/public_html/app/index.php 这样的文件,将总共进行 5 次 lstat() 调用,每个目录一次,还有文件的整个路径一次。 对于 .htaccess 检查,这些调用的成本可能变得非常高昂,这是因为从不缓存操作的结果,而且必须对每个请求重复此过程,从而导致了相当大的文件 I/O 开销。 考虑到大多数人都创建符号链接来使用,因此通过在配置文件中设置 Options FollowSymLinks 来取消这一额外的验证检查很有意义。
配置 DirectoryIndex 指令。另一个文件 I/O 技巧涉及 DirectoryIndex 配置指令,它用于指定当请求目录的索引时应加载的文件的名称。 很多开发人员常犯的一个错误就是列出每个可能的值 — 或者更糟,使用一个通配符。结果,当一个用户请求一个目录索引(如 http://www.oracle.com/)时,Web 服务器将按顺序搜索每个可能的目录索引文件直至在提供的列表中找到一个。 如果不存在这样的一个文件或在列表的结尾才找到,则所涉及的 stat() 调用数将会非常大。
尽管完全不用这个指令是不可能的 — 毕竟,您可能并不希望在有人访问一个目录时显示一个文件列表 — 您可以减少在此指令中列出的值的数量并将最常用的目录索引放在列表的开头,从而获得优化。 例如,以下指令
DirectoryIndex index.php index.html
就针对主要基于 PHP 的配置进行了优化 — 其中多数情况下目录索引会是 index.php,而返回的将是一个名为 index.html 的普通 HTML 文件。在这种情况下,可以保证为确定索引文件而进行的 stat() 调用不超过两次,而在 index.php 可用的情况下,只需进行一次调用即可。
禁用请求日志记录。 Web 服务器配置中可用的最后一种优化并不适用于所有情况且应谨慎使用。 它需要将日志文件的输出目标设置为 /dev/null 来禁用请求日志记录。
CustomLog /dev/null combined
通过禁用日志文件,使 Web 服务器不必为每一个请求执行一次日志文件写入操作。 而是将数据写入一个特殊的字符设备中,它会将日志记录信息丢弃。 但是,这个方法并非普遍适用。一般来说,它只适用于为进行带宽跟踪而记录图像的静态请求的情形,而这可以通过其他方法更准确迅速地完成。 禁用所有请求的日志记录通常不是一个好主意,因为这样会阻止流量分析和基于日志的安全审计。 在某些环境中,如果当地法律要求将日志保留一段时间,这样做甚至是违法的。 不过,在有些情况下它是一种有效的优化,它可以带来可观的性能效益,尤其是对于大型站点来说。
PHP 配置
调整 Web 服务器配置可以间接提高 PHP 服务 Web 页面的能力,而调整 PHP 自身的配置能带来一些直接的好处。
禁用标头。像大部分 Web 脚本语言一样,默认情况下 PHP 喜欢通过将 X-Powered-By 标头添加到其服务的每一个请求中来昭示它的存在。
X-Powered-By: PHP/4.3.10
这个标头同样没有什么实际用处,而且浪费带宽。 幸好 PHP 的配置文件提供了禁用这个标头的方法:将 expose_php 指令设置为 Off 或 0。 禁用这一选项除了删除请求中无价值的东西外,还使您的脚本无法用来支持 PHP 的内置“复活节彩蛋”(“特殊”的愚人节徽标) — 它通过将 ?=PHPE9568F36-D428-11d2-A769-00AA001ACF42 添加到脚本的 URL 返回。
处理用户输入。进一步的配置调整涉及对脚本的用户输入的处理。 在默认情况下,PHP 将 magic_quotes_gpc 设置设为 On,这会导致通过 get ($_GET) 和 post ($_POST) 请求、cookie ($_COOKIE)、Web 服务器环境 ($_SERVER) 以及系统环境 ($_ENV) 供应给脚本的任何输入数据的转义。 这一特性旨在为像 MySQL 这样目前尚不支持预处理语句的数据库阻止大多数类型的 SQL 注入式攻击。 虽然它不是最佳方法,但 MySQL 扩展提供了一个较好的转义机制,可以通过简单地转换为 int 或 float 来验证数值。
对于支持变量绑定的数据库(如 Oracle),这一步骤完全没有必要,这是因为不会将值转换为所指定类型外的任何类型,从而使转义没有必要。 无效字符(如数值中的单引号)的存在会导致查询失败,但不会导致 SQL 注入式攻击。
在内部,转义过程通过复制每个值完成,每次一个字节地检查数据以检测需要转义的字符,如需要则将其转义,然后将内存块重新调整至正确大小。 如果脚本具有大量参数或值包含大量数据,则这个过程将非常慢。 对于更复杂的情况,在将数据写入文件或在没必要进行转义的预处理语句中使用数据时,必须将由转义添加的反斜杠剥离。 无法通过 stripslashes() 函数将它们移除则意味着存储的数据现在包含其他字符,从而导致输入损坏 — 这不仅浪费 CPU 和内存,还要浪费时间消除其影响。 为避免这一开销且出于性能方面的考虑,应该禁用 magic_quotes_gpc 配置指令,而在为数不多的需要使用订明函数的时候执行手动转义。
配置 register_globals 指令。 另一与输入相关的优化涉及 register_globals 配置指令 — 尽管在默认情况下它是禁用的,但在很多 PHP 配置上仍被启用。 启用后,此指令强制 PHP 将之前描述的输入方法提供的每个参数注册为一个变量。 例如,如果
?foo=bar&baz=123 GET
查询字符串被传递给一个脚本,除了填充 $_GET 超级全局变量外,PHP 将创建 $foo 和 $baz 变量。 尽管 PHP 足够智能化,不会复制被传递的用即写即复制 (copy-on-write) 技术创建的输入值,它仍需创建额外的变量占位符。 考虑到这些占位符是字符串,因此必须将内存分配给哈希密钥和 PHP 的变量哈希表内的一系列额外项。
比性能损失更令人不安的可能就数启用这个选项的安全后果了。 由于用户现在能够将变量注入 PHP 的作用域内,因此他们就获得了为任何未初始化的变量设置值的能力,接着就可以使用这些变量对代码发起任意次数的攻击。 因此,应该禁用此指令并只使用超级全局变量(如 $_GET、$_POST等)访问输入数据。
配置 variables_order 指令。 最后一个要考虑的与输入相关的设置是 variables_order 指令,它指明哪些输入注册为超级全局变量的形式。 在默认情况下,这个指令包括“E”值,这会导致创建用于访问环境变量的 $_ENV 超级全局变量。 有意思的是,极少脚本需要使用环境变量,而且即使使用也只需一个或两个而已,并仍可以通过 getenv() 函数访问。 因此,对每个请求注册包含 15-20 个变长值的 $_ENV 超级全局变量不仅没有意义,而且还浪费处理器时间和内存。 相反,您应该将 php.ini 中 variables_order 的值设置为仅包含 GPCS,从而将系统环境变量排除在列表之外。
配置 session.use_trans_sid。 另一严重的性能问题涉及一个常用的 php.ini 选项 session.use_trans_sid;如果启用了它,则更易于向所有内部链接和表单自动添加会话 ID。 这个特性的目的在于简化确保会话 ID 始终为请求一部分的过程,从而使基于 URL 的会话正常运行。 为实现这个功能,PHP 需要做一些不少的额外工作,而这无助于高性能。
当启用了这一选项时,PHP 所做的第一件事就是在内存中缓冲整个页面的内容,而非直接输出数据。 PHP 无法预先知道页面的最终大小,因此它无法静态地预先分配一个存储数据的缓冲区。 它而是创建一个小的起始缓冲区,并随着脚本输出数量的增加而将其增大,这就需要相当多的内存重分配工作。 当脚本完成执行后,PHP 获取存储在缓冲区中的输出,并将其通过一个分析器传递。该分析器检查输出中的链接和表单,并将当前会话 ID 加到链接和表单上。 这个过程不仅大量消耗内存,还会对 CPU 造成很大负担。 因而,您应该在脚本撰写时手动将会话 ID 加到链接或表单上。 或者,可以使用仅使用 cookie 的会话,这样就不需要整个这个操作了。
配置目录路径。另一个相关会话设置是 session.save_path,它负责定义会话数据的存储位置。 在使用默认会话句柄、文件时,默认情况下 PHP 会将每个会话作为一个单独文件存储在系统的临时目录 /tmp 内。 尽管这个方法对于访问者相对较少的小型站点来说很好,但对于较大的站点它可能会造成严重的性能问题 — 随着目录内文件数量的增加,对于大多数文件系统来说对这些文件的访问会变得越来越慢。 ext2 文件系统尤其容易受到这个问题的影响,当一个目录的文件数超过几千的时候就会变得非常慢。 如果想想系统临时目录要由其他很多应用程序使用,并且其他 PHP 脚本也要创建的会话,您就知道这个数字有多高了。
一个解决办法就是为每个应用程序在会话初始化前将一个路径设置到一个备用会话存储目录。
session_save_path("/home/user/sessions/appX/"); session_start();
这种方法的附带好处包括降低因两个脚本或请求为不同用户生成相同会话 ID 而导致名称冲突的可能性。 由于会话 ID 和数据不再位于完全可读的 /tmp 目录内,因此它还使本地用户查看活动会话变得稍困难些。
但即便是目录分隔也不足以应对所有情况,尤其是如果允许会话长时间停留的话。 在这种情况下,您可以采取另一步骤来避免在单个目录内积累大量文件: 指令会话扩展基于会话 ID 的前几个字符创建一个目录树,作为实际存储目录路径。
分号后面的数字表明树的深度,如这个实例中,表示创建了一个 3 级目录。
session_save_path("3;/home/user/sessions/appX/"); session_start(); // if session id is 6c178dc19a034137e63fe8d50292df11 // its location will be // /home/user/sessions/appX/6/c/1/sess_6c178dc19a034137e63fe8d50292df11
由于会话 ID 是一个十六进制数(其中每个字符都可能是 0-9a-f 之一),因此它为每一级提供了 15 个可选数。 对于一个 3 级深的结构,这意味着会话数据将基本平均分布于 4096 (16^3) 个目录中,这将确保每个目录里都有合理数量的文件,即便对于大型站点来说也是如此。 即便这还不够,也可以增加该价值,毕竟每增加一级就多提供 16 倍的目录。
I/O 匹配
通过 PHP 提供内容的关键方面之一就是向用户的提供 — 否则,它又有什么用呢? 很多开发人员都忘了,PHP 不直接与用户的浏览器通信,实际上数据要通过很多过程过滤,然后才缓慢通过 TCP/IP 协议,最终到达用户。 在多数情况下,当 PHP 执行一个导致数据输出的操作时,该输出首先被传送给 Web 服务器,Web 服务器将数据传递给操作系统,操作系统再通过 TCP/IP 将其传送给用户。
由于 PHP、Web 服务器以及最后的操作系统之间的“写入”操作都不是很快这一事实,一个性能问题又浮现出来了。 如果一个脚本(大多数脚本都这样)一小块一小块地输出数据,则会导致大量的系统调用。 为了减少写入的次数,PHP 提供了一种输出缓冲,它在默认情况下已启用。 输出缓冲使 PHP 能够将不同写入操作的输出收集到块(默认情况下设置为 4,096 字节 (4KB))中,只有在块已满时才发送数据。 这就是说,在输出一个 48KB 的页面时,PHP 将内容传送给 Web 服务器只需 12 次写入操作就够了,而如果没有将数据进行缓冲的话,则需要数百次写入操作。
缓冲区大小是预先设置的,这避免了我们在将会话 ID 自动追加到 URL 时已遇到的内存重分配开销问题。 但即便是 12 次写入仍然太多,因此对于大的页面来说,通过更改 php.ini 中的 output_buffering 的值增加缓冲区的大小是明智的作法。这样,整个页面都可以装入缓冲区,只需一个写入操作即可将数据传送给 Web 服务器。
// inside PHP.ini output_buffering=50K // inside httpd.conf or .htaccess php_value output_buffering=51200
可以通过在程序开始时调用 ob_start() 函数从脚本强制缓冲输出。 如果未在脚本开始时调用,那么之前发送的任何输出会不经缓冲直接传送给 Web 服务器。
这个方法不如通过 output_buffering INI 设置缓冲区大小的方法有效。 由于无法知道最终输出的约略大小,PHP 将需要为输出大小的增长不断调整缓冲区大小,而不是在请求的开始基于平均页面大小预先分配一个大的缓冲区。 这就是说,每次默认的 4KB 缓冲区已满的时候就要调整其大小。 这个过程可能会重复多次(取决于输出的大小),从而导致过频繁的内存操作。
Web 服务器本身也希望减少与将数据传送到操作系统相关的系统调用的数量,并采用其自己的缓冲。 这个缓冲区的大小默认设置为 4KB,这意味着如果所提供的数据是一小块一小块的,则它将其累积直至缓冲区已满才将其传送给操作系统。 如果所提供的数据大于缓冲区的大小,则将其拆分为缓冲区大小的块分别传送。 另一方面,如果 PHP 在内部使用与 Web 服务器相同大小的缓冲区缓冲数据,那么就可以通过单个 writev() 系统调用将数据直接传送给操作系统而不会有任何干扰或开销。
考虑到我们优化过程的需要,对于传送大量数据的应用程序,增加 Web 服务器缓冲区的大小以防止大的 PHP 缓冲区被拆分并减少写入次数是明智的。 您可以用 SendBufferSize 配置指令来调整 Web 服务器缓冲区的大小。
SendBufferSize = 53248
上面的设置会将缓冲区设置为 52KB,比前面请求中使用的 50KB 的 PHP 缓冲区略大。 通常 Web 服务器缓冲区应稍大,以容纳除页面内容外还将添加的不同标头。 这种方法将确保一次性给请求一个完整的响应,而不需要多次写入操作。 由于每个站点和应用程序都有其数据传输要求,因此并没有一个通用的正确值。 通过分析 Web 服务器日志(它包含响应每次请求时发送多少数据的信息),开发人员和管理员需要确定适合其情况的“正确”值。
以大块发送数据的附加好处是 Web 服务器不再需要等待操作系统来传送数据。 如果使用小缓冲区(或无缓冲区),则在发送更多信息之前将需要等待操作系统指示前面的数据已经写入,从而导致进一步的性能损失。 由一个缓冲区包含整个页面的话,可以将数据一次性传递给内核;Web 服务器可以继续为其他用户生成数据,而不必等待传送确认。 在高流量的服务器上,这个方法会带来处理传入请求所需过程数量的全面降低、活动过程数的减少以及更低的内存使用,这显然会转化为性能的提高。
当然,缓冲区也存在于操作系统中。 正如 PHP 和 Web 服务器一样,内核也不希望执行数百次写入来传输信息。 操作系统缓冲区决定了通过 TCP/IP 一次能发送数据的量,这就是为什么要确保这些缓冲区与 Web 服务器设定的缓冲区相匹配的原因了。
例如,您可以通过 tcp_wmem 指令调整 Linux 系统内核的发送缓冲区 — 该指令可以设立低的、平均的或最大的缓冲区。
/proc/sys/net/ipv4/tcp_wmem 51200 131072 204800
有两种方法可以修改这个选项,第一个主要旨在测试在哪里需要更改这个值,通常是通过使用 bash shell 的 echo 工具完成的
echo "51200 131072 204800" > /proc/sys/net/ipv4/tcp_wmem
这些值按照您希望其在设置内显示的顺序排列,并由单个空格分隔。 更改这些值的另一种方法是在 /etc/sysctl.conf 内设置它们,该文件指定在每次启动时在系统上设置各设置。 理想值确定之后,建议在这个配置文件内设置它们,而不是把前面提到的行添加到引导脚本之一中。
net.ipv4.tcp_wmem = 51200 131072 204800
要为系统设置 wmem 值,所有要做的就是在 sysctl.conf 文件内的某处添加此行。 需要确保的一点是,在文件中的其他地方不能有相同的指令,否则会反转该操作,特别是这些指令位于您的指令之后的时候。
第一个数字表明写入缓冲区的起始值,它在 TCP 套接字创建时分配。 理想情况下,您希望将其设置为由 Web 服务器传送的平均页面大小。 这将确保除非页面大于平均值,否则就无需为连接进行更多缓冲操作。 第二个值表明不受系统妨碍(甚至在可用内存较低的情况下)时可以增加到的缓冲区大小。 超过这个值的话,如果系统当前处在高负载状态下,操作系统可以决定不增加缓冲区大小。最大页面大小是这个选项数字的一个不错选择。 最后一个数字表明可能的最大 TCP 套接字缓冲区大小,它旨在防止为了传送大文件(如图像)而分配大量缓冲区从而导致的内存耗尽的情形。
应设置的一个相关 TCP/IP 设置是 tcp_mem,它定义了 TCP 堆栈的内存使用方式。 与 tcp_wmem 指令相似,它也由 3 个值组成,表明最小值、平均值和最大值。 但这些值的含义略有不同。 它们用于设置对 TCP 套接字缓冲区使用的总限制,也就是说它们必须说明系统上并发活动的套接字数。 而且,与 tcp_wmem 不同,这里大小不是以字节计算,而是以内存页(通常每个内存页代表 4,096 字节)计算。 页的精确大小可以在 C 程序中根据 getpagesize() 或 sysconf() 系统调用确定:
#includeint main() { printf("%ld/n", sysconf(_SC_PAGESIZE)); return 0; }
tcp_mem 的第 1 个值表明低阈值 — 达到这个值,内核将不再尝试限制缓冲区的使用;理想情况下,这个值应与指定给 tcp_wmem 的第 2 个值相匹配 — 这第 2 个值表明,最大页面大小乘以最大并发请求数除以页大小 (131072 * 300 / 4096)。 第 2 个值表明内存使用情况,对此内核会尝试将内存使用压低;理想情况下这个值应该是 TCP 可以使用的总缓冲区大小的最大值 (204800 * 300 / 4096)。 第 3 个也是最后一个值表示最大缓冲区限制。 如果超过这个值,TCP 连接将被拒绝,这就是为什么不要令其过于保守 (512000 * 300 / 4096) 的原因了。 在这种情况下,提供的价值很大,它能处理很多连接,是所预期的 2.5 倍;或者使现有连接能够传输 2.5 倍的数据。
在设置这些限制时,记住以下两点是很重要的: 首先,并发 TCP 套接字的数量总会超过 Web 连接数,很多其他系统进程(如到 Oracle 数据库的连接)也会导致创建套接字。 这意味着,缓冲限制还应考虑到那些连接的存在和需要。 例如,尽管可能只有 300 个 Web 用户同时访问一个站点,但套接字数量很容易就会高达 700 之多,这是因为每个连接还需要一个数据库套接字,并且其他系统进程(如 SMTP/POP3/IMAP)也使用套接字。 还有一点也很重要,即其他套接字连接可能像 HTTP 一样常见,如果它们传输较多信息,也许可以使用它们的发送大小确定理想值。 否则,尽管 HTTP 流量可能平滑,但数据库通信(也主要基于套接字)则将因需要将传输拆分为多个段而不会获得最佳性能。
其次,一些连接会逗留超过 1 秒,因此任何一个时间都可能有数千套接字是活动的。 为了容纳这么多的套接字,上面的值大约应该乘上 15-20 — 取决于平均连接生命周期。
在我们的例子中,/proc/sys/net/ipv4/tcp_mem 设置应该是:
192000 300000 732000
最后的结果是通过大幅度降低写入操作数优化了用户内容的传送。
除了提高服务器性能外,缓冲还通过加快页面加载改善了用户体验。 大多数现代浏览器在接收到数据(即便是很少)都会重新呈现页面,以使用户能够尽快看到页面内容。 当页面以很多小块发送时,页面重新呈现所需的时间会很长,这给用户的机器造成过度的负担,从而使内容加载看起来比较慢。 但是,当页面以单个大数据块发送时,浏览器只需将其重新呈现一次即可,从而使其看起来要快很多。
缓冲一个可能的缺点是由 PHP、Web 服务器和操作系统导致的内存使用的增加。 在低内存系统上,不同缓冲区耗尽所有可用内存后迫使使用交换区,从而导致总体操作大大减慢是完全可能的。 在计算缓冲区大小的时候考虑总的可用服务器内存并确保留出足够内存给其他的非缓冲相关操作是很重要的。 幸好,在现代服务器系统上 RAM 的容量以 GB 计,因此很少会出现这一问题。
基准测试与监测
在开始任何代码优化之前,了解应用程序中什么地方存在瓶颈很重要。 没有这一信息的话就只能进行猜测,而这些假定可能使您把时间和资源用来解决某个低效 — 而这实际上可能只是问题的一小部分。
与之相比,通过对应用程序的基准测试和监测,您可以准确知道站点/应用程序的哪部分比较慢,然后分析造成这种情形的脚本来确定瓶颈位于何处。 由这个过程提供的初始快照也是重要信息,它使您能够判断应用程序的当前状态并确定性能目标。
每次更改后您都应该再次检查性能以确定其是否已获得提高。 很多因性能原因促使的更改(如缓冲区调整)如果设置不当在某些情况下会导致性能下降,因此验证这些更改确实有益是绝对重要的。
基准测试方法。对基于 Web 的应用程序,对其性能进行基准测试的最简单方法是模拟预计的用户流量并查看服务器能否应付这一负载。 由于站点流量从来都不是线性的,因此通常最好测试最差的情景 — 例如,峰值时间负载的两倍 — 新产品发布或热门评阅可能会造成这种情况。
有许多工具可用于模拟请求,其中最受欢迎的是 Apache Bench,通常在大多数基于 *NIX 的系统上以 ab 实用程序的形式提供。 这个工具提供一个非常简单的界面来将很多请求发送到服务器,并能够使之看起来这些请求正同时来自不同的用户。 例如,命令
ab -n 10000 -c 10 http://localhost/
会导致以并发级 10 级执行 10,000 个请求,这对于预期在任一时间服务 10 个并发用户的站点来说是一个很合适的估计值。
由 ab 生成的报告会包含大量令人感兴趣的信息,如每秒服务的实际请求数 — 理论上它应大于并发数。 (如果这一数字恰巧低于并发数,服务器就不能应付与服务 10 个并行页面实例关联的负载。) 报告中显示的其他有用信息包括输出的大小 — 一个非常有用的值,用于确定 PHP、Web 服务器和 TCP 堆栈的缓冲区大小。 报告还表明传输的总字节数和平均传输速度,您可以用来查看网络管道是否已饱和。 例如,在 10MB 管道上速率为 1.5MB/s 就意味着瓶颈真正来说并非出自于代码,而是出站网络连接。
还有不同的执行时间平均值,它们进一步加大了请求时间。 例如,如果由于某些原因建立一个连接比处理的时间还长,Web 服务器没有足够数量的子级来处理传入请求,导致要实时创建这样的流程。 类似地,如果失败的请求数很高(一个重要的 sata 点),负载可能正在导致请求失败或被拒绝。
在对一个站点或应用程序进行基准测试时,一个重要的注意事项就是要花时间分析所有页面,而不是仅分析那些被怀疑为较慢的页面。 一个常见错误是只对首页或可能几个从其衍生的页面进行基准,而实际上性能损失可能是由一个极少使用的页面造成的。 此外,在使用 PHP 脚本时,不同的输入可能造成轮换输出 — 它们不一定需要同样多的生成时间,这一点很重要。 为了避免遗漏可能的瓶颈,您不仅应该测试每个页面,而且应该用可能造成轮换数据生成的不同输入来测试它们。
监测方法和工具。当识别出一个甚至一系列慢速脚本的时候,您可能只知道它们的确慢,而不知道为什么会慢。 这就是要用到第二种性能分析工具: 监测器的原因了。 PHP 监测器是一个 Zend 模块,它位于执行器周围并跟踪所有函数调用。 作为其跟踪过程的一部分,它分析每个函数需要多长时间来运行、在脚本执行期间执行多少次以及是谁调用了该函数。 如果启用了 php.ini 指令 memory_limit,它还将跟踪并报告函数执行后脚本内存使用的变化。 随后将这个信息写入一个文件或一个相似的数据存储区 — 稍后您可以用其生成一个详细报告。 生成的报告就能够清楚地标识脚本的慢速部分,而且在大多数情况下可以清楚地识别出问题原因。 例如,如果 OCIExecute() 的执行需要超过 1 秒钟,那么这很可能是一个强制数据库执行整个表扫描的未优化查询导致的。
对于操作码缓存,有多个 PHP 监测工具。 两个开放源代码解决方案,DBG 和 XDebug,它们通常可以在不同的 IDE 程序包中找到,并且集成了一个监测器与调试器,这是它们的主要特性。 一个 PHP 的纯监测器以 APD 的形式提供,可以在 PECL 库内找到,这使得它极易于部署。 而且 Zend Studio 在其通用开发套件中提供了一个监测器。
使用监测器(如 APD)需要 4 个步骤。首先以根用户运行 pear install apd 命令安装工具。 这个命令将下载 APD 的最新稳定版并将其安装。 现在,应该在 php.ini 中添加 zend_extension = /path/to/apd.so 指令以将这个模快加载到 PHP 中。另一应指定的重要设置是 apd.dumpdir,它指示由 APD 生成的分析文件应放置的位置。 默认情况下,它们将存储在 /tmp 目录内。
现在需要重新启动 Web 服务器软件以使 PHP 配置更改生效。 此时,您已经准备好开始监测应用程序了 — 通过将 apd_set_pprof_trace() 放置在脚本顶部来实现。 函数调用从脚本中的那一点开始监测程序,这对只有一部分代码要检查的大型脚本来说非常有用。
请求完成时,监测文件被作为一个单独文件写入到指定的 dumpdir,这个文件的名称将以用于处理请求的 Web 服务器进程的进程 ID (PID) 为基础。 这个文件的内容并不为了供人使用,因此 APD 提供了一个 pprof 工具来基于这个转储生成不同类型的性能分析报告。 通过为该实用程序指定一系列的开关可以形成不同的报告模式 — 例如,调用 pprofp -u /tmp/apd/pprof.1234.0 会在顶部生成一个占用用户时间最多的函数的报告。
Real User System secs/ cumm %Time (excl/cumm) (excl/cumm) (excl/cumm) Calls call s/call Name ----------------------------------------------------------------------- 33.3 0.02 0.02 0.02 0.02 0.00 0.00 7 0.0029 0.00 require_once 33.3 0.01 0.01 0.02 0.02 0.00 0.00 55 0.0004 0.00 sprintf 33.3 0.00 0.00 0.02 0.02 0.00 0.00 144 0.0001 0.00 feof 0.0 0.00 0.00 0.00 0.00 0.00 0.00 1 0.0000 0.00 htmlspecialchars
不同的时间规范使您能够查看在一个特定例程中用去的总时间(真实时间),并对比执行系统调用所用的时间(系统时间)和普通处理操作所用的时间(用户时间)。 这个差异可用于确定应用程序是否正遇到一个 I/O 瓶颈,基例程执行系统调用时间大部分都用在了哪里。
高用户时间表明一个慢速的或复杂的非系统相关的操作,如在一个大的字符串上执行一个正则表达式。 在大多数情况下,调整低效用户代码比解决 I/O 瓶颈简单得多,因此这一信息在安排可能的修复的优先顺序上很有帮助。
另一个有用的模式包括 -t,它显示一个压缩的调用树来演示脚本内的函数调用流。 在你需要确定为何及在何处某个特定函数被调用了数百次(如我们实例中的 feof())时,这是一个非常便利的工具。
数据库性能
大多数 PHP 应用程序依靠数据库来存储信息。 使用正确时,数据库能提供高效地数据存储,并且能非常好地伸缩。 但是,不正确的使用经常会在脚本内导致某些最严重的性能损失,这就是为什么调整很重要了。
使用数据库的步骤涉及通过打开一个到数据库服务器的套接字来建立一个连接以及传递请求访问部分数据的身份验证信息。 然后数据库要验证这一信息,将它与其权限设置相比较,并确定为正在连接的客户授予何种访问权限(如果有的话)。 在一些情况下,这个过程可能涉及建立一个加密通道 — 这会引发另一系列的操作。 这个相当复杂而且不很快的过程在每次页面请求中重复 — 在页面请求的完成过程中要查询一个数据库。 对于特性完整的数据库如 Oracle,为每个请求启动一个新数据库会话会带来重大损失。 实际上,如果最后脚本只执行一个或两个快速查询,其连接时间就会占到整个数据库通信时间的 30% 之多。
还好 PHP 提供了一种变通方法,该方法使代码能够避免必须为每个请求打开一个新连接,从而消除了连接初始化开销。 这是通过将连接函数从 ocilogon() 更改为一个持久性连接函数 ociplogon() 实现的。 使用后者时,在打开一个连接前 PHP 会检查其内部资源表,查看是否已有一个使用相同身份验证信息的 Oracle 数据库连接可用。 如果已有,则返回这个资源以避免其他操作。否则,就建立一个新连接,并将其添加到与身份验证数据关联的内部资源表中。 由于此连接被标记为是持久的,因此在脚本终止时将不再将其自动关闭,从而使后续请求可以重用它。
这一优化若没有几个“转向”就不能做到。 在 PHP 中,持久性连接不是跨整个服务器实例共享的,而是按每个子实例保存,这意味着最终每个 Web 进程将要服务一个数据库请求并以其自己的一个持久性连接告终。 在一个有 100 个活动子实例的 Web 服务器上,这一情况会导致 100 个或更多活动数据库连接。
为什么会更多呢? 是这样的,连接资源是基于身份验证信息关联的,因此,如果内容管理系统使用一组权限而电子商务应用程序使用另一组,则您现在就会有 200 个活动连接。 由于到数据库的并发连接数会是有限的,因此这些持久性连接会阻止其他进程与数据库通信。 对于更复杂的情况,终止这些连接的 PHP 函数 ociclose() 目前是一个空操作,是无效的,这意味着关闭这些连接的唯一方法就是重新启动 Web 服务器。
持久性连接的另一个可能问题与锁或事务有关。 当一个常规连接关闭时,任何当前锁都被释放,未提交事务自动回退。 但是,持久性连接不会自动发生这个过程 — 由于连接没有终止,锁或事务会无限期存在。 如果是一个剩余写入锁,就意味着将拒绝试图访问锁定的表或行的其他进程。
幸好,通过谨慎地编写代码以在脚本结束时确认无挂起的未提交事务可以避免这个问题。
function oci_safe_close() { if (defined('oci_conn') && oci_conn) { ocirollback(oci_conn); } } register_shutdown_function("oci_safe_close"); define('oci_conn', ocilogin("user", "pass", "db"));
这部分代码注册一个回调函数,PHP 脚本一结束 — 无论是因为脚本到达结尾,还是因用户放弃请求而被终止 — 就会调用此函数。 通过确认一个存储 Oracle 连接的常数的存在和有效性,此函数检查是否设置了这个连接。 如果连接有效,调用 ocirollback() 函数,这将回退任何未提交事务,从而防止死锁。
但是,数据库中最严重的性能降低不是慢速连接而是没有使用可用数据库工具进行快速数据存储和检索的慢速查询。 这些问题中最常见的是缺少索引,这意味着数据检索必须执行一个整表检索来取得所需要的行。 或者是有过多的索引,这会由于在插入时强制重新创建索引和强制数据库检查多个索引而降低插入速度。 在开发期间,由于数据很少,实际上任何查询都可立即执行,因此这些问题很难发现,这更加重了问题的复杂性。 在生产期间,当数据集持续增加时问题就会有所抬头,而缺少索引马上就会导致页面加载时间非常慢。
Oracle 用户是幸运的,他们有工具来分析查询执行所涉及的操作,还有关于受每次操作影响的行的摘要。 通过将 autotrace 设置为“on”,可以从 SQL*Plus 接口触发对每个已执行查询的分析。 这一操作完成后,每个已执行查询都会后跟输出 EXPLAIN PLAN,详细描述为取得所需数据而执行的内部操作。 在内部,这个信息存储在 PLAN_TABLE 表内,此表的结构可以在位于 $ORACLE_HOME/rdbms/admin 目录的 UTLXPLAN.SQL 脚本内找到。 (有关通过 Oracle Enterprise Manager GUI 执行类似分析的信息,请参阅此文档。)
此表的由 6 列组成: ID、操作、名称、行、字节和 CPU 成本。 尽管 ID 字段不是特别有用,但其他所有各列都包含性能调整的相关信息。 “操作”列包含对操作的描述,如 TABLE ACCESS FULL;而“名称”列包含操作所涉及的表的名称。 “行”列显示受操作影响的行数,“字节”列表明被操作数据的大小。 最后一列,“成本”,说明执行某一特定操作所花费的处理时间。 (如您可能想到的,这个值越低,查询就越快。)
为了便于正确操作 SQL*plus 内的查询跟踪,需要执行另一个脚本 — PLUSTRCE.SQL(可以在 $ORACLE_HOME/sqlplus/admin 目录内找到)以允许 SQL*Plus 将报告可视化。 如果没有由这个脚本和前一个脚本提供的结构,autotrace 就无法将查询相关的信息可视化。 假定两个脚本都在运行且通过执行 set autotrace on 命令启用了查询跟踪,则每个查询输出后都会跟着执行计划和统计值,用于详细描述执行的操作和传输的数据。 这个分析工具就绪后,一旦脚本分析器显示一个 ociexecute() 函数调用用去了数秒以上的时间,您就可以取得其查询并分析它来确定性能降低的原因。
特别要注意的是所涉及的行数远远多于正在检索的行数的查询,这通常表明缺少索引或索引未正确实现。 在调整查询时,基本的经验方法是,所需操作越少,查询的执行就越快。 同时,力求减少需要分析、需要在数据库中内部传递以及需发送回 PHP 的数据的量。
有关在 PHP 中正确使用索引的更多完整讨论,请参阅本系列的第一部分或 Oracle 数据库 10g 调整指南。
结论
还有很多窍门可以用来进一步提高速度,但要注意: 通向最高性能之路是漫无止境的。 多一次调整或设置修正总能带来更好的结果,但这很容易令人沉迷其中而花费过多时间进行调整、微调、重调而忽略其他事情。
因此,您应提前设定一个合理的性能目标。 还需要记住的重要一点是,在大多数情况下,与开发人员成本相比,硬件成本微不足道。 很多情况下,在您已经应用了基本的优化(如引入操作码缓存和查询优化)后,如果仅为了提高计算能力,则通过添加另一服务器或升级现有服务器做到这一点是更简单、更快、更经济的方法。
www.jiedichina.com 南京捷帝科技
Ilia Alshanetsky 是 PHP 核心开发人员,负责大量扩展、通过安全修复而全面改善语言、性能增强以及通用故障修复的开发。 他是 Advanced Internet Designs Inc. — 一家负责开发 FUDforum(一种用 PHP 编写的高性能高安全性的 bbs 软件)的公司的首席设计师。 他还是 Zend 认证培训和专业 PHP 开发课程的作者,也经常教授这些课程。