缓存和数据库两个地方都存了同一份数据,一旦更新,就得保证两边一致。
我一开始的直觉是"改完数据库,顺手把缓存也改一下"------这个直觉是错的。
一、先想清楚:缓存到底该怎么更新
缓存更新有三种常见策略,先摆出来对比:
| 策略 | 做法 | 一致性 | 实时性 | 维护成本 |
|---|---|---|---|---|
| 内存淘汰 | 不管,等 Redis 内存满了自己淘汰 | 差 | 差 | 零 |
| 超时剔除 | 给缓存设 TTL,到期自动删 | 一般 | 一般 | 低 |
| 主动更新 ⭐ | 改数据库时,自己动手处理缓存 | 好 | 好 | 高(要写代码) |
秒杀、商品详情这种场景,数据一致性直接影响用户判断(价格、库存、店铺状态),光靠 TTL 太被动------用户可能看到 30 分钟的旧数据。所以必须主动更新。
问题来了:主动更新的时候,缓存是"删"还是"改"?
二、第一个决定:删,不是改
我最开始的写法是"改"------数据库改完,把新值也写进缓存:
java
// ❌ 我最初的写法:改缓存
public Result updateShop(Shop shop) {
updateById(shop); // 1. 改数据库
stringRedisTemplate.opsForValue().set( // 2. 改缓存
CACHE_SHOP_KEY + shop.getId(),
JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
return Result.ok();
}
看起来天衣无缝。但它有两个问题:
问题一:并发更新会互相覆盖
两个线程同时改同一个店铺:
线程 A:改库(name="A店") → 改缓存(name="A店")
线程 B:改库(name="B店") → 改缓存(name="B店")
如果 B 先改库、A 后改库,但 A 先写缓存、B 后写缓存 ,最终数据库是 "A店"、缓存是 "B店"------不一致。
要解决,就得给缓存写入加锁或者版本控制,复杂度立刻上去了。
问题二:写入的值可能已经过期了
更隐蔽的一种:线程 A 算出要写的新值,但还没来得及写缓存,数据又被改了。A 写进去的是"曾经的正确值",现在是错的。
所以答案是:删缓存
java
/** 更新商铺:先改库再删缓存(Cache Aside,顺序不能反) */
@Override
public Result updateShop(Shop shop) {
// 1. 校验 id
Long shopId = shop.getId();
if (shopId == null) {
return Result.fail("店铺id不能为空");
}
// 2. 更新数据库
updateById(shop);
// 3. 删除缓存,下次查询时重建
stringRedisTemplate.delete(CACHE_SHOP_KEY + shopId);
return Result.ok();
}
为什么"删"比"改"好?三个理由:
- 删除是幂等的。删一次、删十次结果一样;改就可能因为顺序问题留下错误的值。
- 删了之后,下次查询一定从库里读最新值。缓存重建时拿到的是当时数据库的真值,天然不会过期。
- 不用关心缓存里存的是什么结构 。只要删掉,重建逻辑复用了已有的查询路径(也就是上一篇写的
CacheClient)。
一句话:"删"把"保证一致"的责任,交给了"下次读库"这个必然发生的事件。
三、第二个决定:先改库,还是先删缓存
删除缓存有两个时机,差别很大。
先删缓存,再改库 → 有明确的不一致窗口
线程 A:删缓存
↓
线程 B:查缓存 → 未命中 → 查库(旧值 100)→ 写缓存(旧值 100)
↓
线程 A:改库(新值 200)
↓
结果:数据库 200,缓存 100 ❌
问题在于:B 写缓存的时机,可能晚于 A 删缓存,但早于 A 改库。 于是旧值被重新写回缓存,而且如果没设 TTL,这个错会一直存在。
这个窗口看着很窄,但只要 B 那次查询稍微慢一点(慢 SQL、GC 停顿),就会被撞上。
先改库,再删缓存 → 窗口小得多
线程 A:查缓存 → 未命中 → 查库(旧值 100)
线程 B:改库(新值 200)→ 删缓存
线程 A:写缓存(旧值 100) ← 还是把旧值写回去了
理论上也可能不一致,但触发条件苛刻得多:
- A 必须在 B 改库之前就查完库
- A 的写缓存必须在 B 删缓存之后
而"写缓存"是一个纯内存操作,通常只有几毫秒。要让它跨过 B 的"改库 + 删缓存"两步,概率极低。
这就是为什么业界标准做法是 Cache Aside:先更新数据库,再删除缓存 。
它不能 100% 消除不一致,但把一个"容易撞上"的窗口,缩成了"几乎撞不上"的窗口。
兜底:延迟双删
如果业务对一致性要求更高,可以再加一手:
java
// 1. 改数据库
updateById(shop);
// 2. 立即删缓存
stringRedisTemplate.delete(key);
// 3. 延迟再删一次(干掉可能被其他线程写回的旧值)
executor.schedule(() -> stringRedisTemplate.delete(key),
500, TimeUnit.MILLISECONDS);
第二次删除等多久?要大于"一次读请求从查库到写缓存"的耗时。给 500ms 是个经验值。
我们项目最终只做了"先改库再删缓存",没有加延迟双删 。原因很实际:商铺信息不是资金数据,配合 TTL 兜底已经够用,多一次延迟删除就多一个后台任务要维护。面试时我会说清这个取舍。
四、必须加的那层保险:TTL
不管时序分析做得多细,都要给缓存设 TTL。
理由:上面所有分析都建立在"我知道所有写路径"这个假设上。但现实中:
- 有人直接改数据库(运维脚本、其他服务)
- 你的代码未来会加新的写入口
- 极端情况下主从延迟
TTL 是最后一道保险------就算前面全漏了,最坏情况也只错 TTL 那段时间。
所以 updateShop 之外,写入缓存的地方全都有 TTL:
java
// CacheClient 里的 set 方法
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
五、一个例外:逻辑过期方案
上一篇(缓存三件套)里提过"逻辑过期"方案------缓存不设 TTL,过期时间写在 value 里。那个方案下的双写一致性要单独想。
因为 key 永不过期,删缓存是唯一的失效手段。所以数据更新时,必须同时删掉缓存,否则旧数据会一直返回(直到有人异步重建)。
这就是"高可用"的代价:逻辑过期换来了不阻塞,但也把一致性的责任全压在了"删除"这一个动作上。
| 方案 | 一致性 | 可用性 | 谁负责失效 |
|---|---|---|---|
| 互斥锁 + TTL | 强(下次读必是新值) | 会阻塞 | TTL 自动 + 主动删除 |
| 逻辑过期 | 弱(可能返回旧值) | 不阻塞 | 只能主动删除 |
六、面试怎么答(可直接背)
问:缓存和数据库怎么保证一致性?
"用的是 Cache Aside 模式:先更新数据库,再删除缓存。
三个关键选择:
第一,删缓存而不是改缓存 ------改缓存有两个问题:并发更新时后写的可能覆盖先写的、写进去的值可能已经过期;删除是幂等的,而且下次查询必然从库里读最新值。
第二,先改库再删缓存 ------反过来(先删缓存再改库)有个明确的不一致窗口:另一个线程可能在缓存删除后、数据库更新前查库并回写旧值。先改库的话窗口小得多。
第三,缓存一定要设 TTL 兜底------时序分析再严密,也可能有遗漏的写入口,TTL 是最后一道保险。"
如果被追问"先改库再删缓存就 100% 一致吗":
"不是。理论上仍有一种情况:线程 A 查库拿到旧值后停住,线程 B 改库并删缓存,A 再写缓存把旧值写回去。但触发条件是'A 写缓存在 B 删缓存之后',而写缓存是纯内存操作只有几毫秒,概率极低。
要更强可以加延迟双删------改库、删缓存、延迟 500ms 再删一次,把窗口里可能写回的旧值干掉。延迟时间要大于一次读请求从查库到写缓存的时间。"
如果被追问"为什么不加延迟双删":
"商铺信息不是资金数据,配合 TTL 兜底已经够用。多加一层就多一个后台任务要维护,也要考虑延迟多久合适。这是按数据重要性做的取舍,不是不知道有这方案。"
七、小结
- "删"比"改"好在幂等。删除不关心顺序,改就必须关心"谁最后写"。
- 顺序比方案更重要。同样是 Cache Aside,先删和后删的窗口大小差一个数量级。
- TTL 是不能省的保险。所有一致性分析都建立在"写入口已知"的假设上,TTL 防的就是这个假设不成立。
我是正在准备 Java 后端秋招的应届生,把项目里踩过的坑按"改坏---定位---修好"的过程记下来。如果这篇对你有用,欢迎关注,后面会持续更。