
前阵子,我在香港将军澳机房值夜班,晚上 23:40,东南亚的渠道商突然推了我们的爆款 SKU,海外站点的商品详情页(PDP, Product Detail Page)QPS 从 300 飙到 2k,Nginx+PHP-FPM 后端 CPU 直奔 95%,MySQL 的 Threads_running 抖成心电图。监控里最刺眼的一行字是:回源率 82%。
我当即决定把 Varnish 顶上来做 PDP 级别的强缓存与“带保鲜期的陈旧内容”(grace/keep),并把请求归一、Cookie 去噪、精细 TTL、按标签 BAN 的那一套全部落地。下面就是那一夜的完整操作手记与后来几天的优化笔记。
一、现场环境与目标
1)硬件与网络(香港机房,真实参数示例)
| 角色 | 规格 | 存储 | 网络 | 备注 |
|---|---|---|---|---|
| Varnish 前置节点 ×1 | 8 vCPU(EPYC 7B13)、32GB RAM | NVMe 960GB(系统盘) | 1Gbps 独享,双线 BGP | Ubuntu 24.04 LTS |
| 应用/Nginx/后端 ×2 | 8 vCPU、32GB RAM | NVMe 1.92TB | 1Gbps | 内网互通,做主备/轮询 |
| MySQL ×1 | 16 vCPU、64GB RAM | NVMe RAID1 3.84TB | 1Gbps | 读写分离计划中 |
优化目标
- PDP 命中率拉到 70%+(匿名用户更高),回源率 ≤ 30%。
- 高峰期 p95 响应 < 200ms(静态命中)/< 450ms(回源)。
- 允许 5 分钟内的“轻微陈旧”(grace),保障促销高峰稳定。
二、部署拓扑与原则
[Client] --TLS--> [Nginx/HAProxy(443终结)] --HTTP--> [Varnish:80] --HTTP--> [Nginx:8080 -> PHP-FPM]
- TLS 终结在 Nginx/HAProxy,Varnish 专心做 HTTP 缓存加速(Varnish 不直接处理 TLS)。
- Varnish 统一接 80 端口,后端 Nginx 改到 8080。
- 只缓存 PDP 详情页及其依赖的接口/片段,对购物车、结算、账户相关一律透传或 hit-for-pass。
- 以 TTL + Grace + Keep 管控生命周期:新鲜期 + 可陈旧期 + 保留期(用于 If-Modified-Since 条件请求)。
三、安装与基础配置(Ubuntu)
下面以 Ubuntu 24.04 举例,22.04 操作相同。Varnish 官方仓库托管在 Packagecloud;若你追求最新稳定版,可添加官方仓库或直接用系统自带版本(通常略旧)。
1)安装 Varnish
sudo apt update
sudo apt install -y varnish
# 如需官方最新版,请按 varnish-cache.org 的 Debian/Ubuntu 指引添加 packagecloud 源再安装
官方文档:Debian/Ubuntu 安装方法与官方仓库位置说明。
2)把后端 Nginx 改到 8080
/etc/nginx/sites-available/your-site.conf(示例)
server {
listen 8080;
server_name www.example.com;
root /var/www/app/public;
# PDP 详情页示例路由
location ~ ^/product/([\w-]+)$ {
try_files $uri /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_read_timeout 30s;
}
location /healthz {
return 200 "ok";
add_header Content-Type text/plain;
}
}
重载 Nginx:
sudo nginx -t && sudo systemctl reload nginx
3)让 Varnish 监听 80 端口(systemd 覆盖)
不同发行版对 /etc/default/varnish 或 varnish.params 的使用略有差异;在现代系统,推荐用 systemd drop-in 覆盖 ExecStart,最稳。官方与社区文档也建议通过 systemd 配置启动参数。
sudo systemctl edit varnish
写入(drop-in):
[Service]
ExecStart=
ExecStart=/usr/sbin/varnishd \
-j unix,user=vcache \
-F \
-a :80 \
-T 127.0.0.1:6082 \
-f /etc/varnish/default.vcl \
-s malloc,24G
# 若内存富余可用 malloc;如需利用 NVMe,可换成:
# -s file,/var/lib/varnish/cache.bin,80G,1M
然后:
sudo systemctl daemon-reload
sudo systemctl restart varnish
sudo systemctl status varnish
如果你习惯编辑 /lib/systemd/system/varnish.service 或 /etc/default/varnish,也能生效,但 drop-in 更符合 systemd 最佳实践。
四、VCL:面向外贸 PDP 的“细粒度”缓存策略
默认 VCL 位置:/etc/varnish/default.vcl,里面已经有一个指向 127.0.0.1:8080 的 backend 模板。
1)完整示例(可直接落地)
下面这份 VCL 主要针对 /product/ 路由的匿名访问,做 Cookie 去噪、参数归一、TTL/Grace/Keep 精细设置,并实现 按标签 BAN 与 后端健康探针。
vcl 4.1;
import std; # 提供 querystring 操作、日志等
import directors; # 需要多后端负载均衡时使用
# ----------------------------
# 后端与健康探针
# ----------------------------
probe healthcheck {
.url = "/healthz";
.timeout = 1s;
.interval = 5s;
.window = 5;
.threshold = 3; # 3/5 成功判定健康
}
backend app1 {
.host = "127.0.0.1";
.port = "8080";
.probe = healthcheck;
}
# 如有第二台后端,按需开启:
# backend app2 { .host = "10.0.0.12"; .port = "8080"; .probe = healthcheck; }
# new be = directors.round_robin();
# sub vcl_init {
# be.add_backend(app1);
# be.add_backend(app2);
# }
# ----------------------------
# 允许内网或特定地址执行 PURGE/BAN
# ----------------------------
acl purge {
"127.0.0.1";
"10.0.0.0"/8;
}
sub vcl_recv {
# 仅允许内网做 PURGE/BAN
if (req.method == "PURGE" || req.method == "BAN") {
if (!client.ip ~ purge) {
return (synth(405, "Not allowed"));
}
# 支持按标签 BAN:curl -X BAN -H "x-invalidate-pattern: tag:SKU-1234"
if (req.http.x-invalidate-pattern) {
ban("obj.http.x-cache-tags ~ " + req.http.x-invalidate-pattern);
return (synth(200, "Banned by tag"));
}
# 按 URL BAN(包含 Host)
ban("obj.http.url == " + req.url + " && obj.http.host == " + req.http.host);
return (synth(200, "Banned"));
}
# 只接受 GET/HEAD 对缓存友好
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# 归一 Accept-Encoding,提升命中率
if (req.http.Accept-Encoding) {
if (req.url ~ "\\.(jpg|jpeg|png|gif|webp|svg|ico|woff2?)$") {
unset req.http.Accept-Encoding;
} else {
if (req.http.Accept-Encoding ~ "br") {
set req.http.Accept-Encoding = "br";
} else {
set req.http.Accept-Encoding = "gzip";
}
}
}
# 归一查询参数:只保留与 PDP 渲染相关的白名单,去掉跟踪参数
if (req.url ~ "^/product/") {
# 示例:只保留 id/lang/currency,去掉utm_*等
set req.url = std.querysort(req.url);
set req.url = std.queryfilter(req.url, "id|lang|currency");
}
# Cookie 去噪:登录、购物车、结算相关一律 pass
if (req.http.Cookie) {
if (req.url ~ "^/(cart|checkout|account)") { return (pass); }
# 对 PDP,移除无关 Cookie(如 _ga/_gid/_fbp 等追踪)
if (req.url ~ "^/product/") {
set req.http.Cookie = std.collect(req.http.Cookie);
set req.http.Cookie = regsuball(req.http.Cookie, "(^|; )(_ga|_gid|_fbp|utm_[^=]+)=[^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "^;\\s*|;\\s*$", "");
if (req.http.Cookie == "") { unset req.http.Cookie; }
}
# 若仍存在会导致个性化的 Cookie(如 session),则不缓存
if (req.http.Cookie ~ "(session|logged_in|auth)") {
return (pass);
}
}
}
sub vcl_hash {
# 缓存键包含 Host
hash_data(req.http.host);
hash_data(req.url);
return (lookup);
}
sub vcl_backend_fetch {
# 后端选择:单后端用 app1,多后端用 directors
set bereq.backend = app1;
# set bereq.backend = be.backend(); # 使用负载均衡时启用
}
sub vcl_backend_response {
# 对 PDP 设置更积极的缓存策略
if (bereq.url ~ "^/product/") {
# 遵守后端的 no-store/no-cache
if (beresp.http.Cache-Control ~ "no-store") {
set beresp.uncacheable = true;
return (deliver);
}
# PDP:新鲜 10m,grace 5m,keep 1h
set beresp.ttl = 10m;
set beresp.grace = 5m;
set beresp.keep = 1h;
# 存入自定义标签,供 BAN 使用(后端需回源时设置 x-cache-tags)
if (beresp.http.x-cache-tags) {
set beresp.http.x-cache-tags = beresp.http.x-cache-tags;
} else {
# 兜底以 SKU 作为标签(从 URL 提取)
set beresp.http.x-cache-tags = "tag:" + regsub(bereq.url, "^/product/([^?]+).*", "\1");
}
}
# 图片/CSS/JS:若后端未明确 TTL,给一个保守 TTL
if (bereq.url ~ "\\.(css|js|jpg|jpeg|png|webp|svg|ico|woff2?)$" && beresp.ttl <= 0s) {
set beresp.ttl = 1h;
set beresp.grace = 10m;
set beresp.keep = 12h;
}
# 允许弱一致背景刷新:当对象过期但在 grace 内,继续回源更新
return (deliver);
}
sub vcl_deliver {
# 命中标识
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-Hits = obj.hits;
# 透出对象生命周期,便于观察
set resp.http.X-TTL = resp.ttl;
set resp.http.X-Grace = resp.grace;
set resp.http.X-Keep = obj.keep;
}
sub vcl_synth {
set resp.http.Content-Type = "text/plain; charset=utf-8";
return (deliver);
}
要点解释
- probe 健康检查让 Varnish 只向健康后端发请求;也可复用命名探针给多个 backend。
- Grace/Keep 用于“陈旧可用”与条件请求,保证在促销洪峰下页面稳定可用。
- BAN/PURGE:通过 x-invalidate-pattern 实现“按标签”失效,如更新 SKU=1234 时发 BAN: tag:SKU-1234。
- 接入层只缓存 GET/HEAD,动态写操作全部 pass。
- Cookie 去噪 是 eCom 命中率的第一生产力:追踪类 Cookie 一律剔除,含 session 的请求直接不缓存。
- 参数归一(白名单、排序、去 UTM)能明显提升变体命中率。
- 负载均衡可用 directors.round_robin(),大促时很好用。
五、失效与更新:如何正确“刷新” PDP
1)按 URL 失效(内网)
curl -X BAN "http://127.0.0.1/product/my-sku-1234"
2)按标签失效(推荐)
当商品详情页回源时后端返回:
x-cache-tags: tag:SKU-1234,tag:CATEGORY-12
更新此 SKU 时:
curl -X BAN http://127.0.0.1/ \
-H "x-invalidate-pattern: tag:SKU-1234"
Varnish 原生支持 purge/ban 流程;示例里的“ban lurker 友好写法”建议避免使用 req.*,用对象头实现更高效的清理。
六、监控与诊断:命中率不是拍脑袋
常用命令:
# 1)快照指标:命中/未命中/回源量
varnishstat -1 | egrep 'cache_hit|cache_miss|backend_req|sess_drop'
# 2)实时前 N 热点 URL
varnishtop -i ReqURL
# 3)看具体一次请求的命中/回源细节
varnishlog -g request -q "ReqURL ~ '/product/' and ReqHeader:Host eq 'www.example.com'"
关键观测点
- MAIN.cache_hit / MAIN.cache_miss:命中与未命中。
- MAIN.backend_req:回源请求量。
- SMA.s0.g_bytes / c_bytes:内存或文件存储的使用。
- sess_drop:会话丢弃,意味着资源吃紧。
- 后端健康状态可结合 probe 与共享内存日志分析。
七、灰度与回滚
灰度:先只让特定国家/ASN 或 UA 命中 Varnish,其余透传。
回滚:将 Varnish -a 改回 :6081,由 Nginx 直接对外;或在前端负载均衡上摘掉 Varnish 节点。
保护:保留 hit-for-pass、超时合理(connect_timeout, first_byte_timeout 等),防止后端雪崩。
八、我们踩过的坑(以及怎么填)
监听端口不生效
一开始改了 /etc/default/varnish 没反应——因为系统采用 systemd 管理启动参数。解决:用 systemd drop-in 覆盖 ExecStart。
回源仍然很高
追踪到大量 Cookie: _ga/_fbp 等导致变体爆炸。用 Cookie 去噪 + 参数白名单/排序 后,命中率直接拉升。
BAN 效率低
逐 URL purge 太慢。改为 标签化(x-cache-tags) 一把梭:一次 BAN 命中同 SKU 全部变体。参考 ban/purge 文档做了“lurker 友好”的写法。
后端挂了导致抖动
未配置探针时 Varnish 仍尝试回源。加 probe 健康检查,并在 bereq.retries 内做重试,抖动消失。
内存 OOM
初期把 -s malloc,28G 拉太满,在高峰+日志峰值下偶发 OOM。收敛到 24G,并把较大对象(图片)迁到 -s file,稳了。(系统层面也可针对 OOM killer 调参,但根因仍是容量规划)
TLS 怎么办?
Varnish 不处理 TLS。将 443 的 TLS 终结在 Nginx/HAProxy/云负载均衡,回源到 Varnish 的 80,外界不感知。
九、策略与结果
缓存策略矩阵(节选)
| 路径 | 匹配 | TTL | Grace | Keep | 说明 |
|---|---|---|---|---|---|
/product/*(匿名) |
GET/HEAD | 10m | 5m | 1h | 允许轻微陈旧,购物高峰最有效 |
/product/*(登录态) |
Cookie: session | pass | — | — | 避免个性化污染缓存 |
/cart/*,/checkout/*,/account/* |
— | pass | — | — | 业务强一致 |
| 静态资源 | 后端未设 TTL | 1h | 10m | 12h | 省回源带宽 |
接口 /api/stock?id= |
GET, 可弱一致 | 30s | 30s | 10m | 结合前端降级展示 |
上线前后对比(香港节点 30 分钟窗口)
| 指标 | 上线前 | 上线后 |
|---|---|---|
| PDP 回源率 | 82% | 27% |
| 命中率(匿名) | 38% | 78% |
| p95 响应(匿名) | 720ms | 180ms |
| 后端 CPU | 90%+ | 45% |
| 带宽峰值 | 300 Mbps | 520 Mbps(由 Varnish 承担外发) |
命中提升的核心来自:参数归一、Cookie 去噪、TTL/Grace/Keep 的合理组合。关于 Grace/Keep 的机制与对象生命周期,官方教程解释得很清楚。
十、给后端/运营的“刷新接口”
我们在内网 CI 里做了个简单脚本,商品更新后自动 BAN 对应标签:
#!/usr/bin/env bash
SKU="$1"
curl -s -X BAN http://127.0.0.1/ -H "x-invalidate-pattern: tag:SKU-${SKU}" | cat
运营平台改价/上下架也会异步触发这段,从此没人半夜喊“清缓存”。
十一、附:最小可用版(MVP)VCL
若你想先跑起来再慢慢精细化,下面是极简版:
vcl 4.1;
backend default { .host = "127.0.0.1"; .port = "8080"; }
sub vcl_recv {
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
if (req.url ~ "^/(cart|checkout|account)") { return (pass); }
if (req.url ~ "^/product/") {
if (req.http.Cookie) {
set req.http.Cookie = regsuball(req.http.Cookie, "(^|; )(_ga|_gid|_fbp)=[^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "^;\\s*|;\\s*$", "");
if (req.http.Cookie ~ "(session|logged_in|auth)") { return (pass); }
if (req.http.Cookie == "") { unset req.http.Cookie; }
}
}
}
sub vcl_backend_response {
if (bereq.url ~ "^/product/") {
set beresp.ttl = 10m;
set beresp.grace = 5m;
set beresp.keep = 1h;
}
}
大促那晚 01:10,命中曲线终于拐头向上,后台的报警一条条变灰。凌晨三点,我在机房喝着冰到发凉的罐装咖啡,看着 varnishstat 里 cache_hit 一路走高,心里踏实了许多。第二天早会,老板问“你昨晚做了什么?”我指着大屏:
“没什么,就让 PDP 不总是回源而已。”
从那以后,每逢活动,我们先想清楚 TTL/Grace、Cookie 策略、标签化 BAN,再铺量。这套在香港节点落地后,也被复制到了新加坡与法兰克福,稳定得像凌晨三点的机房走廊