
1. 先搞懂为什么缓存数据库天生就会不一致1.1 一致性问题只在可写数据上成立先说个前提面试里聊的一致性问题默认讨论的是 Redis 这类外部缓存 MySQL 这类关系型数据库的组合数据既会被读也会被写。如果一份数据是纯只读的比如配置表、字典表那缓存怎么缓存都不会出错顶多是缓存过期时间长短的问题。真正让人头疼的是那些要读、要写、写完后必须让读的人看到新数据的场景。我在准备面试复盘时习惯先把问题的边界画清楚数据是否允许短暂读到旧值比如用户改昵称晚几秒生效是可以接受的数据是否绝对不能读到旧值比如余额、库存、订单状态读错就出事故系统的读写比例是多少如果是 99% 读 1% 写一致性方案的成本可以放宽团队有没有能力运维额外的中间件比如 Canal、Kafka如果只有两三个人就别选重方案。面试官问缓存和数据库的一致性问题如何保证本质上不是要你背一个标准答案而是看你能不能根据业务场景动态选型。这一点先记住后面全篇文章都会围绕这条主线展开。1.2 不一致的真正根源两个存储不是同一个事务缓存和数据库为什么会不一致很多人第一反应是并发导致这个答案对但不够本质。更深一层的原因是你没法把写 MySQL和写 Redis放进同一个事务里。MySQL 有自己的事务Redis 也有自己的事务但两者之间没有分布式事务的原生支持。你可以想象这样一个场景你在 excel 里维护一份订单表另外又在一张纸上抄了一份订单摘要。每次改 excel 的时候你也想顺手改掉纸上的摘要。可问题是改 excel 和改纸是两个独立动作中间一旦断电、走神、或者手一抖两边的数据就对不上了。数据库和缓存的关系就是这个。你用代码先操作数据库、再操作 Redis这两步之间任何一步失败都会导致两边数据漂移。就算两步都成功了只要中间有并发读请求穿插进来也可能把一个旧值写回 Redis覆盖掉刚才更新的新值。所以所谓保证一致性并不是把不一致消灭到零而是通过一系列手段把不一致的概率压缩到业务可以接受的范围同时在不一致发生之后能快速收敛回一致。1.3 面试官问这道题到底想听什么这道题高频出现是因为它几乎能把一个人的后端基本功全测出来懂不懂缓存更新的经典模式Cache Aside了不了解并发时序问题知不知道删除缓存 vs 更新缓存的区别有没有真正在线上处理过缓存污染的运维经验能不能在一致性的绝对性和系统的复杂度之间做取舍面试官不是要一个完美的方案而是要看到你有分层思考的能力先分析场景再给方案然后说清方案的代价。我见过不少候选人上来就背延迟双删但问他为什么延迟、延迟多久、第二个删除失败了怎么办就卡住了。这就是典型的背答案没理解。接下来我从最经典的两种操作顺序讲起逐步深入到延迟双删、Binlog 订阅、强一致方案最后再给一套可以直接用于面试的回答框架。2. 经典答案之争先删缓存还是先更新数据库2.1 先删缓存再更新数据库并发读会写回脏数据面试里最常见的两种操作顺序是先删 Redis再更新 MySQL先更新 MySQL再删 Redis。第一种方案思路是既然缓存迟早要失效那我先把它删了再去改数据库这样读请求落空后会去数据库读而数据库此时还没更新就可能读到旧值。问题恰恰就出在这个读旧值写回缓存的动作上。看这个时序写请求 A 删除缓存 key读请求 B 此时来查缓存发现 miss于是去 MySQL 查查到的是旧值 v1写请求 A 更新 MySQL把值改成 v2读请求 B 把刚查到的 v1 写回 Redis后续所有读请求都命中缓存的 v1而数据库里已经是 v2。也就是说一次读请求把早就该失效的旧值续命回了缓存而且这次续命可能持续到下次缓存过期这是最典型的缓存污染场景。现实中这个场景并不罕见尤其是在缓存设置成永不过期或者过期时间很长的系统里一旦污染恢复要等很久。这也是为什么很多技术文章说先删缓存再更新数据库基本不推荐除非你能做到删除缓存后、更新完数据库之前拒绝所有读请求——而这是不现实的。2.2 先更新数据库再删缓存删除失败就会长期不一致第二种方案是大多数系统实际采用的方式即 Cache Aside Pattern 的标准做法写请求先更新 MySQL然后删除 Redis 里对应的 key后续读请求 miss 后从 MySQL 读新值并回填。这个方案的关键优势是在更新数据库期间缓存里的旧值仍然可以被读并不会出现先删缓存导致的大量缓存穿透。即使读请求在数据库更新前读取读到的也是旧值但旧值和数据库旧值是一致的不会污染缓存。它的核心风险则集中在一个点上第二步删除 Redis 如果失败了缓存里会一直保留旧值。只要提升删除缓存的成功率这个方案在绝大多数业务场景下都足够用。所以问题的核心就变成了如何保证删除缓存一定执行成功这就需要引入重试机制。最常见的做法是同步删除失败后把删除动作投递到消息队列由消费者异步重试或者写一个本地重试表扫表任务定时重新执行删除或者直接用开源的 Reliable Cache 删除组件把失败的删除操作记录到日志表定期补偿。我在实际项目中见过很多团队第一步没问题第二步直接删一下不管结果这是最危险的写法。因为 Redis 的删除操作本身出错概率不高但一旦出错就是长期性的数据错误比偶发的并发问题更难排查。2.3 为什么更新缓存通常是陷阱很多初学者会问既然删除这么麻烦为什么不同时更新缓存也就是先写 MySQL再直接 set Redis。直观上这确实更合理省了一次缓存 miss。但在高并发写场景下更新缓存是个巨大的坑。考虑这个场景写请求 A 先把 MySQL 更新为 v1写请求 B 接着把 MySQL 更新为 v2写请求 A 再把缓存 set 为 v1写请求 B 再把缓存 set 为 v2。这个时序看着没问题因为最终缓存是 v2。但别忘了网络请求并不是严格有序的更常见的情况是写请求 A 更新 MySQL 为 v1准备 set 缓存写请求 B 更新 MySQL 为 v2写请求 B 先 set Redis 为 v2写请求 A 后 set Redis 为 v1。最终缓存是 v1数据库是 v2永远不一致。这就是经典的写写并发导致旧值覆盖新值。除非你给每次写操作加版本号只允许高版本号写入缓存否则更新缓存的方式在大并发下很难保证正确。删除缓存就不存在这个问题因为删除让缓存失效下一次读会强制从数据库取最新值相当于让数据库来做最终的裁决。这个点面试经常考答得好能直接体现你是否理解缓存设计的核心逻辑。2.4 两种方案的实际对比与边界对比维度先删缓存再更新 DB先更新 DB 再删缓存并发读的风险高读可能把旧值写回缓存低缓存旧值在更新期间仍可用主要失败点更新 DB 期间缓存 miss 引发穿透删除缓存失败导致长期不一致对 Redis 的依赖删除后依赖 DB 扛读流量删除失败需要重试兜底适用场景写多读少且能接受瞬时穿透绝大多数业务系统面试时你应该明确告诉面试官首选先更新数据库、再删除缓存但删除动作必须有可靠性保障而不是裸删。这样回答已经比 80% 只会背方案的候选人强了。3. 延迟双删最常见的补救方案但要补三个洞3.1 延迟双删的原理与代码先更新数据库再删缓存虽然优于先删再更但在 2.1 的场景里如果先删缓存再更新数据库这个时序换成先更新数据库再删除缓存依然存在一个极端情况下的漏洞读请求 B 在写请求 A 更新完 MySQL 之前从 MySQL 读到了旧值写请求 A 更新 MySQL 为 v2然后删除了缓存读请求 B 此时才把自己的旧值 v1 写回缓存。这个窗口比先删缓存再更新数据库小很多但确实存在。为了解决它就出现了延迟双删Double Delete删除缓存更新 MySQL隔一段时间比如 500ms~1s再删除一次缓存。第二次删除的目的就是清掉那个在第一步和更新之间、或者更新之后才姗姗写回缓存的旧值。代码写出来是这样Java 伪代码public void updateData(String key, Object newValue) { // 第一次删除 redisTemplate.delete(key); // 更新数据库 orderDao.update(newValue); // 延迟第二次删除 threadPool.schedule(() - redisTemplate.delete(key), 500, TimeUnit.MILLISECONDS); }这个方案非常简单粗暴适合中小型项目快速落地。但我在面试和实际代码 review 中发现它有三个很容易被忽略的洞。3.2 延迟时间到底怎么定延迟双删最难的地方在于这个500ms或者1s怎么定的如果拍脑袋写一个固定值就等于把方案的可靠性交给了运气。延迟时间的核心约束是必须大于从 MySQL 读旧值到写回 Redis这个链条的总耗时。这条链路包括应用服务器执行 SQL 的时间网络传输时间应用代码处理结果、序列化、写缓存的时间。如果读请求的耗时是 300ms你的第二次删除定在 200ms那么旧值刚写回缓存删除动作已经执行完了等于白删。更严谨的做法是先压测出读请求在慢查询、网络抖动情况下的最大耗时然后把这个值乘以 2 或 3 作为延迟时间。我常用的经验值是一般业务系统取 1s 比较稳妥宁可多点延迟也不要因为时间太短而失去第二次删除的意义。但这里又冒出一个新问题延迟 1s 意味着在这 1s 内缓存里依然可能保留旧值。如果业务对秒级失效都无法容忍延迟双删就不合适了。3.3 双删的三个漏洞与补救第一个洞第二次删除也可能失败。第一次删除失败你还可以通过同步重试第二次删除失败呢普通的定时线程池根本不知道删除失没失败。补救方式是把第二次删除投递到可靠消息队列由消费者执行删除删除失败就重试重试还失败就告警人工介入。第二个洞Redis 主从架构下的主从延迟。如果 Redis 配了主从读请求可能命中从节点。第一次删除删的是主节点从节点的旧值还可以被读一段时间第二次删除如果太早从节点还没来得及同步主节点的删除命令可能删不掉从节点上的数据。这种情况要把延迟时间拉长或者用 Redis 集群的读写分离方案时对一致性要求高的 key 强制读主节点。第三个洞延迟双删本质上是靠概率降低不一致不是数学意义上的消除。它只能应对读请求恰好穿插在写窗口内的场景如果服务在第二次删除前宕机、线程池拒绝任务、或者 Redis 故障依然会残留旧值。所以延迟双删必须配合缓存过期时间兜底——即使脏数据存在最坏也会在缓存过期后自动恢复。我给面试者的建议是延迟双删可以作为先更新 DB 再删缓存的增强方案来提但一定要主动说清它的局限并补充 MQ 重试和过期兜底这样才能体现你真的理解它而不仅仅是背了一个名词。4. 更稳的路订阅 Binlog 异步删缓存Canal 方案4.1 从业务代码发删除信号到数据库自己发信号前面几种方案都有一个共同点由业务代码主动发起缓存删除。这带来一个潜在问题——如果业务代码忘了删、删失败了、或者上线时漏改了某些分支缓存和数据库就可能长期不一致。更好的思路是让数据库告诉我们数据变了而不是让业务代码记得去通知 Redis。MySQL 的 Binlog 天生就是一个数据变更事件流主从复制就是靠它实现的。既然 MySQL 能靠 Binlog 同步数据到从库我们当然可以靠它同步数据变更这个事件到缓存系统。这正是阿里 Canal 的定位伪装成 MySQL 的从库拉取 Binlog解析出增删改记录再推给下游消费者。整个链路变成业务代码只操作 MySQL写完不用管缓存Canal 监听到 Binlog 变更Canal 把变更事件发送到 MQ 或者直接回调业务接口消费者收到事件后删除对应缓存 key。这样做最大的好处是缓存删除逻辑和业务代码彻底解耦。业务 100% 不会忘删因为只要你改了数据库Binlog 就一定会有记录。即使某次删除失败消息队列里的重试机制也能兜住。4.2 Canal 方案的落地架构整套架构大概长这样业务服务 - MySQL写入 | v Binlog Canal Server | v 解析后的变更事件 Kafka / RocketMQ | v 消费者 删除 Redis Key这套方案我有两段亲身体会。第一段是接入初期你会发现 Canal 部署本身不难难的是配置MySQL 需要开启 Binlog并且要设置binlog_formatrow。我见过太多人忽略这一条用的 statement 格式解析出来的变更信息根本没有行的前后值删除缓存时都不知道该删哪个 key。第二段体会是Canal 高可用需要自己搭官方有 HA 方案但实际生产环境很多人就是单节点跑一旦 Canal 宕机消息队列里堆积的全是被跳过的变更缓存里的数据就假装没事地继续用旧值。所以生产环境一定要配监控Canal 延迟超过阈值必须告警。4.3 幂等、乱序、重试三个细节订阅 Binlog 方案虽然可靠但引入 MQ 之后就逃不开消息队列的三座大山幂等、乱序、重试。幂等同一个 key 的删除事件可能被消费多次消费者重启、重试等但Redis DEL是幂等的删一个不存在的 key 也没关系。所以删除缓存的消费者天然幂等这是比更新缓存更适合这个消息链路的原因。乱序如果一个 key 被连续变更两次Binlog 里是 A 事件、B 事件如果消费者并发处理B 可能先到先删A 后到后删。但因为都是删除最终缓存是 miss 状态下一次读会去数据库拿最新值所以乱序也不会造成永久脏数据。这一点要感谢删缓存而不是更新缓存的设计。重试如果删除 Redis 失败消费者需要重试。最简单的做法是消费时 Catch 异常并返回消费失败让 MQ 自动重试。但要注意如果 Redis 故障持续较长时间消息会反复重试可能阻塞后面的消息。这时候推荐使用直连重试 最终补偿表失败先记录到本地表定时任务再扫表重删。4.4 这个方案的代价与适用边界Binlog 方案不是银弹。它至少多引入了两个组件Canal 和 MQ。这意味着运维成本上升需要监控 Canal 的延迟、MQ 的堆积消息链路有毫秒到秒级的延迟如果业务要求改完数据库立刻能读到新数据这个方案就不够快如果系统里既有 Redis 缓存又有本地缓存如 CaffeineBinlog 只能通知到 Redis本地缓存还是得靠业务代码自己失效。所以我的建议是查询逻辑复杂、更新频繁、且团队有一定中间件运维能力的中大型系统适合用 Binlog 方案小项目或者面试场景提到这个方案作为更可靠的进阶手段就够了没必要强行落地。5. 强一致路线什么时候该放弃缓存或者用锁把请求串行化5.1 如果不能容忍任何不一致先回答业务允许多旧面试聊到这里面试官通常会追问一句如果业务要求绝对一致呢比如余额、库存这种场景。这是一个陷阱题。如果你直接说用分布式锁或者用分布式事务基本上就掉坑了。因为强一致场景下的最优解往往不是技术方案更牛而是压根不该用缓存。现金、余额、库存这类数据读多写少吗其实写也不少。而且读错了代价极高用户看到余额多了可能去消费库存超卖可能引发客诉。对这类数据最稳妥的做法是不缓存每次读直接查 MySQL或者缓存只做展示层的数据真实校验永远走数据库数据库层面用行锁、乐观锁版本号来控制并发写。面试时你主动说出我会评估这个数据要不要进缓存比任何花哨的技术方案都加分。5.2 分布式锁读写串行化如果你的业务确实需要缓存又无法容忍并发窗口下读到旧值那可以考虑读写串行化思路写请求先获取分布式锁Redis SETNX 或者 Redisson 的锁在锁内完成更新数据库 删除缓存读请求也可以尝试获取同一把锁但读全部加锁会把并发性能降到和直连数据库差不多通常只对写操作加锁即可。加锁之后的时序是串行的不会出现写中间夹一个读的操作自然也就没有旧值写回缓存的问题。代价很明显锁竞争会降低吞吐量分布式锁本身也可能失效主从切换、锁过期需要选成熟的实现Redisson如果锁粒度太大整个表的数据都互斥那系统基本废了。所以这种方案只适合写频率低、一致性要求极高的少数 key比如活动库存的扣减。5.3 版本号校验缓存还有一种相对温和的强一致方案是在缓存中保存一个版本号业务更新时把版本号递增读请求拿到缓存后会校验版本和数据库是否一致不一致就主动失效缓存并重查。伪代码示意// 写请求 Long newVersion incrementAndGetVersion(key); updateDb(newValue); redis.set(key :value, newValue); redis.set(key :version, newVersion); // 读请求 String cacheVersion redis.get(key :version); String dbVersion getDbVersion(key); if (cacheVersion.equals(dbVersion)) { return redis.get(key :value); } else { String freshValue loadFromDb(); refreshCache(key, freshValue); return freshValue; }这个方案的缺点是每次读都要多查一次数据库的版本号缓存的意义就被削弱了一半。它更适合读多写少但写后校验成本低的场景比如配置中心的数据。不过在很多公司里因为版本号存储可能再引入一次 Redis 读操作实际用得并不多。5.4 事务消息/分布式事务的取舍还有一些团队尝试用分布式事务的方案把更新 MySQL和更新 Redis纳入同一个事务。比如用 RocketMQ 的事务消息先发送半消息执行本地事务事务成功则提交消息消费者收到消息后更新缓存或者用 Seata 的 AT/TCC 模式把缓存操作作为一个分支事务参与全局事务。这类方案理论上能实现强一致但实际落地时你会遇到一个尴尬的问题Redis 的事务能力很弱不支持和 MySQL 一起做两阶段回滚。即使你用 TCC缓存那边也没有undo 日志可以回滚到之前的状态。所以最终你发现大多数强一致诉求还不如回到 5.1 的思路——别缓存。面试时提到事务消息只能说可以考虑但一定要随口补一句我觉得在这个场景下性价比不高更推荐评估去掉缓存或用数据库兜底。这个补充非常关键能让面试官看到你不是为了炫技。6. 面试回答模板与高频追问拆解6.1 两分钟答题框架如果面试官让你完整回答如何保证缓存和数据库一致性我建议按下面这个结构组织逻辑清晰又有层次感先定义问题缓存和 Redis、数据库和 MySQL它们不是一个事务所以永远存在不一致的窗口我们要做的是把窗口压到业务可接受范围给出默认方案日常场景用 Cache Aside先更新数据库再删除缓存删除动作要做好失败重试比如 MQ 补偿不能裸删给出进阶增强如果并发窗口要求更小可以叠加延迟双删注意延迟时间要经过压测且要配缓存过期作为兜底给出可靠方案如果系统规模较大用 Canal 订阅 Binlog 异步删缓存让缓存失效和业务代码解耦强调场景判断如果业务要求绝对一致直接建议不缓存或者用分布式锁串行化不要让缓存承担强一致责任。这样回答面试官听到的是一个完整的技术决策链路而不是一个孤立的知识点。6.2 面试官最爱追问的五个点追问一为什么是删缓存而不是更新缓存答更新缓存存在写写并发下的旧值覆盖新值问题删除缓存是幂等的最多引发一次缓存 miss下一次读会从数据库重新加载。删除永远比重置更安全。追问二删除缓存失败怎么办答同步重试 异步补偿。同步删除失败后把删除事件写入重试表或者 MQ由消费者异步重试重试多次仍失败要告警并依靠缓存过期时间兜底。追问三延迟双删的延迟时间怎么定答要大于从数据库读旧值到写回缓存的完整耗时一般按压测最大耗时乘以系数常规取 500ms~1s如果 Redis 有主从还需覆盖主从同步延迟。追问四Canal 挂了呢答Canal 挂掉期间 Binlog 消费会中断MQ 里没有新事件缓存清理会停摆。所以必须监控 Canal 延迟并设置告警更保险的做法是同时保留业务侧删除作为双保险。追问五为什么不用分布式事务把两边包起来答Redis 不支持与 MySQL 的两阶段事务回滚成本高、收益小强一致场景更推荐直接读数据库或者只对关键 key 加锁。6.3 我见过的最加分的收尾方式很多候选人答完所有方案就结束了其实还有一个很加分的收尾主动说出自己对一致性的认知边界。比如你可以说这个问题没有绝对正确的答案。我的理解是一致性是一个从弱到强的光谱业务允许 1s 的延迟就选 Cache Aside 重试业务要求秒级以内加延迟双删或者 Binlog业务一点脏数据都不能忍受那我会建议放弃缓存用数据库兜底。我平时做项目第一步都是先确认业务对陈旧读的容忍度再定技术方案。这段话一出口面试官会明显感觉到你不是背了八股文而是真的有系统设计思维。6.4 平时做项目可以这样准备最后说点实在的。如果你正在准备面试不要只停留在看文章建议在项目里动动手把你负责的某个读多写少的接口做一次代码改造从裸删缓存改成先更新 DB MQ 补偿删除在本地起一个 Canal 容器连着开发库的 Binlog观察一次更新如何变成一条 MQ 消息写一个小的并发测试模拟读请求穿插在写请求之间看看 Cache Aside 和延迟双删各自的行为差异把 Redis 的 key 设置一个合理的过期时间作为所有方案的最终兜底。面试时如果能说出我在自己的项目里用 MQ 重试解决了两次删除失败的问题远比背十个方案更值钱。毕竟面试官想找的是真的能干活、真的理解数据一致性问题的人。