一、Redis主从集群
1. 搭建主从集群
1.1 主从集群结构
单节点Redis的并发能力是有限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
如图所示,集群中有一个master节点、两个slave节点(现在叫replica)。当我们通过Redis的Java客户端访问主从集群时,应该做好路由:
- 如果是写操作,应该访问master节点,master会自动将数据同步给两个slave节点
- 如果是读操作,建议访问各个slave节点,从而分担并发压力
1.2 搭建主从集群
步骤:
①将课前资料提供的docker-compose文件上传到root/redis文件夹(新建)下,构建主从集群:
文件内容如下:
version: "3.2"
services:
r1:
image: redis
container_name: r1
network_mode: "host"
entrypoint: ["redis-server", "--port", "7001"]
r2:
image: redis
container_name: r2
network_mode: "host"
entrypoint: ["redis-server", "--port", "7002"]
r3:
image: redis
container_name: r3
network_mode: "host"
entrypoint: ["redis-server", "--port", "7003"]
②执行命令,运行集群:
cd redis
docker compose up -d
这里如果还没有加载redis镜像的,先把课前资料里的redis.tar上传到虚拟机root目录下,然后加载镜像,创建并运行容器。
③在宿主机通过os命令查看Redis进程:
ps -ef | grep redis
④配置主从关系
# Redis5.0以前
slaveof <masterip> <masterport>
# Redis5.0以后
replicaof <masterip> <masterport>
有临时和永久两种模式:
- 永久生效:在redis.conf文件中利用slaveof命令指定master节点
- 临时生效:直接利用redis-cli控制台输入slaveof命令,指定master节点
我们测试临时模式,首先连接r2,让其以r1为master:
docker exec -it r2 redis-cli -p 7002
slaveof 192.168.126.151 7001
查看节点信息
info replication
然后连接r3,让其以r1为master:
dcoker exec -it r3 redis-cli -p 7003
slaveof 192.168.126.151 7001
⑤测试
依次在r1、r2、r3节点上执行下面命令
set num 123
get num
只有r1节点上可以执行set命令(写操作),其他两个节点只能执行get命令(读操作)。实现了读写分离。
2. 主从同步原理
当主从第一次同步连接或端口重连时,从节点都会发送psync请求,尝试数据同步:
问题1:master如何得知slave是否时第一次来同步呢?
- replicationID:简称replid,是数据集的标记,replid一致则是同一数据集。每一个master节点都有自己的唯一replid,slave则会继承master节点的replid
- offset:偏移量,随着记录在repl_backlog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
因此slave做数据同步,必须向master声明自己的replication id和offset,master才可以判断到达需要同步哪些数据。
由于我们在执行slaveof
命令之前,所有redis节点都是master
,有自己的replid
和offset
。当我们第一次执行slaveof
命令,与master
建立主从关系时,发送的replid
和offset
是自己的,与master
肯定不一致。master
判断发现slave
发送来的replid
与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。master
会将自己的replid
和offset
都发送给这个slave
,slave
保存这些信息到本地。自此以后slave
的replid
就与master
一致了。
因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致。
问题2:master怎么知道slave与自己的数据差异在哪里呢?
repl_baklog文件是一个固定大小的数组,只不过数组是环形的,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。
repl_baklog中会记录Redis处理过的命令及offset,包括master当前的offset,和slave已经拷贝到的offset:
slave与master的offset之间的差异,就是slave需要增量拷贝的数据。
随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset:
直到数组被填满:
此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步扫slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分:
但是,如果slave出现网络阻塞,导致master的offset远远超过slave的offset:
如果master继续写入新数据,master的offset就会覆盖repl_backlog中旧的数据,直到slave现在的offset也覆盖:
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset没有了,无法完成增量同步了,只能做全量同步。
repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于repl_baklog做增量同步,只能再次全量同步。
2.1 主从同步优化
可以从以下几个方面来优化Redis主从就集群:
- 在master中配置repl_diskless_sync yes启用无磁盘复制,避免全量同步时的磁盘IO
- Redis单节点的内存占用不要太大,减少RDB导致的过多磁盘IO
- 适当提高repl_backlog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
- 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力
总结
1. 简述全量同步和增量同步的区别?
- 全量同步:master将完整内存数据生成RDB,发送RDB到slave
- 增量同步:slave提交自己的offset到master,master获取repl_baklog中slave的offset之后的命令给slave
2. 什么时候执行全量同步?
- slave节点第一次连接master节点时
- slave节点断开时间太久,repl_baklog中的offset已经被覆盖时
3. 什么时候执行增量同步?
- slave节点断开又恢复,并且在repl_baklog中能找到offset时
3. 哨兵原理
3.1 哨兵的作用
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。哨兵的具体作用如下:
- 监控:Sentinel会不断检查您的master和slave是否按预期工作
- 自动故障切换:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
- 通知:当集群发生故障转移时,Sentinel会将最新节点角色信息推送给Redis的客户端
3.2 服务状态监控
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
主观下线:如果某Sentinel节点发现某实例未在规定时间响应,则认为改实例主观下线。
客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
选举新的master
一旦发现master故障,sentinel需要在slave中选择一个作为新的master,选择依据是这样的:
- 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点;
- 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举(默认都是1);
- 如果slave-priority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高;
- 最后是判断slave节点的运行id大小,越小优先级越高
如何实现故障转移
当选中了其中越高slave为新的master后(例如slave1),故障的转移的步骤如下:
- sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master;
- sentinel给所有其它slave发送slaveof 192.168.126.151 7002命令,让这些slave成为新master的从节点,开始从新的master上同步数据;
- 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点。
4. 哨兵集群
4.1 搭建哨兵集群
①停掉之前的redis集群(注意要进入redis目录下)
docker compose down
②然后,把课前资料提供的sentinel.conf文件,在虚拟机的/root/redis目录下新建三个文件夹:s1、s2、s3,然后把sentinel.conf文件分别拷贝一份到3个文件夹中
内容如下:
sentinel announce-ip "192.168.126.151"
sentinel monitor hmaster 192.168.126.151 7001 2
sentinel down-after-milliseconds hmaster 5000
sentinel failover-timeout hmaster 60000
③修改docker-compose.yaml文件
version: "3.2"
services:
r1:
image: redis
container_name: r1
network_mode: "host"
entrypoint: ["redis-server", "--port", "7001"]
r2:
image: redis
container_name: r2
network_mode: "host"
entrypoint: ["redis-server", "--port", "7002", "--slaveof", "192.168.126.151", "7001"]
r3:
image: redis
container_name: r3
network_mode: "host"
entrypoint: ["redis-server", "--port", "7003", "--slaveof", "192.168.126.151", "7001"]
s1:
image: redis
container_name: s1
volumes:
- /root/redis/s1:/etc/redis
network_mode: "host"
entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27001"]
s2:
image: redis
container_name: s2
volumes:
- /root/redis/s2:/etc/redis
network_mode: "host"
entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27002"]
s3:
image: redis
container_name: s3
volumes:
- /root/redis/s3:/etc/redis
network_mode: "host"
entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27003"]
④启动集群
docker compose up -d
⑤查看哨兵运行日志
docker logs -f s1
⑥模拟master节点宕机
docker stop r1
# 或
# 连接7001这个master节点,通过sleep模拟服务宕机,60秒后自动恢复
docker exec -it r1 redis-cli -p 7001 DEBUG sleep 60
r2成为了新的master
启动r1
docker start r1
查看r2的信息
4.2 RedisTemplate连接哨兵集群
步骤:
①引入依赖,就是SpringDataRedis的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
②配置哨兵地址。连接哨兵集群与传统单点模式不同,不再需要设置每一个redis的地址,而是直接指定哨兵地址。
spring:
redis:
sentinel:
master: hmaster # 集群名
nodes: # 哨兵地址列表
- 192.168.150.101:27001
- 192.168.150.101:27002
- 192.168.150.101:27003
③配置读写分离。让java客户端将写请求发送到master节点,读请求发送到slave节点。定义一个bean即可:
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
这个bean中配置的就是读写策略,包括四种:
- MASTER:从主节点读取
- MASTER_PREFERRED:优先从master节点读取,master不可用才读取slave
- REPLICA:从slave节点读取
- REPLICA_PREFERRED:优先从slave节点读取,所有的slave都不可用才读取master
二、Redis分片集群
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
- 海量数据存储问题
- 高并发写的问题
使用分片集群可以解决上述问题,分片集群特征:
- 集群中有多个master,每个master保存不同数据
- 每个master都可以有多个slave节点
- master之间通过ping监测彼此的健康状态
- 客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点
1. 搭建分片集群
Redis分片集群最少也需要3个master节点,由于我们的机器性能有限,我们只给每个master配置1个slave,形成最小的分片集群:
计划部署的节点信息如下:
容器名 | 角色 | IP | 映射端口 |
r1 | master | 192.168.126.151 | 7001 |
r2 | master | 192.168.126.151 | 7002 |
r3 | master | 192.168.126.151 | 7003 |
r4 | slave | 192.168.126.151 | 7004 |
r5 | slave | 192.168.126.151 | 7005 |
r6 | slave | 192.168.126.151 | 7006 |
步骤:
①停掉之前的几个容器
cd redis
docker compose down
②在虚拟机的root目录下新建一个redis-cluster目录,然后在其中新建一个docker-compose.yaml文件,内容如下:
version: "3.2"
services:
r1:
image: redis
container_name: r1
network_mode: "host"
entrypoint: ["redis-server", "--port", "7001", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r2:
image: redis
container_name: r2
network_mode: "host"
entrypoint: ["redis-server", "--port", "7002", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r3:
image: redis
container_name: r3
network_mode: "host"
entrypoint: ["redis-server", "--port", "7003", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r4:
image: redis
container_name: r4
network_mode: "host"
entrypoint: ["redis-server", "--port", "7004", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r5:
image: redis
container_name: r5
network_mode: "host"
entrypoint: ["redis-server", "--port", "7005", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r6:
image: redis
container_name: r6
network_mode: "host"
entrypoint: ["redis-server", "--port", "7006", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
③启动集群
cd redis-cluster
docker compose up -d
④查看启动的进程
ps -ef | grep redis
可以发现每个redis节点都可以以cluster模式运行,不过节点与节点之间并未建立连接。
⑤创建集群(注意改为自己的虚拟机IP)
# 进入任意节点容器
docker exec -it r1 bash
# 然后,执行命令
redis-cli --cluster create --cluster-replicas 1 \
192.168.126.151:7001 192.168.126.151:7002 192.168.126.151:7003 \
192.168.126.151:7004 192.168.126.151:7005 192.168.126.151:7006
⑥查询集群状态
redis-cli -p 7001 cluster nodes
2. 散列插槽
在Redis集群中,共有16384个hash slots,集群中的每一个master节点都会分配一定数量的hash slots:
Redis数据不是与节点绑定,而是与插槽slot绑定。当我们读写数据时,Redis基于CRC16 算法对key做hash运算,将得到的结果与16384取余,就计算出了这个key的slot值。然后到slot所在的Redis节点执行读写操作。
Redis在计算key的hash值不一定是根据整个key计算,分两种情况:
- 当key中不包含{}时,根据{}之间的字符串计算hash slot
- 当key中不包含{}时,则根据整个key字符串计算hash slot
例如,key是num,那么就根据num计算,如果是{itcast}num,则根据itcast计算。
示例:先于7001建立连接:
连接集群时,要加-c参数
# 进入容器
docker exec -it r1 bash
# 进入redis-cli
redis-cli -c -p 7001
# 测试
set user jack
总结
1. Redis如何判断某个key应该在哪个实例?
- 将16384个插槽分配到不同的实例;
- 根据key的有效部分计算哈希值,对16384取余
- 余数作为插槽,寻找插槽所在实例即可
2. 如何将同一类数据固定的保存在同一个Redis实例?
- Redis计算key的插槽值时会判断key中是否包含{},如果有则基于{}内的字符计算插槽
- 数据的key中可以加入{类型},例如key都以{typeId}为前缀,这样同类型数据计算的插槽一定相同。
3. 故障转移
分片集群的节点之间会互相通过ping的方式做心跳检测,超时未回应的节点会被标记为下线状态。当发现master下线时,会将这个master的某个slave提升为master。
我们先打开一个控制台窗口(进入redis-cluster目录),利用命令监测集群状态:
watch docker exec -it r1 redis-cli -p 7001 cluster nodes
命令前面的watch可以每隔一段时间刷新执行结果,方便我们实时监控集群状态变化。
接着,利用命令让某个master节点休眠。比如这里我们让7002节点休眠,打开一个新的ssh控制台,输入下面的命令:
docker exec -it r2 redis-cli -p 7002 DEBUG sleep 30
可以观察到,集群发现7002宕机,标记为下线。
过了一段时间后,7002原本的slave7004变成了master:
而7002被标记为slave,而且其master正好是7004,主从地位互换。
4. Java客户端连接分片集群
RedisTemplate底层同样基于lettuce实现了分片集群的支持,而使用的步骤与哨兵模式基本一致,分为以下三步:
①引入Spring Data Redis的starter依赖(它会包括RedisTemplate和Lettuce相关的依赖)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
②配置分片集群地址;
spring:
redis:
cluster:
nodes:
- 192.168.150.101:7001
- 192.168.150.101:7002
- 192.168.150.101:7003
- 192.168.150.101:8001
- 192.168.150.101:8002
- 192.168.150.101:8003
③配置读写分离
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
三、Redis数据结构
常用的Redis数据类型有5种,分别是:String、List、Set、SortedSet、Hash。
还有一些高级数据类型,比如Bitmap、HyperLogLog、GEO等,其底层都是基于上述5种基本数据类型。因此,在Redis的源码中,其实只有5种数据类型。
1. RedisObject
Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象,源码如下:
整个结构体并不包含真实的数据,仅仅是对象头信息,内存占用的大小为4+4+24+32+64=128bit。也就是16字节,然后指针ptr指向的才是真实数据存储的内存地址,所以RedisObject的内存开销也是很大的。
Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含12种不同类型:
编号 | 编码方式 | 说明 |
---|---|---|
0 | OBJ_ENCODING_RAW | raw编码动态字符串 |
1 | OBJ_ENCODING_INT | long类型的整数的字符串 |
2 | OBJ_ENCODING_HT | hash表(也叫dict) |
3 | OBJ_ENCODING_ZIPMAP | 已废弃 |
4 | OBJ_ENCODING_LINKEDLIST | 双端链表 |
5 | OBJ_ENCODING_ZIPLIST | 压缩列表 |
6 | OBJ_ENCODING_INTSET | 整数集合 |
7 | OBJ_ENCODING_SKIPLIST | 跳表 |
8 | OBJ_ENCODING_EMBSTR | embstr编码的动态字符串 |
9 | OBJ_ENCODING_QUICKLIST | 快速列表 |
10 | OBJ_ENCODING_STREAM | Stream流 |
11 | OBJ_ENCODING_LISTPACK | 紧凑列表 |
Redis中的5种不同的数据类型采用的底层数据结构和编码方式如下:
数据类型 | 编码方式 |
---|---|
STRING |
|
LIST |
|
SET |
|
ZSET |
|
HASH |
|
2. SkipList
SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
- 元素按照升序排序存储
- 节点可能包含多个指针,指针跨度不同
SkipList的特点:
- 跳跃表时一个有序的双向链表;
- 每个节点都可以包含多层指针,层数是1到32之间的随机数;
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大;
- 增删改查效率与红黑树基本一致,实现却更简单,但空间复杂度更高。
传统链表只有指向前后元素的指针,因此只能顺序依次访问,如果查找的元素在链表中间,查询的效率会比较低。而SkipList则不同,它内部包含跨度不同的多级指针,可以让我们跳跃查找链表中间的元素,效率非常高。跳表最多允许32级指针,最多允许存储2^32个元素。
其结构如图:
我们可以看到1号元素就有指向3、5、10的多个指针,查询时就可以跳跃查找。例如我们要找大小为14的元素,查找的流程是这样的:
- 首先找元素1节点最高级指针,也就是4级指针,起始元素大小为1,指针跨度为9,可以判断出目标元素大小为10。由于14比10大,肯定要从10这个元素向下接着找;
- 找到10这个元素,发现10这个元素的最高级指针跨度为5,判断出目标元素大小为15,大于14,需要判断下级指针;
- 10这个元素的2级指针跨度为3,判断出目标元素为13,小于14,因此要基于元素13接着找;
- 13的下级指针跨度为1,因此目标元素是14,刚好与目标一致,找到。
这种多级指针的查询方式避免了传统链表的逐个遍历导致的查询效率下降问题。在对有序数据做随机查询和排序时效率非常高。
跳表的结构体如下:
typedef struct zskiplist {
// 头尾节点指针
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 最大的索引层级
int level;
} zskiplist;
可以看到SkipList主要属性是header和tail,也就是头尾指针,因此它是支持双向遍历的。
跳表中节点的结构体如下:
typedef struct zskiplistNode {
sds ele; // 节点存储的字符串
double score;// 节点分数,排序、查找用
struct zskiplistNode *backward; // 前一个节点指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 下一个节点指针
unsigned long span; // 索引跨度
} level[]; // 多级索引数组
} zskiplistNode;
每个节点中都包含ele和score两个属性,其中score是得分,也就是节点排序的依据。ele则是节点存储的字符串数据指针。
其内存结构如下:
3. SortedSet
SortedSet数据结构的特点是:
- 每组数据都包含score和member
- member唯一
- 可根据score排序
Redsi源码中zset,也就是SortedSet的结构体如下:
typedef struct zset {
dict *dict; // dict,底层就是HashTable
zskiplist *zsl; // 跳表
} zset;
其内存结构如图:
面试题:Redis的SortedSet底层的数据结构是怎样的?
答:SortedSet是有序集合,底层的存储的每个数据都包含element和score两个值。score是得分,element则是字符串值。SortedSet会根据每个element的score值排序,形成有序集合。
它支持的操作很多,比如:
- 根据element查询score值 -> 哈希表
- 按照score值升序或降序查询element -> 跳表
要实现根据element查询对应score值,就必须实现element与score之间的键值映射。SortedSet底层是基于HashTable来实现的。
要实现对score值排序,并且查询效率还高,就需要有一种高效的有序数据结构,SortedSet是基于跳表实现的。
加分项:因为SortedSet底层需要用到两种数据结构,对内存占用比较高。因此Redis底层会对SortedSet中的元素大小做判断,如果元素大小小于128且每个元素都小于64字节,SortedSet底层会采用ZipList,也就是压缩列表来代替HashTable和SkipList。
不过,ZipList存在连锁更新问题,因此在Redsi7.0版本以后,ZipList又被替换成ListPack(紧凑列表)。
四、Redis内存回收
Redis之所以性能强,最主要的原因就是基于内存存储。然而单结点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。
我们可以通过修改redis.conf文件,添加下面的配置来配置Redis的最大内存:
maxmemory 1gb
当内存达到上限,就无法存储更多的数据了。因此,Redis内部会有两套内存回收的策略:
- 内存过期策略
- 内存淘汰策略
1. 过期KEY处理
Redis提供了expire命令,给key设置TTL(存活时间):
可以发现,当key的TTL到期以后,再次访问name返回的是nil,说明这个key已经不存在了,对应的内存也得到释放,从而起到内存回收的目的。
思考:
1. Redis是如何知道一个key是否过期呢?
Redis的本身是键值型数据库,其所有数据都存在一个redisDB的结构体中,其中包含两个哈希表:
- dict:保存Redis中所有的键值对
- expires:保存Redis中所有的设置了过期时间的KEY及其到期时间(写入时间+TTl)
要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。
2. 是不是TTL到期就立刻删除了呢?
Redis并不会实时监测key的过期时间,在key过期后立刻删除。而是采用两种延迟删除的策略:
- 惰性删除:当有命令需要操作一个key的时候,检查改key的存活时间,如果已经过期才执行删除。
- 周期删除:通过一个定时任务,周期性的抽样部分有TLL的key,如果过期执行删除。
周期删除的定时任务执行周期有两种:
SLOW模式:默认执行频率为每秒10次,但每次执行时长不能超过25ms,受server.hz参数影响。
- ①执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
- ②执行清理耗时不超过一次执行周期的25%,即25ms。
- ③逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。
- ④如果没有达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束。
FAST模式:频率不固定,跟随Redis內部IO事件循环执行。两次任务之间间隔不低于2ms,执行时长不超过1ms。
- ①执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms。
- ②执行清理耗时不超过1ms。
- ③逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。
- ④如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束。
2. 内存淘汰策略
内存淘汰:就是当Redis内存使用达到设置的阈值时,Redis主动挑选部分key删除以释放更多内存的流程。
Redis会在每次处理客户端命令时都会对内存使用情况做判断,如果必要则执行内存淘汰。内存淘汰的策略有:
- noeviction:不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
- volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key,随机进行淘汰。也就是直接从db->dict中随机挑选
- volatile-random:对设置了TTL的key,随机进行淘汰。也就是从db->expires中随机挑选
- allkeys-lru:对全体key,基于LRU(最近最久未使用)算法进行淘汰
- volatile-lru:对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu:对全体key,基于LFU(最少使用)算法进行淘汰
- volatile-lfu:对设置了TTL的key,基于LFU算法进行淘汰。
比较容易混淆的两个:
- LRU(Least Recently Used):最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高;
- LRU(Least Frequently Used):最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
Redis怎么知道某个KEY的最近一次访问时间或者是访问频率呢?
其中的lru就是记录最近一次访问时间和频率的。当然,你可以选择LRU和LFU时的记录方式不同:
- LRU:以秒为单位记录最近一次访问时间,长度24bit
- LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数。
LFU的访问次数之所以叫作逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:
- ①生成[0~1)之间的随机数R
- ②计算 1/(旧次数 * lfu_log_factor + 1),记录为P,lfu_log_factor默认为10
- ③如果R < P,则计数器 + 1,且最大不超过255
- ④访问次数会随时间衰减,距离上一次访问时间每隔lfu_decay_time分钟(默认为1),计数器-1
显然,LFU的基于访问频率的统计更符合我们的淘汰目标,因此官方推荐使用LFU算法。
问题:Redis中的KEY可能有百万甚至更多,每个KEY都有自己访问时间或逻辑访问次数,我们要找出时间最早的或者访问次数最小的,难道要把Redis中所有数据排序?
答:Redis的内存淘汰是在每次执行命令时处理的,如果每次执行命令都先对全量数据做内存排序,那命令的执行时长肯定会非常长,这是不现实的。
Redis采取的是抽样法,即每次抽样一定数量(maxnenory_smples)的key,然后基于内存策略做排序,找出淘汰优先级最高的,删除这个key。这就导致Redis的算法并不是真正的LRU,而是一种基于抽样的近似LRU算法。
不过,在Redis3.0以后改进了这个算法,引入了一个淘汰候选池,抽样的key要与候选池中的key比较淘汰优先级,优先级更高的才会被放入候选池。然后在候选池中找出优先级最高的淘汰掉,这就使算法的结果更接近于真正的LRU算法了。特别是在抽样值较高的情况下(例如10),可以达到与真正的LRU接近的效果。
在图表中看到三种颜色的点形成三个不同的带,每个点就是一个加入的KEY。
- 浅灰色带是被驱逐的对象
- 灰色带是没有被驱逐的对象
- 绿色带是被添加的对象
3. 总结
1. Redis如何判断KEY是否过期呢?
答:在Redis中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。
2. Redis何时删除过期KEY?如何删除?
答:Redis的过期KEY处理有两种策略,分别是惰性删除和周期删除。
惰性删除是指在每次用户访问某个KEY时,判断KEY的过期时间;如果过期则删除;如果未过期则忽略。
周期删除有两种模式:
- SLOW模式:通过一个定时任务,定期的抽样部分带有TTL的KEY,判断其是否过期。默认情况下定时任务的执行频率是每秒10次,但每次执行不能超过25毫秒。如果执行抽样后发现时间还有剩余,并且过期KEY的比例过高,则会多次抽样。
- FAST模式:在Redis每次处理NIO事件之前,都会抽样部分带有TTL的KEY,判断是否过期,因此执行频率较高。但是每次执行时长不能超过1ms,如果时间充足并且过期KEY比例过高,也会多次抽样。
3. 当Redis内存不足时会怎么做?
答:这取决于配置的内存淘汰策略,Redis支持很多种内存淘汰策略,例如LRU、LFU、Random。但默认的策略是直接拒绝新的写入请求。而如果设置了其他策略,则会在每次执行命令后判断占用内存是否达到阈值。如果达到阈值则会基于配置的淘汰策略尝试进行内存淘汰,直到占用内存小于阈值为止。
4. 那你能聊聊LRU和LFU吗?
答:LRU是最近最久未使用。Redis的key都是RedisObject,当启用LRU算法后,Redis会在key的头信息中使用24个bit记录每个key的最近一次使用的时间lru。每次需要内存淘汰时,就会抽样一部分KEY,找出其中空闲时间最长的,也就是now - lru结果最大的,然后将其删除,如果内存依然不足,就重复这个过程。
5. 逻辑访问次数是如何计算的?
答:由于记录访问次数的只有8bit,即便是无符号数,最大值只有255,不可能记录真实的访问次数。因此Redis统计的其实就是逻辑访问次数。这其中只有一个计算公式,会根据当前的访问次数做计算,结果要么是次数+1,要么是次数不变。但随着当前访问次数越大,+1的概率也会越低,并且最大值不超过255。
除此之外,逻辑访问次数还有一个衰减周期,默认为1分钟,即每隔1分钟逻辑访问次数会-1。这样逻辑访问次数就能基本反映出一个key的访问热度了。
五、Redis缓存问题
1. 缓存一致性
Cache Aside:有缓存调用者自己维护数据库与缓存的一致性。即:
- 查询时:命中则直接返回,未命中则查询数据库并写入缓存
- 更新时:更新数据库并删除缓存,查询时自然会更新缓存
Read/Write Through:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:
- 查询时:命中则直接返回,未命中则查询数据库并写入缓存
- 更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据流
Write Behind Caching:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库。
目前企业中使用最多的就是Cache Aside模式,因为实现起来非常简单。但缺点也很明显,就是无法保证数据库与缓存的强一致性。分析如下:
Cache Aside的写操作是要在更新数据库的同时删除缓存,那为什么不选择更新数据库的同时更新缓存,而是删除呢?原因很简单,假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。
存在两种更新策略:①先更新数据库再删除缓存;②先删除缓存再更新数据库。
策略1:先更新数据库再删除缓存:
正常情况 异常情况
异常情况说明:
- 线程1查询缓存未命中,于是去查询数据库,查询到旧数据
- 线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存
- 线程1执行写入缓存的操作,写入旧数据
策略2:先删除缓存再更新数据库:
正常情况 异常情况
异常情况说明:
- 线程1删除缓存后,还没来得及更新数据库,
- 此时,线程2来查询,发现缓存为命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
- 然后线程1更新数据库,此时数据库是新数据,缓存是旧数据。
综上,添加缓存的目的是为了提高系统性能,而你要付出的代价就是缓存与数据库的强一致性。如果你要求数据库与缓存的强一致,那就需要加锁避免并行读写。但这降低了性能,与缓存的目标背道而驰。
因此,不管任何缓存同步方案最终的目的都是尽可能保证一致性,降低发生不一致的概率。我们采用先更新数据库再删除缓存的方案,已经将这种概率降到足够低,目的已经达到了。
同时,我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。
总结
缓存一致性策略的最佳实践方案:
1. 低一致性需求:使用Redis的key过期清理方案
2. 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:缓存命中则直接返回。缓存未命中则查询数据库,并写入缓存,设定超时时间。
- 写操作:先写数据库,然后再删除缓存。要确保数据库与缓存操作的原子性。
2. 缓存穿透
缓存穿透是指客户端请求的数据再数据库中根本不存在,从而导致请求穿透缓存,直接打到数据库的问题。
常见的解决方案有两种:
2.1 缓存空值
当发现请求的数据既不存在于缓存,也不存在于数据库时,将空值缓存到Redis,避免频繁查询数据库
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗
2.2 布隆过滤
布隆过滤是一种数据统计的算法,用于简述一个元素是否存在于一个集合中。但是布隆过滤无需存储元素到集合,而是把元素映射到一个很长的二进制数位上。
- 首先需要一个很长很长的二进制数,默认每一位都是0
- 然后需要N个不同算法的哈希函数
- 将集合中的元素根据N个哈希函数做运算,得到N个数字,然后将每个数字对应的bit位标记为1
- 要判断某个元素是否存在,只需要把元素按照上述方式运算,判断对应的bit位是否是1即可
例如,现在N=3:
- hello经过运算得到3个角标:1、5、12
- world经过运算得到3个角标:8、17、21
- java经过运算得到3个角标:17、25、28
则需要将每个元素对应角标位置置为1:
此时,我们要判断元素是否存在,只需要再次基于N个hash函数做运算,得到N个角标,判断每个角标的位置是不是1:
- 主要全是1,就证明元素存在
- 任意位置为0,就证明元素一定不存在
假如某个元素本身并不存在,也没添加到布隆过滤器过。但是由于存在hash碰撞的可能性,这就会出现这个元素计算出的角标已经被其他元素置为1的情况。那么这个元素也会被误判为已经存在。
因此,布隆过滤器的判断存在误差:
- 当布隆过滤器认为元素不存在时,它肯定不存在
- 当布隆过滤器认为元素存在时,它可能存在,也可能不存在
当bit数组越大、Hash函数N越复杂,N越大时,这个误判的概率也就越低。由于采用bit数组来标示数据,即便4,294,967,296个bit位,也只占用512mb的空间。
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂,存在误判可能
3. 缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同Key的TTL添加随机值,这样KEY的过期时间不同,不会大量KEY同时过期
- 利用Redis集群提高服务的可用性,避免缓存服务宕机
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存,比如先查询本地缓存,本地缓存未命中再查询Redis,Redis未命中再查询数据库。即便Redis宕机,也还有本地缓存可以抗压力。
4. 缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
由于我们采用的是Cache Aside模式,当缓存失效时需要下次查询时才会更新缓存。当某个key缓存失效时,如果这个key是热点key,并发访问量比较高,就会在一瞬间涌入大量请求,都发现缓存未命中,于是都会去查询数据库,尝试重建缓存。可能一瞬间就把数据库压垮了。
如上图所示:
- 线程1发现缓存未命中,准备查询数据库,重建缓存,但是因为数据比较复杂,导致查询数据库耗时较久
- 在这个过程中,一下子来了3个新的线程,就都会发现缓存未命中,都去查询数据库
- 数据库压力激增
常见的解决方案有两种:互斥锁和逻辑过期
互斥锁:给重建缓存逻辑加锁,避免多线程同时执行。
逻辑过期:热点key不要设置过期时间,在活动结束后手动删除。
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 |
|
|
逻辑过期 |
|
|
5. 总结
1. 如何保证缓存的双写一致性?
答:缓存的双写(写数据库和缓存)一致性很难保证强一致,只能尽可能降低不一致的概率,确保最终一致。我们项目中采用的是Cache Aside模式。简单来说,就是在更新数据库之后删除缓存;在查询时先查询缓存,如果未命中则查询数据库并写入缓存。同时我们会给缓存设置过期时间作为兜底方案,如果真的出现了不一致的情况,也可以通过缓存过期来保证最终一致。
追问:为什么不采用延迟双删机制?
答:延迟双删的第一次删除并没有实际意义,第二次采用延迟删除主要是解决数据库主从同步的延迟问题,我认为这是数据库主从的一致性问题,与缓存同步无关。既然主节点数据已经更新,Redis的缓存理应更新。而且延迟双删会增加缓存业务复杂度,也没能完全避免缓存一致性问题,投入回报比太低。
2. 如何解决缓存穿透问题?
答:缓存穿透也可以说是穿透攻击,具体来说是因为请求访问到了数据库不存在的值,这样缓存无法命中,必然访问数据库。如果高并发地访问这样的接口,会给数据库带来巨大压力。
我们项目中都是基于布隆过滤器来解决缓存穿透问题的,当缓存未命中时基于布隆过滤器判断数据是否存在,如果不存在则不去访问数据库。当然,也可以使用缓存空值发方式解决,不管这种方案比较浪费内存。
3. 如何解决缓存雪崩问题?
答:缓存雪崩的常见原因有两个,第一是因为大量key同时过期。针对这个问题我们可以给缓存key设置不同的TTL值,避免key同时过期。
第二个原因是Redis宕机导致缓存不可用。针对这个问题我们可以利用集群提高Redis的可用性。也可以添加多级缓存,当Redis宕机时还有本地缓存可用。
4. 如何解决缓存击穿问题?
答:缓存击穿往往是由热点Key引起的,当热点Key过期时,大量请求涌入同时查询,发现缓存未命中都会去访问数据库,导致数据库压力激增。解决这个问题的主要思想就是避免多线程并发去重建缓存,因此解决方案有两种。
第一种是基于互斥锁。当发现缓存未命中时需要先获取互斥锁,再重建缓存,缓存重建完成释放锁。这样就可以保证缓存重建同一时刻只会有一个线程执行。不过这种做法会导致缓存重建时性能下降严重。
第二种时基于逻辑过期。也就是不给热点Key设置过期时间,而是给数据添加一个过期时间的字段。这样热点Key就不会过期,缓存中永远有数据。查询到数据时基于其中的过期时间判断Key是否过期,如果过期开启独立新线程异步的重建缓存,而查询请求先返回旧数据即可。当然,这个过程也要加互斥锁,但由于重建缓存是异步的,而且获取锁失败也无需等待,而是返回旧数据,这样性能几乎不受影响。
需要注意的是,无论是采用哪种方式,在获取互斥锁之后一定要再次判断缓存是否命中,做double check,因为当你获取锁成功时,可能在你之前有其他线程已经重建缓存了。
资料文档:Docs