1. 问题是怎么发生的:先看懂缓存不一致的根源
后端开发做到一定阶段,几乎都会撞上这个坑——我们一边依赖 Redis 的高速读写扛住高并发,一边又必须接受一个事实:多级存储之间永远存在一致性的风险。最近帮一位电商平台的后端负责人排查线上问题,现象很典型:用户改完收货地址,页面刷新还是旧地址,过了十几秒才恢复正常。查了一圈,MySQL 里数据其实已经更新了,但 Redis 里的缓存 key 一直没被清掉,用户读到的全是旧数据。
这个问题之所以反复出现,根源在于应用层把 Redis 当成了 MySQL 前面的“加速层”,但这两套存储系统各自独立,事务控制、数据复制、故障恢复机制都不一样,不存在原生的强一致约束。要解决它,不能靠运气,得从读写流程和并发时序上把每个窗口期都掰开看。
1.1 正常读写流程里藏着哪些更新窗口
先走一遍最常规的缓存读写路径。读请求来了,应用先查 Redis,如果命中就直接返回,这一步的性能是毫秒级的;如果没有命中,再去查 MySQL,查到数据后回填到 Redis,同时设置一个过期时间,最后把结果返回给前端。这套“旁路缓存”方案在多数业务里都被验证过是高效的。
写请求的路径就要小心了。业务上修改了数据库记录,那么 Redis 里的旧值如何处理?常见的做法有两种,一种是直接更新 Redis 里的值,另一种是删除 Redis 里对应的 key,等下一次读请求未命中时再回填新值。两种做法看起来都是“更新缓存”,但后果差别很大,后面我会专门展开。
问题的核心在于,读写请求是并发的,数据库更新和缓存更新之间会有一段天然的时间差。假设商品库存数据库值是 100,线程 A 把库存改成 80,线程 B 把库存改成 60,两个线程都在修改数据库和缓存。如果线程 A 先更新数据库,然后线程 B 再更新数据库,但线程 B 比线程 A 先写缓存,最后缓存里是 80(线程 A 的值),数据库里是 60(线程 B 的值)。用户查到的库存和真实库存就对不上了。
这里每个人都能看到,只要数据库更新和缓存操作不是原子的,就会有不一致的窗口。问题的关键不是要不要用缓存,而是怎么设计缓存更新策略,把不一致的概率压到业务可接受的范围内。
1.2 为什么“先更新数据库再更新缓存”容易翻车
很多刚接触缓存的同学会想当然:写请求来了,把数据库更新了,顺手把 Redis 也更新了,数据不就一致了吗?实际上一旦并发上来,这种做法最容易出乱子。
我举个例子。商品价格字段初始是 100 元。请求 A 要把价格改成 80 元,请求 B 要把价格改成 60 元。
- 线程 A 执行 update MySQL set price=80 where goods_id=1,数据库变成 80。
- 线程 B 执行 update MySQL set price=60 where goods_id=1,数据库变成 60。
- 线程 B 接着执行 Redis set goods_price_1 60,缓存变成 60。
- 线程 A 接着执行 Redis set goods_price_1 80,缓存变成 80。
最终数据库里是 60,缓存里是 80,用户看到的商品价格比真实价格贵了 20 元。这类错乱在并发稍有规模时非常容易复现,而且很难排查,因为代码逻辑本身是“先更新库再更新缓存”,方向上没错,但没考虑并发时序。
有人可能会说,那把顺序反一反,先更新缓存再更新数据库呢?问题更严重。缓存更新成功、数据库更新失败的话,缓存里就是永远无法落盘的数据,一旦 Redis 重启或者缓存过期,数据直接丢失,业务上完全不可接受。数据库作为唯一事实来源,必须在事务中保证最终数据可靠,缓存只能作为它的投影存在。
1.3 Cache Aside 模式为什么是多数团队的基础选择
业界最常用的缓存策略是 Cache Aside,也叫旁路缓存。它的核心逻辑是:读请求未命中缓存时由应用回填,写请求先更新数据库,再删除缓存,而不是更新缓存。
“删除缓存”而不是“更新缓存”,这个细节很多新手不理解。我来解释一下。更新缓存是直接把新值写进去,一旦后续有其他线程把旧值写回,新值就被覆盖了;删除缓存则是把旧值彻底清掉,即使某个读线程临时回填了旧值,下次读请求也会因为缓存中仍然存在旧值而拿到过期数据,最终只能等过期时间兜底或者下一次更新触发删除。
理论上 Cache Aside 也做不到绝对强一致。经典的竞态场景是:读线程查 MySQL 拿到旧值,还没回填 Redis,这时候写线程更新了数据库并删除了缓存,接下来读线程把旧值写回 Redis,缓存里就长期存着旧数据。解决这个窗口期,就要用到后面讲的延迟双删、版本控制等手段。但先别急着上复杂方案,把 Cache Aside 的“先更新库再删缓存”这条路走扎实,配合合理过期时间,已经能覆盖绝大多数业务了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主流缓存更新策略的对比与选型
服务端缓存的策略不止 Cache Aside 一种,很多技术方案讨论里还会提到 Read Through、Write Through、Write Behind,以及实际中改造成本比较低的“延迟双删”。这几种策略的思路完全不同,适合的业务场景也差异很大。下面这张表能帮你快速建立全局判断。
| 策略 | 写路径 | 读路径 | 一致性强度 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| Cache Aside | 更新数据库后删除缓存 | 未命中时应用回填 | 最终一致,存在窗口 | 低 | 绝大多数互联网业务 |
| Read Through | 更新数据库由缓存组件同步处理 | 缓存组件负责回填 | 取决于实现 | 中 | 对封装要求高的团队 |
| Write Through | 先写缓存,缓存组件同步写库 | 缓存组件负责回填 | 强一致,写延迟高 | 高 | 对一致性要求严苛的模块 |
| Write Behind | 先写缓存,异步批量写库 | 缓存组件负责回填 | 弱一致,存在丢数据风险 | 高 | 写多读少、容忍丢失的场景 |
| 延迟双删 | 更新数据库,删缓存,延时后再删一次 | 未命中时应用回填 | 最终一致,窗口大幅缩小 | 中 | 并发读写都高的业务 |
2.1 Read Through 与 Write Through 的优点和代价
Read Through 和 Write Through 的核心思路,是把缓存的读写操作封装到缓存组件内部,业务代码不再直接操作 Redis。缓存组件自己负责查库、回填、失效,业务方只需要调用统一接口。这种封装对团队协作有好处,避免每个人写出风格迥异的缓存操作代码,也让缓存策略的变更范围缩小到组件内部。
代价是缓存组件要处理的工作变多了。Write Through 为了保证强一致,每次写操作都要等缓存和数据库都提交成功才算完成,写路径的延迟会明显上升。在“读多写少”的互联网业务里,如果用 Write Through 扛所有写请求,数据库压力没有减少,反而增加了缓存和数据库之间的同步等待,整体吞吐量可能还不如 Cache Aside。
Write Behind 则往另一个极端走,缓存先写入,数据库异步批量落盘。这样写请求的响应速度非常快,但代价是如果缓存服务在批量落盘前宕机,这部分数据就会丢失。对于支付、订单、账户这类不允许丢数据的业务,Write Behind 基本不能用;用在浏览记录、行为日志这类可容忍少量丢失的场景里,倒是能发挥很高的性能优势。实际做技术选型时,先问自己三个问题:数据丢了能不能接受?写延迟要求有多高?团队有没有能力维护复杂的缓存组件?回答完这三个问题,方案基本就清晰了。
2.2 延迟双删:最接地气的“土办法”
如果直接在 Cache Aside 的基础上改进,延迟双删可能是投入产出比最高的一种方案。它的思路很简单:第一步更新数据库,第二步删除缓存,第三步等待一小段时间,第四步再次删除缓存。
为什么第二步删除之后还要再删一次?回到前面说的竞态场景:读线程查到旧值后准备回填,写线程更新数据库并删除了缓存,然后读线程把旧值写回 Redis。如果不做第二次删除,这个旧值会一直留在缓存里,直到过期时间到达。延迟双删就是在“读线程大概率已经完成回填”的时间点,再删一次缓存,把旧值清掉。这个延迟时间要大于一次完整的读请求回填耗时,一般经验值是 500 毫秒到 1 秒,具体要结合业务接口的响应时间来估算。
下面是一段 Python 风格的伪代码,展示了延迟双删的核心逻辑。
python复制import time
import threading
def update_with_delay_double_delete(db, redis_client, key, new_value):
# 第一步:更新数据库
db.execute("UPDATE goods SET stock = %s WHERE id = %s", (new_value, goods_id))
# 第二步:立即删除缓存
redis_client.delete(key)
# 第三步:延迟一段时间后再次删除缓存
def delayed_delete():
time.sleep(0.5)
redis_client.delete(key)
threading.Thread(target=delayed_delete, daemon=True).start()
实际工程中,我不建议直接在业务进程里开线程做延迟删除,因为业务实例可能会重启,线程任务会丢失。更稳妥的做法是把删除操作封装成一条消息发给消息队列,由一个消费服务延迟消费,再执行 Redis 删除。这样即使业务服务重启,消费服务照样能把第二次删除执行掉,可靠性高很多。
2.3 延迟双删的局限:问题变小了,但没有消失
延迟双删虽然能显著压缩不一致窗口,但它并不能根治问题,主要有三个弱点。
第一个弱点是删除操作本身可能失败。Redis 连接超时、服务器网络抖动、key 已经被其他线程删掉后又被回填,这些情况都会让第二次删除失去意义。要做到尽量可靠,就必须给删除操作加上重试机制,比如用消息队列表单处理重试,或者写一个定时任务扫描那些“应该删但没删掉”的缓存 key。
第二个弱点是延迟时间无法精确控制。项目里 500 毫秒的经验值,在数据库慢查询、网络抖动、接口响应变慢的情况下,可能不够用。如果读线程回填耗时超过延迟时间,第二次删除照样删不掉刚回填的旧值。想彻底规避,就得把延迟时间设置得非常保守,但这样又会让旧数据在缓存里存活更久,牺牲实时性。
第三个弱点是多级缓存场景下复杂度翻倍。如果业务用了本地缓存加 Redis 两级缓存,延迟双删就不好使了。因为本地缓存存在于每个业务实例的内存里,你删了 Redis,但所有实例的本地缓存还留着旧值。针对这种情况,通常需要引入消息广播机制,让每个实例都收到失效通知,复杂度一下子就上去了。
3. 从根本解决:binlog 订阅方案
延迟双删是在业务代码层面打补丁,真正要从源头上解决一致性问题,思路要换一下:不要把缓存管理逻辑散落在业务代码里,而是让 MySQL 自己告诉我们“哪条数据变了”,由独立的消费端负责同步到 Redis。
这个方案的典型实现就是订阅 MySQL 的 binlog。MySQL 的 binlog 记录了所有数据变更事件,业务上称之为“二进制日志”,主从复制就依赖它。我们完全可以借用这套机制,把 binlog 当作一个数据变更事件流,通过中间件解析后投递给缓存同步服务。市面上最流行的中间件是 Canal,阿里巴巴开源的,专门用来解析 binlog 并模拟成主从协议里的从节点。
3.1 架构思路:业务代码只管数据库,缓存交给事件流
落地这套方案,需要先把整体架构理清楚。业务服务更新 MySQL 后,不再直接去操作 Redis,Redis 的删除或更新动作全部由异步任务完成。这样做最明显的好处是业务代码变得干净,数据库事务和缓存操作天然解耦,不会因为 Redis 超时阻塞主流程的写请求。
一个完整的链路是这样的:
- 业务应用执行 update/delete/insert 语句操作 MySQL。
- MySQL 将变更记录写入 binlog。
- Canal 伪装成 MySQL 从节点,拉取 binlog 并解析成结构化事件。
- Canal 将事件投递到消息队列,比如 Kafka 或 RocketMQ。
- 缓存同步服务消费事件,根据事件里的表名、主键和操作类型,构造对应的 Redis key,执行删除或更新。
- 如果同步失败,消费服务根据重试机制再次执行,直到成功。
这套架构里,MySQL 是唯一的数据写入入口,Redis 的变更严格依赖 binlog 事件的顺序,不会出现多个业务线程各写各的、最终互相覆盖的情况。数据库和缓存之间的同步是单向异步的,最终一致性的保障能力比延迟双删强得多。
3.2 落地步骤:从开启 binlog 到消费服务上线
先说第一步,MySQL 必须开启 binlog,并且把格式设置为 ROW。ROW 格式会记录每行数据变更前后的完整镜像,解析事件时能拿到具体字段值,这对我们构造缓存删除操作非常关键。在 MySQL 配置文件里加上以下几行,然后重启服务。
ini复制[mysqld]
log-bin=mysql-bin
binlog-format=ROW
server-id=1
注意 binlog 格式一定要是 ROW,STATEMENT 格式只记录 SQL 语句,无法准确定位哪些行数据被修改了。接下来为 Canal 创建专用的 MySQL 账号,这个账号需要有复制权限。
sql复制CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
然后下载 Canal 服务端,修改配置文件,核心是告诉 Canal 连接哪个 MySQL 实例、监听哪个库表。Canal 的 instance.properties 大致配置如下。
properties复制canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal_password
canal.instance.filter.regex=mall.goods,mall.order
filter.regex 的格式是“库名.表名”,多个表用逗号分隔。这里建议只订阅真正需要同步缓存的表,减少无效事件对消息队列的压力。Canal 启动后,会像从节点一样持续接收 binlog 变更,然后把解析后的事件输出到 MQ 或者本地 TCP 端口。
最后是消费服务。消费服务从消息队列拉取事件后,判断操作类型,如果是 UPDATE 或 DELETE,就删除对应 Redis key;如果是 INSERT,可以直接把数据写入 Redis,也可以不处理,取决于业务上是否需要预置缓存。有一点必须要做:消费逻辑要支持幂等。因为 MQ 的重试机制可能让同一条事件被消费两次,删除一个不存在的 key 本身是幂等的,但如果是更新缓存,就要保证重复执行不会产生副作用。
3.3 为什么说这套方案更接近根治
binlog 订阅方案解决了一个很关键的问题:事件顺序是数据库层面的真实顺序,而不是应用线程之间的执行顺序。多个线程并发写数据库,最终只有一份 binlog 记录,顺序唯一确定,消费端严格按这个顺序同步缓存,就不会出现前面例子中“数据库是 60、缓存是 80”的乱象。
另外,binlog 方案的兜底能力很强。因为 Canal 消费是基于位点(position)的,如果消费服务宕机,重启后可以从上次记录的位置继续消费,中间漏掉的事件都能补回来。这就是持久化的消息日志带来的好处,业务代码不会丢事件。延迟双删就不行了,业务进程一重启,任务就没了。
这套方案也不是没有代价。整个链路引入了 Canal、消息队列、消费服务,部署和运维成本明显上升,对中小团队来说,前期的学习成本和后期的问题排查成本都不低。如果团队人少、系统简单,直接上 binlog 方案容易造成过度设计,反而不如老老实实把 Cache Aside 加过期时间做好。
4. 进阶策略:分布式锁、版本号与缓存治理
binlog 方案适合从架构层面解决问题,但有些团队因为历史包袱太重,没法快速引入消息队列和 Canal,就需要在应用代码层面用一些更精细的手段,把一致性窗口继续压缩。这一节讲几个实战中经常用到的进阶策略。
4.1 分布式锁能不能兜住并发读写窗口
一种思路是在“更新数据库”和“删除缓存”这两个动作的外面加一把分布式锁。也就是说,同一个 key 的写请求,必须获取到锁之后才能执行数据库更新和缓存删除,持有锁期间,读请求等待或者直接读旧值。这样一来,就不会出现读线程和写线程同时操作同一个 key 的竞争情况。
听起来很完美,但代价也很明显。这把锁把读操作也限制住了,如果在读多写少的场景下用,每次读都要先抢一次锁,Redis 的高并发优势就被削弱了。更麻烦的是锁粒度不好控制。按单条数据加锁,锁数量爆炸,管理成本高;按整表加锁,并发能力瞬间归零,业务基本没法玩。
实际经验是:分布式锁可以用于对一致性要求极高、写频率较低的模块,比如订单状态流转、账号余额变更。对于商品详情、文章内容这类读多写少但又允许短暂旧数据的场景,上锁属于自己给自己找麻烦。大多数时候,通过合理的缓存过期时间就能把旧数据的影响控制在可接受范围内。
4.2 版本号方案:给数据增加一个单调递增标记
另一种思路是给数据加版本号。MySQL 表结构里增加一个 version 字段,每次更新数据库时 version 加 1,Redis 缓存里也存一份 version。缓存同步时不直接删除旧 key,而是把新数据和新版本号一起写进去。读请求拿到缓存后,比对版本号和数据库当前的版本号,如果发现版本号偏旧,就认为缓存失效,重新从数据库加载。
这个方案的优点是不需要删除操作,每次都是覆盖写,不存在“删除失败导致旧值永久残留”的问题。同时也挺好地解决了并发问题:旧线程即使在写线程更新之后回填了缓存,也会因为版本号落后而被后续请求识别出来并修正。
不过版本号方案也不是银弹,它要求所有读取和写入缓存的代码都遵循同一套版本判定规则,一旦某处省略了版本校验,缓存脏数据就会漏过去。对于已有系统,改造范围可能比较大;但对于新系统,提前设计好版本字段的成本很低,我个人比较推荐在核心数据表里直接预留这个字段,给未来缓存治理留一条退路。
4.3 缓存治理的底线:合理过期时间与自动降级
聊了这么多方案,有一条最基础的底线往往被忽略:所有写入 Redis 的 key 都必须设置合理的过期时间。过期时间不是随便填的,它实际上是一个自动兜底机制,即使前面所有同步逻辑都出了问题,只要过期时间一到,旧数据也会被清掉,下次读请求就会从数据库重新拉取。
那么过期时间怎么设置才合理?核心原则是“业务能容忍多长时间的旧数据,过期时间就设置为多长”。比如商品详情页,用户对价格实时性要求没那么高,容忍 5 分钟旧数据,那 TTL 设置为 300 秒,既保证了缓存命中率,又能让数据最长 5 分钟后自动恢复一致。如果是用户登录态,旧数据可能导致鉴权失败,TTL 就要压缩到几十秒甚至更短。
实践中有个我很喜欢用的判断标准:把 TTL 和缓存更新策略的关系想成“安全网”。正常业务流程负责让数据尽快一致,过期时间负责兜底。哪怕你用了延迟双删,把第二次删除延迟设成 1 秒,但 TTL 只有 10 秒的时候,万一第二次删除失败,用户最坏也只会看到 10 秒旧数据,而不会无限期错下去。这种“双保险”的习惯,能让很多线上事故的影响范围控制在极小范围内。
5. 问题排查与面试高频考点整理
缓存和数据库不一致的问题,线上排查起来往往又急又费劲。这里把最常见的几种故障特征、可能原因和排查路径整理成一张速查表,遇到问题可以直接照着做。同时,这节内容对准备面试或者带新人也很有用,很多后端岗位的面试题都会围绕这个主题展开。
5.1 一张可以直接抄的排查速查表
| 故障特征 | 可能原因 | 定位方法 | 解决动作 |
|---|---|---|---|
| 数据修改后长时间不更新 | 缓存删除失败,且没有重试 | 查看 Redis key 的 TTL 是否未变,对比 MySQL 最新值 | 补上删除重试机制,或改用 binlog 订阅 |
| 数据偶尔对不上,过一段时间自动恢复 | 并发窗口导致的旧值回填 | 查看业务代码是否存在“查库后回填”的读路径 | 启用延迟双删,压缩窗口期 |
| 重启业务服务后缓存里出现旧数据 | 延迟双删任务在进程内执行,重启丢失 | 检查删除任务是否依赖内存线程 | 改为消息队列延迟消费 |
| 缓存总是返回该删没删的值 | 缓存 key 命名不统一 | 对比服务代码中写缓存和删缓存时的 key 拼接规则 | 统一 key 前缀和参数顺序 |
| 数据库已更新,Redis 一直显示旧值 | 使用“先更新库再更新缓存”且更新顺序乱 | 检查写请求的并发时序 | 切换为删除缓存模式,配合版本号 |
这张表背后有个很通用的排查方法论:先确认 MySQL 数据是否准确,再看 Redis 里的值是什么时候写入的,最后反推写入路径。Redis 里值对不对,直接决定下一步是查删除逻辑还是回填逻辑。
5.2 线上一旦出了事,先别急着改代码
我曾经踩过一个印象特别深的坑:商品库存在秒杀活动中出现错乱,我第一反应就去翻代码改缓存策略,结果问题越改越乱,最终发现根本原因是两台业务服务器的系统时间不一致,导致延迟双删的第二次删除时间戳出现了偏差。这个教训让我养成一个习惯:排查缓存不一致问题时,先花十分钟确认环境基础信息,包括服务器时间、Redis 版本、MySQL binlog 开关状态、消息队列消费位点,再动代码。
这里提供一个最简单的验证手法。发现缓存疑似不一致时,手动删除对应的 Redis key,观察数据库查询结果是否能正常返回新值。如果删完就正常,说明问题出在“删除缓存”环节;如果删完还是旧值,说明数据源头可能就有问题。通过这个三十秒的测试,能把排查范围缩小一半以上。
线上处理还要注意一点:不要大规模直接清空 Redis。清空缓存虽然能让数据短期恢复一致,但瞬间大量缓存未命中会对数据库产生非常大的压力,可能导致数据库连接数被打满,引发更严重的故障。正确做法是精准删除出问题的 key,小范围观察效果,确认无异常后再逐步扩大到其他可疑 key。
5.3 面试里这个主题是怎么被追问的
缓存与数据库一致性几乎是后端面试的必考题,很多候选人能答出“先更新数据库再删除缓存”这句话,但一到追问环节就露馅。我把面试官常见的追问整理一下,你可以拿来当自测题。
第一个追问是:既然删完缓存还有窗口期,你怎么处理?这时候提到延迟双删,基本能过关,但面试官会紧接着问:延迟时间怎么定?如果你只能说“设置 1 秒”,而没有说“延迟时间要大于一次完整读请求回填耗时,并且要考虑业务接口最慢响应时间”,面试官大概率不满意。
第二个追问是:第二次删除如果也失败了怎么办?这里就要答出消息队列重试机制,或者定时补偿任务。能提到“删除操作应该独立于业务主流程”的候选人,说明真有线上经验。
第三个追问是:如果让你设计一套方案,在极端并发下也不会出现旧值回填,你会怎么做?这个问题的标准答案就是 binlog 订阅方案,或者版本号校验。能把两种方案的优缺点对比说清楚,并给出业务场景下怎么选型,就是很漂亮的回答了。
第四个追问相对刁钻:为什么缓存里存旧值就不行?如果你的缓存里存的是商品名称、文章标题这类很少变动的数据,旧值完全不影响业务;如果存的是库存、价格、订单状态这类强时效数据,旧值会造成超卖或者金额显示错误。想明白这一点,就能理解一致性方案的严格程度应该跟数据本身的时效敏感度挂钩,不是所有数据都需要最高等级的一致性保障。
这个问题延展到系统设计层面,就是典型的 CAP 权衡。互联网高并发业务绝大多数不追求强一致,而是追求最终一致加上尽量小的不一致窗口。换句话说,目标不是“永远一致”,而是“在用户可感知的时间范围内快速恢复一致”。明白这个本质之后,再去看延迟双删、binlog 方案、版本号方案,都是在不同成本约束下对这个目标的逼近。
以我个人经验来说,真正决定方案成败的往往不是技术本身,而是对业务的理解。改一个用户头像缓存,延迟双删足够了;改一个支付状态,就算 C 端用户催得再急,我也宁可多花半天把 binlog 链路搭好。先明确业务能接受多大的不一致窗口,再回头选方案,才不会在技术选型里绕来绕去。这套思路,无论是写代码还是面试答辩,都很管用。
