3. 缓存读写模式设计
缓存读写模式决定了应用代码、缓存层与数据库之间的职责边界。选错模式,往往不是“性能差一点”,而是出现脏数据、写放大、缓存击穿或数据丢失。本节把四种主流模式放在同一张“读写路径图”上对比,再落到高并发场景下的取舍依据。
一、四种模式的读写路径
先明确一个约定:下文所有“读/写”均以业务请求方向为参照——读指业务需要获取数据,写指业务需要修改数据;缓存与数据库之间的同步方向单独说明。
二、Cache-Aside:最常用,也最容易写错
Cache-Aside 由应用代码同时管理缓存和数据库,是 Redis 场景下最普遍的模式。读路径是“先查缓存,命中直接返回;未命中查库,再把结果写回缓存”。写路径的争议最大:推荐“先更新数据库,再删除缓存”,而不是“更新数据库后更新缓存”。原因是并发写时,两次更新缓存的顺序可能颠倒,导致缓存长期停留在旧值;删除缓存则让下一次读自然回源,把不一致窗口压缩到一次读的时长。
即便如此,仍存在经典的“读旧写新”竞态:读线程 A 未命中,查库得到旧值 v1;此时写线程 B 更新库为 v2 并删除缓存;A 随后把 v1 写回缓存,缓存就脏了。缓解手段有三类:一是给缓存设置合理 TTL(例如 300 秒),把最坏不一致限制在 TTL 内;二是延迟双删,写后延迟约 500ms 再删一次;三是用 Canal 订阅 binlog 异步删除,把删除动作与业务解耦。
// Cache-Aside 读
String get(String key) {
String v = redis.get(key);
if (v != null) return v;
v = db.query(key); // 回源
if (v != null) redis.setex(key, 300, v);
return v;
}
// Cache-Aside 写:先库后删
void update(String key, String val) {
db.update(key, val);
redis.del(key); // 删除而非更新
}
三、Read-Through:把回源逻辑收进缓存层
Read-Through 与 Cache-Aside 的读路径结果相似,区别在职责归属:应用只调用缓存,缓存组件在未命中时自己回源数据库并回填。它的价值是统一回源入口,避免每个业务方各写一套“查库+回填+空值处理”的代码,减少漏加 TTL、漏防穿透的概率。代价是需要缓存组件支持 loader 扩展,且回源逻辑与缓存实现耦合,排查问题时要同时看两侧日志。适合读多写少、数据访问模式统一、团队希望收敛缓存治理的场景。
四、Write-Through:一致优先,写延迟换稳定
Write-Through 在写请求时同步更新缓存与数据库,通常由缓存组件保证两者在同一逻辑写操作内完成。它的优点是缓存几乎不会出现“库新缓存旧”,读路径命中率高且稳定;缺点是每次写都要等数据库落盘,写吞吐受数据库限制,且对冷数据也会产生一次无意义的缓存写入(可配合“写时判断是否已缓存”优化)。适合对一致性敏感、写量不大但读量大的配置类、账户状态类数据。
五、Write-Behind:写性能最好,复杂度最高
Write-Behind 先写缓存并立即返回,再由后台任务批量、延迟地把变更刷入数据库。它把随机写合并为批量写,能显著降低数据库压力,适合计数、点赞、行为埋点等高写并发场景。但它引入了三个必须正视的问题:一是数据丢失窗口,Redis 宕机可能丢掉尚未落库的写;二是顺序与幂等,同一 key 的多次变更合并后要保证最终值正确;三是补偿,需要重试队列、死信记录和对账任务。没有这些配套,不建议在生产使用。
六、高并发下的取舍依据
| 模式 | 一致性 | 写性能 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|
| Cache-Aside | 最终一致(TTL 内) | 高 | 低 | 商品详情、用户资料 |
| Read-Through | 最终一致 | 高 | 中 | 读多写少、统一回源 |
| Write-Through | 较强一致 | 中 | 中 | 配置、账户状态 |
| Write-Behind | 弱一致、可能丢写 | 极高 | 高 | 计数、埋点、点赞 |
落地时建议先按数据域画一张“读写路径表”:列出 key 前缀、读 QPS、写 QPS、可接受不一致时长、是否允许丢写,再对照上表选模式。例如订单状态可接受不一致 1 秒且不允许丢写,选 Cache-Aside + 延迟双删;文章阅读量允许丢少量计数,选 Write-Behind 并每 5 秒批量落库一次。把选择写成显式规则,比凭经验拍板更可靠。