上个月处理一个资讯站的线上问题,后端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.
