读缓存先写合同: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 lookup | 5 秒 | 登出/撤销必须同步驱逐;缓存不能延长真实 session 过期时间。 |
/api/auth/me | 5 秒 | 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_pools(server/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-273、323-390、397-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 做读缓存,不会只说“加缓存”。我会要求它先交出这份合同:
- 写清楚到底缓存哪几个读模型;
- 每个模型单独定义 TTL 和内存上限;
- 把字段白名单做进缓存值类型,而不是只写在评论里;
- 先接好所有会让值变假的写路径,再谈性能收益;
- 用 generation 或 version 检查防住 miss 之后的 late insert;
- 把撤销、过期、写入失效和字段保密都写成测试。
没有这张表,缓存只是更快的数据库查询。有了这张表,缓存才是一份读路径合同:系统变快,是因为它知道自己到底允许复用哪一种真相。