缓存的类型
按照Redis是否接收写请求,可以将缓存分为读写缓存和只读缓存
只读缓存
当Redis作为只读缓存时,应用程序要读取数据时,会去Redis中查找。而应用程序的写请求则会发往后端的数据库,在数据库中完成增删改操作。对于删除、修改动作,如果Redis中已经缓存了这些数据量,那么应用程序需要把这些缓存数据给删除掉。
//增删改伪代码
db.updateData(X);
redis.deleteKey(X);
1、对数据库进行增删改操作
2、删除缓存(这里先不考虑缓存数据一致性问题)
当应用程序再次需要读取这些数据时,发现在Redis中找不到数据,就会去数据库中把这些数据读取出来并写入到缓存中。这样一来,这些数据再次被读取时,就可以直接从缓存中获取了,能起到加速访问的效果。
//读取数据伪代码
result=redis.getKey(X);
if(result = null){
data=db.getData(X);
redis.setData(X,data);
}
1、先从缓存中读取数据
2、当缓存中数据不存在时,转而去数据库中查找数据
3、将数据更新到缓存
只读缓存直接在数据库中更新数据的好处是所有最新的数据都是在数据库中的,这些数据不会有丢失风险
读写缓存
读写缓存,顾名思义 就是读请求和写请求都交给缓存处理,在缓存中进行数据的查询和增删改操作。
这样确实可以提升应用程序的响应速度,但是一旦出现掉电或宕机,内存中的数据就会有丢失,给业务带来风险
根据应用程序对数据可靠性和缓存性能的不同要求,对读写缓存有同步直写和异步写回两种策略。其中,同步直写可以保证数据可靠性,而异步写回策略可以提供快速响应。
同步直写
在写请求发给缓存的同时,也会发给后端数据库进行处理,等到缓存和数据库都处理完毕,才向客户端返回。这样,即使缓存宕机或发生故障了,最新的数据依然保存在数据库中。不过同步直写会降低缓存的访问性能,因为快速的写缓存要等待慢速的写数据库完毕后才返回结果。
异步写回
所有的写请求都先在缓存中处理,然后异步地将这些请求的增删改数据再写回数据库。它可以降低响应延迟,但是如果发生了掉电,而这些数据还没有被写回数据库,就会有数据丢失的风险。
问题
Redis只读缓存和同步直写都会把数据同步写到数据库中,那它们有什么区别吗?
1、只读缓存,是先把修改写到库中,再删除缓存。等下次访问这个数据时,会从数据库加载到缓存中,这可以保证数据库和缓存一致性,但由于每次修改数据后都会删除缓存,导致接下来的一次读请求要读库,会导致访问延迟变大。但是当只读缓存操作中出现失败或在高并发情况下,都可能会出现缓存和数据库数据不一致。
2、同步直写是同时修改数据库和缓存,这可以确保被修改的数据一直在缓存中,下次读请求会命中缓存,它可以保证更好的访问性能。但在高并发场景下,可能会出现缓存和数据库数据不一致
缓存和数据库的数据一致性
一致性包含以下两种情况:
- 缓存中有数据,那么缓存中的数据要与数据库中数据相同(对应删除、修改数据动作)
- 缓存中没有数据库,那么数据库中的数据必须是最新值(对应新增数据动作)
读写缓存的数据一致性
针对读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。 必须要保证缓存和数据库的更新具有原子性,这种情况下需要使用分布式事务机制来保证了。
只读缓存的数据一致性
只读缓存由于其是先对数据库增删改,再删除缓存。在这个过程中可能会出现数据不一致的情况。
当新增数据时,数据直接写到数据库,这可以保证缓存和数据库的一致性。
先删缓存再更新数据库
当线程A更新数据出错时,也会出现数据不一致情况
时间 | 线程A | 线程B |
---|---|---|
t1 | 删除数据x的缓存值 | |
t2 | 更新数据库中X的数据(发生错误,更新失败) | |
t3 | 1、从缓存读取x时发现数据不存在,再去数据库中读取X,读到了旧值 2、将旧值数据写入缓存中 |
针对更新数据出错,可以在开发阶段多注意下,先忽略掉这种情况,后面会单独拿出来说
情况2
时间 | 线程A | 线程B |
---|---|---|
t1 | 删除数据x的缓存值 | |
t2 | 1、从缓存读取x时发现数据不存在,再去数据库中读取X,读到了旧值 2、将旧值数据写入缓存中 |
|
t3 | 更新数据库中X的数据 |
在并发量比较大或数据X为热点数据时,极易出现读取到未更新数据的情况。此时缓存中的数据是旧值,而数据库中的是最新值。这时缓存中的旧数据得等到下一次删除缓存时才能被调整成正确数据,这可能会持续一段时间。
针对情况2,市面上给出了采用延迟双删的方案,即在线程A更新完数据库值以后,可以先让它sleep一小段时间,再进行一次缓存删除操作.
//伪代码
redis.delKey(X);
db.updateData(X);
Thread.sleep(N);
redis.delKey(X);
但是,①sleep时间不好把控。② 在高并发场景下,依然会存在线程B读取到旧值现象,依然会出现缓存数据不一致的情况,只是可能缓存中旧数据存在的时间会短一些(sleep后再次删除了)
可以尝试使用MySQL 互斥锁思路,思路如下:
假设我们这里是按照主键来更新数据,并且线程B也是要查找id=1的数据
线程A伪代码
//开启事务
redis.delKey(X);
//update user set name='wojiushwo' where id=1
db.updateData(X);
//提交事务
线程B伪代码
//开启事务
result=redis.getKey(X);
if(result = null){
//select * from user where id=1 for update;
data=db.getData(X);
redis.setData(X,data);
}
//提交事务
线程A中update user set name='wojiushwo' where id=1
为id=1这条数据加上一把互斥锁
线程B中select * from user where id=1 for update;
也为id=1这条数据加上一把互斥锁。
因此即使出现上述情况2,线程B也会被阻塞到线程A执行完更新操作提交事务后 才能解除锁定。
另外使用分布式锁使线程A、线程B转为串行运行 应该也可以解决这个问题。
先更新数据库再删缓存
时间 | 线程A | 线程B |
---|---|---|
t1 | 更新、修改数据库中X的数据 | |
t2 | 从缓存读取x时发现数据存在,直接读到了旧值 | |
t3 | 删除数据x的缓存值 |
这种场景下如果线程A更新或修改了数据库中的值,在还未删除缓存时,线程B就开始读取数据了,此时线程B可以命中缓存,并且读到了旧数据。
当其他线程并发读缓存的请求不多时,就不会有很多请求读取到旧值,并且线程A紧接着就删除缓存了,这样一来,其他线程再次读取时,就会去数据库读最新值了。对业务的影响较小。
重试机制
前面两种情况下,无论如何调整更新数据库与删除缓存的顺序,如果其中一个步骤操作失败了,当有大量并发请求时,应用还是会出现数据不一致的情况的。针对这些情况 可以采用重试机制,毕竟更新或删除操作是幂等的,多次执行结果不变。
具体来说,可以把要书暗处的缓存值或要更新的数据库值暂存到消息队列中。当应用没有成功执行删除缓存或更新数据库操作时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
如果能够成功删除或更新,我们就把这些值重消息队列去除,此时我们可以保证数据库和缓存的数据一致性。否则的话,我们还需要再次重试。如果重试超过一定次数,还是没有成功,就要向业务程序报错了。
问题
在只读缓存中进行数据的删改操作时,需要在缓存中删除相应的缓存值。如果在这个过程中,我们不删除缓存值,而是直接更新缓存的值,这和删除缓存值相比,有什么好处和不足吗?
这种情况相当于把Redis当做读写缓存来使用,删除、修改同时操作数据库和缓存。
不存在并发请求时
1、先更新数据库,再更新缓存;如果更新数据库成功,但更新缓存失败了,缓存中仍是旧值,此时后续的读请求会直接命中缓存,得到旧值
2、先更新缓存,再更新数据库;如果缓存更新成功但是数据库更新失败。后续的读请求会命中缓存并读到最新值。但是一旦缓存过期或执行内存淘汰策略后,随之而来的读请求就需要从数据库中加载数据至缓存了,而我们知道数据库中存储的是旧值,这会对业务产生影响的。
其实 上面的这两种情况,因为某一步骤失败导致的数据不一致,可以采用重试机制来解决
存在并发请求时
1、先更新数据库,再更新缓存,写+读并发
时间 | 线程A | 线程B |
---|---|---|
t1 | 更新、修改数据库中X的数据 | |
t2 | 从缓存读取x时发现数据存在,直接读到了旧值 | |
t3 | 更新数据x的缓存值 |
线程A先更新数据库,之后线程B读取数据,此时线程B会直接命中缓存读到旧值,之后线程A更新缓存成功,后续的读请求会命中缓存得到最新值。这种情况会出现短暂数据不一致情况。
2、先更新缓存,再更新数据库 写+读并发
时间 | 线程A | 线程B |
---|---|---|
t1 | 更新数据x的缓存值 | |
t2 | 从缓存读取x时命中缓存,直接读到了最新值 | |
t3 | 更新、修改数据库中X的数据 |
这种情况即使线程B先于线程A更新数据库之前读取数据,但此时缓存是最新值,虽然数据库与缓存会短暂不一致,但丝毫不影响线程B读取正确数据,因此这种情况对业务没影响。
3、先更新数据库,再更新缓存, 写+写并发
时间 | 线程A | 线程B |
---|---|---|
t1 | 更新、修改数据库中X的数据 | |
t2 | 更新、修改数据库中X的数据 | |
t3 | 更新数据x的缓存值 | |
t4 | 更新数据x的缓存值 |
比如线程A、线程B 都要对id=1这条数据进行修改,线程A要将数据列改为10,线程B要将数据列改为20。
当按照线程A、线程B顺序操作数据库后,数据列已经被改为20了(10就相当于旧数据了)。但是由于更新缓存顺序是先线程B执行再线程A执行,就会把线程A的旧数据更新到缓存中,导致缓存和数据库数据不一致。
4、先更新缓存,再更新数据库, 写+写并发
时间 | 线程A | 线程B |
---|---|---|
t1 | 更新数据x的缓存值 | |
t2 | 更新数据x的缓存值 | |
t3 | 更新、修改数据库中X的数据 | |
t4 | 更新、修改数据库中X的数据 |
按照上面的思路,这种顺序也会导致数据库与缓存数据不一致,缓存中是最新数据了,而数据库则是旧数据。
针对场景3和场景4,对于写请求需要配合分布式锁来使用,确保同一时间只有一个线程去更新数据库和缓存。
除此之外,采用更新缓存的方式可确保缓存中一直有数据,可以保证读请求命中缓存,降低读请求对数据库的压力。但是如果此数据更新后很少被访问到,会导致缓存中保留的不是热点数据,缓存利用率不高。