十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL与Redis数据一致性实战:Cache Aside、删除补偿与TTL兜底

MySQL与Redis数据一致性实战:Cache Aside、删除补偿与TTL兜底 前几天有个同事跑来找我说线上出了个怪事用户在App里改了头像Redis里的缓存明明已经删了但部分请求还是返回旧头像而且能持续好几分钟。我让他把代码发过来一眼就看出问题——他把“删除Redis缓存”写在了数据库事务提交之前。事务没提交别人读库读到的是旧数据顺带把旧头像又塞回缓存去了。这个场景搞过后端高并发的基本都遇到过。MySQL 与 Redis 的数据一致性问题几乎是 Java 后端面试必问题也是线上排查中出现频率最高的经典坑之一。这篇不打算复述网上那些模板化八股而是结合我自己处理过的线上问题把几种主流方案拆开讲清楚不一致到底怎么发生的、先更新库还是先删缓存、延迟双删实不实用、删除失败怎么兜底以及最终我推荐什么组合。1. 一致性问题的本质两个存储之间永远存在时间差1.1 缓存和数据库为什么不能天然保持一致先摆一个很多人忽略的前提MySQL 是整个系统的事实来源source of truth所有数据最终以 MySQL 里的记录为准。Redis 是加速层它的职责是让热点数据留在内存里把那些重复且昂贵的读请求挡在数据库外面。问题是同一份数据在两个地方各存了一份。那只要发生更新就必然存在“一边已经变了、另一边还没来得及变”的窗口。这个窗口在单一请求里可能只有几十毫秒感觉不出来但一旦并发上来就会出现A线程写入新值、B线程刚好读到旧值的交叉。这就是缓存一致性问题的根源——不是某个代码写错了而是分布式系统里天然存在同步时差。我见过不少团队为了“彻底解决”这个问题试图引入分布式锁把所有读写操作串行化结果接口耗时从十几毫秒涨到几百毫秒缓存带来的性能优势全部被吃掉了。原因很简单缓存一致性问题的核心不是“能不能一致”而是“值不值得为了强一致付出那么大的代价”。1.2 先接受一个结论大部分业务要的是最终一致不是强一致这里要先分清楚一致性的等级强一致、弱一致、最终一致。强一致要求任何时刻任何请求读到的都是最新值这在单库单机里容易做到一旦引入缓存、MQ、微服务这类多组件架构想做到强一致就得引入分布式事务像 2PC、TCC开发和运维成本都极高而且对性能影响很大。大多数互联网业务根本不需求这个级别。最终一致就现实得多允许在一小段时间内读到旧数据但最终所有副本都会收敛到最新值。比如用户改昵称改完两三秒内所有端都看到新的用户不会拿秒表去卡但像账户余额、库存扣减这类场景就不能接受任何人看到中间状态。所以第一步不是去选方案而是先问产品这个数据到底能容忍多久不一致这个答案直接决定技术选型。我接触过的项目里90%以上的缓存数据场景都属于最终一致。剩下的10%强一致业务比如支付和库存通常的做法是干脆不用 Redis 缓存或者只是在数据库行锁保护下做缓存并且缓存过期时间设得很短。1.3 读多写少是前提不是所有接口都该上缓存还有一个更前置的问题这个数据到底适不适合用 Redis我见过不少项目为了用缓存而用缓存结果给自己埋坑。判断方法很直接看接口的读写比例和热点集中程度。如果一组数据的读 QPS 远高于写 QPS比如 10:1 以上且读请求集中在一部分高频数据上那 Redis 缓存带来的收益非常明显。反过来如果接口是写多读少比如一天几百万次更新但没多少人读那缓存拦截不了多少读请求反而每次写都要多一次“维护缓存状态”的动作收益低、风险高。业务上经常出现这样一个误区活动期间搞了个秒杀请求量暴涨于是给所有查询都加了缓存。结果秒杀的核心是库存扣减写并发远大于读并发缓存里存的库存根本不准引发超卖问题这就是典型的场景与方案不匹配。缓存适合雪中送炭的热点读不适合当万能加速器。2. Cache Aside 主线为什么“先更新库再删缓存”是大部分场景的默认选择2.1 更新与删除的四种组合筛掉一大半针对“写操作怎么维护缓存”最常见的方案叫 Cache Aside Pattern旁路缓存模式核心就两步读先读 Redis命中直接返回未命中读 MySQL回填 Redis 后返回。写更新 MySQL然后删除 Redis 里的缓存。但很多人在“写”这步纠结到底是先更新数据库还是先删缓存是删缓存还是更新缓存把排列组合列出来实际上是四种主要策略更新数据库 更新缓存删除缓存 更新数据库更新数据库 删除缓存只更新数据库等待缓存过期第4种最粗暴如果业务能接受缓存 TTL 内一直不一致那确实最简单但大多数业务不会接受这种漫长的过期等待。第1种把“更新缓存”作为主动操作会有两个硬伤一是缓存里的值如果涉及复杂计算每次写都要做一次无谓运算二是并发写时两个线程各自拿到不同的字段值后更新缓存的可能把另一个线程刚写的新值覆盖成旧值。所以业界主流的正确思路就集中到第2种和第3种。这两种的共同点都是“删除缓存”让缓存失效下次读的时候再从数据库里重建。为什么是删而不是更新这点下面专门说。2.2 为什么选择“删缓存”而不是“更新缓存”我把这个问题讲得直白一点缓存不是数据仓库它只是“读过一次后留下的副本”。更新缓存意味着每次数据库变更都要主动去维护这个副本的状态。但问题是很多写操作涉及的字段可能根本没人读或者读的频率很低你更新了半天缓存可能压根没被用到白白浪费时间。更关键的是并发覆盖问题。举个例子用户表里有昵称和签名两个字段线程A改昵称线程B改签名。如果两个线程都走“更新缓存”的路径A先读出来的用户对象里签名还是旧的B读出来的昵称还是旧的二者都往 Redis 里写整个对象。无论谁后写都会把对方刚更新的字段覆盖回旧值。换成“删除缓存”就没这个问题更新完数据库后把整条缓存删掉下一次任何字段的读取都会从数据库重新拉一整份完整数据回填。这样虽然会让 Redis 多一次 cache miss但花的代价远小于解决并发覆盖的复杂度。记住这句话删除永远比更新简单简单意味着不容易出错。2.3 两种执行顺序的并发风险推演现在只剩一个问题先删缓存再更新数据库还是先更新数据库再删缓存先删缓存后更新数据库存在一个很明显的坑线程A删掉 Redis 里的缓存 key线程B发起读请求缓存未命中去 MySQL 查询线程B在数据库里读到旧值线程A更新 MySQL 为新值线程B把刚才读到的旧值写回 Redis这样一来Redis 里就长期存了一份旧数据只能靠 TTL 过期才可能恢复。而且这个窗口内的读请求越多旧值回填的概率越大。线上真实的场景往往不是单个线程而是一大波读请求同时涌进几乎一定会有一两个线程卡在删除后、更新前这个间隙里。先更新数据库后删缓存的风险窗口就小一些线程A更新 MySQL 为新值在删除 Redis 之前的极短时间里有读请求命中旧缓存这个窗口期很短短到几乎可以忽略但的确存在。如果删除执行失败缓存里会一直保留旧值——这是一个需要兜底机制来解决的问题而不是方案本身的逻辑缺陷。所以从概率和工程代价两个角度看先更新数据库再删除缓存都明显优于先删缓存。这也是后来所有兜底方案的基础。3. 延迟双删的边界它解决的只是“回填慢”这一类问题3.1 延迟双删在解决什么既然“先删缓存再更新数据库”会被并发读回填旧值有人就发明了延迟双删在第一次删缓存之后更新数据库等一小段时间再删一次缓存。第二次删的目的很清楚就是为了把那段窗口里读线程回填的旧值清掉。伪代码大概是这样public void updateUser(User user) { String key user: user.getId(); redisTemplate.delete(key); // 第一次删除 userDao.updateById(user); // 更新数据库 // 延迟一定时间后再次删除 CompletableFuture.delayedExecutor(300, TimeUnit.MILLISECONDS) .execute(() - redisTemplate.delete(key)); }线下验证的时候这个方案确实能把大多数因“读线程回填旧值”导致的不一致捞回来所以很多团队一遇到一致性问题就往上套。但我得说一句挨喷的话延迟双删是“看起来有用”的典型方案实际使用中麻烦很多。3.2 延迟时间怎么定不是拍脑袋延迟双删最关键的参数是“延迟多久”这个值拍脑袋定基本都会出问题。它的本质是等待可能发生的旧值回填动作全部完成。一次缓存回填的耗时由这几部分组成MySQL 查询耗时考虑慢 SQL 的极端情况网络传输耗时序列化和反序列化耗时Redis 写入耗时假设你的业务接口读数据库平均耗时 30ms慢查询极限 100ms那么延迟 300ms 左右比较稳妥。如果业务里出现 500ms 的慢查询那延迟 300ms 就不够旧值还是会被写回 Redis只是二次删除没删在点子上。但这里有个现实问题延迟时间怎么动态感知慢查询的变化线上 SQL 性能是会波动的今天 50ms明天因为数据量变大变成 200ms代码里那个固定 300ms 很快就失效了。想做到自适应又需要额外的监控指标复杂度直接上升。3.3 延迟双删的局限性极端并发下仍是“尽力而为”延迟双删不能做到绝对一致。极限情况下第二次删除执行之前仍然有读请求可能读到旧库数据并回填只是概率降低了。而且同步 sleep 会阻塞写线程通常需要改成异步删除异步删除又需要考虑线程池和延迟队列整套落地成本并不低。我自己的项目实践中延迟双删的使用场景很窄一般只用在“旧值回填风险高、且短期内无法重构代码”的存量项目上。新项目我不会主动选它。延迟双删本质上是给“先删缓存再更新数据库”这个不太合理的顺序打补丁既然可以改成“先更新数据库后删缓存”就没必要打这个补丁。4. 删除失败才是真正的大坑消息补偿与重试机制4.1 为什么“先更新库再删除”也会出长期不一致前面说了“先更新数据库后删缓存”的窗口期很短但有一个前提是 Redis 删除操作必须成功。实际上删除操作很容易失败Redis 超时、网络抖动、连接池被占满、Redis 主从切换期间的请求异常甚至代码异常导致删除语句压根没执行到。一旦更新 MySQL 成功但删除 Redis 失败缓存里就会一直保留旧值如果没有 TTL 兜底这个不一致可以持续到天荒地老。所以“先更新库再删缓存”这个方案能不能站稳不在于正常流程而在于你为“删除失败”设计了什么样的恢复机制。4.2 方案一本地消息表 定时任务重试最朴素但非常可靠的做法是借用数据库事务把“更新 MySQL”和“写入待删除任务”绑在一起。设计思路CREATE TABLE cache_del_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, cache_key VARCHAR(256) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待处理 1-成功 2-失败待重试, retry_times INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) COMMENT 缓存删除任务表;业务更新数据时同一个本地事务里做两件事update 业务数据insert 一条 cache_del_task。因为共用一个事务要么都成功要么都失败不会出现“数据改了但删除任务没记上”的情况。事务提交后后台任务扫描 status0 的记录执行 Redis 删除动作Scheduled(fixedDelay 5000) public void processDeleteTask() { ListCacheDelTask tasks taskMapper.selectPending(100); for (CacheDelTask task : tasks) { try { redisTemplate.delete(task.getCacheKey()); taskMapper.markSuccess(task.getId()); } catch (Exception e) { // 重试次数 1达到上限后告警 taskMapper.markRetry(task.getId()); } } }这套方案的优点是可靠性高因为你已经有一条带事务保证的任务记录在数据库里可以随时重试、对账、告警。缺点是需要建表、写任务扫描代码上多一点点侵入但相比它带来的确定性这点成本很值得。4.3 方案二基于 binlog 变更订阅做异步失效如果公司已经引入了 Canal 这类伪装成从库的中间件那还可以走 binlog 订阅路线。基本原理是MySQL 在事务提交后才会把 binlog 变更事件输出业务代码完全不用关心缓存删除逻辑只要监听对应表的 update/delete 事件解析出主键再去把 Redis 里对应 key 删掉。这个方案最大的优点是无侵入业务代码里连“删 Redis”都不用写逻辑被收拢到一个统一的消费端。第二个优点是事务时序天然正确因为拿到 binlog 事件时 MySQL 事务已经提交数据已经可见再删缓存就不会有预提交窗口。它不是没有成本需要额外维护 Canal 服务需要处理消息的重复投递删除操作要尽量幂等Redis 删除恰好事幂等的这倒是刚合适还要考虑 binlog 处理延迟。如果你的团队已经有 Canal 或类似基础设施那直接复用如果为了一个缓存一致性问题专门引入一套中间件那我建议先用 4.2 的本地消息表顶上。5. 实操中容易忽略的边界事务、主从延迟、TTL 和序列化5.1 缓存删除不要在事务里做要在事务提交后做回到文章开头那个同事的案例。他的问题不在于方案选错而是把删除缓存放在了事务执行过程中。Spring 的 Transactional 方法里事务提交发生在方法返回之前。如果在事务还没提交时就把缓存删了别的线程此时去读数据库因为读的是快照或未提交数据看到的还是旧值然后顺手回填旧数据又进缓存了。正确做法是把删除动作放到事务提交之后推荐用 Spring 的事务同步器Transactional public void updateUserNickname(Long userId, String newNickname) { userDao.updateNickname(userId, newNickname); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { redisTemplate.delete(user: userId); } }); }afterCommit 保证是在数据库事务成功提交后才执行删除最大程度避免“删太快”的问题。如果你的业务里用了消息补偿同样的道理发消息的动作也不能放在事务提交前直接发否则事务回滚后消息已经发出去消费端就会拿到一个不存在的变更。5.2 主从延迟会把“回填旧值”问题悄悄放大很多项目在 MySQL 层做了读写分离。这时一个隐藏问题就会出现写操作直接更新主库缓存删除之后缓存未命中读请求为了负载均衡被发到了从库而从库还没同步到主库的最新数据于是从库返回旧值缓存回填了一份旧数据。这个问题非常隐蔽因为它和你写代码的方案无关是部署架构引入的。应对办法有几个缓存回填的关键读取强制走主库避免从库延迟影响回填质量缩短缓存 TTL让错误的数据快速过期对一致性要求高的数据写入后短时间内读主库我见过团队为了这个事把从库同步延迟监控加上了一旦从库落后超过阈值就告警虽然不能立刻解决缓存不一致但至少不会让问题埋得无声无息。5.3 TTL 是兜底机制设多长直接决定不一致的持续时间无论采用哪种方案我都强烈建议不要依赖“缓存一直有效”TTL 必须设置。TTL 的作用是兜底恢复即使删除失败、重试也失败至少到了过期时间缓存会被强制清除下一次读请求会重新从数据库加载。它不该作为解决一致性的主要手段但它是最后一道防线。TTL 设置多长要结合业务容忍度。一般业务数据比如用户资料、文章详情我习惯设 300秒 到 1800秒对一致性要求偏高的数据比如配置类、会话状态TTL 压到 30秒 以内。TTL 设太短会失去缓存意义设太长则会放大各类异常导致的不一致时间。类型建议 TTL适用场景配置类数据30秒 ~ 60秒更新频率低但要求变更尽快生效用户资料300秒 ~ 1800秒改昵称、头像等容忍秒级不一致热门列表60秒 ~ 300秒列表查询压力大内容变更不追求即时强一致敏感数据不缓存或 10秒内余额、库存等宁可多查库也别返回旧值5.4 Redis 数据类型选择也会影响一致性心智这块常被忽略。如果缓存里是一个对象的多个字段很多人习惯直接存 JSON 字符串这样最简单删除时整个 key 删掉一致性心智清晰。但如果你为了局部更新方便用 Redis Hash 存对象然后只更新某个 field那就要考虑数据库某一行 update 时你到底应该同时更新哪几个 field一个没对上缓存里就会出现“这个字段新、那个字段旧”的拼凑状态。我的经验是除非有很强的性能需求否则旁路缓存优先用 String 整体存、整体删。Hash 适合那些真正需要高频读单个字段的场景但一致性维护成本更高别为了省一点内存把简单的方案复杂化。6. 从“方案正确”到“线上可信”一致性校验与压测补充6.1 一个简单可落地的对账思路方案设计得再完美没有校验手段就永远是“理论上一致”。我习惯在项目里加一个轻量对账服务低频定时扫描一部分缓存 key查出 Redis 里的快照写入时间或者版本号再去库里比对数据的 update_time如果差值超过阈值就判定为不一致删除缓存并告警。这里有个技术细节对账服务扫描 Redis key 时线上千万别用 KEYS 指令会阻塞 Redis。正确做法是用 SCAN 命令游标分批扫或者直接在业务日志中异步打印“缓存写入时间”和“数据库更新时间”由日志链路分析平台去统计差异。后者更轻量我实际用下来效果不错。6.2 并发压测具体观察什么在做并发验证时建议关注几个核心指标操作结束后缓存最终是否等于数据库最新值不一致窗口的平均持续时长删除失败率失败后重试是否成功恢复缓存回填时是否偶发旧值压测方法可以写一个简单的并发程序多个线程同时执行“读缓存、写数据库、删缓存”的交错场景结束后统一比对缓存和数据库。目标不是追求零不一致——那不可能而是把不一致的概率控制在业务容忍范围内同时确保它能在 TTL 或补偿机制下快速恢复。6.3 我在实际项目里的最终推荐组合做过的项目多了我现在基本不迷信任何单一方案而是用组合拳所有读走 Cache Aside写操作统一“先更新 MySQL再删除 Redis”核心写操作把“删除任务”落到本地消息表定时重试删除每条缓存都设置合理 TTL 做兜底对账服务定时扫描发现差异自动修复对一致性要求极高的少部分数据干脆不缓存直接读库这套组合的成本不算高但每个风险点都有专门的机制兜住。延迟双删不是不能用只是我不太喜欢它的不确定性本地消息表和 binlog 订阅各有利弊实际要看团队有没有现成基础设施。方案没有银弹但把“更新库后删缓存、删除失败必补偿、TTL 兜底不能省”这几条做到位绝大多数业务的一致性问题都会从“事故”降级成“可解释的短暂延迟”这已经是个很好的结果了。
返回列表