Rust in Everything
基于 pingora-cache 0.9.0

存储与淘汰

主线位置:缓存主线的「写入存储」和第二次请求的「从存储读出」。第一次请求的响应头到达时,set_miss_handler 让存储收下元数据、交出一个写 body 的句柄;body 写完,finish_miss_handler 提交,并把大小报给淘汰管理器。第二次请求的 cache_lookup 从存储拿到元数据和一个读 body 的句柄。存不存、存多大,还要过预测器、准入策略和最大文件大小这三道关。

flowchart TB
  hdr[响应头到达] --> gate{"大小与准入"}
  gate -->|不收| skip([不缓存])
  gate --> smh["get_miss_handler"]
  smh --> wb["write_body 逐块"]
  wb --> fin["finish 得到大小"]
  fin --> ev["EvictionManager admit"]
  ev -->|挤出的键| purge["spawn purge"]
  look["第二次 lookup"] --> hh["HitHandler read_body"]
  hh --> acc["EvictionManager access"]

代码在 storage.rs(463 行)、memory.rs(812 行)、put.rs(879 行)、eviction/(2321 行)、predictor.rs(291 行)、admission.rs(103 行)、max_file_size.rs(53 行),行数含单元测试。淘汰管理器用到的分片 LRU 在 pingora-lru,见 TinyUFO 与内存缓存。实验环境同 缓存状态机(exp-pingora/cache-lab,pingora 0.9.0,tokio 1.53.2,debug,关键结果 release 复跑相同)。

三个 trait

Storage 的方法都以 &'static self 为接收者(注释「TODO: shouldn't have to be static」):

方法谁调用做什么
lookupcache_lookup返回 (CacheMeta, HitHandler) 或 None
lookup_streaming_writeset_miss_handler按写入标签找到「正在写的这一份」,默认等于 lookup
get_miss_handlerset_miss_handler收下元数据,返回写 body 的 MissHandler
purge清除请求、淘汰删一个条目
expire清除请求(PurgeAction::Expire)0.9.0 新增:标记过期但保留 body;默认实现退化成删除
update_metarevalidate_cache_meta304 之后只换元数据
support_streaming_partial_write多处能否边写边读,默认 false

HandleHit 是读一个条目:read_body 返回 None 表示读完,finish 收尾;can_seek / seek 支持 Range(不支持时代理自己从完整 body 里截);should_count_access 和 get_eviction_weight 给淘汰管理器用。HandleMiss 是写一个条目:write_body(data, eof),finish 返回 MissFinishType:Created(size) 是新条目,Appended(size, max) 是往已有条目上追加(淘汰管理器相应地 admit 或 increment_weight)。streaming_write_tag 让读者能找到同一次写入,entry_id 是 0.9.0 加的,见后面的淘汰。

HitHandler、MissHandler 都是 Box<dyn …>,每个请求一个,跟着 HttpCache 走。

MemCache:唯一的实现,只供测试

仓库里 Storage 的实现只有 MemCache 和测试里的几个夹具。模块文档写「For testing only, not for production use」,lib.rs 里 memory 是私有模块,只把 MemCache 导出。它是两张表:

pub struct MemCache {
    pub(crate) cached: Arc<RwLock<HashMap<String, CacheObject>>>,
    pub(crate) temp: Arc<RwLock<HashMap<String, HashMap<u64, TempObject>>>>,
    pub(crate) last_temp_id: AtomicU64,
}

键是 key.combined() 的十六进制字符串。get_miss_handler 把元数据序列化(所以 MemCache 也会用到 header-serde,打出那条「no header compression dictionary loaded」警告),在 temp 里放一个 TempObject,写入 id 是自增的 last_temp_id,支持同一个键同时有多个写者。write_body 往共享的 Vec<u8> 里追加,再通过一个 tokio::sync::watch 广播已写字节数。finish 把 body 整个复制一份放进 cached(注释「this is not at all optimized」),然后从 temp 删掉;MemMissHandler 被丢弃时也从 temp 删掉。

lookup 先查 temp、再查 cached:注释说「always prefer partial read otherwise fresh asset will not be visible on expired asset until it is fully updated」。查到 temp 时返回 PartialHit,它在 watch 上等新数据;写者消失时 changed() 返回错误,PartialHit 把它当成读完。这就是 缓存锁 那一页写者中途失败、5 个读者都只拿到 1000 字节的原因,源码在这里的注释是「FIXME: sender could drop because of an error」。

另外几处只适合测试的地方:update_meta 找不到条目时直接 panic!("no meta found");淘汰权重只算 body 不算元数据(两处 FIXME);MemCache 自己没有容量上限,不配淘汰管理器就一直长下去。

📝NOTE
没有蠢问题

问: 既然 MemCache 不能用于生产,生产上用什么存储?

答: pingora 0.9.0 里没有。要么自己实现 Storage,要么接受只有测试级别的 MemCache。实现时有几条约定只写在文档注释里:支持边写边读时,lookup_streaming_write 必须返回同一次写入的读者,否则「the calling request may receive a different body from the one it expected」;HandleHit::entry_id 和 HandleMiss::entry_id 要用同一套编号;expire 之后同一个条目不能再被当成新鲜的命中。README 对这一块的定位是「experimental」「highly volatile」,0.9.0 的 CHANGELOG 里光是缓存相关的破坏性变更就有 CacheKey、PurgeOutcome、ForcedFreshness 三处。

put.rs 不是代理写缓存的路径

CachePutCtx 读的是请求体:请求体里是一段完整的原始 HTTP 响应(状态行、头、body),cache_put 一边读一边用内置的 ResponseParse(httparse 之上,最多 256 个头)解析,按 CachePut 的 cacheable(默认就是 resp_cacheable)判断能不能存,能存就 get_miss_handler、write_body、finish、报给淘汰管理器。它是「把一个响应推进缓存」的接口,用于预热这类场景。它边收边写,但和代理的未命中流程无关:pingora-proxy 里没有任何地方调用它,代理的写入走的是 proxy_cache.rs 的 cache_http_task(见 缓存状态机)。它也不拿缓存锁,结构体里有一条注释「FIXME: cache put doesn't have cache lock but some storage cannot handle concurrent put to the same asset」。

淘汰:EvictionManager

enable 的文档写「without it, nothing will be evicted from the storage」。EvictionManager 只记账,不碰存储:

  • admit(item, size, fresh_until):记下一个新条目,返回需要淘汰的条目列表,「The caller needs to make sure that these assets are actually removed from the storage」。finish_miss_handler 收到列表后 spawn 一个任务,逐个 storage.purge(PurgeTarget::Exact(..), PurgeType::Eviction)。
  • access(item, size, fresh_until):命中时调用(cache_found 里,should_count_access 为真时),不认识的条目也开始跟踪,但不触发淘汰。
  • increment_weight、remove、peek,以及 save / load:把状态存到磁盘,重启后恢复。

0.9.0 把条目的身份从 CompactCacheKey 换成了 CacheEntryKey:KeyOnly(key) 或 Identified { key, id },id 来自存储的 entry_id,让同一个键的几代条目可以同时存在、淘汰时删对那一代(373140f,「identify eviction entries with storage markers」,提交说明自称「a breaking API change」)。

三个实现:

实现结构特点
simple_lru::Managerlru crate 的 LruCache,一把全局 RwLock注释「The implementation is not optimized. All operations require global locks」;可加条目数水位
lru::Manager<N>pingora_lru::Lru,N 个分片为大量条目设计,save 按分片序列化,不锁住全部
async_lru::Manager<N>每个分片一个 actor 任务admit / increment_weight 永远返回空列表,淘汰在后台 worker 里通过回调完成

三个实现的 admit 和 access 都把 fresh_until 参数命名成 _fresh_until,不用它。淘汰只按大小和最近使用排序:过期的条目不会因为过期被提前回收,一个过期但最近被访问过的条目,比一个新鲜但很久没访问的条目活得久。

实验:simple_lru::Manager::new(10000)(EVICT=10000),每个资产 4000 字节,按 a b a c a b c 的顺序请求:

a -> 200 x-cache=miss size=4000
b -> 200 x-cache=miss size=4000
a -> 200 x-cache=hit size=4000
c -> 200 x-cache=miss size=4000
a -> 200 x-cache=hit size=4000
...

最后还有 b、c 两个请求,结果放在文末。

不配淘汰管理器时,同样的序列只有前两个和 c 第一次是 miss,其余全是 hit。

❗IMPORTANT
动脑筋:上面最后两个请求 b 和 c 是命中还是未命中?

上限 10000 字节,每个 4000 字节,simple_lru 的 evict 在总量超过上限时从最久未用的一端弹出,直到不超。

预测器:记住不可缓存的键

Predictor<N> 是一个分片的 LRU,键是 primary_bin(不含变体,因为它在查找之前就要用),值是当时的 NoCacheReason。proxy_cache 在查找之前问 cacheable_prediction,预测不可缓存就 bypass:不查存储,也不拿缓存锁。mark_uncacheable 只记 OriginNotCache、ResponseTooLarge 和 Custom(可以用 skip_custom_reasons_fn 排除一部分);内部错误、存储错误、上游错误、缓存锁的几种原因都不记,因为它们说明不了这个 URL 本身。

被 bypass 的请求照常去上游,响应仍然过 response_cache_filter。如果这次可缓存,response_became_cacheable 把键从预测器里删掉,GET 的 200 当场 cache_miss 写进缓存;其他方法或状态码这次不写,下次再说。

📝NOTE
冷知识:被预测器绕过的请求,以前一律被报成「太大」

预测器原来只记「这个键不可缓存」,不记为什么。proxy_cache 里重新接纳一个被绕过的响应时,只要配了最大文件大小、响应又没有 Content-Length,不管这个键当初为什么被绕过,都不把这次从上游取回的响应写进缓存、等下一个请求再写,并把原因写死成 PredictedResponseTooLarge。352e3b6(「use the remembered predictor reason when re-admitting a bypass」,首次进入 0.9.0)的说明是:「Traces then pointed at a size limit that was never involved, which made bypasses on unrelated paths hard to diagnose」。现在 Predictor 的值就是 NoCacheReason,新加的 predicted_uncacheable_reason 把它取出来,bypass 时存进 HttpCacheInner;cache_http_task 只在原因确实是 ResponseTooLarge(或者不知道原因)、又配了最大文件大小、响应还没有 Content-Length 时,才推迟写入并报 PredictedResponseTooLarge;其余情况当场写入。下一节的实验走的正是这条推迟的路。

最大文件大小

set_max_file_size_bytes 给这个请求设一个 body 上限(MaxFileSizeTracker 就是一个累加器)。cache_http_task 在两个地方检查:

  • 响应头有 Content-Length 且超过上限:disable(ResponseTooLarge),告诉预测器,并且 ignore_downstream_range。响应照常转发。
  • 没有 Content-Length(分块传输):每块数据累加,超过上限时返回错误 ERR_RESPONSE_TOO_LARGE。

第二种情况的错误一路传回 h1_response_filter:缓存被 disable(StorageError),如果下游此时正从存储读(ServeFromCache 在 CacheBodyMiss),整个请求失败。上限 3000 字节(MAXFILE=3000),上游的 /sized 回 5000 字节带 Content-Length,/big 回 5000 字节分块:

/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=3000 exit=18
[2026-10-29T06:03:46Z ERROR pingora_proxy] Fail to proxy:  response too large context: Peer: addr: 127.0.0.1:18601, scheme: HTTP cause:  context: writing data of size 977 bytes would exceed max file size of 3000 bytes, status: 500, tries: 1, retry: false, GET /big?n=5000, Host: 127.0.0.1:18600
[ 1039.3ms] req3 logging phase=disabled reason=StorageError upstream_used=true lock_wait=None status=Some(200) err=Some("response too large")

分块的那个 URL 每次都只给客户端 3000 字节,curl 退出码 18(CURLE_PARTIAL_FILE);错误日志写的是 status: 500,客户端实际收到的是已经发出的 200 头和半截 body。logging 里的原因是 StorageError,不是 ResponseTooLarge。代码注释承认这个行为:「the miss handler will count the response size on the fly, aborting the request mid-transfer if the max file size is exceeded」。release 构建结果相同。

配上预测器(PREDICTOR=1):

/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

第一次照样被截断,但它在出错前调了 response_became_uncacheable(ResponseTooLarge);之后的请求被 bypass,响应没有 Content-Length,按上面冷知识里的规则 disable(PredictedResponseTooLarge),完整转发、不写缓存。所以 set_max_file_size_bytes 和预测器要一起用:不配预测器,一个超过上限的分块资产对每个客户端都是坏的。

准入策略(0.9.0)

AdmissionPolicy 只有一个方法 observe(&CacheKey) -> Decision,在 cache_lookup 发现存储里确实没有这个键时调用,每个 HttpCache 最多一次(11b5764,「Add deferred cache admission policy hook」,首次进入 0.9.0)。返回 Defer 时 cache_lookup 不拿缓存锁,并 disable(NoCacheReason::Deferred):这次不写缓存。典型用法是「一个 URL 被请求过 N 次才缓存」,挡掉只访问一次的长尾。用 set_admission_policy 在 request_cache_filter 或 cache_key_callback 里设置。pingora 没有提供可用的实现:唯一的 MinUsesAdmissionPolicy 标着 #[cfg(test)]。文档注释还提醒,因为 valid_after 被过滤掉的旧条目不算「没有」,清除之后的并发填充不会被准入策略挡住,只能靠缓存锁。

动脑筋答案

a -> 200 x-cache=miss size=4000
b -> 200 x-cache=miss size=4000
a -> 200 x-cache=hit size=4000
c -> 200 x-cache=miss size=4000
a -> 200 x-cache=hit size=4000
b -> 200 x-cache=miss size=4000
c -> 200 x-cache=miss size=4000

两个都是未命中。按最近使用排:c 写入时总量 12000,超过 10000,弹出最久没用的 b(a 刚被访问过);第五个请求访问 a,顺序变成 c、a;b 再写入时弹出 c;c 再写入时弹出 a。容量只够两个、而三个资产轮流访问时,LRU 每次都恰好淘汰下一个要用的。

小结

  • Storage 管查找、写入、删除、过期和更新元数据;HandleHit / HandleMiss 是每个请求的读写句柄;support_streaming_partial_write 决定缓存锁什么时候放。
  • MemCache 是唯一的实现,文档写明只供测试:没有容量上限、finish 复制整个 body、写者失败时读者拿到截断的响应、update_meta 找不到就 panic。
  • put.rs 的 CachePutCtx 是把请求体里的原始响应推进缓存的接口,代理不用它,也不拿锁。
  • 淘汰管理器只记账、返回要删的键,由调用者 spawn 任务删;三种实现都忽略 fresh_until,过期条目只按 LRU 回收。
  • 预测器记住 OriginNotCache、ResponseTooLarge 和自定义原因,下次 bypass;0.9.0 起还记住原因本身。准入策略是 0.9.0 新增的钩子,没有自带实现。
  • 最大文件大小遇到分块响应会在中途掐断下游连接;不配预测器时每次都如此。

💬 关联讨论

发起讨论
加载中...