PHP 性能优化,以及用 FastCGI 缓存加速 PHP 页面

这台图床跑的是 Laravel 全栈框架,程序侧的每一毫秒都要乘以图片请求量。折腾了大半年,把 PHP 版本、扩展、FPM、Nginx、FastCGI 缓存一层层捋下来,外链首字节从 800ms 压到 60ms 上下。这篇把踩过的坑和可落地的配置按层拆开讲,示例统一用去不图床(兰空企业版)这套环境。

一、先说清楚瓶颈在哪

图床这种业务有个特殊之处:写请求很少,读请求极多。

上传一张图,走的是完整的 Laravel 生命周期——鉴权、策略匹配、Imagick 处理、缩略图生成、写库、写盘、CDN 刷新,耗时可能好几秒。但用户实际访问图床,绝大部分时间花在浏览图片列表、看图片详情页、加载缩略图,这些是纯粹的只读请求。

去不图床现在的实际规模:托管图片 27 万张以上,磁盘占用 112GB 起步,CDN 覆盖 17 个节点,上行 100M、下行 700M。这种量级下,如果每个页面请求都完整走一遍 PHP + 数据库,FPM 进程池很快就会被打满。

优化之前,先把瓶颈定位清楚,别一上来就改配置。三个必看的指标:

listen queue 长期大于 0,说明 FPM 进程数不够,请求在排队;time_starttransfer 高但 time_total 也高,说明是 PHP 侧慢;如果 time_starttransfer 高、time_total 接近,那基本就是 PHP 前面的环节慢——这时候上 FastCGI 缓存才是对症下药。

理解这个差异很重要:命中缓存的请求,Nginx 直接把磁盘上的响应文件吐回客户端,不碰 PHP、不碰数据库;没命中的才走完整的 Laravel 生命周期。后面每一节都在解决「怎么让请求更多地走上前者」——PHP 层、数据库层的优化是让后者更快,FastCGI 缓存则是让前者变多。

二、PHP 层:版本、扩展与 OPcache

版本选择

兰空企业版 v1 官方要求 PHP >= 8.1,安装文档里推荐环境写的是 PHP 8.2+ / Nginx 1.21+ / MySQL 5.7+ / Redis 7.0+。不要低于 8.1,程序里用了大量 8.x 的语法特性和枚举类。

8.2 相比 8.1 在这条链路上有实际收益:性能提升主要来自对象内联优化、只读类、类型化常量,ReadOnly 类的引入让模型层的数据传递少了一层复制开销。对图床这种模型层调用密集的应用,这个改动比调 FPM 参数更实在。

Imagick 是真正的瓶颈

文档里专门有一页讲 Imagick 扩展,这个不是 PHP 预装的。兰空的所有图片处理——压缩、缩略图、水印、格式转换——全部走 Imagick。装的时候必须确认 ImageMagick 库本身支持你需要的格式:

常见坑:装完程序发现 WebP 上传失败,八成是 ImageMagick 编译时没带 libwebp,得回去重新编译。

更重要的一个认知——水印是内存杀手。文档里说得很直接:一个 3MB 的 JPEG 合成水印,过程中可能占用 60MB 以上内存;PHP 默认 memory_limit = 128M,很容易直接 OOM。所以图床用 CDN 图片处理做水印,而不是让 PHP 扛。

OPcache:必开,没得商量

企业版文档在「优化加载速度」那页把 OPcache 标成了推荐,和 Laravel Octane 并列。翻译成人话就是:Octane 是实验性、有副作用、官方自己都在劝退,OPcache 是无脑开、纯收益。

几个参数说明一下为什么这么配:

  • max_accelerated_files 必须调。默认 20000 对 Laravel 项目是不够的——一个中等规模的图床站点,vendor/ 加上 storage/framework/views 编译产物轻松过两万。淘汰机制一旦触发,缓存命中率掉到 50% 以下,OPcache 基本白装。
  • memory_consumption 给 256M。编译后的 PHP 文件全存在这块共享内存里,给小了就是不断重编译。
  • validate_timestamps 生产环境关掉。关掉后 PHP 彻底不 stat 文件,加载速度能再快一截。代价是改代码不生效,必须配合 php artisan optimize(下面会讲)和 reload。而且要注意:升级图床程序后一定要先清 OPcache 再 reload FPM,否则会出现「后台显示新版本,页面还是旧模板」的鬼故事。

JIT 那一段需要说明一下边界:JIT 对图床这种 IO 密集 的场景收益有限,因为瓶颈不在 PHP 的 CPU 指令执行,而在 Imagick 和磁盘。开启成本很低,可以开;但别指望 JIT 带来数量级的提升,它真正的用武之地是纯计算型负载(大量数学运算、序列化、加密)。

框架侧的编译缓存

企业版文档给的常规优化方案是三条命令,一条都别漏:

执行后 .env 的改动不再生效,改配置必须 php artisan config:clear 重新生成。这三条合起来在图床这种 Laravel 应用上通常能省掉 30%~50% 的框架启动时间。

顺带说个坑:如果你开了 Octane(文档也标了实验性),别在生产环境用。文档原话是「可能会对站点产生一些奇怪的副作用」——Laravel 的文件缓存、Session 锁、数据库连接复用都会和常驻进程打架。图床这种带用户登录、购物车、订单的站点,风险只会更高。

三、数据库与查询优化

连接与索引

图床的数据表设计是文档里明确提过的优势:「开发初期精心设计了数据表结构,开发过程中也针对各种 SQL 语句优化」。但这不代表你不用管索引——真实用户的自定义相册、Token 分享、套餐订购这些查询,基数和数据分布跟开发测试时完全不同。

先看慢查询有没有开:

然后对图床最热的几个查询做实测。最典型的三个:

图床的图片列表是典型的「深分页 + 多条件」组合。offset 到几万之后,LIMIT 30000, 30 会让数据库先扫三万行再丢掉三万行。用游标分页替换:

这个改动对图片管理页(第 500 页往后)通常是几十倍的差距,因为查询从 扫描 15000 行 变成了 扫描 30 行。

Redis 缓存

兰空要求装 Redis 7.0+,这是硬性依赖不是可选项。图床里 Redis 承担两类活:队列(emails / images / thumbnails 三个队列)、以及各类的短期缓存。

值得单独做缓存的热点:

  • 角色组配置:用户的角色组、上传限制、命名规则、水印配置。改一次全站生效,但 99.99% 的请求只读。缓存起来零成本,管理员改配置时主动 del 掉即可。
  • 存储策略元数据:Bucket 配置、访问 URL 前缀、区域信息。也是读多写极少。
  • 图片详情页的元数据:hash → 图片元信息。图片本身是不可变的(改了就是新文件),所以这个缓存没有失效问题,可以放心设长 TTL。

要注意 Redis 的 maxmemory-policy 一定要设成 allkeys-lru 或者 volatile-lru。默认的 noeviction 在内存打满时会直接写失败,队列任务堆积、图片处理失败,排查起来很费时间。

四、图片上传与存储优化

这一层是图床区别于普通网站的地方,也是收益最大的一层。

上传链路

兰空的上传流程是靠队列异步化的:上传时把大图直接写盘,元信息入库,然后往 images 和 thumbnails 队列各投一个任务。这套设计是对的——慢活交给队列,快活立刻返回。Supervisor 配置文档里给了:

这里 numprocs=1 值得单独说。图床的瓶颈是 Imagick(CPU 密集),如果你用云主机的多核,把 numprocs 调到 核数 - 2,并按队列优先级拆成多个进程组:

thumbnails 优先级最高(用户上传完立刻要看到缩略图),给它更多进程。注意每次升级程序后队列会自动安全退出,必须手动重启 Supervisor,否则会出现「上传成功但缩略图不生成、邮件发不出去」。

存储介质:别让回源打在系统盘上

去不图床的实际配置是 512G NVMe + 1024G 企业级 SSD 双盘。这个配置对图床来说是关键,不是奢侈——图片量到 27 万张、112GB 之后,磁盘的 IOPS 直接决定回源速度。

用 lsblk 确认挂载关系,把图片目录放在 NVMe 上:

lsky:symlinks 这条命令文档里有专门一节——它重建程序需要的符号连接。本地存储策略必须有一个域名后缀(比如 http://7bu.top/uploads/xxx.png 里的 uploads),这个后缀对应的就是 public 目录下的符号连接。本地存储 + Nginx 直出,是图床能跑快的前提,后面讲 FastCGI 缓存时会重点用到。

格式与体积:WebP 是白捡的收益

图片列表页、缩略图,用户看到的应该是压缩后的版本而不是原图。兰空企业版的策略管理里可以配置 WebP 输出,webp 支持 jpeg 级别的视觉质量、体积通常能小 25%~35%。

CDN 图片处理(比如又拍云、七牛、阿里云 OSS 的图片处理)比源站 PHP 处理划算得多,理由文档里也讲了:水印、压缩这类操作交给 CDN,源站的 CPU 和内存就解放出来了。源站只存原图 + 一份缩略图就够。

防盗链与图片鉴黄

图床是典型的「被盗链就白干」场景。兰空的角色组里有原图保护功能,文档里对它的性能代价有明确警告:

原图保护功能对服务器配置要求较高,虽然有缓存驱动,原理还是从内存中读取二进制内容,且每次都将图片内容读取到内存中,对服务器内存有着比较高的要求。这种负载压力在图片请求量(除了缩略图)过多的时候尤为明显。

翻译一下:开了原图保护,图片请求就从 Nginx 直出变成 PHP 处理了。原图请求量大的站点,这一个功能能把 FastCGI 缓存完全废掉,因为根本没有页面可以缓存——缓存的是图片字节流。

实际建议:缩略图不开原图保护(走 Nginx 直出 + CDN),只在「Token 分享」「用户私有相册」这类需要鉴权的场景开原图保护。这样公共图片走 CDN,私有图片的 PHP 开销在可接受范围内。

分布式的三个动作

去不图床在 2024 年 5 月上了海外节点,现在是 17 个 CDN 节点。站在运维视角看,图片加速的动作就三个:

  1. CDN 缓存策略分层:缩略图设长 TTL(30 天甚至永久,因为内容不可变),原图设短一些,或者走「缓存 + 后台刷新」;
  2. 回源走内网:如果 CDN 源站指向的是同一台机器的公网 IP,改成内网回源能省掉带宽费和网络延迟;
  3. 热点保护:把最近上传、图片广场这类高频访问的目录单独设规则,避免被批量爬取打穿。

五、FPM 与 Nginx

FPM 进程池

图床这台机器是 16 核 32G,按下面配:

max_children 别乱调大。32G 内存看着很富裕,但 Imagick 处理大图时单个进程可能吃到几百 MB,32 个并发满负荷跑就爆了。如果发现 502 或 FPM 进程莫名重启,先看是不是内存打满:

Nginx 与 FastCGI 的基础配置

fastcgi_pass 用 unix socket 而不是 TCP 端口,省掉一次 TCP 往返和协议解析。本机通信走 socket 是标准做法。

注意最后那行 fastcgi_param HTTP_PROXY "",这是下一节的前提——设置登录用户信息,才能在 skip_cache 里把它们排除掉。

六、FastCGI 缓存:图床加速的核心

为什么图床特别适合 FastCGI 缓存

FastCGI 缓存缓存的是 PHP 的完整输出(也就是渲染好的 HTML),由 Nginx 存到磁盘,下次同样的请求直接吐文件,不碰 PHP、不碰数据库。

图床页面有两个特性让它特别适合:

  1. 内容几乎不变。图片列表页的图片数量在用户下一次上传前不会变;存储配置页、套餐页更不用说了。变化频率是「每次用户操作」,不是「每分钟」。
  2. 匿名可访问的页面占比很高。首页、图片广场、公开相册、图床介绍页——这些页面的输出对所有访客完全一致。

与之相对,必须排除的:

  • 带登录态的页面(用户控制台、管理后台、订单页)
  • 任何带 CSRF token 的表单页
  • API 接口
  • 支付回调、邮件验证跳转

开启方式

几个参数的取舍:

  • levels=1:2:缓存文件按 2 层目录散开。100 万个文件全堆在一个目录里,inode 会很难看。
  • keys_zone=50m:这是索引区大小(1MB 约存 8000 个 key),不是缓存大小。50MB 大约能索引 40 万个页面。图床的公开页面组合(不同分页 × 不同排序)实际不会太多,20m 也够,但富余一点没坏处。
  • max_size=20g:磁盘里缓存数据的总量上限,放 NVMe 上。图床的图片目录都放 NVMe 了,缓存没理由放系统盘。
  • inactive=7d:7 天没被访问的缓存条目自动清理。不设的话会被 LRU 规则慢慢清,但显式设更可控。

max_size 和 keys_zone 算错是这个配置最常见的翻车点——keys_zone 太小会不停淘汰(命中率上不去),max_size 太大则磁盘写满导致服务异常。

server 块的完整配置

逐条解释那些容易被抄错的参数

fastcgi_cache_bypass vs fastcgi_no_cache

这俩长得像但作用不同:

  • bypass:读缓存前先问一下,条件成立就跳过读缓存,但仍然把请求转给 PHP;
  • no_cache:条件成立时不把这次的响应写进缓存。

只写 bypass 是最常见的错误配置——登录用户看到的页面虽然不会读缓存,但别人的私有页面内容会被存进缓存,下一个匿名访客直接命中。这个是安全事故,不是性能问题。所以登录态和后台必须两个都写。

fastcgi_cache_use_stale

这个参数决定后端出问题时是否还喂旧数据。图床的访问高峰常出现在用户集中上传之后,此时 PHP 和队列都在满负荷,配上这行:

意思是「后端出错时用旧的」,updating 更进一步——后台正在异步更新缓存时,也允许继续服务旧内容。

fastcgi_cache_lock on

这个参数值得重点说。图床的 /images?page=1 这种页面,缓存过期的那一瞬间可能有几十个并发请求同时到达。没有 lock 的话,这几十个请求全部穿透到 PHP,等于制造一次自发的 DDoS。开启后第一个请求去回源,其余请求等待,然后直接用它的结果。配合 fastcgi_cache_lock_age 防止死锁。

fastcgi_cache_revalidate

开启后,过期条目在回源前会先发一个 If-Modified-Since / If-None-Match。后端返回 304 就不传完整 body,省带宽。图床的图片元信息带更新时间,这个命中率挺高。

验证缓存是否真的生效

配置完必须验证,不能靠猜:

add_header X-Cache-Status $upstream_cache_status always; 这行是专门用来调试的,HIT / MISS / EXPIRED 三个值一目了然。上线后可以留着,观察一段时间再决定要不要摘掉(部分用户不喜欢暴露缓存状态)。

缓存失效策略

30 分钟的 TTL 是个保守起点,实际怎么定:

场景建议 TTL说明
首页5 分钟更新不算频繁,但用户敏感
图片广场10 分钟有新图会想看到
图片列表 / 相册30 分钟单用户自己的图,不急
存储配置、套餐介绍12 小时基本不变
4041 分钟短到足以防穿透,长到不误伤

过期后内容不立刻更新怎么办? 三个手段:

  1. 发版时手动清缓存:
  1. 利用 fastcgi_cache_revalidate:过期后先发条件请求,内容没变就只回 304,用户体感上几乎无感。
  2. 主动推送刷新:图床每次上传新图后,如果希望广场页立即可见,可以调 purge 接口(如果用了第三方 CDN)。源站的 FastCGI 缓存没这个能力,只能等 TTL。

原图保护与 FastCGI 缓存的冲突

这是图床特有、最需要注意的一点。

前面提过:开了原图保护后,图片请求由 PHP 处理输出。而 FastCGI 缓存针对的是 \.php$ 的页面请求——如果图片也走 PHP 转发,它就会被当成页面缓存,命中的缓存里存的是某个用户的图片内容,这对私有相册就是严重的数据泄露。

所以:开了原图保护的图片请求,必须显式排除在 FastCGI 缓存之外:

更稳妥的做法是从架构上隔开:公共图片走 location /uploads/ 由 Nginx + CDN 处理,私有图片走独立的 PHP location 并强制 fastcgi_cache off。前者吃缓存红利,后者走实时鉴权,两条路径互不干扰。

千万别给上传接口加缓存

图床的 API 是接口上传的主力入口(文档里专门写了「支持通过接口上传、管理图片、管理相册,接口支持设置权限」)。给 POST 接口加缓存是自找麻烦——轻则上传后图片不显示,重则数据错乱。

七、优化前后的对比

在对比之前,先把兰空的图片处理链路摆清楚,因为所有优化都是围绕它展开的。

链路大致是这样:原始图片进来,先过格式与内容校验(校验用的是腾讯云内容安全 / 阿里云内容安全 / 自建 NsfwJS 三选一);然后交给 Imagick 做压缩和缩略图生成——这两步是 CPU 和内存消耗的大头,所以它们被放进 images 和 thumbnails 队列异步执行,而不是卡在上传请求里等;最后落盘并推送到 CDN。

看清楚这条链路,就明白优化的两个方向:

  1. 上传链路(写):靠队列拆分、OPcache、多进程把耗时压下来——这是第一节到第五节的内容;
  2. 访问链路(读):靠 CDN + Nginx 直出 + FastCGI 缓存把耗时压下来——这是第六节的内容。

各层的收益是逐层叠加的:PHP 版本和 OPcache 给的是基本盘,FPM 和 Redis 解决的是运行时和数据层的瓶颈,FastCGI 缓存是最后也是最陡的一段——因为它把「读请求」这件事整体从 PHP 里摘出去了。单独看任何一层都不如最后一层,但前面的层不做,最后一层也救不回来上传链路。

对比的时候别只看平均值,要看 P99。平均 TTFB 300ms 的站点,可能 P99 是 3 秒——图床用户抱怨的永远是那 3 秒。压测用 wrk 或 hey 跑,并且分场景测:

三组结果对照着看,才能定位到底是哪一层还有空间。典型的对比维度:

指标优化前优化后主要贡献项
首页 TTFB800ms60msFastCGI 缓存
图片列表 P993.2s180msFastCGI 缓存 + 键集分页
缓存命中率—92%+fastcgi_cache_lock + revalidate
图片回源 TTFB450ms25ms本地存储 Nginx 直出 + CDN
上传 P956.5s3.1sOPcache + 多队列进程 + WebP
FPM 峰值进程32(打满)14缓存削峰

最后一行是缓存最容易被低估的价值:它不只降低响应时间,还削平了峰值。原来 32 个进程全满才算扛住,现在平均只用 14 个,意味着同样的机器能扛 2~3 倍的访问量——这是量变到质变的那种收益。

八、完整的落地检查清单

按顺序做,每步做完验证一次:

  1. 确认 PHP 版本 ≥ 8.2,检查必需扩展齐全:php -m | grep -E 'fileinfo|imagick|openssl|PDO|redis|pcntl|zip'
  2. 确认 Imagick 支持 WebP:identify -list format | grep webp
  3. 开启 OPcache,validate_timestamps=0 + max_accelerated_files=30000
  4. 执行三条缓存命令:config:cache、route:cache、view:cache
  5. 配置 FPM 进程池,pm.max_children 按 IO 密集型算,加 pm.status_path
  6. 开启 Supervisor 多进程队列,拆分 thumbnails / images 优先级
  7. 确认图片目录在 NVMe,跑一次 php artisan lsky:symlinks
  8. 配置 FastCGI 缓存区,放在 http 块,注意 keys_zone 和 max_size 别算错
  9. 写好缓存规则:登录态 + API + 原图保护路由全部 bypass + no_cache
  10. 开 fastcgi_cache_lock,这是防止缓存失效被打穿的保险丝
  11. 用 X-Cache-Status 头验证命中率,达不到预期就调 TTL 和 skip 规则
  12. 压测三个场景(匿名 / 登录 / 图片直出),对比 P99 而不是平均值

九、几个容易踩的坑

缓存了不该缓存的内容。 最严重的一次性问题。登录态判断只看 Authorization 头是不够的,图床用 Cookie 维持会话,必须同时检查 Cookie。宁可多 bypass,也别少。

升级程序后忘了清缓存。 兰空支持在线增量更新,更新完 config:cache / route:cache 都变了,OPcache 里还留着旧的,结果就是后台改了配置前台没变化。标准动作:

fastcgi_cache 和 proxy_cache 搞混。 proxy_cache 管的是 Nginx 作为反向代理时的响应,fastcgi_cache 才是 PHP 页面的。图床所有动态页面都走 FastCGI,用错指令等于没配。

CDN 和 FastCGI 缓存双层缓存的顺序问题。 两层都在缓存时,回源次数取决于哪层先失效。图床的做法是:CDN 缓存图片(不可变,TTL 长),源站 FastCGI 缓存页面(短 TTL)。不要给页面同时上两层 CDN + FastCGI 缓存,回源路径太长,排查困难。

把 CDN 源站指向公网 IP。 同一台机器有内网地址时,CDN 回源走内网能省带宽费和一跳延迟。

十、写在最后

图床这类业务的优化有一条隐性原则:把重活挪到写路径,把读路径做薄。

Imagick 压缩、缩略图生成、水印合成——这些 CPU 和内存密集的操作,全部发生在上传那一刻,通过队列异步消化。用户访问的时候,理想状态是请求根本碰不到 PHP:图片走 CDN,公共页面走 FastCGI 缓存,命中的时候 Nginx 直接吐文件。

优化的天花板不在配置里,在架构里。当缓存命中率能稳定在 95% 以上、FPM 进程占用长期低于一半,你会发现真正的收益是同一台机器能服务多得多的用户——这比任何单点的毫秒优化都有价值。

如果后续要继续折腾,两个方向值得投入:把 CDN 的图片处理规则接上(替代源站的水印和压缩),以及关注 Laravel Octane 在 v2 版本上的成熟度——文档里那句「实验性」有摘掉的一天。

最后回到验证这件事。判断 FastCGI 缓存配得对不对,不用猜,看 X-Cache-Status 响应头就够了:命中是 HIT,未命中回源是 MISS,过期正在更新是 UPDATING。这三个值的分布,直接告诉你 TTL 定得合不合理、skip 规则是不是写太严了。

参与讨论