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