先从一个我亲手处理过的线上事故说起。用户支付回调成功之后,刷新订单详情页,状态却还挂在"待支付"上。数据库里查了一圈,订单状态明明是已支付,问题就出在 Redis 里那份缓存订单状态还停留在旧值,而它的过期时间还有十分钟。这种"缓存和数据库对不上账"的问题,几乎是每个做后端的人迟早都要正面回答的:缓存与数据库之间的数据一致性,到底怎么保证?这篇文章不打算给出一套万能银弹,而是把这个问题的根因、业界主流方案、生产环境里容易踩的坑,以及我自己的选型经验,完整地讲一遍。
1. 先弄清楚:缓存和数据库的账,到底是怎么对不上的
1.1 两个存储的"性格差异"才是问题根源
MySQL 这类关系型数据库是业务的"真相",它有事务、有 ACID、有行锁,但它的吞吐和延迟扛不住所有的读请求。Redis 这类缓存是"加速器",它快,但它没有跨系统的分布式事务能力,数据随时可以被淘汰、可以被删除,也没有和 MySQL 之间的原子提交机制。
这两者放在一起,问题就来了:一次写操作,在数据库里和缓存里各执行一次,天然不是一个原子操作。你可以给 MySQL 一个事务,给 Redis 一条命令,但你没法用一个事务同时把两个系统包进去。除非引入分布式事务(2PC、TCC 这种),但那个复杂度,绝大多数业务根本扛不住,性能代价也极高。
打个比方:数据库是账本,缓存是写在白板上的提示数字。店员把账本改了,却忘了擦白板,或者白板擦了但账本没改成,客人看到的就是错的。问题不在于哪个系统出错,而在于两个系统之间没有一个可靠的同步机制。
1.2 三种典型的"错位"场景,你早晚会遇到
第一种:先更新缓存,再写数据库。这是新手最容易犯的错。缓存更新成功了,数据库写失败了,缓存里是新值,数据库里是旧值。只要缓存没过期,这个不一致就一直存在,而且方向是"缓存领先于数据库",比"缓存滞后"更难察觉。
第二种:先写数据库,再更新缓存(而不是删除缓存)。这种做法的隐患发生在并发写的时候。线程 A 把数据库更新成 value1,线程 B 把数据库更新成 value2(数据库最终值是 value2),但两个线程更新缓存的顺序反了,缓存最终落在 value1。更麻烦的是,如果后面没有别的写操作触发缓存更新,这个 value1 会一直留在缓存里,数据库和缓存就永久对不上了。
第三种:读请求把旧值回写了。这是最隐蔽的一种。线程 R 读缓存,发现 miss,于是去数据库查询,读到了一个旧值;此时线程 W 完成了数据库更新并删了缓存;随后线程 R 把自己读到的旧值 set 回缓存。缓存里从此挂着旧值,直到 TTL 到期。
这三种场景本质上是同一件事的两个面:写路径上的"更新/删除"次序问题,和读路径上的"回填"行为,共同构成了一致性缺口。
1.3 一致性不是一个档位:先想清楚你要的是哪种
在讨论方案之前,必须先统一认知:一致性不是一个开关,它分强弱。
- 强一致:任何一次读都能读到最近一次写的结果,读和写是线性化的。这在"缓存 + 数据库"这种双存储架构里几乎不可能低成本实现。
- 弱一致:读可能读到旧值,但不保证多久会收敛。
- 最终一致:经过一段时间,所有副本最终会收敛到一致的状态。这是缓存架构能提供的"最高性价比"级别。
- 写后读一致(read-your-writes):我提交了写操作之后,我自己后续的读必须能读到我写的数据。这在很多业务里比"绝对强一致"更实际。
很多团队一上来就要"强一致",结果方案越做越重,最后缓存形同虚设。我见过太多项目,真正的需求只是"最终一致 + 写后读一致",却硬上了一堆分布式锁。先把你要的一致性级别定下来,再谈方案,这才是正确顺序。
2. 主流方案拆解:为什么绕了一大圈,还是回了 Cache Aside
2.1 Cache Aside:为什么"写库后删缓存"比"写库后更缓存"安全
业界公认的基准方案叫 Cache Aside,也叫旁路缓存。它的读写路径很简单:
读:先读缓存,命中就直接返回;miss 才去数据库查,查完回填缓存,设置 TTL。 写:先更新数据库,然后删除缓存,而不是更新缓存。
public void write(String key, Object value) { // 第一步:更新数据库 db.update(key, value); // 第二步:删除缓存(不是更新) cache.delete(key); } public Object read(String key) { Object v = cache.get(key); if (v != null) { return v; } // 缓存 miss,查数据库并回填 v = db.query(key); cache.set(key, v, TTL); return v; }很多人会问:为什么是删缓存,而不是直接把新值写进缓存?原因有三个。
第一,删除是幂等的。不管删几次,结果都是"缓存里没有这个 key",而更新则要考虑并发顺序。两个并发写线程,更新缓存的顺序一旦和数据库的更新顺序错位,缓存就存了旧值;但如果都用删除,即使删两次,下一次读取也一定会从数据库拉最新值。
第二,删除符合"按需加载"原则。缓存 miss 之后重建数据,只需要在真正有人读的时候发生一次;而主动更新缓存,可能把很多"根本没人在读"的 key 也写一遍,纯属浪费。
第三,删除能规避"部分字段更新"的问题。很多业务场景一次只改一个字段,但缓存里存的是整个对象。你"更新缓存"时得先拼出完整对象,很容易把其他字段的旧值一起写进去;删除则完全不用关心这个。
Cache Aside 也有短板:读请求 miss 后回填旧值的那一小段窗口(也就是 1.2 里的第三种场景),它堵不住。但窗口极小,如果配合合理 TTL,大多数业务都能接受。
2.2 延迟双删:用一次"迟到的删除"堵住并发窗口
既然 Cache Aside 的残留问题出在"读线程把旧值回填到缓存",那最朴素的办法就是:在写操作完成之后,稍微等一会儿,再删一次缓存。这就是延迟双删。
时间线是这样的:
- 线程 R 读缓存 miss,到数据库读到旧值 value1。
- 线程 W 更新数据库为 value2,删除缓存。
- 线程 R 把旧值 value1 写回缓存。
如果 W 在删除缓存之后,再等 500 毫秒,重新删一次,那么步骤 3 里刚回填的 value1 就被清掉了。下次读请求再来,自然从数据库拉到 value2。
这里有个重要细节:同步 sleep 是犯傻。让请求线程阻塞 500 毫秒,延迟直接爆炸。正确的做法是把第二次删除放到异步延迟队列里,比如用 Redis 的 zset 做延迟队列,或者丢给 MQ。
def write(key, value): db.update(key, value) cache.delete(key) # 投递一个延迟任务,500ms 后再次删除 delay_queue.put("cache_delete", key, delay_ms=500) def delayed_worker(): while True: task = delay_queue.pop() if task: cache.delete(task.key)延迟双删的"延迟多久"是个关键参数。理论上要大于"从缓存 miss 到数据库读到旧值、再回填缓存"的完整耗时。实际经验里取 300~500 毫秒基本够用,因为正常业务的读路径在局域网内返回极快。如果你在做跨机房架构,这个值就得放大到秒级。
它也不是免费的:第二次删除可能把"另一个线程刚写好的新缓存值"也误删。但没关系,删除之后下一次读取会从数据库重建,代价只是一次缓存 miss,最终一致性能保证。
2.3 Read/Write Through 与 Write Behind:把缓存变成 DB 的代理人
Cache Aside 是应用层自己管缓存和数据库。还有另一类方案,思路完全不同:缓存层本身变成数据库的代理,应用只跟缓存打交道,不直接碰数据库。
- Read Through:读的时候如果缓存 miss,不是应用去查库,而是缓存组件自己从数据库加载并回填。应用只调缓存。
- Write Through:写的时候,应用写缓存,缓存组件同步把数据写进数据库,等数据库确认后才返回。
- Write Behind(也常叫 Write Back):写的时候只写缓存,立即返回成功;之后由后台线程把数据批量异步刷进数据库。
这套思路是不是很眼熟?操作系统的页缓存就是这么干的。Write Through 保证写路径上缓存和数据库同步,读性能高,但写性能被数据库拖住;Write Behind 写性能拉满,代价是如果缓存没来得及刷库就宕机了,数据就丢了。
在"Redis + MySQL"这种组合里,这三种模式并不好落地,因为 Redis 本身不是数据库代理,你要在业务代码里再包一层中间件,或者借助 Tair 这类集成缓存产品。但对某些特殊场景(比如缓存本身就是主存储,数据库只是持久化落地的副本),Write Behind 反而很合适。选择它之前,必须先回答一个问题:丢数据,你能不能接受。
2.4 四套方案的成本、边界与一致性对比
| 方案 | 读性能 | 写性能 | 一致性保障 | 实现复杂度 | 典型适用 |
|---|---|---|---|---|---|
| Cache Aside | 高 | 中 | 最终一致,存在极小窗口 | 低 | 大多数传统业务 |
| 延迟双删 | 高 | 中 | 最终一致,窗口明显缩小 | 中 | 写并发较高的互联网业务 |
| Read/Write Through | 高 | 中(写同步被库拖累) | 写路径较强一致 | 高 | 有独立缓存中间件的架构 |
| Write Behind | 高 | 极高 | 最终一致,有丢数据风险 | 高 | 允许丢失、追求吞吐的场景 |
这张表不是用来告诉你"哪个最好",而是告诉你"每个方案的代价在哪"。选方案之前,先看看自己不能承受哪个代价。
3. 生产环境实操复盘:这些坑,文档里基本不会写
3.1 删除缓存失败被静默吞掉:脏数据能赖到 TTL 到期
我见过最多的生产问题,不是方案选错了,而是删除缓存这一步被静默吞掉。
很多同学的代码是这样的:
try { redis.delete(key); } catch (Exception e) { // 忽略异常,下次更新再删 }Redis 超时、连接池耗尽、网络抖动,任何一次 delete 失败,都会让这次写操作的一致性保障失效。而你要知道,脏数据一旦进了缓存,它会一直待在那里,直到 TTL 到期,而不只是几毫秒。TTL 如果设的是 30 分钟,用户就看 30 分钟的旧数据。
正确的做法是:删除失败时,把 key 投递到一个待补偿队列,由后台线程重试删除;同时记录日志和告警。不要吞异常,不要指望"下次更新会删"——下次更新可能永远不会来,这个 key 可能是一个长时间不变的配置项。
另外还要注意:删除操作本身也可以带上"校验"。如果你的缓存值里带版本号,删除前可以比对一下;如果删除的是别人刚更新的新版缓存,那才是真正的误删。这个后面 5.2 节会展开。
3.2 TTL 不是拍脑袋定的:改小就击穿,改大就脏读
TTL 是缓存架构里最容易被低估的配置。它是所有一致性方案的最后一道兜底——无论你的删除逻辑多完美,消息丢了、进程崩了、代码写错了,只要 TTL 到了,缓存都会失效重查,数据会回到一致。
所以 TTL 设多长,本质是在"脏数据驻留时长"和"缓存命中率"之间做权衡。
- TTL 太长(几小时甚至一天):一旦出现删除失败或回填旧值,用户长时间读脏数据。
- TTL 太短(几秒):缓存命中率暴跌,大部分读请求穿透到数据库,数据库压力骤增,还可能引发缓存击穿。
我常用的经验做法是:基础数据 30 分钟到 1 小时,热点数据用逻辑过期。逻辑过期的意思是缓存不设置物理 TTL(或者设很长),而是在 value 里存一个 expireAt 字段。读取的时候判断 expireAt,如果过期了,先返回旧值给调用方,同时异步去数据库拉新值回填。这样既挡住了缓存击穿,又保证数据迟早会刷新。
public Object readWithLogicalExpire(String key) { CacheValue cv = cache.get(key); if (cv == null) { return loadFromDB(key); } if (cv.expireAt < now) { // 异步刷新,先返回旧值 asyncRefresh(key); } return cv.data; }这个模式在"热点数据 + 允许短暂脏读"的场景下非常好用。但它本质上牺牲了一致性来换性能和稳定,需要业务能接受。
3.3 缓存穿透、击穿、雪崩和一致性问题的叠加
缓存一致性聊到最后,一定会撞上缓存三兄弟:穿透、击穿、雪崩。它们不是一致性问题的直接来源,但会让一致性问题的后果被放大。
- 穿透:查询一个不存在的 key,缓存里没有,数据库里也没有,每次请求都打到 DB。一个恶意攻击者可以靠构造不存在的 ID 把你数据库打挂。
- 击穿:一个热点 key 在缓存过期的瞬间,大量并发请求同时 miss,全部冲进数据库。数据库慢查询之后,多个线程各自查库、各自回填,可能发生旧值覆盖新值。
- 雪崩:大量 key 在同一时间集体过期,数据库被一波流量打垮。
对一致性来说,最值得警惕的是击穿时的回填竞争。多个线程同时发现缓存 miss,同时去查库,由于查询发生在不同的时间点,查到的值可能新旧不一,后回填的旧值就可能把先回填的新值覆盖。
解法也很经典:用互斥锁让同一时刻只有一个线程去重建缓存,其他线程等锁或者短暂返回旧值。重建线程只允许从数据库取最新值,绝不使用本地旧值回填。再配合"空值也缓存"解决穿透,配合"TTL 加随机抖动"解决雪崩,这套组合拳基本就是标准答案。
3.4 读写分离下的回填陷阱:你写入缓存的可能是个旧值
现在的系统很少有单库部署,读写分离、一主多从是常态。问题就藏在这里:MySQL 主从同步有延迟,Redis 主从复制也有延迟。
一个非常典型的翻车现场:用户在订单页提交了修改,应用写的是 MySQL 主库,更新成功后删除了缓存;但用户紧接着的读请求,命中的是 MySQL 从库,而从库还没来得及同步这条更新,于是读到的还是旧值;这个旧值被回填进缓存,后续所有读都是一样的旧数据。
这个窗口,用延迟双删能缓解,但不是根本解法。根本解法是三个方向:
- 写后读一致性:对于"我刚写的数据,我必须立刻能读到"的场景,让这部分的读请求强制走主库。比如订单表按用户维度路由,用户自己查自己的单就走主库。
- 版本号校验:从库读回的值,和 Redis 里存的版本号比对,版本落后就重新从主库拉。
- 短 TTL 兜底:就算回填了旧值,也让它在几十秒内过期重查。
记住一个原则:在分布式系统里,任何"读"都可能读到旧数据,一致性方案的职责是让旧数据尽快失效,而不是天真地以为它永远不会出现。
3.5 补偿队列变成消息堆积:一致性又被拖长了
用 MQ 做删除缓存失败后的补偿,方向没错,但很多人忽略了一点:补偿队列本身会堆积。消费者处理不过来,或者消费者宕机,消息在队列里躺了十分钟,意味着脏数据在缓存里躺了十分钟。
治理思路也不复杂:
- 按 key 做哈希分区,保证同一个 key 的补偿消息落到同一个消费者,避免乱序。
- 给消息设置 TTL,超过一定时间直接丢弃,因为 TTL 兜底会接管。
- 监控队列积压量,积压超过阈值就报警。
- 更极端的做法是不用 MQ,直接在 Redis 里做延迟队列,消费逻辑里带重试上限。
我自己踩过一次:双十一大促时补偿消费者线程池被调小,积压了上百万条删除消息,脏数据持续可见。那次之后,我把"补偿队列的积压指标"加进了值班告警。缓存一致性问题,很多时候不是方案不对,而是外围设施没扛住。
4. 按业务场景选方案:一张可以直接抄的决策表
4.1 读多写少、能容忍短暂脏读:Cache Aside + TTL + 补偿就能行
这是最主流的业务形态:商品详情、用户资料、配置信息、类目树。这些数据的特点是写操作很少,读操作极多,偶尔读到旧值(几百毫秒到几秒)用户感知不明显。
我给你的配方是:Cache Aside + TTL(如 30 分钟)+ 删除失败补偿队列。不需要延迟双删,因为写太少,读回填旧值和写操作的并发窗口几乎遇不到;不需要 binlog 订阅,因为成本不划算。补偿队列保证哪怕删除失败,也能在几秒内自愈。
这个方案我很推荐作为中小项目的默认选择。它足够简单,团队成员都能看懂,出问题时排查链路短。
4.2 写频繁、读量大、最终一致可接受:延迟双删或 binlog 订阅
典型业务是点赞数、浏览量、排行榜、库存余量(注意,库存通常需要更强的保障,这里说的是允许超卖几件也无所谓的场景)。这些数据的写操作可能每秒成千上万,如果用 Cache Aside,每次写都触发删缓存,缓存命中率会很难看;而且删除失败概率会随写频率上升。
这个档位,两个选项:
- 延迟双删:简单直接,适合不想引入额外组件的团队。写操作先更新 DB,删除缓存,异步再来一次删除。因为它本质是"Cache Aside 的更稳健版",对现有代码改动最小。
- binlog 订阅:适合"不能改原有写代码"的存量系统。数据库更新通过 binlog 被捕获,异步通知缓存层删除或更新。这样业务代码一行不用动。
两者都是最终一致,binlog 订阅的一致性窗口通常在秒级,延迟双删在百毫秒级。
4.3 强一致/写后读一致的场景:先反问自己能不能不用缓存
如果你真的遇到强一致需求——比如支付状态、余额扣减、订单状态流转,第一反应不应该是"用什么缓存方案能保证强一致",而是这个数据到底要不要走缓存。
我的建议很直接:业务核心状态数据,放弃缓存,直接读库。加索引、读写分离、订单维度分库,这些手段能解决大部分性能问题。缓存的优势场景是"高并发读、低一致要求",你非要用在强一致场景,等于用短板去硬刚。
如果一定要缓存,可以退而求其次做"写后读一致":
- 用户写完数据后,强制读主库,直到这个用户下次会话结束再恢复缓存读取。
- 或者数据不进缓存,只做"查询结果缓存",每次写操作直接把对应缓存 key 删除,并等待主从同步确认。
- 或者引入版本号,读的时候校验版本,版本不一致就重查主库。
强一致没有捷径,每一条路都在牺牲性能或增加复杂度。问问产品经理,"这里真的要强一致吗",往往能得到比技术方案更有效的答案。
4.4 场景-方案-注意事项决策表
| 业务场景 | 推荐方案 | 关键注意事项 |
|---|---|---|
| 读多写少,容忍短暂脏读 | Cache Aside + TTL + 补偿队列 | TTL 别太短,删除失败必须告警 |
| 写频繁,读量大,最终一致 | 延迟双删 / binlog 订阅 | 第二次删除必须异步;binlog 消费延迟要监控 |
| 强一致写后读 | 直接读库 / 主库强制读 | 不要硬上缓存;先确认产品需求 |
| 热点 key 高并发读 | 逻辑过期 + 互斥锁重建 | 重建只准取最新库值,禁止用旧值回填 |
| 读写分离架构 | 写后读强制主库 + 短 TTL | 主从延迟是隐性脏数据来源 |
5. 进阶玩法:让数据库主动把"数据变了"这件事告诉缓存
5.1 binlog 订阅:业务代码零侵入的异步删除
前面多次提到的 binlog 订阅,值得单独讲一下。它不完全是一条"缓存一致性方案",更准确地说,是绕过业务代码,直接从数据库层面感知变更的方案。
原理很简单:MySQL 开启 binlog,并且用 row 格式记录每一行数据的变更;Canal(或类似的工具)把自己伪装成 MySQL 从库,向主库拉取 binlog;解析出变更事件(insert、update、delete)之后,投递给 MQ;下游消费者根据事件构造出"该删哪个缓存 key"的消息,执行删除。
这套方案有几个明显优势:
- 业务代码零侵入:不需要在每一处写库的地方都写"删缓存"逻辑,存量系统改造时几乎无损接入。
- 天然有序:binlog 是有序的,同一行的变更事件按顺序消费,不容易出现乱序覆盖。
- 精确反映数据库状态:以数据库实际变更作为触发源,不会出现"删了缓存但数据库其实没改"的尴尬。
代价也很明确:额外引入一套基础设施(Canal、MQ、消费者),排障链路变长,消费延迟可能导致脏数据存在秒级。它尤其适合多个小组共用同一个库、谁都不知道对方什么时候改数据的系统——这种系统里,指望各业务方自觉删缓存根本不现实,binlog 是唯一可靠的抓手。
5.2 版本号 + Lua 校验:从源头阻止旧值回写
前面所有方案,都是在"旧值已经进缓存"之后想办法删。更优雅的思路是让旧值根本没机会写进去。
做法是在缓存值里带一个版本号(或者一个时间戳)。数据库表加一个 version 字段,每次更新 version + 1。更新数据时,同时把最新版本号写进 Redis 的一个对应 key。读数据时,从数据库或缓存拿到数据后,比对版本号,版本号落后就直接丢弃、重新拉取。
Redis 的 Lua 脚本很适合做这个原子操作。比如删除缓存时,要求"只有缓存的版本号和输入的版本号一致才删除":
-- KEYS[1]: 缓存key -- ARGV[1]: 当前数据库中的版本号 if redis.call("EXISTS", KEYS[1]) == 1 then local v = redis.call("GET", KEYS[1]) if v == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0 end return 0这个脚本的意思是:如果缓存里的值和数据库当前版本一致,说明缓存还是干净的,删掉没问题;如果不一致,说明缓存本来就旧了或已经被别人更新,删除操作冗余,不删也罢。这样既避免误删别人刚刚写好的新缓存,又防止旧值长期残留。
版本号方案的一致性最强,但代价是每次读都要多一次版本比对,或者把版本号直接嵌在缓存值里、在反序列化后比较。它适合对一致的确定性要求比较高的场景,但也不是银弹——如果业务本身并发太夸张,版本号本身也会成为热点。
5.3 本地缓存、Redis、DB 三级的联动失效
微服务架构下,很多人会在进程内加一层本地缓存(Caffeine、Guava),再在外面套 Redis。本地缓存的性能最好,但一致性问题也最明显:每个节点各自存一份,Redis 删了,各节点的本地缓存还活着。
三级架构的失效路径应该是单向的:写操作先更新 DB,再更新或删除 Redis,然后通过 Redis 的 pub/sub 或者配置中心广播一条"某个 key 已失效"的消息,所有节点监听到之后,清掉本地缓存。
但要小心:Redis pub/sub 是即发即弃的,节点在广播期间掉线,就错过了这条消息,本地缓存就不会失效。所以更稳妥的做法是定期轮询一个"全局版本号"或"失效 key 集合",各节点定时拉取,和本地缓存里的版本比对,不一致就丢弃。
本地缓存通常只用在不常变的配置类数据上,例如系统开关、字典表、渠道配置。对这个级别的数据,每次变更广播一下,几十个节点清一次缓存,成本完全可以接受。你要是把订单状态也塞进本地缓存,那就是自己给自己挖坑。
5.4 所有方案的兜底:给缓存一个"必死"的 TTL
讲了这么多方案,最后必须回到那条被我反复强调的底线:任何缓存,都必须设置一个合理的过期时间,除非你有非常充分理由让它永久存活,并且为永久性脏数据负责。
为什么?因为无论你选 Cache Aside、延迟双删、binlog 订阅,还是版本号校验,都有失败的可能。Redis 服务重启、MQ 消息丢失、代码里某个异常路径没有覆盖到——这些在长周期里一定会发生。TTL 的存在,意味着哪怕所有的主动失效机制全部失灵,数据也终究会被"强制重查",回到一致状态。
TTL 的设计有几个细节:
- 基础数据设固定值 + 随机抖动(比如 30 分钟 ± 5 分钟),防止大量 key 同时过期引发雪崩。
- 热点数据用逻辑过期(value 里带 expireAt),既保证活跃度,又避免击穿。
- 对一致性要求稍高的数据,TTL 缩到 1~5 分钟,把脏数据驻留时间控制在可接受范围。
- TTL 尽量在写入时由代码统一携带,别依赖 Redis 默认配置,避免不同 key 的过期策略混乱。
我个人在这类问题上的态度是三个字:别贪。缓存一致性从来不是靠某一个"完美方案"解决的,而是靠一层层兜底叠加出来的:Cache Aside 把基本盘稳住,延迟双删堵并发窗口,补偿队列收拾删除失败的意外,版本号挡住旧值回写,TTL 负责最后收尸——这五层各管一段,配合好才能睡个安稳觉。决定用几层,取决于你的业务愿意为"不脏读"付出多少成本,而不是取决于方案听起来多高级。缓存永远只是加速器,数据库才是真相;任何一致性工程的终点,都是让数据最终回到真相上。