双写一致性:为什么是“删缓存“,而不是“改缓存“

缓存和数据库两个地方都存了同一份数据,一旦更新,就得保证两边一致。

我一开始的直觉是"改完数据库,顺手把缓存也改一下"------这个直觉是错的。

一、先想清楚:缓存到底该怎么更新

缓存更新有三种常见策略,先摆出来对比:

策略 做法 一致性 实时性 维护成本
内存淘汰 不管,等 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();
}

为什么"删"比"改"好?三个理由:

  1. 删除是幂等的。删一次、删十次结果一样;改就可能因为顺序问题留下错误的值。
  2. 删了之后,下次查询一定从库里读最新值。缓存重建时拿到的是当时数据库的真值,天然不会过期。
  3. 不用关心缓存里存的是什么结构 。只要删掉,重建逻辑复用了已有的查询路径(也就是上一篇写的 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 兜底已经够用。多加一层就多一个后台任务要维护,也要考虑延迟多久合适。这是按数据重要性做的取舍,不是不知道有这方案。"

七、小结

  1. "删"比"改"好在幂等。删除不关心顺序,改就必须关心"谁最后写"。
  2. 顺序比方案更重要。同样是 Cache Aside,先删和后删的窗口大小差一个数量级。
  3. TTL 是不能省的保险。所有一致性分析都建立在"写入口已知"的假设上,TTL 防的就是这个假设不成立。

我是正在准备 Java 后端秋招的应届生,把项目里踩过的坑按"改坏---定位---修好"的过程记下来。如果这篇对你有用,欢迎关注,后面会持续更。

相关推荐
小蒜学长1 小时前
基于SpringBoot+Vue的云南地区扶贫助农系统的设计与实现(代码+数据库+LW)
java·vue.js·spring boot·后端·乡村振兴
砚底藏山河1 小时前
量化回测可复现流水线CI化
java·python·ci/cd·金融
骑着蜗牛撵大象3271 小时前
Spring Boot 4.1 + Spring AI 2.0:RAG 供文、图谱供关系、MCP 供工具的 Java Agent 平台
java·spring boot·spring·微服务·知识图谱·rag·mcp
衍生星球1 小时前
【Java 语言】— 面向对象:构造方法(无参、有参构造)
java·开发语言
SimonKing1 小时前
素笺( local-notes)全面优化,新增功能体验不一样的笔记
java·后端·程序员
techpupil1 小时前
IDEA基础配置及开发基础插件
java·ide·intellij-idea
yuniko-n1 小时前
【JVM】对象内存布局
java·开发语言·jvm
snow@li1 小时前
redis:RedisDesktopManager(RDM/RESP.app)Redis 数据可视化全景深度梳理
数据库·redis·缓存
CoderYanger2 小时前
Java EE 进阶:6.SpringBoot 配置文件(第二弹)
java·spring boot·spring·面试·职场和发展·java-ee·程序员创富