Redis与MySQL数据一致性:从Cache Aside到Binlog订阅的可靠方案
发布时间:2026/10/3 18:03:08 作者:尧图编辑部 阅读量:1,286

1. 面试官问出这道题时到底在想什么后端开发干到一定年头基本都会被这道题堵在面上“Redis 和 MySQL 的数据一致性怎么保证” 我很少听到有人能一次答得干净利落大多数时候是“先删缓存再更新数据库然后 sleep 几百毫秒再删一次缓存”这种背出来的答案。面试官不傻他清楚 Redis 不慢但要保证和 MySQL 一致绝对不是靠一个 Sleep 能搞定的。这道题本质问的是在缓存与数据库并存的双写架构下你怎么处理并发请求下的脏读和旧数据覆盖问题。它并不要求你必须给出“强一致”的分布式事务方案——绝大多数业务场景只需要“最终一致”而且要在尽量短的时间内收敛。如果能在说完最终一致之后再讲清楚极端情况下的兜底那就更接近阿里二面期望的解答。仔细拆一下问题可以分成三个层次第一层你知不知道缓存和数据库为什么不一致。这个没难度写操作、读操作交叉执行时序一乱就脏了。第二层你能不能从“纸面正确”走到“实现可靠”。很多人知道要删除缓存但删了没成功怎么办没人管。第三层你有没有在性能和一致性之间做取舍的思维。一致性不是无限拉高而是给业务一个可控的容忍度。面试官真正想看的不是“标准答案”而是你面对并发场景时能不能把系统拆开、把风险点找全。所以你一上来就背“延时双删”的时候他基本已经判定你只停留在刷题层面了。Sleep 那个值设多少合适主从延迟超过它怎么办Redis 删除操作失败怎么感知这些追问一来背的答案就碎了。这篇我就按自己对缓存一致性的理解把常规方案和真正能用在高性能场景下的做法聊透。包括“先写库再删缓存”的标准姿势、删除失败后的重试补偿、基于 Binlog 订阅的异步清理以及让人纠结的“严格一致”怎么兜底。不搞玄学直接说能落地的思路和踩坑经验。2. 为什么“延时双删”看着合理用起来却到处漏风先复盘一下那个流传很广的“延时双删”流程更新数据库之前先删除缓存。更新数据库。Sleep 一个固定时间比如 500ms。再一次删除缓存。设计者的理由是第一次删除缓存是为了避免在数据库更新期间旧缓存被读走第二次删除是为了清掉在 Sleep 期间可能被某个并发读线程回填的旧数据。听起来挺自洽但把它放到真实环境和面试官的追问下问题全出来了。2.1 Sleep 时长的“薛定谔取值”第一个硬伤是 Sleep 时间根本没法估。你 Sleep 500ms可如果 MySQL 主从复制延迟了 800ms某个读请求在从库上查到的还是旧数据那么这个旧数据会在 Sleep 结束之后被回填到缓存里你的第二次删除已经执行完了旧缓存就稳稳地留在那。下一次读命中旧值业务就出问题。更麻烦的是这个延迟不是固定值它会随着主库写入压力、从库机况、网络抖动而剧烈波动。今天 200ms明天 1200ms你怎么定定大了写请求全部慢吞吞吞吐掉一半定小了覆盖不住延迟一致性该破还是破。2.2 第二次删除失败等于裸奔Sleep 方案只考虑了“最好情况”——两次删除都成功。实际系统里 Redis 可能超时、连接池耗尽、网络闪断第二次删除一旦失败没有任何补偿机制。于是那个期间被回填的旧数据会一直挂在 Redis 里直到过期时间到达。如果业务没给缓存设置过期时间那这个脏数据就真成了孤儿永久污染。这个“过期时间兜底”不是没有但很多人设计时根本没把它纳入一致性方案导致出问题后只能手动清 Redis。我在项目里就见过某模块数据一夜之间全变成旧值查日志发现是删除缓存的操作抛了异常代码只是 catch 后打了个日志就完事了。2.3 性能与一致性两头不讨好为了等那个 Sleep每个写请求都得阻塞在业务线程上。你算一下假设 QPS 是 1000平均 Sleep 300ms那在线程池里占用的时间就是 300 秒每秒线程池再大也扛不住这种消耗。所谓“高性能”根本谈不上它只是一个用自我安慰式的等待来假装解决问题。一致性层面也不严谨在“第一次删除缓存”到“更新数据库”完成之间如果某个读请求来了它读了 DB 的旧数据并回填缓存那第二次 Sleep 可能还是来不及挡住它因为在 Sleep 结束后这个回填才发生。也就是说时序只要错开一点点方案就失效。双删的核心缺陷是用一个固定等待去匹配一个动态不可控的复制延迟窗口这跟“赌运气”没区别。所以我现在基本不建议任何新手把“延时双删”写进简历。它在极简单的单库、单机 Redis、低并发场景下也许能跑但一旦进入高并发、主从复制、Redis 集群马上就漏。面试里与其背这个不如承认它的局限然后拿出下面这些设计来。3. 一致性问题的两个核心场景拆解读回源与写更新要设计可靠方案先得把问题场景分清楚。很多时候不一致不是说缓存数据“一定是错的”而是你对“何时必须读新、何时可以容忍旧”没有定义清楚。在缓存架构下请求会走两种路径读请求缓存命中就直接返回未命中则查询 MySQL把结果回填到 Redis再返回。写请求更新 MySQL然后想办法让 Redis 里的旧值失效或更新。于是不一致出现的根源就集中在这两个地方并发读请求在错误时刻把旧值回填到缓存写请求更新了 MySQL却没有成功清理或刷新 Redis 中的旧值。3.1 读回源旧值填充是最大的脏数据来源想象一个线下单据状态初始是“待支付”缓存里存的就是“待支付”。这时候一个写请求把 DB 状态改成“已支付”但还没来得及更新缓存。另一个读请求同时进来它在缓存里没命中因为可能刚被删除或过期于是去 MySQL 查——这时查到的可能是旧值“待支付”因为在主从架构下刚才那个写事务可能还没同步完成。然后它把旧值“待支付”回填到 Redis。等写请求想“删除缓存”的时候恰好这个读请求已经先一步把旧值塞回去了。删除操作晚了一点就会把一个旧值留在缓存里后续所有读都看到旧状态。有人会说那用“更新缓存”而不是“删除缓存”不就行了直接写“已支付”进去不就不会有旧值覆盖这里有个经典的并发覆盖问题两个写请求 A 和 B 几乎同时提交A 把状态改成“已支付”B 把状态改成“已取消”。如果 A 更新缓存写入了“已支付”而 B 的 DB 事务先提交但缓存更新晚一点就可能在缓存里留下“已支付”而 DB 实际是“已取消”。顺序一乱缓存值比 DB 新也一样是脏数据。删除缓存比更新缓存安全就在于它不参与“具体值的竞争”——把不确定的值清掉让下一次读统一回到数据库取最新。这是 Cache Aside 模式的核心思想。3.2 写更新到底删还是更对于“删还是更”我的实践结论很干脆绝大多数业务字段都适合“删除”不适合“更新缓存”。为什么更新缓存要求你知道这次写操作影响哪些缓存 key而且必须保证更新顺序与 DB 事务顺序一致。一旦并发、失败或网络乱序缓存里的值可能是多个写操作的乱序结果。删除缓存则不用管值本身是什么只要删掉就行哪怕删除的顺序有变化最坏结果就是读未命中多查一次 DB但不会读到错误值。删除有幂等性同一个 key 删两次、删十次结果都一样更新可没有这个特性后写覆盖先写一个乱序就翻车。所以在双删方案里第二次删除也是删除思路是对的但它没有解决“什么时候删、删了失败怎么办”这两个根本问题。只要你把“删除”这个动作的可靠性做扎实了就不需要 Sleep 了。4. 高性能高可靠方案一先写 DB 再删 Cache失败用 MQ 补偿我落地过的最实用的方案就是在 Cache Aside 基础上把“删除缓存”做成一个必须成功的异步操作核心要点有三个先更新数据库、后删除缓存、删除失败进队列重试。整个流程可以简化成下面这样写请求进来开启 DB 事务。执行更新 SQL。提交事务。删除 Redis 缓存 key。如果第 4 步失败把“删除 key”的任务写入一个可靠的消息队列或本地任务表。由后台消费者不断重试删除。读请求进来读 Redis命中直接返回。未命中读 MySQL。把结果写回 Redis并设置合理的过期时间。返回结果。4.1 为什么“先更新 DB”而不是“先删缓存”这是整个方案的关键。如果先删缓存再更新 DB那么在“缓存已删、DB 未更新”这个窗口里任何读请求都会直查 DB查到旧值并回填缓存。这个旧值会存活到下一次删除甚至可能永久存活。如果先更新 DB再删缓存缓存里存的是旧值但在删缓存之前读请求还能命中旧值。这个旧值只是一段时间的“旧”不会污染数据库而且一旦删除完成后续读就强制回源读到新值。旧数据窗口很短且只发生在写请求执行期间和删除完成之前。因此“先更新 DB 再删缓存”并不是消除了不一致它是把不一致窗口压缩到最小而且这个窗口不依赖任何 Sleep只取决于删除动作什么时候完成。4.2 删除失败为什么必须重试很多人觉得「删除缓存失败」是小概率事件。但在高并发场景下小概率乘上大流量就是必然事故。你每秒写 1000 个键删除失败率就算只有 0.1%每秒也有一次失败积累下来就是大问题。所以删除动作不能“偷懒”。最直接的做法是业务线程在删除 Redis 抛出异常时把 key 封装成一个任务消息投递到 MQ后台消费者收到消息后调用 Redis 删除如果还是失败就按指数退避策略重试比如 1s、5s、30s、5 分钟最多重试 15 次最后还是失败就进入死信队列并告警给值班人。这里有个容易踩的坑不要依赖业务线程在本地 sleep 后重试。一旦进程重启没执行完的重试就丢了。要把任务放进能持久化的队列或表里。4.3 一个可落地的本地任务表示例如果你们的系统没有现成的 MQ可以用一个简单的本地任务表建一张表cache_clean_task字段大致是id、cache_key、status、retry_count、next_retry_time、created_at、updated_at。流程是这样的-- 在业务 DB 事务内同步插入一条待清理任务 INSERT INTO cache_clean_task(cache_key, status, retry_count, next_retry_time) VALUES (user:profile:1001, 0, 0, NOW());事务提交后一个后台定时任务每 3 秒扫描一次next_retry_time NOW() AND status ! 1的记录对每条记录执行 Redis 删除。删除成功就把 status 置为 1失败则更新retry_count 1并计算下一次重试时间import redis import random def delete_cache(key: str) - bool: r redis.Redis(hostyour-redis-host, port6379, decode_responsesTrue) try: deleted r.delete(key) return deleted 0 # 删除成功与否都算任务完成 except redis.RedisError: return False重试退避可以简单用next_retry_time now min(2 ** retry_count, 300) random_jitter。这样既避免了集中暴击也保证任务最终会被执行。我个人建议如果公司已经有 Canal 或者 DTS不要单独搞任务表直接让 Binlog 订阅系统去干这事。下面这段展开讲。5. 高性能高可靠方案二Binlog 订阅触发异步清理彻底绕过 Sleep本地任务表和 MQ 补偿的本质还是业务侧主动删除缓存。如果删除动作始终由业务代码触发总有一个隐患你以为写成功了但异步删任务在某个环节丢了或者这条数据根本不是由你当前服务写的而是其他系统改的你这里完全感知不到。这时候就需要一个“上帝视角”——监听 MySQL 的 Binlog把所有数据变更自动翻译成“缓存清理任务”。这就是业界常说的基于 Binlog 订阅的异步缓存治理方案最典型的落地是 Canal 或云厂商的数据订阅服务。5.1 原理一句话讲清楚MySQL 主库把每一次数据变更记录到 Binlog包括我们关心的INSERT、UPDATE、DELETE。Canal 把自己伪装成一个 MySQL 从库去主库拉 Binlog然后解析成结构化的事件再把事件推给 MQ。我们的缓存消费者只需要订阅一个 topic收到事件后做一件事删除对应的 Redis key。因为 Binlog 是从主库按照提交顺序生成的这个顺序是全局有序的你不需要关心业务线程里的并发时序数据变更的顺序由 MySQL 自己保证。整个链路变成业务代码只写 MySQL。Binlog 被 Canal 捕获。Canal 把变更事件投递到 Kafka/RocketMQ。消费者消费事件删除 Redis key。对比“业务线程主动删”这个方法的好处是缓存清理完全从业务链路中剥离出来不会增加写请求的耗时也不依赖业务代码正确调用了删除方法。只要 Binlog 里有变更清理任务就一定会出现。5.2 需要注意的三个细节第一个Binlog 格式必须调整为 ROW。只有 ROW 格式才有变更前后的完整字段。Statement 格式可能只有 SQL 文本不方便解析出主键。所以上线 Canal 前先检查数据库的binlog_format是否已经是ROW。第二个不能把“新值直接写入缓存”还是要删除。有人会想既然 Binlog 里已经拿到了更新后的值那直接把新值写进 Redis 不就行了还能省一次回源。这是个陷阱。如果同一个 key 在 Binlog 里连续出现两次更新消费者处理顺序稍有延迟先处理了第二次更新的值后处理第一次更新的值那缓存里留下的就是旧值服务端还得再读 DB 才有新值不一致窗口反而被拉大了。统一删除然后让读请求回源值一定是最新的。第三个要有一个消费失败的死信处理。即使有了 Binlog消费者也可能因为网络闪断、Redis 临时不可用而删除失败。标准做法是重试多次后丢进死信队列同时告警。然后由运维或定时任务定期从死信队列里重新发起删除。这个补偿环节看起来不起眼却是高可靠的关键。5.3 适合引入 Binlog 方案的场景这个方案不是银弹它引入了额外的基础设施Canal 集群、MQ topic、消费服务。如果你只是一个小项目流量没多大搞这套会有点重。但如果你的服务已经具备以下特征我强烈建议上有多套业务系统同时在写同一张表你无法在入口处拦截所有写请求缓存里的数据是基础资料类比如用户信息、商品信息写少读多你正在治理缓存脏数据已经不想再依赖业务代码里的“删除缓存”调用了。我做过一个会员中心的缓存治理就是这种场景。原来业务代码每个地方写 DB 后都手动删缓存漏删的地方不少后来把会员表 Binlog 接到 Kafka统一由消费者删 Redis脏数据率直接降到几乎为零。代价只是多了一个消费者服务但维护成本远低于每次写操作都去关注缓存。6. 严格一致性的终极解法版本号令牌与串行化控制聊到这儿会有人问那“最终一致”够用吗某些业务场景——比如库存扣减、账户余额、秒杀资格——不想看到任何一秒钟的旧数据哪怕缓存里短暂返回一个旧库存都不可接受。这种严格一致性的诉求光靠“删除缓存 重试”是不够的因为删除动作完成前依然有一个旧值窗口。怎么办我在实际工作中试过两条路一条是版本号令牌一条是串行化控制。它们都不是“通用最佳实践”但能解决特定场景下的强一致需求。6.1 版本号令牌让读请求识别缓存是否过期思路是这样的缓存 value 里面不只存业务数据还存一个 version 字段。这个 version 由 MySQL 侧维护每次业务数据变更时递增。读请求拿到缓存里的 version 后可以通过一个轻量级接口向 DB 校验版本号是否匹配。如果匹配直接返回如果不匹配忽略缓存重新查 DB 并回填。这听起来有点傻——每次读都查一次 DB 版本号缓存的意义不就少了一半所以它一般被用在“热点数据”上用一个独立的 Redis batch 命令或一个极简的 DB 查询比如select version from table where id?走覆盖索引来降低成本。更极致一点你可以把版本号放在 key 的命名里比如user:profile:1001:v3。每次数据变更后业务侧删除v3并把新的v4提前写入不这里还是得靠删除。严格版本一致性最简实现是每次写 DB 时记录version 1。缓存 value 里带上 version。读请求比对缓存 version 和 DB 当前 version用带版本号的 Redis 锁或者 DB 快照查询。如果 DB 当前版本号和缓存不一致就视为缓存失效从 DB 拉最新数据并回填。这个方案能挡住“缓存里存的旧值”被直接返回因为读请求会先发现版本不匹配。代价是读路径多了一步版本校验而且需要所有业务写入口都维护好 version。如果表结构没有现成的 version 字段加一个也不难但要注意并发递增的原子性。6.2 串行化控制用分布式锁把读写排队既然不一致源于并发那对同一个 key 的读写操作串行化自然就没有交叉污染了。具体做法写请求处理前先去 Redis 抢一把分布式锁比如基于 Redisson锁的粒度精确到业务主键抢不到就等待或返回失败。抢到锁后先更新 MySQL再更新缓存这里可以用更新而非删除因为此时没有并发读回源干扰。释放锁。读请求同样需要抢同一把锁如果缓存未命中先抢锁再去读 DB、回填缓存然后释放锁如果缓存命中不需要抢锁。但这样做的代价非常明显同一时刻同一个 key 的读和写都要排队热点 key 的吞吐量会被锁死死压住。秒杀库存、抽奖这类高并发写场景用这个方案几乎等于自杀。所以串行化控制更适合两类数据极其敏感且对一致性要求苛刻的数据比如支付单状态、金额变更并发量其实不大但业务上绝不允许读旧值的数据。在这些场景下牺牲一部分吞吐换取确定性是合理的取舍。6.3 不要迷信“绝对强一致”实话实说缓存 数据库这种架构本身就很难做成“绝对强一致”除非你放弃缓存所有读请求都走 DB。分布式系统里CAP 定理这一关绕不过去。你在 Redis 上部署再复杂的锁也无法替代数据库事务的 ACID 保证。所以设计原则很简单读多写少、对短暂旧值不敏感的数据用 Cache Aside 删除补偿 Binlog 订阅就够了。对旧值敏感但并发量尚可的数据用版本号/令牌。对旧值完全不能忍且并发量有限的数据直接不缓存或者用分布式锁串行化。在实际业务里“缓存不一致”很多时候不是技术问题而是产品容忍度问题。你以为用户无法接受旧库存但他只是看到一个数字短暂停留了几百毫秒对他没有任何实质影响反之你把所有东西都加锁用户等半天拿不到数据那才是真正的体验灾难。7. 落地细节与面试话术怎么答才能让面试官点头讲了这么多方案最后回到面试场景。阿里二面这道题面试官要的绝对不是你背诵某个框架而是看你有没有一套“从一致性本质出发”的推理能力。我建议按下面这个节奏答。7.1 先定义问题边界你可以这样开场“要保证 Redis 和 MySQL 的数据一致性我首先要分业务场景。大部分缓存场景可以接受最终一致只需要把不一致窗口控制到足够小只有少数核心数据才需要强一致那必须严肃对待。”这句话一出来面试官就知道你不是只会背方案而是能区分需求和成本。然后快速说明 Cache Aside 模式读时回源写时先更新 DB、后删除缓存。重点强调“先更新 DB 后删缓存”的顺序原因以及为什么不采用“先删缓存再更新 DB”或“直接更新缓存”。讲清楚之后面试官通常会追问“那如果删除缓存失败了呢”“主从延迟那种情况你怎么处理”7.2 把补偿机制讲成体系答删除失败不要说“重试几次就行”而要说业务线程先发一个“缓存清理任务”到消息队列。消费者收到任务后删 Redis失败则按指数退避重试。达到最大重试次数后进入死信队列并触发告警。与此同时给缓存 key 设置合理的过期时间作为兜底避免最坏情况下脏数据“长生不老”。如果面试官还追主从延迟你可以答对“刚写完必须立刻读到”的请求可以让客户端在读请求上携带一个版本或时间戳识别出是“写后即读”的请求直接穿透缓存查主库或者利用 Binlog 订阅让缓存清理在主从复制链路上尽可能早地触发缩短不一致窗口如果业务允许最简单的是“写后稍等再删”这种语义但不建议 Sleep最好是把删除动作交给异步队列由队列自身的延迟来控制。7.3 主动抛出 Binlog 方案别等面试官问你要主动说如果是对一致性要求很高的核心链路我更倾向于用 Binlog 订阅做异步清理。可以简单描述 Canal 抓取 binlog丢到 MQ消费者删除缓存。这能体现你有全局架构视野。但一定要说明适用条件只有当系统规模足够、有多服务写同一数据源时才值得引入。不要一上来就说“我们所有项目都上 Canal”那样显得没有成本意识。7.4 把“高性能”落到关键数字上既然标题里写了“高性能 高可靠”别只给方案不给数据。你可以说写链路中不引入阻塞操作避免无谓 Sleep删除操作异步化写入耗时只包含 DB 事务时间。默认给缓存设置这么长的过期时间比如 5 分钟就算删除任务全部失败脏数据的最长存活时间也被限制在过期时间之内。使用本地任务表或 MQ 的消费者去重删删除动作是幂等的重复执行成本极低。这些细节都能让面试官感受到你做过真实性能调优而不是只背概念。7.5 我在实际项目里的最终选择按我自己的项目经验如果 Redis 和 MySQL 一致性要求高、且数据由多个服务写入我会优先上 Binlog 订阅 删除消费。如果只读不改、删缓存偶尔失败就用本地任务表或 MQ 补偿。版本号方案由于读路径多了一步校验我只会在“无论如何不能读旧值”的极小范围数据上用绝大多数业务都用不到它。最后抛一个小技巧不管用哪种方案都把缓存看作一个可以被随时删除的临时层把 MySQL 当成唯一事实来源。只要你所有的一致性设计都围绕“失效缓存 回源”展开就不会设计出“延时双删”那种依赖玄学等待的方案。面试官问什么你都有的聊而不是只有背答案的心虚。