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 没有提交。
问: 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,超时只停止等待。