从一个后台留言说起
上篇把订单接口从 3 秒优化到 30 毫秒,有同学问我:为什么写数据是「先更新数据库,再删除缓存」,能不能反过来?这个问题的完整答案,就藏在这篇的主角——缓存读写模式里。模式决定了读写时序,时序决定了一致性和性能的上限。四种模式一次讲透:Cache Aside、Read Through、Write Through、Write Behind。
Cache Aside:九成项目的默认选择
Cache Aside(旁路缓存)是最常见的模式。它的核心特征是:应用同时对接缓存和数据库,缓存只是旁路——数据库才是数据的主本,缓存只是加速层。
读路径:先缓存,未命中回源
public Article getArticle(Long id) {
String key = "article:detail:" + id;
Article article = redisTemplate.opsForValue().get(key);
if (article != null) {
return article; // 1. 命中,直接返回
}
article = articleMapper.selectById(id); // 2. 未命中,回源查库
if (article != null) {
redisTemplate.opsForValue().set(key, article, Duration.ofMinutes(10)); // 3. 回填缓存
}
return article;
}三步代码,三个细节都有讲究:回填必须带 TTL(裸 set 是事故之源,第 6 篇雪崩会专门讲);查库结果为空时不回填(空值防护是第 4 篇穿透的主题);回填失败不要抛异常阻断主流程(缓存只是加速层,挂了要能降级直读 DB)。
写路径:先更库,再删缓存
@Transactional
public void updateArticle(Article article) {
articleMapper.updateById(article); // 1. 先更新数据库
redisTemplate.delete("article:detail:" + article.getId()); // 2. 再删除缓存
}为什么是「删」不是「更」,是「先库」不是「先缓存」
这两个选择背后是同一类问题:并发时序。
为什么删除而不是更新缓存?两个原因。其一,并发写会乱序:两个请求先后更新同一行,写库顺序是 A、B,但两次缓存写回到达 Redis 的顺序可能颠倒成 B、A,缓存里就永久留下了旧值;删除是幂等操作,谁后执行都是删,天然没有顺序问题。其二,惰性加载:写时回填的值可能到过期都没人读一次,白算;删除后等真正有人读再回填,按需加载。
那为什么先更库再删缓存?假设反过来,先删缓存再更新库:删除和更新之间有个时间窗,期间任何读请求都会把旧值从库里捞出来重新灌进缓存——旧数据要一直住到 TTL 过期。而先更库再删缓存,即使窗口里有读请求灌入旧值,也会在第二步删除时被清掉,风险窗口小一个量级。
当然它也不是绝对安全:读请求若恰好落在「库已更新、缓存未删」之间,仍会读到旧值并回填。所以工程上通常再加一层 TTL 兜底,配合延迟双删等进阶手段——这些留到第 7 篇一致性专题展开。
Read Through 与 Write Through:把缓存当主存储
这对模式里,应用只和缓存层打交道,数据库完全透明——查库、回填、落库全部由缓存服务自己完成。
Read Through:读请求只发给缓存,未命中时缓存服务自己去查库并回填,应用无感知。和 Cache Aside 的区别只有一个:回填动作从应用代码挪进了缓存组件。
Write Through:写请求也只发给缓存,缓存服务同步地先写自己、再写数据库,两边都成功才算成功,强一致。
听起来很美好,但 Redis 本身并不提供这套能力——它不知道你的数据库长什么样。要落地,要么团队自己封装统一的缓存组件,要么用现成框架。Spring Cache 的抽象其实就在往这个方向走:你面对的只有 CacheManager,背后连的是 Redis 还是 Caffeine,业务代码不感知。对有基建能力的团队,把这套模式沉淀成中间件,业务代码会非常干净;对没有基建的小团队,手写 Cache Aside 反而更务实。
Write Behind:用一致性换写性能
Write Behind(也叫 Write Back)是激进派:写请求只写缓存,立即返回;后台线程异步、批量地把数据刷回数据库。
收益是本质性的:写耗时就是一次 Redis 内存操作,数据库压力被削峰填谷。代价同样是本质性的:缓存挂了,还没来得及刷库的数据就永久丢了。
所以它的适用边界极其清晰——能容忍丢失的计数类数据。举个我自己做过的例子,文章阅读量:
// 每次阅读,阅读量 +1(纯内存操作,微秒级)
public void incrViews(Long articleId) {
redisTemplate.opsForValue().increment("article:views:" + articleId);
}
// 定时任务:每 30 秒把计数批量刷回 MySQL
@Scheduled(fixedDelay = 30000)
public void flushViews() {
Set<String> keys = redisTemplate.keys("article:views:*");
for (String key : keys) {
Long views = redisTemplate.opsForValue().getAndDelete(key);
articleMapper.addViews(extractId(key), views); // 增量累加,不覆盖
}
}阅读场景里,丢掉最后几秒的计数完全无感,但数据库从「每次阅读写一次」变成「每 30 秒批量写一次」,写压力差了三个数量级。反过来,订单、支付、账户余额这类数据,一条都不能丢——Write Behind 想都不要想。
Spring Cache 注解背后的模式语义
把模式套回 Spring 生态,你会发现注解的语义一下子清晰了:
// 读:Read Through 语义,未命中自动回源并回填
@Cacheable(cacheNames = "article:detail", key = "#id")
public Article getArticle(Long id) {
return articleMapper.selectById(id);
}
// 写:Cache Aside 写路径——方法体先更库,切面再删缓存
@CacheEvict(cacheNames = "article:detail", key = "#id")
public void updateArticle(Article article) {
articleMapper.updateById(article);
}读方法上的 @Cacheable 是 Read Through 语义:框架代理方法调用,未命中自动回源回填。更新方法上的 @CacheEvict 是 Cache Aside 的写路径:方法体先执行(更新库),切面再删除缓存——和我们手写的时序完全一致。
两个注意点:其一,Spring Cache 的 TTL 由 CacheManager 统一配置,注解本身不指定过期时间,不同数据需要不同 TTL 时,要么定义多个 cacheNames,要么退回手动控制(上篇的订单接口就是因此退回手写的);其二,需要分隔符的复合 key,在 SpEL 里做字符串拼接即可,这里不展开。
选型对照表
| 模式 | 一致性 | 写性能 | 实现成本 | 典型场景 |
|---|---|---|---|---|
| Cache Aside | 最终一致,窗口小 | 中 | 低 | 读多写少业务,默认选它 |
| Read Through | 最终一致 | 中 | 中 | 有统一缓存组件的团队基建 |
| Write Through | 强一致 | 低(同步双写) | 中 | 配置、字典等强一致小数据量写 |
| Write Behind | 弱一致,可能丢数据 | 极高(异步) | 高 | 阅读量、点赞数等计数场景 |
一句话选型:拿不准就用 Cache Aside 配合理 TTL;有基建就把 Read/Write Through 沉淀成中间件;只有能容忍丢失的计数场景,才值得上 Write Behind。
小结
决策链三步走:
- 默认 Cache Aside,写路径死记「先更库、再删缓存」,TTL 兜底;
- 团队有基建诉求时,把读写封装成统一的 Through 组件,业务只面对缓存;
- Write Behind 只给能容忍丢失的计数场景用,别碰主业务数据。
下一篇预告:模式选好了,数据准备进 Redis 了,但「怎么塞」还有一堆工程问题——键怎么命名才不会冲突、容量怎么估算才不会撞 maxmemory、大 value 怎么治理。下一篇《键设计与容量规划:数据进 Redis 之后的工程问题》一次讲透。
咖啡续上了,我们下篇见。
评论 (0)