「网站很慢」是个不能直接开工的说法。等自己接手,手上往往只剩一句话:打开要好几秒。这篇文章记的是一次把这种模糊说法变成可复现结论的过程——从一段切得开的计时开始,一步步缩小范围,最后停在一个 ini 开关上。
结论先放在这里:open_basedir 一旦生效,PHP 会直接停用 realpath 缓存。而 WordPress 这类一个前台请求要 include 几百个文件的程序,开销几乎全压在路径解析上。
先量,别猜:把一次请求切成三段来计时
调性能最容易走错的第一步,是直接动手改配置。php.ini 里能动的开关有几十个——OPcache、JIT、内存上限、进程数——随便改一个,主观上都会觉得「快了一点」,但没有基线,你分不清是真快了还是这一下碰巧。所以在动任何参数之前,先在站点根目录放一个一次性的计时脚本,把一次完整请求切成三段:客户端发起到 TLS 握手完成、PHP 引导(加载内核与插件)、主题渲染加数据库查询。三段各自计时,加起来应当接近整页耗时,否则说明还有一段没被计进去。
结果相当集中:网络与 TLS 约 130 毫秒,PHP 引导与插件约 1200 毫秒,主题渲染与查询约 250 毫秒。第一段不到一成,也就是说,换线路、上 CDN、调 Keep-Alive 这类网络侧优化的天花板,就在那 130 毫秒里。

同一台服务器上找个对照组,比自己跟自己比有用得多
这台机器上还跑着另一个站,同一份 nginx 配置、同一套 PHP,只是域名和站点根目录不同。这就是现成的对照组:如果两边都慢,问题在服务器;如果一边快一边慢,问题在站里。
结果分成两半,分界线干净得有点意外。纯静态文件两边几乎一样快,295 毫秒对 242 毫秒;可一旦请求需要 PHP 参与,差距立刻拉开——登录页 1444 对 945,首页 1706 对 1251,都差了七八成。既然静态文件一样快,那么网络栈、TLS、nginx、操作系统这一整条链都可以先放回架子上,剩下的差异只可能在 PHP 层。
对照里还有个更刺眼的反常值:robots.txt。一个几百字节的纯文本,一边 202 毫秒,另一边 1456 毫秒,差了七倍。追下去才明白,慢的那一边的 robots.txt 根本不是一个文件——它被 rewrite 交给了 WordPress 动态生成,每访问一次就完整引导一遍内核。同一个道理还落在裸域跳 www 上:那一次 301 也是由 WordPress 发出的,意味着一跳要跑满两遍 PHP 引导。
这两条线索指向同一个方向:慢不是因为 PHP 算得慢,而是因为它被叫醒的次数太多,而且每次醒来的准备都太贵。

顺着「路径检查」这条线,往下钻到文件系统调用
范围锁在 PHP 之后,下一步是看它究竟把时间花在哪种调用上。再放一个脚本,对常用的文件系统函数各调用两千次、取单次均耗,看形态。结果不是「整体慢」,而是很挑食:会做路径检查的那几个明显偏高,file_exists 299 微秒、is_readable 255 微秒、realpath 397 微秒;而 stat 只要 41.67 微秒,is_file 只要 0.17 微秒。同一个目录、同一块盘,差了上千倍。
这种形态不像磁盘慢,也不像 CPU 慢,倒像是每次调用之前都被额外套了一层检查。顺着「路径检查」往下想,就会想到 PHP 里专门管目录边界的那个配置项。
只改一个变量做 A/B,把猜测变成证据
怀疑归怀疑,怀疑不能当结论。真正的验证是把变量收到只剩一个:同一套 PHP、同一台机器、同一份脚本,唯一的变化就是 open_basedir 开着还是关着,各跑一遍。
这组对照把话说死了。realpath 从 1.64 微秒涨到 2583.18 微秒,1575 倍;file_exists 从 72.7 涨到 2042.69,28 倍;is_readable 从 86.11 涨到 2172.63,25 倍。而 is_file 和 stat 几乎没动,0.17 对 1.12,0.82 对 0.79。
还有一组数字更能说明机制:realpath 缓存的条目数。关掉 open_basedir 时是 13 条,开着的时候是 0 条。缓存不是命中率下降,而是根本没有往里写。

机制:为什么这个开关会让 realpath 缓存失效
PHP 官方手册在 open_basedir 条目里有一句很短的 Note:使用 open_basedir 会把 realpath_cache_size 设为 0,从而停用 realpath 缓存。性能调优那一节里又重复了一遍。这句话平时翻手册很容易滑过去,但它就是整件事的全部答案。
原因在安全。realpath 缓存会把一次路径解析的结果存下来,下次遇到同一路径直接用缓存;而 open_basedir 的检查方式是把「解析后的真实路径」跟允许的目录前缀比对——如果这个解析结果来自缓存,就存在一个可以被利用的时间窗,符号链接可以在检查之后、打开之前被换掉。PHP 修 CVE-2006-5178 时选了最省事的做法:只要 open_basedir 生效,整个 realpath 缓存直接关掉。这个取舍记录在官方缺陷跟踪里,从 2006 年一直讨论到现在,社区的解法是让第三方扩展在保留 open_basedir 的前提下把缓存重新打开,而不是把这个行为改回去。
落到 WordPress 上,代价被放得很大。一个前台请求要 include 几百个文件,每个文件都要做一次路径解析:缓存关着时每次几十微秒,几百次就是几十毫秒;缓存开着时零点几微秒,可以忽略。而宝塔这类面板默认给每个站点都开着「防跨站攻击」,等于默认给所有站都加了这笔税——文件越多、目录越深,税越重。这也正好解释了开头那个现象:两个站的静态文件一样快,需要 PHP 的页面却一快一慢,差的其实是各自要解析多少条路径。
怎么改,以及这个开关真正的代价
关掉之后,两边的首页耗时都回到了这台机器历史最好水平。操作本身很简单:在面板的站点设置里把「防跨站攻击(open_basedir)」取消勾选,然后重启对应的 PHP-FPM。不同面板版本的入口略有差别,有的在「网站目录」页,有的在 PHP 版本的设置页,但本质是同一件事。只重载 nginx 不生效,一定要重启 PHP 服务。
有两个坑要提前知道。一是面板上关掉开关之后,站点根目录下的 .user.ini 可能还留着旧的 open_basedir 路径,迁移或复制过站点的尤其容易中招,改完顺手看一眼。二是把 open_basedir 写窄一点并不能两头都要——只写站点自己的目录同样是「生效」,同样会关掉 realpath 缓存,这个开关没有中间档。
代价也很实在:open_basedir 是同一台服务器上多个站之间的那道隔离墙。关掉它,等于承认这台机器上各站的 PHP 进程之间不再设防。如果机器上只有自己的站,这个取舍是划算的;如果还放着别人的站,或者允许别人上传并执行 PHP 文件,那就先把隔离方案换成容器或独立系统用户,再来关这个开关。
这套流程可以照搬到别的站
四个动作,跟具体技术栈关系不大:先分层计时拿到基线;再找一台同环境服务器上的对照组;然后把变量收敛到一个、做 A/B;最后去翻官方文档里那些被写成一句话 Note 的行为。第一条最省时间,它让你有资格反驳「感觉快了一点」;第二条最省力气,一步就把可疑范围的边界画出来;第四条最容易被跳过,而这次真正的根因,就藏在一句 Note 里。
附:本文信息来源
一、一手信息
PHP 官方手册 · open_basedir 与 realpath 缓存
- Description of core php.ini directives — open_basedir(「Using open_basedir will set realpath_cache_size to 0 and thus disable the realpath cache」一句的出处)
- Description of core php.ini directives — realpath_cache_size(性能调优一节里的同一条 Note)
- realpath_cache_get(自己写探针、把缓存条目数打出来的入口)
PHP 官方缺陷跟踪
- Bug #52312 · PHP safe_mode/open_basedir – lstat performance problem(从 CVE-2006-5178 的修法到 realpath 缓存的取舍,原始讨论都在这里)
在保留限制的前提下恢复缓存
- Whissi/realpath_turbo(用扩展重新打开 realpath 缓存;README 同时说明了它在共享主机环境下的风险)
二、延伸阅读
同类案例与实测数据
- Nexcess:PHP open_basedir: How to disable PHP open_basedir to improve Magento performance(单次商品页请求的 lstat 次数对照:25998 对 1155)
- jify.de:A Performance Pitfall in PHPs open_basedir option(strace 视角下 file_exists 触发的逐级目录回溯)
缓存本身怎么工作
- Stack Overflow:How does PHP realpath cache work under the hood?(进程内、非共享、按 TTL 老化)
文中数据均为本机实测,除注明外未采用第三方评测口径。
相关阅读
原创声明:本文由 618A 情报站整理撰写,基于公开信息与实测记录,转载请注明出处并保留原文链接。