缓存状态机
主线位置:缓存主线的骨架。
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. */
问: 状态不对就 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:
cache_lookup调storage.lookup(key)。什么都没找到时,在这里向缓存锁要锁:拿到写锁的去上游,拿到读锁的等。- 找到了:先处理
Vary(命中的是主槽、但变体不对时改键再continue,见键那一页);再算meta.is_fresh(now),调用户的cache_hit_filter。它返回None时按新鲜度得到HitStatus::Fresh或Expired,也可以强制成过期、未命中或新鲜。然后cache_found把相位换成Hit或Stale。过期的资产在这里拿锁,lock(key, stale=true)。 - 没找到(或被当成未命中):手里是读锁就
cache_lock_wait,等完continue重新查;否则调用户的cache_miss钩子,它的默认实现就是session.cache.cache_miss(),相位换成Miss,返回None。 - 新鲜的命中:
proxy_cache_hit把响应写给下游,返回Some((reuse, err))。
查存储出错(Err)时不关缓存,而是直接当成未命中:注释说这主要是为了元数据格式升级后旧条目读不出来时,还能用新响应覆盖它。
等锁、重试次数、超时见 缓存锁。
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:
cache_hit_header复制存下的响应头。没访问上游时加Age(RFC 9111 要求),值是meta.age(),从入库或最近一次重新验证算起;没有Content-Length的加Transfer-Encoding: chunked。cache_not_modified_filter:请求的条件不满足就改成 304;HEAD和 304 只发头。- 存储能 seek 时调
range_header_filter,可能改成 206 或 416。 response_filter、下游模块的响应头过滤,写响应头。- 循环
hit_handler().read_body(),每块过response_body_filter和下游模块,写给下游。 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」。
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 默认就生效,不要锁,上游每次都会被访问。