
先从我前阵子排查的一个线上问题说起。商品详情页的缓存里价格居然比数据库里新改的价格高了0.5元用户在下单页看到的价格和结算价不一致差点引发投诉。当时我们几个后端围着Redis和MySQL来回比对最后发现就是一次普通的更新操作触发了经典的“缓存与数据库更新顺序不一致”问题。说实话这类问题在CRUD业务里太常见了但凡是缓存数据库的组合就一定会碰到写操作时到底先动谁、怎么动才能让两边数据对齐的纠结。这篇博文我就把自己实践过的方案、踩过的坑、以及最后的排查套路完整梳理一遍希望能帮你少走弯路。这个问题的本质是缓存和数据库是两个独立的存储系统各自维护一份数据而业务代码在更新数据时很难保证两个系统在同一时刻完成写入。一旦并发请求穿插进来就可能出现数据库是新值、缓存是旧值或者反过来缓存是新值、数据库是旧值的状态。本文适合所有后端开发、架构师尤其是正在做电商、交易、配置中心等强一致场景的朋友参考。1. 为什么更新缓存和数据库会出现不一致1.1 缓存和数据库是两套生命周期的系统先理解一个基础事实缓存的存在是为了扛住高并发读让请求尽量不落到数据库上。Redis之类的缓存组件和MySQL这类关系型数据库本质上各自维护独立的数据副本它们之间没有内建的事务机制也没有分布式事务协议来保证每一次写入都原子完成。也就是说当你执行“更新数据库字段 更新缓存”这行代码时从操作系统和网络层面看这是两个独立的写操作中间可能发生任意数量的其他读写请求。这就好比你在两个记事本上同时记账一个本是数据库一个本是缓存。你准备把某个数字从10改成20先在第一本写上20再准备去第二本写20结果同事在你还没写第二本的时候把第二本的10读走了。他读到的是旧值。更麻烦的是如果另一个同事也在同时改同一个数字两个本的最终状态可能不一样一个写的是你改的20另一个写的是他改的30。所以当我们需要保证缓存与数据库最终一致时核心任务不是“让两边同时写完”而是设计一套写入顺序和失效策略使得在任意并发交错下最终两边都能收敛到正确的值并且用户读到的错误数据窗口尽可能短。1.2 先更新缓存还是先更新数据库两种经典闪失先更新数据库再更新缓存这是很多入门者最容易写的方案。乍一看很合理数据库为主缓存跟着同步更新。但实际操作中你会发现问题如果更新数据库成功而更新缓存失败那么缓存里就是旧值接下来的读请求全部命中旧数据。有人会想那失败重试不就好了但重试期间依然有大量读请求命中旧值而且如果缓存更新顺序晚于数据库提交中间还有并发读把旧缓存返回给用户的时间窗。更危险的是并发事务AB交错。假设有两个线程同时在修改同一个商品数据线程A要把价格改成50线程B要把价格改成80两个线程都先更新数据库数据库最终的值取决于锁或事务提交顺序但缓存更新未必按这个顺序来。比如A先更新数据库提交后值为50B后更新数据库提交后值为80。但B的缓存更新可能比A更快最后缓存里的值是50数据库是80。这种“数据库正确、缓存错误”的状态会一直持续到缓存过期或被再次刷新时间可能长达数小时甚至数天。再来看先更新缓存再更新数据库的方案。这个方案的问题更明显如果缓存更新成功但数据库更新失败则缓存里的新值无法落库后续读会一直读到库里不存在的新值产生“幻觉数据”。比如你改了一个用户昵称缓存显示新昵称但数据库写失败回滚了用户刷新后还是新昵称换个设备访问就是旧昵称造成严重的体验不一致。而且缓存是易失的有可能缓存被淘汰后重新回源数据库读到的又是旧值前后表现非常诡异。提示在绝大多数业务场景下缓存应该看成数据库的一份“临时冗余副本”而不是数据的第二主存储。更新时优先保证数据库正确再尽量让缓存失效或更新。这两种方案本质上都属于“双写”思路双写无法避免的就是部分失败和顺序不一致。所以业内更倾向于放弃“更新缓存”转而采用“删除缓存”策略也就是接下来要讲的Cache Aside模式。2. 主流通用方案Cache Aside 与延迟双删2.1 Cache Aside模式为什么不更新缓存而是删缓存Cache Aside旁路缓存是当前实践最广泛的缓存模式它的核心思想很朴素读的时候先查缓存缓存不命中再查数据库并回填缓存写的时候只更新数据库然后删除缓存。下一次读请求发现缓存没了会从数据库把最新数据读进来。这个模式最大的优点在于把“数据写入的时机”交给了下一次读操作避免了复杂的双写一致性问题。那为什么写操作不直接更新缓存而是简单地删掉呢两个原因。第一更新缓存是有成本的尤其是写入的数据需要经过复杂的计算、格式化、关联查询后才生成最终的缓存结构如果直接缓存更新每次数据库写操作都要重复一遍计算得不偿失。第二更新缓存的并发竞态远比删除缓存严重正如前面所说的两个线程同时更新数据库和缓存时谁先谁后很难控制而删除缓存则是一种“无条件失效”操作任何读请求只要没命中缓存就必须重新加载数据库值天然具备“以数据库为准”的收敛性。Cache Aside的经典流程代码大致是这样// 读流程 public Product getProduct(Long id) { // 1. 先读缓存 String key product: id; String json redis.get(key); if (json ! null) { return JSON.parseObject(json, Product.class); } // 2. 缓存未命中查数据库 Product product productMapper.selectById(id); if (product ! null) { // 3. 写回缓存设置合理过期时间 redis.setex(key, 3600, JSON.toJSONString(product)); } return product; } // 写流程 public void updateProduct(Product product) { // 1. 更新数据库 productMapper.updateById(product); // 2. 删除缓存让缓存失效 redis.del(product: product.getId()); }这个模式能解决大部分场景的问题但它并不是万无一失的。假如写操作删缓存之后、读操作回填缓存之前有另一个写操作再次更新了数据库那么读操作回填的可能是旧值或者一个读线程在查询数据库时拿到的是旧值刚准备回填缓存此时一个写线程更新数据库并删除了缓存读线程随后把旧值写回缓存。这两个窗口就是经典的“缓存被旧数据回填”问题。2.2 延迟双删的正确姿势和局限为了解决缓存被旧数据回填的问题很多团队会在Cache Aside基础上加一个“延迟双删”。所谓延迟双删就是在更新数据库之后先删除一次缓存然后等待一小段时间再删除一次缓存。第二次删除的目的是清除在第一次删除之后、延迟等待期间可能被读线程回填的旧缓存。延迟双删的Java实现大致是这样public void updateProduct(Product product) { // 1. 先删除缓存第一次删 String key product: product.getId(); redis.del(key); // 2. 更新数据库 productMapper.updateById(product); // 3. 延迟一定时间后再次删除缓存 executor.schedule(() - redis.del(key), 500, TimeUnit.MILLISECONDS); }这里有几个细节值得注意。延迟时间到底设多少经验值通常是几百毫秒到一秒不等具体取决于你的业务耗时分布。理论上延迟时间要大于一次“读数据库并回填缓存”的最坏耗时才能保证读线程已经完成了旧值回填后续删除动作才能把它清掉。如果延迟时间太短读线程还没回填完成第二次删除就执行了等于白删如果延迟时间太长中间窗口期的读请求会一直命中旧缓存影响体验。但延迟双删也有自己的问题它不是一个强一致方案只是把不一致窗口缩小到第二次删除之前的这段时间。如果第二次删除执行失败比如Redis超时、进程重启、线程池拒绝那旧缓存还是会残留在Redis里。而且对于高并发场景延迟双删里那次“先删缓存再更新数据库”的顺序也可能引入新的竞态删完缓存后、更新数据库前有读请求发现缓存未命中于是去数据库读到了旧值并回填等数据库更新完成后第二次删除才姗姗来迟。所以延迟双删在实际生产中的效果取决于你对删除失败的重试兜底而不是靠“两次删除”本身就能包治百病。2.3 重试机制与binlog订阅让删除“必须成功”既然删除操作可能失败那就必须引入重试机制。最简单的做法是在业务代码里把删除失败的任务投递到消息队列由消费端异步不断重试。但这会给项目引入额外的消息中间件依赖而且要注意消费端的幂等性避免重复删除导致缓存雪崩或额外开销。更优雅的做法是通过订阅数据库binlog来感知数据变更然后异步删除或更新缓存。比较成熟的方案是Canal MQ 缓存更新服务业务代码只负责更新数据库数据库的binlog会被Canal伪装成从库拉取解析出变更事件投递到MQ消费者拿到变更的主键ID后去删除对应缓存。这种方案把缓存删除动作和业务代码解耦了即使业务方忘了删缓存只要数据库变更了binlog一定会记录消费端一定能触发删除。不过binlog订阅方案也不是没有代价。你需要多维护一套Canal组件和消费者集群对于中小项目来说运维成本不算低。而且binlog是异步消费的从数据库提交到缓存删除会有至少几百毫秒甚至秒级的延迟如果业务对实时性要求极高可能还是要考虑同步删除 重试的折中方案。我个人的经验是小型项目直接用Cache Aside 同步删除 本地内存表记录失败Key 定时任务扫描重试足够用了大型项目或者团队已有Canal基础设施的再考虑上binlog订阅。注意延迟双删里的第一次删除如果放在了事务未提交之前要格外小心。有些同事喜欢在事务内先删缓存再执行数据库更新SQL最后提交事务。如果SQL执行失败回滚缓存已经被删此时没有新值回填问题倒不大如果事务迟迟不提交缓存已被删期间大量读请求打到数据库可能引发缓存击穿。所以缓存删除操作我建议放在事务提交之后至少也要在事务边界内谨慎设计执行顺序。3. 从线上事故复盘看一致性问题的真实场景3.1 一个典型的商品改价事故复盘说回到开头的改价事故。我们当时的业务是这样的商品表product存价格字段Redis里缓存了完整的商品详情JSON接口层面先从缓存读商品信息展示给用户。运营人员通过后台修改商品价格调用的是updateProduct接口代码大致是“先更新数据库再删除缓存”。这个逻辑看起来没什么问题但事故当天流量很大而且运营在很短的时间内连续两次修改了同一个商品的价格。大致时间线是这样的第一次修改时线程A把数据库价格从100改成120然后删除了缓存成功。第二次修改时线程B把数据库价格从120改成110然后删除缓存但这次Redis的删除命令超时了没有真正删掉。与此同时线程A删除缓存之后大量读请求涌入线程C查数据库时刚好查到了120这个中间价格于是回填了缓存为120。等到线程B删除缓存失败后缓存里的120就永久留了下来数据库最终是110用户端却一直是120。这次事故的根本原因有两个一是缓存删除命令没有重试机制删除失败后没有兜底二是读请求回填缓存时数据库可能正处于中间状态比如事务还没提交完成就查到了快照数据导致旧值被回填。复盘后我们做了两个改动更新数据库后增加延迟双删 删除失败记录日志并投递一个异步重试任务同时读回填缓存时增加数据库层面的版本号判断如果版本低于当前值则丢弃本次回填结果。3.2 为什么漏掉缓存更新的代码如此常见除了技术窗口本身我在排查过程中发现一个更普遍的问题很多漏掉缓存失效的场景根本不是什么高并发竞态而是纯粹的人为疏忽。最常见的几种情况包括新增了一个数据库写接口但忘了在写完之后删除或刷新相关缓存。比如开发同学在新写了一个批量导入接口时只关注了数据库的写入没有意识到会改变列表页缓存的数据。数据库表结构变更后缓存Key里存储的字段已经过期但代码里仍在读旧结构并尝试反序列化导致缓存中数据解析失败回源数据库又拿到了新值。业务上用了多级缓存一级本地缓存如Caffeine或MyBatis本地缓存、二级Redis缓存开发只清理了Redis却漏掉了本地缓存导致应用节点之间数据不一致。事务也是个大坑。有的同学在事务里执行了“更新数据库 删除Redis缓存”两个操作事务提交成功后发现Redis删除命令因为网络抖动失败了但代码已经走到了事务提交后的正常返回异常被吞掉缓存过期时间又设置得特别长比如24小时于是这个脏数据能存活一整天。针对这种情况我强烈建议团队建立一套缓存操作规范所有修改类接口必须明确写清楚“更新了哪些表、影响了哪些缓存Key、如何失效、失败如何重试”并在代码评审时重点检查。缓存Key一定要有统一的构建工具类不要散落在业务代码里随手new String拼接。3.3 分布式环境下缓存与数据库的“双主幻觉”还有一个容易被忽视的场景就是在读写分离或分库分表的数据库架构下缓存的一致性更复杂。假设你主库写入后删除缓存但缓存Key被路由到了另一个分片消费者读缓存后又回源到了从库而从库因为主从延迟还是旧数据那么缓存回填的就是旧值。这种问题不是缓存与数据库顺序不一致而是“数据库内部本身出现了短暂不一致”被缓存放大了。在这种架构下我更推荐的做法是对一致性要求比较高的数据比如价格、库存读操作尽量走主库或者强制走从库时增加延迟容忍控制缓存写入时可以在数据上附带一个更新时间戳或版本号回填时如果发现缓存中的版本号比数据库查询结果的版本号还要新则说明数据库查询可能是旧数据这时候要放弃回填等下一次读请求再来获取新值。这个方案我在后面的版本号章节会详细展开。4. 进阶手段锁、版本号与最终一致性的取舍4.1 用分布式锁把并发写变成串行写如果业务场景对一致性的要求特别高并且并发写比较集中在同一个Key上可以考虑用分布式锁来串行化读写操作。简单来说当线程要更新某个商品数据时先尝试获取一个针对该商品ID的分布式锁拿到锁之后执行“更新数据库 删除缓存”释放锁读线程在缓存未命中去查数据库之前也可以尝试获取同一个锁确保它不会在一个写线程执行的中间状态去查库回填。使用Redisson等成熟客户端代码看起来并不复杂public void updateProductWithLock(Product product) { String lockKey lock:product: product.getId(); RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // 等待锁最多3秒持有锁默认10秒 locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException(获取锁超时请稍后重试); } productMapper.updateById(product); redis.del(product: product.getId()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(更新被中断, e); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }用锁的好处是可以直接消除“读回填旧值”这个竞态窗口缺点是性能下降明显而且锁粒度如果不小心设置成全局锁等于把整个系统的并发能力直接拉没了。我在实际项目里通常只对“价格、库存、状态”这类高敏感字段加锁普通商品标题、描述之类根本不需要。还有一个细节持有锁的时间要仔细评估最好加监控。如果某个线程持锁后处理数据库事务过慢锁自动过期其他线程依然能进来操作同一个Key等于锁形同虚设。可以考虑使用看门狗机制Redisson默认会为锁续期但续期本身也可能引发新的复杂度所以在高并发敏感数据上我更倾向于用版本号方案而不是锁。4.2 版本号方案让旧数据不可能覆盖新数据版本号方案是我比较推崇的一种做法尤其适合“更新次数很少、但读请求非常多”的数据。基本思路是数据库表里增加一个version字段每次更新数据时version自增缓存中存储的JSON里也包含这个version字段当读请求回填缓存时如果发现数据库返回的数据version比缓存里已有的version旧就丢弃这次回填结果。举个例子假设商品表的版本号初始是1-- 更新价格时version字段自增 UPDATE product SET price 110, version version 1 WHERE id 123;读请求回填缓存时通常会先从Redis里取出旧缓存如果有再查数据库如果查到的新数据版本号小于等于旧缓存中的版本号说明这个数据是延迟或错序数据直接抛弃不回填public Product getProductWithVersion(Long id) { String key product: id; String cacheJson redis.get(key); if (cacheJson ! null) { return JSON.parseObject(cacheJson, Product.class); } // 从数据库查询带上版本号 Product dbProduct productMapper.selectById(id); if (dbProduct ! null) { if (cacheJson ! null) { Product cacheProduct JSON.parseObject(cacheJson, Product.class); // 数据库版本号更旧不回填 if (dbProduct.getVersion() cacheProduct.getVersion()) { return cacheProduct; } } // 否则回填缓存 redis.setex(key, 3600, JSON.toJSONString(dbProduct)); } return dbProduct; }这个方案的优越之处在于不管线程怎么交错只要数据库的版本号是单调递增的旧数据就永远无法覆盖新数据。即使缓存删除失败下一次读到缓存后发现缓存里的版本号落后于数据库版本号我们可以选择主动更新缓存或者触发一次缓存重建。版本号方案也有变体不一定要在表里加字段可以单独维护一个版本号Redis Key统一管理多个字段的变更。但单独维护版本又引入了新的缓存一致性风险所以如果你的数据库表和字段允许直接加字段是最省事的。需要注意的是在关系型数据库层面version自增必须和更新字段在同一个UPDATE语句里完成保证原子性绝不能分开执行。4.3 读多写少场景适当延长缓存 定时刷新不是所有数据都要追求秒级一致。有些业务的读多写少场景比如首页推荐位、榜单、配置项、商品类目树这些数据更新频率极低用户对一致性的容忍度也比较高比如差几分钟没问题。这种场景下最简单可靠的做法是数据库更新完成后不删缓存而是让缓存自然过期或者用一个定时任务主动刷新缓存。我见过不少团队在这里过度设计了又是延迟双删又是订阅binlog结果业务上根本不需要那么强的一致性。理性分析一下业务需求就够了如果允许5分钟以内的数据延迟那我建议把缓存过期时间设为5分钟数据库更新之后只修改一个“配置版本号”定时任务每分钟扫描一次配置版本号发现变化就全量重建相关缓存。这套方案并发压力小、逻辑简单出问题的概率很低比一大堆花哨技巧更稳。当然定时刷新方案也有代价缓存过期瞬间如果有高并发读可能造成缓存击穿。解决办法是加互斥锁当发现缓存过期后只允许一个线程去数据库加载其他线程短暂等待或者返回旧值兜底。这个套路很成熟我再多说一句就算用了Cache Aside我也建议给缓存设置一个合理的过期时间千万不要依赖“删除缓存”这个动作作为唯一失效手段因为代码bug、网络故障、人工误操作随时可能发生过期时间是最后一道安全网。5. 排查缓存不一致的实操工具箱5.1 如何判断问题到底出在缓存还是数据库遇到用户反馈“数据不对”第一件事不是改代码而是先确认缓存里实际存的是什么、数据库里实际存的是什么。我常用的排查手段是直接比对两边数据# 查Redis中的缓存值 redis-cli get product:123 # 查MySQL中的最新值 mysql -h host -u user -p -e SELECT id, price, version FROM product WHERE id 123;如果发现缓存和数据库的值不一样再继续缩小范围是缓存多了一个旧值还是数据库多了一个新值如果缓存的值是旧的说明大概率是更新顺序或删除失败问题如果缓存的值是新的但数据库是旧的说明可能是先写缓存后写数据库并且数据库写入失败或回滚了。锁定方向之后再结合日志去排查当时是否有异常、是否有并发请求思路会清晰很多。还有一种情况需要特别注意缓存里存的结构化和数据库字段并不是一一对应可能存在加工逻辑。比如缓存里存的是“商品基本信息 收藏数 评论数”其中收藏数来自另一个表。那你在排查时要确认到底是主表数据不一致还是关联数据不一致。我的习惯是每一个缓存写入的地方都打上日志至少包含Key、数据库数据版本号、写入时间这样排查时能直接从日志里看到是谁在什么时间点把什么值写入了缓存。5.2 我能直接复用的监控与对比脚本对于核心链路我会额外加一套离线对账任务定期扫描一部分缓存Key和数据库数据做一致性校验发现不一致就告警。对账脚本的核心逻辑很简单分三步从Redis中拉取一批缓存Key如果是JSON结构则解析出主键和业务字段从数据库批量查询这些主键的最新数据逐个比对两个来源的关键业务字段是否一致。这里分享一个简化的Python对账脚本思路实际使用中你需要按自己的数据格式调整字段映射import redis import pymysql def check_consistency(keys): r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) conn pymysql.connect(host127.0.0.1, userroot, passwordxxx, dbshop) cursor conn.cursor() for key in keys: cache_val r.get(key) if not cache_val: continue # 假设缓存的JSON中有id和price字段 cache_data json.loads(cache_val) product_id cache_data[id] cursor.execute(SELECT price FROM product WHERE id %s, (product_id,)) row cursor.fetchone() if row and float(row[0]) ! float(cache_data[price]): print(f不一致: key{key}, cache_price{cache_data[price]}, db_price{row[0]}) cursor.close() conn.close()除了主动对账被动监控也很重要。在Redis侧关注缓存删除失败的异常日志在MySQL侧关注慢查询日志可能说明缓存失效后大量请求打到数据库在业务侧关注接口响应时间波动。它们结合起来能帮你快速发现缓存一致性问题而不是等用户投诉了才被动排查。5.3 常见问题速查表从现象到解法我把平时最常遇到的缓存与数据库不一致问题整理成一个速查表按现象分类直接给出定位思路和解决方案方便你遇到问题时快速对照。现象可能原因定位方式解决思路缓存是旧值数据库是新值更新数据库后缓存删除失败比较Redis与MySQL值查删除日志增加重试机制延迟双删binlog订阅缓存是新值数据库是旧值先写缓存后写数据库数据库失败回滚查数据库错误日志与回滚记录调整更新顺序保证数据库优先不同节点读到不同值本地缓存未清理或清理失败对比多节点缓存值使用分布式缓存统一失效广播缓存偶发回填旧值读回填与写删除并发交错观察日志中回填顺序延迟双删 版本号校验缓存过期瞬间大量请求打到DB未加互斥锁导致击穿观察慢SQL和Redis连接数加互斥锁加过期空值保护数据一段时间后自动恢复缓存过期时间兜底但业务无法容忍观察TTL与恢复时间缩短过期时间或主动刷新注意以上所有方案都有一个共同前提——缓存只是数据库的性能加速层不是数据的权威来源。任何时候你都要接受“缓存和数据库最终一致而不是强一致”的设计理念。如果业务真的要求强一致那就不要用缓存直接查数据库或者引入分布式事务但那会付出巨大的性能和复杂度代价一般业务场景不值得。6. 最后再聊点实在的说句老实话缓存与数据库更新顺序不一致这个问题不会有某个方案能一劳永逸地解决。它更多是一个“风险控制”问题在代码层面选择尽量合理的顺序和失效策略加上失败重试的兜底再通过监控对账及时发现问题最后依靠版本号之类的机制阻止旧值覆盖新值。能把这几层做扎实线上基本不会因为缓存一致性问题出大事。根据我的实践经验有几个小技巧想分享一下所有缓存Key务必带上业务前缀和ID结构如product:123或cart:user:456:sku:789别用裸ID。否则排查和清理都方便还能避免和其他业务Key冲突。写操作统一封装一个工具方法比如CacheManager.evict(key)内部实现删除 失败日志 异步重试避免在每个业务代码里手写Redis删除命令。如果项目用Spring务必注意MyBatis一级缓存和二级缓存的坑。默认一级缓存是SqlSession级别的如果你在同一个事务里先查询后更新再查询可能读到的是本地缓存里的旧值而不是数据库新值。必要时需要手动清空SqlSession缓存或者规避。定时对账不用太频繁每天跑一次、抽核心5%的数据就够了主要起保险作用。重点还是要在业务代码里减少产生不一致的机会。对于那种“读了多次都一样但就是和数据库对不上”的顽固缓存别犹豫直接对照缓存过期时间如果过期时间很长就直接手动清掉让流量自然重建缓存。缓存一致性没有一个“银弹”方案但把原理搞清楚、把常见方案吃透、再配合一套监控和兜底机制你完全可以让它在生产环境中变得可控。希望这篇博文能帮你避开那些我踩过的坑在项目里少一点“线上数据对不上”的深夜排查。