缓存与数据库一致性:从根因到生产实践方案解析 先从一个我亲手处理过的线上事故说起。用户支付回调成功之后刷新订单详情页状态却还挂在待支付上。数据库里查了一圈订单状态明明是已支付问题就出在 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 Aside2.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_ms500) 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 负责最后收尸——这五层各管一段配合好才能睡个安稳觉。决定用几层取决于你的业务愿意为不脏读付出多少成本而不是取决于方案听起来多高级。缓存永远只是加速器数据库才是真相任何一致性工程的终点都是让数据最终回到真相上。