目录
1.Redis主从
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
1.1.主从集群结构
如图所示,集群中有一个master节点、两个slave节点(现在叫replica)。当我们通过Redis的Java客户端访问主从集群时,应该做好路由:
-
如果是写操作,应该访问master节点,master会自动将数据同步给两个slave节点
-
如果是读操作,建议访问各个slave节点,从而分担并发压力
1.2 搭建主从集群
我们会在同一个虚拟机中利用3个Docker容器来搭建主从集群,容器信息如下:
1.2.1.启动多个Redis实例
利用课前资料提供的docker-compose文件来构建主从集群,文件内容如下:
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"]
将其上传至虚拟机的/root/redis
目录下:
执行命令,运行集群:
docker compose up -d
结果:
查看docker容器,发现都正常启动了:
由于采用的是host模式,我们看不到端口映射。不过能直接在宿主机通过ps命令查看到Redis进程:
1.2.2.建立集群
虽然我们启动了3个Redis实例,但是它们并没有形成主从关系。我们需要通过命令来配置主从关系:
# Redis5.0以前
slaveof <masterip> <masterport>
# Redis5.0以后
replicaof <masterip> <masterport>
有临时和永久两种模式:
-
永久生效:在redis.conf文件中利用
slaveof
命令指定master
节点 -
临时生效:直接利用redis-cli控制台输入
slaveof
命令,指定master
节点
我们测试临时模式,首先连接r2
,让其以r1
为master
# 连接r2
docker exec -it r2 redis-cli -p 7002
# 认r1主,也就是7001
slaveof 192.168.150.101 7001
然后连接r3
,让其以r1
为master
# 连接r3
docker exec -it r3 redis-cli -p 7003
# 认r1主,也就是7001
slaveof 192.168.150.101 7001
然后连接r1
,查看集群状态:
# 连接r1
docker exec -it r1 redis-cli -p 7001
# 查看集群状态
info replication
结果如下:
可以看到,当前节点r1:7001
的角色是master
,有两个slave与其连接:
-
slave0
:port
是7002
,也就是r2
节点 -
slave1
:port
是7003
,也就是r3
节点
1.2.3.测试
依次在r1
、r2
、r3
节点上执行下面命令:
你会发现,只有在r1
这个节点上可以执行set
命令(写操作),其它两个节点只能执行get
命令(读操作)。也就是说读写操作已经分离了。
1.3.主从同步原理
在刚才的主从测试中,我们发现r1
上写入Redis的数据,在r2
和r3
上也能看到,这说明主从之间确实完成了数据同步。那么这个同步是如何完成的呢?
1.3.1.全量同步
主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点,流程:
这里有一个问题,master
如何得知salve
是否是第一次来同步呢??
有几个概念,可以作为判断依据:
-
Replication Id
:简称replid
,是数据集的标记,replid一致则是同一数据集。每个实例都有唯一的replid
,slave
则会继承master
节点的replid
-
offset
:偏移量,随着记录在repl_baklog
中的数据增多而逐渐增大。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是否一致。流程如图:
那怎么把数据传给slave节点呢?
会执行bgsave命令,开启一个独立的进程,然后把redis在内存中存储的数据持久化到硬盘上,写到一个RDB文件里,把RDB文件发给slave,slave把本地数据清除掉,然后把RDB文件加载到内存,这样就实现了两者数据一致。
在增量同步的时候,master怎么知道slave缺失了什么数据?
1.3.2.增量同步
全量同步需要先做RDB,然后将RDB文件通过网络传输个slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步。
什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:
那么master怎么知道slave与自己的数据差异在哪里呢?
1.3.3.repl_baklog原理
master怎么知道slave与自己的数据差异在哪里呢?
这就要说到全量同步时的repl_baklog
文件了。这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。
repl_baklog
中会记录Redis处理过的命令及offset
,包括master当前的offset
,和slave已经拷贝到的offset
:
slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。
随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset:
直到数组被填满:
此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分:
但是,如果slave出现网络阻塞,导致master的offset
远远超过了slave的offset
:
如果master继续写入新数据,master的offset
就会覆盖repl_baklog
中旧的数据,直到将slave现在的offset
也覆盖:
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset
都没有了,无法完成增量同步了。只能做全量同步。
repl_baklog
大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于repl_baklog
做增量同步,只能再次全量同步。
1.4.主从同步优化
主从同步可以保证主从数据的一致性,非常重要。可以从以下几个方面来优化Redis主从就集群:
-
在master中配置
repl-diskless-sync yes
启用无磁盘复制(主节点不用把数据写到磁盘,而是直接通过网络传给slave,这样的话就可以大量减少磁盘IO),避免全量同步时的磁盘IO。 -
Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
-
适当提高
repl_baklog
的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步 -
限制一个master上的slave节点数量,如果实在是太多slave,则可以采用
主-从-从
链式结构,减少master压力
主-从-从
架构图:
简述全量同步和增量同步区别?
-
全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
-
增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave
什么时候执行全量同步?
-
slave节点第一次连接master节点时
-
slave节点断开时间太久,repl_baklog中的offset已经被覆盖时
什么时候执行增量同步?
-
slave节点断开又恢复,并且在
repl_baklog
中能找到offset时
2.Redis哨兵
主从结构中master节点的作用非常重要,一旦故障就会导致集群不可用。那么有什么办法能保证主从集群的高可用性呢?
2.1.哨兵工作原理
Redis提供了哨兵
(Sentinel
)机制来监控主从集群监控状态,确保集群的高可用性。
2.1.1.哨兵作用
哨兵集群作用原理图:
哨兵的作用如下:
-
状态监控:
Sentinel
会不断检查您的master
和slave
是否按预期工作 -
故障恢复(failover):如果
master
故障,Sentinel
会将一个slave
提升为master
。当故障实例恢复后会成为slave
-
状态通知:
Sentinel
充当Redis
客户端的服务发现来源,当集群发生failover
时,会将最新集群信息推送给Redis
的客户端
那么问题来了,Sentinel
怎么知道一个Redis节点是否宕机呢?
2.1.2.状态监控
Sentinel
基于心跳机制监测服务状态,每隔1秒向集群的每个节点发送ping命令,并通过实例的响应结果来做出判断:
-
主观下线(sdown):如果某sentinel节点发现某Redis节点未在规定时间响应,则认为该节点主观下线。
-
客观下线(odown):若超过指定数量(通过
quorum
设置)的sentinel都认为该节点主观下线,则该节点客观下线。quorum值最好超过Sentinel节点数量的一半,Sentinel节点数量至少3台。
如图:
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
-
先会判断slave节点与master节点断开时间长短,如果超过
down-after-milliseconds * 10
则会排除该slave节点 -
然后判断slave节点的
slave-priority
值,越小优先级越高,如果是0则永不参与选举(默认都是1)。 -
如果
slave-prority
一样,则判断slave节点的offset
值,越大说明数据越新,优先级越高 -
最后是判断slave节点的
run_id
大小,越小优先级越高(通过info server可以查看run_id
)。
High availability with Redis Sentinel | Docs
2.1.3.选举leader
例如,有一个集群,初始状态下7001为master
,7002和7003为slave
:
假如master发生故障,slave1当选。则故障转移的流程如下:
1)sentinel
给备选的slave1
节点发送slaveof no one
命令,让该节点成为master
2)sentinel
给所有其它slave
发送slaveof 192.168.150.101 7002
命令,昭告天下,新王登基,也就是7002
的slave
节点,开始从新的master
上同步数据。
3)最后,当故障节点恢复后会接收到哨兵信号,执行slaveof 192.168.150.101 7002
命令,成为slave
:
3.Redis分片集群
主从模式可以解决高可用、高并发读的问题。但依然有两个问题没有解决:
-
海量数据存储
-
高并发写
要解决这两个问题就需要用到分片集群了。分片的意思,就是把数据拆分存储到不同节点,这样整个集群的存储数据量就更大了。
Redis分片集群的结构如图:
分片集群特征:
-
集群中有多个master,每个master保存不同分片数据 ,解决海量数据存储问题
-
每个master都可以有多个slave节点 ,确保高可用
-
master之间通过ping监测彼此健康状态 ,类似哨兵作用
-
客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点
3.1.搭建分片集群
Redis分片集群最少也需要3个master节点,由于我们的机器性能有限,我们只给每个master配置1个slave,形成最小的分片集群:
计划部署的节点信息如下:
3.1.1.集群配置
分片集群中的Redis节点必须开启集群模式,一般在配置文件中添加下面参数:
port 7000
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
其中有3个我们没见过的参数:
-
cluster-enabled
:是否开启集群模式 -
cluster-config-file
:集群模式的配置文件名称,无需手动创建,由集群自动维护 -
cluster-node-timeout
:集群中节点之间心跳超时时间
一般搭建部署集群肯定是给每个节点都配置上述参数,不过考虑到我们计划用docker-compose
部署,因此可以直接在启动命令中指定参数,偷个懒。
在虚拟机的/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"]
注意:使用Docker部署Redis集群,network模式必须采用host
3.1.2.启动集群
进入/root/redis-cluster
目录,使用命令启动redis:
docker-compose up -d
启动成功,可以通过命令查看启动进程:
ps -ef | grep redis
# 结果:
root 4822 4743 0 14:29 ? 00:00:02 redis-server *:7002 [cluster]
root 4827 4745 0 14:29 ? 00:00:01 redis-server *:7005 [cluster]
root 4897 4778 0 14:29 ? 00:00:01 redis-server *:7004 [cluster]
root 4903 4759 0 14:29 ? 00:00:01 redis-server *:7006 [cluster]
root 4905 4775 0 14:29 ? 00:00:02 redis-server *:7001 [cluster]
root 4912 4732 0 14:29 ? 00:00:01 redis-server *:7003 [cluster]
可以发现每个redis节点都以cluster模式运行。不过节点与节点之间并未建立连接。
接下来,我们使用命令创建集群:
# 进入任意节点容器
docker exec -it r1 bash
# 然后,执行命令
redis-cli --cluster create --cluster-replicas 1 \
192.168.150.101:7001 192.168.150.101:7002 192.168.150.101:7003 \
192.168.150.101:7004 192.168.150.101:7005 192.168.150.101:7006
命令说明:
-
redis-cli --cluster
:代表集群操作命令 -
create
:代表是创建集群 -
--cluster-replicas 1
:指定集群中每个master
的副本个数为1-
此时
节点总数 ÷ (replicas + 1)
得到的就是master
的数量n
。因此节点列表中的前n
个节点就是master
,其它节点都是slave
节点,随机分配到不同master
-
输入命令后控制台会弹出下面的信息:
输入yes
然后回车。会发现集群开始创建,并输出下列信息:
接着,我们可以通过命令查看集群状态:
redis-cli -p 7001 cluster nodes
3.2.散列插槽
数据要分片存储到不同的Redis节点,肯定需要有分片的依据,这样下次查询的时候才能知道去哪个节点查询。很多数据分片都会采用一致性hash算法。而Redis则是利用散列插槽(hash slot
)的方式实现数据分片。
Scale with Redis Cluster | Docs
在Redis集群中,共有16384个hash slots
,集群中的每一个master节点都会分配一定数量的hash slots
。具体的分配在集群创建时就已经指定了:
如图中所示:
-
Master[0],本例中就是7001节点,分配到的插槽是0~5460
-
Master[1],本例中就是7002节点,分配到的插槽是5461~10922
-
Master[2],本例中就是7003节点,分配到的插槽是10923~16383
当我们读写数据时,Redis基于CRC16
算法对key
做hash
运算,得到的结果与16384
取余,就计算出了这个key
的slot
值。然后到slot
所在的Redis节点执行读写操作。
不过hash slot
的计算也分两种情况:
-
当
key
中包含{}
时,根据{}
之间的字符串计算hash slot
-
当
key
中不包含{}
时,则根据整个key
字符串计算hash slot
例如:
-
key是
user
,则根据user
来计算hash slot -
key是
user:{age}
,则根据age
来计算hash slot
我们来测试一下,先于7001
建立连接:
# 进入容器
docker exec -it r1 bash
# 进入redis-cli
redis-cli -p 7001
# 测试
set user jack
会发现报错了:
提示我们MOVED 5474
,其实就是经过计算,得出user
这个key
的hash slot
是5474
,而5474
是在7002
节点,不能在7001
上写入!!
说好的任意节点都可以读写呢???
这是因为我们连接的方式有问题,连接集群时,要加-c
参数:
# 通过7001连接集群
redis-cli -c -p 7001
# 存入数据
set user jack
结果如下:
可以看到,客户端自动跳转到了5474
这个slot
所在的7002
节点。现在,我们添加一个新的key,这次加上{}
:
# 试一下key中带{}
set user:{age} 21
# 再试一下key中不带{}
set age 20
结果如下:
可以看到user:{age}
和age
计算出的slot
都是741
。
3.3.故障转移
分片集群的节点之间会互相通过ping的方式做心跳检测,超时未回应的节点会被标记为下线状态。当发现master下线时,会将这个master的某个slave提升为master。
我们先打开一个控制台窗口,利用命令监测集群状态:
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原本的小弟7006变成了master
:
而7002被标记为slave
,而且其master
正好是7006,主从地位互换。
3.4.总结
Redis分片集群如何判断某个key应该在哪个实例?
-
将16384个插槽分配到不同的实例
-
根据key计算哈希值,对16384取余
-
余数作为插槽,寻找插槽所在实例即可
如何将同一类数据固定的保存在同一个Redis实例?
-
Redis计算key的插槽值时会判断key中是否包含
{}
,如果有则基于{}
内的字符计算插槽 -
数据的key中可以加入
{类型}
,例如key都以{typeId}
为前缀,这样同类型数据计算的插槽一定相同
4.Redis数据结构
我们常用的Redis数据类型有5种,分别是:
-
String
-
List
-
Set
-
SortedSet
-
Hash
还有一些高级数据类型,比如Bitmap、HyperLogLog、GEO等,其底层都是基于上述5种基本数据类型。因此在Redis的源码中,其实只有5种数据类型。
4.1.RedisObject
不管是任何一种数据类型,最终都会封装为RedisObject格式,它是一种结构体,C语言中的一种结构,可以理解为Java中的类。
结构大概是这样的:
可以看到整个结构体中并不包含真实的数据,仅仅是对象头信息,内存占用的大小为4+4+24+32+64 = 128bit
也就是16字节,然后指针ptr
指针指向的才是真实数据存储的内存地址。所以RedisObject的内存开销是很大的。属性中的encoding
就是当前对象底层采用的数据结构或编码方式,可选的有11种之多:
编号 | 编码方式 | 说明 |
---|---|---|
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 |
|
4.2.SkipList
SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
-
元素按照升序排列存储
-
节点可能包含多个指针,指针跨度不同。
传统链表只有指向前后元素的指针,因此只能顺序依次访问。如果查找的元素在链表中间,查询的效率会比较低。而SkipList则不同,它内部包含跨度不同的多级指针,可以让我们跳跃查找链表中间的元素,效率非常高。
其结构如图:
我们可以看到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这个元素最高级级指针跨度为2,判断出目标元素为15,比14大,需要判断下级指针。
-
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则是节点存储的字符串数据指针。其内存结构如下:
4.3.SortedSet
Redis源码中zset
,也就是SortedSet
的结构体如下:
typedef struct zset {
dict *dict; // dict,底层就是HashTable
zskiplist *zsl; // 跳表
} zset;
其内存结构如图:
5.Redis内存回收
Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。我们可以通过修改redis.conf文件,添加下面的配置来配置Redis的最大内存:
当内存达到上限,就无法存储更多数据了。因此,Redis内部会有两套内存回收的策略:
-
内存过期策略
-
内存淘汰策略
5.1 内存过期处理
存入Redis中的数据可以配置过期时间,到期后再次访问会发现这些数据都不存在了,也就是被过期清理了。
5.1.1.过期命令
Redis中通过expire
命令可以给KEY设置TTL
(过期时间),例如:
# 写入一条数据
set num 123
# 设置20秒过期时间
expire num 20
不过set命令本身也可以支持过期时间的设置:
# 写入一条数据并设置20s过期时间
set num EX 20
当过期时间到了以后,再去查询数据,会发现数据已经不存在。
5.1.2.过期策略
那么问题来了:
-
Redis如何判断一个KEY是否过期呢?
-
Redis又是何时删除过期KEY的呢?
Redis不管有多少种数据类型,本质是一个KEY-VALUE
的键值型数据库,而这种键值映射底层正式基于HashTable来实现的,在Redis中叫做Dict.
typedef struct redisDb {
dict dict; / The keyspace for this DB , 也就是存放KEY和VALUE的哈希表*/
dict *expires; /* 同样是哈希表,但保存的是设置了TTL的KEY,及其到期时间*/
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS /
int id; / Database ID, 0 ~ 15 /
long long avg_ttl; / Average TTL, just for stats /
unsigned long expires_cursor; / Cursor of the active expire cycle. */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
现在回答第一个问题:
Redis是何时删除过期KEY的呢?
Redis并不会在KEY过期时立刻删除KEY,因为要实现这样的效果就必须给每一个过期的KEY设置时钟,并监控这些KEY的过期状态。无论对CPU还是内存都会带来极大的负担。
Redis的过期KEY删除策略有两种:
-
惰性删除
-
周期删除
惰性删除,顾明思议就是过期后不会立刻删除。那在什么时候删除呢?
Redis会在每次访问KEY的时候判断当前KEY有没有设置过期时间,如果有,过期时间是否已经到期。对应的源码如下:
// db.c
// 寻找要执行写操作的key
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
// 检查key是否过期,如果过期则删除
expireIfNeeded(db,key);
return lookupKey(db,key,flags);
}
// 寻找要执行读操作的key
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
robj *val;
// 检查key是否过期,如果过期则删除
if (expireIfNeeded(db,key) == 1) {
// 略 ...
}
val = lookupKey(db,key,flags);
if (val == NULL)
goto keymiss;
server.stat_keyspace_hits++;
return val;
}
周期删除:顾明思议是通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。
执行周期有两种:
-
SLOW模式:Redis会设置一个定时任务
serverCron()
,按照server.hz
的频率来执行过期key清理 -
FAST模式:Redis的每个事件循环前执行过期key清理(事件循环就是NIO事件处理的循环)。
SLOW模式规则:
-
① 执行频率受
server.hz
影响,默认为10,即每秒执行10次,每个执行周期100ms。 -
② 执行清理耗时不超过一次执行周期的25%,即25ms.
-
③ 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
-
④ 如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束
FAST模式规则(过期key比例小于10%不执行):
-
① 执行频率受
beforeSleep()
调用频率影响,但两次FAST模式间隔不低于2ms -
② 执行清理耗时不超过1ms
-
③ 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
-
④ 如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束
5.2.内存淘汰策略
对于某些特别依赖于Redis的项目而言,仅仅依靠过期KEY清理是不够的,内存可能很快就达到上限。因此Redis允许设置内存告警阈值,当内存使用达到阈值时就会主动挑选部分KEY删除以释放更多内存。这叫做内存淘汰机制。
5.2.1.内存淘汰时机
那么问题来了,当内存达到阈值时执行内存淘汰,但问题是Redis什么时候会执去判断内存是否达到预警呢?
Redis每次执行任何命令时,都会判断内存是否达到阈值:
// server.c中处理命令的部分源码
int processCommand(client *c) {
// ... 略
if (server.maxmemory && !server.lua_timedout) {
// 调用performEvictions()方法尝试进行内存淘汰
int out_of_memory = (performEvictions() == EVICT_FAIL);
// ... 略
if (out_of_memory && reject_cmd_on_oom) {
// 如果内存依然不足,直接拒绝命令
rejectCommand(c, shared.oomerr);
return C_OK;
}
}
}
5.2.2.淘汰策略
好了,知道什么时候尝试淘汰了,那具体Redis是如何判断该淘汰哪些Key
的呢?
Redis支持8种不同的内存淘汰策略:
-
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,基于LFI算法进行淘汰
LRU 算法原理
LRU 算法的核心思想是,如果数据在最近一段时间内没有被访问过,那么在未来它被访问的可能性也相对较低。所以当 Redis 内存达到设定的阈值时,会优先淘汰最近最少使用的数据,以腾出空间来存储新的数据。
比较容易混淆的有两个算法:
-
LRU(
L
east
R
ecently
U
sed
),最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。 -
LFU(
L
east
F
requently
U
sed
),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
Redis怎么知道某个KEY的最近一次访问时间
或者是访问频率
呢?还记不记得之前讲过的RedisObject的结构?
其中的lru
就是记录最近一次访问时间和访问频率的。当然,你选择LRU
和LFU
时的记录方式不同:
-
LRU:以秒为单位记录最近一次访问时间,长度24bit
-
LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数
时间就不说了,那么逻辑访问次数又是怎么回事呢?8位无符号数字最大才255,访问次数超过255怎么办?
这就要聊起Redis的逻辑访问次数算法了,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采取的是抽样法,即每次抽样一定数量(maxmemory_smples
)的key,然后基于内存策略做排序,找出淘汰优先级最高的,删除这个key。这就导致Redis的算法并不是真正的LRU,而是一种基于抽样的近似LRU算法。
不过,在Redis3.0以后改进了这个算法,引入了一个淘汰候选池,抽样的key要与候选池中的key比较淘汰优先级,优先级更高的才会被放入候选池。然后在候选池中找出优先级最高的淘汰掉,这就使算法的结果更接近与真正的LRU算法了。特别是在抽样值较高的情况下(例如10),可以达到与真正的LRU接近的效果。
这也是官方给出的真正LRU与近似LRU的结果对比:
5.3.总结
6.缓存问题
Redis经常被用作缓存,而缓存在使用的过程中存在很多问题需要解决。例如:
-
缓存的数据一致性问题
-
缓存击穿
-
缓存穿透
-
缓存雪崩
6.1.缓存一致性
我们先看下目前企业用的最多的缓存模型。缓存的通用模型有三种:
-
Cache Aside
:有缓存调用者自己维护数据库与缓存的一致性。即:-
查询时:命中则直接返回,未命中则查询数据库并写入缓存
-
更新时:更新数据库并删除缓存,查询时自然会更新缓存
-
-
Read/Write Through
:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:-
查询时:命中则直接返回,未命中则查询数据库并写入缓存
-
更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据库
-
-
Write Behind Cahing
:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库
目前企业中使用最多的就是Cache Aside
模式,因为实现起来非常简单。但缺点也很明显,就是无法保证数据库与缓存的强一致性。为什么呢?我们一起来分析一下。
Cache Aside
的写操作是要在更新数据库的同时删除缓存,那为什么不选择更新数据库的同时更新缓存,而是删除呢?原因很简单,假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。
那到底是先更新数据库再删除缓存,还是先删除缓存再更新数据库呢?现在假设有两个线程,一个来更新数据,一个来查询数据。我们分别分析两种策略的表现。
我们先分析策略1,先删除缓存再更新数据库(更新的请求先到达, 先把redis缓存删掉,还没来得及向数据库更新数据,查询的请求来了,发现缓存已删,就去查数据库,此时数据库更新操作还没执行完,因为写操作比较耗时,查询请求查数据库查到的是旧数据,然后把旧的数据再次缓存到redis当中,于是之前删除缓存那个操作不是白删了嘛,这个时候更新的线程缓过来劲儿了,数据库里面变成新数据了,而你的缓存是旧的缓存,这不是做了个无用功嘛!!,数据出现了不一致,因为数据库写操作比较耗时,所以这种情况发生的概率还是比较高的!!!!):
异常情况
异常情况说明:
-
线程1删除缓存后,还没来得及更新数据库,
-
此时线程2来查询,发现缓存未命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
-
然后线程1更新数据库,此时数据库是新数据,缓存是旧数据
由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高。因此不推荐这种方案。
再来看策略2,先更新数据库再删除缓存(假设查询的请求先到达,查询那一刻刚好发现redis缓存未命中,未命中只能去查数据库, 查到了数据,准备把数据缓存到redis中,正在准备缓存的时候,来一个线程,请求修改,更新数据库,数据变成新数据了,但是,在修改之前,查询的请求已经查到数据了,而且是旧数据,接着更新请求要执行删除缓存的操作,更新的线程弄完以后,第一个线程换过劲儿来了,把旧数据写到缓存当中,此时缓存中是旧数据,而数据库当中是新数据,此时就出现了数据不一致的情况,并发安全问题又一次发生,但是这种情况发生的概率是极低的,必须是恰好缓存过期,查询未命中,此外,在准备写入缓存的时候,另一个更新线程干了一大堆的事儿,更新数据库和更新缓存,写操作是非常耗时的,所以这种并发安全问题发生的概率极低!!!):
正常情况
异常情况
可以发现,异常状态发生的概率极为苛刻,线程1必须是查询数据库已经完成,但是缓存尚未写入之前。线程2要完成更新数据库同时删除缓存的两个操作。要知道线程1执行写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低。
6.2.缓存穿透
什么是缓存穿透呢?
我们知道,当请求查询缓存未命中时,需要查询数据库以加载缓存。但是大家思考一下这样的场景:如果我访问一个数据库中也不存在的数据。会出现什么现象?
由于数据库中不存在该数据,那么缓存中肯定也不存在。因此不管请求该数据多少次,缓存永远不可能建立,请求永远会直达数据库。
假如有不怀好意的人,开启很多线程频繁的访问一个数据库中也不存在的数据。由于缓存不可能生效,那么所有的请求都访问数据库,可能就会导致数据库因过高的压力而宕机。
解决这个问题有两种思路:
-
缓存空值
-
布隆过滤器
6.2.1.缓存空值
简单来说,就是当我们发现请求的数据即不存在于缓存,也不存在于数据库时,将空值缓存到Redis(以查询的参数为Key,在redis中去缓存一个空值。相当于给那些不存在的数据也建立了缓存,避免不存在的数据请求打到数据库),避免频繁查询数据库。实现思路如下:
优点:
-
实现简单,维护方便
缺点:
-
额外的内存消耗
6.2.2.布隆过滤器
布隆过滤是一种数据统计的算法,用于检索一个元素是否存在一个集合中。一般我们判断集合中是否存在元素,都会先把元素保存到类似于树、哈希表等数据结构中,然后利用这些结构查询效率高的特点来快速匹配判断。但是随着元素数量越来越多,这种模式对内存的占用也越来越大,检索的速度也会越来越慢。而布隆过滤的内存占用小,查询效率却很高。布隆过滤首先需要一个很长的bit数组,默认数组中每一位都是0.
然后还需要K
个hash
函数,将元素基于这些hash函数做运算的结果映射到bit数组的不同位置,并将这些位置置为1,例如现在k=3:
-
hello
经过运算得到3个角标:1、5、12 -
world
经过运算得到3个角标:8、17、21 -
java
经过运算得到3个角标:17、25、28
则需要将每个元素对应角标位置置为1:
此时,我们要判断元素是否存在,只需要再次基于K
个hash
函数做运算, 得到K
个角标,判断每个角标的位置是不是1:
-
只要全是1,就证明元素存在
-
任意位置为0,就证明元素一定不存在
假如某个元素本身并不存在,也没添加到布隆过滤器过。但是由于存在hash碰撞的可能性,这就会出现这个元素计算出的角标已经被其它元素置为1的情况。那么这个元素也会被误判为已经存在。
因此,布隆过滤器的判断存在误差:
-
当布隆过滤器认为元素不存在时,它肯定不存在
-
当布隆过滤器认为元素存在时,它可能存在,也可能不存在
当bit
数组越大、Hash
函数K
越复杂,K
越大时,这个误判的概率也就越低。由于采用bit
数组来标示数据,即便4,294,967,296
个bit
位,也只占512mb
的空间
我们可以把数据库中的数据利用布隆过滤器标记出来,当用户请求缓存未命中时,先基于布隆过滤器判断。如果不存在则直接拒绝请求,存在则去查询数据库。尽管布隆过滤存在误差,但一般都在0.01%左右,可以大大减少数据库压力。
使用布隆过滤后的流程如下:
6.3.缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
常见的解决方案有:
-
给不同的Key的TTL添加随机值,这样KEY的过期时间不同,不会大量KEY同时过期
-
利用Redis集群提高服务的可用性,避免缓存服务宕机
-
给缓存业务添加降级限流策略
-
给业务添加多级缓存,比如先查询本地缓存,本地缓存未命中再查询Redis,Redis未命中再查询数据库。即便Redis宕机,也还有本地缓存可以抗压力
6.4.缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
由于我们采用的是Cache Aside
模式,当缓存失效时需要下次查询时才会更新缓存。当某个key缓存失效时,如果这个key是热点key,并发访问量比较高。就会在一瞬间涌入大量请求,都发现缓存未命中,于是都会去查询数据库,尝试重建缓存。可能一瞬间就把数据库压垮了。
如上图所示:
-
线程1发现缓存未命中,准备查询数据库,重建缓存,但是因为数据比较复杂,导致查询数据库耗时较久
-
在这个过程中,一下次来了3个新的线程,就都会发现缓存未命中,都去查询数据库
-
数据库压力激增
常见的解决方案有两种:
-
互斥锁:给重建缓存逻辑加锁,避免多线程同时指向
-
逻辑过期:热点key不要设置过期时间,在活动结束后手动删除。
基于互斥锁的方案如图:
缺点: 如果说构建缓存的时间比较久,那么大量的请求就会等待,性能会差一点。
逻辑过期的思路如图:
6.5.面试总结
7.高频面试考点
7.1.Redis为什么这么快?
之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:
- Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
- Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
- Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO流的效果。
7.2为什么redis比mysql要快
- 内存存储:Redis 是基于内存存储的 NoSQL 数据库,而 MySQL 是基于磁盘存储的关系型数据库。由于内存存储速度快,Redis 能够更快地读取和写入数据,而无需像 MySQL 那样频繁进行磁盘 I/O 操作。
- 简单数据结构:Redis 是基于键值对存储数据的,支持简单的数据结构(字符串、哈希、列表、集合、有序集合)。相比之下,MySQL 需要定义表结构、索引等复杂的关系型数据结构因此在某些场景下 Redis 的数据操作更为简单高效,比如 Redis 用哈希表查询, 只需要O(1) 时间复杂度而MySQL引擎的底层实现是B+Tree,时间复杂度是O(logn)。
- 线程模型:Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
7.3mysql对比redis的优势
- 事务支持:MySQL 是关系型数据库,提供了完整的事务处理功能,支持 ACID(原子性、一致性、隔离性、持久性)属性,能够确保数据在复杂的业务操作中的一致性和完整性。
- 复杂查询:MySQL 支持强大的 SQL 语言,能够进行复杂的查询操作,如多表连接、子查询、分组、排序等。可以方便地处理各种复杂的业务逻辑和数据分析需求,适用于构建大型的企业级应用系统,能够对大量数据进行高效的检索和处理。
- 可以定义各种数据约束,如主键约束、外键约束、唯一性约束、检查约束等,能够有效地保证数据的合法性和完整性,防止非法数据的插入和更新,维护数据的一致性。
- 结构化存储:MySQL 擅长处理结构化数据,将数据按照表格形式存储,每个表由行和列组成,数据之间的关系清晰明确。这种结构化的存储方式便于进行数据的管理、维护和查询,适合存储和处理具有固定格式和关系的数据,如用户信息、订单数据、商品目录等。
- 数据安全:提供了完善的数据备份和恢复机制,如使用 mysqldump 工具进行数据备份,以及基于二进制日志的恢复等功能。同时,还具备强大的用户权限管理系统,可以对不同用户授予不同的数据库操作权限,确保数据的安全性和保密性。
7.4.redis持久化,AOF和RDB对比
- AOF:由于是记录写命令,随着时间的推移和写操作的增加,AOF 文件会越来越大。虽然可以通过重写机制来压缩 AOF 文件,但总体上文件大小通常会比 RDB 文件大。
- RDB:RDB 文件是数据的二进制快照,只包含数据本身,不包含命令,因此文件大小相对较小,尤其是对于包含大量重复数据的情况,RDB 文件的压缩效果会更好。
- AOF:由于是记录写命令,只要 AOF 文件没有损坏,恢复出来的数据与原数据在一致性上有较好的保证。
- RDB:因为是快照形式,如果在快照过程中数据发生了变化,可能会导致恢复出来的数据与原数据存在一定的差异,在数据一致性方面相对较弱。
- AOF:恢复数据时,需要重新执行 AOF 文件中的所有写命令来重建数据。如果 AOF 文件非常大,恢复时间可能会很长。
- RDB:恢复时只需将 RDB 文件读入内存即可,速度相对较快,尤其对于大规模数据的恢复,RDB 的优势更为明显。
未完待续~~~~~~