缓存保存可以重新获得的数据,用额外复杂度换取较低的读取成本。设计前先回答三个问题:允许旧多久、缓存不可用时怎样降级、哪些数据不能只放缓存。库存余额和支付状态通常仍以数据库中的受约束事实为准,不能把缓存命中当作强一致性的证据。

Cache Aside 的正常路径

读取先查缓存,未命中再查数据库,并以有限 TTL 写回。更新先提交数据库,再使对应缓存失效。比起把两个存储当成一个事务,它更容易恢复,但仍有竞争窗口。

时刻读请求 A写请求 B
1未命中缓存,读到数据库旧值 v1
2数据库提交 v2
3删除缓存
4把刚才读到的 v1 写回缓存

于是即使“先写数据库后删缓存”,仍可能暂时读到 v1。TTL 给旧值一个存活上界,前提是没有其他请求持续续期旧值;它不证明写后立即可见。固定睡眠后再删一次只是缩小某些窗口,不能对任意暂停、重试和网络分区提供证明。

让策略匹配一致性需求

对商品介绍,可以容忍数十秒旧值,采用短 TTL、失效重试和回源保护。对刚修改完资料的用户,可在写响应中返回新值,或短时间直接读主库。要求更高时,需要版本号校验、可靠失效事件或将并发协调纳入设计;版本检查和写入若不是原子操作,也会再次出现竞争。

Redis 命令示例:

SET product:book-1 '{"version":7,"title":"数据库入门"}' EX 30
GET product:book-1
DEL product:book-1
SET rebuild:book-1 random-owner-token NX PX 2000

最后一行只是带租期的占位,不等于完整分布式锁。持有者可能暂停到租期过后;释放时要原子检查 token 再删除,不能无条件 DEL 别人的新锁。涉及外部资源时还要考虑 fencing token,由资源端拒绝旧持有者的操作。

热点问题是放大器

缓存雪崩指大量键同时失效或缓存整体不可用;击穿是少数热点键失效后大量请求回源;穿透是持续查询根本不存在的数据。分别可以考虑 TTL 抖动与降级、同键请求合并与后台刷新、短期空值缓存与输入校验。空值缓存也要过期,否则新建数据可能长时间不可见。

单进程请求合并只减少这一进程内重复工作,多实例仍可能各回源一次。回源必须有并发上限和超时,否则缓存故障会把数据库连接池耗尽。并发与背压解释怎样限制进入系统的工作量。

配置 Redis 的 maxmemory 与淘汰策略时,要区分允许淘汰的缓存和不能随意丢失的业务数据。TTL 是到期规则,eviction 是内存压力下的规则;设置了 TTL 不代表内存永远够用。观察命中率还不够,还要看回源延迟、淘汰数、热点分布和数据库压力。

练习与面试自查

把上表实现成可控制顺序的两个测试协程,用 channel 或屏障让旧读取停在写回之前。先复现旧值回填,再给缓存增加版本,并说明版本检查与写入需要怎样的原子边界。不使用随机 sleep 来证明不存在竞争。

  • 为什么缓存删除失败不能简单回滚已提交的数据库事务?
  • 缓存不可用时,“全部直接查库”为什么可能造成第二次故障?
  • 强一致库存为何不宜仅用普通缓存的 GET、减一、SET 实现?

参考资料