POST 命中 GET 的缓存、Set-Cookie 发给别人
主线位置:缓存主线的三处。查找之前(
request_cache_filter决定这个请求用不用缓存)、写入之前(response_cache_filter决定这个响应能不能存)、命中之后(cache_vary_filter决定存下的这一份是不是给这个请求的)。三件事的机制见键、元数据与可缓存性的「默认不管的三件事」和「Vary」两节。
flowchart TB
req["请求"] --> m{"方法"}
m -->|"默认 不看"| look["按键查找"]
look --> hit["命中 GET 的那份"]
up["上游响应"] --> sc{"Set-Cookie"}
sc -->|"默认 照存"| store["连 cookie 一起存"]
hit2["命中"] --> v{"Vary"}
v -->|"默认 None"| same["所有语言同一份"]
实验 crate exp-pingora/pitb-lab,bin p23_cache,脚本 tools/p23.sh 加客户端 tools/c24.sh,上游 tools/origin.py(debug);M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。起点是上一章的修法:RCF=1,response_cache_filter 用 resp_cacheable(打开了缓存却什么都没缓存)。上游三个路径都带 Cache-Control: max-age=60:/hello 的 body 写着第几次、什么方法;/cookie 每次发一个新的 Set-Cookie: sid=userN;/vary 带 Vary: Accept-Language,body 回显请求的语言。
现象
客户端依次:GET、POST、DELETE 同一个 /hello;两个不同的客户端各请求一次 /cookie(输出里叫 alice 和 bob,请求本身完全一样,都不带 cookie);/vary 按 en、fr、en 请求三次:
hello #1 (GET)
<- GET x-cache=miss
hello #1 (GET)
<- POST x-cache=hit
hello #1 (GET)
<- DELETE x-cache=hit
/cookie as alice -> x-cache=miss set-cookie=sid=user1; Path=/
/cookie as bob -> x-cache=hit set-cookie=sid=user1; Path=/
lang=en #1
<- asked en, x-cache=miss
lang=en #1
<- asked fr, x-cache=hit
lang=en #1
<- asked en, x-cache=hit
## origin
origin #1 GET /hello
origin #1 GET /cookie
origin #1 GET /vary accept-language=en
POST和DELETE根本没到上游,拿到的是GET存下的hello #1 (GET)。一个下单或删除请求就这样被吞掉了,客户端还收到 200。- bob 收到了 alice 的
sid=user1。会话 cookie 串给了别人。 - 要法语的客户端拿到了英语版本。
上游各只收到一个请求,剩下的全由缓存回答。
出处
方法。 filters::request_cacheable 只认 GET 和 HEAD,但 pingora-proxy 里没有任何地方调它。proxy_cache 只看 session.cache.enabled():request_cache_filter 里 enable 了,什么方法都按键查找;而 cache_key_callback 生成的键里通常没有方法。
Set-Cookie。 resp_cacheable 只看 Cache-Control / Expires;strip_private_headers 只剥 private="..." 和 no-cache="..." 里点名的头。上游只写了 max-age=60,Set-Cookie 就原样进了元数据,命中时原样回给每一个人。
Vary。 默认的 cache_vary_filter 返回 None,注释是「default to None for now to disable vary feature」;没有变体,一个键只有一份。
问: pingora-cache 里既然有 request_cacheable,为什么代理不调它?
答: 源码里看不出理由,只看得到事实:这个函数从 0.1.0 起就在 filters.rs 里,函数体第一行是 // TODO: the check is incomplete,0.9.0 的整个 pingora-proxy 里没有对它的调用(git grep request_cacheable 0.9.0 只命中定义本身)。缓存开不开、对哪些请求开,完全由 request_cache_filter 决定,pingora 把这件事整个交给了用户。测试里的参考实现对所有请求都 enable,头一行注释是「TODO: only allow GET & HEAD」。
修法
三个钩子各管一件(SAFE=1):
fn request_cache_filter(&self, session: &mut Session, _: &mut ()) -> Result<()> {
if !matches!(session.req_header().method, http::Method::GET | http::Method::HEAD) {
return Ok(()); // leave the cache disabled for this request
}
session.cache.enable(&*STORAGE, None, None, None, None);
Ok(())
}
fn response_cache_filter(&self, session: &Session, resp: &ResponseHeader, _: &mut ()) -> Result<RespCacheable> {
if resp.headers.contains_key("set-cookie") {
return Ok(RespCacheable::Uncacheable(NoCacheReason::Custom("set-cookie")));
}
let cc = CacheControl::from_resp_headers(resp);
let auth = session.req_header().headers.contains_key("authorization");
Ok(resp_cacheable(cc.as_ref(), resp.clone(), auth, &DEFAULTS))
}
fn cache_vary_filter(&self, meta: &CacheMeta, _: &mut (), req: &RequestHeader) -> Option<HashBinary> {
let mut v = VarianceBuilder::new();
let names: Vec<String> = meta
.headers()
.get_all("vary")
.iter()
.filter_map(|h| h.to_str().ok())
.flat_map(|h| h.split(','))
.map(|s| s.trim().to_lowercase())
.collect();
for n in names {
let val = req.headers.get(n.as_str()).map(|h| h.as_bytes().to_vec()).unwrap_or_default();
v.add_owned_name_value(n, val);
}
v.finalize()
}
同样的请求序列:
hello #1 (GET)
<- GET x-cache=miss
hello #2 (POST)
<- POST x-cache=disabled
hello #3 (DELETE)
<- DELETE x-cache=disabled
/cookie as alice -> x-cache=disabled set-cookie=sid=user1; Path=/
/cookie as bob -> x-cache=disabled set-cookie=sid=user2; Path=/
lang=en #1
<- asked en, x-cache=miss
lang=fr #2
<- asked fr, x-cache=miss
lang=en #1
<- asked en, x-cache=hit
## origin
origin #1 GET /hello
origin #2 POST /hello
origin #3 DELETE /hello
origin #1 GET /cookie
origin #2 GET /cookie
origin #1 GET /vary accept-language=en
origin #2 GET /vary accept-language=fr
## proxy
logging GET /hello phase=miss reason=- status=Some(200) err=None
logging POST /hello phase=disabled reason=NeverEnabled status=Some(200) err=None
logging DELETE /hello phase=disabled reason=NeverEnabled status=Some(200) err=None
logging GET /cookie phase=disabled reason=set-cookie status=Some(200) err=None
logging GET /cookie phase=disabled reason=set-cookie status=Some(200) err=None
logging GET /vary phase=miss reason=- status=Some(200) err=None
logging GET /vary phase=miss reason=- status=Some(200) err=None
logging GET /vary phase=hit reason=- status=Some(200) err=None
POST、DELETE不开缓存(NeverEnabled),直达上游。- 带
Set-Cookie的响应不存,两个人各拿到自己的 cookie。想缓存页面又不想串 cookie,另一条路是在upstream_response_filter里把Set-Cookie删掉(那里的改动会进缓存),前提是这个页面本来就不该给 cookie。 Vary生效:fr是另一个变体,未命中、去上游;第二个en命中自己那一份。VarianceBuilder按名字排序后哈希,和Vary里头的顺序无关;没有Vary的响应finalize()返回None,照旧一份。- 这个实现照搬了
Vary里的每个头的原值。Accept-Language、User-Agent这类头取值很散,每个不同的值都是一个变体、一份存储;可以先归一化(比如只取主语言)再加进去。
怎么确认没中招
- 对一个可缓存的 URL 先
GET再POST:POST不该命中,上游应该收到它。 - 上游给每次响应发不同的
Set-Cookie,经代理请求两次,比较两次的值。 - 上游带
Vary: Accept-Language,换着语言请求,看 body 是否跟着变。 - 代码里搜:
request_cache_filter里有没有方法判断,response_cache_filter里有没有set-cookie,有没有覆盖cache_vary_filter。
小结
- pingora 的缓存默认不看请求方法(
request_cacheable存在但没人调用)、不剥Set-Cookie、cache_vary_filter返回None忽略Vary。 - 实测:
POST/DELETE拿到GET的缓存且没到上游;第二个客户端收到第一个客户端的会话 cookie;不同语言共用一份。 - 修法:
request_cache_filter只对GET/HEAD开缓存;response_cache_filter遇到Set-Cookie不存;cache_vary_filter用VarianceBuilder按Vary列出的头算变体。