Redis缓存不一致性问题详解

一、业务场景和问题产生的原因

1.1 业务场景

在绝大多数的系统中数据库往往是用户并发访问最薄弱的地方,并且在高并发下的读多写少的情况下,我们往往会借助一些中间键,来解决数据访问过大时造成的数据库宕机情况,例如我们可以使用Redis来作为缓存,让请求先访问到Redis,而不是直接访问数据库。而在这种业务场景下,可能会出现缓存和数据库数据不一致性的问题。

在这里插入图片描述

1.2 问题产生的原因

一般来说读取缓存步骤是不会有什么问题的,但是一旦涉及到数据更新,也就是数据库和缓存都操作,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
在数据更新时,我们需要做以下两步:

  • 操作MySQL
  • 操作缓存

但是无论是先执行步骤1还是先执行步骤2,都有可能出现数据不一致的情况,主要是因为读写是并发的,我们无法保证他们的先后顺序。

1.3 相关策略

先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案(如果要求强一致性的话,我认为没有必要添加缓存了,直接走数据库)。这种前提下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。因此,接下来讨论的思路不依赖于给缓存设置过期时间这个方案。

给出了三种更新策略:

  • 先更新数据库,再更新缓存
  • 先删除缓存,再更新数据库
  • 先更新数据库,在删除缓存

二、相关策略调研和解决方案

2.1 先更新数据库值,再更新缓存值

最不可能选择的策略,原因是此种策略可能会在线程安全的角度和业务场景角度生成脏数据和性能问题。

原因一:线程安全的角度
同时有请求A和请求B进行更新操作,那么就会出现

  1. 请求A更新数据库
  2. 请求B更新数据库
  3. 请求B更新缓存
  4. 请求A更新缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B比A更早更新了缓存。这就导致了脏数据,因此不考虑。
在这里插入图片描述

原因二:业务场景角度
(1)如果是写数据库场景比较多,而读数据场景比较少的业务需求,那么采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能,缓存此类数据,没有很大的意义。
(2)如果是写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

后面两种策略都是对缓存进行删除,这里先做一个解释。
例子:数据库在1小时内更新1000次那么缓存也更新1000次,但是这个缓存可能在1小时内只被读了1次,那么就没有必要更新1000次了。反过来,如果是删除的话,那么也只是做了1次删除操作,当缓存真正被读取的时候才去更新。

2.2 先删除缓存值,再更新数据库值

假设有两个请求,请求A进行更新操作,请求B进行查询操作。
那么会出现如下情形:

  1. 请求A进行更新操作,首先删除缓存
  2. 请求B查询发现缓存不存在
  3. 请求B去数据库查询得到旧值
  4. 请求B将旧值写入缓存
  5. 请求A将新值写入数据库

在这里插入图片描述

上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。我们可以采用延迟双删策略,来解决这个问题。
相对应的步骤:

  1. 先淘汰缓存
  2. 再写数据库
  3. 休眠t秒,再次淘汰缓存

这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

// 伪代码
public void updateDb(String key,Object data) {
    redis.delKey(key);
    db.updateData(data);    
    Thread.sleep(t);
    redis.delKey(key);
}

如果系统中MySQL使用了读写分离模式,那么有可能会出现在主从同步没有完成时,读请求就去读取数据了,这时候就会读取到旧值,这里我们可以延长睡眠时间,让主从同步完成后在进行一次删除。

2.3 先更新数据库值,在删除缓存值

假设有两个请求,请求A进行更新操作,请求B进行查询操作。
那么会出现如下情形:

  1. 请求A进行更新操作,首先更新数据库
  2. 请求B进行查询操作,击中缓存,得到旧值
  3. 请求A进行删除缓存操作

在这里插入图片描述
在这种情况下如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,请求 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

无论是策略2还是策略3都有可能会出现这种情况:删除缓存失败,这时我们可以采用重试机制来保证数据的一致性。

三、方案的详细设计

在相关策略的调用中,虽然提出了一些简单解决方案,但是还是可能会出现比较不一致的问题,此处详细介绍几种方案。

3.1 异步重试

在这里插入图片描述

流程如下:

  1. 更新数据库数据;
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 自己消费消息,获得需要删除的key
  5. 继续重试删除操作,直到成功

如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了。否则的话,我们还需要再次进行重试。如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。

// 伪代码
public void updateDb(String key,Object data){
    db.updateData(data);
    if (!redis.delKey(key)) {
        mq.send(key);
        new Thread(() -> asyncDel()).start();
    }    
}
// 异步重试
private void asyncDel() {
    int count = 0;
    String key = mq.get();
    while(!redis.delKey(key)) {
        count++;
        if (count > 5) {
            throw new DelFailException();        
        }
    }
    mq.remove(key);
}

这种虽然可以解决,但是会对业务代码造成侵入,而且还需要去维护消息队列,如果可以容忍的话,我觉得是可选的方案之一。

3.2 订阅binlog

在这里插入图片描述

业务代码只会操作数据库,不操作缓存。同时启动一个订阅binlog的程序去监听删除操作,然后投递到消息队列中。再启动一个消费者,根据消息去删除缓存。

canal是用来模拟MySQL slave,来订阅MySQL master 的binlog。

最后,Redis可能会使用集群模式,首先这是为了满足业务,而增加节点提高性能,但是可能会有一种情况出现:
1.客户端向主节点B写入一条命令;
2.主节点B向客户端回复命令状态;
3.主节点将写入操作复制给他的从节点 B1, B2 和 B3;
4.这时候主节点对命令的复制工作发生在返回命令回复之后, 因为如果每次处理命令请求都需要等待复制操作完成的话, 那主节点处理命令请求的速度将极大地降低 —— 我们必须在性能和一致性之间做出权衡。

主要还是一个IO操作太耗性能了,可以减少通信的次数或者降低连接成本,比如:长连接、连接池或者NIO等方式。

四、总结

  • 对于读多写少的数据,请使用缓存。
  • 为了保持数据库和缓存的一致性,会导致系统吞吐量的下降。
  • 为了保持数据库和缓存的一致性,会导致业务代码逻辑复杂。
  • 缓存做不到绝对一致性,但可以做到最终一致性。
  • 对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计。
  • 4
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值