目录
2.1 旁路缓存模式(Cache Aside Pattern)
2.2 读写穿透模式(Read/Write Through Pattern)
2.3 异步缓存写入(Write Behind Pattern)
Redis的数据是存在内存中的,所以他的读写速度非常快,被广泛地应用在缓存的方向,Redis存储的是KV键值对数据。
Redis可以解决数据库的IO操作的压力,对于一些需要耗时很久而且结果不怎么频繁变动的SQL,我们可以将这样的数据放到Redis缓存中,这使得请求能够得到迅速的响应。
在高并发情况下,如果所有的请求都直接访问数据库,数据库会出现连接异常,这个时候我们引入redis做一个缓冲操作,能够有效提高数据库的高并发的承受能力。
1. Redis的数据结构
Redis常用的数据结构有String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)五种。
(1)String 的数据结构为简单动态字符串(Simple Dynamic String,缩写 SDS)。是可以 修改的字符串,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式 来减少内存的频繁分配。,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次 只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。
String是Redis中最常见也是最常用的一种数据结构,他可以用来存储任何类型的数据如字符串、整数、浮点数、图片(编码后)、序列化后的对象等数据。String主要应用于session、token、序列化后的对象、图片的路径等的缓存
(2)Redis中的List数据结构是一个简单的字符串列表,按照插入顺序排序,可以添加一个元素到列表的头部或者尾部。List 的数据结构为快速链表 quickList。首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是压缩列表。
ziplist是由一系列特殊编码的连续内存块组成的顺序存储结构,类似于数组,ziplist在内存中是连续存储的,但是不同于数组,为了节省内存 ziplist的每个元素所占的内存大小可以不同,每个节点可以用来存储一个整数或者一个字符串。
ziplist类似于双向链表,但是它不存储上一个节点和下一个节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,节约内存。如下图所示:
它将所有的元素紧挨着一起存储,分配的是一块连续的内存。 当数据量比较多的时候才会改成 quicklist。 因为普通的链表需要的附加指针空间太大,会比较浪费空间。比如这个列表里存的只 是 int 类型的数据,结构上还需要两个额外的指针 prev 和 next。
Redis 将链表和 ziplist 结合起来组成了 quicklist。也就是将多个 ziplist 使用双向指 针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。
(3)Redis中的Set数据结构和List数据结构的功能差不多,区别在于Set数据结构可以实现降重的功能。Set 数据结构是 dict 字典,字典是用哈希表实现的。 Java 中 HashSet 的内部实现使用的是 HashMap,只不过所有的 value 都指向同一个对象。 Redis 的 set 结构也是一样,它的内部也使用 hash 结构,所有的 value 都指向同一个内部值。
(4)Redis中的Hash数据结构是一个键值对集合,特别适合存储对象。Hash 类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当 field-value 长度较短且个数较少时,使用 ziplist,否则使用 hashtable。
(5)Redis的有序集合数据结构(Zset)。Redis 有序集合 zset 与普通集合 set 非常相似,是一个没有重复元素的字符串集合。 不同之处是有序集合的每个成员都关联了一个评分(score),这个评分(score)被用 来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分 可以是重复了 。 因为元素是有序的, 所以你也可以很快的根据评分(score)或者次序(position)来获 取一个范围的元素。
SortedSet(zset)是 Redis 提供的一个非常特别的数据结构,一方面它等价于 Java 的数据结构 Map,可以给每一个元素 value 赋予一个权重 score,另 一方面它又类似于 TreeSet,内部的元素会按照权重 score 进行排序,可以得到每个元 素的名次,还可以通过 score 的范围来获取元素的列表。 zset 底层使用了两个数据结构 (1)hash,hash 的作用就是关联元素 value 和权重 score,保障元素 value 的唯 一性,可以通过元素 value 找到相应的 score 值。(2)跳跃表,跳跃表的目的在于给元素 value 排序,根据 score 的范围获取元素列表。
有序集合在生活中比较常见,例如根据成绩对学生排名,根据得分对玩家排名 等。对于有序集合的底层实现,可以用数组、平衡树、链表等。数组不便元素的插入、 删除;平衡树或红黑树虽然效率高但结构复杂;链表查询需要遍历所有效率低。Redis 采用的是跳跃表。跳跃表效率堪比红黑树,实现远比红黑树简单。
2. Redis缓存的三种缓存读写策略
2.1 旁路缓存模式(Cache Aside Pattern)
该模式比较适合读请求比较多的场景。
写策略:首先将需要写入的数据加入到数据库中,然后再直接删除缓存中的该数据。
读策略:首先尝试从缓存中读取数据,如果读取不到就从数据库中读取,然后将读取到的数据加入到缓存中。
2.2 读写穿透模式(Read/Write Through Pattern)
写策略:首先查询缓存,如果缓存中不存在该数据,就直接写入数据库。如果缓存中存在该数据,先更新缓存,然后再缓存自己更新db。
读策略:从缓存中读取数据,如果读取到就直接返回,如果读取不到就先从数据库中加载,写入到缓存后再响应读的请求。
读写穿透策略相对于旁路缓存模式的区别是,读写穿透策略是以缓存为中心。在写的时候先更新缓存中的数据,然后再更新数据库信息。在读的时候也是先从缓存在读取,就算缓存中不存在,从数据库中拿到数据也是先更新到缓存再响应。
2.3 异步缓存写入(Write Behind Pattern)
异步缓存写入和读写穿透模式很相似,都是以缓存为中心。区别在于读写穿透模式是同步更新缓存和数据库,而异步缓存写入只更新缓存,不直接更新数据库,而是改为异步批量的方式更新数据库。
3. Redis持久化
3.1 快照持久化(snapshotting, RDB)
Redis可以通过快照获得存储在内存中的数据在某个时间节点的副本。Redis创建快照后,可以对快照备份,将快照赋值到其他服务器从而创建相同数据的服务器副本。
快照持久化是Redis默认的持久化方式。
3.2 AOF(append only file)持久化
开发AOF持久化后,每一条更改Redis数据的命令,Redis首先将该命令写入到AOF缓存区server.aof_buf中,然后再写入到AOF文件中(该文件在系统内核缓存区),然后再根据持久化方式的配置决定何时将系统内缓存区的数据同步到硬盘中。
3.2.1 AOF工作的基本流程
1.命令追加(append):将所有的写命令加入到AOF缓存区中
2.文件写入(write): 调用write函数(系统调用)将AOF缓存区的数据写入到AOF文件中。write将数据写入到系统内核缓存区。
3.文件同步(fsync):调用fsync函数(系统调用)将AOF缓存区根据对应的持久化方式(fsync策略)向硬盘做同步策略。
4.文件重写(rewrite): 定期对AOF文件进行重写,达到压缩的目的。
5.重启加载(load): 当Redis重启后,加载AOF文件进行数据恢复。
4. Redis的发布订阅和主从复制
4.1 Redis的发布订阅
Redis的发布订阅是一种消息通信模式,发布(pub),订阅(sub)。
首先打开redis的一个客户端,订阅一个频道channel1,我们也可以在subscribe后面添加多个频道的名字,这样就可以订阅多个频道,如下图所示:
然后我们打开另外一个客户端,向频道channel1中发送消息,订阅channel1的客户端就会接收到发送的的消息,如下图所示,我们向channel1中发送消息hello:
在订阅的客户端我们可以看到,收到了发送的消息。
4.2 Redis的主从复制
Redis的主从复制是指将一台Redis服务器的数据,复制到其他Redis服务器的机制。前者称为主节点(master),后者称为从节点(slave)。Master以写为主,Slave以读为主。Redis的主从复制可以实现读写分离,能够拓展性能,容灾的快速恢复。
对于从Redis可以执行slaveof ip地址 端口命令,指定自己作为哪个Redis的从Redis。在主Redis上写入数据,在从Redis上即可读取数据。在从Redis上不可写入数据。这就是主从复制的读写分离,主Redis负责写,从Redis负责读。如果主Redis挂掉,重启即可,一切如初。如果从Redis重启,需要重新设置一下slaveof ip地址 端口,将自己加入到主Reids中,如果不设置的话,该Redis服务是单独存在的,不存在主从关系。
4.2.1 Redis主从复制的一主两从方式
主从复制的复制原理:当从服务器连接上主服务器侯,从服务器从主服务器发送数据进行同步消息。主服务器接收到从服务器发送过来的同步消息,把主服务器进行数据持久化,rdb文件,然后将rdb文件发送给从服务器,从服务器拿到rdb进行读取。每次主服务进行写操作后,就会和从服务器进行数据同步。
如果从服务器挂掉,然后重新启动并加入到主redis中,那从服务会读取主Redis中的所有数据,仍然能读取主服务器的所有数据。
如果主服务器挂掉,从服务器并不会变成主服务器,等到主服务器重启后,主服务器仍是主服务器。
4.2.2 Redis主从复制的薪火相传
一主两从是指一台主服务器,多个从服务器。而薪火相传是指一个主服务器下面有多个从服务器,从服务器下面还有从服务器,这样的模式在大量Redis服务器时,较为优越。但是对于薪火相传模式来说,如果中间的一个服务器挂掉之后,后续的服务器将不能工作。
薪火相传的主服务器的工作模式和一主二仆一样,当主服务器挂掉之后从服务器并不会上位,主服务器重启后仍然是主服务器。
4.2.3 Redis主从复制的反客为主
反客为主模式和一主二仆的不同在于,主服务器挂掉之后,从服务器会上位变成主服务器。在从服务器中运行slaveof no one即可将从服务器变为主服务器。但是这种解决方案仍然需要手动操作,我们可以引入反客为主的自动版哨兵模式解决这种情况。
4.2.4 哨兵模式
该模式是反客为主的自动版,能够后台监控主机是否故障,如果发生了故障,就会根据投票数将从服务器转变为主服务器。
如果想要使用哨兵模式,需要先启动一个Redis作为哨兵,首先需要新建一个名为sentinel.conf的配置文件,然后在配置文件中写入sentinel monitor mymaster 127.0.0.1 6379 1命令。其中mymaster是为监控对象起的服务器名称,1表示至少有多少个哨兵同意迁移的数量(至少有多少个哨兵同意,才可以进行从服务器到主服务器的迁移)。
执行redis-sentinel sentinel.conf启动哨兵。当主服务器挂掉之后,哨兵会从所有的从服务器中选取一个服务器作为主服务器。当已经挂掉的主服务器重启之后,只会作为新的主服务器的从服务器,而不会作为主服务器。
从服务器成为主服务器的选举主要有三个条件:
1、选择优先级靠前的从服务器。redis.conf配置中的slave-priority的值越小,优先级越高。
2、选择偏移量最大的从服务器。偏移量指的是从服务器中和主服务器的数据同步值,同步值越高代表偏移量越大。
3、选择runid最小的从服务器。如果1,2都相同的话,再看runid的值,redis每次启动都会生成40位的runid,越小的runid被选为作为主服务器的优先级越高。
5. Redis线程模型
对于读写命令,Redis一直是单线程模型。Redis基于Reactor模式设计开发了一套高效的事件处理模式。虽然Redis是单线程模型,但是Redis的文件事件处理器可以通过IO多路复用程序监听来自客户端的大量连接(监听多个socket),并根据套接字目前执行的任务为套接字关联不同的事件处理器。
Redis的IO多路复用技术的使用使得Redis不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗。
Redis的文件事件处理器的流程如下4步所示:
1. 多个socket(客户端连接)
2. IO多路复用程序(支持多个客户端连接的关键)
3. 文件事件分派器(将socket关联到相应的事件处理器)
4. 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
6. Redis事务
Redis事务提供了一种将多个命令请求打包的功能,然后,再按顺序执行打包的所有命令,并且不会被中途打断。Redis事务与我们平时理解的关系型数据库的事务不一样,Redis不满足事务的原子性,事务中的每条命令都会与Redis服务器进行网络交互,比较浪费资源。
6.1 Redis事务的操作指令
Redis主要通过MULTI、EXEC、DISCARD、WATCH等命令实现事务的功能。
MULTI:开启事务的命令,在使用这个命令后,可以输入多条Redis事务处理命令,Redis将这些命令放到队列中,当使用EXEC命令后,再执行所有的命令。
DISCARD: 取消一个事务的命令,他会清空事务队列中保存的所有命令。
EXEC:执行事务的命令,依次执行在Redis队列中所有的命令。
WATCH:监听指定Key的指令,当调用EXEC命令执行事务时,如果一个WATCH命令监听的Key被其他客户端/Session修改的话,整个事务都不会被执行。但是如果WATCH与事务在同一个Session中,并且被WATCH监视的Key被修改的操作发生在事务内部,这个事务是可以被执行成功的。具体的代码示例如下所示:
package com.project.test;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
/**
* Rdis 事务
* @author wqj24
*
*/
public class TestTX {
static final Jedis jedis = new Jedis("132.232.6.208", 6381);
/**
* 清空数据库
*/
@Before
public void flushdb() {
jedis.flushDB();
}
@Test
public void commitTest() {
// 开启事务
Transaction transaction = jedis.multi();
transaction.set("key-1", "value-1");
transaction.set("key-2", "value-2");
transaction.set("key-3", "value-3");
transaction.set("key-4", "value-4");
// 提交事务
transaction.exec();
System.out.println(jedis.keys("*"));
}
@Test
public void discardTest() {
// 开启事务
Transaction transaction = jedis.multi();
transaction.set("key-1", "value-1");
transaction.set("key-2", "value-2");
transaction.set("key-3", "value-3");
transaction.set("key-4", "value-4");
// 放弃事务
transaction.discard();
System.out.println(jedis.keys("*"));
}
/**
* watch 命令会标记一个或多个键
* 如果事务中被标记的键,在提交事务之前被修改了,那么事务就会失败。
* @return
* @throws InterruptedException
*/
@Test
public void watchTest() throws InterruptedException {
boolean resultValue = transMethod(10);
System.out.println("交易结果(事务执行结果):" + resultValue);
int balance = Integer.parseInt(jedis.get("balance"));
int debt = Integer.parseInt(jedis.get("debt"));
System.out.printf("balance: %d, debt: %d\n", balance, debt);
}
// 支付操作
public static boolean transMethod(int amtToSubtract) throws InterruptedException {
int balance; // 余额
int debt; // 负债
jedis.set("balance", "100");
jedis.set("debt", "0");
jedis.watch("balance", "debt");
balance = Integer.parseInt(jedis.get("balance"));
// 余额不足
if (balance < amtToSubtract) {
jedis.unwatch(); // 放弃所有被监控的键
System.out.println("Insufficient balance");
return false;
}
Transaction transaction = jedis.multi();
// 扣钱
transaction.decrBy("balance", amtToSubtract);
Thread.sleep(5000); // 在外部修改 balance 或者 debt
transaction.incrBy("debt", amtToSubtract);
// list为空说明事务执行失败
List<Object> list = transaction.exec();
return !list.isEmpty();
}
}
6.2 Redis事务的三特性
Redis具有单独的隔离操作,事务中的所有的命令都会序列化。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断
Redis没有隔离级别的概念,队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行。
Redis不保证原子性,Redis事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚。
7. 使用Redis时可能会存在的问题
7.1 缓存穿透
缓存穿透,顾名思义就是请求穿透了缓存,使得缓存并未起到应有的作用。主要是指,大量请求的key时不合理的,根本不存在于缓存中,因此该请求将去访问数据库,又因为该请求也不存在于数据库中,这就对数据库造成了大量的压力,数据库可能会受不了这么大量请求导致宕机。
缓存穿透的主要的解决办法就是做好参数检验,将一些不合法的参数请求直接抛出异常返回给客户端。
7.2 缓存击穿
缓存击穿是指缓存中的某个热点数据因为过期或者一些其他原因不在缓存中了,导致在一瞬间大量请求直接到达数据库,对数据库造成了巨大的压力,这种情况多见于秒杀这种案例。
主要的解决办法可以通过设置热点数据永不过期或者过期时间比较长,或者在请求数据库写数据到缓存之前,先获取互斥锁,保证只有一个请求落到数据库上,减少数据库的压力。
7.3 缓存雪崩
缓存雪崩是指缓存在同一时间大面积的失效,导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。出现这种情况的原因可能是缓存服务宕机,或者是缓存中的大量数据在同一时间内过期。
针对缓存服务宕机的情况可以采用Redis集群,避免出现一个服务器出现问题导致数据库崩溃的情况。针对在同一时间缓存中的大量数据失效的情况,可以通过设置二级缓存的方式解决。
8. Redis的分布式锁原理与实现
8.1 Redis分布式锁的原理
分布式锁,是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
使用setnx、getset、expire、del这4个redis命令实现Redis的分布式锁功能:
1、setnx 是『SET if Not eXists』(如果不存在,则 SET)的简写。 命令格式:SETNX key value;使用:只在键 key 不存在的情况下,将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作。返回值:命令在设置成功时返回 1 ,设置失败时返回 0 。
2、getset 命令格式:GETSET key value,将键 key 的值设为 value ,并返回键 key 在被设置之前的旧的value。返回值:如果键 key 没有旧值, 也即是说, 键 key 在被设置之前并不存在, 那么命令返回 nil 。当键 key 存在但不是字符串类型时,命令返回一个错误。
3、expire 命令格式:EXPIRE key seconds,使用:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。返回值:设置成功返回 1 。 当 key 不存在或者不能为 key 设置生存时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的生存时间),返回 0 。
4、del 命令格式:DEL key [key …],使用:删除给定的一个或多个 key ,不存在的 key 会被忽略。返回值:被删除 key 的数量。
具体的原理图如下所示:
8.2 Redis分布式锁的实现
redis分布所实现的过程如下所示:
1、A尝试去获取锁lockkey,通过setnx(lockkey,currenttime+timeout)命令,对lockkey进行setnx,将value值设置为当前时间+锁超时时间;
2、如果返回值为1,说明redis服务器中还没有lockkey,也就是没有其他用户拥有这个锁,A就能获取锁成功;
3、在进行相关业务执行之前,先执行expire(lockkey),对lockkey设置有效期,防止死锁。因为如果不设置有效期的话,lockkey将一直存在于redis中,其他用户尝试获取锁时,执行到setnx(lockkey,currenttime+timeout)时,将不能成功获取到该锁;
4、执行相关业务;
5、释放锁,A完成相关业务之后,要释放拥有的锁,也就是删除redis中该锁的内容,del(lockkey),接下来的用户才能进行重新设置锁新值
redis分布式锁的代码实现如下所示:
public void redis1() {
log.info("关闭订单定时任务启动");
long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout", "5000"));
//这个方法的缺陷在这里,如果setnx成功后,锁已经存到Redis里面了,服务器异常关闭重启,将不会执行closeOrder,也就不会设置锁的有效期,这样的话锁就不会释放了,就会产生死锁
Long setnxResult = RedisShardedPoolUtil.setnx(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));
if (setnxResult != null && setnxResult.intValue() == 1) {
//如果返回值为1,代表设置成功,获取锁
closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
} else {
log.info("没有获得分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}
log.info("关闭订单定时任务结束");
}
private void closeOrder(String lockName) {
//对锁设置有效期
RedisShardedPoolUtil.expire(lockName, 5);//有效期为5秒,防止死锁
log.info("获取锁:{},ThreadName:{}",Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
//执行业务
int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour", "2"));
iOrderService.closeOrder(hour);
//执行完业务后,释放锁
RedisShardedPoolUtil.del(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
log.info("释放锁:{},ThreadName:{}",Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
log.info("=================================");
}
缺陷:
如果A在setnx成功后,A成功获取锁了,也就是锁已经存到Redis里面了,此时服务器异常关闭或是重启,将不会执行closeOrder,也就不会设置锁的有效期,这样的话锁就不会释放了,就会产生死锁。
9. Redis的缓存一致性策略
对于新增缓存数据会直接写入数据库,无需对缓存进行操作。在这种情况下,缓存中本身就没有新增数据,而数据库中保存的是最新值。因此,缓存和数据库的数据是一致的。
而对于Redis的数据更新处理,主要有接下来几种情况会导致缓存不一致。
1.先更新缓存,再更新数据库:先更新缓存可以提高读取性能,但如果更新缓存成功而更新数据库失败,可能导致数据不一致。在这种情况下,可能会发生更新缓存后,数据库更新出现异常,这会导致缓存和数据库的数据不一致,但是这种不一致很难查到。
2.先更新数据库,再更新缓存:确保数据的持久性,但如果更新数据库成功而更新缓存失败,也可能导致数据不一致。这种也会导致数据不一致的问题,数据库更新成功了,但是缓存并没有更新成功。对于这种情况在高并发场景下会出现接下来的两个问题:
(1)并发问题:当有请求A和请求B同时进行更新操作时,可能出现以下情况:线程A先更新数据库,然后线程B也更新了数据库,随后线程B更新了缓存,最后线程A也更新了缓存。这导致了请求A应该先更新缓存,但由于网络等原因,请求B却比请求A更早更新了缓存,从而产生脏数据。
(2) 业务场景问题:如果写数据库的操作比读数据的操作更频繁,采用这种方案会导致数据还没有被读取,就频繁更新缓存,从而浪费性能。
3.先删除缓存,后更新数据库:通过先删除缓存,再更新数据库的方式,可以在数据更新后保证数据的一致性,但会降低读取操作的性能。
4.先更新数据库,后删除缓存:确保数据的持久性,并在更新数据库成功后再删除缓存,以保持数据的一致性。
9.1 先删除缓存,后更新数据库的缓存一致性方案
当我们使用先删除缓存,然后更新数据库的方案时,会造成下面的情况。
1. 请求A进⾏写操作,删除缓存 2. 请求B查询发现缓存不存在 3. 请求B去数据库查询得到旧值 4. 请求B将旧值写⼊缓存 5. 请求A将新值写⼊数据库。
对于这种情况,我们可以采用延时双删的方法解决该问题,代码如下所示:
public void write(String key,Object data){
Redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
Redis.delKey(key);
}
转化为中⽂描述就是 (1)先淘汰缓存 (2)再写数据库(这两步和原来⼀样) (3)休眠1秒,再次淘汰缓存,这 么做,可以将1秒内所造成的缓存脏数据,再次删除。确保读请求结束,写请求可以删除读请求造成的缓存脏数 据。⾃⾏评估⾃⼰的项⽬的读数据业务逻辑的耗时,写数据的休眠时间则在读数据业务逻辑的耗时基础上,加⼏百 ms即可。
如果Mysql使用的是读写分离的架构的话,主从同步之间也会有时间差,会造成下面如图所示的情况:
此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作) 1. 请求 A 更新操作,删除了 Redis 2. 请求主库进⾏更新操作,主库与从库进⾏同步数据的操作 3. 请 B 查询操作,发现 Redis 中没有数据 4. 去从库中拿去数据 5. 此时同步数据还未完成,拿到的数据是旧数据 此时的解决办法就是如果是对 Redis 进⾏填充数据的查询数据库操作,那么就强制将其指向主库进⾏查询。
9.2 先更新数据库,然后删除缓存的缓存一致性方案
这⼀种情况也会出现问题,⽐如更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功,那么此时再读取缓存的时候每次都是错误的数据了。
对于这种情况,我们可以使用接下来的解决方案进行处理。
此时解决⽅案就是利⽤消息队列进⾏删除的补偿。具体的业务逻辑⽤语⾔描述如下:
1. 请求 A 先对数据库进⾏更新操作
2. 在对 Redis 进⾏删除操作的时候发现报错,删除失败
3. 此时将Redis 的 key 作为消息体发送到消息队列中
4. 系统接收到消息队列发送的消息后再次对 Redis 进⾏删除操作
但是这个⽅案会有⼀个缺点就是会对业务代码造成⼤量的侵⼊,深深的耦合在⼀起,所以这时会有⼀个优化的⽅ 案,我们知道对 Mysql 数据库更新操作后再 binlog ⽇志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog ⽇志对缓存进⾏操作。具体的实施过程如下所示: