# Redis 面试篇
# 1.Redis 主从
单节点 Redis 的并发能力是有上限的,要进一步提高 Redis 的并发能力,就需要搭建主从集群,实现读写分离。
# 1.1. 主从集群结构
下图就是一个简单的 Redis 主从集群结构:
如图所示,集群中有一个 master 节点、两个 slave 节点(现在叫 replica)。当我们通过 Redis 的 Java 客户端访问主从集群时,应该做好路由:
- 如果是写操作,应该访问 master 节点,master 会自动将数据同步给两个 slave 节点
- 如果是读操作,建议访问各个 slave 节点,从而分担并发压力
# 1.2. 主从同步原理
在主从测试中,我们发现 master
上写入 Redis 的数据,在 slave1
和 slave2
上也能看到,这说明主从之间确实完成了数据同步。
那么这个同步是如何完成的呢?
# 1.2.1. 全量同步
主从第一次建立连接时,会执行全量同步,将 master 节点的所有数据都拷贝给 slave 节点,流程:
这里有一个问题, master
如何得知 salve
是否是第一次来同步呢??
有几个概念,可以作为判断依据:
Replication Id
:简称replid
,是数据集的标记,replid 一致则是同一数据集。每个master
都有唯一的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
节点请求增量同步master
节点判断replid
,发现不一致,拒绝增量同步master
将完整内存数据生成RDB
,发送RDB
到slave
slave
清空本地数据,加载master
的RDB
master
将RDB
期间的命令记录在repl_baklog
,并持续将 log 中的命令发送给slave
slave
执行接收到的命令,保持与master
之间的同步
# 1.2.2. 增量同步
全量同步需要先做 RDB,然后将 RDB 文件通过网络传输个 slave,成本太高了。因此除了第一次做全量同步,其它大多数时候 slave 与 master 都是做增量同步。
什么是增量同步?就是只更新 slave 与 master 存在差异的部分数据。如图:
那么 master 怎么知道 slave 与自己的数据差异在哪里呢?
# 1.2.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.3. 主从同步优化
主从同步可以保证主从数据的一致性,非常重要。
可以从以下几个方面来优化 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 时
# 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
)。
对应的官方文档如下:
https://redis.io/docs/management/sentinel/#replica-selection-and-priority
问题来了,当选出一个新的 master 后,该如何实现身份切换呢?
大概分为两步:
- 在多个
sentinel
中选举一个leader
- 由
leader
执行failover
# 2.1.3. 选举 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
呢?
# 2.1.4.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
:
# 2.2. 总结
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
)。
# 2.3.RedisTemplate 连接哨兵集群
分为三步:
- 1)引入依赖
- 2)配置哨兵地址
- 3)配置读写分离
# 2.3.1. 引入依赖
就是 SpringDataRedis 的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
# 2.3.2. 配置哨兵地址
连接哨兵集群与传统单点模式不同,不再需要设置每一个 redis 的地址,而是直接指定哨兵地址:
spring:
redis:
sentinel:
master: hmaster # 集群名
nodes: # 哨兵地址列表
- 192.168.150.101:27001
- 192.168.150.101:27002
- 192.168.150.101:27003
# 2.3.3. 配置读写分离
最后,还要配置读写分离,让 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
# 3.Redis 分片集群
主从模式可以解决高可用、高并发读的问题。但依然有两个问题没有解决:
- 海量数据存储
- 高并发写
要解决这两个问题就需要用到分片集群了。分片的意思,就是把数据拆分存储到不同节点,这样整个集群的存储数据量就更大了。
Redis 分片集群的结构如图:
分片集群特征:
- 集群中有多个 master,每个 master 保存不同分片数据 ,解决海量数据存储问题
- 每个 master 都可以有多个 slave 节点 ,确保高可用
- master 之间通过 ping 监测彼此健康状态 ,类似哨兵作用
- 客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点
# 3.2. 散列插槽
数据要分片存储到不同的 Redis 节点,肯定需要有分片的依据,这样下次查询的时候才能知道去哪个节点查询。很多数据分片都会采用一致性 hash 算法。而 Redis 则是利用散列插槽( hash slot
)的方式实现数据分片。
详见官方文档:
https://redis.io/docs/management/scaling/#redis-cluster-101
在 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}
为前缀,这样同类型数据计算的插槽一定相同
# 3.5.Java 客户端连接分片集群
RedisTemplate 底层同样基于 lettuce 实现了分片集群的支持,而使用的步骤与哨兵模式基本一致,参考 2.5节
:
1)引入 redis 的 starter 依赖
2)配置分片集群地址
3)配置读写分离
与哨兵模式相比,其中只有分片集群的配置方式略有差异,如下:
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
# 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 | int 、 embstr 、 raw |
LIST | LinkedList和ZipList (3.2 以前)、 QuickList (3.2 以后) |
SET | intset 、 HT |
ZSET | ZipList (7.0 以前)、 Listpack (7.0 以后)、 HT 、 SkipList |
HASH | ZipList (7.0 以前)、 Listpack (7.0 以后)、 HT |
这些数据类型比较复杂,我们重点讲解几个面试会问的,其它的大家可以查看黑马程序员发布的 Redis 专业课程:
https://b11et3un53m.feishu.cn/wiki/Jck7w4GBSia4sukQn1vc9s3anMf#U9EadvPvAoXS2Fx8ziDcjT57npf
# 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 的 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;
其内存结构如图:
# 5.Redis 内存回收
Redis 之所以性能强,最主要的原因就是基于内存存储。然而单节点的 Redis 其内存大小不宜过大,会影响持久化或主从同步性能。
我们可以通过修改 redis.conf 文件,添加下面的配置来配置 Redis 的最大内存:
maxmemory 1gb
当内存达到上限,就无法存储更多数据了。因此,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.
来看下 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(
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 的结果对比:
你可以在图表中看到三种颜色的点形成三个不同的带,每个点就是一个加入的 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
的访问热度了。
# 6. 缓存问题
Redis 经常被用作缓存,而缓存在使用的过程中存在很多问题需要解决。例如:
- 缓存的数据一致性问题
- 缓存击穿
- 缓存穿透
- 缓存雪崩
# 6.1. 缓存一致性
我们先看下目前企业用的最多的缓存模型。缓存的通用模型有三种:
Cache Aside
:有缓存调用者自己维护数据库与缓存的一致性。即:- 查询时:命中则直接返回,未命中则查询数据库并写入缓存
- 更新时:更新数据库并删除缓存,查询时自然会更新缓存
Read/Write Through
:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:- 查询时:命中则直接返回,未命中则查询数据库并写入缓存
- 更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据库
Write Behind Cahing
:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库
目前企业中使用最多的就是 Cache Aside
模式,因为实现起来非常简单。但缺点也很明显,就是无法保证数据库与缓存的强一致性。为什么呢?我们一起来分析一下。
Cache Aside
的写操作是要在更新数据库的同时删除缓存,那为什么不选择更新数据库的同时更新缓存,而是删除呢?
原因很简单,假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。
那到底是先更新数据库再删除缓存,还是先删除缓存再更新数据库呢?
现在假设有两个线程,一个来更新数据,一个来查询数据。我们分别分析两种策略的表现。
我们先分析策略 1,先更新数据库再删除缓存:
正常情况
异常情况
异常情况说明:
- 线程 1 删除缓存后,还没来得及更新数据库,
- 此时线程 2 来查询,发现缓存未命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
- 然后线程 1 更新数据库,此时数据库是新数据,缓存是旧数据
由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高。因此不推荐这种方案。
再来看策略 2,先更新数据库再删除缓存:
正常情况
异常情况
异常情况说明:
- 线程 1 查询缓存未命中,于是去查询数据库,查询到旧数据
- 线程 1 将数据写入缓存之前,线程 2 来了,更新数据库,删除缓存
- 线程 1 执行写入缓存的操作,写入旧数据
可以发现,异常状态发生的概率极为苛刻,线程 1 必须是查询数据库已经完成,但是缓存尚未写入之前。线程 2 要完成更新数据库同时删除缓存的两个操作。要知道线程 1 执行写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低。
综上,添加缓存的目的是为了提高系统性能,而你要付出的代价就是缓存与数据库的强一致性。如果你要求数据库与缓存的强一致,那就需要加锁避免并行读写。但这就降低了性能,与缓存的目标背道而驰。
因此不管任何缓存同步方案最终的目的都是尽可能保证最终一致性,降低发生不一致的概率。我们采用先更新数据库再删除缓存的方案,已经将这种概率降到足够低,目的已经达到了。
同时我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。
# 6.2. 缓存穿透
什么是缓存穿透呢?
我们知道,当请求查询缓存未命中时,需要查询数据库以加载缓存。但是大家思考一下这样的场景:
如果我访问一个数据库中也不存在的数据。会出现什么现象?
由于数据库中不存在该数据,那么缓存中肯定也不存在。因此不管请求该数据多少次,缓存永远不可能建立,请求永远会直达数据库。
假如有不怀好意的人,开启很多线程频繁的访问一个数据库中也不存在的数据。由于缓存不可能生效,那么所有的请求都访问数据库,可能就会导致数据库因过高的压力而宕机。
解决这个问题有两种思路:
- 缓存空值
- 布隆过滤器
# 6.2.1. 缓存空值
简单来说,就是当我们发现请求的数据即不存在与缓存,也不存在与数据库时,将空值缓存到 Redis,避免频繁查询数据库。实现思路如下:
优点:
- 实现简单,维护方便
缺点:
- 额外的内存消耗
# 6.2.2. 布隆过滤器
布隆过滤是一种数据统计的算法,用于检索一个元素是否存在一个集合中。
一般我们判断集合中是否存在元素,都会先把元素保存到类似于树、哈希表等数据结构中,然后利用这些结构查询效率高的特点来快速匹配判断。但是随着元素数量越来越多,这种模式对内存的占用也越来越大,检索的速度也会越来越慢。而布隆过滤的内存占用小,查询效率却很高。
布隆过滤首先需要一个很长的 bit 数组,默认数组中每一位都是 0.
然后还需要 K
个 hash
函数,将元素基于这些 hash 函数做运算的结果映射到 bit 数组的不同位置,并将这些位置置为 1,例如现在 k=3:
hello
经过运算得到 3 个角标:1、5、12world
经过运算得到 3 个角标:8、17、21java
经过运算得到 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. 面试总结
面试题:如何保证缓存的双写一致性?
答:缓存的双写一致性很难保证强一致,只能尽可能降低不一致的概率,确保最终一致。我们项目中采用的是 Cache Aside
模式。简单来说,就是在更新数据库之后删除缓存;在查询时先查询缓存,如果未命中则查询数据库并写入缓存。同时我们会给缓存设置过期时间作为兜底方案,如果真的出现了不一致的情况,也可以通过缓存过期来保证最终一致。
追问:为什么不采用延迟双删机制?
答:延迟双删的第一次删除并没有实际意义,第二次采用延迟删除主要是解决数据库主从同步的延迟问题,我认为这是数据库主从的一致性问题,与缓存同步无关。既然主节点数据已经更新,Redis 的缓存理应更新。而且延迟双删会增加缓存业务复杂度,也没能完全避免缓存一致性问题,投入回报比太低。
面试题:如何解决缓存穿透问题?
答:缓存穿透也可以说是穿透攻击,具体来说是因为请求访问到了数据库不存在的值,这样缓存无法命中,必然访问数据库。如果高并发的访问这样的接口,会给数据库带来巨大压力。
我们项目中都是基于布隆过滤器来解决缓存穿透问题的,当缓存未命中时基于布隆过滤器判断数据是否存在。如果不存在则不去访问数据库。
当然,也可以使用缓存空值的方式解决,不过这种方案比较浪费内存。
面试题:如何解决缓存雪崩问题?
答:缓存雪崩的常见原因有两个,第一是因为大量 key 同时过期。针对问这个题我们可以可以给缓存 key 设置不同的 TTL 值,避免 key 同时过期。
第二个原因是 Redis 宕机导致缓存不可用。针对这个问题我们可以利用集群提高 Redis 的可用性。也可以添加多级缓存,当 Redis 宕机时还有本地缓存可用。
面试题:如何解决缓存击穿问题?
答:缓存击穿往往是由热点 Key 引起的,当热点 Key 过期时,大量请求涌入同时查询,发现缓存未命中都会去访问数据库,导致数据库压力激增。解决这个问题的主要思路就是避免多线程并发去重建缓存,因此方案有两种。
第一种是基于互斥锁,当发现缓存未命中时需要先获取互斥锁,再重建缓存,缓存重建完成释放锁。这样就可以保证缓存重建同一时刻只会有一个线程执行。不过这种做法会导致缓存重建时性能下降严重。
第二种是基于逻辑过期,也就是不给热点 Key 设置过期时间,而是给数据添加一个过期时间的字段。这样热点 Key 就不会过期,缓存中永远有数据。
查询到数据时基于其中的过期时间判断 key 是否过期,如果过期开启独立新线程异步的重建缓存,而查询请求先返回旧数据即可。当然,这个过程也要加互斥锁,但由于重建缓存是异步的,而且获取锁失败也无需等待,而是返回旧数据,这样性能几乎不受影响。
需要注意的是,无论是采用哪种方式,在获取互斥锁后一定要再次判断缓存是否命中,做 dubbo check. 因为当你获取锁成功时,可能是在你之前有其它线程已经重建缓存了。