Rust in Everything
基于 dioxus 0.7.10

打开增量缓存后,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(&#34;alice&#34;))
### 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(&#34;alice&#34;))
### 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(&#34;alice&#34;))
### 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(&#34;alice&#34;))
-- whoami calls in the server log:
whoami() -> alice
-- files:
isr-static/gone/index/6ae1eb05.html
isr-static/me/index/6ae1eb05.html

四个问题,一次跑全:

  1. 串用户:bob 拿到 alice 的页面。whoami 只跑了一次。页面里的水合数据也是 alice 的,浏览器水合时直接用它,不会再请求。
  2. 串 query:/me?x=1 的内存键和 /me 不同,内存没命中,但文件缓存的路径去掉了 query,读到了刚才 alice 那份。分页、搜索这类按 query 变的页面会互相串。
  3. 状态码丢失:/gone 第一次 404,第二次命中缓存变成 200。组件加的响应头同样不会出现在命中的响应里。
  4. 不过期: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(&#34;alice&#34;))
### GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok(&#34;bob&#34;))
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 里水合后再取。

💬 关联讨论

发起讨论
加载中...