上个月处理一个资讯站的线上问题,后端CPU平时只有30%,一到活动时间直接冲到95%,数据库连接池被打满,慢查询一串接一串。一开始我也按老思路加机器、加并发参数,冷静下来分析流量才发现,大量请求其实都在反复拉取同一批HTTP响应——列表页、详情页、接口返回的数据根本没变化,却一遍遍打到后端重新渲染。真正值钱的不是加机器,而是把这些重复响应在入口截住。这个活儿最后交给了Varnish。
Varnish是一款面向HTTP协议的缓存服务器,通过VCL(Varnish Configuration Language,Varnish配置语言)控制缓存行为。它站在业务后端的入口处,把重复的GET请求直接在缓存层命中并原样返回,后端连请求都看不到。这篇文章会把从选型、安装、VCL编写、缓存失效到线上排障的完整链路走一遍,适合刚接触Varnish的后端工程师和运维同学,也适合那种Varnish已经跑起来、但命中率一直上不去的团队对照排查。
1. 先别急着装Varnish:先搞清楚它管什么、不管什么
1.1 它是一台"懂HTTP语义"的缓存机,不是Redis替代品
很多人一听缓存,第一反应是Redis,然后把Varnish也往那个方向想,这是最普遍的误解。Redis缓存的是业务对象,需要你在代码里自己读写、自己定过期策略、自己处理并发穿透;Varnish缓存的是完整的HTTP响应,状态码、响应头、响应体一起存,并且直接用HTTP协议自己的语义来管理。请求进来,它根据请求方法和URL算出缓存键(cache key),命中就把整个响应对象直接回给客户端,连后端连接都省了。
Varnish内部用哈希树管理对象,查找是纯内存操作,命中耗时通常是几十微秒到几百微秒这个量级。更关键的是它真正理解HTTP:会正确处理Age头、ETag和If-None-Match条件请求、Vary头、压缩版本切换这些细节。这不是一个"把响应塞进内存"的玩具,而是一台完整的HTTP语义机。所以讨论它的时候,不要拿"Redis加一层缓存"的思路来套,它是独立于业务代码之外的、站在HTTP层面工作的一道缓存。
1.2 适用边界:能解决的问题和不能碰的场景
任何缓存都不是银弹,Varnish的适用边界其实非常清晰。判断标准就一句话:同样的请求,在可接受的时间窗口内,是否应该返回同样的内容。
适合引入的场景有这些:
- 读多写少,存在大量重复GET请求,比如新闻站、商品列表、文档站、公开接口。
- URL和参数能够比较明确地表达内容差异,缓存键好算。
- 内容可以公开缓存,或者虽然按登录态区分,但能清楚划分出公开部分和私有部分。
- 业务能接受秒级到分钟级的发布延迟,不需要"一改立即全网生效"。
不适合的场景也很明确:
- 写多读少,数据秒级变化,缓存带来的收益远小于一致性问题。
- 每个响应都携带强个性化cookie,且后端渲染强依赖cookie,无法剥离。
- 要求强一致,发布后所有用户必须立刻看到新内容,不能有任何人命中旧缓存。
- 后端响应头带着Set-Cookie且无法拆分,这类响应在Varnish默认策略下根本不缓存。
我见过最典型的翻车案例,是把强个性化的用户中心首页交给了Varnish。每个用户看到的都是自己的数据,缓存键却只有URL,结果要么缓存串号,要么命中率上不去还白白增加排查成本。这类场景一开始就不该上Varnish。
1.3 和Nginx自带缓存、浏览器缓存、CDN怎么分工
Nginx自带的HTTP缓存模块也能做类似的事,但它通常同时还承担着业务接入、TLS卸载这些工作,配置起来没有VCL这种专门语言灵活。Varnish的定位非常纯粹,只做缓存这件事,VCL可以精细控制到每个请求该不该缓存、缓存多久、缓存键怎么算。如果项目里Nginx已经铺得很重,那用Nginx自带缓存顺手做一层也完全合理;但如果缓存策略很复杂、需要频繁调整,VCL的表达能力会省很多事。
浏览器缓存是私有缓存,存在每个用户自己机器上,按用户隔离;Varnish是共享缓存,站在这台服务器的入口给所有人服务。CDN是分布在不同地理位置的边缘缓存,通常负责把内容推到离用户更近的地方;Varnish则常作为源站这侧的入口缓存,兜住所有回源流量。三层配合起来才是完整链路:CDN挡边缘流量,Varnish挡回源重复流量,浏览器缓存挡用户自己重复访问。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 建立VCL心智模型:一个请求在Varnish里走的完整路径
2.1 请求状态机:recv到deliver,每一步该做什么
VCL不是一种普通的配置文件,它会被编译成C代码,再编成共享对象加载进Varnish进程。所以你在VCL里写的每个sub例程,本质上都是挂在请求状态机上的钩子。理解Varnish的钥匙就是先理解这个状态机。
一次请求的典型路径是:客户端进来先到vcl_recv,在这里做最粗粒度的决策——这个请求是直接放行到后端(pass),还是参与缓存查找(hash)。接着进入vcl_hash,在这里计算缓存键;然后Varnish拿着键去查找缓存对象,命中走vcl_hit,没命中走vcl_miss;miss需要到后端取数据,取回来进入vcl_backend_response,你在这里设置TTL、grace这些对象属性;最后统一回到vcl_deliver,把响应交给客户端。一个最简单的自定义VCL长这样:
vcl复制vcl 4.1;
backend web {
.host = "127.0.0.1";
.port = "8080";
}
sub vcl_recv {
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
return (hash);
}
sub vcl_hash {
hash_data(req.url);
if (req.http.host) {
hash_data(req.http.host);
}
return (lookup);
}
每个阶段返回的关键字决定了下一条路:pass是完全绕过缓存,前向请求和后向响应都不落缓存;lookup是去查找缓存对象;hit直接返回;miss去后端取;deliver把响应交给客户端。日常调VCL,百分之八十的工作量都集中在vcl_recv和vcl_backend_response这两个钩子里,一个管"要不要缓存",一个管"缓存多久"。
2.2 缓存键:默认由什么组成,什么时候必须自己改
Varnish默认的缓存键是host加URL,也就是说不带查询字符串的两个不同路径是不同对象,带了不同查询字符串的同一个路径也是不同对象。这个默认行为大多数时候是合理的,但也是很多问题的根源。
比如一个商品接口/product/123?from=home和/product/123?from=detail,后端返回的内容一模一样,但因为URL不同,Varnish会当成两个对象缓存两份。如果外部投放系统每天往URL上追加不重样的追踪参数,缓存键就会变得无穷无尽,命中率直接被打到谷底。这时候就需要在vcl_hash里做手脚,把不参与内容差异的部分从缓存键里摘掉,或者反过来,把影响内容差异的维度加进缓存键:
vcl复制sub vcl_hash {
hash_data(req.url);
if (req.http.host) {
hash_data(req.http.host);
}
if (req.http.X-Lang) {
hash_data(req.http.X-Lang);
}
return (lookup);
}
我见过不少团队在vcl_recv里判断了语言、版本、登录态,却忘了改vcl_hash,结果后端确实收到了正确请求,但缓存对象还是按老键存的,新流量永远命中不了。记住一个原则:你判断"内容是否相同"所用的维度,必须和缓存键的维度一一对应,否则缓存就白做了。
2.3 TTL、grace、keep三个时间参数,各管哪一段
Varnish里一个缓存对象有三个和生命周期相关的参数,很多人只认识TTL,后面的grace和keep根本没用起来。
beresp.ttl:对象从后端取回后的正常存活时间,也就是"保质期"。beresp.grace:对象过期后还能继续对外服务多久,用于后端故障或者后端变慢时兜底。beresp.keep:对象过期后继续在缓存里保留多久,目的是配合条件请求,让客户端拿着旧ETag回来时有东西可以重新验证。
打个比方,TTL是保质期,grace是临期缓刑期,keep是过期之后先放进冰箱,万一有需要还能拿出来做二次加工。默认情况下grace只有10秒,很多生产事故都是这10秒不够用导致的,后端起个故障,缓存里明明有内容却全部过期,直接把后端压死。合理的做法是在vcl_backend_response里统一放大:
vcl复制sub vcl_backend_response {
set beresp.ttl = 600s;
set beresp.grace = 24h;
set beresp.keep = 1h;
}
grace怎么配合vcl_hit做故障兜底,后面第6章会专门讲,这里先记住概念:这三个参数是一套完整的时间策略,不是随便填个TTL就完事。
3. 最小可用环境:从装包到亲手看到第一个命中
3.1 安装、目录结构和服务管理
Debian和Ubuntu系直接apt install varnish,CentOS和Rocky系用dnf install varnish,各发行版仓库里的版本一般不是最新的,但胜在省事。如果想要特定的LTS版本比如Varnish 6.0,建议直接用官方仓库装,避免系统包里带的版本太老导致VCL语法对不上。
装完以后有两个文件先认识一下:/etc/varnish/default.vcl是默认的VCL配置文件,所有缓存策略写在这里;/etc/varnish/secret是管理端口CLI的认证密钥,这个文件权限应该收紧,内容也不要外传,后面你会频繁用它登录管理口。systemd管理的服务配置文件里,ExecStart那行是最终的启动参数,端口、存储、VCL路径都在这里调整。
3.2 最小VCL与服务启动参数
先给一个最小可用的VCL,后端假设是本机和8080端口的Nginx或者Java服务:
vcl复制vcl 4.1;
backend web {
.host = "127.0.0.1";
.port = "8080";
}
启动时关键参数就三个:-a指定HTTP监听地址,默认6081;-T指定CLI管理地址,默认6082,生产环境别裸奔到公网;-s指定存储后端,malloc,1G表示用1GB内存,如果要存超大文件对象,也可以用file类型指定一块磁盘文件。完整启动命令:
bash复制varnishd -a :6081 -T 127.0.0.1:6082 -s malloc,1G -f /etc/varnish/default.vcl
如果用systemd托管,直接把这段参数写进service文件的ExecStart行。注意-f指定的VCL如果没有通过编译,Varnish会拒绝启动,所以线上改VCL之前一定要先做编译检查,这个习惯我后面会详细说。
3.3 用Age头和varnishlog确认命中
启动之后,先请求一次看看能不能通:
bash复制curl -sI http://127.0.0.1:6081/
第一次请求大概率是miss,后端返回数据后Varnish把对象存了下来。紧接着再请求一次,如果命中了,响应里会出现一个Age头,数值表示这个对象在缓存里已经存活了多少秒。第一次没有Age头,第二次出现Age: 0或者Age: 1,恭喜,第一个缓存对象已经跑通了。
如果Age头一直不出现,就得开日志看请求到底走到哪一步了。Varnish的日志查询工具varnishlog可以直接按请求维度过滤:
bash复制varnishlog -g request -q 'ReqURL == "/"'
日志里能看到完整链路:ReqMethod、ReqURL是客户端请求信息,VCL_call能看到调用到了recv还是hash,Hit或Miss标签直接告诉你是否命中,TxStatus是最终返回给客户端的状态码。这一步排查习惯了,以后线上定位问题会特别快。
4. 把命中率打上去的VCL实战:cookie、URL、响应头逐个收拾
4.1 cookie是命中率第一杀手
cookie对缓存命中的杀伤力在所有因素里排第一,因为它直接破坏"同样URL返回同样内容"这个前提。Varnish的默认策略是:后端响应带Set-Cookie就不缓存。也就是说,一个列表页只要后端往下吐一个setCookie,这个页面在Varnish眼里就是不可缓存的,命中率直接清零。
处理cookie的思路要分两类场景。第一种是纯公开页面,cookie对内容完全没有影响,那就在vcl_recv里直接摘掉:
vcl复制sub vcl_recv {
if (req.http.cookie) {
unset req.http.cookie;
}
}
第二种是页面需要区分登录态和非登录态。最稳妥的办法是让这类请求直接pass,不参与缓存;如果非要缓存"未登录版本",那就要确保带登录cookie的请求不带脏cookie进缓存键,同时保证后端返回不含Set-Cookie。很多站点还会遇到第三方统计cookie乱飞的情况,页面上挂了一堆_ga、utm_相关cookie,这些不影响内容,但会让后端认为请求是私有的。可以用正则只保留真正影响内容的那一两个cookie,其余全部剥掉:
vcl复制sub vcl_recv {
if (req.http.cookie) {
if (req.http.cookie ~ "session=") {
set req.http.cookie = ";" + req.http.cookie;
set req.http.cookie = regsuball(req.http.cookie, "; +", ";");
set req.http.cookie = regsuball(req.http.cookie, ";(session)=", "; \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;
}
} else {
unset req.http.cookie;
}
}
}
这段逻辑是只保留名为session的cookie,其他全扔。注意它默认还是会让带session的请求走缓存,所以只适用于"登录与否不影响页面内容"的场景。如果登录之后内容确实变,老老实实对带session的请求做pass,别硬缓存。
4.2 URL规范化:把缓存键里的噪音去掉
缓存键由URL决定,URL里的每一个查询参数差异都会生成新缓存对象。投放系统往URL追了一堆utm_source、utm_campaign,前端轮询接口顺手加了个时间戳当cache-busting参数,这些都是命中率杀手。解决思路是在进入缓存键之前先做规范化,把噪音参数剥掉:
vcl复制sub vcl_recv {
if (req.url ~ "(\?|&)utm_") {
set req.url = regsuball(req.url, "&?utm_[^&]+", "");
set req.url = regsub(req.url, "\?&", "?");
set req.url = regsub(req.url, "\?$", "");
}
if (req.url ~ "(?i)\?(.*&)?cachebust=[^&]+") {
set req.url = regsub(req.url, "&?cachebust=[^&]+", "");
set req.url = regsub(req.url, "\?&", "?");
set req.url = regsub(req.url, "\?$", "");
}
}
这里有个隐藏的坑需要注意:set req.url是在vcl_recv里改的,后端最终还是按修改后的URL发请求。如果后端业务依赖这些追踪参数做统计,直接在recv里改URL会影响后端数据。要么保证后端不依赖这些参数,要么把追踪参数留在别的Header里由后端自行解析,总之要跟业务方对齐,别自己默默改了URL导致后端统计数据对不上。
更彻底一点的做法是统一参数顺序。有些客户端生成请求时参数顺序不固定,?a=1&b=2和?b=2&a=1内容完全相同,但缓存键不同。这种情况需要在recv里把查询字符串拆出来排序再拼回去。VCL写这个比较绕,我一般建议在更前面的接入层做,或者直接约束客户端按固定顺序拼参数。
4.3 后端响应头:TTL、Set-Cookie、Cache-Control怎么处理
后端响应的Header是对缓存策略影响最大的输入。Varnish默认对带Set-Cookie的响应不缓存、对Vary: *不缓存,这两个默认行为要心里有数。在此基础上,我通常会在vcl_backend_response里统一做一套TTL决策:
vcl复制import std;
sub vcl_backend_response {
if (beresp.status >= 500) {
set beresp.ttl = 0s;
return (deliver);
}
if (beresp.http.Set-Cookie) {
set beresp.ttl = 0s;
return (deliver);
}
if (beresp.http.Cache-Control ~ "no-store|private") {
set beresp.ttl = 0s;
return (deliver);
}
if (beresp.http.Cache-Control ~ "max-age=(\d+)") {
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "^.*max-age=(\d+).*$", "\1") + "s", 0s);
} else {
set beresp.ttl = 300s;
}
set beresp.grace = 6h;
return (deliver);
}
这套逻辑的优先级是先排除不能缓存的异常,再尊重后端给的Cache-Control,后端没给就用300秒兜底。还有一个经常被忽略的点:5xx错误响应千万别缓存,否则后端故障期间吐出来的错误页会被缓存下来,恢复后用户还在持续看错误页,这个坑我见人踩过不止一次。
Vary头也要单独说。Varnish支持Vary,同一个URL可以因为Accept-Encoding不同而保存多个压缩变体,这个机制是自动的。但Vary里的取值越少越好,每次Vary值一多,缓存对象就按变体拆成多份,存储膨胀不说,命中率也会被稀释。后端如果到处乱发Vary,建议在业务侧收敛。
把这一章的几件事列成一张对照表,排查命中率问题时照着看:
| 问题 | 现象 | 对策 |
|---|---|---|
| 无关cookie串扰 | 命中率上不去,但响应内容其实没差异 | recv里剥离无关cookie |
| 登录态影响内容 | 缓存串号或强制不缓存 | 按登录态pass或细分缓存键 |
| 查询参数噪音多 | 同一内容N份缓存对象 | recv里规范化URL |
| 后端返回Set-Cookie | 页面永远不缓存 | 后端移除或前端分离接口 |
| 5xx被缓存 | 故障恢复后用户仍看错误页 | backend_response里对5xx设TTL 0 |
5. 缓存失效三件套:purge、ban、xkey怎么选
5.1 purge:精确删除,权限必须收口
内容更新了,缓存还是旧的,这是所有缓存系统的老难题。Varnish里最直接的失效手段是purge,按缓存键精确删除一个对象。它语义很简单,但直接给了任何人"删缓存"的能力,所以权限必须收口。常规做法是在vcl_recv里拦PURGE方法,只允许可信来源调用:
vcl复制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);
}
}
使用的时候:
bash复制curl -X PURGE http://127.0.0.1:6081/product/123
需要注意,purge是按缓存键精确匹配的。如果你在vcl_hash里自定义了缓存键,比如加了语言维度,那purge请求也必须带完全相同的维度,否则算出来的键对不上,删不掉。很多"purge没反应"的排查最后都回到这个问题上。
5.2 ban:按规则批量失效,注意惰性执行的代价
批量失效是另一种常见需求,比如商品改版要把整个分类下所有页面都失效。这种场景不适合一条条purge,要用ban。ban的思路是不直接删对象,而是立一条规则,每次有请求命中某个对象时去比对规则,匹配上了就不再返回。可以给VCL加上接受BAN方法的能力:
vcl复制sub vcl_recv {
if (req.method == "BAN") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Not Allowed"));
}
ban("req.url ~ " + req.url);
return (synth(200, "Ban Added"));
}
}
命令行这样用:
bash复制varnishadm ban "req.url ~ ^/product/"
这条规则会让所有/product/开头的URL立即失效,下一个请求来时重新回源。但你要清楚ban的代价:它是惰性执行的,规则本身一直留在ban列表里,直到匹配范围内的对象全部过期才会被清理。如果TTL很长、ban规则又很多,每次缓存查找都要多比对一遍规则列表,查找开销会持续累积。所以我的习惯是:精确到单个URL用purge,批量、模式化用ban,但ban规则的粒度别太碎,用完能合并就合并。
5.3 xkey:用标签做批量失效的进阶玩法
如果后端能控制响应头,xkey这套玩法会舒服很多。xkey是vmod_xkey模块提供的能力,核心思路是后端在响应里打标签,运营侧按标签精确失效。比如商品页同时属于product_123和category_456两个标签,发布某个分类改版时只需要purge掉category_456这个标签,所有挂着这个标签的页面一次性失效。
需要安装varnish-modules并加载:
vcl复制import xkey;
sub vcl_recv {
if (req.method == "PURGE") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Not Allowed"));
}
xkey.purge(req.url);
return (synth(200, "Purged by xkey"));
}
}
sub vcl_backend_response {
if (beresp.http.Surrogate-Key) {
unset beresp.http.Surrogate-Key;
}
}
后端在响应头里返回Surrogate-Key: product_123 category_456,Varnish会把这个标签挂到对象上,同时unset掉响应头,避免标签泄露给客户端。失效时执行:
bash复制curl -X PURGE http://127.0.0.1:6081/category_456
xkey最大的价值是把"按URL失效"升级成"按业务维度失效",运营人员不需要知道具体页面URL,缓存治理的抽象层级完全不一样。代价是需要额外装模块、后端要配合打标签,以及对Surrogate-Key头的规范有约束。如果团队规模小、对象数量少,ban就够用了;一旦对象数量和业务维度复杂起来,xkey值的这个投入。
6. 缓存雪崩和故障兜底:grace、健康检查与并发保护
6.1 grace模式:后端挂了如何继续供货
缓存系统最大的悖论是:正常情况下它在保护后端,可万一后端真出了故障,一堆缓存同时过期,不仅保护不了,还会把所有流量瞬间灌给已经生病的后端,造成雪崩。grace模式就是解决这个问题的。
核心思路是:对象过期了不要立刻扔掉,在grace允许的时间窗口内,如果后端不可用,就把"过期但还能看"的内容返回给用户。用户看到的可能是几分钟前的内容,但至少不是5xx错误页。配合健康检查使用的标准写法:
vcl复制sub vcl_recv {
set req.grace = 24h;
}
sub vcl_hit {
if (obj.ttl >= 0s) {
return (deliver);
}
if (obj.grace > 0s && !std.healthy(req.backend_hint)) {
return (deliver);
}
return (miss);
}
这里有两个点必须同时做对:一是vcl_recv里要把req.grace调大,默认只有10秒,兜底效果约等于零;二是依赖std.healthy判断后端状态,而后端必须有健康检查probe,否则std.healthy拿不到真实状态,这个兜底逻辑就失效了。grace的时长设置要根据业务对内容新鲜度的容忍度来定,我对新闻资讯类站点习惯设6到24小时,宁可给用户看旧内容,也不让整个站点在故障期间变白屏。
6.2 健康检查与熔断:让流量绕开生病后端
健康检查是Varnish感知后端状态的唯一途径。给后端配上probe:
vcl复制probe healthcheck {
.url = "/healthz";
.interval = 5s;
.timeout = 1s;
.window = 3;
.threshold = 2;
}
backend web1 {
.host = "127.0.0.1";
.port = "8080";
.probe = healthcheck;
}
含义是每5秒探测一次/healthz,1秒超时算失败,最近3次探测里至少有2次成功才认为后端健康。这个探测路径要选择一个真正能反映服务存活状态的接口,别拿一个永远返回200的静态文件当健康检查,否则探出来的结果全是假阳性。
如果有多台后端,可以用directors做调度,把请求分散到健康的机器上:
vcl复制import directors;
sub vcl_init {
new pool = directors.round_robin();
pool.add_backend(web1);
pool.add_backend(web2);
}
sub vcl_recv {
set req.backend_hint = pool.backend();
}
配合熔断还有一个saint mode机制。当后端连续返回错误,Varnish会暂时把该后端排除出调度,避免每个请求都打到受伤的机器上。可以通过在vcl_backend_error里调整saint mode的窗口:
vcl复制sub vcl_backend_error {
if (beresp.status >= 500) {
set beresp.saintmode = 10s;
return (retry);
}
}
意思是后端返回5xx时,把这段错误"记恨"10秒,期间减少对它的请求。这个机制在混合负载和多后端场景里非常有用,单后端场景下它兜底能力有限,主要还得靠grace。
6.3 并发保护:别让缓存层自己成了风暴中心
缓存放到入口之后,Varnish自己也可能成为瓶颈,尤其是miss风暴的时候。Varnish内部其实内置了一道保护:同一个缓存键的并发miss请求会被合并,第一个请求去后端取数据,其他请求在"busy object"上等待同一个结果,而不是各自向后端发一遍。这就是请求合并(request coalescing),它能天然挡住很大一部分缓存穿透打爆后端的风险。
即便如此,线程池参数还是值得关注。Varnish的工作线程数由thread_pool_min和thread_pool_max控制,默认值一般够用,但遇到大促流量激增,可能出现线程创建跟不上、请求排队的情况。varnishstat里的MAIN.thread_queue_len如果持续增长,说明线程池不够,可以适当调大:
bash复制varnishd -a :6081 -T 127.0.0.1:6082 -s malloc,1G -p thread_pool_max=3000 -p thread_pool_min=50
线程池参数最忌讳乱调,不是设置得越大越好。线程太多反而增加上下文切换和内存占用。我的做法是先保持默认,压测观察thread_queue_len曲线,有真实排队再逐步加,同时警惕另一个信号:MAIN.sess_conn和线程创建数同时飙升时,问题多半出在命中率上而不是线程数上,优先回去调VCL。
7. 线上调优与排障:三板斧加我踩过的坑
7.1 varnishstat:先看命中率,再看驱逐和线程
线上调优第一件事不是看代码,是看统计。varnishstat输出的是Varnish进程的全量计数器,优先盯这几个:
| 指标 | 含义 | 健康信号 |
|---|---|---|
| MAIN.cache_hit | 命中次数 | 配合miss算命中率 |
| MAIN.cache_miss | 未命中次数 | 命中率=hit/(hit+miss) |
| MAIN.n_lru_nuked | 内存不足被LRU逐出次数 | 持续增长说明存储容量不够 |
| MAIN.thread_queue_len | 等待线程的请求队列长度 | 长期大于0考虑加线程 |
| MAIN.backend_busy | 打向后端但后端忙的重试 | 过大说明后端压力高 |
bash复制varnishstat -1 -f MAIN.cache_hit -f MAIN.cache_miss -f MAIN.n_lru_nuked
命中率不等于一切,但它是第一道筛子。如果命中率低于70%,先回去看第4章的内容,把cookie、URL规范化、响应头三关逐个过一遍;如果命中率还行但响应偶尔变慢,看n_lru_nuked,这个数字持续增长说明内存存储被LRU不断挤掉老对象,考虑加存储容量或者换成file存储兜底大对象。
7.2 varnishlog:一次请求为什么没命中
统计只能告诉你"是不是"有问题,varnishlog能告诉你"为什么"有问题。排查一次具体的miss请求:
bash复制varnishlog -g request -q 'ReqURL ~ "^/product/123"'
看日志的链路顺序:ReqMethod和ReqURL确认进来了,VCL_call记录调用了哪个sub,然后关键看有没有Miss标签。如果看到VCL_call recv返回的是pass,说明是vcl_recv里某个判断把请求放行了;如果走到fetch,查看BereqURL和BerespStatus,确认后端到底返回了什么;如果后端返回了Set-Cookie,那在vcl_backend_response阶段就会被标记为不可缓存。
我排查miss请求的标准动作是:先确认vcl_recv没让它pass,再确认vcl_hash算出的键在预期范围内,最后看后端响应头有没有踩了"不可缓存"的红线。三步走完,绝大多数miss原因都能定位。注意varnishlog在高流量下日志量巨大,生产环境别开着全量日志跑,用-q过滤、用完就关。
7.3 我踩过的坑和现在的检查习惯
踩坑总结可能比任何理论都值钱。这些年我在Varnish上遇到过的真实事故,挑几个有代表性的说说:
第一个是管理端口裸奔。部署时图省事,把-T绑在了0.0.0.0:6082,secret文件还是默认的。结果线上被人扫描到管理端口并执行了ban操作,缓存被批量清掉,后端承受了一轮原始流量冲击。现在我的底线是管理端口只监听本机或运维网段,secret文件用独立工具生成,绝不沿用默认值。
第二个是改了VCL直接覆盖生效,出问题没法回滚。VCL是编译型配置,一个语法错可能让Varnish拒绝启动,直接在线上改default.vcl再reload,风险极大。正确的热加载流程是先在本地做编译检查:
bash复制varnishd -C -f /etc/varnish/default.vcl
确认编译通过后再用CLI分两步加载:
bash复制varnishadm vcl.load myvcl_20250101 /etc/varnish/default.vcl
varnishadm vcl.use myvcl_20250101
这样新VCL先load进内存并编译,通过后use才生效,如果use之后发现问题,还能马上切回旧版本。varnishadm vcl.list可以看到所有已加载的VCL版本。
第三个是5xx错误被缓存。某次后端发布过程中返回了一串502,我当时的VCL没有对5xx做TTL归零,这些错误响应被缓存了几分钟,发布完成后的用户还持续收到502。从那以后,beresp.status >= 500必须设TTL为0这条被我写进了所有项目的VCL模板,成了硬性规定。
第四个坑是日志采集工具把Varnish的日志当普通access log处理。Varnish自带的日志系统是共享内存环形缓冲区,不是写文件,varnishlog只是它的读取器,想落盘要单独起varnishncsa。很多刚接触的人找"Varnish的日志文件"找不到,最后发现它根本不写文件。生产环境如果要接日志平台,直接用varnishncsa输出到管道或文件就行。
踩过这些坑之后,我现在的固定流程是:改VCL先编译检查,再load和use分步走;上线前看一遍varnishstat基线,确认命中率和驱逐数正常;每次发布涉及缓存失效,先想清楚用purge还是ban,别随手全量ban;平时盯着cache_hit和n_lru_nuked两条曲线,异常波动第一时间定位是后端改动还是流量形态变化。
个人体会是,Varnish这套东西真正难的不是装起来,而是你想要什么样的缓存行为,得先在心里想明白,然后用VCL精确表达出来。每一条规则都要能回答三个问题:这个请求该缓存吗?缓存多久?失效时怎么让它消失?能回答清楚,上线基本就不会出大乱子。
