← 返回博客2026-08-26

读缓存先写合同:5 秒 session cache 也不能延长登录态

这次性能改动真正有用的不是 HashMap,而是缓存边界:三类读模型、各自 TTL、字段白名单、同步失效、generation 防 late insert,以及专门测试撤销和过期。缓存之前,先定义系统允许复用哪一种真相。

问题不是“要不要缓存”

在有登录态的产品里,“加个缓存”这个需求太粗了。缓存确实能少打数据库,但也可能让已经登出的 cookie 继续有效、让用户看到旧头像,或者把原本不该出现在返回值里的字段一起缓存出去。

这次改动给 BluffKing server 加了一层进程内读缓存。真正值得抄的不是 HashMap,而是它外面的合同:哪些读模型允许复用,最多复用多久,哪些写入必须立刻失效,以及缓存值里到底允许有哪些字段。

代码把边界写在了最前面。server/src/read_cache.rs:1-14 明说这不是通用 HTTP response cache,只缓存三类低波动读模型:正向 session 身份、调用者自己的 /api/auth/me UI 读模型,以及牌桌上公开可见的头像/昵称字段。

先写读模型合同

我的可复用做法是:先把缓存表写出来,再写缓存实现。这个 patch 里,三类缓存各自有窗口和安全条件:

读模型允许窗口安全条件
正向 session lookup5 秒登出/撤销必须同步驱逐;缓存不能延长真实 session 过期时间。
/api/auth/me5 秒profile、email、session 相关变更会让用户读模型失效;membership/entitlement 变更依赖安全 TTL。
牌桌头像/昵称字段60 秒缓存值只含 id、display name、avatar key、avatar data;不含 email。

这些不是文档里的愿望,而是代码常量:server/src/read_cache.rs:28-37。头像缓存的字段白名单是结构体本身:server/src/read_cache.rs:77-84。真正查询时,handler 也只取这几列:server/src/handlers/users.rs:1244-1254

这就是“缓存一次 SQL”和“缓存一份合同”的区别。前者只说明 SQL 贵;后者说明系统愿意从内存里复用哪一种真相。

先失效,再谈命中率

最危险的是 session cache。它缓存的是“这个 cookie 对应哪个用户”的正向结果;如果驱逐不及时,一个已撤销 cookie 就可能继续通过认证。

这个 patch 用两层防护收口。第一层:数据库 lookup 会返回 cache_valid_until,而它被真实的 session 截止时间封顶。server/src/auth/session.rs:219-275 同时检查绝对 expires_at 和 idle timeout;server/src/read_cache.rs:333-355 插入缓存时最多只用剩余有效期。

第二层:logout 和 logout-all 会把失效信号同步推到内存。WebSocket revocation registry 在单 token 撤销时调用 invalidate_session_hash_all_pools,在按用户撤销时调用 invalidate_user_all_poolsserver/src/ws_revocation.rs:223-240)。缓存实现会删除匹配项,并推进 generation(server/src/read_cache.rs:412-453)。

generation 解决的是缓存补丁里最容易漏掉的 race:缓存 miss,开始等数据库;这时发生 revoke;await 结束后又把旧值塞回缓存。这里每次 load 先记录 generation,只有 generation 没变时,put_session_if_current / put_me_if_current / put_avatar_if_current 才允许写入(server/src/read_cache.rs:270-273323-390397-409)。

测试失败模式,不只测试变快

这组回归测试的重点不是“第二次请求命中了缓存”。它们把缓存最容易出错的边界单独钉住:

  • server/tests/avatar_data.rs:168-182 先预热 /api/auth/me,再写入头像,并断言缓存的 /me 读模型已经失效。
  • server/tests/avatar_data.rs:193-224 检查重叠头像 batch 会收敛成一个按 user 的结果,同时守住不返回 email 的字段边界。
  • server/tests/avatar_data.rs:226-237 证明 logout 会同步驱逐正向 session cache,同一个 token 立刻变成 unauthorized。
  • server/tests/avatar_data.rs:239-261 证明 5 秒 cache 不会把一个只剩 150 毫秒的 session 延长。
  • server/src/read_cache.rs:512-580 直接测试 “miss → await → invalidate → late insert” 这条 race。

还有一个性能守卫:server/tests/avatar_data.rs:264-309 对 warm 状态下的 /api/auth/me/api/users/avatars 各跑 100 次进程内调用,要求 p95 低于 5,000 微秒。这个数字只是 service path 的回归预算,不是生产延迟承诺;它不包含 TLS、网络传输,也不代表线上机器负载。

我会抄走的部分

以后我让 agent 做读缓存,不会只说“加缓存”。我会要求它先交出这份合同:

  1. 写清楚到底缓存哪几个读模型;
  2. 每个模型单独定义 TTL 和内存上限;
  3. 把字段白名单做进缓存值类型,而不是只写在评论里;
  4. 先接好所有会让值变假的写路径,再谈性能收益;
  5. 用 generation 或 version 检查防住 miss 之后的 late insert;
  6. 把撤销、过期、写入失效和字段保密都写成测试。

没有这张表,缓存只是更快的数据库查询。有了这张表,缓存才是一份读路径合同:系统变快,是因为它知道自己到底允许复用哪一种真相。