Rust in Everything
基于 pingora-cache 0.9.0

缓存锁

主线位置:缓存主线的「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 状态。

💡TIP
打个比方:餐厅门口的取号机

一桌客人到了,店里没有现成的菜(存储里没有),领号机(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重新查存储,查不到就重新抢锁,可能成为新的写者
GiveUpdisable(CacheLockGiveUp),不缓存,去上游
Abandoned { reason, .. }disable(reason),不缓存,去上游(0.9.0 新增,见文末)
WaitTimeoutdisable(CacheLockTimeout),不缓存,去上游

「重新查存储」的那几种算一次重试。cache_lock_retry_limit_exceeded 数重试次数,上限是 CacheOptionOverrides::max_lock_retries,默认 DEFAULT_MAX_CACHE_LOCK_RETRIES = 2;第 3 次时 disable(CacheLockRetryLimit),不再等锁,直接去上游。

📝NOTE
冷知识:0.9.0 之前,读者可以一直等下去

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 后关连接,之后正常21 个 502,9 个 200(8 个 hit)无(命中)
/fail?bad=100:每个请求都等 300 ms 后关连接1010 个 5027 个 CacheLockRetryLimit
/nostore:等 300 ms 回 no-store1010 个 2009 个 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)」。

两种超时的用途不同。锁龄防的是写者卡死,比如上游一直不回响应头,读者不能永远陪着;等待时间防的是单个请求的延迟失控。两个都应该比上游正常的响应时间长,否则锁形同虚设。

❗IMPORTANT
动脑筋: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 下写者中途失败,所有读者拿到截断的响应。

💬 关联讨论

发起讨论
加载中...