Rust in Everything
基于 pingora-memory-cache 0.9.0

RTCache 的写者被取消后,这个键再也缓存不上

主线位置:请求主线的 upstream_peer。常见的用法是在这里按 Host 查路由或配置,查询慢,就用 RTCache 读穿缓存,再套一个时间预算,查不到就走默认路由。RTCache 的锁和查询合并见 TinyUFO 与内存缓存。

sequenceDiagram
  participant R1 as 请求 1
  participant RT as RTCache
  participant R2 as 请求 2
  R1->>RT: get 未命中 插锁 当写者
  Note over R1: lookup 进行中
  Note over R1: 50 ms 预算到 future 被丢弃
  Note over RT: 锁还在 许可是 0
  R2->>RT: get 未命中 看到锁 当读者
  Note over R2: 等许可 永远等不到

实验 crate exp-pingora/pitb-lab,bin p28_rtcache,脚本 tools/p28.sh(debug);M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。代理在 19395,后端 python3 -m http.server 19396。

现象

upstream_peer 用 RTCache 按 Host 查后端地址,查询(RouteLookup::lookup)要 200 ms;每个请求最多等 50 ms,超时就走默认路由:

static ROUTES: Lazy<RTCache<String, String, RouteLookup, ()>> = Lazy::new(|| RTCache::new(1000, None, None));

// in upstream_peer:
let budget = Duration::from_millis(50);
let got = match tokio::time::timeout(budget, ROUTES.get(&host, None, None)).await {
  Ok((Ok(v), st)) => Some((v, st)),
  _ => None, // timed out: the get future, and the lookup inside it, is dropped here
};

同一个 Host 每隔 300 ms 请求一次,共 5 次:

/r1 200 0.072105s
/r2 200 0.057097s
/r3 200 0.055705s
/r4 200 0.053876s
/r5 200 0.057035s
## proxy
mode=bug
  lookup #1 started
/fast?r=1 route timed out after 52.562ms, fallback, lookups so far 1
/fast?r=2 route timed out after 52.281417ms, fallback, lookups so far 1
/fast?r=3 route timed out after 52.133125ms, fallback, lookups so far 1
/fast?r=4 route timed out after 51.1435ms, fallback, lookups so far 1
/fast?r=5 route timed out after 52.257916ms, fallback, lookups so far 1
  • 查询只开始过一次(lookup #1 started),从没结束:第一个请求 50 ms 时放弃,get 的 future 连同里面的 lookup 一起被丢掉。
  • 之后每个请求都白等 50 ms 再走默认路由,路由永远进不了缓存,查询也不会再发起。
  • 没有任何错误日志。默认路由碰巧能用时,表面上只是「每个请求慢了 50 ms」。

出处

RTCache::get 未命中时,第一个到的请求往锁表里插一把 CacheLock(一个 0 许可的 Semaphore),自己当写者;后来的请求看到锁,当读者,等许可。写者这一支:

            let value = CB::lookup(key, extra).await;
            let ret = match value {
                // ...
            };
            if let Some(my_write) = my_write {
                /* add permit so that reader can start. Any number of permits will do,
                 * since readers will return permits right away. */
                my_write.lock.add_permits(10);

                {
                    // remove the lock from locker
                    let mut lockers = self.lockers.write();
                    lockers.remove(&hashed_key);
                } // write lock dropped here
            }

放许可、删锁都在 CB::lookup(key, extra).await 之后。写者的 future 在这个 .await 处被丢弃,这两步就不会发生:锁留在表里,许可永远是 0。之后的 get 都成了读者:

  • lock_timeout 是 None(这里):读者在 acquire() 上永远等。上面每个请求只是被自己的 50 ms 预算救了出来。
  • 设了 lock_timeout:读者每次先等到超时,再自己查一次,结果直接返回、不写缓存。
  • 设了 lock_age:锁「太老」以后每个请求都自己查,同样不写缓存;锁也没人删,删锁的只有写者。

三种情况的直接测量见 TinyUFO 与内存缓存。锁上没有 drop 守卫,这是一个上游问题;read_through.rs 在 tag 之后到 84f21ee 没有提交。

📝NOTE
没有蠢问题

问: RTCache 不是还有一个 get_stale_while_update,会在后台 tokio::spawn 去查吗?用它是不是就没事了?

答: 只解决一半。get_stale_while_update 先调 get_stale,只有拿到的是过期值(cache_status.stale().is_some())时才 spawn 一个后台的 get 去刷新;缓存里根本没有这个键时,get_stale 直接调 get,查询就跑在调用方自己的 future 里,照样会被外面的 timeout 取消。第一次查询恰恰就是没有键的那一次。

修法

把 get 放进自己的任务,超时只是「不再等它」,而不是「把它丢掉」:

// the lookup keeps running in its own task even if we stop waiting
let task = tokio::spawn(async move {
  let (r, st) = ROUTES.get(&host, None, None).await;
  r.ok().map(|v| (v, st))
});
let got = match tokio::time::timeout(budget, task).await {
  Ok(Ok(v)) => v,
  _ => None,
};

MODE=fix:

/r1 200 0.055573s
/r2 200 0.002553s
/r3 200 0.002213s
/r4 200 0.001635s
/r5 200 0.003595s
## proxy
mode=fix
  lookup #1 started
/fast?r=1 route timed out after 52.519625ms, fallback, lookups so far 1
  lookup #1 finished
/fast?r=2 route 127.0.0.1:19396 (Hit) in 47.75µs, lookups so far 1
/fast?r=3 route 127.0.0.1:19396 (Hit) in 42.584µs, lookups so far 1
/fast?r=4 route 127.0.0.1:19396 (Hit) in 32µs, lookups so far 1
/fast?r=5 route 127.0.0.1:19396 (Hit) in 124.417µs, lookups so far 1

第一个请求照样在 50 ms 时走默认路由,但查询在后台跑完了(lookup #1 finished),值写进缓存、锁被删掉;之后的请求 50 µs 内命中。tokio::spawn 要求 future 是 'static,所以 RTCache 要放在 static(Lazy)或 Arc 里,键要克隆进去。

怎么确认没中招

  • 搜 RTCache 的 .get( / .get_stale(:外面有没有 timeout(、select! 或其他可能在它完成前丢弃它的结构。
  • 现象:某个键的查询计数停在 1、之后再也不增长,而这个键一直未命中;或者某些请求在 get 上一直挂到 lock_timeout 或自己的超时。
  • 用一个故意很慢的 Lookup 加一个短超时,对同一个键连查两次:第二次还是超时,就是锁漏了。

小结

  • RTCache::get 的写者在 lookup().await 之后才放许可、删锁;写者被取消,锁就永远留在表里。
  • 之后这个键:不设 lock_timeout 时读者永远等,设了就每次白等再自己查、不写缓存,设 lock_age 也永远 miss。实测每个请求都多等 50 ms,查询只发起过一次。
  • 上游问题,main 未修。修法:get 放进 tokio::spawn,超时只停止等待。

💬 关联讨论

发起讨论
加载中...