Rust in Everything
基于 pingora-cache 0.9.0

缓存状态机

主线位置:缓存主线的骨架。process_request 在 request_filter 之后调 proxy_cache:它打开缓存、算键、查存储;命中就自己把响应写给下游,process_request 直接收尾;未命中就返回 None,请求照常去上游,响应在双向转发里经过 cache_http_task 写进存储。第二次请求查到的就是这份存储。每一步都是 HttpCache 的一次状态转换。

stateDiagram-v2
  [*] --> Disabled
  Disabled --> Uninit: enable
  Uninit --> CacheKey: set_cache_key
  CacheKey --> Bypass: 预测不可缓存
  CacheKey --> Hit: 新鲜
  CacheKey --> Stale: 过期
  CacheKey --> Miss: 没找到
  Bypass --> Miss: 响应可缓存
  Stale --> Expired: 上游给 200
  Stale --> Revalidated: 上游给 304
  Stale --> RevalidatedNoCache: 304 但不可缓存
  Stale --> StaleUpdating: 先给旧的
  Stale --> Miss: 等锁后已被删
  Miss --> Disabled: disable

图里省掉了一条边:除了已经是 Disabled 的情况,任何状态调 disable(reason) 都会回到 Disabled(reason)。

代码分两半。pingora-cache 的 lib.rs(3074 行,后 1100 行是测试)定义 HttpCache 和它的状态转换,不知道代理是什么;pingora-proxy 的 proxy_cache.rs(2791 行,其中一半是 Range 处理和测试)在请求的各个阶段调用它。pingora-cache 的 src/ 一共 13551 行。README 写的是「Pingora proxy integration with caching should be considered experimental」,CHANGELOG 把这部分标成「Caching (alpha)」。

实验在 exp-pingora/cache-lab:origin 是一个手写的 HTTP/1.1 上游(127.0.0.1:18601),每个请求打一行日志、按路径计数,每个响应都带 Connection: close,这样代理每次都新建上游连接,失败也不会被当成复用连接上的失败而重试;proxy 是开了缓存的代理(127.0.0.1:18600),用环境变量切换缓存锁、超时、最大文件大小等设置;burst 并发发请求。平台 M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷),没有特别说明的都是 debug 构建,关键实验在 release 下复跑过,结果相同。

程序

开缓存要实现三个钩子:request_cache_filter 打开缓存,cache_key_callback 给出键,response_cache_filter 判断响应能不能存。三个的默认实现分别是什么都不做、unimplemented!、一律不缓存(ProxyHttp 有完整的表)。实验代理的这三个钩子:

static STORAGE: Lazy<MemCache> = Lazy::new(MemCache::new);
const DEFAULTS: CacheMetaDefaults = CacheMetaDefaults::new(|_| None, 0, 0);

fn request_cache_filter(&self, session: &mut Session, ctx: &mut usize) -> Result<()> {
  let eviction = EVICTION.as_ref().map(|m| m as &'static (dyn EvictionManager + Sync));
  let predictor = env_on("PREDICTOR").then(|| &*PREDICTOR as _);
  let lock = LOCK.as_deref();
  let mut overrides = CacheOptionOverrides::default();
  overrides.wait_timeout = env_num("WAIT").map(Duration::from_millis);
  overrides.max_lock_retries = env_num("RETRIES").map(|n| n as usize);
  session.cache.enable(&*STORAGE, eviction, predictor, lock, Some(overrides));
  // ...
  Ok(())
}

fn cache_key_callback(&self, session: &Session, ctx: &mut usize) -> Result<CacheKey> {
  // host 和 path 各加 4 字节长度前缀再拼起来
  // ...
  Ok(CacheKey::new(primary, ""))
}

fn response_cache_filter(&self, _s: &Session, resp: &ResponseHeader, ctx: &mut usize) -> Result<RespCacheable> {
  let cc = CacheControl::from_resp_headers(resp);
  Ok(resp_cacheable(cc.as_ref(), resp.clone(), false, &DEFAULTS))
}

enable 的参数全是 &'static:存储、淘汰管理器、预测器、缓存锁都是进程级的单例,每个请求只拿一个引用。DEFAULTS 对所有状态码都返回 None,意思是响应既没有 Cache-Control 的有效期也没有 Expires 时不缓存。另外 response_filter 把 session.cache.phase().as_str() 写进响应头 x-cache,logging 打出最终状态。

输出:同一个 URL 两次

for i in 1 2; do curl -s --noproxy '*' -D - http://127.0.0.1:18600/hello; echo "--"; done
HTTP/1.1 200 OK
Cache-Control: max-age=60
Connection: keep-alive
Content-Length: 9
Accept-Ranges: bytes
x-cache: miss
Date: Thu, 29 Oct 2026 05:59:03 GMT

hello #1
--
HTTP/1.1 200 OK
Cache-Control: max-age=60
Connection: keep-alive
Content-Length: 9
Age: 0
Accept-Ranges: bytes
x-cache: hit
Date: Thu, 29 Oct 2026 05:59:03 GMT

hello #1

上游只收到一个请求:

[1041.4ms] origin #1 GET /hello (path hit 1) port=59157 if-none-match=- accept-language=-

代理的钩子日志:

[ 1033.6ms] req1 request_cache_filter GET /hello
[ 1035.2ms] req1 cache_key_callback primary=a9661f920872b907e010f649be868a93
[ 1035.6ms] req1 cache_miss phase=miss
[ 1035.8ms] req1 proxy_upstream_filter phase=miss lock_writer=false
[ 1035.9ms] req1 upstream_peer 
[ 1038.0ms] req1 upstream_response_filter 200
[ 1044.2ms] req1 response_cache_filter 200 -> Cacheable fresh_sec=60 swr=0 sie=0
[ 1046.5ms] req1 response_filter 200 x-cache=miss
[ 1046.9ms] req1 logging phase=miss reason=- upstream_used=true lock_wait=None status=Some(200) err=None
[ 1065.1ms] req2 request_cache_filter GET /hello
[ 1065.2ms] req2 cache_key_callback primary=a9661f920872b907e010f649be868a93
[ 1065.4ms] req2 cache_hit_filter is_fresh=true age=0s phase=key
[ 1065.5ms] req2 response_filter 200 x-cache=hit
[ 1065.6ms] req2 logging phase=hit reason=- upstream_used=false lock_wait=None status=Some(200) err=None

第二个请求没有 proxy_upstream_filter、upstream_peer、upstream_response_filter:在 proxy_cache 里就结束了。下面按这两段日志逐步走。

逐步走

1. HttpCache 在 Session 里,一开始是 Disabled(NeverEnabled)

每个 Session 都有一个 cache: HttpCache 字段,HttpCache::new 把它设成 Disabled(NoCacheReason::NeverEnabled)。结构体本身只有 phase、inner: Option<Box<HttpCacheInner>> 和一份摘要,注释写「Box the rest so that a disabled HttpCache struct is small」。实测 size_of::<HttpCache>() 是 112 字节(release,aarch64),其中 CachePhase 占 32;缓存开着时的状态全在 inner 指向的堆上。CachePhase 有 11 个变体,NoCacheReason 有 13 种(含 Custom),logging 里 reason= 一栏打的就是它。

HttpCache 的大部分方法开头都是一个 match self.phase,相位不对就 panic!:

    /* The following methods panic when they are used in the wrong phase.
     * This is better than returning errors as such panics are only caused by coding error, which
     * should be fixed right away. Tokio runtime only crashes the current task instead of the whole
     * program when these panics happen. */
📝NOTE
没有蠢问题

问: 状态不对就 panic,不怕把代理搞崩吗?为什么不返回 Err?

答: 这些转换全是 proxy_cache.rs 自己按固定顺序调的,相位错了只可能是代码写错了,不是外部输入能触发的;返回 Err 只会让每个调用点多一段永远不该走到的错误处理。panic 发生在处理这个请求的 tokio 任务里,任务结束,连接断开,进程和其他连接不受影响。用户代码能碰到的是钩子里那几个方法:set_cache_key 只能在 Uninit 或 CacheKey 调,set_max_file_size_bytes 在 Disabled 调会 panic,bypass 只能在 CacheKey 调。在 request_cache_filter 之外的地方乱调它们,代价就是这个请求的连接直接断掉。

2. request_cache_filter → enable → Uninit

enable 只能从 Disabled 进入,建好 HttpCacheInner:存储、淘汰、预测器存进去,有缓存锁就建一个 LockCtx(锁本身还没拿)。对已经开着的缓存再调一次是 panic「Cannot enable already enabled HttpCache」。

3. cache_key_callback → set_cache_key → CacheKey

proxy_cache 只在缓存开着时才调 cache_key_callback。返回 Err 时用 disable(NoCacheReason::StorageError) 关掉缓存、打一条 warn,请求照常去上游;这个原因名和实际原因对不上,看 logging 时要知道这一点。键怎么构造见 键、元数据与可缓存性。

接下来两件事和查找无关:is_purge 返回 true 就转去 proxy_purge 处理清除请求,到此为止;配了预测器且它预测这个键不可缓存时,bypass 把相位换成 Bypass,不查存储也不拿锁。预测器见 存储与淘汰。

4. 查找循环

proxy_cache 的主体是一个 loop:

  1. cache_lookup 调 storage.lookup(key)。什么都没找到时,在这里向缓存锁要锁:拿到写锁的去上游,拿到读锁的等。
  2. 找到了:先处理 Vary(命中的是主槽、但变体不对时改键再 continue,见键那一页);再算 meta.is_fresh(now),调用户的 cache_hit_filter。它返回 None 时按新鲜度得到 HitStatus::Fresh 或 Expired,也可以强制成过期、未命中或新鲜。然后 cache_found 把相位换成 Hit 或 Stale。过期的资产在这里拿锁,lock(key, stale=true)。
  3. 没找到(或被当成未命中):手里是读锁就 cache_lock_wait,等完 continue 重新查;否则调用户的 cache_miss 钩子,它的默认实现就是 session.cache.cache_miss(),相位换成 Miss,返回 None。
  4. 新鲜的命中:proxy_cache_hit 把响应写给下游,返回 Some((reuse, err))。

查存储出错(Err)时不关缓存,而是直接当成未命中:注释说这主要是为了元数据格式升级后旧条目读不出来时,还能用新响应覆盖它。

等锁、重试次数、超时见 缓存锁。

📝NOTE
冷知识:ForceExpiredServeStale 修了一个多给 65 分钟的窗口

cache_hit_filter 能返回 ForcedFreshness::ForceExpired,把一个还新鲜的资产当成过期,用来让清除生效。0.9.0 之前这是唯一的办法,而它会顺手 disable_serve_stale(),把 stale-while-revalidate 和 stale-if-error 一起关掉,因为它的本意是「下线数据」。9c9acc3(「Let a purge expire an asset instead of deleting it」,首次进入 0.9.0)加了 ForceExpiredServeStale { expired_at }:结束新鲜期,但保留资产自己的两个窗口。提交说明里算了一笔账:一个新鲜期 60 分钟、stale-while-revalidate 10 分钟的资产,在第 5 分钟被强制过期,窗口如果从原来的截止时间算,旧 body 要到入库后第 70 分钟才不能用,「roughly 65 minutes after the event that was supposed to end its life」。所以现在 proxy_cache 在这个分支里调 meta.expire_at(expired_at),把 fresh_until 挪到下线的那一刻(只往前挪,用 min),窗口从那里开始算。同一个提交给清除加了「标记过期而不删除」的选项(purge_action 返回 PurgeAction::Expire)。CHANGELOG 0.9.0 的两条是「Add an opt-in purge mode that expires an asset while retaining its body for conditional revalidation and stale serving」和「ForcedFreshness and hit-status reporting gain ForceExpiredServeStale variant」。

5. 未命中:去上游,边转发边写

proxy_cache 返回 None,process_request 继续调 proxy_upstream_filter 和重试循环(process_request)。缓存开着时,h1 转发在调 upstream_request_filter 之前先过一遍 filters::upstream::request_filter:

  • HEAD 改成 GET,因为 HEAD 的响应没有 body,存不了;
  • 删掉 If-Match、If-None-Match、If-Modified-Since、If-Unmodified-Since、If-Range 和 Range,因为要存的是完整的 200,不是 304 或 206;
  • 有过期的旧元数据时(重新验证),按旧响应的 Last-Modified 和 ETag 加上 If-Modified-Since、If-None-Match。

这些条件头和 Range 在回下游时由代理自己处理:mark_upstream_headers_mutated_for_cache 记下请求头被改过,响应头出来后跑 cache_not_modified_filter 和 range_header_filter。上面输出里未命中的那次响应也带了 Accept-Ranges: bytes,就是 range_header_filter 加的,上游并没有发它。

上游响应在 h1_response_filter 里按这个顺序走:upstream_response_filter → cache_http_task → response_filter。所以 upstream_response_filter 的改动会进缓存,response_filter 的不会;实验里 x-cache 头加在 response_filter,存进去的响应里没有它。cache_http_task 按任务类型处理:

  • 响应头:1xx(101 除外)跳过;调 response_cache_filter。Cacheable(meta) 时依次检查最大文件大小、用 cache_vary_filter 算变体、set_cache_meta、update_variance、set_miss_handler:存储在这时收下元数据,返回一个写 body 用的 MissHandler。Uncacheable(reason) 时告诉预测器、disable(reason)。
  • 响应体:每块数据先累计大小,再 miss_handler.write_body(data, end)。
  • 结束:finish_miss_handler 提交这次写入、释放写锁(状态 Done),再把大小报给淘汰管理器,被挤出去的条目在一个新 spawn 的任务里从存储删掉。

set_miss_handler 还有一个分支:存储支持「边写边读」(support_streaming_partial_write,实验用的 MemCache 支持)时,拿到 MissHandler 后立刻释放写锁,并从存储打开一个读这次写入的 reader。这样等锁的请求不用等整个 body 写完;写入者自己的下游响应也改成从这个 reader 读。转发循环用 ServeFromCache 记住下游的数据该从哪来:

pub(crate) enum ServeFromCache {
    // not using cache
    Off,
    // ...
    // should serve cache header but upstream response should be admitted to cache
    // This is the starting state for misses, which go to CacheBodyMiss or
    // CacheHeaderOnlyMiss before ending at DoneMiss
    CacheHeaderMiss,
    // should serve cache body but upstream response should be admitted to cache, bool to indicate seek status
    CacheBodyMiss(bool),
    // ...
}

ServeFromCache 是代理这一侧的第二个状态机,9 个状态,决定转发循环里下游的数据从哪来:Off 是直接转发上游;CacheHeader、CacheBody 是从缓存出(重新验证拿到 304、或 stale-if-error 时用);带 Miss 的是「下游从缓存读、上游继续往缓存写」。双向转发讲的 select! 循环里有一个分支专门调 next_http_task 从它取数据。

6. 命中:从存储出响应

第二个请求在 cache_found 之后进了 proxy_cache_hit:

  1. cache_hit_header 复制存下的响应头。没访问上游时加 Age(RFC 9111 要求),值是 meta.age(),从入库或最近一次重新验证算起;没有 Content-Length 的加 Transfer-Encoding: chunked。
  2. cache_not_modified_filter:请求的条件不满足就改成 304;HEAD 和 304 只发头。
  3. 存储能 seek 时调 range_header_filter,可能改成 206 或 416。
  4. response_filter、下游模块的响应头过滤,写响应头。
  5. 循环 hit_handler().read_body(),每块过 response_body_filter 和下游模块,写给下游。
  6. finish_hit_handler,finish_body。

命中时 upstream_response_filter 不会被调用,response_filter 会。三种请求打到同一个缓存条目上,脚本 tools/range.sh:

u=http://127.0.0.1:18600/swr
curl -s --noproxy '*' -I $u | grep -i -E '^HTTP|x-cache|content-length|etag'
echo --
curl -s --noproxy '*' -r 0-3 -D - $u | grep -i -E '^HTTP|x-cache|content-range|content-length|^swr'; echo
echo --
curl -s --noproxy '*' -H 'If-None-Match: "v1"' -D - $u | grep -i -E '^HTTP|x-cache|content-length'
HTTP/1.1 200 OK
ETag: "v1"
Content-Length: 7
x-cache: miss
--
HTTP/1.1 206 Partial Content
Content-Length: 4
content-range: bytes 0-3/7
x-cache: hit
swr 

--
HTTP/1.1 304 Not Modified
x-cache: hit

上游的日志是 origin #1 GET /swr:客户端发的是 HEAD,到上游变成了 GET,完整的 200 被存下来,后面的 Range 和条件请求都在代理里就地回答。

过期之后:Stale 的四条出路

max-age 到期的资产查出来是 Stale。/swr 的上游第一次回 Cache-Control: max-age=1, stale-while-revalidate=30 和 ETag: "v1",之后带 If-None-Match: "v1" 的请求都回 304;每个请求在上游睡 300 ms。客户端请求一次,等 1.5 秒,再请求两次(间隔 0.6 秒)。

304:Revalidated

开缓存锁(LOCK=5000),should_serve_stale 用默认逻辑:

r1 -> 200 x-cache=miss age= t=0.307558s
r2 -> 200 x-cache=revalidated age= t=0.311404s
r3 -> 200 x-cache=hit age=0 t=0.002387s
[2897.0ms] origin #2 GET /swr (path hit 2) port=61718 if-none-match="v1" accept-language=-
[ 2893.5ms] req2 cache_hit_filter is_fresh=false age=1s phase=key
[ 2893.6ms] req2 should_serve_stale error=None -> false
[ 2893.7ms] req2 proxy_upstream_filter phase=stale lock_writer=true
...
[ 3199.9ms] req2 upstream_response_filter 304

第二次请求带着 If-None-Match: "v1" 去了上游,等了 300 ms。上游回 304 时,revalidate_or_stale 先对 304 跑 upstream_response_filter,再用 revalidate_merge_header 把 304 里的 Cache-Control、Expires、Cache-Tag、CDN-Cache-Control、ETag 合并进旧的 200 头,拿合并结果去问 response_cache_filter:可缓存就 revalidate_cache_meta 更新存储里的元数据(保留原来的入库时间),相位 Revalidated;不可缓存就 RevalidatedNoCache,这一次仍然用旧的 body 回应(RFC 9111 允许,因为刚验证过)。上游的 304 本身不转发;客户端的请求不带条件时,它收到的是 200 和缓存里的 body。Vary 在 304 里变了不管,源码注释写的是「For simplicity, ignore changing Vary in revalidation for now」。

❗IMPORTANT
动脑筋:stale-while-revalidate 配了,should_serve_stale(None) 也返回 true 了,但没有开缓存锁。过期后的第二次请求 x-cache 是什么,花了多久?

用上面的 /swr(上游每次睡 300 ms)和同样的三次请求脚本,环境变量只设 SWR=1。

200:Expired

上游不认 ETag、回了新的 200 时,set_cache_meta 把 Stale 换成 Expired,之后和未命中一样写入。

stale-while-revalidate:StaleUpdating

上面那次 /swr 的元数据里明明有 swr=30,第二次请求还是等了上游,日志里 should_serve_stale 返回了 false。要「先给旧的」,两个条件缺一不可:

                        } else if session.cache.is_cache_lock_writer() {
                            // stale while revalidate logic for the writer
                            let will_serve_stale = session.cache.can_serve_stale_updating()
                                && self.inner.should_serve_stale(session, ctx, None);
                            if will_serve_stale {
                                // create a background thread to do the actual update
                                // ...
                                let (permit, cache_lock) = session.cache.take_write_lock();
                                SubrequestSpawner::new(self.clone()).spawn_background_subrequest(
                                    // ...
                                );
                                // continue to serve stale for this request
                                session.cache.set_stale_updating();
                            } else {
                                // return to fetch from upstream
                                break None;
                            }
                        } else {
                            // return to fetch from upstream
                            break None;
                        }
  • 「先给旧的」只出现在拿到写锁的分支里(等锁的读者另有一个分支,同样要问 should_serve_stale)。没配缓存锁时 cache_found 不拿锁,is_cache_lock_writer() 是 false,直接 break None 去上游。
  • should_serve_stale 的默认实现是 error.is_some_and(|e| e.esource() == &ErrorSource::Upstream),重新验证时 error 是 None,所以默认返回 false。注释引的是 RFC 9111:缓存不得自作主张给出过期响应,除非断网或者得到明确许可。

开缓存锁、并让 should_serve_stale(None) 返回 true 之后:

r1 -> 200 x-cache=miss age= t=0.304363s
r2 -> 200 x-cache=stale-updating age=1 t=0.001982s
r3 -> 200 x-cache=hit age=0 t=0.000990s

代理日志(grep 去掉了 request_cache_filter、cache_key_callback、upstream_peer 三种行,... 是删去的内容):

[ 2829.8ms] req2 cache_hit_filter is_fresh=false age=1s phase=key
[ 2829.9ms] req2 should_serve_stale error=None -> true
[ 2830.8ms] req2 response_filter 200 x-cache=stale-updating
[ 2830.9ms] req2 logging phase=stale-updating reason=- upstream_used=false lock_wait=None status=Some(200) err=None
[ 2831.1ms] req3 cache_hit_filter is_fresh=false age=1s phase=key
[ 2831.2ms] req3 proxy_upstream_filter phase=stale lock_writer=true
[ 3135.6ms] req3 upstream_response_filter 304
[ 3135.7ms] req3 response_cache_filter 200 -> Cacheable fresh_sec=1 swr=30 sie=0
[ 3135.9ms] req3 response_filter 200 x-cache=revalidated
[ 3136.0ms] req3 logging phase=revalidated reason=- upstream_used=true lock_wait=None status=Some(200) err=None
[ 3446.2ms] req4 cache_hit_filter is_fresh=true age=0s phase=key
...

客户端第二次请求 2 ms 就拿到了旧的响应(age=1)。req3 不是客户端发的:它是 spawn_background_subrequest 派生的后台子请求,new_ctx 照样被调用,ProxyHttp 的钩子照样一个不落地走一遍,写锁从 req2 转交给它(take_write_lock / set_write_lock),它带着 If-None-Match 去上游拿到 304、更新元数据。客户端的第三次请求(req4)已经是新鲜的命中。

Age 一栏:StaleUpdating 和 Hit 都没有访问上游,cache_hit_header 给它们加 Age;Revalidated 访问了上游,不加。

stale-if-error

/sie 第一次回 max-age=1, stale-if-error=60,之后一律回 503。同样的客户端脚本,不开缓存锁:

r1 -> 200 x-cache=miss age= t=0.012180s
r2 -> 200 x-cache=stale age=1 t=0.001745s
r3 -> 200 x-cache=stale age=2 t=0.001500s
...
[ 2605.2ms] req2 proxy_upstream_filter phase=stale lock_writer=false
[ 2605.8ms] req2 should_serve_stale error=Some("HTTPStatus") -> true
[ 2605.9ms] req2 response_filter 200 x-cache=stale
[ 2606.0ms] req2 logging phase=stale reason=- upstream_used=false lock_wait=None status=Some(200) err=None

上游收到了三个请求,客户端一个 503 都没看到。5xx 的响应头到达时,revalidate_or_stale 把状态码包成 ErrorType::HTTPStatus(503)、来源 Upstream,默认的 should_serve_stale 对上游错误返回 true,于是 ServeFromCache::enable(),丢掉上游的 503,从缓存出旧响应。连接级的错误走另一条路:把上游进程杀掉再请求,重试循环结束后 process_request 调 handle_stale_if_error,日志是:

[2026-10-29T06:03:18Z WARN  pingora_proxy::proxy_cache] Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18601, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18601 cause: Connection refused (os error 61), serving stale, GET /sie, Host: 127.0.0.1:18600

客户端同样收到 200 和 x-cache: stale。两点要注意:

  • stale-if-error 不需要缓存锁,因为它不涉及「谁去更新」,每个请求都自己去上游试一次。也因为这样,上游一直坏着,每个请求都会打到上游,缓存只是替它遮住了错误。
  • upstream_used 对 Stale 返回 false,文档注释说它判断的是「Whether anything was fetched from the upstream」。stale-if-error 时上游其实被访问了(还回了 503),这个函数的回答是按相位推出来的,用它统计上游流量会漏掉这些请求。

动脑筋答案

r1 -> 200 x-cache=miss age= t=0.306026s
r2 -> 200 x-cache=revalidated age= t=0.305484s
r3 -> 200 x-cache=hit age=0 t=0.002159s
...
[ 2875.5ms] req2 cache_hit_filter is_fresh=false age=1s phase=key
[ 2875.5ms] req2 proxy_upstream_filter phase=stale lock_writer=false
...
[ 3179.1ms] req2 upstream_response_filter 304
...

revalidated,0.305 秒,和完全没配 stale-while-revalidate 一样。日志里连一行 should_serve_stale 都没有:没有缓存锁,cache_found 不拿写锁,proxy_cache 走到 is_cache_lock_writer() 为 false 的最后一个 else,直接去上游重新验证,should_serve_stale 根本没被问到。stale-while-revalidate 的语义是「一个请求去更新,其他人先拿旧的」,「一个」要靠锁来选;没有锁,pingora 就让每个请求自己去验证。

小结

  • HttpCache 有 11 个相位,从 Disabled(NeverEnabled) 开始;方法在错误的相位上调用会 panic,只影响当前请求的任务。
  • proxy_cache 依次调 request_cache_filter、cache_key_callback、is_purge、预测器,然后在一个循环里查存储、处理 Vary、调 cache_hit_filter、等锁;命中时自己写完响应。
  • 未命中的请求去上游前,HEAD 改 GET,条件头和 Range 被删掉;响应依次过 upstream_response_filter、cache_http_task、response_filter,只有前一个的改动进缓存。
  • 存储支持边写边读时,写锁在响应头写入后就释放,下游从存储读这次写入(ServeFromCache 的 Miss 状态)。
  • 过期资产:304 → Revalidated,200 → Expired;stale-while-revalidate 要缓存锁、还要覆盖 should_serve_stale;stale-if-error 默认就生效,不要锁,上游每次都会被访问。

💬 关联讨论

发起讨论
加载中...