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

一、先说清楚瓶颈在哪

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

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

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

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

1
2
3
4
5
6
7
8
# 1. 单个 PHP 请求的耗时分布(90% 的请求在哪个区间)
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s Total: %{time_total}s\n" https://7bu.top/

# 2. FPM 进程状态:pm.status_path 有没有开,开了一起看
curl -s http://127.0.0.1/status?full | grep -E "active processes|idle processes|listen queue"

# 3. 慢日志——Laravel 侧看具体是哪个环节慢
tail -f /www/wwwroot/lsky-pro/storage/logs/laravel.log | grep -i "query"

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 库本身支持你需要的格式:

1
2
3
4
5
6
7
8
# 看所有支持的格式
identify -list format

# 重点确认 webp(WebP 能省 25%~35% 体积,博客配图首选)
identify -list format | grep webp

# 确认版本,ImageMagick 7 内存占用和处理速度都优于 6
convert --version

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

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

OPcache:必开,没得商量

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

1
2
3
4
5
6
7
8
9
10
11
12
; /etc/php/8.2/fpm/php.ini 或 www.conf
[opcache]
opcache.enable=1
opcache.memory_consumption=256 ; 图床给 256MB,别用默认 128
opcache.interned_strings_buffer=32 ; 字符串驻留,Laravel 模板里大量重复字符串
opcache.max_accelerated_files=30000 ; Laravel + Vendor 文件多,默认 20000 会频繁淘汰
opcache.max_wasted_percentage=10
opcache.validate_timestamps=1 ; 开发阶段开,生产环境见下文
opcache.revalidate_freq=60 ; 60 秒检查一次文件变更
opcache.fast_shutdown=1 ; 收尾阶段优化
opcache.jit=tracing
opcache.jit_buffer_size=64M

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

  • 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 带来数量级的提升,它真正的用武之地是纯计算型负载(大量数学运算、序列化、加密)。

框架侧的编译缓存

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

1
2
3
4
5
6
7
8
9
10
cd /www/wwwroot/lsky-pro

# 配置缓存:把 .env 和所有 config 文件合并成一个 PHP 数组文件
php artisan config:cache

# 路由缓存:几百条路由不再逐个解析
php artisan route:cache

# 视图缓存:Blade 模板预编译,运行时不再做正则解析
php artisan view:cache

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

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

三、数据库与查询优化

连接与索引

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

先看慢查询有没有开:

1
2
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log = 'ON';

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

1
2
3
4
5
6
7
8
9
10
-- 图片列表页:按用户 + 分页 + 排序
EXPLAIN SELECT * FROM images WHERE user_id = 42 AND is_deleted = 0
ORDER BY created_at DESC LIMIT 30;

-- 图片详情:按 hash 查
EXPLAIN SELECT * FROM images WHERE hash = 'abc123' AND is_deleted = 0;

-- CDN 刷新型查询:按更新时间范围扫
EXPLAIN SELECT * FROM images WHERE updated_at > '2026-11-01'
AND storage_strategy_id = 1;

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 改之前:offset 分页,深分页下线性劣化
$images = Image::where('user_id', $uid)->offset($page * 30)->limit(30)->get();

// 改之后:基于上一页最后一条的 created_at + id 做键集分页
$images = Image::where('user_id', $uid)
->where(function ($q) use ($cursor) {
$q->where('created_at', '<', $cursor->created_at)
->orWhere('created_at', $cursor->created_at)
->orWhere(function ($q2) use ($cursor) {
$q2->where('created_at', $cursor->created_at)
->where('id', '<', $cursor->id);
});
})
->orderByDesc('created_at')->orderByDesc('id')
->limit(30)->get();

这个改动对图片管理页(第 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 配置文档里给了:

1
2
3
4
5
[program:lsky-pro-worker]
command=php artisan queue:work --queue=emails,images,thumbnails
directory=/www/wwwroot/lsky-pro/
numprocs=1
autorestart=true

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

1
2
3
4
5
6
7
[program:lsky-thumbnails]
command=php artisan queue:work --queue=thumbnails --sleep=1 --tries=3
numprocs=4

[program:lsky-images]
command=php artisan queue:work --queue=images --sleep=1 --tries=3
numprocs=2

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

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

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

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

1
2
3
4
5
# 图床本地存储策略的物理路径,务必和系统盘分开
ln -s /data/nvme/lsky-uploads /www/wwwroot/lsky-pro/public/uploads

# 建完链接后跑一次校验
cd /www/wwwroot/lsky-pro && php artisan lsky:symlinks

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,按下面配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
; /etc/php/8.2/fpm/pool.d/www.conf
[www]
user = www
group = www
listen = /run/php/php8.2-fpm.sock
listen.owner = www
listen.group = www
listen.mode = 0660

; 进程数怎么定:图床是 IO 密集型(大部分请求在等 Imagick 和磁盘)
; 起高了 CPU 上下文切换损耗大,起低了请求排队
pm = dynamic
pm.max_children = 32 ; 约为核数的 2 倍,IO 密集型别按 CPU 密集型的公式算
pm.start_servers = 8
pm.min_spare_servers = 8
pm.max_spare_servers = 16
pm.max_requests = 500 ; 500 次后回收,防止内存泄漏累积

; 图床特有:缩略图和原图保护会吃内存,给 PHP 留够
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 32M
php_admin_value[post_max_size] = 32M
php_max_input_vars = 3000

; 慢请求追踪,配合 pm.status_path 一起看
pm.status_path = /status
request_terminate_timeout = 60s

; OPcache 是 FPM 级别的,挂在 pool 里更清晰
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.memory_consumption] = 256

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

1
grep -i "allowed memory size" /var/log/php8.2-fpm.log | tail -5

Nginx 与 FastCGI 的基础配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
server {
listen 80;
server_name 7bu.top;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl http2;
server_name 7bu.top;

ssl_certificate /etc/nginx/ssl/7bu.pem;
ssl_certificate_key /etc/nginx/ssl/7bu.key;
ssl_protocols TLSv1.2 TLSv1.3;

root /www/wwwroot/lsky-pro/public;
index index.php;

client_max_body_size 32m;

# 图片上传的日志单独开,方便排查慢上传
location ~* \.(jpg|jpeg|png|gif|webp|avif|ico)$ {
access_log off;
log_not_found off;
expires 30d;
add_header Cache-Control "public, immutable";
}

# 静态资源直接由 Nginx 出,不进 PHP
location ~* \.(css|js|woff2?|ttf|eot|svg|map)$ {
expires 7d;
add_header Cache-Control "public";
access_log off;
}

# 伪静态,安装文档里给的标准配置
location / {
try_files $uri $uri/ /index.php?$query_string;
}

location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;

include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;

# 图床后端 API 调用多,超时给足
fastcgi_connect_timeout 10s;
fastcgi_read_timeout 60s;
fastcgi_send_timeout 60s;

# 后端调用不过 CDN 缓存(下一节的 skip 判断靠这几个参数)
fastcgi_param HTTP_PROXY "";
}
}

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 接口
  • 支付回调、邮件验证跳转

开启方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 放在 http 块里,全局定义缓存区
proxy_cache_path /data/nvme/nginx-cache levels=1:2 keys_zone=lsky_cache:50m
inactive=7d max_size=20g
use_temp_path=off;

# 图床需要缓存的页面域
map $uri $lsky_cache_uri {
default 0;
/ 1; # 首页
/explore 1; # 图片广场
/images 1; # 图片列表
/galleries 1; # 相册
~^/storage/ 0;
~^/admin/ 0;
~^/api/ 0;
}

几个参数的取舍:

  • 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 块的完整配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
server {
listen 443 ssl http2;
server_name 7bu.top;

root /www/wwwroot/lsky-pro/public;
index index.php;

# ---- FastCGI 缓存核心 ----
fastcgi_cachelsky_cache;
fastcgi_cache_valid 200 30m; # 缓存有效期
fastcgi_cache_valid 404 1m; # 404 短缓存,防止不存在的路径反复穿透
fastcgi_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
fastcgi_cache_background_update on;
fastcgi_cache_methods GET HEAD;
fastcgi_cache_bypass $lsky_cache_uri $cache_bypass;
fastcgi_no_cache $cache_bypass;
fastcgi_cache_lock on; # 防止缓存失效瞬间大量请求同时打到 PHP
fastcgi_cache_lock_timeout 10s;
fastcgi_cache_lock_age 10s;
fastcgi_cache_revalidate on; # 过期后先回源确认,省流量

client_max_body_size 32m;

# 登录用户、后台、API 一律不缓存
map $http_cookie $cookie_logged {
default 1;
~*remember_web_.* 0; # Laravel Remember Me
~*lsky_session 0;
}

location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";

# 触发 bypass 的三个条件
set $cache_bypass 0;
if ($cookie_logged = 1) { set $cache_bypass 1; }
if ($http_authorization != "") { set $cache_bypass 1; }
if ($request_method = POST) { set $cache_bypass 1; }

add_header X-Cache-Status $upstream_cache_status always;
}

# 图片直出:本地存储策略的图片由 Nginx 直接发,完全不碰 PHP
location /uploads/ {
alias /data/nvme/lsky-uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
log_not_found off;
try_files $uri =404;
}
}

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

fastcgi_cache_bypass vs fastcgi_no_cache

这俩长得像但作用不同:

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

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

fastcgi_cache_use_stale

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

1
fastcgi_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

意思是「后端出错时用旧的」,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,省带宽。图床的图片元信息带更新时间,这个命中率挺高。

验证缓存是否真的生效

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

1
2
3
4
5
6
7
8
# 第一次请求通常是 MISS(回源并写缓存)
curl -sI https://7bu.top/images | grep -i "x-cache-status"

# 第二次应该 HIT
curl -sI https://7bu.top/images | grep -i "x-cache-status"

# 观察命中率累积
# nginx stub_status 里看 HIT/MISS 比例,MISS 高说明缓存规则太严或页面太动态

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

缓存失效策略

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

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

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

  1. 发版时手动清缓存:
1
2
3
4
5
6
# 找到 keys_zone 对应的缓存目录,全清
rm -rf /data/nvme/nginx-cache/*

# 或者更温和——只清某个 URI 的
# 先算出 key,再删对应文件
curl -sI https://7bu.top/images | grep -i x-cache-status
  1. 利用 fastcgi_cache_revalidate:过期后先发条件请求,内容没变就只回 304,用户体感上几乎无感。

  2. 主动推送刷新:图床每次上传新图后,如果希望广场页立即可见,可以调 purge 接口(如果用了第三方 CDN)。源站的 FastCGI 缓存没这个能力,只能等 TTL。

原图保护与 FastCGI 缓存的冲突

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

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

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

1
2
3
4
5
6
7
8
location ~* \.php$ {
# 原图保护的路由(按实际路由调整)
if ($uri ~ "^/origin/") { set $cache_bypass 1; }

fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
add_header X-Cache-Status $upstream_cache_status always;
}

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

千万别给上传接口加缓存

1
2
3
4
5
6
7
# 上传、删除、订单、支付回调——写操作零缓存
location /api/ {
fastcgi_cache off;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}

图床的 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 跑,并且分场景测:

1
2
3
4
5
6
7
8
9
# 场景 A:匿名访问图片列表(FastCGI 缓存应该命中率极高)
hey -n 5000 -c 50 -m GET https://7bu.top/images

# 场景 B:登录用户的控制台(缓存应该完全绕过,看 PHP 真实性能)
hey -n 2000 -c 20 -m GET https://7bu.top/admin/dashboard \
-H "Cookie: lsky_session=xxx"

# 场景 C:图片直出(看 Nginx + 磁盘,不经过 PHP)
hey -n 10000 -c 100 -m GET https://7bu.top/uploads/2026/11/example.webp

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

指标优化前优化后主要贡献项
首页 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 里还留着旧的,结果就是后台改了配置前台没变化。标准动作:

1
2
3
4
5
cd /www/wwwroot/lsky-pro
php artisan optimize:clear
rm -rf /data/nvme/nginx-cache/*
sudo supervisorctl restart lsky-pro-worker:*
systemctl reload php8.2-fpm

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 规则是不是写太严了。

站内搜索

没有找到内容!