在数据读多写少的情况下作为缓存来使用,恐怕是Redis使用最普遍的场景了。当使用Redis作为缓存的时候,一般流程是这样的。
- 如果缓存在Redis中存在,即缓存命中,则直接返回数据
- 如果Redis中没有对应缓存,则需要直接查询数据库,然后存入Redis,最后把数据返回
通常情况下,我们会为某个缓存设置一个key值,并针对key值设置一个过期时间,如果被查询的数据对应的key过期了,则直接查询数据库,并将查询得到的数据存入Redis,然后重置过期时间,最后将数据返回,伪代码如下:
代码语言:java复制/**
* 根据用户名获取用户详细信息
* @author 公众号【蝉沐风】
*/
public User getUserInfo(String userName) {
User user = redisCache.getName("user:" userName);
if (user != null) {
return user;
}
// 从数据库中直接搜索
user = selectUserByUserName(userName);
// 将数据写入Redis,并设置过期时间
redisCache.set("user:" userName, user, 30000);
// 返回数据
return user;
}
1. 一致性问题
但是,在Redis的key值未过期的情况下,用户修改了个人信息,我们此时既要操作数据库数据,也要操作Redis数据。现在我们面临了两种选择:
- 先操作Redis的数据,再操作数据库的数据
- 先操作数据库的数据,再操作Redis的数据
如论选择哪种方法,最理想的情况下,两个操作要么同时成功,要么同时失败,否则就会出现Redis和数据库数据不一致的情况。
遗憾的是,目前没有什么框架能够保证Redis的数据和数据库的数据的完全一致性。我们只能根据场景和所需要付出的代码来采取一定的措施降低数据不一致出现的概率,在一致性和性能之间取得一个折中。
下面我们来讨论一下关于Redis和数据库之间数据一致性的一些方案。
2. 方案选择
2.1. 是删除缓存还是更新缓存?
当数据库数据发生变化的时候,Redis的数据也需要进行相应的操作,那么这个「操作」到底是用「更新」还是用「删除」呢?
「更新」的话调用Redis的set方法,新值替换旧值;「删除」直接删除原来的缓存,下次查询的时候重新读取数据库,然后再更新Redis。
结论:推荐直接使用「删除」操作。
因为使用「更新」操作的话,你会面临两种选择
先更新缓存,再更新数据库- 先更新数据库,再更新缓存
第1种不用考虑了,下面讨论一下「先更新数据库,再更新缓存」这种方案。
如果线程1和线程2同时进行更新操作,但是每个线程的执行顺序如上图所示,此时就会导致数据不一致,因此从这个角度上我们推荐直接使用删除缓存的方式。
此外,推荐使用「删除缓存」还有两点原因。
- 如果写数据库的场景比读数据场景多,采用这种方案就会导致缓存就被频繁写入,浪费性能;
- 如果缓存要经过一系列复杂的计算才能得到,那么每次写入数据库后,都再次计算写入的缓存无疑也是浪费性能的。
明确这个问题之后,摆在我们面前的就只有两个选择了:
- 先更新数据库,再删除缓存
- 先删除缓存,再更新数据库
2.2. 先更新数据库,再删除缓存
这种方式可能存在以下两种异常情况
- 更新数据库失败,这时可以通过程序捕获异常,直接返回结果,不再继续删除缓存,所以不会出现数据不一致的问题
- 更新数据库成功,删除缓存失败。导致数据库是最新数据,缓存中的是旧数据,数据不一致
第2种情况应该怎么办呢?我们有两种方式:失败重试和异步更新。
2.2.1. 失败重试
如果删除缓存失败,我们可以捕获这个异常,把需要删除的 key 发送到消息队列。自己创建一个消费者消费,尝试再次删除这个 key,直到删除成功为止。
这种方式有个缺点,首先会对业务代码造成入侵,其次引入了消息队列,增加了系统的不确定性。
2.2.2. 异步更新缓存
因为更新数据库时会往 binlog 中写入日志,所以我们可以启动一个监听 binlog变化的服务(比如使用阿里的 canal开源组件),然后在客户端完成删除 key 的操作。如果删除失败的话,再发送到消息队列。
总结
总之,对于删除缓存失败的情况,我们的做法是不断地重试删除操作,直到成功。无论是重试还是异步删除,都是最终一致性的思想。
2.3. 先删除缓存,再更新数据库
这种方式可能存在以下两种异常情况
- 删除缓存失败,这时可以通过程序捕获异常,直接返回结果,不再继续更新数据库,所以不会出现数据不一致的问题
- 删除缓存成功,更新数据库失败。在多线程下可能会出现数据不一致的问题
这时,Redis中存储的旧数据,数据库的值是新数据,导致数据不一致。这时我们可以采用延时双删的策略,即更新数据库数据之后,再删除一次缓存。
用伪代码表示就是:
代码语言:java复制/**
* 延时双删
* @author 公众号【蝉沐风】
*/
public void update(String key, Object data) {
// 首先删除缓存
redisCache.delKey(key);
// 更新数据库
db.updateData(data);
// 休眠一段时间,时间依据数据的读取耗费的时间而定
Thread.sleep(500);
// 再次删除缓存
redisCache.delKey(key);
}
最后给读者留下两个思考题:
- 为什么
先更新缓存,再更新数据库行不通? - 延时双删的方法为什么要休眠一段时间呢?
欢迎大家评论区留言。