打开增量缓存后,bob 看到了 alice 的页面
主线位置:首屏主线的两头。
render_to一开始先查增量缓存,命中就整页送出,不建VirtualDom、不跑组件、不调 server function;没命中就照常渲染,最后把结果存起来(增量渲染与配置)。缓存里的是渲染好的 HTML 和水合数据,谁请求都发同一份。
sequenceDiagram participant A as alice participant B as bob participant S as render_to participant K as 增量缓存 A->>S: GET /me S->>K: 未命中 渲染 alice S->>K: 存 /me B->>S: GET /me S->>K: 命中 /me K-->>B: alice 的页面
实验应用 pit2-fs(dioxus = "=0.7.10",dx 0.7.9,release 构建)。环境变量 PIT2_MODE=isr 时 ServeConfig::new().incremental(IncrementalRendererConfig::new().static_dir(..)),PIT2_TTL 设 invalidate_after,PIT2_MEM 设 memory_cache_limit。M2 Max、macOS 27、rustc 1.99.0。
现象
#[get("/api/whoami", headers: HeaderMap)]
pub async fn whoami() -> Result<String, ServerFnError> {
let user = headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("user=")))
.unwrap_or("anonymous")
.to_string();
info!("whoami() -> {user}");
Ok(user)
}
#[component]
fn Me() -> Element {
let user = use_server_future(whoami)?;
rsx! { p { id: "hello", "hello, {user():?}" } }
}
#[component]
fn Gone() -> Element {
use_hook(|| {
dioxus::fullstack::FullstackContext::commit_http_status(
dioxus::fullstack::StatusCode::NOT_FOUND,
None,
)
});
rsx! { p { "this page is gone" } }
}
isr.sh:PIT2_TTL=3,static_dir 为空,依次请求,每次只打印状态行、age、cache-control 和页面里那一段:
### GET /me (cookie: user=alice)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### GET /me?x=1 (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### GET /gone
HTTP/1.1 404 Not Found
age: 0
<p data-node-hydration="0">this page is gone</p>
### GET /gone
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
<p data-node-hydration="0">this page is gone</p>
### (5 秒后) GET /me (cookie: user=carol)
HTTP/1.1 200 OK
age: 18446744073709551611
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
-- whoami calls in the server log:
whoami() -> alice
-- files:
isr-static/gone/index/6ae1eb05.html
isr-static/me/index/6ae1eb05.html
四个问题,一次跑全:
- 串用户:bob 拿到 alice 的页面。
whoami只跑了一次。页面里的水合数据也是 alice 的,浏览器水合时直接用它,不会再请求。 - 串 query:
/me?x=1的内存键和/me不同,内存没命中,但文件缓存的路径去掉了 query,读到了刚才 alice 那份。分页、搜索这类按 query 变的页面会互相串。 - 状态码丢失:
/gone第一次 404,第二次命中缓存变成 200。组件加的响应头同样不会出现在命中的响应里。 - 不过期:
invalidate_after是 3 秒,5 秒后 carol 拿到的还是 alice 的页面,Age是 2⁶⁴ − 5。
出处
- 键:
SsrRendererPool::render_to用parts.uri.path_and_query()作路由字符串,check_cached_route拿它查缓存,命中时直接返回状态 200、空的HeaderMap。请求头、cookie 都不在键里。 - 文件缓存:默认的
map_path先去掉?之后的部分,再拼到static_dir下,所以不同 query 落在同一个文件上。 - 不过期:
InMemoryCache::try_get_or_insert用timestamp.signed_duration_since(now),即「存入时刻减现在」,对过去存入的条目总是负数,to_std()失败,过期判断永远不成立;同一个负数转成u64写进Age。来历和推导见增量渲染与配置的「过期时间不起作用」。 - 没有按路由关闭的开关:
ServeConfig::incremental对所有走渲染 fallback 的路由生效;IncrementalRenderer::invalidate是公开方法,但渲染器在FullstackState的私有字段里,应用拿不到。
修法
能不开就不开。 页面里只要有一处读 cookie、读请求头、按 query 变、设状态码,就不该打开 incremental。本站没有开(本站的页面)。
非开不可时,让缓存的 HTML 对谁都一样。 用户相关的部分不在服务端渲染,水合之后由浏览器自己取:
#[component]
fn Me2() -> Element {
// the cached HTML has no user in it; each browser asks for its own after hydration
let mut user = use_signal(|| None::<String>);
use_effect(move || {
spawn(async move {
user.set(whoami().await.ok());
});
});
rsx! { p { id: "hello", "hello, {user():?}" } }
}
use_effect 不在服务端跑,服务端渲染(也就是被缓存的那一份)里永远是 None:
== /me2: user fetched after hydration (PIT2_TTL=3)
### GET /me2 (cookie: user=alice)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, None
### GET /me2 (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, None
"bob" <- GET /api/whoami (cookie: user=bob)
bob 命中了缓存,但缓存里没有 alice 的任何东西;水合后浏览器带着自己的 cookie 调 /api/whoami,server function 不经过增量缓存,拿到的是 bob。代价是首屏不含用户名,水合后多一个请求,use_effect 里的写入在水合之后发生,所以不会造成水合不一致。
按 query 变的页面同理:query 相关的数据放到水合之后取,或者不要用增量缓存。需要 404 的页面不要依赖增量缓存保留状态码。
过期在 0.7.10 里修不好。 内存那一层的判断方向反了,文件那一层方向是对的,但只有内存里没有这个键时才会去看文件。很自然会想到把内存缓存关掉:
== memory_cache_limit(0), PIT2_TTL=3
### GET /me (cookie: user=alice)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("bob"))
whoami() -> alice
whoami() -> bob
isr-static-fix/me/index/6ae1eb3d.html
bob 拿到了自己的页面,但不是因为过期判断起作用了,而是缓存再也不会被读:InMemoryCache::new 在上限为 0 时 lru 是 None,try_get_or_insert 开头的 if let Some(memory_cache) = self.lru.as_mut() 不成立,直接 Ok(None),读文件的那个闭包从来不被调用。每个请求都重新渲染,文件照写不读。memory_cache_limit 的文档只有一句「Set the memory cache limit.」,看起来 0 就是「只用文件缓存」,实际效果是整个增量缓存只写不读。要让内容更新,只能重启进程(内存清空,文件按 invalidate_after 判断过期)。
怎么确认没中招
- 搜
.incremental(:没有就不受影响。 - 开着的话,用两个不同的 cookie 先后请求每个页面,比较 HTML;再对同一个页面换不同 query 请求,比较 HTML。
- 看响应头:
age是一个接近 2⁶⁴ 的数,就是内存缓存命中了一个已经存了几秒的条目;本该是 404 的页面第二次请求是 200,就是命中了缓存。
小结
- 增量缓存的键是
path?query,不看 cookie 和请求头;带用户信息的页面第二个人看到第一个人的,水合数据也是。 - 文件缓存的路径去掉 query,不同 query 会串;命中时状态码写死 200,响应头丢失。
- 0.7.10 的内存缓存从不过期,
Age是负数转u64;memory_cache_limit(0)让缓存只写不读。 - 只对所有人都一样、和 query 无关的页面打开;用户相关部分在
use_effect里水合后再取。