键、元数据与可缓存性
主线位置:缓存主线的两个判断点。第一次请求进来时,
cache_key_callback决定「这是哪份缓存」;上游响应头到达时,response_cache_filter决定「能不能存、存多久」,答案是一个CacheMeta。第二次请求查到的就是这个键下的这份元数据,is_fresh决定它是Hit还是Stale。
flowchart TB
resp[上游响应头] --> cc["CacheControl 解析"]
cc --> rc[resp_cacheable]
rc --> auth{"有 Authorization"}
auth -->|没有 public 等| no([不可缓存])
auth --> ns{"no-store 或 private"}
ns -->|是| no
ns --> ttl{"s-maxage 或 max-age"}
ttl -->|有| meta[CacheMeta new]
ttl -->|没有| exp{Expires}
exp -->|有| meta
exp -->|没有| def{"默认 TTL"}
def -->|有| meta
def -->|None| no
涉及的文件:key.rs(434 行)、meta.rs(1279 行)、cache_control.rs(1273 行)、filters.rs(683 行)、variance.rs(126 行),都含单元测试。实验在 exp-pingora/cache-lab(环境同 缓存状态机:pingora 0.9.0,tokio 1.53.2,debug;代理和上游关键结果在 release 下复跑过):ccparse 不起代理,直接把一组响应头喂给 CacheControl::from_resp_headers 和 resp_cacheable;其余是经过代理的请求。
CacheKey:只有 primary 被哈希
pub struct CacheKey {
// Primary is essentially a string, except it allows invalid UTF-8 sequences.
// This field should be able to be hashed.
primary: Vec<u8>,
primary_bin_override: Option<HashBinary>,
variance: Option<HashBinary>,
/// An extra tag for identifying users
///
/// For example, if the storage backend implements per user quota, this tag can be used.
pub user_tag: String,
/// Grab-bag for user-defined extensions. These will not be persisted to disk.
pub extensions: Extensions,
}
CacheKey::new(primary, user_tag) 只存字节,哈希在用的时候才算:
primary_bin():对primary做 Blake2b,输出 16 字节(HashBinary)。primary_bin_override设了就直接用它,跳过哈希。combined_bin():没有变体时等于primary_bin();有变体时再对「primary 的哈希 + 变体哈希」做一次 Blake2b。存储和缓存锁都用这个值:MemCache的表以key.combined()为键,CacheLock以combined_bin()为键。- 选 Blake2b 的理由写在注释里:要跨机器稳定,所以不能用
ahash这类只为内存哈希表设计的函数。 CompactCacheKey是存进淘汰管理器的形式:两个哈希加user_tag,不再带原始字节。
user_tag 不进哈希。ccparse 的输出:
raw concat: 6c79e74e88bacb8eb370adb7617068c8 vs 6c79e74e88bacb8eb370adb7617068c8
length-prefixed: 40732393477dfe6e6cbeabce68ea9868 vs 2950edc9ac9184b76bf94d9a704dbd6f
user_tag differs: 67b48a3723d34e91c49421f5aaaca8fb vs 67b48a3723d34e91c49421f5aaaca8fb
第三行是 CacheKey::new("/same", "user-1") 和 CacheKey::new("/same", "user-2"):哈希相同,MemCache 会把它们存在同一个槽里。按注释,user_tag 是给存储做配额统计用的;要按用户隔离缓存,用户标识必须编进 primary。
前两行是同一件事的另一面。("tenant_a", "/path") 和 ("tenant_", "a/path") 直接拼接,得到的字节一样,哈希也一样;每段前面加长度前缀,就分开了。实验代理的 cache_key_callback 就是这样做的:host 和 path_and_query 各加 4 字节长度。
pingora 没有给出默认的缓存键。cache_key_callback 的默认实现是 unimplemented!,文档注释写「There is no sensible default cache key for all proxy applications」,键选错会导致缓存投毒(cache poisoning)。它指向测试代码里的一个参考实现,那个实现用 {host}{path_and_query} 直接拼接,注释写着「not production ready」。
namespace 参数0.8.0 的构造函数是 CacheKey::new(namespace, primary, user_tag),primary_hasher 先 update(namespace) 再 update(primary),两段之间没有分隔。51fb7e7(「Remove cache key namespace parameter」,首次进入 0.9.0)的说明是:这种拼接「allowing distinct component pairs to produce the same key」,于是干脆删掉这个「misleading」的字段,让调用者自己在 primary 里给各段加边界。同一个提交补了上面那两个测试:直接拼接保留旧哈希(6c79e74e…),也保留了歧义;加长度前缀不再碰撞,但哈希变了,提交说明直说「adopting framed keys creates a cold cache」。CHANGELOG 0.9.0 把它列在「Potential Breaking Changes」里:「Removed the CacheKey namespace parameter」。
照 0.8.0 的写法调用,编译错误是:
error[E0061]: this function takes 2 arguments but 3 arguments were supplied
--> src/bin/oldkey.rs:5:13
|
5 | let key = CacheKey::new("my-namespace", "/index.html", "");
| ^^^^^^^^^^^^^ -- unexpected argument #3 of type `&'static str`
|
note: associated function defined here
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-cache-0.9.0/src/key.rs:223:12
|
223 | pub fn new<B, S>(primary: B, user_tag: S) -> Self
| ^^^
help: remove the extra argument
|
5 - let key = CacheKey::new("my-namespace", "/index.html", "");
5 + let key = CacheKey::new("my-namespace", "/index.html");
|
...
CacheKey::new("my-namespace", "/a.html") 和 CacheKey::new("my-namespace", "/b.html"),两个键的 combined() 一样吗?CacheMeta:一份响应头加几个时间
CacheMeta 是 Box<CacheMetaInner>:要存的 ResponseHeader、一份内部元数据、一个不保证持久化的 Extensions。内部元数据现在是 InternalMetaV2:
| 字段 | 含义 |
|---|---|
fresh_until | 新鲜期的截止时刻(SystemTime) |
created | 入库时刻 |
updated | 最近一次重新验证的时刻,新建时等于 created |
stale_while_revalidate_sec、stale_if_error_sec | 两个 stale 窗口的秒数,从 fresh_until 往后算 |
variance | 变体哈希,见下面的 Vary |
epoch_override | 算 Age 的起点,覆盖 updated |
provenance | 0.9.0 新增:这份资产「血统」的时间戳,重新验证时沿用旧值 |
判断全是时间比较:
is_fresh(t)是fresh_until >= t。can_serve_stale:窗口为 0 就是false,否则看fresh_until + 窗口 >= t,溢出时当成无限。注释特意写了它不检查!is_fresh,调用者要先确定资产已经过期。age()是now - epoch(),epoch()默认是updated,所以重新验证会把Age清零(状态机那一页的实验里,304 之后的命中age=0)。
存储时分两部分序列化(serialize):内部元数据用 MessagePack(rmp_serde),响应头交给 pingora-header-serde(见 请求头与错误)。没调过 set_compression_dict_path / set_compression_dict_content 时,第一次序列化会打一条警告,实验里 RUST_LOG=warn 时看得到:
[2026-10-29T06:00:52Z WARN pingora_cache::meta] no header compression dictionary loaded - use set_compression_dict_content() or set_compression_dict_path() to set one
内部元数据有 V0、V1、V2 三个版本,deserialize 能读所有旧版本。V2 之后加字段的规矩写在结构体里:一次只加一个,先用 #[serde(default)] 和 skip_serializing_if 让新旧版本互相能读,全量发布后再去掉 skip_serializing_if。provenance 现在就处在第一步。
CacheMetaDefaults 是响应没写有效期时的兜底:一个「状态码 → TTL」的函数,加两个默认窗口。fresh_sec 对 304 一律按 200 的 TTL 算,注释说这是一道保险,让 304 和 200 的默认 TTL 一致。函数对某个状态码返回 None,这个状态码默认就不缓存。
Cache-Control 的解析:宽松
CacheControl::from_headers 用一个正则逐行扫 Cache-Control,指令名转小写,已知的映射成 DirectiveKey 的枚举变体,值放进一个 IndexMap。注释列了它比 RFC 宽松的地方:分号也算分隔符、数值可以加引号、布尔指令带了值也认。ccparse 把一组响应头逐个喂进去(NO_DEFAULT 对所有状态码都给 None):
Cache-Control: max-age=60 fresh_duration=Some(60) -> Cacheable fresh_sec=60 swr=0 sie=0 set-cookie kept=false
Cache-Control: max-age=10, max-age=100 fresh_duration=Some(100) -> Cacheable fresh_sec=100 swr=0 sie=0 set-cookie kept=false
Cache-Control: MAX-AGE=30; public fresh_duration=Some(30) -> Cacheable fresh_sec=30 swr=0 sie=0 set-cookie kept=false
Cache-Control: max-age=abc fresh_duration=None -> Uncacheable(OriginNotCache)
Cache-Control: max-age=1.9 fresh_duration=None -> Uncacheable(OriginNotCache)
Cache-Control: max-age=1.9 fresh_duration=Some(1) -> Cacheable fresh_sec=1 swr=0 sie=0 set-cookie kept=false [float]
Cache-Control: max-age=99999999999 fresh_duration=Some(2147483647) -> Cacheable fresh_sec=2147483647 swr=0 sie=0 set-cookie kept=false
Cache-Control: no-cache fresh_duration=Some(0) -> Cacheable fresh_sec=0 swr=0 sie=0 set-cookie kept=false
Cache-Control: no-cache, max-age=60 fresh_duration=Some(0) -> Cacheable fresh_sec=0 swr=0 sie=0 set-cookie kept=false
Cache-Control: private, max-age=60 fresh_duration=Some(60) -> Uncacheable(OriginNotCache)
Cache-Control: private="set-cookie", max-age=60 | Set-Cookie: a=1 fresh_duration=Some(60) -> Cacheable fresh_sec=60 swr=0 sie=0 set-cookie kept=false
Cache-Control: max-age=60 | Set-Cookie: a=1 fresh_duration=Some(60) -> Cacheable fresh_sec=60 swr=0 sie=0 set-cookie kept=true
Cache-Control: no-store, max-age=60 fresh_duration=Some(60) -> Uncacheable(OriginNotCache)
Cache-Control: s-maxage=5, max-age=60, stale-while-revalidate=30 fresh_duration=Some(5) -> Cacheable fresh_sec=5 swr=0 sie=0 set-cookie kept=false
Cache-Control: max-age=5, stale-while-revalidate=30, stale-if-error=600 fresh_duration=Some(5) -> Cacheable fresh_sec=5 swr=30 sie=600 set-cookie kept=false
Cache-Control: max-age=5, must-revalidate, stale-if-error=600 fresh_duration=Some(5) -> Cacheable fresh_sec=5 swr=0 sie=0 set-cookie kept=false
Expires: Thu, 01 Jan 2099 00:00:00 GMT fresh_duration=None -> Cacheable fresh_sec=2277655097 swr=0 sie=0 set-cookie kept=false
Expires: not a date fresh_duration=None -> Cacheable fresh_sec=0 swr=0 sie=0 set-cookie kept=false
fresh_duration=None -> Uncacheable(OriginNotCache)
逐行对应源码:
- 重复的指令,后一个赢。
IndexMap::insert遇到已有的键只替换值,max-age=10, max-age=100得到 100。 - 数值解析失败就当没写。
fresh_duration里max_age()的错误被unwrap_or(None)吞掉,退到Expires,再退到默认值。max-age=1.9在严格模式下就是这样变成了不可缓存;0.9.0 新增的with_float_seconds(6c523ee,首次进入 0.9.0,CHANGELOG「Support optionally flooring fractional delta-seconds for RFC 9111 handling」)向下取整成 1。过大的数截到i32::MAX(2147483647),RFC 9111 要求把溢出当成「无限」。 no-cache是「可以存、每次用之前都要验证」。fresh_duration返回 0,calculate_fresh_until把 0 换成now - 1s,存进去的资产一出生就过期。private和no-store不可缓存。 pingora 按共享缓存(shared cache)的规则解释,private只对浏览器缓存有效。带字段名的private="set-cookie"不算整体私有:响应照存,只是把列出的头删掉(strip_private_headers,no-cache="…"同理)。s-maxage优先于max-age,而且关掉两个 stale 窗口。serve_stale_while_revalidate_duration在must-revalidate、proxy-revalidate、s-maxage任一存在时返回 0:RFC 7234 说这几条禁止给出过期响应,s-maxage隐含proxy-revalidate。Expires的两种坏情况都被存下来。calculate_expires_header_time:多个Expires头当成没写;日期解析不了按 RFC「当成过去的时间」处理,返回UNIX_EPOCH。于是Expires: not a date的响应被存了,fresh_sec=0,和no-cache一样每次都要验证。- 什么都没写,就看默认值。 这里默认值全是
None,所以不可缓存。
resp_cacheable 的第三个参数 authorization_present 由调用者传:请求带 Authorization 时,只有响应有 must-revalidate、public 或 s-maxage 才能存。实验代理和测试里的参考实现都传的是 false。
问: no-cache 字面意思是「不缓存」,为什么 pingora 把它存下来了?
答: HTTP 里不让存的是 no-store。no-cache 的意思是「存下来可以,但每次拿出来用之前都要找源站确认」。pingora 的做法是让它一入库就过期:下一个请求查到的是 Stale,filters::upstream::request_filter 按存下的 ETag / Last-Modified 加上条件头,上游回 304 时 body 不用重传,回 200 就换新。比起每次都不缓存,省的是 body 的流量;代价是每个请求仍然要等一次上游。
默认不管的三件事
resp_cacheable 只看响应头;请求方法、Set-Cookie、Vary 都要用户自己处理。
请求方法。 filters.rs 有一个 request_cacheable,只认 GET 和 HEAD,但 pingora-proxy 里没有任何地方调用它。实验代理的 request_cache_filter 对所有请求都开缓存,测试里的参考实现也是(它头一行注释是「TODO: only allow GET & HEAD」)。先 GET /hello,再 POST、DELETE 同一个 URL:
hello #1
<- GET x-cache=miss
hello #1
<- POST x-cache=hit
hello #1
<- DELETE x-cache=hit
上游只收到第一个 GET,POST 和 DELETE 从缓存拿到了 GET 的响应,根本没到上游。反过来也一样:先 POST /hello,它的响应带 max-age=60 被存下来,接着的 GET 命中这份 POST 的响应(origin #1 POST /hello,然后 x-cache=hit)。release 构建结果相同。在 request_cache_filter 里先检查方法再 enable,才能避免这种情况。
Set-Cookie。 上面 ccparse 输出里 max-age=60 | Set-Cookie: a=1 那一行:只有 max-age=60 时 Set-Cookie 原样留在元数据里。经过代理请求两次 /cookie(上游每次发不同的 sid):
/cookie 1 -> 200 x-cache=miss set-cookie=sid=user1
/cookie 2 -> 200 x-cache=hit set-cookie=sid=user1
第二个客户端拿到了第一个客户端的会话 cookie。filters.rs 自己的单元测试 test_resp_cache_no_cache_fields 断言的就是这个行为:没有 private="set-cookie" 或 no-cache="set-cookie" 时,Set-Cookie 被保留。要么在 response_cache_filter 里遇到 Set-Cookie 就返回不可缓存,要么在 upstream_response_filter 里删掉它(那里的改动会进缓存)。
Vary。 cache_vary_filter 的默认实现返回 None,注释是「default to None for now to disable vary feature」。上游 /vary 回 Vary: Accept-Language,body 回显请求的语言。让实验代理的 cache_vary_filter 返回 None(NOVARY=1):
lang=en #1
<- asked en, x-cache=miss
lang=en #1
<- asked fr, x-cache=hit
要法语的客户端拿到了英语的版本。下一节是自己实现以后的样子。
Vary:主槽和副槽
实验代理的 cache_vary_filter 照着测试里的参考实现写:读存下的响应头里的 Vary,把列出的每个请求头的值加进 VarianceBuilder。它是一个 BTreeMap,finalize 按名字排序后依次哈希「名、0 字节、值、0 字节」,所以和添加顺序无关;一个值都没有时返回 None,表示没有变体。三个请求依次带 en、fr、en:
lang=en #1
x-cache=miss
lang=fr #2
x-cache=miss
lang=en #1
x-cache=hit
上游收到两个请求(accept-language=en、accept-language=fr)。存储里有两个槽:
en的请求未命中。响应带Vary,cache_http_task算出变体哈希写进元数据;这时键里还没有变体,update_variance保持原样,资产写进主槽(combined_bin等于primary_bin的那个位置)。fr的请求先查到主槽,cache_vary_lookup用fr算出的变体和元数据里的en不同,把变体写进键、返回false,proxy_cache的循环continue,用新的combined_bin再查一次。没有,未命中,写进这个副槽。- 再来
en:主槽的变体对得上,cache_vary_lookup把键里的变体去掉(主槽要用不带变体的键读写),命中。
cache_vary_lookup 还顺手设了 valid_after = meta.provenance():副槽里早于主槽这个时间戳的资产一律当成不存在。注释解释了原因:清除一个 URL 时只删主槽,主槽的时间戳就是所有旧副槽的「墓碑」。provenance 在重新验证时沿用旧值,只在 Vary 变了时重置(fdc63f7,首次进入 0.9.0,CHANGELOG「Preserve Vary provenance across stale refreshes」),否则一次刷新就会让所有副槽失效。
动脑筋答案
rustc-help migration: 607d3271325b5a9414ad7653da2ce9e8 vs 607d3271325b5a9414ad7653da2ce9e8
一样。rustc 只知道参数个数不对,建议删掉最后一个;删完以后 "my-namespace" 成了 primary,路径成了 user_tag。user_tag 不参与哈希,于是同一个命名空间下的所有 URL 都是同一个键:第一个被缓存的响应会被当成所有路径的响应。正确的迁移是把命名空间编进 primary,例如 CacheKey::new(framed(ns, path), "");想保持 0.8.0 的旧哈希、接受它的歧义,就把两段直接拼起来。
小结
CacheKey只哈希primary(Blake2b,16 字节);user_tag不进哈希,不能用来隔离用户;多段内容要自己加边界。0.9.0 删掉了namespace,照 rustc 的提示改会让一个命名空间下的所有 URL 撞成一个键。CacheMeta是响应头加fresh_until、created、updated、两个 stale 窗口、变体和provenance;新鲜、stale、Age都是时间比较。Cache-Control解析比 RFC 宽松:分号、大小写、引号都认,重复指令后者赢,数值解析失败当成没写;no-cache和坏的Expires都会存成「一入库就过期」。private、no-store不存;s-maxage、must-revalidate、proxy-revalidate关掉 stale 窗口。- 默认不检查请求方法、不删
Set-Cookie、不看Vary:POST 会命中 GET 的缓存,cookie 会串给别人,不同语言共用一份,三件事都要在钩子里自己处理。