MySQL与Redis缓存一致性实战:从原理到架构选型
发布时间:2026/10/13 11:04:10 锦皓数字建站

做后端这几年我算是把“缓存和数据库数据对不上”这件事踩透了。团队经常半夜被叫醒一查原因十有八九是 Redis 里的旧数据还在服务而 MySQL 里的记录早就改了。这问题听着朴素真要讲清楚背后牵扯到读写顺序、并发窗口、失败补偿、主从延迟、消息可靠性一整串东西。这篇文章我想围绕 MySQL 与 Redis 的数据一致性问题把从原理到落地的完整链路梳理一遍。先解释为什么这两个存储天生就对不齐再对比几种主流的读写策略包括 Cache Aside、延迟双删、异步补偿、binlog 订阅这类方案然后结合一个商品详情页的实操案例把代码和参数怎么选讲明白最后把我排查过的典型故障和沉淀下来的避坑经验一并分享出来。不管你是在校学生准备面试还是刚接手缓存模块的后端开发或者正在设计新项目的缓存方案这篇文章都可以当作一份“抄作业”的参考。1. 先想清楚为什么 Redis 和 MySQL 天然就是对不齐的1.1 两种存储的“性格”差异MySQL 和 Redis 看起来都是存数据的地方但俩玩意儿的定位完全不同。MySQL 是账本记录的是业务事实讲究持久化、事务、ACID数据以 B 树的形式落在磁盘上。Redis 更像是便利贴放在内存里追求的是极致的读写速度哪怕挂了也能从持久化文件恢复一部分数据但它天生不承担“业务事实记录”的责任。这种定位差异决定了它们在写入路径上的行为截然不同。MySQL 每次写都要走事务日志、缓冲池、磁盘刷盘性能和可靠性绑定在一起慢是慢但稳。Redis 的写操作基本都是纯内存操作单线程模型下每秒能扛几十万次写Optimistic 一点说它本身也不打算跟你保证“一旦写入就永久可靠”持久化更多是兜底手段而不是写路径的核心保证。这就衍生出一个非常关键的事实缓存和数据库是两份独立的数据没有任何机制保证它们原子地同时变更。你去改 MySQL 里的商品价格Redis 里那条老价格不会自动失效除非你代码里显式去删、去同步。所谓一致性问题本质上就是这两份数据的变更没有对齐造成的时间差和脏读。1.2 双写路径上的不一致是怎么产生的既然两份数据要同时维护就必然有一条“双写路径”。以最常见的 Cache Aside 模式来说写请求要做两件事更新数据库再删除缓存读请求要做三件事查缓存、缓存未命中查数据库、把结果回填到缓存。问题就出在这些步骤不是一个大事务中间任何一步出问题或者两个请求交错执行就会出现数据对不上的情况。我把最常见的几种不一致场景整理出来你对照着看会非常有体感场景发生时序结果先更新数据库后删缓存但删除失败MySQL 已更新Redis 删除命令失败或超时缓存长期存有旧值直到 TTL 过期先删缓存后更新数据库期间有读请求缓存被删旧值被读回并回填然后数据库才更新缓存里又变成了旧值且可能长时间不变缓存回填时数据库主从延迟缓存过期回填请求路由到从库从库还没同步到最新数据缓存被填成旧值继续服务多实例并发写同一 key两个线程各自执行删缓存与更新库顺序交错缓存可能最终保留了过期数据我见过很多团队在方案选型上争论不休说到底都是在跟上面这几张时间窗口作斗争。要解决一致性问题首先要接受一个现实在缓存这个场景里我们追求的绝大多数时候不是强一致而是“最终一致”并且把不一致的窗口压到业务可接受的范围。至于那些业务上绝对不能容忍不一致的比如库存扣减、资金流水我会在后面讲到最稳的办法其实是不要走 Redis 缓存或者干脆用锁把写路径串行化。2. 缓存读写策略怎么选四种主流方案对比我经常看到群里有人问“你们用缓存双删了吗”、“你们是不是先更新库再删缓存”其实这几个方案不是互斥的更像是层层递进的关系在不同的一致性和运维复杂度之间做取舍。我先用一张表给你拉开全貌再逐个拆细节。方案核心动作一致性强度失败处理成本适用场景Cache Aside旁路缓存更新库再删缓存最终一致窗口小删除失败需额外补偿绝大多数读多写少场景延迟双删删缓存-更新库-延迟删缓存压缩旧值回填窗口第二次删除失败仍要盯单机 Redis、写并发不极端MQ 异步删除更新库后发消息删缓存最终一致链路更长需要消息重试和幂等写入量较大、可接受异步订阅 binlog 同步解析数据库 binlog 同步缓存最终一致天然有序中间件维护成本高不想侵入业务代码的场景表格里的后两行已经算是“工程化进阶”但不管你最后选了哪种第 2.1 到 2.3 节里讲的底层逻辑都绕不开所以我建议先把这几个基础动作理解透。2.1 Cache Aside 为什么是默认选择Cache Aside也叫旁路缓存是应用层缓存最经典、最不容易出错的策略。读路径先查缓存命中就直接返回没命中就去查数据库再把结果写回缓存。写路径只更新数据库然后删除缓存让下次读取时重新加载。很多人会问为什么写路径是“删除缓存”而不是“更新缓存”这个问题我第一次接触时也很困惑后来在并发场景里吃过亏才明白。假如两个线程同时更新同一个用户的昵称线程 A 先更新数据库线程 B 再更新数据库最终库里存的是 B 的新值。如果我们是“更新缓存”的策略线程 A 可能先把缓存更新成自己的新值线程 B 再把缓存更新成自己的新值如果顺序恰好和数据库写入顺序相反缓存就会留下一个和库不一致的中间值。但如果是“删除缓存”事情就简单得多下一次读请求重新从库里加载拿到的必然是最新值。删除一个不存在的 key 和删除一个存在的 key成本都极低却天然规避了并发写覆盖的问题。另一个原因是很多场景下缓存放的并不是数据库里的原始行而是一个聚合后的视图。比如商品详情页可能要聚合商品主表、SKU 表、库存表、促销表的数据如果你去“更新缓存”你得把这些数据全部查一遍再拼装代价非常大。但删除缓存只需要一个 DEL 命令下次读的时候再拼装一遍就行了。一删一读把复杂度转移给了“读多写少”场景里更常见的读路径。不过 Cache Aside 有一个绕不开的软肋删除缓存这步失败怎么办。数据库更新成功了但 Redis 的 DELETE 因为网络抖动或者超时没有执行成功缓存里就会一直留着旧值直到 TTL 到期。所以后面所有进阶方案本质上都是在给“删除失败”和“并发回填旧值”这两个风险上保险。2.2 先删缓存再更新库这个坑藏得很深网上很多文章会提醒你写路径里应该“先更新库再删缓存”我完全同意但很多人不知道为什么“先删缓存再更新库”不行。这里有一个非常隐蔽的窗口我当年排查 bug 时找了好久才定位到。假设你现在执行的是“先删缓存再更新库”请求 A 删除缓存 key。请求 A 开始更新数据库这一步可能耗时几十毫秒甚至更久。在这段时间里请求 B 来读取这个 key缓存未命中于是去数据库里查查到的是还没被 A 更新完的旧值然后把这个旧值回填到缓存。请求 A 完成数据库更新库里已经是新值了但缓存里已经被 B 填满了旧值。除非这个 key 有 TTL 并且恰好很快过期否则缓存会在很长一段时间里都是错的。这个问题的可怕之处在于它不是靠重试就能解决的。因为缓存里已经塞进了旧值你就算再删一次缓存、再更新一次库只要中间有读请求插进来同样的窗口还会出现。所谓并发问题本质就是慢操作窗口里的交错。这也是为什么很多资深开发会建议“先更新库再删缓存”。这里的风险只剩一个删除缓存失败。但这个失败是孤立的、可探测的你可以把删除失败的 key 扔进重试队列比处理那种“窗口型”的并发回填要简单得多。所以我把这个顺序问题放在最前面是因为它是所有缓存一致性讨论的地基。2.3 延迟双删补上旧值回填的窗口延迟双删名字听起来高大上其实就是一种很朴素的补偿思路先删缓存更新数据库等上一个“慢查询回填”的窗口过去后再删一次缓存。流程如下删除缓存 key。更新数据库。休眠一段时间。再次删除缓存 key。第二次删除的目标就是把第 2.2 节里那个被并发读请求回填的旧值清掉。关键问题来了休眠时间怎么定如果设得太短可能旧读请求还没完成回填第二次删除就执行了等于白删设得太长系统在这段时间里会持续有缓存未命中数据库压力变大。我在实际项目里一般会先做一轮压测或者看监控统计读请求从缓存未命中到完成回填的耗时取 P99 值再留 20% 到 50% 的余量。举个例子如果回填耗时 P99 是 60 毫秒那我就把延迟双删的时间定在 200 到 500 毫秒保证绝大多数慢请求都已经结束。如果业务允许也可以直接设到 1 秒对这个场景来说性价比非常高。ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); public void delayDeleteCache(String key, long delayMs) { scheduler.schedule(() - { try { redisTemplate.delete(key); } catch (Exception e) { // 第二次删除如果失败需要进入重试队列不能就此放弃 retryQueue.offer(new CacheDeleteJob(key)); } }, delayMs, TimeUnit.MILLISECONDS); }你可能会觉得延迟双删不是会有一段时间缓存是空的吗没错但我们要的是最终一致这个“空窗期”其实本身就是可接受的。相比让旧值长时间存活短暂地让请求落到数据库反而更安全。唯一需要警惕的是如果第二次删除失败了这套方案就只剩下“过期时间”兜底了。所以延迟双删并不是银弹它只是把旧值回填的窗口压缩了并没有根治删除失败的问题。3. 更工程化的同步方案异步补偿与 binlog 订阅3.1 异步补偿把删除动作装进消息队列如果写 QPS 很高或者你不想在业务代码里塞一坨延迟删缓存的逻辑可以引入消息队列把“删缓存”这件事从同步路径里摘出去。大体流程是更新数据库成功后发送一条“key 已变更”的消息消费者收到消息后去删除缓存删除失败就重试。这里的核心难点在第一步如何保证“更新数据库”和“发送消息”这两个动作的原子性。如果你直接在业务代码里先 update 数据库再发消息发送消息时进程突然挂了、消息队列不可用消息就丢了缓存永远得不到删除。反过来先发消息再更新数据库消费者可能提前把缓存删了但数据库还没来得及更新又会产生不一致。业界常见的做法有两类。一类是本地消息表在同一个数据库事务里写入业务数据的同时写入一张待发送消息表然后有个后台任务扫描这张表把消息发到 MQ收到确认后标记为已发送。另一类是支持事务消息的 MQ比如 RocketMQ 的事务消息先发一个半消息再执行本地事务事务成功就提交消息事务失败就回滚消息。这两个方案本质都是把“本地操作和消息发送”纳入同一个可回滚的上下文。选择 MQ 方案的好处是缓存删除不再阻塞主流程遇到 Redis 抖动的时候消息还可以在队列里等一等消费端配合重试机制可靠性比同步删除高很多。但代价也很明显链路变长了你要处理消息的重复投递消费端必须做好幂等消息消费顺序也可能乱比如两个更新同一个 key 的消息先后到达如果后一个先被消费缓存就可能被旧值覆盖。应对方法是在消息里带版本号或更新时间戳消费端判断是否比当前值新再决定要不要删。3.2 binlog 订阅让数据库反过来通知应用还有一种思路更彻底既然所有数据变更最终都会落到 MySQL 的 binlog 里那应用就不用显式在业务代码里同步缓存了直接监听 binlog解析出变更事件再决定是删除缓存还是回填缓存。社区里有一类开源中间件就是干这个事的它们伪装成 MySQL 的从库订阅主库的 binlog然后推送给业务方。binlog 订阅方案最大的优势是零侵入。业务代码里只需要关心写数据库不用管缓存同步哪怕是老系统改造也能在不改业务逻辑的情况下给热点数据加上缓存。另一个天然优势是顺序性良好binlog 是按照事务提交顺序写入的消费端按顺序处理就不会出现前面说的“消息乱序”问题。它不是没有代价。首先你得多引入一个中间件这个中间件本身要部署、监控、处理主从切换的场景运维成本不低。其次同步链路有延迟从数据库写入到 binlog 被解析再到缓存变更一般是毫秒级到秒级如果你的业务对一致性要求很高这个延迟可能不可接受。最后消费端处理变更事件时要注意幂等因为 binlog 可能因为重复投递或者重复消费产生重复事件。另外一个非常容易踩的坑是如果你用 binlog 去“更新缓存”而不是“删除缓存”一定要在数据里带版本号或更新时间防止老事件晚到以后覆盖新值。3.3 强一致诉求怎么办别硬上分布式事务聊完上面的方案肯定有人会问那有没有办法做到强一致比如数据库和缓存在同一个事务里同时成功或失败现实是想靠分布式事务把 MySQL 和 Redis 绑在一条两阶段提交里成本极高效果还很差。Redis 本身不支持和 MySQL 形成标准的全局事务即便你强行锁住所有读请求让写请求串行执行性能也会退化成和直接查库差不多缓存也就失去了意义。我给个非常务实的建议如果业务真的要求强一致不要试图用缓存方案来兜底而是直接把缓存从这条链路上拿掉。比如库存扣减你就老老实实走数据库事务配合乐观锁或行锁必要时在 Redis 里做限流和排队把并发挡在数据库外面。不要把 Redis 当成数据库的影子它更适合承载“可以接受轻微过期”的数据。共识是缓存一致性问题方案的本质都是在一致性、可用性和性能之间找平衡点没有免费的午餐。4. 实操案例商品详情页缓存同步的落地流程4.1 项目背景与一致性目标设定前几节讲了不少原理这里我把一套真实用过的商品详情页缓存同步方案拆开讲讲落地的全流程。场景是某个电商系统的商品详情页商品的基本信息、价格、卖点等变更频率很低但读流量非常大热点商品的详情页 QPS 能到几千甚至上万。这种场景非常适合上缓存。我们的目标不是强一致而是商品信息变更后最多容忍 1 秒内出现旧数据。不能出现“长时间不变”的脏缓存否则客服会疯掉。缓存失效导致的数据库压力要在可控范围内。基于这些目标我选择的是“Cache Aside 延迟双删 删除重试 TTL 兜底”的组合拳没有上 MQ 和 binlog 订阅因为对于一个详情页场景来说这套复杂链路引入的运维成本大于收益。技术方案不是越重越好是越合适越好。4.2 关键代码与参数计算先看核心更新接口的骨架这里用 Java 风格描述思路对任何语言都适用public void updateProductDetail(Long productId, ProductDetailVO newDetail) { String cacheKey buildKey(productId); // 第一步先删除缓存让读取方立即 miss // 这里不用 update是因为聚合详情拼装成本高删除更划算 Boolean deleted tryDeleteCache(cacheKey); if (!deleted) { // 删除失败进重试队列不能放弃 retryQueue.offer(new CacheDeleteJob(cacheKey)); } // 第二步更新数据库 productMapper.updateById(newDetail); // 第三步延迟双删兜住旧值回填窗口 scheduler.schedule(() - { Boolean secondDeleted tryDeleteCache(cacheKey); if (!secondDeleted) { retryQueue.offer(new CacheDeleteJob(cacheKey)); } }, 1000, TimeUnit.MILLISECONDS); } private String buildKey(Long productId) { return shop:product:detail: productId; } private Boolean tryDeleteCache(String key) { try { // Redis 6.0 对大 key 可用 UNLINK避免阻塞主线程 return redisTemplate.unlink(key); } catch (Exception e) { // 记录日志进入补偿流程 log.error(delete cache failed, key{}, key, e); return false; } }为什么延迟时间选了 1000 毫秒因为当时我们统计过缓存回填的 P99 耗时在 60 毫秒左右理论上延迟 300 毫秒就够但我把值放得宽松一些考虑到商品详情聚合查询偶尔会因为数据库慢查询把耗时拉到几百毫秒1 秒可以覆盖绝大多数慢场景。同时又不会让用户觉得数据半天变不过来。TTL 怎么设详情页缓存我设了 5 分钟并在基础值上加一个随机的 0 到 60 秒浮动。固定 TTL 有个坏处热点 key 会在同一个时间点集体过期造成缓存雪崩。加随机浮动之后过期时间在 300 到 360 秒之间均匀分布数据库压力就不会瞬间拉满。缓存 key 的命名也要想清楚。我用的是shop:product:detail:123冒号分隔符让 key 在 Redis 里有天然的命名空间排查问题时可以用scan shop:product:detail:*来定位。当然线上不建议用 SCAN 高频扫全库但应急排查时这个前缀结构很有用。下面再补一个重试消费者的思路。重试队列我用的是 MQ简单场景也可以用 Redis 的 List 结构当队列用。消费端逻辑很简单收到任务就尝试删除失败就重试超过最大次数就告警人工介入Component public class CacheDeleteRetryConsumer { RabbitListener(queues cache-delete-retry-queue) public void onMessage(CacheDeleteJob job) { boolean deleted tryDeleteCache(job.getKey()); if (!deleted job.getRetryCount() 3) { job.setRetryCount(job.getRetryCount() 1); retryQueue.offer(job); } else if (!deleted) { // 到这里就要发告警了大概率是 Redis 连接或 key 本身有问题 alertService.send(cache delete failed after retry: job.getKey()); } } }这套方案跑下来线上基本没出现过长时间脏数据的问题。每次商品信息更新读请求最多在延迟双删的空窗期里多打一次数据库然后缓存就会在下一次读取时重建。这个代价换来的是一致性窗口被压缩到了 1 秒内完全可以接受。5. 常见问题与故障排查实录5.1 缓存击穿、穿透与雪崩这些问题会和一致性一起出现我遇到过好几次团队在排查一致性问题时发现根子其实是缓存雪崩或者击穿。这三个问题虽然主要说的是“可用性”但一旦发生数据库压力剧增慢查询变多缓存回填耗时变长又会进一步拉大一致性的窗口堪称连锁反应。缓存穿透查一个必然不存在的 key缓存永远不命中请求直接打到数据库。解决方案是缓存空值或者用布隆过滤器拦截。缓存击穿某个热点 key 过期的瞬间大量请求同时来查全部打到数据库。解决方案是加互斥锁让只有一个请求去重建缓存其他请求等锁或者直接返回旧值。缓存雪崩大量 key 在同一时间段集中过期数据库被一波流量冲垮。解决方案就是前面说的 TTL 加随机值或者对热点 key 做二级缓存。从一致性角度看缓存重建耗时越长延迟双删需要等待的时间就越长否则旧值回填的窗口就越难覆盖。所以把可用性问题先治理好是在给一致性方案减负。5.2 MySQL 主从延迟给缓存回填挖的坑这个坑我印象特别深。当时我们为了降低主库压力读请求默认走从库缓存回填也是从从库读。某次 DBA 做维护主从同步出现秒级延迟结果商品改价之后缓存过期重建时读到了从库的旧价格把旧值又填回去了。表面上看起来像是缓存删除没生效排查了很久才发现是回填源的数据库读到了旧数据。这个问题比普通的并发回填更隐蔽因为代码逻辑完全没问题问题出在数据源本身。解决思路有几个一是对一致性要求高的数据强制回填时读主库哪怕读压力大一点也值二是延迟双删的时间要继续拉长覆盖主从延迟的峰值三是如果你用了 binlog 订阅同步缓存这类问题天然规避因为 binlog 是从主库视角产生的。但要注意如果业务侧读取回填混用了从库binlog 订阅更新缓存也可能读到旧值最好统一回填来源。5.3 删除失败与对账机制缓存删除失败是很多线上事故的直接元凶。常见的失败原因包括Redis 连接池耗尽、命令超时、网络分区、key 太大导致删除命令阻塞。单纯的“捕获异常 重试”只能解决瞬态故障如果 Redis 集群持续不可用重试多少次都是白费所以还要有一个兜底的对账机制。我推荐的做法是双管齐下。第一重试队列要设定最大重试次数超过阈值立刻告警让值班同学知道有缓存没有删除成功。第二定期跑一个对账任务找出那些“数据库已更新但缓存还是旧值”的 key。对账任务可以这样实现把最近变更过的商品 ID 放一个集合比如 Redis 的 Set 或者一张变更记录表后台任务逐个比较缓存值与数据库值如果发现不一致就强制删除缓存或重新回填。这个任务不用跑得很频繁5 分钟一次就够了目的是兜住那些所有同步手段都失效的极端情况。对账逻辑里有几个细节要注意比较缓存和数据库时不要在业务高峰期把大量数据查出来否则会反而引发放大的压力。可以把对账集合按时间分片每次只处理最近 5 分钟内变更的 ID量一般不会很大。另外数据库如果是主从架构对账查询最好走主库避免因为从库延迟产生误报。6. 选型与自检我踩坑后沉淀的判断做了这么多缓存改造我慢慢形成了一套自己的选型判断。如果你的数据允许最终一致缓存 TTL 可以做到分钟级那 Cache Aside 加删除重试就够了如果数据不能容忍长时间旧值但对异步有容忍度那就上延迟双删或者把删除丢进消息队列用重试和幂等兜底如果团队运维能力强业务代码也想保持干净那 binlog 订阅是个不错的选择如果业务真的要求强一致我的建议永远是别用缓存扛这件事让它直接走数据库。这里我给一份我每次上线前都会过一遍的自检清单也算是我踩了无数坑之后沉淀下来的经验Redis 删除失败有没有监控告警重试队列有什么保护策略缓存 TTL 是否设置了随机浮动会不会集中过期延迟双删的时间是否大于缓存回填的 P99 耗时缓存回填的数据源是主库还是从库主从延迟高峰期有没有可能导致旧值回填如果缓存服务整个不可用业务有没有降级开关能不能直接打到数据库上线前是不是做过故障演练把 Redis 断掉、把消息队列断掉看看系统会有什么表现很多人一上来就堆 MQ、堆 binlog 订阅链路拉得很长反而因为每个环节都可能出问题导致整体可靠性下降。我越来越倾向于一个观点一致性方案不是越高级越好而是越简单越能扛住故障。先用好 Cache Aside、TTL、删除重试这套基本功能解决绝大多数问题等真到了不够用的那天再按需引入更复杂的工具也不迟。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。