【9】面试官:讲一下MySQL 和 Redis 的缓存一致性问题

后台运营把某个热门商品的库存从 12 改成 5,管理后台弹出"修改成功"。可不到一秒,用户刷新商品详情页,页面上还是 12

这类现象一出来,团队里最常见的两种回答很快就会冒出来。

  • 一种说法是:Redis 只是缓存,过期了就好了。
  • 另一种说法是:更新数据库后把缓存删掉,不就一致了。

这两句话都不算错,但都不够。因为真正难的不是"缓存会不会有旧值"这句空话,而是下面这几个更具体的问题:

  1. 旧值到底会在什么时刻冒出来。
  2. 为什么很多文章都推荐"先更新 MySQL,再删除 Redis"。
  3. 为什么明明删了缓存,旧值还是可能被并发请求重新写回去。
  4. 延迟双删、TTLCDC 这些词,到底分别在补什么洞。
  5. 如果业务真的不接受脏读,究竟该继续优化缓存,还是缩小缓存在这条链路里的职责。

这篇文章就只围绕这条主线往下讲,不展开成缓存全家桶,也不把 CanalDebeziumFlink CDC 写成部署手册。

1. 先把问题说准:这里的一致性,到底在说什么

MySQLRedis 组合里,最容易先犯的错,就是把两个东西写成两个平级的数据源。

它们不是平级关系。

  • MySQL 是事实来源。
  • Redis 是副本,是为了把热点数据放得更近,读得更快。

只要这个角色关系没说清,后面很多争论都会跑偏。因为缓存一致性讨论的重点,从来不是"缓存里会不会有旧值"。缓存本来就可能有旧值。重点是旧值会存在多久、会影响哪些请求、业务能不能接受。

这件事通常至少要分成三层来看:

目标 含义 常见要求
强一致 任意时刻读到的都像只有一个真相源 余额、配额结算、最终扣减库存
读后写一致 自己刚写完,再读时希望看到新值 用户资料修改、配置开关、生效确认
最终一致 允许短时间旧值,但最终会收敛 商品详情、推荐结果、非关键统计

很多系统嘴上说"要缓存一致性",真正想要的其实不是第一层,而是后两层。因为一旦把"强一致"当成硬目标,缓存就会立刻变得很贵,甚至不再适合放在关键读路径上。

Redis 不是第二个真相源,它只是把 MySQL 的结果搬近了一点。

所以,这篇文章后面说的"解决策略",本质上都是在回答一件事:怎样把不一致窗口压到业务能接受的范围里。

2. 为什么默认基线是"先更新 MySQL,再删除 Redis"

先看最常见的一套读写流程。这里说的"旁路缓存",指的是应用自己决定何时读缓存、何时读库、何时删除旧缓存,而不是把这些动作完全交给缓存中间件自动处理。

这张图对应的是旁路缓存的基线做法:

读路径很简单:先查缓存,没命中再查库,然后把结果回填到缓存。

写路径也很简单:先更新数据库,成功后删除缓存。

用伪代码写出来,大概就是这样:

java 复制代码
public Product getProduct(Long productId) {
    String key = "product:" + productId;
    Product cached = redisClient.get(key);
    if (cached != null) {
        return cached;
    }

    Product product = productRepository.findById(productId);
    if (product != null) {
        redisClient.set(key, product, 300);
    }
    return product;
}

public void updateProductStock(Long productId, Integer newStock) {
    productRepository.updateStock(productId, newStock);
    redisClient.delete("product:" + productId);
}

如果把这两条链路再压缩成最容易记的一版,其实就是下面这两句:

  • 查询时缓存没命中 :查 MySQL,把结果回填到 Redis,再返回给调用方。
  • 修改时数据发生变化 :先更新 MySQL,成功后删除 Redis 里的旧 key。

也可以把它们理解成两种完全不同的职责:

  • 查询链路负责把热点数据搬进缓存
  • 修改链路负责把旧缓存作废

这里最值得注意的一点是,查询链路做的是"回填",修改链路做的是"失效",不是"顺手把缓存也改掉"。

很多人第一次看到这里,会有个本能追问:为什么不是更新完数据库,再顺手把缓存也更新成新值?

表面上看,更新缓存好像更直接。但缓存一致性里真正更常见的做法,恰恰不是"改缓存",而是"删缓存"。因为大多数系统想要的不是"每次写完都立即重建所有副本",而是"先把旧副本作废,等下一次真实读请求再按新数据重建"。

换句话说,这里不是"数据库和缓存一起改,谁更顺手"的问题,而是"写请求到底该承担到哪一步"的问题。

真正落到工程里,删除通常更稳,原因主要有三层。

2.1. 第一层:删除比更新更容易做到幂等

删缓存这件事,失败重试的语义很简单。再删一次,结果还是"没有这条 key"。

更新缓存不是这样。缓存里放的值不一定就是数据库一行原样拷贝,它可能是:

  • 多张表 join 之后的结果。
  • 经过字段裁剪和格式转换的结果。
  • 一个详情页对象外加多个统计字段的聚合结果。
  • 单条记录、列表页、分页页、搜索结果这几类 key 的组合。

一旦改成"更新缓存",写请求就得知道自己影响了哪些 key,以及每个 key 的新值该长什么样。这件事会迅速从"更新库存"变成"重建一堆视图"。

2.2. 第二层:删除把"重建新值"的动作推迟到了真正需要读的时候

不是每次写完都值得立刻重建缓存。很多数据写了之后,短时间内未必有人读。

删除缓存的意思是:先把旧副本干掉。至于新副本什么时候构建,交给下一次真正的读请求。

这就是旁路缓存常见的风格。写请求只负责维护事实来源和清掉旧副本,不额外承担"马上把所有缓存视图都修好"的责任。

2.3. 第三层:把事实来源放在前面,错误面更小

只要数据库还是最终真相源,写请求就不该先把缓存写成新值,再去赌数据库一定成功。

下面这几种顺序,问题都更明显:

这张图对应的是几种常见写序的总对照:

顺序 主要问题
先更新缓存,再写数据库 数据库失败时,缓存先变成假新值
先删缓存,再写数据库 在数据库提交完成前,别的请求可能把旧值重新回填
先写数据库,再更新缓存 要维护复杂缓存视图,多 key 场景容易漏、容易乱
先写数据库,再删缓存 不是完美,但错误面最小,通常作为默认基线

很多文章把这件事总结成一句话:"更新数据库后删除缓存。"这句话本身没问题,但它只是基线,不是终点。因为下面这个竞态窗口,才是缓存一致性真正让人头疼的地方。

3. 为什么删了缓存,旧值还是会回来

这件事不靠口号,靠时序才能看清。

这张图对应的是最典型的旧值回填竞态:

这里最关键的一步,是线程 A 的读请求在更早的时候就已经去数据库查了,只是它查到的还是旧值。线程 B 在这段时间里完成了写库和删缓存。按直觉看,缓存已经被清掉了,系统应该变干净了。

但线程 A 还没结束。它手里拿着旧值,最后一步还是会把 12 写回 Redis

这就是很多人第一次遇到时会觉得别扭的地方:删缓存并不等于从此以后不会再出现旧值。删缓存只能保证"旧副本被清掉",不能保证"某个更早开始的读请求不会把旧值重新带回来"。

这个窗口通常不大,也不是每次都会撞上,所以"写库后删缓存"在大多数读多写少场景里依然够用。但只要业务对旧值更敏感,光靠这一步就不够了。

4. 先删缓存再写数据库,为什么反而更危险

很多人会想:既然问题出在"写库后删缓存"也有窗口,那是不是把顺序改成"先删缓存,再写数据库"更干净?

看起来像是在提前清场,实际更危险。

这张图对应的是这个顺序的风险:

这里的问题更直接:缓存刚删完,数据库还没改好。只要这时插进来一个读请求,它查到的一定还是旧值,而且还会名正言顺地把旧值重新写回缓存。

和前面那个竞态相比,这个窗口更大,也更容易撞上。因为它把"缓存为空"这段时间主动提前了,而这段时间里的数据库仍然是旧值。

所以,"先删缓存再写数据库"不是更稳,而是把危险窗口放在了更显眼的位置。

5. 这些补强手段,分别在补什么洞

到这里,主线已经很清楚了。

  • 基线方案是"先写库,再删缓存"。
  • 这个方案不是零风险,只是把错误面压到了最小。
  • 真正要做工程优化,重点不是去找银弹,而是分清每个补丁到底在补什么洞。

5.1. TTL 是兜底,不是主解法

缓存加过期时间当然有用,因为它至少能限制旧值最多活多久。

但它解决不了根问题。原因很简单:TTL 只能回答"脏数据多久后会死",回答不了"脏数据为什么会出现"。

如果系统本来就有旧值回填窗口,TTL=5minTTL=30s 只是把后果拉长或缩短,不会让这个窗口消失。

所以 TTL 的定位更像兜底手段:

  • 防止漏删缓存后旧值长期悬挂。
  • 控制缓存自然淘汰节奏。
  • 和随机过期一起分散热点 key 集中过期。

它很重要,但不能拿来替代失效策略本身。

5.2. 延迟双删补的是"旧值回填尾巴"

延迟双删的思路是:写库成功后先删一次缓存,过一个短延迟,再删一次。

这张图对应的是它在补哪个窗口:

它想补的就是前面那种情况:某个更早开始的读请求,晚一点把旧值又塞回缓存里。第二次删除把这条"尾巴"再扫一遍。

这个办法在工程上是有价值的,但边界也要说清:

  • 它补的是特定竞态,不是把系统变成强一致。
  • 第二次删除必须能重试,否则异步任务丢了,这个补丁就断了。
  • 延迟多久没有通用标准,通常要结合业务读写时延和数据库访问耗时来定。
  • 如果业务根本无法接受这段窗口里的旧值,延迟双删也不够。

5.3. 互斥回源 / 单飞,补的是失效瞬间的大量并发回填

有些 key 很热,一旦失效,可能一群请求一起 miss,然后一起去数据库查,再一起回填缓存。

这时候真正的问题不只是数据库被打爆,还可能出现多个并发回填把旧值和新值来回覆盖。常见的办法是让同一个 key 在同一时刻只允许一个请求负责回源,其他请求等待结果或者短暂重试缓存。

它补的不是写序,而是缓存失效瞬间的并发回填秩序

5.4. 版本号 / 时间戳,补的是"谁有资格写回缓存"

如果旧值回填特别敏感,还可以给缓存值带上版本号或更新时间。

思路很直接:

  • 读请求从数据库查到数据时,顺手带上版本。
  • 回填缓存时,只有当自己的版本不落后,才允许写入。

这类做法会复杂一些,但它回答的是一个很关键的问题:不是所有查完库的请求,都应该有资格把结果写回缓存。

5.5. 读后写一致要求更高时,可以短时绕开缓存

有些场景不是全局都要求强一致,只是"刚改完的人,下一次最好别看到旧值"。

比如用户改昵称、改地址、改配置开关之后,自己立刻刷新页面。这个时候可以做短时绕过缓存:

  • 更新成功后,把某个用户或某个 key 标记为短时间强制读库。
  • 或者在响应里带一个最新版本,让随后的读请求按版本校验。

这不是全局强一致,但它能把"自己刚改完却还看到旧值"的体验问题单独收掉。

6. 当写入路径不止一个服务时,为什么要让 CDC 接手

如果整个系统只有一个应用写数据库,而且所有写操作都老老实实从这一层经过,那么"写库后删缓存"虽然不完美,至少闭环还在同一个服务里。

真正麻烦的,是写入路径开始分叉。这里的 CDC 全称是 Change Data Capture,中文通常翻成"变更数据捕获"。它的意思是把数据库里的新增、修改、删除变更转成一条条可消费的事件,再由下游去做同步、失效或派生处理。

  • 管理后台也能改这张表。
  • 运营脚本会直连数据库修数据。
  • 另一个服务会更新同一份业务对象。
  • 订正任务、批处理任务会在半夜批量改库。

这时候,缓存失效逻辑继续写在当前服务里,就天然覆盖不全。因为总会有一些数据库变更根本没经过这段删缓存代码。

这张图对应的是更稳的一种做法:让数据库变更自己触发失效通知。

这里的关键变化,不是"删缓存更快了",而是删缓存这件事不再依赖某个业务服务记得手写这段逻辑。

只要数据库发生了变更,不管变更来自哪个入口,都能沿着统一的变更传播链路去失效缓存。

这也是 binlog / CDC 真正有价值的地方:

  • 它覆盖跨服务写入。
  • 它覆盖后台脚本或直连 SQL。
  • 它把业务写逻辑和缓存失效逻辑解耦。

当然,边界也要说清:

  • CDC 不是零延迟,仍然存在传播时间。
  • 它解决的是"失效通知覆盖不全",不是直接把系统变成强一致。
  • 如果缓存 key 很复杂,最终还是要解决"根据哪条数据库变更删除哪些 key"的映射问题。

所以 CDC 更像是把"谁来发失效通知"这件事做统一,而不是把所有缓存问题一次解决掉。

7. 到底该怎么选:别再追一个万能答案

讲到这里,其实已经能看出一个很现实的结论:缓存一致性没有万能答案,只有按业务约束选一个最合适的错误面。

下面这个表,更适合拿来做工程判断:

场景 典型特点 推荐基线 常见补强
商品详情、配置页、用户资料展示 读多写少,允许极短时间旧值 写库后删缓存 TTL、必要时延迟双删
热点 key,高并发回源明显 缓存一失效就很多请求一起查库 写库后删缓存 互斥回源、单飞、随机过期
多服务写入、后台脚本改库 删除缓存逻辑无法保证都经过同一服务 数据库变更驱动失效 binlog / CDCMQ
聚合缓存、列表缓存 一个写操作会影响多个 key 写库后删缓存 标签失效、版本号、异步重建
余额、最终扣减库存、硬约束配额 不能把旧值当成可接受代价 关键读绕开缓存 数据库事务、锁、限流,把缓存降级成加速层

这个表里最容易被忽略的一行,反而是最后一行。

很多人遇到强一致诉求时,第一反应还是继续给缓存打补丁。但对余额、结算、最终扣减库存这类场景,真正该先问的往往不是"缓存怎么做得更准",而是:这次读,是否还应该经过缓存。

如果这次读本来就承担最终裁决职责,那缓存更适合做旁路加速,而不是做判定依据。

8. 最后收一下:缓存一致性真正要解决的,不是口号

把整件事压成几条判断,其实就够了。

  • MySQL 是事实来源,Redis 是副本,这个关系决定了写请求为什么通常先写库,再删缓存。
  • "写库后删缓存"能成为默认答案,不是因为它完美,而是因为它把错误面压到了最小。
  • 旧值回填是缓存一致性的核心竞态,延迟双删、互斥回源、版本号这些补强,都是围着这个窗口在做文章。
  • TTL 只能兜底,不能代替失效策略本身。
  • CDC 的意义,不是把系统升级成强一致,而是把数据库变更统一转成失效通知,覆盖跨服务和直连库写入。
  • 如果业务真的不能接受脏读,优先缩小缓存的职责,而不是继续指望缓存承担最终真相。

说到底,缓存一致性讨论的重点,从来不是"系统里有没有旧值",而是旧值会出现在哪里、会持续多久、业务愿不愿意为更强的一致性继续付成本。

相关推荐
雨辰AI2 小时前
人大金仓 V9 生产级专用监控大盘(含 120 + 指标 + 告警规则 + 一键导入)
java·开发语言·数据库·mysql·政务
还是鼠鼠2 小时前
AI掘金头条新闻系统 (Toutiao News)-用户注册-创建用户
后端·python·mysql·fastapi·web
我是一颗柠檬3 小时前
【MySQL全面教学】MySQL条件查询与排序Day4(2026年)
数据库·后端·mysql
2401_878820473 小时前
Redis+Lua脚本实现全局令牌桶限流
数据库·redis·lua
身如柳絮随风扬3 小时前
Redis 主从复制与哨兵机制详解:从原理到高可用实战
数据库·redis·缓存
YL200404264 小时前
MySQL-运维篇-主从复制
运维·数据库·mysql
Wzx1980124 小时前
MySQL什么时候索引失效反而提升效率?
数据库·mysql
AI人工智能+电脑小能手4 小时前
【大白话说Java面试题 第71题】【Mysql篇】第1题:索引是什么?
java·开发语言·b树·mysql·面试
sukioe4 小时前
Linux RPM 方式安装 MySQL 8.0
linux·mysql·adb