往期文章:
【Redis】缓存击穿、缓存穿透、缓存雪崩原理以及多种解决方案
一、前言
在前面的文章中,我们探讨了为什么要使用 Redis 作为系统的缓存,今天我们接着来探讨加入缓存后可能遇到的一个问题:数据一致性问题
当我们在项目中接入缓存后,用户访问系统获取数据的大致流程如下:
加入缓存(Redis)后,请求先访问到缓存(Redis),而不是直接访问数据库。
在这种业务场景下,可能会出现缓存和数据库数据不一致性的问题。
在更新的时候,操作缓存和数据库无疑就是以下四种可能之一:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
二、问题分析
1、先更新缓存,再更新数据库
如果缓存更新成功,但在执行更新数据库时,服务器突然宕机了,那么缓存中是最新的数据,而数据库中是旧的数据。
脏数据就因此诞生了,并且如果缓存的信息是单独某张表的,而且这张表也在其他表的关联查询中,那么其他表关联查询出来的数据也是脏数据,结果就是直接会产生一系列的问题。
2、先更新数据库,在更新缓存
只有等到缓存过期之后,才能访问到正确的信息,那么在缓存没过期的时间段内,所看到的都是脏数据。
以上两图中只要执行第二步时失败了,就必然会产生脏数据。
3、先删除缓存,在更新数据库
这种方式在没有高并发的情况下,是可能保持数据一致性的。
如果只有第一步执行成功,而第二步失败,那么只有缓存中的数据被删除了,但是数据库没有更新,那么在下一次进行查询的时候,查不到缓存,只能重新查询数据库,构建缓存,这样其实也是相对做到了数据一致性。
但如果是处于读写并发的情况下,还是会出现数据不一致的情况:
执行完成后,明显可以看出,1号用户所构建的缓存,并不是最新的数据,还是存在问题的。
4、先更新数据库,在删除缓存
如果更新数据库成功了,而删除缓存失败了,那么数据库中就会是新数据,而缓存中是旧数据,数据就出现了不一致情况。
和之前一样,如果两段代码都执行成功,在并发情况下会是什么样呢?
还是会造成数据的不一致性。
但是此处达成这个数据不一致性的条件明显会比起其他的方式更为困难 :
- 时刻1:读请求的时候,缓存正好过期
- 时刻2:读请求在写请求更新数据库之前查询数据库,
- 时刻3:写请求,在更新数据库之后,要在读请求成功写入缓存前,先执行删除缓存操作。
这通常是很难做到的,因为在真正的并发开发中,更新数据库是需要加锁的,不然没一点安全性~
一定程度上来讲,这种方式还是解决了一定程度上的数据不一致性问题的。
以上四种方式无论选择那种方式,如果实在多服务或时并发的情况下,其实都是有可能产生数据不一致性的。
三、解决方案
1、延迟双删
先进行缓存清除,再执行 update,最后(延迟N秒)再执行缓存清除。
进行两次删除,且中间需要延迟一段时间。
java
// 延迟双删伪代码
public void write(String key,Object data){
deleteRedisCache(key); // 删除redis缓存
updateMysqlSql(obj); // 更新mysql
Thread.sleep(100); // 延迟一段时间
deleteRedisCache(key); // 再次删除该key的缓存
}
延迟双删的流程图:
解决这样的问题,其实最好的方式就是在执行完更新数据库的操作后,先休眠一会儿,再进行一次缓存的删除,以确保数据一致性。
首先延迟删除的时间需要大于用户1执行流程的总时间,就是1号用户从数据库读取数据 写入缓存时间。
2、加入 MQ
无论是更新缓存还是删除缓存,在同时操作缓存和数据库时,都无法保证两者都能一次性操作成功,所以我们最好的办法就是重试,这个重试并不是立即重试,因为缓存和数据库可能因为网络或者其它原因停止服务了,立即重试成功率极低,而且重试会占用线程资源,显然不合理,所以需要采用异步重试机制。
异步重试可以使用消息队列来完成,因为消息队列可以保证消息的可靠性,消息不会丢失,也可以保证正确消费,当且仅当消息消费成功后才会将消息从消息队列中删除。
优点:
- 可以大幅减少接口的延迟返回的问题
- MQ 本身有重试机制,无需人工去写重试代码
- 解耦,把查询 MySQL 和同步 Redis 完全分离,互不干扰
3、Canal 订阅日志
Canal 是一个阿里巴巴开源的项目,用于监听并捕获 MySQL 数据库的 binlog 日志变化,以便实时处理数据变更事件。
修改数据时只需要更新数据库,无需修改缓存,那什么时候修改缓存呢?
在数据库一条记录发生变更时就会生成一条 binlog 日志,我们可以订阅这种消息,拿到具体的数据,然后根据日志消息更新缓存,订阅日志目前比较流行的就是阿里开源的 Canal,那么我们的架构就变为如下形式:
订阅数据库变更日志,当数据库发生变更时,就可以拿到具体操作的数据,然后再去根据具体的数据,去删除对应的缓存。
当然 Canal 也是要配合消息队列一起来使用的,因为其 Canal 本身是没有数据处理能力的。
这个方式算的上彻底解耦了,应用程序代码无需再管消息队列方面发送失败问题,全交由 Canal来发送。
参考文章:Redis数据一致性问题的三种解决方案-CSDN博客
一 叶 知 秋,奥 妙 玄 心