分布式缓存
redis 是非常热门的缓存选择。但是,如果只有一个 redis 节点,那么就会面临很多问题。所以,更好的做法是搭建 redis 集群,可以使用多个节点缓存数据。多节点 redis 其实就是分布式缓存。
单节点 redis 的不足:
- 数据丢失:redis 是基于内存存储的,服务重启内存数据会丢失。
- 并发能力有限:redis 是基于内存存储的,并发能力远远强于 mysql。但是,在单节点的情况下,redis 的并发能力依赖于物理机器的性能,也就是物理内存的读写速度和容量大小。而单台机器的性能总是有限的,所以单节点 redis 的并发能力也是有限的。
- 故障恢复问题:redis 作为缓存,在系统中的应用非常广泛,一旦服务不可用,则整个系统的工作能力会大幅下降或者直接不可用。
- 存储能力不足,高并发写压力大:redis 基于内存,但是内存的大小相对于磁盘来说是非常有限的。同时,内存除了 redis 在使用,还有其他程序在使用,redis 能够使用的内存空间相对于缓存需求明显不足。而且,虽然缓存一般是读多写少的,但是如果在高读高写的情况下,此时的 redis 可以看作是一个增强版的 mysql,大量读写操作会降低并发性能。
分布式缓存 的优势:
- 数据丢失:利用 redis 本身的持久化功能将缓存数据写入到磁盘中,保证数据不丢失。
- 并发能力有限:搭建主从集群,实现读写分离,每个节点都可以作为缓存,提高并发能力。
- 故障恢复问题:通过 redis 的哨兵机制,检测节点是否健康以及重启服务。
- 存储能力不足、高并发写压力大:搭建分片集群,不同节点分别存储不同的数据,利用插槽机制实现动态扩容,而且有多个节点都可以写,缓解高并发写操作带来的压力。
1.redis 持久化
redis 持久化的策略有两种:RDB 和 AOF。
1.1.RDB 持久化策略
RDB:Redis Database Backup file (redis 数据备份文件)。其实就是把内存的数据全部写入磁盘中作为备份文件,redis 实例重启后从这个备份文件中读取数据,保证数据不丢失。这个备份文件也被称为 RDB 文件,默认是保存在当前运行目录。
RDB 是 Redis 默认采用的持久化方式,会创建一个经过压缩的二进制文件,RDB 文件以".rdb"结尾,默认文件名是"dump.rdb"。
可以通过 save 命令或者 bgsave 命令进行数据备份,将内存数据写入 rdb 文件。
- 如果是通过 save 命令进行持久化,主进程就会阻塞,直到数据持久化结束。
- 如果是通过 bgsave 命令进行持久化,主进程会 fork 一个子进程,子进程和主进程共享同一个内存空间,让子进程来执行持久化操作,主进程继续执行读写操作。当主进程需要读取数据的时候,直接读取共享的内存空间;当主进程需要更新数据的时候,会先将原本的数据拷贝一个副本,然后修改副本,下一次读取就会直接读取副本。
总体来看,bgsave 优于 save,但是 bgsave 还存在一些问题。因为 bgsave 每次运行都要执行 fork 操作创建子进程,fork 操作是阻塞的,主进程 fork 完成后才能执行读写操作。fork 属于重量级操作,不建议频繁执行。
可在 redis.conf 中配置 RDB:
bash
# 300秒内,至少有10个key被修改则执行bgsave
save 300 10
# 300秒内,至少有20个key被修改则执行bgsave
save 300 20
# 600秒内,至少有1000个key被修改则执行bgsave
save 600 1000
# 是否对内存数据进行压缩,压缩后体积减小,但是压缩操作需要消耗CPU性能
rdbcompression yes
# RDB文件名
dbfilename dump.rdb
# 文件保存的路径目录
dir ./
1.2.AOF 持久化策略
AOF:Append Only File (追加文件)。redis 的每一个写命令都会记录到 AOF 缓冲区中,将写命令从 AOF 缓冲区写入 AOF 文件这个过程才是持久化,重启后重新执行 AOF 文件中的写命令来恢复数据。
redis 默认使用 RDB,而 AOF 默认不开启,需要修改配置项,重启服务才能启用:
bash
# 启用AOF
appendonly yes
# AOF文件名
appendfilename "appendonly.aof"
# 写命令记录到AOF文件的时机
# always 每个写命令都要同步写入磁盘,对性能消耗大,但是一致性高
# no 不主动刷盘,写命令会堆积在AOF缓冲区中,由操作系统决定何时同步到磁盘,大家可以自己测试一下是多久同步
# everysec 每秒刷盘一次,将AOF缓冲区的命令持久化到AOF文件,最多丢失一秒内的数据,在性能和一致性中进行折中
appendfsync everysec
但是,AOF 开启后默认会记录所有的写入命令并重新执行,会存在浪费性能的问题。比如:
set name amy
set name lingling
set name jiujiu
set age 18
setnx id 100
set name kk
对于 name 这个 key,虽然进行了 4 次写入操作,但是其实只要执行最后一次的"set name kk"就能得到相同的效果,前面的三次写操作反而会浪费 CPU 性能。
所以 redis 提供了 bgrewriteaof 命令对 AOF 中记录的命令进行重写,目标是用最少的命令得到相同的效果。
bash
# AOF文件体积超过原文件多少百分比才能触发重写
auto-aof-rewrite-percentage 100
# AOF体积超过128mb才能触发重写
auto-aof-rewrite-min-size 128mb
# 以上两个条件必须同时满足才能重写
# 比如:原本的大小是25mb,现在达到了50mb,文件体积刚好是原文件的两倍,超过部分是100%,但是并没有超过128mb,不能重写
# 比如:原本的大小是127mb,现在达到了254mb,文件体积刚好是原文件的两倍,超过部分是100%,127 < 128,但是 128 < 254,此时会触发重写
在实际开发中,往往会结合这两种持久化策略使用。
RDB | AOF | |
---|---|---|
持久方式 | 对内存文件进行备份 | 记录执行的写入命令 |
数据完整性 | 两次备份之间容易丢失数据 | 不好说,取决于刷盘策略 |
文件大小 | 经过压缩的文件,体积小 | 记录所有写入的命令,体积大,重写后体积一般还是比 RDB 大 |
恢复速度 | 二进制文件,速度快 | 重新执行,速度慢 |
数据恢复优先级 | 低,因为数据完整性没有那么高 | 高,因为数据完整性更高 |
系统资源占用 | 高,因为要解压和恢复 | 刷盘主要是占用磁盘资源,但是批量执行命令对 CPU 消耗大 |
适用场景 | 可以容忍数据丢失,追求快速启动 | 对数据完整性要求高,比较常见 |
2.主从集群
2.1.实现主从关系
现在有 n 个 redis 节点,在其中选出一个节点作为主节点,master 节点;其余的 n - 1 个节点是从节点,slave 节点或者 replica 节点。redis 5.0 之前叫做 slave,5.0 之后叫做 replica 节点。slave 是奴隶的意思,master-slave 相当于主仆关系;replica 有复制品的意思,master-replica 就是主节点-副本节点的关系。
假如现在有三个 redis 节点:
- r1 127.0.0.1 7000
- r2 127.0.0.1 7001
- r2 127.0.0.1 7002
将 r1 作为主节点,r2、r3 成为从节点。
1.修改配置文件,将 slaveof 命令配置到 redis.conf 中(持久)
修改 r2、r3 的配置文件:slave <master.ip> <master.port>,指定 r1 的 ip 和端口,成为 r1 的从节点,r1 自然成为主节点。
这种方式将主从关系持久化到了 redis.conf 中,服务重启后依然可以建立主从关系,因为 redis 服务还是使用 redis.conf 启动,slave 命令在配置文件中依然可以生效。
bash
# r2 redis.conf
slaveof 127.0.0.1 7000
# r3 redis.conf
slaveof 127.0.0.1 7000
2.redis-cli 客户端执行 slaveof 命令实现(临时)
通过 redis-cli 客户端连接服务,输入 slaveof <master.ip> <master.port>,则当前节点成为 <master.ip> 的从节点。master 节点不需要执行任何命令,自然成为主节点。
修改配置文件可以持久化主从关系。而执行 slaveof 命令是临时建立主从关系,从节点重启后不会恢复主从关系,是一个独立的节点。
5.0 之前,使用 slaveof 命令;5.0 之后,使用 replicaof 命令。
主节点可以进行读写操作,从节点只能读,不能写,天然实现读写分离。由于从节点无法写数据,因此主节点需要和从节点进行数据同步。
2.2.主从数据同步
每个 master 都有唯一的 replication id,主从集群中所有的从节点都会记录主节点的 replication id,同一个集群的所有节点拥有相同的 replication id,以下简称 rid。
offset:偏移量,写入的数据越多,offset 越大,可以用 offset 判断数据是否同步。主节点的 offset 是最大的,若从节点的 offset 小于主节点的 offset,说明主从节点之间还有数据没有同步。
①主从节点第一次同步,从节点从一个独立节点第一次加入主从集群
- 从节点通过 slaveof 或者 replicaof 跟主节点建立连接
- 从节点请求数据同步,发送"psync ? -1"指令,"psync rid offset",由于从节点之前一直是独立节点,是第一次加入主从集群,所以没有保存 rid,没有同步过数据,rid = ?,offset = -1
- 主节点根据"psync ? -1"判断从节点是第一次同步
- 返回主节点的 rid 和 offset
- 执行 bgsave 生成 RDB 文件,将该文件发送给从节点,也就是将主节点内存副本拷贝给从节点
- 在生成 RDB 文件,发送 RDB 文件的过程中,主节点可以持续写入数据,所以即使从节点得到主节点的 RDB 文件也不能保证跟主节点数据完全一致。主节点会将这个过程(生成 RDB 文件,发送 RDB 文件)中执行的写操作记录到一个叫做 replication backlog 的先进先出的缓冲区队列中
- 在发送完 RDB 文件后,主节点会将 replication backlog 缓冲区的写命令继续发送给从节点进行数据同步
- 从节点得到主节点的反馈后
- 记录主节点的 rid 和 offset
- 接收主节点的 RDB 文件,清除本地数据,将主节点的副本数据加载到内存中,成为主节点的数据副本
- 从节点继续执行主节点发送过来的写命令,跟主节点数据同步
②主从节点实时同步
主节点已经和从节点进行第一次同步了,但是同步工作还没有结束。之后,主节点这边会源源不断的收到新的修改数据的请求,主节点上的数据就会随之改变,也需要同步给从节点 。
从节点和主节点之间会建立 TCP 的长连接。主节点把自己收到的修改数据的请求,发送给从节点,从节点再根据这些修改请求,修改内存中的数据。
在进行实时同步的时候,需要保证连接处于可用状态:
- 主节点:默认,每隔 10s 给从节点发送一个 ping 命令,从节点收到就返回 pong
- 从节点:默认,每隔 1s 就给主节点发起一个特定的请求,就会上报当前从节点的 offset
③主从节点第一次同步,从节点从另一个主从集群加入一个新的主从集群(跟 ① 的同步过程完全一致,只要主节点校验请求节点不属于自己的集群就要:返回 rid、offset、生成 RDB...)
- 从节点通过 slaveof 或者 replicaof 跟主节点建立连接
- 从节点请求数据同步,发送"psync old-rid old-offset"指令,由于从节点之前是另一个主从集群的节点,所以 rid 和 offset 都是以前的遗留数据
- 主节点发现从节点有 rid,但是跟自己的 rid 不同,由此得出判断:从节点是第一次跟自己 同步
- 返回主节点的 rid 和 offset
- 执行 bgsave 生成 RDB 文件,将该文件发送给从节点,也就是将主节点内存副本拷贝给从节点
- 在生成 RDB 文件,发送 RDB 文件的过程中,主节点可以持续写入数据,所以即使从节点得到主节点的 RDB 文件也不能保证跟主节点数据完全一致。主节点会将这个过程(生成 RDB 文件,发送 RDB 文件)中执行的写操作记录到一个叫做 replication backlog 的环形缓冲区中
- 在发送完 RDB 文件后,主节点会将 replication backlog 缓冲区的写命令继续发送给从节点进行数据同步
- 从节点得到主节点的反馈后
- 记录主节点的 rid 和 offset
- 接收主节点的 RDB 文件,清除本地数据,将主节点的副本数据加载到内存中,成为主节点的数据副本
- 从节点继续执行主节点发送过来的写命令,跟主节点数据同步
④主从节点连接中断后重新建立联系
当主从节点之间连接中断时,主节点依然响应命令,但这些复制命令都因连接中断无法及时发送给从节点,所以暂时将这些命令滞留在 replication backlog 缓冲区中。
当从节点再次连接上主节点时,从节点会请求数据同步:
- 从节点发送"psync rid offset"
- 主节点校验 rid 和 offset
- 如果 rid 不一样,就说明是一个新的节点要加入自己的集群,- > ③
- 如果 rid 一样,就根据 offset 再判断一次,看主节点和从节点的 offset 的差距是否大于缓冲区的大小
- 例如,缓冲区的长度是 1024,主节点的 offset 是 8080,从节点的 offset 是 7070,8080 - 7070 = 1010。此时主从 offset 的差距 < 缓冲区长度,主节点只需要把和从节点之间的滞留在缓冲区没有同步的写命令发送给从节点即可
- 如果主从 offset 的差距 >= 缓冲区长度,则 - > ③
- 缓冲区是环形逻辑的。举个例子,你和你兄弟现在在比赛跑步,跑道是非常常规的400米环形跑道,你就是从节点,你兄弟就是主节点。因为主从节点之间的关系,主节点是最新的数据,从节点不可能超过主节点,但是因为数据同步关系,即使从节点数据落后也可以很快追赶上来。平时你和你兄弟通常不会有太大的差距,但是突然你抽筋了,跑不动了,只能眼睁睁看着你兄弟慢慢远离你。跑道总共是400米,即使你兄弟拉开了你399米,只要你身体恢复,可以继续跑,都可以很快追赶上。但是,当你兄弟拉开了你400米以上,这个时候,你已经被套圈了,可能你们之间的物理距离只有1米,但是实际上的差距已经是400 + 1米了
- 所以,一旦主从节点的 offset 差距 >= 缓冲区大小,就表示从节点已经断联太久了,主从之间的数据已经有很大的差距了,从节点需要重新拷贝主节点的内存数据副本
3.哨兵模式
在主从集群下,即使从节点宕机,等它重新启动后还可以找主节点进行数据同步,保证主从节点数据的一致性。
但是,如果是主节点宕机,则无法进行写入操作。
Redis 提供了哨兵机制来实现主从集群的自动故障恢复:
- 监控:Sentinel 会不断检查 master 和 slave 是否健康
- 自动故障恢复:如果 master 故障,则将一个 slave 提升为 master,故障实例恢复后以新 master 为主
- 通知:Redis 客户端向 Redis 实例发送命令需要经过 Sentinel,Sentinel 负责实现读写分离和故障转移,相当于 nacos 的服务发现
Sentinel 通过心跳机制来监控服务状态,每隔一秒向每个实例发送 ping 命令。
如果一个 Sentinel 节点发现一个 Redis 实例未在预定时间范围内响应,则认为该实例主观下线。如果超过某个数量的 Sentinel 都认为该实例主观下线,则该实例被客观下线,客观下线就是被认为是故障实例。(具体的数值可以在 sentinel.conf 中配置,这个数量一般 = 哨兵数量 / 2,也就是哨兵数量的一半,当超过一般的哨兵都认为主观下线则是客观下线)
一旦 master 客观下线,则 Sentinel 需要选择一个 slave 作为主节点:
-
master 主观下线
-
sentinel 集群交流后判断 master 客观下线,需要选举一个 slave 作为新的 master 节点
-
过滤客观下线的 slave 节点
-
获取每个 slave 的 slave-priority,值越小优先级越高,这个值默认情况所有节点都是相同的
-
if(slave-priority 相同) ,则 offset 越大,说明该节点的数据一致性越高,优先级越高
-
if(slave-priority 相同 && offset 相同),则选取 runid 最小的 slave 节点作为新的 master 节点,runid 是 redis 服务启动时随机生成的标识符。其实这一步就是在所有参赛选手中随便选一个,因为 runid 是随机的,运气不好的节点不能作为 master
故障转移:
- 选择一个 slave 作为 master,这个 slave 执行"slaveof no one"从 slave 节点提升到 master 节点
- 剩下的 slave 节点成为新 master 的从节点,然后主节点和从节点会进行数据同步
- 旧 master 被标记为 slave,当旧 master 重启恢复后,自动成为新 master 的从节点
bash
# sentinel.conf:指定该节点的端口
port 8000
# sentinel.conf:sentinel节点的ip
sentinel announce-ip <ip>
# sentinel.conf:指定master的名字、ip、端口,当超过多少个sentinel认为主观下线才会客观下线,建议quorum = sentinel的数量 / 2
# sentinel节点只需要监听master即可,如果一个主从集群中有三个sentinel,那这三个sentinel节点都要监听master
sentinel monitor <master-name> <ip> <redis-port> <quorum>
# sentinel.conf:连接客户端需要提供的密码
sentinel auth-pass <master-name> <password>
spring-boot-starter-data-redis 底层基于 Lettuce 实现了对主从集群、哨兵模式的支持:
- 引入 spring-boot-starter-data-redis 依赖
- 配置分片集群地址
- 配置读写分离
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.0.0</version>
</dependency>
yml
spring:
data:
redis:
sentinel:
master: mymaster # 主节点的名字,可以随便取
nodes:
# sentinel节点的地址,因为sentinel已经监听了master节点,master又管理了slave节点,所以sentinel节点可以摸到这个集群下所有redis节点
- 127.0.0.1:8000
- 127.0.0.1:8001
- 127.0.0.1:8002
java
/**
* 读操作访问slave
* 写操作访问master
*
* @return
*/
@Bean
public LettuceClientConfigurationBuilderCustomizer lettuceClientConfigurationBuilderCustomizer() {
return new LettuceClientConfigurationBuilderCustomizer() {
@Override
public void customize(LettuceClientConfiguration.LettuceClientConfigurationBuilder clientConfigurationBuilder) {
// ReadFrom.MASTER:从master读取
// ReadFrom.MASTER_PREFERRED:优先从master中读,master不可用才读取slave
// ReadFrom.REPLICA:从slave读取
// ReadFrom.REPLICA_PREFERRED:优先从slave中读,所有slave不可用才读取master
clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
};
}
java
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
void contextLoads() {
// 客户端不需要关心访问哪个节点,只需要发送命令即可
stringRedisTemplate.opsForValue().set("key", "value");
String value = stringRedisTemplate.opsForValue().get("key");
System.out.println(value);
}
4.分片集群
bash
# redis.conf:当前节点的端口
port 7000
# redis.conf:开启集群功能
cluster-enabled yes
# redis.conf:集群的配置文件名称,不需要我们创建,由redis自己维护
cluster-config-file nodes.conf
# redis.conf:当前节点的ip
replica-announce-ip 127.0.0.1
# redis-server redis.conf启动服务即可
# 此时,各个节点之间没有建立联系,但是已经开启集群功能了,每一个节点就是一个集群,或者你可以认为一个集群只有一个节点
redis-cli --cluster create --cluster-replicas 0 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002
主从集群能够提高可用性和并发读性能,但是在这种集群模式下主从节点的数据是相同的,是将一份数据拷贝多个副本存储。而服务器的内存大小是有限的,redis 怎么存储大量的缓存数据呢?而且,主从集群还有一个问题,就是读写分离,在面对读多写少的情况效果确实好,但是如果写操作的占比也很高呢,该怎么解决呢?
分片集群可以解决海量数据存储和高并发写问题:
- 集群中有多个 master,每个 master 保存不同的数据,每个 master 都可以写。
- 同时,这些 master 也有自己的 slave,也就是分片集群和主从集群同时存在,保持了高可用性和高并发读能力。
- master 之间可以相互监控彼此的健康状态,代替了原来哨兵的作用,当一个 master 服务下线后会从该 master 的主从集群中选出一个从节点作为主节点。
- 客户端可以访问任意一个节点,最终都会被路由到正确的节点。
在分片集群中,有多个主从集群,每个主从集群都有一个 master。分片集群中有 0 ~ 16383 一共 16384 个槽位,每个 master 节点会分配到一部分槽位。
在任意一个节点向 redis 存入数据时,会根据 key 的有效部分,利用 CRC 16 算法计算出一个 hash 值,再将 hash 值跟 16384 取余得到插槽值,将这个 key 存入管理这个插槽值的 master 节点中。
什么是 key 的有效部分?
- 如果 key 中包含 {} 并且 {} 包含至少一个字符,则 {} 中的部分是有效部分
- 如果 key 中不包含 {},则整个 key 都是有效部分
比如:有三个 master:A、B、C,A 管理的插槽值是 0 ~ 5000,B 管理的插槽值是 5001 ~ 12000,C 管理的插槽值是 12001 ~ 16383。当通过任意一个 redis 客户端写入"set num 100"时,"num"就是有效部分,根据有效部分计算 hash 值,跟 16384 取余得到插槽值。这里假设插槽值是 100,落在 A 的范围 [0, 5000],则这个 key 会写入 A 节点,然后被 A 节点同步给它管理的主从集群下的 slave 节点。
存入数据如此,读取数据也一样,会计算 key 有效部分的 hash 值再对 16384 取余得到插槽值,再去对应的主从集群的 slave 节点中读取。所以,客户端可以访问任意一个节点,最终都会被路由到正确的节点,都可以实现读写分离。
而且,分片集群还可以实现缓存数据的业务分离:
- 假设以 yefdefgu 为 key 会落在 [0, 5000] 区间内,存入 A 的主从集群;以 sefbrg 为 key 会落在 [5001, 12000] 区间内,存入 B 的主从集群;以 freivyshoorgb 为 key 会落在 [12001, 16383] 区间内,存入 C 的主从集群
- 此时,项目有三个服务:用户服务、商品服务、订单服务
- 用户服务的 key 中包含" {yefdefgu} ",则用户服务的所有缓存数据会存入 A 主从集群;商品服务的 key 中包含" {sefbrg} ",则商品服务的所有缓存数据会存入 B 主从集群;订单服务的 key 中包含" {freivyshoorgb}",则订单服务的所有缓存数据会存入 C 主从集群
spring-boot-starter-data-redis 底层同样基于 Lettuce 实现了对分片集群的支持,使用步骤和哨兵模式基本一致:
- 引入 spring-boot-starter-data-redis 依赖
- 配置分片集群地址
yml
spring:
data:
redis:
cluster:
nodes:
# 配置所有节点的ip和端口
# 7000系的是A主从集群
- 127.0.0.1:7000
- 127.0.0.1:7001
- 127.0.0.1:7002
# 8000系的是B主从集群
- 127.0.0.1:8000
- 127.0.0.1:8001
- 127.0.0.1:8002
# 9000系的是C主从集群
- 127.0.0.1:9000
- 127.0.0.1:9001
- 127.0.0.1:9002
- 配置读写分离
java
/**
* 读操作访问slave
* 写操作访问master
*
* @return
*/
@Bean
public LettuceClientConfigurationBuilderCustomizer lettuceClientConfigurationBuilderCustomizer() {
return new LettuceClientConfigurationBuilderCustomizer() {
@Override
public void customize(LettuceClientConfiguration.LettuceClientConfigurationBuilder clientConfigurationBuilder) {
// ReadFrom.MASTER:从master读取
// ReadFrom.MASTER_PREFERRED:优先从master中读,master不可用才读取slave
// ReadFrom.REPLICA:从slave读取
// ReadFrom.REPLICA_PREFERRED:优先从slave中读,所有slave不可用才读取master
clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
};
}