一致性:如何保证 MySQL 与 Redis 的数据一致性?

0 阅读6分钟

前言

本文图片来源于互联网,暂时找不到出处,侵权请联系处理

在高并发的互联网架构中,我们通常采用 "MySQL + Redis" 的组合拳:

  • MySQL:作为“权威数据源”,负责数据的持久化存储,保证数据的绝对安全和可靠。
  • Redis:作为“高速缓存”,负责承载数量的读取请求,减轻数据库压力,提升系统响应速度。

然而,这带来了一个经典的分布式难题:当 MySQL 中的数据发生变化时,如何让 Redis 中的数据也能保持一致? 如果处理不好,轻则导致用户读到陈旧数据,重则导致严重的生产事故。今天,我们就来深入聊聊这个问题的解决方案,本文主要涉及六种缓存一致性同步方案。

Pasted image 20260311215845.png

不好的方案

1. 先写 MySQL,再写 Redis

图解说明:

image.png

这是一幅时序图,描述请求的先后调用顺序; 橘黄色的线是请求 A,黑色的线是请求 B; 橘黄色的文字,是 MySQL 和 Redis 最终不一致的数据; 数据是从 10 更新为 11; 后面所有的图,都是这个含义,不再赘述。 请求 A、B 都是先写 MySQL,然后再写 Redis,在高并发情况下,如果请求 A 在写 Redis 时卡了一会,请求 B 已经依次完成数据的更新,就会出现图中的问题。

2. 先写 Redis,再写 MySQL

同“先写 MySQL,再写 Redis”,看图可秒懂。

image.png

3. 先删除 Redis,再写 MySQL

image.png

这幅图和上面有些不一样,前面的请求 A 和 B 都是更新请求,这里的请求 A 是更新请求,但是请求 B 是读请求,且请求 B 的读请求会回写 Redis。 请求 A 先删除缓存,可能因为卡顿,数据一直没有更新到 MySQL,导致两者数据不一致。这种情况出现的概率比较大,因为请求 A 更新 MySQL 可能耗时会比较长,而请求 B 的前两步都是查询,会非常快。

好的方案

4. 先写 MySQL,再删除 Redis

这是目前最主流的方案,也就是 Cache Aside Pattern

image.png

对于上面这种情况,对于第一次查询,请求 B 查询的数据是 10,但是 MySQL 的数据是 11,只存在这一次不一致的情况,对于不是强一致性要求的业务,可以容忍。

流程

  1. 先更新 MySQL。
  2. 再删除 Redis 中的 key。

代码示例

public void updateData(Data data) {
    // 1. 先更新数据库
    dbMapper.update(data);
    // 2. 再删除缓存
    redisTemplate.delete("data:" + data.getId());
}

缺点与兜底: 如果第二步(删除缓存)失败了怎么办?数据库是新值,缓存还是旧值。

  • 解决办法:虽然概率低,但如果要求严格,可以让数据库更新操作和缓存删除操作放入同一个本地事务中;或者更常见的是,设置一个较短的过期时间(TTL) 作为最后的兜底保障(例如 5 分钟),确保最坏情况下数据也能自动恢复。

5. 先删除 Redis,再写 MySQL,再删除 Redis

image.png

对于“先删除 Redis,再写 MySQL”,如果要解决最后的不一致问题,其实再对 Redis 重新删除即可,这个也是大家常说的“缓存双删”。为了便于大家看图,对于蓝色的文字,“删除缓存 10”必须在“回写缓存10”后面,那如何才能保证一定是在后面呢?网上给出的第一个方案是,让请求 A 的最后一次删除,等待 500ms。对于这种方案,风险不可控。

image.png 那有没有更好的方案呢,我建议异步串行化删除,即删除请求入队列 异步删除对线上业务无影响,串行化处理保障并发情况下正确删除。如果双删失败怎么办,网上有给 Redis 加一个缓存过期时间的方案,这个不敢苟同。个人建议整个重试机制,可以借助消息队列的重试机制,也可以自己整个表,记录重试次数,方法很多。

6. 先写 MySQL,通过 Binlog,异步更新 Redis

对于并发极高、数据量巨大的场景(如电商大促),业务代码不应该去操作缓存,否则会影响主流程的性能。

架构原理

  1. 业务代码只管更新 MySQL,操作完成后立即返回。
  2. MySQL 产生 Binlog(二进制日志)。
  3. Canal 模拟 MySQL 的从库,伪装成 Slave,订阅并拉取 Binlog。
  4. Canal 解析出数据变更(增、删、改),发送到消息队列(如 RocketMQ、Kafka)。
  5. 消费者服务监听 MQ,收到消息后去 删除 Redis。

这种方案,主要是监听 MySQL 的 Binlog,然后通过异步的方式,将数据更新到 Redis,这种方案有个前提,查询的请求,不会回写 Redis。

image.png

这个方案,会保证 MySQL 和 Redis 的最终一致性,但是如果中途请求 B 需要查询数据,如果缓存无数据,就直接查 DB;如果缓存有数据,查询的数据也会存在不一致的情况。所以这个方案,是实现最终一致性的终极解决方案,但是不能保证实时性。

几种方案比较

这里再简单对比下本文提到的比较好的几种一致性同步方案:

方案一致性复杂度性能适用场景
Cache Aside最终一致性90% 的常规业务(用户信息、商品详情)
延时双删最终一致性对一致性要求稍高,并发一般的场景
Canal + MQ最终一致性极高高并发、微服务架构(解决分布式一致性问题)
读写锁强一致性金融、计费、库存扣减(严禁脏读)

最佳实践建议: 对于绝大多数互联网应用,直接使用 "先更新数据库,再删除缓存" 即可。

  • 如果担心删除失败,可以引入 重试机制(比如把删除失败的 key 扔到 MQ 重试)。
  • 一定要给 Redis 设置一个合理的 过期时间(TTL) ,这是保证数据最终一致性的最后一道防线。

个人结论:

  • 实时一致性方案: 采用“先写 MySQL,再删除 Redis”的策略,这种情况虽然也会存在两者不一致,但是需要满足的条件有点苛刻,所以是满足实时性条件下,能尽量满足一致性的最优解。
  • 最终一致性方案: 采用“先写 MySQL,通过 Binlog,异步更新 Redis”,可以通过 Binlog,结合消息队列异步更新 Redis,是最终一致性的最优解。