Rust in Everything
基于 pingora-proxy 0.9.0

response too large

主线位置:缓存主线第四步,未命中时一边把上游响应转发给下游、一边写进存储。cache_http_task 每写一块 body 就累加一次大小,超过上限时整个请求出错。最大文件大小和预测器的机制见存储与淘汰。

flowchart TB
  head["上游响应头"] --> cl{"有 Content-Length"}
  cl -->|"有 且超限"| off["disable 照常转发"]
  cl -->|"没有 chunked"| body["逐块写缓存"]
  body --> sum{"累计超限"}
  sum -->|否| body
  sum -->|是| err["response too large"]
  err --> cut["下游连接中断"]

实验 crate exp-pingora/pitb-lab,bin p23_cache,脚本 tools/p23.sh 加客户端 tools/c25.sh,上游 tools/origin.py(debug);M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。缓存的写法是 打开了缓存却什么都没缓存 的修法(RCF=1),另加 MAXFILE=3000:request_cache_filter 里 session.cache.set_max_file_size_bytes(3000)。上游 /sized?n=5000 回 5000 字节、带 Content-Length;/big?n=5000 回 5000 字节的分块响应,每块 1000 字节;两者都是 max-age=60。

现象

/sized 两次,/big 三次:

/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=miss size=3000 exit=18
/big?n=5000 -> 200 x-cache=miss size=3000 exit=18
/big?n=5000 -> 200 x-cache=miss size=0 exit=18
logging GET /sized?n=5000 phase=disabled reason=ResponseTooLarge status=Some(200) err=None
logging GET /sized?n=5000 phase=disabled reason=ResponseTooLarge status=Some(200) err=None
[2026-10-29T06:47:13Z ERROR pingora_proxy] Fail to proxy:  response too large context: Peer: addr: 127.0.0.1:19381, scheme: HTTP cause:  context: writing data of size 922 bytes would exceed max file size of 3000 bytes, status: 500, tries: 1, retry: false, GET /big?n=5000, Host: 127.0.0.1:19380
logging GET /big?n=5000 phase=disabled reason=StorageError status=Some(200) err=Some(" response too large context: Peer: addr: 127.0.0.1:19381, scheme: HTTP cause:  context: writing data of size 922 bytes would exceed max file size of 3000 bytes")
...
  • 带 Content-Length 的 /sized:不缓存(ResponseTooLarge),5000 字节完整送到。这是设计的行为。
  • 分块的 /big:客户端每次都拿到 200 的响应头,body 被截断,curl 退出码 18(CURLE_PARTIAL_FILE)。截在哪里不固定:同一个程序跑了 4 轮共 12 次 /big,5 次收到 3000 字节,7 次收到 0 字节。
  • 日志写的是 status: 500,客户端实际收到的是 200 加半截 body;logging 里的原因是 StorageError,不是 ResponseTooLarge。
  • 每次都这样:第三次和第一次一样被截断,这个 URL 对所有客户端都是坏的,直到缓存配置改掉。

出处

cache_http_task 处理每一块 body:

                    Some(d) => {
                        if session.cache.enabled() {
                            // TODO: do this async
                            // fail if writing the body would exceed the max_file_size_bytes
                            let body_size_allowed =
                                session.cache.track_body_bytes_for_max_file_size(d.len());
                            if !body_size_allowed {
                                debug!("chunked response exceeded max cache size, remembering that it is uncacheable");
                                session
                                    .cache
                                    .response_became_uncacheable(NoCacheReason::ResponseTooLarge);

                                return Error::e_explain(
                                    ERR_RESPONSE_TOO_LARGE,
                                    format!(
                                        "writing data of size {} bytes would exceed max file size of {} bytes",
                                        d.len(),
                                        session.cache.max_file_size_bytes().expect("max file size bytes must be set to exceed size")
                                    ),
                                );
                            }

响应头阶段只有 Content-Length 能提前判断大小,超过就 disable(ResponseTooLarge)、照常转发;分块响应只能边收边数,数到超过上限时返回一个 ERR_RESPONSE_TOO_LARGE 错误,它就是 ErrorType::Custom("response too large"),日志开头那个 response too large 由此而来。这个错误一路回到转发循环,请求失败;响应头早已写给下游,代理能做的只有断开连接。response_became_uncacheable 会告诉预测器「这个键太大」,可这里没有配预测器,这句话没人听,于是下一个请求还是同样的结局。writing data of size 922 bytes 里的块大小和上游的 1000 字节对不上,是代理那一侧读到的块,不是上游写的块。

cache_http_task 在响应头阶段的注释直说了这个行为:没有 Content-Length 时「the miss handler will count the response size on the fly, aborting the request mid-transfer if the max file size is exceeded」。tag 之后到 84f21ee,main 上这段没有改。

📝NOTE
冷知识:0.6.0 之前,太大的分块资产会被永远记成「不可缓存」

配了预测器时,第一次超限会让预测器记下这个键。0.6.0 之前的问题是记下之后就再也出不来:被预测为不可缓存的请求直接绕过缓存,而大小的累计只在 miss handler 里做,绕过时没人再数,就算上游后来把资产改小了也一样。889b69e(「Mark previously too large chunked assets as cacheable」,没有 PR 编号,首次进入 0.6.0,CHANGELOG 0.6.0 有这一条)的说明是:「Too large assets without content-length could previously be skipped as uncacheable indefinitely by the cacheable predictor.」它把累计挪出 miss handler(MaxFileSizeMissHandler 换成 MaxFileSizeTracker),缓存被关掉时也继续数,并新加了 NoCacheReason::PredictedResponseTooLarge:这次不写缓存,但完整转发;数完发现没超限,就把这个键重新标成可缓存。下面修法一里的 PredictedResponseTooLarge 就是它。

修法

一、配上预测器(PREDICTOR=1,session.cache.enable 的第三个参数传一个 Predictor):

static PREDICTOR: Lazy<Predictor<16>> = Lazy::new(|| Predictor::new(64, None));
// in request_cache_filter:
session.cache.enable(&*STORAGE, None, Some(&*PREDICTOR), None, None);
session.cache.set_max_file_size_bytes(3000);
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=miss size=3000 exit=18
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
...
logging GET /big?n=5000 phase=disabled reason=StorageError status=Some(200) err=Some(" response too large context: Peer: addr: 127.0.0.1:19381, scheme: HTTP cause:  context: writing data of size 922 bytes would exceed max file size of 3000 bytes")
logging GET /big?n=5000 phase=disabled reason=PredictedResponseTooLarge status=Some(200) err=None
logging GET /big?n=5000 phase=disabled reason=PredictedResponseTooLarge status=Some(200) err=None

之后的请求完整送达、不写缓存。但第一个请求照样被截断,每个超限的键都要牺牲一次;预测器是一个有容量的分片 LRU(Predictor::new 的第一个参数是每个分片记几个键,这里 16 个分片各 64 个),键被挤出去以后又要牺牲一次。

二、没有 Content-Length 的响应不缓存(NEEDLEN=1):

fn response_cache_filter(&self, session: &Session, resp: &ResponseHeader, _: &mut ()) -> Result<RespCacheable> {
  if session.cache.max_file_size_bytes().is_some() && !resp.headers.contains_key("content-length") {
    return Ok(RespCacheable::Uncacheable(NoCacheReason::Custom("no content-length")));
  }
  // ... resp_cacheable as before
}
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/sized?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0
/big?n=5000 -> 200 x-cache=disabled size=5000 exit=0

连跑 3 次,每次都是这 5 行,logging 里的原因是 no content-length。响应头阶段就决定不缓存,body 不经过大小累计,不会中途出错。代价是小的分块响应也不缓存了;上游能给 Content-Length 的资产不受影响。两种修法可以一起用。

怎么确认没中招

  • 日志搜 would exceed max file size:每一条都是一个被截断的下游响应,日志里的 status: 500 不是客户端看到的状态码。
  • 客户端或 CDN 报 CURLE_PARTIAL_FILE / ERR_INCOMPLETE_CHUNKED_ENCODING,而代理的访问日志是 200。
  • 代码里有 set_max_file_size_bytes,同一处 enable 的预测器参数是 None,也没有对分块响应单独处理。

小结

  • 最大文件大小对带 Content-Length 的响应是「不缓存、照常转发」,对分块响应是「数到超限就让整个请求失败」:客户端拿到 200 加截断的 body,日志写 500、原因 StorageError。
  • 不配预测器时,同一个 URL 每次都被截断。
  • 修法:配预测器,只牺牲第一次(0.6.0 起超限的键还能重新变成可缓存,889b69e);或者在 response_cache_filter 里对没有 Content-Length 的响应直接不缓存。

💬 关联讨论

发起讨论
加载中...