
先聊点实际的。凡是做过高并发业务、或者正在准备技术面试的朋友几乎都会被同一个问题缠住Redis 缓存和 MySQL 数据一致性到底怎么保证网上一搜一堆理论什么旁路缓存、延迟双删、binlog 订阅看着头头是道但自己一上手改一行代码可能就翻车。这篇我就用一个最常见也最好理解的场景——商品库存把 Redis 缓存与 MySQL 数据一致性这件事从原理到实操彻底拆开讲顺便把那些平时文档里不会写、但生产环境里天天踩的细节都翻出来晒一晒。 不管你现在是做后端、写架构设计文档还是单纯想把缓存系列面试题弄通透这篇都值得你花十分钟看完。1. 缓存一致性问题的真正来由1.1 为什么大家都上了Redis缓存先把最底层的逻辑理顺。绝大多数业务系统的瓶颈最终都会落在数据库上尤其是 MySQL。用户量一大、接口一多同样的查询语句同一秒被请求几千次数据库的连接池、磁盘 IO、查询执行计划都会被压得很惨。这时候加 Redis 缓存本质上就是把高频读的数据从“磁盘 MySQL 查询引擎”挪到“内存 网络读取”因为内存的读取速度比磁盘快几个数量级网络内网延迟也比 SQL 解析执行小得多。商品详情、用户信息、配置项、热点榜单这些读多写少的数据用 Redis 扛读流量是再合适不过的。但缓存带来的红利背后藏着一个代价数据被存了两份一份在 MySQL一份在 Redis。MySQL 里的数据是业务真正的“事实来源”你必须保证它的持久化和正确性。而 Redis 里的数据只是一份加速副本它需要跟 MySQL 保持一致但这个一致性是有时效的、有条件的、甚至在某些窗口期内是不可保证的。很多人一开始没意识到这一点直接把缓存当数据库使数据一不一致全靠运气等线上出了超卖、错账、缓存脏数据的问题才开始回头补功课。1.2 不一致的问题到底出在哪数据不一致的直接原因其实很朴素MySQL 和 Redis 是两个独立的存储系统一次业务操作往往要分别对两个系统发请求而这两次请求没法做到原子性。也就是说任何时刻都可能发生“MySQL 改了但 Redis 没改”或者“Redis 改了但 MySQL 没改”的中间状态。我来举个例子你在代码里写了一个很普通的更新库存逻辑先更新 MySQL再更新 Redis。如果 MySQL 更新成功Redis 更新也成功那万事大吉但如果 Redis 更新失败了呢MySQL 已经变成 9 了Redis 还留着旧的 10下一次读请求打到缓存上拿到的还是 10。用户以为库存还有 10 件实际只有 9 件如果同时有多个用户下单多卖出去一两件是很正常的事。反过来如果逻辑是先更新 Redis 再更新 MySQL那 Redis 成功了、MySQL 失败了问题更严重因为缓存里已经是“未来状态”但数据库还停在旧状态任何依赖数据库做对账、统计、事务回滚的操作都会出乱子。这两种顺序都有一个共同缺陷它们把“一次完整操作”拆成了两次独立请求而网络通讯、程序异常、并发时序任何一环出问题都会留下不一致的尾巴。理解了这一点后面所有方案都是围绕“如何把不一致的窗口缩到最小、如何补偿已经发生的不一致”展开的。2. 用商品库存这个例子把问题拆明白2.1 场景一更新数据库后删缓存失败这里我把前面提到的场景再具象化一点。假设商品 ID 是 10086MySQL 库存字段 stock 10Redis 缓存里也有一个 key比如product:10086:stock值是 10。现在有个用户下单需要把库存扣到 9。常规操作是先执行 UPDATE把数据库改成 9然后执行 DEL 把缓存删掉等下一次读请求来了重新从 MySQL 加载最新的 9。这个设计的初衷是“懒加载”不主动更新缓存而是让读请求在缓存被删除后自己回源。可问题就出在那个 DEL 操作上。因为 Redis 操作要走网络可能超时可能连接池被打满可能执行到一半进程重启DEL 一旦失败缓存里的旧值 10 就留在那儿了。后续所有读请求都会命中缓存里的 10而不是 MySQL 里的 9。用户看到的库存永远是 10下单永远能成功直到缓存过了过期时间或者有人工干预。这时候你会想那我能不能不删缓存而是直接更新缓存的值为 9听起来很顺但实际更麻烦更新缓存需要把最新值算出来而有些场景缓存里的数据结构不是简单一个整数可能是经过序列化的对象、聚合统计、列表页你根本不知道要怎么更新才正确。而且更新操作不是幂等的多个并发线程先后执行 SET 时后执行的未必是 MySQL 最新的值很容易出现缓存被旧数据覆盖的情况。相比之下删缓存就简单得多删掉后读请求自然会把最新值加载回来。这也是为什么 Redis 缓存与 MySQL 一致性的经典方案都倾向于“更新数据库 删除缓存”而不是“更新数据库 更新缓存”。2.2 场景二删缓存和更新数据库的时序竞争删缓存也不是万无一失的。你换成“先删缓存再更新数据库”的顺序同样会踩坑。设想一个并发时序线程 A 先执行删除缓存准备去更新 MySQL在执行更新数据库之前线程 B 的读请求到了。B 发现缓存里没有 key于是去查 MySQL这时候数据库还是旧值 10B 查出来 10把它写回 Redis然后返回给用户。这时候 A 才真正执行 UPDATE把 MySQL 改成 9。结果就是缓存里是 10数据库里是 9而且这个脏数据会一直存在直到下次缓存过期或再次被删。这个场景在真实生产环境里出现的概率并不低。热点商品的瞬时并发读非常高任何一个节点在缓存删除和数据库更新之间有空档就可能有大量请求穿透到 MySQL顺手把旧值写回缓存。这就是经典的“缓存不一致窗口”。换句话说先删缓存再更新数据库比先更新数据库再删缓存更容易制造脏缓存因为缓存写入这个动作发生在数据库更新之前窗口期太长。2.3 场景三缓存失效瞬间的并发回源还有一种经常被归到“缓存与数据库一致性”里讨论的场景其实本质是缓存击穿和并发回源。假设某个商品的缓存 key 正好到了过期时间同一瞬间来了几十个用户查询这个商品。因为缓存已经没了这些请求全部穿过 Redis打到 MySQL 上执行同一条 SELECT。如果 MySQL 里恰好有更新操作在执行那这些读请求可能查到新旧不同的值然后各自把结果写回 Redis造成缓存抖动。这类场景的典型特征是“同一时刻大量并发读 缓存刚好失效”。它和前面说的一致性问题的差异在于它不一定是同一份数据的新旧不一致但会造成数据库压力暴涨极端情况下把 MySQL 打挂进而影响所有依赖数据库的服务。实际生产里处理这种问题有两种方向一是用互斥锁让一个线程回源数据库其余线程等待或短暂返回旧缓存二是做热点 key 永不过期 后台定时更新保持缓存的连续性。后面我还会专门展开讲这里先记住一个结论一致性问题的核心不在“缓存的过期时间”而在“缓存生命周期里的读写顺序”。3. 工程上常用的三种一致性保障方案3.1 Cache Aside Pattern 为什么写时要删缓存现在业界用得最广泛的模式叫 Cache Aside Pattern中文一般叫旁路缓存模式它的规则很简单读请求先查缓存命中就直接返回没命中就查数据库再把结果写回缓存。写请求先更新数据库然后删除缓存。这套规则背后有个更细的讲究为什么写的时候只删除缓存、而不是更新缓存。原因有两层。第一层前面提过缓存里的数据形式未必和数据库里的行结构一致。比如商品详情在 MySQL 里是十几个字段在 Redis 里可能是一个 HTTL、一个 JSON 串或者一个 Hash你更新 MySQL 后要同步生成新的缓存值等于把业务逻辑又执行一遍成本高且容易出错。第二层更新缓存会引入“并发覆盖”问题。两个线程同时更新同一商品A 把库存改为 9B 把库存改为 8但 B 的 SET 比 A 的 SET 晚到达 Redis缓存最终可能是 9 而不是 8和数据库的最新状态不一致。而删除缓存就简单了谁后执行 DEL 都无所谓缓存清空后下一个读请求会老老实实从 MySQL 加载最新值。所以 Cache Aside 模式里“删除”才是更安全的兜底动作这点很多初学的人都容易拧巴过来。3.2 先更新DB再删缓存 vs 先删缓存再更新DB既然 Cache Aside 模式明确了“写操作 更新DB 删缓存”那顺序到底怎么定这里我直接给结论在绝大多数场景下先更新数据库、再删除缓存比先删缓存、再更新数据库更安全。我来把两种顺序的风险再对比一遍。先删缓存再更新数据库最典型的故障时序是删除缓存后、更新数据库前有其他线程回源数据库并把旧值写回了缓存导致缓存里长期残留旧数据。这个窗口期有多长取决于你更新数据库需要多久可能是几毫秒也可能是几百毫秒而热点 key 在这几百毫秒里完全可能被高并发读流量命中旧值被写回的概率极大。先更新数据库再删缓存的问题就小一些虽然删除缓存也可能失败但至少缓存里残留的旧值会有过期时间兜底最坏情况下也就是在一段时间内读到旧数据等到缓存过期后仍然会恢复一致。而且删除失败这个事件可以通过重试、补偿、告警来兜住但先删缓存再更新数据库那个窗口你除非加锁否则很难彻底封死。所以工程上默认选择的顺序就是“先更新数据库再删除缓存”。3.3 延迟双删的细节和局限性延迟双删就是为了解决“先更新数据库、再删除缓存”方案里那个删除失败或者时序竞态而设计出来的增强策略。它的执行过程是更新数据库之后先删除一次缓存然后等待一段很短的时间比如几百毫秒再删除第二次。这么做的目的是什么我前面提到第一次删除后如果恰好有读请求把旧值写回缓存那么第二次删除可以把这个残留的旧值再清掉从而保证缓存里不会长期留下脏数据。延迟双删有一个关键参数需要你根据业务情况去调就是两次删除之间的等待时间。这个时间不能太短至少要大于“读请求从 MySQL 回源数据到写回缓存”所花费的时间否则第二次删除执行完了那个读请求才把旧值写进去等于白删。通常建议设为 500ms 到 1s但如果你发现业务里的 SQL 查询极慢、网络上跨机房那还得再加。另外一个细节是第二次删除最好是异步执行比如丢到一个消息队列里或者用定时任务扫描不要阻塞主流程同步等 500ms否则接口响应时间直接多出 500ms业务上很难接受。但我也得说清楚延迟双删并不能 100% 保证强一致。极端情况下如果你的第二次删除和某个读请求的写回动作仍然存在竞态缓存还是可能短暂不一致。它只是把不一致的窗口缩小到“第二次删除之后的极短时间”配合缓存过期时间兜底最终一致性是可以收敛的。如果你的业务要求的是强一致比如金融级别的余额操作那就不能用这种策略得走分布式锁或者干脆直接读数据库。3.4 基于 binlog 的最终一致性方案如果业务上写请求特别多而且你已经没办法依赖“业务代码里同步删缓存”这种思路去保证一致性那就要考虑异步化、解耦化的方案订阅 MySQL 的 binlog通过消息队列把数据变更同步到 Redis。这个方案里数据库的变化会被解析成事件比如一条 UPDATE 语句执行后binlog 里会记录变更前后的数据你用一个中间件常见的是 Canal它伪装成 MySQL 的从节点把 binlog 流拉出来解析这些事件然后发到 Kafka 或者 RocketMQ再由消费端去执行 Redis 的缓存更新或者删除。这种方式有什么好处第一业务代码里不需要显式地写删缓存的逻辑减去了大量重复代码和出错机会。第二数据库更新和缓存更新彻底解耦哪怕 Redis 短暂不可用消息在 MQ 里排队Redis 恢复后消费端继续处理最终一致性也能保证。第三它对业务无侵入非常适合那种你有多个下游系统需要同步消费 MySQL 变更的场景。但代价也很明显整个链路变长了引入了 Canal、MQ、消费端运维复杂度和故障排查难度直线上升同时 binlog 消费是有延迟的在极端场景下缓存从“变更发生”到“更新完成”之间可能隔着几十到几百毫秒读请求在这期间还是会拿到旧值。所以 binlog 方案是典型的“最终一致性”方案它赢在可靠性和解耦输在实时性。库读多写少、并发冲击没那么大的业务完全可以把 binlog 方案作为后备机制主流程用同步删缓存备用一条异步补偿链路。4. 一致性之外的四个并发坑4.1 缓存穿透说到缓存与 MySQL 的一致性不可避免要连带讲到几个和它并列的高频故障模式因为它们在线上常常一起出现。第一个是缓存穿透。场景是这样的用户传来一个不存在的商品 ID比如 1008611数据库里根本没有这条记录。代码从 Redis 查没有回源 MySQL 查也没有那就不写缓存了直接返回空。下一次再有人查这个 ID又是空查一轮如果这个 ID 恰好被恶意脚本循环请求MySQL 就会被空查询打垮。这就是穿透穿透的本质是“查询了一个不存在的数据缓存永远无法命中”。解决穿透有两个常用手段。一个是对查不到的数据也做缓存但缓存一个空值或者特殊标记设置一个比较短的过期时间比如 60 秒这样后续同样的请求能在短期内命中空值缓存而不会每次都打到 MySQL。另一个是布隆过滤器把所有存在的 ID 预先加载进一个 bit 数组查询前先过滤一遍如果 ID 不在过滤器里就直接返回不存在根本不用走到数据库。这两种方式各有适用场景空值缓存实现简单布隆过滤器更节省空间、但需要维护一个全量 ID 集合适合 ID 规模可控的业务。4.2 缓存击穿第二个是缓存击穿。它和穿透的区别在于击穿指的是某个热点 key 在缓存过期的瞬间大量请求同时涌入一齐穿透到 MySQL 查询。对数据库来说这种短时间内的突发流量比分散的空查询更致命因为热点 key 往往对应着高流量商品或用户瞬间的并发可能是几千甚至上万。比如一个秒杀品的库存 key刚好在秒杀开始前过期几十万人在同一时刻刷新详情页MySQL 根本扛不住。击穿的解决办法通常是加互斥锁或者做逻辑过期。互斥锁的思路是当发现缓存没有命中时不是让所有请求都去访问数据库而是让第一个请求获取锁去加载数据其他请求等待一段时间后重新尝试读取缓存。这里的关键细节是锁要设置超时时间防止持有锁的线程因为数据库查询过慢而卡死整个流程。另一种逻辑过期的思路更巧妙缓存里存一个逻辑过期时间比如实际的 key 在 10 分钟后会被业务标记为过期但缓存不会真正删掉它。后台用一个定时任务检测到逻辑过期后主动去数据库加载最新数据同时把旧值继续提供给读请求直到新数据写回。这种方案可以实现“永远有数据可读”适合那种不能容忍缓存击穿瞬时空窗的读多写少场景。4.3 缓存雪崩第三个是缓存雪崩指的是大量 key 在同一时间段集体失效导致请求全部落到数据库上。雪崩和击穿的区别在于范围击穿是单个热点 key雪崩是群体性失效。雪崩最常见的原因是你在设置缓存过期时间时图省事给所有 key 设了相同的过期时间比如统一 10 分钟。到了 10 分钟的临界点成千上万的 key 一起过期这些 key 对应的业务数据全都要回源 MySQL数据库瞬间可能被打爆。应对雪崩的办法其实很简单给过期时间加一个随机扰动比如基础 10 分钟再加上一个 0 到 300 秒的随机值。这样每个 key 的过期时间都会被“抖开”不集中在同一个时间点。另一个方案是设置多级缓存比如本地应用缓存扛掉一大部分流量Redis 作为二级缓存数据库作为最后兜底这样即使 Redis 里的 key 集体失效本地缓存还能撑住一部分读请求。生产环境里这两种手段通常是同时上线的。4.4 缓存污染第四个坑是缓存污染讨论的人相对少一点但实际导致的数据不一致问题不比前面几个少。所谓污染就是缓存里存储的数据已经不是数据库的真实状态了但缓存本身不会自动感知。比如你用“更新数据库 更新的缓存”模式本来想把最新的值写进缓存但并发环境下两个线程的 SET 顺序错乱导致缓存里最终留下旧值。或者你明明删除了缓存但有一个老的读请求在删除之前就已经从数据库里取到了旧值并且在删除之后才把旧值写入缓存这个旧值就“复活”了把新数据顶掉了。治理缓存污染的核心手段有两个。一个是尽量少用“更新缓存”的方案而用“删除缓存”让数据自然回流。另一个是给缓存设置尽量短的过期时间让“污染状态”顶多存活一个过期周期。如果你发现某种缓存数据经常和数据库不一致别着急加各种补偿逻辑先检查是不是缓存更新顺序的问题很多时候把“更新缓存”改成“删除缓存”问题直接消失。5. Redis缓存治理的实操细节5.1 缓存Key和过期时间设计很多人在项目初期不重视 key 的设计等到缓存规模大了才后悔。一个规范的 key 通常包含业务前缀、对象类型、唯一标识和可能的版本号。比如商品库存可以写成inv:sku:10086:v1前面是业务域中间是对象类型后面是 ID最后是版本或者状态。用冒号分隔在 Redis 里也很有用因为它天然形成层级关系Redis 的可视化工具和命令行SCAN操作都能按前缀快速筛选。过期时间的设计原则是所有读多写少的数据都必须设置 TTL哪怕业务上你希望缓存长期有效也要设置一个较长但是不等于无限的过期时间。原因很简单TTL 是最终一致性的最后一道保险。哪怕你的删除缓存逻辑有缺陷、哪怕 binlog 消费链路断了只要 TTL 一到期读请求就会重新回源数据库把缓存纠正过来。如果某个 key 完全不设过期时间一旦出现脏数据它会一直脏下去连补救的机会都没有。同时过期时间建议加随机扰动防止雪崩这个前面已经说过。5.2 序列化方式的选择另一个容易被忽略的细节是缓存 value 的序列化方式。你用 Redis 存一个 Java 对象、Go struct 或者 Python dict最终落到底层都是字节。序列化方式直接决定缓存的体积、读取速度和跨语言兼容性。简单数据可以存成字符串或者数字存储效率最高。复杂结构建议用 JSON 或者 Protobuf。JSON 的好处是可读性极强排查问题的时候可以直接用redis-cli get看内容而且跨语言友好Python 写的服务写的缓存Java 服务也能读坏处是体积偏大解析耗 CPU。Protobuf 的体积小、解析快但需要维护.proto文件调试时也比较痛苦。最不推荐的是 Java 原生序列化ObjectOutputStream它序列化出来的二进制一大坨还容易因为反序列化漏洞出安全问题。早年间很多老项目踩过这个坑我接手过的项目里就有一个 Redis 缓存里存着 Java 序列化对象后来为了跨语言消费数据硬是花了一周末把线上缓存全部清了一遍才切换成 JSON。这里还有一个实践建议不管用哪种序列化都要考虑缓存值的兼容性。如果你的缓存会被多个版本的服务同时读写比如灰度发布期间新旧版本共存序列化格式一变旧服务读不出新数据缓存命中率会骤降。稳妥的做法是 value 里带上一个格式版本号或者干脆用文本格式JSON降低兼容性成本。5.3 删除失败的重试补偿聊回一致性最核心的兜底动作删除缓存失败后怎么办。简单记一下你要的不是“一次删除必定成功”而是“删除失败后一定有补偿链路”。补偿方案常见有三种。第一种是同步重试也就是在删除缓存失败时业务代码里捕获异常后重试几次。但同步重试不适合高并发场景因为 Redis 失败往往意味着网络抖动或连接池耗尽你越重试越加重故障。第二种是异步补偿把“待删除的 key”丢进本地内存队列或者 MQ由一个独立的消费者专门去执行删除。这个方案的关键点在于消息不能丢所以最好用 MQ 而非本地内存队列否则服务一重启队列里的补偿任务就全部丢失。第三种是定时任务扫描适合那种你能够明确知道哪些 key 可能有问题、并且可以定期对账的场景。比如每五分钟扫描一次最近更新的商品 ID把它们的缓存统一删除一遍保证即使前面个别删除失败最终也能在几分钟内收敛一致。每种方案都有适用条件异步补偿适合高频删除定时任务适合低频对账。我自己在项目里通常是把同步删除作为主链路、异步 MQ 作为兜底同时配合一个低频定时任务做全局对账这是成本和可靠性之间比较均衡的组合。6. 一点实战心得在做缓存治理时我个人的体会是不要试图用一套方案解决所有一致性问题也不要迷信某个技术能带来百分之百的强一致。先想清楚你的业务能容忍多大的不一致窗口再决定用什么级别的方案。大多数读多写少、并发峰值有限的场景Cache Aside 先更新数据库再删除缓存 TTL 兜底已经足够。如果热点 key 特别多、并发冲击很猛再叠加逻辑过期、互斥锁、异步补偿这些升级手段。如果业务对一致性要求极高那最踏实的方案不是把缓存弄得多复杂而是让关键数据的读取直接走数据库缓存只用来承载非关键的热点读。一致性没有银弹但你只要把不一致的成因、窗口、补偿手段这三件事想透生产环境里的缓存问题基本都能定位到根因不会再像救火一样到处乱撞。