目录
接下来的内容主要包括以下几方面:
Redis高级:
- Redis主从
- Redis哨兵
- Redis分片集群
- Redis数据结构
- Redis内存回收
- Redis缓存一致性
Redis主从
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
主从集群结构
下图就是一个简单的Redis主从集群结构:
编辑
如图所示,集群中有一个master节点、两个slave节点(现在叫replica)。当我们通过Redis的Java客户端访问主从集群时,应该做好路由:
- 如果是写操作,应该访问master节点,master会自动将数据同步给两个slave节点
- 如果是读操作,建议访问各个slave节点,从而分担并发压力
搭建主从集群
我们会在同一个虚拟机中利用3个Docker容器来搭建主从集群,容器信息如下:
容器名 |
角色 |
IP |
映射端口 |
r1 |
master |
192.168.150.101 |
7001 |
r2 |
slave |
192.168.150.101 |
7002 |
r3 |
slave |
192.168.150.101 |
7003 |
启动多个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进程:
编辑
建立集群
虽然我们启动了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
结果如下:
127.0.0.1:7001> info replication
# Replication
role:master
connected_slaves:2
slave0:ip=192.168.150.101,port=7002,state=online,offset=140,lag=1
slave1:ip=192.168.150.101,port=7003,state=online,offset=140,lag=1
master_failover_state:no-failover
master_replid:16d90568498908b322178ca12078114e6c518b86
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:140
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:140
可以看到,当前节点r1:7001的角色是master,有两个slave与其连接:
slave0:port是7002,也就是r2节点slave1:port是7003,也就是r3节点
测试
依次在r1、r2、r3节点上执行下面命令:
set num 123
get num
你会发现,只有在r1这个节点上可以执行set命令(写操作),其它两个节点只能执行get命令(读操作)。也就是说读写操作已经分离了。
主从同步原理
在刚才的主从测试中,我们发现r1上写入Redis的数据,在r2和r3上也能看到,这说明主从之间确实完成了数据同步。
那么这个同步是如何完成的呢?
全量同步
主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点,流程:
编辑
这里有一个问题,master如何得知salve是否是第一次来同步呢??
有几个概念,可以作为判断依据:
Replication Id:简称replid,是数据集的标记,replid一致则是同一数据集。每个master都有唯一的replid,slave则会继承master节点的replidoffset:偏移量,随着记录在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节点请求增量同步master节点判断replid,发现不一致,拒绝增量同步master将完整内存数据生成RDB,发送RDB到slaveslave清空本地数据,加载master的RDBmaster将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slaveslave执行接收到的命令,保持与master之间的同步
来看下r1节点的运行日志:
编辑
再看下r2节点执行replicaof命令时的日志:
编辑
与我们描述的完全一致。
增量同步
全量同步需要先做RDB,然后将RDB文件通过网络传输个slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步。
什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:
编辑
那么master怎么知道slave与自己的数据差异在哪里呢?
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做增量同步,只能再次全量同步。
主从同步优化
主从同步可以保证主从数据的一致性,非常重要。
可以从以下几个方面来优化Redis主从就集群:
- 在master中配置
repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘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时
Redis哨兵
主从结构中master节点的作用非常重要,一旦故障就会导致集群不可用。那么有什么办法能保证主从集群的高可用性呢?
哨兵工作原理
Redis提供了哨兵(Sentinel)机制来监控主从集群监控状态,确保集群的高可用性。
哨兵作用
哨兵集群作用原理图:
编辑
哨兵的作用如下:
- 状态监控:
Sentinel会不断检查您的master和slave是否按预期工作 - 故障恢复(failover):如果
master故障,Sentinel会将一个slave提升为master。当故障实例恢复后会成为slave - 状态通知:
Sentinel充当Redis客户端的服务发现来源,当集群发生failover时,会将最新集群信息推送给Redis的客户端
那么问题来了,Sentinel怎么知道一个Redis节点是否宕机呢?
状态监控
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)。
对应的官方文档如下:
问题来了,当选出一个新的master后,该如何实现身份切换呢?
大概分为两步:
- 在多个
sentinel中选举一个leader - 由
leader执行failover
选举leader
首先,Sentinel集群要选出一个执行failover的Sentinel节点,可以成为leader。要成为leader要满足两个条件:
- 最先获得超过半数的投票
- 获得的投票数不小于
quorum值
而sentinel投票的原则有两条:
- 优先投票给目前得票最多的
- 如果目前没有任何节点的票,就投给自己
比如有3个sentinel节点,s1、s2、s3,假如s2先投票:
- 此时发现没有任何人在投票,那就投给自己。
s2得1票 - 接着
s1和s3开始投票,发现目前s2票最多,于是也投给s2,s2得3票 s2称为leader,开始故障转移
不难看出,谁先投票,谁就会称为leader,那什么时候会触发投票呢?
答案是第一个确认master客观下线的人会立刻发起投票,一定会成为leader。
OK,sentinel找到leader以后,该如何完成failover呢?
failover
我们举个例子,有一个集群,初始状态下7001为master,7002和7003为slave:
编辑
假如master发生故障,slave1当选。则故障转移的流程如下:
1)sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master
编辑
2)sentinel给所有其它slave发送slaveof 192.168.150.101 7002 命令,让这些节点成为新master,也就是7002的slave节点,开始从新的master上同步数据。
编辑
3)最后,当故障节点恢复后会接收到哨兵信号,执行slaveof 192.168.150.101 7002命令,成为slave:
编辑
搭建哨兵集群
首先,我们停掉之前的redis集群:
# 老版本DockerCompose
docker-compose down
# 新版本Docker
docker compose down
然后,我们找到sentinel.conf文件:
其内容如下:
sentinel announce-ip "192.168.150.101"
sentinel monitor hmaster 192.168.150.101 7001 2
sentinel down-after-milliseconds hmaster 5000
sentinel failover-timeout hmaster 60000
说明:
sentinel announce-ip "192.168.150.101":声明当前sentinel的ipsentinel monitor hmaster 192.168.150.101 7001 2:指定集群的主节点信息
hmaster:主节点名称,自定义,任意写192.168.150.101 7001:主节点的ip和端口2:认定master下线时的quorum值
sentinel down-after-milliseconds hmaster 5000:声明master节点超时多久后被标记下线sentinel failover-timeout hmaster 60000:在第一次故障转移失败后多久再次重试
我们在虚拟机的/root/redis目录下新建3个文件夹:s1、s2、s3:
编辑
将课前资料提供的sentinel.conf文件分别拷贝一份到3个文件夹中。
接着修改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.150.101", "7001"] r3: image: redis container_name: r3 network_mode: "host" entrypoint: ["redis-server", "--port", "7003", "--slaveof", "192.168.150.101", "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
运行结果:
编辑
我们以s1节点为例,查看其运行日志:
# Sentinel ID is 8e91bd24ea8e5eb2aee38f1cf796dcb26bb88acf
# +monitor master hmaster 192.168.150.101 7001 quorum 2
* +slave slave 192.168.150.101:7003 192.168.150.101 7003 @ hmaster 192.168.150.101 7001
* +sentinel sentinel 5bafeb97fc16a82b431c339f67b015a51dad5e4f 192.168.150.101 27002 @ hmaster 192.168.150.101 7001
* +sentinel sentinel 56546568a2f7977da36abd3d2d7324c6c3f06b8d 192.168.150.101 27003 @ hmaster 192.168.150.101 7001
* +slave slave 192.168.150.101:7002 192.168.150.101 7002 @ hmaster 192.168.150.101 7001
可以看到sentinel已经联系到了7001这个节点,并且与其它几个哨兵也建立了链接。哨兵信息如下:
27001:Sentinel ID是8e91bd24ea8e5eb2aee38f1cf796dcb26bb88acf27002:Sentinel ID是5bafeb97fc16a82b431c339f67b015a51dad5e4f27003:Sentinel ID是56546568a2f7977da36abd3d2d7324c6c3f06b8d
演示failover
接下来,我们演示一下当主节点故障时,哨兵是如何完成集群故障恢复(failover)的。
我们连接7001这个master节点,然后通过命令让其休眠60秒,模拟宕机:
# 连接7001这个master节点,通过sleep模拟服务宕机,60秒后自动恢复
docker exec -it r1 redis-cli -p 7001 DEBUG sleep 60
稍微等待一段时间后,会发现sentinel节点触发了failover:
总结
Sentinel的三个作用是什么?
- 集群监控
- 故障恢复
- 状态通知
Sentinel如何判断一个redis实例是否健康?
- 每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线(
sdown) - 如果大多数sentinel都认为实例主观下线,则判定服务客观下线(
odown)
故障转移步骤有哪些?
- 首先要在
sentinel中选出一个leader,由leader执行failover - 选定一个
slave作为新的master,执行slaveof noone,切换到master模式 - 然后让所有节点都执行
slaveof新master - 修改故障节点配置,添加
slaveof新master
sentinel选举leader的依据是什么?
- 票数超过sentinel节点数量1半
- 票数超过quorum数量
- 一般情况下最先发起failover的节点会当选
sentinel从slave中选取master的依据是什么?
- 首先会判断slave节点与master节点断开时间长短,如果超过
down-after-milliseconds* 10则会排除该slave节点 - 然后判断slave节点的
slave-priority值,越小优先级越高,如果是0则永不参与选举(默认都是1)。 - 如果
slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高 - 最后是判断slave节点的
run_id大小,越小优先级越高(通过info server可以查看run_id)。
Redis分片集群
主从模式可以解决高可用、高并发读的问题。但依然有两个问题没有解决:
- 海量数据存储
- 高并发写
要解决这两个问题就需要用到分片集群了。分片的意思,就是把数据拆分存储到不同节点,这样整个集群的存储数据量就更大了。
Redis分片集群的结构如图:
编辑
分片集群特征:
- 集群中有多个master,每个master保存不同分片数据 ,解决海量数据存储问题
- 每个master都可以有多个slave节点 ,确保高可用
- master之间通过ping监测彼此健康状态 ,类似哨兵作用
- 客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点
搭建分片集群
Redis分片集群最少也需要3个master节点,由于我们的机器性能有限,我们只给每个master配置1个slave,形成最小的分片集群:
编辑
计划部署的节点信息如下:
编辑
集群配置
分片集群中的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
启动集群
进入/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
输入命令后控制台会弹出下面的信息:
编辑
这里展示了集群中master与slave节点分配情况,并询问你是否同意。节点信息如下:
7001是master,节点id后6位是da134f7002是master,节点id后6位是862fa07003是master,节点id后6位是ad50837004是slave,节点id后6位是391f8b,认ad5083(7003)为master7005是slave,节点id后6位是e152cd,认da134f(7001)为master7006是slave,节点id后6位是4a018a,认862fa0(7002)为master
输入yes然后回车。会发现集群开始创建,并输出下列信息:
编辑
接着,我们可以通过命令查看集群状态:
redis-cli -p 7001 cluster nodes
结果:
编辑
散列插槽
数据要分片存储到不同的Redis节点,肯定需要有分片的依据,这样下次查询的时候才能知道去哪个节点查询。很多数据分片都会采用一致性hash算法。而Redis则是利用散列插槽(hash slot)的方式实现数据分片。
详见官方文档:
在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。
故障转移
分片集群的节点之间会互相通过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,主从地位互换。
总结
Redis分片集群如何判断某个key应该在哪个实例?
- 将16384个插槽分配到不同的实例
- 根据key计算哈希值,对16384取余
- 余数作为插槽,寻找插槽所在实例即可
如何将同一类数据固定的保存在同一个Redis实例?
- Redis计算key的插槽值时会判断key中是否包含
{},如果有则基于{}内的字符计算插槽 - 数据的key中可以加入
{类型},例如key都以{typeId}为前缀,这样同类型数据计算的插槽一定相同
Redis数据结构
我们常用的Redis数据类型有5种,分别是:
- String
- List
- Set
- SortedSet
- Hash
还有一些高级数据类型,比如Bitmap、HyperLogLog、GEO等,其底层都是基于上述5种基本数据类型。因此在Redis的源码中,其实只有5种数据类型。
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 |
|
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则是节点存储的字符串数据指针。
其内存结构如下:
编辑
SortedSet
面试题: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存在连锁更新问题,因此而在Redis7.0版本以后,ZipList又被替换为Listpack(紧凑列表)。
Redis源码中zset,也就是SortedSet的结构体如下:
typedef struct zset {
dict *dict; // dict,底层就是HashTable
zskiplist *zsl; // 跳表
} zset;
其内存结构如图:
编辑
Redis内存回收
Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。
我们可以通过修改redis.conf文件,添加下面的配置来配置Redis的最大内存:
maxmemory 1gb
当内存达到上限,就无法存储更多数据了。因此,Redis内部会有两套内存回收的策略:
- 内存过期策略
- 内存淘汰策略
内存过期处理
存入Redis中的数据可以配置过期时间,到期后再次访问会发现这些数据都不存在了,也就是被过期清理了。
过期命令
Redis中通过expire命令可以给KEY设置TTL(过期时间),例如:
# 写入一条数据 set num 123 # 设置20秒过期时间 expire num 20
不过set命令本身也可以支持过期时间的设置:
# 写入一条数据并设置20s过期时间 set num EX 20
当过期时间到了以后,再去查询数据,会发现数据已经不存在。
过期策略
那么问题来了:
- Redis如何判断一个KEY是否过期呢?
- Redis又是何时删除过期KEY的呢?
Redis不管有多少种数据类型,本质是一个KEY-VALUE的键值型数据库,而这种键值映射底层正式基于HashTable来实现的,在Redis中叫做Dict.
来看下RedisDB的底层源码:
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中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。
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(
LeastRecentlyUsed),最近最久未使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。 - LFU(
LeastFrequentlyUsed),最少频率使用。会统计每个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的结果对比:
编辑
你可以在图表中看到三种颜色的点形成三个不同的带,每个点就是一个加入的KEY。
- 浅灰色带是被驱逐的对象
- 灰色带是没有被驱逐的对象
- 绿色带是被添加的对象
5.3.总结
面试题:Redis如何判断KEY是否过期呢?
答:在Redis中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。
面试题:Redis何时删除过期KEY?如何删除?
答:Redis的过期KEY处理有两种策略,分别是惰性删除和周期删除。
惰性删除是指在每次用户访问某个KEY时,判断KEY的过期时间:如果过期则删除;如果未过期则忽略。
周期删除有两种模式:
- SLOW模式:通过一个定时任务,定期的抽样部分带有TTL的KEY,判断其是否过期。默认情况下定时任务的执行频率是每秒10次,但每次执行不能超过25毫秒。如果执行抽样后发现时间还有剩余,并且过期KEY的比例较高,则会多次抽样。
- FAST模式:在Redis每次处理NIO事件之前,都会抽样部分带有TTL的KEY,判断是否过期,因此执行频率较高。但是每次执行时长不能超过1ms,如果时间充足并且过期KEY比例过高,也会多次抽样
面试题:当Redis内存不足时会怎么做?
答:这取决于配置的内存淘汰策略,Redis支持很多种内存淘汰策略,例如LRU、LFU、Random. 但默认的策略是直接拒绝新的写入请求。而如果设置了其它策略,则会在每次执行命令后判断占用内存是否达到阈值。如果达到阈值则会基于配置的淘汰策略尝试进行内存淘汰,直到占用内存小于阈值为止。
面试题:那你能聊聊LRU和LFU吗?
答:LRU是最近最久未使用。Redis的Key都是RedisObject,当启用LRU算法后,Redis会在Key的头信息中使用24个bit记录每个key的最近一次使用的时间lru。每次需要内存淘汰时,就会抽样一部分KEY,找出其中空闲时间最长的,也就是now - lru结果最大的,然后将其删除。如果内存依然不足,就重复这个过程。
由于采用了抽样来计算,这种算法只能说是一种近似LRU算法。因此在Redis4.0以后又引入了LFU算法,这种算法是统计最近最少使用,也就是按key的访问频率来统计。当启用LFU算法后,Redis会在key的头信息中使用24bit记录最近一次使用时间和逻辑访问频率。其中高16位是以分钟为单位的最近访问时间,后8位是逻辑访问次数。与LFU类似,每次需要内存淘汰时,就会抽样一部分KEY,找出其中逻辑访问次数最小的,将其淘汰。
面试题:逻辑访问次数是如何计算的?
答:由于记录访问次数的只有8bit,即便是无符号数,最大值只有255,不可能记录真实的访问次数。因此Redis统计的其实是逻辑访问次数。这其中有一个计算公式,会根据当前的访问次数做计算,结果要么是次数+1,要么是次数不变。但随着当前访问次数越大,+1的概率也会越低,并且最大值不超过255.
除此以外,逻辑访问次数还有一个衰减周期,默认为1分钟,即每隔1分钟逻辑访问次数会-1。这样逻辑访问次数就能基本反映出一个key的访问热度了。