缓存不是一个 get/set:生产系统的新鲜度、防击穿与失效契约
缓存事故很少来自 get/set 本身,更常见的是租户串数据、价格更新后仍显示旧值,或热点键同时过期压垮数据库。
可靠缓存首先要回答三件事:什么请求代表同一份内容,内容允许旧多久,变化后如何让每一层收敛。命中率只是结果,不是契约。
缓存键必须描述完整表示
同一个 URL 可能因租户、用户权限、语言、设备能力、压缩方式或实验分组返回不同内容。HTTP 缓存通过 Vary 声明参与选择表示的请求头;应用缓存也需要一个集中维护的键构造器,而不是让调用者随手拼字符串。
product:v3:{tenant_id}:{product_id}:{currency}:{locale}
键模式要有版本,并明确缺省值、大小写和规范化方式。不能把令牌、邮箱等敏感原文写进键和日志。更重要的是,不能用“登录了”代替授权上下文:若响应因用户权限而变化,就应使用私有缓存、把安全主体纳入隔离维度,或根本不缓存。共享缓存收到带 Authorization 的请求时,只有响应明确允许才可复用。
新鲜度和可存储性是两件事
Cache-Control: no-store 表示不要存储;no-cache 并非“不缓存”,而是每次复用前必须向源站成功验证。private 阻止共享缓存存储响应,浏览器私有缓存仍可保存。max-age 控制新鲜时间,s-maxage 可以为 CDN 等共享缓存单独设定时间。
Cache-Control: public, max-age=60, s-maxage=300,
stale-while-revalidate=30, stale-if-error=600
这个例子允许浏览器新鲜使用 60 秒、共享缓存使用 300 秒;之后可在 30 秒窗口内边返回旧值边验证,源站出错时还可在限定窗口内返回旧值。所有数字表达的都是产品容忍度,不能从性能目标反推。账户余额、权限撤销和库存承诺通常比文章列表需要更严格的陈旧上限。
immutable 只适合内容寻址或文件名带版本的资源。若 /app.js 内容会原地变化,却声明长期不可变,就只能等待客户端过期。静态资源宜使用哈希文件名和长缓存;HTML 或入口清单使用短新鲜期与验证器。
用验证器减少传输,而不是重新计算全部内容
源站可发送 ETag 或 Last-Modified。缓存过期后携带 If-None-Match 或 If-Modified-Since;表示未变化时,服务器返回 304 Not Modified 并更新缓存元数据,无需传输正文。
ETag 必须对应实际表示。JSON 字段、语言或编码不同,就不能误用同一个强 ETag;压缩协商还要配合 Vary: Accept-Encoding。验证逻辑应比重新查询并渲染整个页面更便宜,否则只省下带宽,没有减轻源站负载。
热点过期需要请求合并
一万个请求发现同一个键过期时,不能让一万个请求同时回源。每个进程或缓存节点应对同一键执行 single-flight:一个请求负责重建,其余等待短暂结果或在策略允许时拿到陈旧值。锁要有超时和所有权校验,重建失败不能留下永久锁。
给 TTL 增加受控随机抖动,可避免大量键同时失效。后台刷新应限制并发并传播 deadline;无限制预热只是把击穿搬到任务队列。负缓存也要谨慎:确定不存在的 404 可以短暂缓存,超时和 500 不能伪装成长期“不存在”,授权失败更不能跨主体复用。
失效必须覆盖每一层
一次请求可能经过浏览器、CDN、服务内存和 Redis。只删除 Redis 键,浏览器仍可能显示旧页面;只清 CDN,进程缓存也可能继续命中。为每类数据建立矩阵:缓存层、键模式、新鲜期限、最大陈旧时间、写入者、失效信号和失败时行为。
数据库事务提交后再发布失效事件,最好使用 outbox 等机制避免“数据已提交但事件丢失”。消费者必须幂等,并按实体版本拒绝乱序旧事件。版本化命名空间也比枚举删除可靠:将 catalog:v42:... 切到 v43 可让新读流量立刻绕开旧集合,旧键随后自然淘汰。它不能替代容量治理,但能把正确性从全量删除是否成功中解耦。
写路径需要明确顺序。常见做法是在数据库提交成功后删除缓存,让下一次读取重建;若先删后写,并发读可能把旧数据库值重新填回缓存。对强一致读取,应直接绕过缓存或使用版本条件,不能把最终一致缓存包装成事务保证。
让命中原因可以解释
记录 hit、miss、stale、revalidated、bypass 和 error,并附缓存层、键模式版本、对象版本、Age 与重建耗时,不记录敏感完整键。RFC 9211 定义的 Cache-Status 能统一说明 HTTP 缓存如何处理响应;多层缓存按经过顺序追加成员,更容易定位是谁返回了旧内容。
看板不能只展示命中率,还要观察回源请求、合并等待者、重建失败、陈旧响应年龄、驱逐、键基数和源站延迟。高命中率也可能掩盖长期错误数据。
测试应覆盖不同租户和权限绝不串值、Vary 维度变化、TTL 边界、304 更新、源站错误时的陈旧窗口、热点并发只触发一次重建、失效事件重复和乱序,以及某一缓存层不可用。使用可控时钟,避免等待真实 TTL。
好的缓存设计不是尽量多存,而是让旧数据的风险有上限、回源的压力可合并、变化能跨层收敛,且每次命中都能解释来源。把这些条件写成契约后,缓存才是可靠性工具,而不是隐藏在成功响应后的另一份数据库。