缓存锁
主线位置:缓存主线的「miss → 缓存锁(同一个键只放一个请求去上游)」。第一次请求未命中时,
cache_lookup顺手向缓存锁要锁:第一个到的拿写锁去上游,同时到的其他请求拿读锁,等写者把响应写进存储,再查一次存储,变成命中。没配缓存锁,每个未命中都自己去上游。
sequenceDiagram participant W as 写者 participant R as 读者 participant L as CacheLock participant S as 存储 participant O as 上游 W->>S: lookup 没有 W->>L: lock 得到 Write R->>S: lookup 没有 R->>L: lock 得到 Read R->>L: wait W->>O: 请求 O-->>W: 响应头 W->>S: 写入 W->>L: release Done L-->>R: 唤醒 R->>S: lookup 命中
图里写者在响应头写进存储后就释放锁,这是存储支持边写边读时的情况;不支持时要等整个 body 写完。代码在 lock.rs,1392 行,前 737 行是实现,其余是测试;等锁和重试的逻辑在 pingora-proxy 的 proxy_cache 里。实验用 exp-pingora/cache-lab 的 origin、proxy、burst(环境见 缓存状态机:pingora 0.9.0,tokio 1.53.2);burst 同时发 N 个请求,每个请求一条新连接。
不开锁和开锁
burst 10 '/slow?ms=500':10 个请求同时打 /slow,上游每个请求睡 500 ms,回 max-age=60。不配缓存锁:
c00 start= 0ms end= 525ms HTTP/1.1 200 OK x-cache=miss body_len=8 first="slow #6"
c01 start= 0ms end= 525ms HTTP/1.1 200 OK x-cache=miss body_len=8 first="slow #3"
...
c09 start= 0ms end= 525ms HTTP/1.1 200 OK x-cache=miss body_len=8 first="slow #2"
上游收到 10 个请求(origin #1 到 origin #10),10 个都是 miss,每个都写了一遍缓存。缓存刚好在最需要它的时候没用上:一个热门资产过期或被清除的瞬间,所有请求一起压到上游,这叫缓存击穿(cache stampede)。配上 CacheLock::new_boxed(Duration::from_secs(5))(LOCK=5000):
c00 start= 0ms end= 512ms HTTP/1.1 200 OK x-cache=hit body_len=8 first="slow #1"
...
c08 start= 1ms end= 512ms HTTP/1.1 200 OK x-cache=hit body_len=8 first="slow #1"
c09 start= 1ms end= 511ms HTTP/1.1 200 OK x-cache=miss body_len=8 first="slow #1"
[1019.7ms] origin #1 GET /slow (path hit 1) port=60226 if-none-match=- accept-language=-
[ 1523.2ms] req1 logging phase=miss reason=- upstream_used=true lock_wait=None status=Some(200) err=None
[ 1524.3ms] req10 logging phase=hit reason=- upstream_used=false lock_wait=Some(505) status=Some(200) err=None
...
[ 1525.2ms] req9 logging phase=hit reason=- upstream_used=false lock_wait=Some(506) status=Some(200) err=None
上游只收到一个请求,9 个读者各等了约 505 ms(HttpCache::lock_duration()),然后命中。release 构建、100 个并发也一样:不开锁时上游收到 100 个请求,开锁时 1 个,99 个读者的 lock_wait 都在 507 到 510 ms 之间,100 个响应都在 511 到 517 ms 之间返回(uptime 负载 11.9,本机同时在跑别的实验)。
锁在哪里拿
HttpCache 有两处向锁要锁:
cache_lookup:存储里什么都没有(且准入策略没有推迟)时,lock(key, stale_writer=false)。cache_found:找到的资产过期了,或者cache_hit_filter要求当成未命中时,lock(key, stale_writer=true)。新鲜的命中不碰锁。
锁是 enable 时传进来的 &'static CacheKeyLockImpl,也就是 dyn CacheKeyLock:pingora 只提供 CacheLock 一个实现,但 CacheKeyLock 是公开的 trait(1b3cc03「make CacheKeyLock a trait」,0.5.0 起),用户可以换上自己的实现。CacheLock 是一张按 combined_bin() 索引的表(16 个分片的 ConcurrentHashTable,键是 128 位哈希转成的 u128),lock 的逻辑:
let table = self.lock_table.get(key);
if let Some(lock) = table.read().get(&key) {
// already has an ongoing request
// ...
if !matches!(
lock.0.lock_status(),
LockStatus::Dangling | LockStatus::AgeTimeout
) {
return Locked::Read(lock.read_lock());
}
// Dangling: the previous writer quit without unlocking the lock. Requests should
// compete for the write lock again.
}
let mut table = table.write();
// check again in case another request already added it
// ...
let (permit, stub) =
WritePermit::new(self.age_timeout_default, stale_writer, Extensions::new());
table.insert(key, stub);
Locked::Write(permit)
表里已经有一把还在用的锁,就拿读锁;没有、或者那把锁已经是 Dangling / AgeTimeout,就在写锁下再查一次,然后插入新的、自己拿写锁。三种对象背后是同一个 LockCore:WritePermit 是写者手里的,ReadLock 是读者手里的,LockStub 留在表里。LockCore 的核心是一个初始为 0 的 tokio::sync::Semaphore 和一个 AtomicU8 状态。
一桌客人到了,店里没有现成的菜(存储里没有),领号机(lock_table)吐出「第一位」,这位客人进后厨点菜(WritePermit,去上游);同时到的客人拿到「请稍候」的号(ReadLock),坐在门口等叫号(Semaphore::acquire)。后厨出菜后,第一位客人在门口的牌子上写下结果(lock_status:Done、TransientError、GiveUp……),然后按铃(add_permits(10))。等候的人看牌子:「已上菜」就进去吃(重新查存储,命中);「后厨出错」就重新抢号;「这道菜不外卖」就各自去别家买(不缓存,各自去上游)。
写者:七种释放状态
LockStatus 有 7 个值,写者释放时写下其中一个,unlock 先存状态、再 add_permits(10)。读者在 acquire 上醒来,拿到的许可在作用域结束时立刻还回去,下一个读者接着醒,「No need to wake up all at once」。写者在这些地方释放:
| 时机 | 状态 |
|---|---|
set_miss_handler,存储支持边写边读 | Done(响应头一写进存储就放) |
finish_miss_handler,body 写完 | 提交成功 Done,失败 TransientError |
revalidate_cache_meta(304 更新了元数据) | Done,无论更新是否成功 |
release_write_lock,由 disable(reason) 或 stale-if-error 调用 | 按原因映射,见下 |
WritePermit 被丢弃而没有释放(Drop) | Dangling,debug 构建里还有 debug_assert!(false, "Dangling cache lock started!") |
release_write_lock 的映射:InternalError、StorageError、Deferred、UpstreamError 是 TransientError,「let the next request try to fetch it」;DeclinedToUpstream(proxy_upstream_filter 拒绝了)也是 TransientError;OriginNotCache、ResponseTooLarge、PredictedResponseTooLarge 是 GiveUp,「no need for the lock anymore」;Custom(reason) 交给 CacheKeyLock::custom_lock_status,默认也是 GiveUp,所以 response_cache_filter 的默认实现 Uncacheable(Custom("default")) 会让所有读者放弃。剩下两类原因不该出现在写者身上,直接 panic。
另外两个状态不是写者写的:AgeTimeout 是读者发现写者拿锁太久时写进去的,WaitTimeout 只出现在读者自己的结果里,不进 LockCore(unlock 里有一条断言)。
读者:等,然后做什么
读者在 proxy_cache 的循环里调 cache_lock_wait。两个超时:
- 锁龄(age timeout):
CacheLock::new(age)的参数,属于这把锁。读者开始等时先看锁已经存在了多久,剩下的时间用writer_done的timeout(duration, core.lock.acquire())等;超时就把AgeTimeout写进LockCore,表里的这把锁从此被lock()当成可以替换。 - 等待时间(wait timeout):
CacheOptionOverrides::wait_timeout,属于这个请求,是它所有等待加起来的上限(每次等的是剩下的部分)。
等完的结果由 handle_lock_wait_outcome 处理:
LockWaitOutcome | 做什么 |
|---|---|
Done | 重新查存储 |
TransientError、Dangling、AgeTimeout | 重新查存储,查不到就重新抢锁,可能成为新的写者 |
GiveUp | disable(CacheLockGiveUp),不缓存,去上游 |
Abandoned { reason, .. } | disable(reason),不缓存,去上游(0.9.0 新增,见文末) |
WaitTimeout | disable(CacheLockTimeout),不缓存,去上游 |
「重新查存储」的那几种算一次重试。cache_lock_retry_limit_exceeded 数重试次数,上限是 CacheOptionOverrides::max_lock_retries,默认 DEFAULT_MAX_CACHE_LOCK_RETRIES = 2;第 3 次时 disable(CacheLockRetryLimit),不再等锁,直接去上游。
0.8.0 的查找循环上方有一行注释「for cache lock, TODO: cap the max number of loops」:读者等完锁、重新查找、又碰上一个新写者,就再等一轮,没有上限。e1b4d84(「bound cache-lock retry loops」)给它加了上限:硬编码的 MAX_CACHE_LOCK_RETRIES = 2,超过就用新增的 CacheLockRetryLimit 放弃;还加了第二道判断:重试后找到的资产如果过期又不能先给旧的、或者一秒内就要过期(CACHE_LOCK_NEAR_EXPIRY_WINDOW),也放弃。提交说明说这是为了「Stop readers from waiting on cache locks forever when each retry still finds a miss, a stale object that cannot be served, or an object close to expiry」。第二天的 277047c(「make cache lock retries configurable」)把上限做成了 max_lock_retries,同时删掉了第二道判断,说明只有一句:「This also removes the ineffective-retry path because it was ... ineffective.」两个提交都首次进入 0.9.0,CHANGELOG 记为「Make cache-lock retries configurable and bounded」。
写者失败时,上游被打几次
同样是 10 个并发、LOCK=5000,换几种上游:
| 场景 | 上游请求数 | 客户端结果 | 读者最终的 reason |
|---|---|---|---|
/fail?bad=1:第 1 个请求等 300 ms 后关连接,之后正常 | 2 | 1 个 502,9 个 200(8 个 hit) | 无(命中) |
/fail?bad=100:每个请求都等 300 ms 后关连接 | 10 | 10 个 502 | 7 个 CacheLockRetryLimit |
/nostore:等 300 ms 回 no-store | 10 | 10 个 200 | 9 个 CacheLockGiveUp |
/fail?bad=100 的代理日志(只留 logging 行):
[ 1309.4ms] req1 logging phase=disabled reason=UpstreamError upstream_used=true lock_wait=None status=Some(502) err=Some("ConnectionClosed")
[ 1614.7ms] req10 logging phase=disabled reason=UpstreamError upstream_used=true lock_wait=Some(306) status=Some(502) err=Some("ConnectionClosed")
[ 1920.2ms] req9 logging phase=disabled reason=UpstreamError upstream_used=true lock_wait=Some(611) status=Some(502) err=Some("ConnectionClosed")
[ 2226.2ms] req8 logging phase=disabled reason=CacheLockRetryLimit upstream_used=true lock_wait=Some(916) status=Some(502) err=Some("ConnectionClosed")
...
[ 2234.9ms] req7 logging phase=disabled reason=CacheLockRetryLimit upstream_used=true lock_wait=Some(917) status=Some(502) err=Some("ConnectionClosed")
写者 req1 失败,disable(UpstreamError) 把锁释放成 TransientError,9 个读者醒来重新抢锁,req10 成了第二个写者;它也失败,req9 成了第三个。第三次失败时剩下 7 个读者已经重试了 3 次,超过上限 2,同时放弃等锁、一起去上游。上游看到的是先 3 个串行、再 7 个并发;最后几个客户端等了约 1.2 秒才拿到 502,而不是 300 ms。
/nostore 是另一种失败:写者拿到的响应不可缓存,disable(OriginNotCache) 对应 GiveUp,9 个读者在 308 ms 时一起醒来、一起去上游。对一个不可缓存的 URL,缓存锁的效果是把并发请求排成两波:第一波 1 个,第二波 N−1 个。配上预测器之后,下一批请求在 bypass 时就不拿锁了(见 存储与淘汰)。
两个超时都会让上游被打满
上游每个请求睡 1000 ms。锁龄设成 300 ms(LOCK=300):
[1020.4ms] origin #1 GET /slow (path hit 1) port=60351 if-none-match=- accept-language=-
[1335.7ms] origin #2 GET /slow (path hit 2) port=60352 if-none-match=- accept-language=-
[1645.5ms] origin #3 GET /slow (path hit 3) port=60355 if-none-match=- accept-language=-
[1958.5ms] origin #4 GET /slow (path hit 4) port=60358 if-none-match=- accept-language=-
...
[1959.0ms] origin #10 GET /slow (path hit 10) port=60361 if-none-match=- accept-language=-
每 300 ms,读者发现写者的锁到龄,写入 AgeTimeout,重新抢锁,选出一个新写者;第三轮之后重试次数用完,剩下 7 个一起去上游,reason=CacheLockRetryLimit。三个写者都成功写了缓存(phase=miss)。第一个写者在 1000 ms 时释放锁:release 看到自己的状态已经是 AgeTimeout,就不从表里删条目,免得删掉后来那个写者的锁。
把锁龄放回 5 秒、改设请求的等待时间 300 ms(WAIT=300):9 个读者在 313 ms 时全部 WaitTimeout,reason=CacheLockTimeout,同时去上游,上游 10 个请求,9 个响应不进缓存。源码注释给的理由是「let the request through while disabling cache (to avoid amplifying disk writes)」。
两种超时的用途不同。锁龄防的是写者卡死,比如上游一直不回响应头,读者不能永远陪着;等待时间防的是单个请求的延迟失控。两个都应该比上游正常的响应时间长,否则锁形同虚设。
LOCK=300、上游每次睡 1000 ms,把重试上限调到 10(RETRIES=10),上游会收到几个请求?读者要等多久?用同样的 burst 10 '/slow?ms=1000'。
边写边读的存储:锁只管到响应头
MemCache 支持边写边读,写者在 set_miss_handler 里一拿到 MissHandler 就释放锁,读者醒来查到的是正在写的临时对象,跟着写者的进度读 body(MemCache 的实现见 存储与淘汰)。/half 先回响应头(Content-Length: 4000)和 1000 字节,停 300 ms,再发剩下的 3000 字节。5 个并发:
[ 1305.8ms] req1 logging phase=miss reason=- upstream_used=true lock_wait=None status=Some(200) err=None
[ 1306.5ms] req4 logging phase=hit reason=- upstream_used=false lock_wait=Some(1) status=Some(200) err=None
...
读者只等了 1 ms,5 个客户端都拿到完整的 4000 字节。/half?bad=1 让写者的上游在发完 1000 字节后断开:
c00 start= 0ms end= 311ms HTTP/1.1 200 OK x-cache=hit body_len=1000 first="aaaaaaaaaaaaaaaaaaaaaaaa"
...
c04 start= 0ms end= 309ms HTTP/1.1 200 OK x-cache=miss body_len=1000 first="aaaaaaaaaaaaaaaaaaaaaaaa"
[2026-10-29T06:00:53Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18601, scheme: HTTP cause: context: Peer prematurely closed connection with 3000 bytes of body remaining to read, status: 502, tries: 1, retry: false, GET /half?ms=300&bad=1, Host: cache.test
[ 1326.7ms] req1 logging phase=disabled reason=UpstreamError upstream_used=true lock_wait=None status=Some(200) err=Some("ConnectionClosed")
[2026-10-29T06:00:53Z ERROR pingora_proxy::proxy_cache] Fail to serve cache: Downstream PrematureBodyEnd context: Content-length: 4000 bytes written: 1000, GET /half?ms=300&bad=1, Host: cache.test
[ 1327.2ms] req2 logging phase=hit reason=- upstream_used=false lock_wait=Some(4) status=Some(200) err=Some("PrematureBodyEnd")
...
5 个客户端都只拿到 1000 字节,连接被断开。锁早在响应头时就以 Done 释放了,写者后来的失败没法再通知读者;读者手里的 PartialHit 在写者的 MemMissHandler 被丢弃后收不到新数据,按 body 结束处理,下游的 HTTP/1 写端发现字节数不够 Content-Length,报 PrematureBodyEnd。MemCache 的源码在这里留了一行「FIXME: sender could drop because of an error」,模块文档也写明「For testing only, not for production use」。release 构建同样复现。生产用的存储如果支持边写边读,要自己把「写入失败」传给正在读的请求。
0.9.0:读者可以提前放弃
a0a88a3(「let a reader stop waiting when the writer names its fill」,首次进入 0.9.0)加了一条读者主动放弃的路:写者用 WritePermit::publish(&[u64]) 描述「这次在取的是什么」,读者把自己不能用的 token 放进 CacheKey.extensions 里的 UnusableFills,cache_lock_wait 改用 wait_unless_published,一旦匹配就以 Abandoned 结束,只有这个读者放弃,锁的状态不变,其他读者照常等。提交说明给的动机是:读者开始等之后,写者才知道的事读者没法利用,只能等满锁龄;两个请求还可能互相等对方的锁。token 的含义由应用自己约定,pingora 只做整数比较。提交说明里这个类型叫 FillInterest,代码里叫 UnusableFills,以代码为准。
动脑筋答案
release 构建,LOCK=300 RETRIES=10,同样的 10 个并发:
[1019.1ms] origin #1 GET /slow (path hit 1) port=53974 if-none-match=- accept-language=-
[1325.3ms] origin #2 GET /slow (path hit 2) port=53975 if-none-match=- accept-language=-
[1632.6ms] origin #3 GET /slow (path hit 3) port=53976 if-none-match=- accept-language=-
[1940.7ms] origin #4 GET /slow (path hit 4) port=53977 if-none-match=- accept-language=-
[ 2020.5ms] req1 logging phase=miss reason=- upstream_used=true lock_wait=None status=Some(200) err=None
[ 2248.4ms] req5 logging phase=hit reason=- upstream_used=false lock_wait=Some(1230) status=Some(200) err=None
...
[ 2249.6ms] req10 logging phase=hit reason=- upstream_used=false lock_wait=Some(1232) status=Some(200) err=None
[ 2326.6ms] req2 logging phase=miss reason=- upstream_used=true lock_wait=Some(305) status=Some(200) err=None
[ 2635.1ms] req3 logging phase=miss reason=- upstream_used=true lock_wait=Some(612) status=Some(200) err=None
[ 2943.6ms] req4 logging phase=miss reason=- upstream_used=true lock_wait=Some(921) status=Some(200) err=None
4 个。0、300、600、900 ms 各选出一个写者,重试次数够用,剩下 6 个读者没有放弃。第一个写者 req1 在约 1000 ms 时写完缓存,但读者没有在这时醒来:它们等的是第四个写者 req4 的锁,req1 释放的是自己那把早已 AgeTimeout 的旧锁,叫醒不了别人。读者一直等到 req4 的锁也到龄(900 + 300 ≈ 1200 ms),醒来重新查存储,这才命中,lock_wait 是 1230 ms,比上游的 1000 ms 还长。调大重试次数把上游请求从 10 个降到了 4 个,但读者的延迟反而比直接去上游更高;锁龄比上游慢,调别的参数都补不回来。
小结
- 缓存锁在
cache_lookup(未命中)和cache_found(过期、强制未命中)里拿;第一个请求拿写锁去上游,其余拿读锁等。没配锁时每个未命中都去上游,10 个并发就是 10 次。 CacheLock是按combined_bin分 16 片的表,锁本体是一个初始为 0 的Semaphore加一个状态字节;写者释放时写下Done、TransientError、GiveUp等状态。- 读者醒来后看状态:
Done重新查存储;TransientError、Dangling、AgeTimeout重新抢锁;GiveUp、WaitTimeout放弃缓存去上游。重试默认最多 2 次(0.9.0 起有上限)。 - 实测(10 个并发):正常 1 次上游请求;写者一直失败、锁龄比上游慢、等待时间比上游慢、响应不可缓存,上游都被打满 10 次。
- 边写边读的存储在响应头写入后就放锁,读者跟着写者读 body;
MemCache下写者中途失败,所有读者拿到截断的响应。