在使用Ruby的HTTPX库做HTTP客户端开发时,ResponseCache插件能帮我们缓存服务器响应,避免重复请求。但不少人会发现一个奇怪的现象:明明请求的是同一个URL,缓存却有时候命中、有时候不命中,甚至拿到了错误的缓存内容。这背后的关键角色就是Vary响应头,以及插件内部负责生成缓存键的KeyGenerator::Headers::Vary模块。理解这套机制,是用好HTTPX响应缓存的前提。

一、Vary响应头到底起什么作用
按照HTTP规范的定义,Vary响应头用来告诉缓存系统:这份响应的内容会根据哪些请求头的值发生变化。比如服务器返回了Vary: Accept-Encoding,就意味着同一个URL对于gzip压缩请求和未压缩请求会返回不同内容。缓存系统如果忽略这一点,把gzip版本的响应直接返回给不支持gzip的客户端,就会出现乱码或解压失败。
常见的Vary取值包括Accept-Encoding(压缩方式不同)、Accept-Language(语言版本不同)、User-Agent(针对不同浏览器或设备返回不同内容)以及Cookie或Authorization(内容与登录态相关)。其中Vary: *是一个特殊值,表示响应完全不可缓存,因为服务器认为内容受未知因素影响,任何缓存行为都可能出错。
在HTTPX的ResponseCache插件里,这些语义被完整实现了。缓存命中判断并不是简单地比对URL,而是把Vary指定的请求头值一起纳入缓存键的计算,从源头上杜绝了缓存串味的问题。
二、KeyGenerator如何结合Vary生成缓存键
HTTPX的ResponseCache插件在写入缓存时会保存两部分信息:一是响应本身的序列化数据,二是与缓存键相关的请求头摘要。当下次请求到来时,KeyGenerator会先根据URL找到该URL下的所有缓存条目,再逐条比对Vary声明的请求头是否匹配。
简化后的逻辑大致如下,摘自HTTPX源码中ResponseCache相关的处理思路:
def vary_headers(request, response)
vary = response.headers["vary"]
return [] if vary.nil? || vary == "*"
# 将Vary头按逗号拆分,取出所有需要参与匹配的请求头名称
vary.split(",").map(&:strip).map(&:downcase)
end
def cache_key(request, vary_header_names)
key = request.origin + request.path
# 把每个Vary指定请求头的当前值拼进缓存键
vary_header_names.each do |name|
key += ":#{name}=#{request.headers[name]}"
end
key
end
这段代码揭示了一个核心细节:Vary的值会被按逗号拆分并统一转成小写再匹配。HTTP头名称本身是不区分大小写的,如果直接用原始大小写比对,accept-encoding和Accept-Encoding会被当成两个不同的头,导致缓存永远无法命中。HTTPX在这一点上做了规范化处理,使用时不必担心大小写差异带来的坑。
另一个值得注意的细节是Vary: *的处理。一旦响应头中出现这个值,插件会直接放弃缓存该响应,这与HTTP规范的要求一致。所以如果你发现某些接口始终不走缓存,第一件事就应该检查响应头里是不是带了Vary: *。
三、实际使用中的常见问题与排查方法
第一个典型问题是缓存命中率远低于预期。比如你用一个默认开启压缩的客户端做请求,服务器返回Vary: Accept-Encoding,此时缓存键里包含了accept-encoding=gzip。如果后续某个请求没有显式设置压缩,或者压缩算法从gzip换成了br(Brotli),缓存键就变了,自然无法命中。解决办法是在发起请求时保持请求头的一致性,不要让Vary涉及的头在不同请求间随机波动。
第二个问题恰好相反,是缓存污染。有些服务器返回Vary: User-Agent,而爬虫程序中User-Agent是随机轮换的,这会导致同一个页面被缓存成无数份,既浪费存储又降低命中率。应对方式有两种:要么在启用缓存时固定一个User-Agent,要么通过自定义插件在计算缓存键时排除某些不关心的请求头。下面的例子演示了如何在插件层面定制行为:
require "httpx"
session = HTTPX.plugin(:response_cache)
session = session.plugin(HashBuilder) # 假设HashBuilder用于控制缓存序列化方式
# 固定请求头,保证Vary参与匹配的值稳定
session = session.with_headers(
"accept-encoding" => "gzip",
"accept-language" => "zh-CN"
)
# 第一次请求:真实发出,响应写入缓存
response = session.get("https://ipipp.com/api/data")
puts response.status # 例如 200
# 第二次请求:直接命中缓存,不发网络请求
cached = session.get("https://ipipp.com/api/data")
puts cached.from_cache? if cached.respond_to?(:from_cache?)
第三个问题是调试手段。HTTPX的ResponseCache插件为响应对象扩展了from_cache?方法,可以用来判断当前响应是否来自缓存。在排查命中问题时,建议先打印实际的响应头,确认Vary的真实取值,再对照请求时发送的对应请求头,基本就能定位到差异所在。如果对方服务器返回的Vary列表很长(有些CDN会返回Vary: Accept-Encoding, Accept-Language, Accept, User-Agent这种组合),就要评估是否有必要针对它做缓存,必要时干脆对该域名禁用缓存插件。
四、设计层面的思考:缓存键的粒度与正确性
Vary机制本质上是在回答一个问题:什么维度上的请求差异才算内容差异?粒度太粗会出现串味缓存,粒度太细会让缓存形同虚设。HTTPX选择严格遵循服务器声明的Vary,这是最稳妥的策略,把内容差异的裁决权交给最了解响应内容的一方——服务器本身。
但在客户端侧,我们仍然有优化空间。比如对于纯API场景,如果确定某个接口的内容只受Authorization影响,可以在封装请求时主动覆盖或统一Accept-Language等头,减少Vary匹配的干扰项。反过来,如果接口内容与登录态相关而服务器没有声明Vary: Authorization,这就是服务端的缓存配置缺陷,客户端做缓存反而有风险,此时应该放弃缓存或自行把Authorization摘要加入缓存键。
总结一下,HTTPX的ResponseCache插件通过KeyGenerator将Vary声明的请求头纳入缓存键计算,实现了符合HTTP语义的缓存隔离。开发中要重点关注三点:Vary涉及的头在请求间保持稳定、警惕Vary: *导致缓存失效、利用from_cache?验证缓存命中情况。把这三点处理好,响应缓存才能真正为程序提速而不是制造隐蔽的脏数据问题。
HTTPXResponseCacheVary修改时间:2026-09-16 06:56:35