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(host="your-redis-host", port=6379, decode_responses=True) 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 当成唯一事实来源。只要你所有的一致性设计都围绕“失效缓存 + 回源”展开,就不会设计出“延时双删”那种依赖玄学等待的方案。面试官问什么你都有的聊,而不是只有背答案的心虚。