缓存命中之后,问题才刚开始
给接口加缓存,最容易演示的是速度:第一次慢,第二次快。可真正需要回答的问题,是数据变了以后,第二次返回的还是不是正确答案。
下面用一个假设的商品详情接口讨论这个问题。没有特定公司的实现,也不涉及线上收益数据。
一次删除,挡不住晚到的旧值
Cache-aside 的读流程很直观:先读缓存,未命中就查数据库,再把结果写回缓存。Redis 的官方说明介绍了这种模式。
假设商品价格原来是 100,后来改成 120。即使更新数据库后删除缓存,也可能遇到下面的交错:
请求 A:缓存未命中,读到数据库中的旧价格 100 |
这不是删除命令没执行,而是旧的读取结果比删除动作更晚到达。只看各自的代码,每一步都说得通;把时间线放到一起,问题才出现。
TTL 给的是期限,不是同步承诺
过期时间能让一份缓存最终退出服务,但不能保证数据库更新后,所有请求立即看到新值。旧值在更新之后才被重新写入时,它还可能得到一段新的存活时间。Redis 对缓存一致性的讨论也指出了回填与失效之间的竞争。
因此,先问业务能接受什么。商品描述晚几秒刷新,和库存扣减晚几秒生效,不是同一个要求。缓存策略必须跟着这个要求走,而不是所有字段统一设一个 TTL。
版本号也需要配套机制
一个可以推演的方案,是让缓存数据携带版本:例如 (version=8, price=120)。但给结果加上版本字段,本身并不能阻止旧版本写入。
写入方必须有可以比较的版本依据,比较与替换还要保持原子性。如果删除缓存时连版本依据一起删除,晚到的旧请求仍可能把空位置填满。版本如何保留、如何过期、多个写入方如何协调,都属于这个方案的一部分。
另一种选择,是把对一致性要求高的操作留给权威数据源,缓存只承担允许短暂陈旧的展示查询。这个边界往往比补上一串复杂机制更容易解释和验证。
用时间线检查设计
评审缓存方案时,我更愿意先画几条请求的时间线:读取暂停、写入成功、删除失败、进程重启,再让先前暂停的请求继续。
至少要说清楚:旧数据从哪里来,靠什么消失,失效失败后谁补偿,以及哪些请求必须绕开缓存。
命中率能说明缓存承担了多少读取,不能单独说明它返回了多少正确答案。缓存的设计应该同时回答这两个问题。