外贸网站运行在香港服务器的 Ubuntu 系统中时,如何通过 Varnish 缓存降低商品详情页的回源压力
技术教程 2025-09-17 09:47 193


前阵子,我在香港将军澳机房值夜班,晚上 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,再铺量。这套在香港节点落地后,也被复制到了新加坡与法兰克福,稳定得像凌晨三点的机房走廊