
做后端这几年我最怕的不是接口突然变慢而是缓存里躺着旧数据数据库里已经是新数据两边对不上。缓存与数据库一致性问题几乎是每个分布式系统都会踩的坑也是面试被问烂、上线后最容易背锅的一个点。今天不聊虚的直接把这个问题拆开它为什么会出现业界有哪些常见解法落到代码和架构上要怎么选以及线上出问题时怎么排查。适合正在做后端开发、系统设计或者准备架构面试的同学认真看完基本能应对大多数场景。很多人一看“缓存一致性”就头大觉得要上一堆分布式事务、中间件。其实绝大多数场景根本用不着那么重。先理解清楚问题产生的根源再用最合适的几招去解决才能既稳定又省钱。1. 先弄明白不一致到底是怎么发生的1.1 一条正常读请求的完整链路缓存为什么能提升性能一句话就能说清读请求先到缓存缓存没有再去数据库拿回来之后回填缓存。下一次同样的请求直接打缓存不再碰数据库。这个机制下缓存是数据库的“影子副本”两者天然就是两份数据而不是一份。只要数据只读不写副本再多也没关系。但业务一旦有更新问题就来了数据库里的值变了缓存里还是旧值或者缓存删了但某个线程又把旧值写回去了。这个“旧值存活窗口”就是一致性问题最本质的来源。用生活里的场景类比就是办公桌上贴了一张便利贴写着“张三电话138xxxx”正式通讯录里已经改成“139xxxx”。你懒得查电脑看到便利贴就打过去结果打错人。便利贴不撕掉错误就一直存在。1.2 写请求的时序才是脏数据根源缓存不一致的触发点绝大多数不在读请求而在写请求和读回填的竞争。看一个非常经典的时序线程 A 读缓存缓存未命中于是去数据库查询拿到旧值 v1线程 B 更新数据库把 v1 改成 v2随后删除了缓存线程 A 这时才把读到的 v1 回填到缓存。结果就是缓存里是 v1数据库里是 v2后续所有读请求都会命中旧值直到缓存过期或者再次被删掉。这个时序问题是“读回填”和“写更新”两个动作没有原子性造成的单纯调整先后顺序并不能完全解决。还有一个常见场景业务代码里先更新数据库再更新缓存。更新数据库成功了但更新缓存时 Redis 超时或者代码里异常被吞掉缓存里还是旧值。等下次缓存过期才勉强恢复。这种情况在真实项目里并不罕见而且比并发时序更隐蔽因为日志里往往只有一条 Redis 超时不仔细看根本发现不了。2. 常见的缓存更新策略各有哪些坑2.1 Cache Aside为什么推荐“更新DB后删缓存”业界最常用的缓存模式叫 Cache Aside也叫旁路缓存。读路径很简单缓存命中就直接返回没命中就去查数据库查到后回填缓存。写路径则有一个关键决策更新数据库之后到底是更新缓存还是删除缓存。我的建议非常明确优先删除缓存而不是更新缓存。原因有两个。第一更新缓存存在并发覆盖问题。比如线程 A 和线程 B 同时更新数据库A 先写 v2B 后写 v3但网络延迟导致 B 的缓存更新请求先到 RedisA 的请求后到缓存最终变成 v2数据库却是 v3又是一次不一致。而删除缓存是幂等的删了就没了下次读请求会重新加载天然避开了“最后写的人覆盖了别人”的问题。第二更新缓存成本高。如果缓存里是一个复杂的聚合对象比如用户信息带着订单列表、积分、优惠券数据库只有订单字段变了更新缓存时却要重新组装整个对象白白浪费一次查询和序列化开销。删除缓存则什么都不用做代价极低。所以 Cache Aside 的标准写法就是更新数据库成功之后删除缓存如果删除失败需要补偿重试。千万别只在日志里打印一条错误就完事。2.2 Read/Write Through 和 Write Back 的取舍除了 Cache Aside还有三种常见模式Read Through、Write Through、Write Back。理解它们有助于在特定场景下做更合理的选择。Read Through 的意思是应用层不直接操作数据库而是由缓存组件负责把数据从数据库加载进来。比如用 Redis 配合自定义的 Cache Loader或者使用一些支持回源功能的缓存中间件。这种模式下应用代码更简洁但缓存组件本身变得复杂需要处理加载失败、并发回源等情况。Write Through 则是写请求先写到缓存由缓存组件同步写入数据库。保证缓存和数据库在写路径上是同步的一致性比后续几种都强但写延迟变高了因为每次写都要经过缓存再落库性能优势被削弱。Write Back 是反过来的写缓存成功就直接返回成功缓存组件后台异步把数据刷到数据库。性能极高适合写多读少的场景但风险也大如果缓存还没落库就宕机数据直接丢失。对金融、订单这类不能丢数据的业务基本不能用。2.3 四类策略对比速查表为了方便选型我把这几种策略放在一起对比。策略一致性读性能写性能实现复杂度适用场景Cache Aside最终一致有短暂窗口高中低读多写少绝大多数业务Read Through最终一致缓存负责加载高中中缓存层能自定义加载逻辑的场景Write Through同步一致写延迟高中低高一致性要求很高写并发不大Write Back弱一致可能丢数据高很高中日志、计数器等允许丢失的场景大多数业务系统选 Cache Aside 就够了。如果在写路径上想进一步压缩不一致窗口就要用到下一部分的几种进阶手段。3. 彻底解决不一致延迟双删、异步补偿与Binlog订阅3.1 延迟双删怎么算延迟时间延迟双删是面试高频题也是实际项目里简单有效的方案。它的核心思想是第一次删除缓存然后更新数据库等一小段时间后再删除一次缓存。为什么删两次回到前面的竞争时序线程 A 在读缓存未命中后可能在线程 B 更新数据库期间把旧值回填到缓存。延迟一段时间后第二次删除就是为了把这种“迟到的回填”清掉。延迟时间怎么定没有固定公式但有一个经验准则延迟要大于一次完整的读请求从缓存未命中到回填缓存所需的时间。一般来说100ms 到 500ms 比较常见。设置太小竞争线程还没写完删了等于白删设置太大不一致窗口被人为拉长体验变差。我之前在并发高的系统里取的是 300ms配合实际压测调整效果比较稳。不过延迟双删不是银弹。它只是把不一致窗口缩小到两次删除之间并不能做到强一致。而且第二次删除如果失败脏数据还是会存在。所以线上必须给删除缓存的操作加重试和监控不能只依赖二次删除。3.2 用消息队列把“删缓存”变成异步重试比延迟双删更工程化的做法是把缓存删除做成一个异步消息。更新数据库成功之后不直接在业务线程里删缓存而是发一条消息到 MQ由专门的消费者执行删除。这样做有三个直接好处。第一业务线程不用等待 Redis 响应不会因为缓存删除慢而拖累接口耗时。第二消费者如果删除失败可以根据 MQ 的重试机制反复执行直到成功。第三消息本身带有业务上下文消费者可以根据业务 ID 精确地删除对应缓存不容易漏删。使用普通消息时需要注意数据库事务还没提交就发了消息消费者可能读到旧数据。更可靠的做法是使用事务消息或者配合本地消息表数据库更新和消息记录在同一个本地事务里提交再由一个后台任务把消息投递到 MQ确认投递成功后才标记完成。这样就把“数据库更新”和“缓存删除”绑到了同一个事务语义里。3.3 基于Binlog的增量更新如果不想在业务代码里到处埋点还有一种更解耦的方案监听数据库的 Binlog解析出变更记录再根据变更去刷新或者删除缓存。最常用的中间件是 Canal它模拟 MySQL 从库协议实时接收 Binlog然后把变更事件推给消费者。这种方案的好处非常明显业务代码完全不需要关心缓存逻辑只要正常写数据库Binlog 就会忠实记录每一条变更。新增一个需要同步缓存的表只需要在 Canal 配置里加一个监听规则对旧代码零侵入。尤其适合表多、团队大、各业务线共用一套数据库的场景。代价是增加了基础设施。Canal 本身要部署还要考虑高可用和监控。如果 Binlog 消费延迟过高缓存不一致的时间也会变长所以需要配套延迟监控。数据量大的时候解析和序列化也会消耗一定资源需要给足资源。3.4 这些方案只能做到最终一致至少到目前为止没有任何一种通用方案能保证缓存和数据库的绝对强一致。原因很简单数据库和 Redis 是两个独立的系统分布式环境下不存在跨系统的原子事务。所谓强一致只能是数据库事务和缓存更新处于同一个事务边界但这会牺牲巨大的性能实际业务里很难接受。所以务实的做法是接受“最终一致”。目标是让不一致的窗口尽量短短到用户无感知短到对业务没有实质影响。窗口越短架构复杂度往往越高需要根据业务容忍度做平衡。纯粹追求理论上的强一致可能把架构拖到不可维护。4. 缓存失效三板斧穿透、击穿、雪崩4.1 三个概念的区别很多人把缓存穿透、击穿、雪崩混在一起其实它们是三个完全不同的问题。缓存穿透是查询一个根本不存在的数据。缓存里没有数据库里也没有每次请求都直接打到数据库。如果有人恶意构造一批不存在的 ID数据库会被拖垮。缓存击穿是某个热点 Key 在失效的瞬间大量并发请求同时打到数据库导致数据库压力突增。缓存雪崩则是大量 Key 在同一时间段集体失效或者 Redis 整体不可用导致海量请求直接落在数据库上。一致性问题和这三个问题经常纠缠在一起。比如热点 Key 击穿时多个线程同时回填缓存如果其中某个线程拿到旧数据并成功写入就会造成较长时间的不一致。所以做缓存治理时不能只盯着一致性击穿和雪崩的防护同样重要。4.2 击穿热点Key的互斥锁重建防止热点 Key 击穿最常用的是互斥锁。当缓存没有命中时不是每个线程都立刻去查数据库而是先尝试获取一个分布式锁。拿到锁的线程去数据库查询并回填缓存其他线程等待一段时间后重新读缓存。这样就保证同一时刻只有一个请求在重建热点 Key。锁的实现有很多种Redis 的 SETNX 加上过期时间是比较常见的做法。要注意设置合理的锁超时时间避免持锁线程数据库查询过慢导致其他线程等了很久又拿不到缓存进而引发超时。也可以在锁的超时时间内加入短轮询线程获取到锁之前先尝试读几次缓存如果已经有线程回填完成就不需要再去抢锁了。4.3 穿透布隆过滤器与空值缓存解决穿透常用的两个手段是布隆过滤器和空值缓存。布隆过滤器的原理是用一个很长的位数组和几个哈希函数快速判断一个 Key 是否可能存在。如果过滤器说“不存在”就直接返回不查数据库。如果过滤器说“可能存在”才去查数据库。它能挡住大量非法请求减少对数据库的无效访问。空值缓存则是另一种思路查询不到数据时也在缓存里写一个空值只是过期时间设置得很短比如 60 秒。后续相同 Key 的请求在空值过期前直接命中缓存不会打到数据库。需要注意空值缓存会让大量不存在的 Key 占用 Redis 内存所以过期时间要短同时最好加一层 Key 的规则校验尽量在源头筛掉非法请求。4.4 雪崩过期时间随机化和多级缓存防止雪崩最简单有效的操作是给缓存过期时间加随机偏移。比如一个热门的列表数据原本统一设置 600 秒过期现在设置成 600 秒到 900 秒之间的随机值。这样即使大批 Key 是同一时间写入的过期时间也不会叠在一起数据库就不会在同一瞬间收到海量请求。更深一层是本地缓存加分布式缓存的多级缓存方案。比如 Guava Cache 或 Caffeine 做第一级Redis 做第二级数据库在最底层。第一级缓存失效后再查第二级只有两级都失效才会打到数据库。这个方案能显著降低 Redis 不可用或者缓存雪崩时对数据库的冲击。代价是要处理多级缓存之间的一致性问题本地缓存需要更短的过期时间或者主动失效机制。过期时间随机化这件事看着简单很多团队却会忽略。我见过一次线上事故零点一到一批整点生成的 Key 统一过期数据库 QPS 从几百瞬间飙到上万接口大面积超时。加上随机偏移之后这类问题几乎绝迹。5. 结合分布式事务的一致性处理5.1 分布式事务与缓存的一致性边界当系统从单库升级到分库分表或者引入了微服务缓存和数据库的一致性问题会进一步升级成分布式事务一致性问题。这时候往往不是更新一个数据库和一份缓存而是多个服务、多个数据库、多个缓存同时参与一个业务动作。比如用户下单订单服务写订单库库存服务扣库存积分服务加积分每个服务可能都有自己的缓存。在分布式事务里最糟糕的情况是数据库主事务已经提交但某个服务的缓存更新失败或者分布式事务回滚了缓存里却已经写入了中间状态。比如扣库存成功了但订单最终取消数据库回滚了库存缓存里却还是扣减后的数字。这时候要有一个清醒认识Redis 的事务和数据库事务没有任何关系。Redis 的 MULTI/EXEC 只是把多个命令打包执行不能完成跨系统的回滚。所以不要试图用 Redis 事务去替代分布式事务正确的思路是让缓存操作支持幂等并且允许通过最终一致来收敛。5.2 本地消息表怎么落地解决分布式事务下的缓存一致性最实用的还是本地消息表方案。思路是在业务数据库里建一张消息表把“业务操作”和“发送消息”放在同一个本地事务里提交。事务提交后一个后台任务扫描消息表把状态为“待发送”的消息投递到 MQ。投递成功后才把消息状态改成“已发送”。举个例子扣减库存成功后本地事务同时写一条“库存变更商品ID100扣减数量1”的消息。后台任务把这条消息投递到库存服务库存服务更新自己的数据库并删除对应商品的库存缓存。如果投递失败消息会一直在表里后台任务下一次扫描会重新投递消费方处理消息时只要保证幂等就不会重复扣减。这个方案的好处是不需要引入额外的分布式事务中间件数据库本身就能保证消息不丢。唯一要做好的事是消息消费的幂等性。比如在消息表里带一个全局唯一的业务 ID消费之前先去 Redis 或者其他存储里查一下这个 ID 是否处理过处理过就直接返回成功。本地消息表配合缓存删除是我在多个项目里验证过的稳定组合。虽然多了一个后台任务但换来的可靠性非常值尤其是对账困难、用户感知强的核心链路。6. 实战排查与经验总结6.1 一个线上余额不一致的案例复盘有一年我们遇到过一个线上问题用户反馈余额显示不对App 上看到的金额比实际少了一笔过了十几分钟又恢复正常。一开始大家以为是前端缓存的问题后来排查发现就是经典的缓存删除失败。整个链路是这样的用户完成一笔消费订单系统更新数据库余额余额从 1000 元扣到 800 元。接着去删除 Redis 中的余额缓存但当时的 Redis 实例刚好在做主从切换删除命令超时。代码里虽然捕获了异常但只打了日志没有做重试。结果缓存里还是 1000 元的旧值用户每次打开 App 都从缓存读到旧余额。直到缓存过了 TTL 自动失效再次回填才恢复正常。这起事故的根因不是 Redis 主从切换而是“缓存删除失败后没有补偿机制”。从那以后我们对所有更新数据库后删除缓存的操作加了统一封装删除失败会先本地重试三次仍然失败则把 Key 写入一个延迟队列由后台任务再删除一次。连续失败超过一定次数还会触发告警。6.2 排查步骤清单遇到疑似缓存一致性问题我建议按下面的顺序排查效率会高很多。先确认缓存里当前是什么值数据库里是什么值判断不一致的具体方向。看缓存 Key 的过期时间如果设置得很长不一致窗口就越大优先检查。检查更新操作之后的缓存删除或更新逻辑是否执行了是否有异常被吞掉。检查是否存在“读回填覆盖新值”的并发竞争。可以参考日志时间线看缓存写入时间是否在数据库更新之后。看消息队列有没有积压或消费失败。如果用了异步删除消费失败也是常见原因。用 Redis 的慢日志或者监控看删除命令是否超时Redis 是否发生过主从切换、内存淘汰等事件。排查工具不必复杂。Redis 可以用monitor命令临时观察实时的命令流量也可以开 AOF 日志辅助分析。数据库侧可以用慢查询日志对比同一段时间的更新和查询。最重要的是把应用日志、数据库 Binlog、Redis 日志的时间线对齐很多问题一眼就能定位。6.3 我的几条实操心得第一缓存更新失败这件事一定要当成一等公民来处理而不是日志里的一个 WARN。我会在代码里把“删缓存失败”这个事件结构化地记录下来包含业务 Key、数据库变更前值、变更后值、失败原因。这些数据在做数据修复时非常有用。第二对于金额、库存、订单状态这类敏感数据我倾向于不依赖长 TTL。宁可设置 5 分钟或者更短的过期时间让数据库多承担一点读压力也不愿意用户看到错误数据。业务上的容错设计比技术上的极致缓存更重要。第三版本号是一个容易被忽略的杀手锏。给缓存的数据加上 version 字段更新数据库时把 version 加一写入缓存时带上当前 version。读取时如果发现缓存里的 version 小于数据库最新 version就拒绝使用并重新加载。这个方案能把不一致窗口压缩到极小代价也很低。第四缓存删除失败后的重试一定要设置上限不能无限重试。一方面避免死循环占用资源另一方面可以通过告警尽早暴露问题。我通常设置最多轮询重试 5 次然后进入人工介入流程。与其让系统一直带着脏数据运行不如早点报警叫人来处理。最后再分享一个小技巧给缓存 Key 增加业务语义而不是简单的数字 ID。比如user:balance:10086、order:status:20250101排查日志和问题的时候能省大量时间。这个习惯看起来不起眼但当你面对几十个服务、上千个 Key 的时候你会发现它比任何监控系统都管用。缓存和数据库一致性问题说到底不是单个技术点而是一整套从设计、编码到治理的工程习惯把这些习惯沉淀下来线上才会真的睡得安稳。