Varnish:把 PHP 从请求链路上"摘出去"的 HTTP 加速器
网站变慢,绝大多数情况下慢的不是 Nginx,也不是网络,而是"每个请求都要叫醒 PHP,叫醒 PHP 就要连数据库"。一次首页请求要走 PHP-FPM、查十几条 SQL、拼模板、渲染输出——即使每次都优化过,这个流程也要几十毫秒。Varnish 的思路很直接:如果这个页面对所有访客都是同一份内容,为什么每次都要重新生成?把生成好的整页 HTML 存进内存,下一个访客直接拿走,PHP 和数据库全程不用参与。
这就是 Varnish 和 Nginx 的 proxy_cache、以及 Memcached 这类"对象缓存"的根本区别。Nginx 缓存把内容存在磁盘(或内存 zone),规则相对简单;Memcached 缓存的是应用主动存进去的"数据片段",需要改代码;而 Varnish 缓存的是完整的 HTTP 响应,用一套叫 VCL 的领域语言让你精细控制"什么能缓存、缓存多久、什么条件下绕过"。对个人站长来说,Varnish 最实际的价值是:不改一行业务代码,就能把动态站的 TTFB 从几百毫秒压到几毫秒。
架构位置:Varnish 应该站在哪里
Varnish 是一个反向代理,它必须挡在 Web 服务器前面。标准链路是:
访客 → Varnish(:80/:443 前置) → Nginx(:8080 后端) → PHP-FPM → MySQL注意端口的变化:Varnish 抢占了 80/443,Nginx 必须退到高位端口(如 8080)。这是首次部署最容易卡住的地方——如果你直接 apt install varnish 然后启动,会发现 Nginx 和 Varnish 抢 80 端口导致其中一个起不来。
# 修改 Nginx 监听端口(/etc/nginx/sites-enabled/你的站.conf)
# listen 80; → listen 127.0.0.1:8080;
# 修改 Varnish 后端指向 Nginx(/etc/varnish/default.vcl)
backend default {
.host = "127.0.0.1";
.port = "8080";
}
# Varnish 监听端口(/etc/default/varnish 或 /etc/varnish/varnish.params)
# DAEMON_OPTS="-a :80 -T 127.0.0.1:6082 -f /etc/varnish/default.vcl -s malloc,256m"现代发行版(Varnish 6/7)多半用 systemd,配置在 /lib/systemd/system/varnish.service 的 ExecStart 里,通过 systemctl edit varnish 做 override 更安全,避免升级时被覆盖。
VCL 不是配置,是程序:理解它的两个函数
VCL(Varnish Configuration Language)看着像配置,实际会被编译成 C 再编译成共享对象。写错一个分号,Varnish 就启动失败。它的核心是两个函数:vcl_recv(请求进来时)和 vcl_backend_response(后端返回时)。
vcl_recv 决定"这个请求要不要走缓存"。默认行为下,只要请求带 Cookie 或者是 POST,Varnish 就会 pass(直接转发给后端不缓存)——这对带登录态的站点是必要的,但也意味着如果你站点的每个响应都带 Cookie,Varnish 的缓存命中率会是 0。所以第一步永远是:把这些无关的 Cookie 在 Varnish 层剥掉。
vcl 4.1;
backend default {
.host = "127.0.0.1";
.port = "8080";
}
sub vcl_recv {
# 只保留真正需要的 Cookie(会话、登录),其余全部清除
# 很多站点被统计代码、CDN 探针塞了一堆无意义 Cookie,它们会毁掉命中率
if (req.http.Cookie) {
set req.http.Cookie = ";" + req.http.Cookie;
set req.http.Cookie = regsuball(req.http.Cookie, "; +", ";");
set req.http.Cookie = regsuball(req.http.Cookie, ";(PHPSESSID|wordpress_logged_in_[^=]*)=", "; \1=");
set req.http.Cookie = regsuball(req.http.Cookie, ";[^ ][^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "^[; ]+|[; ]+$", "");
if (req.http.Cookie == "") {
unset req.http.Cookie;
}
}
# 已登录/已评论的访客不缓存(WordPress 场景示例)
if (req.http.Cookie ~ "wordpress_logged_in") {
return (pass);
}
# 后台、购物车、结账等路径永不缓存
if (req.url ~ "^/(wp-admin|wp-login\.php|cart|checkout)") {
return (pass);
}
# 只缓存 GET 和 HEAD,其余方法直接放过
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
return (hash);
}然后 vcl_backend_response 决定"这个响应该缓存多久":
sub vcl_backend_response {
# 后端是 PHP 时,PHP 端会发 Cache-Control,我们以它为准
# 但给一个兜底 TTL,防止 PHP 没发缓存头时把整页设成不缓存
if (beresp.ttl <= 0s) {
set beresp.ttl = 120s;
}
# 有 Set-Cookie 的响应默认不能进共享缓存(会串号),除非确认安全
if (beresp.http.Set-Cookie) {
return (pass);
}
# 静态资源给长 TTL
if (bereq.url ~ "\.(css|js|png|jpg|jpeg|gif|webp|svg|woff2)$") {
set beresp.ttl = 7d;
unset beresp.http.Set-Cookie;
}
return (deliver);
}三条会让缓存失效的隐形规则
配好 VCL 之后命中率还是上不去,九成是踩了下面三条。
第一,响应带了 Set-Cookie。只要后端在响应头里放了 Set-Cookie,Varnish 默认就认为"这个响应因人而异",不会缓存。很多 PHP 程序(尤其是框架)会在每个响应里塞一个 session cookie,即使访客没登录。要让整页缓存生效,必须在未登录时通过 PHP 或 VCL 把无关的 Set-Cookie 去掉。
第二,请求带 Authorization 头或 Cookie。见前面的 vcl_recv,Cookie 必须清理到"无状态"才可能命中。
第三,命中的 hash 里混入了变化的成分。Varnish 默认用 URL 做 hash(return(hash) 时),但如果你在 vcl_hash 里加了 User-Agent 或 Cookie,同一个页面就会被拆成无数份缓存,命中率归零。检查 vcl_hash,确保里面除了 req.url 和必要的 host 之外不加别的。
看命中率:varnishstat 才是裁判
Varnish 自己记录所有请求的命中情况,这是判断配置对错的唯一权威:
varnishstat
# 关键几行(括号内是社区版/商业版的叫法差异):
# MAIN.cache_hit 命中缓存直接返回的次数
# MAIN.cache_miss 未命中,转发给后端的次数
# MAIN.cache_hitpass 命中但被 pass 规则强制作废的次数
# MAIN.backend_conn 后端连接数
# MAIN.n_object 当前缓存的对象数量
# MAIN.n_expired 已过期但仍占内存的对象
# MAIN.sess_conn 总连接数命中率 = cache_hit / (cache_hit + cache_miss)。内容站的整页缓存,60% 以上算及格,80% 以上算好。如果偏低,用 varnishtop 看看到底哪些 URL 在反复 miss:
# 实时显示最常出现的请求 URL(默认按 hit count 排序)
varnishtop -i ReqURL
# 看看后端都给响应打了什么 Cache-Control
varnishtop -i RespHeader:Cache-Control
# 排查为什么没缓存:看 TXURL 与 Miss 标记
varnishtop -i VCL_callvarnishtop -i ReqURL 是排查神器。如果看到某个 URL 反复出现且明明应该缓存,说明它的响应里带着 Set-Cookie,或者被你的 pass 规则误伤了。
缓存失效:purge 与 ban
文章更新后,旧缓存必须立刻作废,否则访客看到的是旧内容。Varnish 提供两种机制:purge 精确删一个 URL 的缓存,ban 按条件批量失效。
# 精确清除单个 URL(需要先在 VCL 里允许 purge)
varnishadm "ban req.url == /2026/10/new-post.html"
# 用正则批量失效(淘汰所有 .html)
varnishadm "ban req.url ~ \.html$"
# 查看当前的 ban 列表
varnishadm "ban.list"要在 VCL 里开放 purge,得加一段 ACL 限制来源(绝不能对公网开放 purge,否则任何人都能刷掉你的缓存):
acl purge_acl {
"127.0.0.1";
"10.0.0.0"/8;
}
sub vcl_recv {
if (req.method == "PURGE") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Not allowed."));
}
return (purge);
}
}推荐做法是把 purge 逻辑挂进网站后台:文章发布/编辑的钩子里调用 varnishadm "ban req.url ~ ^/对应路径",或发一个 PURGE 请求给 Varnish。WordPress 有现成的 Varnish 插件做这件事,Typecho 可以写个简单的钩子脚本。
该不该上 Varnish:三种情况分清
适合上:内容型站点(博客、资讯、文档站),访客看到的基本是同一份页面,登录用户占比低,且你愿意接受"多一层需要维护的组件"。这类站点上 Varnish 后 TTFB 常有数量级的改善,后端 CPU 和数据库压力显著下降。
没必要上:纯静态站(Nginx 直接吐静态文件已经足够快,加 Varnish 是画蛇添足)、日访问量几百的极小站(瓶颈根本不在渲染)、以及缓存规则复杂的电商站(个性化太重,命中率上不去反而添乱)。
谨慎上:如果站点重度依赖登录态、或者每个页面都有个性化内容(推荐、购物车、用户头像),Varnish 的整页缓存几乎不会命中,收益有限。这种情况更适合用 Memcached/Redis 做对象级缓存,而不是整页缓存。
还有几个权衡点要清楚。第一,Varnish 在 HTTPS 上不做终止——它自己不处理 TLS(社区版要配合 Hitch 或用 Nginx 做 TLS 终止再转发给 Varnish),所以架构会变成"Nginx(TLS) → Varnish → Nginx(后端)",多一跳。第二,内存要留够,-s malloc,256m 是最小配置,缓存 1 万个页面大约需要几百 MB。第三,VCL 编译错误会导致 Varnish 拒绝启动,改配置务必先 varnishd -C -f /etc/varnish/default.vcl 语法检查再 reload。
HTTPS 架构:Varnish 不处理 TLS 的应对
社区版 Varnish 本身不做 TLS 终止,这在"全站 HTTPS"已是标配的今天是个必须提前设计的问题。常见有三种架构。第一种是用 Nginx 做 TLS 终止,然后明文转发给 Varnish,Varnish 再转发给后端的 Nginx——链路变成"Nginx(443) → Varnish(80) → Nginx(8080)",多一跳但配置最直观。第二种是引入 Hitch(Varnish 官方的 TLS 代理),由它专门监听 443 做 TLS 卸载,再把明文交给 Varnish,职责更单一,但要额外维护一个进程。第三种是用商业版 Varnish Enterprise 的原生 TLS,对个人站长来说成本不划算。
# 方案一:前置 Nginx 做 TLS 终止,转发给 Varnish 的 80
# /etc/nginx/sites-enabled/site-tls.conf
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:80; # 交给 Varnish
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}注意 X-Forwarded-Proto https 这一行。Varnish 到后端是明文 HTTP,如果不在请求头里标明原始协议是 https,后端的 PHP 程序(以及重定向逻辑)会以为访客走的是 http,从而产生重定向死循环或者生成错误的绝对链接。这是 HTTPS 架构里最常见的坑。
内存估算与启动参数
Varnish 最耗的是内存,因为它把整个响应体都放在 RAM 里。启动参数里的 -s malloc,256m 只是下限,估算公式大致是"平均页面大小 × 期望缓存的对象数 × 1.5"。一个页面 HTML 平均 50KB,想缓存 5000 个页面,就要 50KB × 5000 × 1.5 ≈ 360MB,给 512MB 更稳。
# 语法检查(改完 VCL 必须先跑这步,否则 reload 会让服务起不来)
varnishd -C -f /etc/varnish/default.vcl
# 优雅重载 VCL,不中断服务
varnishreload
# 或
varnishadm "vcl.load myvcl /etc/varnish/default.vcl"
varnishadm "vcl.use myvcl"varnishadm 还能在不停机的情况下做很多事:查看后端健康状态、临时屏蔽某个 URL、动态调整线程池。它是 Varnish 的"管理控制台",出事时比翻日志快得多。
# 进入交互式管理控制台
varnishadm
# 常用命令
backend.list # 后端健康状态与探测结果
param.show # 当前运行参数
vcl.list # 已加载的 VCL 版本
status # 子进程运行状态监控与容量:把 TTFB 的改善量化出来
上线 Varnish 之后一定要用数据验证效果,否则你无法判断它是真在帮忙还是白占资源。方法是对比上线前后的 TTFB(首字节时间)和缓存命中率。测量 TTFB 可以用 curl 的 -w 格式化输出:
# 连续测量 10 次首页的 TTFB,观察第一次(miss)与后续(hit)的差距
for i in $(seq 1 10); do
curl -o /dev/null -s -w "TTFB=%{time_starttransfer}s total=%{time_total}s\n" \
-H "Host: www.example.com" http://127.0.0.1/
done
# 典型结果:
# TTFB=0.312s <-- 第一次 miss,走了后端
# TTFB=0.002s <-- 之后都是 hit,直接从内存返回
# TTFB=0.002s
# ...从 0.3 秒降到 0.002 秒,就是 Varnish 命中缓存的真实收益——两个数量级。如果命中后 TTFB 依然在 0.1 秒以上,说明你的缓存没真正命中,去查 varnishstat 的 cache_miss 为什么一直涨。
长跑的监控建议:把 varnishstat -1(一次性输出全部指标)接进你现有的监控(Netdata、Zabbix 或简单的 shell 脚本 + 告警),重点盯三个数——cache_hit 的增速、n_expired(过期对象是否积压占内存)、backend_fail(后端失败次数)。这三个数异常,分别对应"缓存没生效""内存要加""后端挂了"三种问题,比翻日志直观得多。
结语:让"重复的活"不再重复干
Varnish 背后的思想是 Web 性能优化里最朴素也最有效的一条:不要为同一个结果计算两次。当站点 90% 的请求都是访客读取同样的内容时,让 PHP 每一次都重新渲染,本质上是在浪费 CPU 和数据库资源。Varnish 把这些重复劳动挡在内存层,把宝贵的动态处理能力留给真正需要它的请求——登录、下单、提交评论。
对个人站长来说,它是一层"配置一次、长期受益"的基础设施。前提是理解它的边界:它是 HTTP 层的缓存,不解决应用逻辑慢的问题,也不改变数据本身。用对了,它是你网站上性价比最高的一次提速;用错了(比如对个性化站点硬上整页缓存),它只会带来"内容串号""数据错乱"这类更难查的问题。