在数据读多写少的情况下,作为缓存来使用,恐怕Redis的使用是最普遍的场景了。当时用Redis作为缓存的时候,一般流程是这样的。
如果缓存在Redis中存在,即缓存命中,则直接返回数据。
如果Redis中没有对应缓存,则需要直接查询数据库,然后存入Redis,最后把数据返回。
通常情况下,我们会为某个缓存设置一个key值,并针对key值设置一个过期时间,如果被查询的数据对应的key过期了,则直接查询数据库,并将查询得到的数据存入Redis,然后重置过期时间,最后将数据返回,伪代码如下:
/**
* 根据用户名获取用户详细信息
*/
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;
}
一致性问题
在Redis的key值未过期的情况下,用户修改了个人信息,我们此时既要操作数据库数据,也要操作Redis数据。导致我们现在面临着2种选择:
1. 先操作Redis的数据,在操作数据库的数据。
2. 先操作数据库的数据,在操作Redis的数据。
不论选择哪种处理方式,最理想的情况下,两个操作要么同时成功,要么同时失败,否则就会出现Redis和数据库的书库不一致情况。
遗憾的是,目前没有什么框架能够保证Redis和数据库的数据一致性。我们只能根据场景和所需付出的代码来采取一定的措施,降低数据不一致出现的概率,在一致性和性能之间取一个折中。
下面我们来讨论关于Redis和数据库质检数据一致性的一些方案。
方案选择
是删除缓存还是更新缓存?
当数据库的数据发生变化的时候,Redis的数据也需要进行相应的操作,那么这个操作是选择 更新or删除呢?
更新的话调用Redis的set方法,新值替换旧值;删除直接删除原来的缓存,下次查询直接去数据库读取,然后再更新Redis。
结论:推荐直接使用删除操作。
使用更新操作的话,会面临两种选择
1.先更新缓存,再更新数据库。
2.先更新数据库,再更新缓存。
第一种不用考虑了,下面讨论一下第二种先更新数据库的方案。
如果线程1和线程2同时进行更新操作,但是每个线程的执行顺序如上图所示,此时就会导致数据不一致,因此从这个角度上我们推荐直接使用删除缓存的方式。
此外,推荐使用删除缓存还有两点原因。
1.如果写数据库的场景比读数据库场景多,采用这种方案就会导致缓存被频繁写入,浪费性能;
2.如果缓存要结果一系列的复杂计算才能得到,那么每次写入数据库后,都再次计算写入的缓存,无疑也是浪费性能的。
明确了这个问题后,摆在我们面前的还是两个选择。
1.先更新数据库,再删除缓存
2.先删除缓存,再更新数据库
先更新数据库,再删除缓存
数据不一致的情况列举:
1) 线程A读取商品数据,刚好缓存失效了,故查询数据库数据,刚要把得到的数据放入Redis缓存中,这时候cpu发生上下文切换,线程A暂时得不到执行。
2)好,那么此时线程B来修改数据,执行update操作,然后删除缓存。
3)再到线程A获得执行时间片后,继续执行未完成的操作,将之前查询到的旧数据存到Redis中,此时就会出现Redis和数据库的数据不一致。
当然这种情况,条件比较严苛,出现的可能性较低一些。
解决方案:
那这种情况一般会有两种解决方式:失败重试和异步更新。
失败重试
如果删除缓存失败,我们可以捕捉这个异常,把需要删除的key发送给消息队列。自己创建一个消费者消费,尝试再次删除这个key,直到删除成功为止。
这种处理方式有个缺陷,首先会对业务代码造成入侵,其次引入了消息队列,增加了系统的不确定性。
异步更新缓存
因为更新数据库时会忘binlog中写入日志,所以我们可以启动一个监听binlog变化的服务(比如使用阿里的canal开源组件),然后再客户端完成删除key操作。如果删除失败的话,再发送到消息队列。
总结:
总而言之,对于删除缓存失败的情况,我们的做法是不断地尝试删除操作,直到成功。无论选择哪种处理方式,始终只能保证一点时间内的最终一致性。只要系统有写的操作,就无法保证任意时刻的一致性。
先删除缓存,再更新数据库
数据不一致的情况:
1) 线程A修改数据库时,需先删除缓存数据
2) 线程B在线程A删除缓存和执行update操作期间,查询了数据库得到了旧数据,再写入Redis缓存,此时就有极大的概率会出现数据不一致的情况。
解决方案列举:
之前项目里就有过类似的解决方案:先操作缓存,但是不能删除缓存,可以将缓存设置为一个特殊值,与业务无关的一个特殊值(-999),客户端读取缓存时,发现时特殊值,就休眠一会,再去查一次Redis。(注意:特殊值是对业务有侵入的,指不定什么时候就会用到这个特殊值。以及休眠时间,可能会多次重复,如高并发情况下,缓存频繁被设置为特殊值,那么进来访问的线程都会进入休眠状态,这样对性能会有影响)
下面介绍另一种业内常见的机制,延时双删策略
先删除缓存,再写入数据库,休眠一会,再次删除缓存,如果写操作很频繁,同样会存在读取到脏数据的问题。注意:如线程进行sleep休眠一秒,系统对接口的性能要求比较高,超过半秒都不行,更别说一秒了。那么可以启动一个线程来异步执行延迟删除key,就可以解决时间问题。使用@Async注解来进行删除key即可。
@Async介绍:在Spring中,基于@Async标注的方法,称之为异步方法。这些方法在执行的时候,会独立起一个线程,调用者无需等待它的完成,即可继续其他操作。
延迟双删策略理解图。
用伪代码表示就是:
/**
* 延时双删
*/
public void update(String key, Object data) {
// 首先删除缓存
redisCache.delKey(key);
// 更新数据库
db.updateData(data);
// 休眠一段时间,时间依据数据的读取耗费的时间而定
Thread.sleep(500);
// 再次删除缓存
redisCache.delKey(key);
}
总结:
总结还是同理,不论如何处理,选择那种方式,始终只能保证一点时间内的最终一致性。只要系统有写的操作,就无法保证任意时刻的一致性。