Rust in Everything
基于 pingora-proxy 0.9.0

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」;没有变体,一个键只有一份。

📝NOTE
没有蠢问题

问: 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 列出的头算变体。

💬 关联讨论

发起讨论
加载中...