Redis高级:

  • Redis主从
  • Redis哨兵
  • Redis分片集群
  • Redis数据结构
  • Redis内存回收
  • Redis缓存一致性

img

img

1.Redis主从

img

1.1.主从集群结构

下图就是一个简单的Redis主从集群结构:

img

  • 一主两从(r1 是 master,r2 和 r3 是 slave)
  • 写数据发给 master,master 自动同步给 slave
  • 读请求优先发往 slave,减轻 master 压力,实现 读写分离

1.2.搭建主从集群

img

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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目录下

加载 redis 镜像

1
docker load -i redis.tar

执行命令,后台启动集群:

1
docker compose up -d

img

建立集群

我们启动了3个Redis实例,但是它们并没有形成主从关系。我们需要通过命令来配置主从关系:

1
2
3
4
# Redis5.0以前
slaveof <masterip> <masterport>
# Redis5.0以后
replicaof <masterip> <masterport>

有临时和永久两种模式:

  • 永久生效:在redis.conf文件中利用slaveof命令指定master节点
  • 临时生效:直接利用redis-cli控制台输入slaveof命令,指定master节点

我们测试临时模式,首先连接r2,让其以r1为master

1
2
3
4
# 连接r2
docker exec -it r2 redis-cli -p 7002
# 认r1主,也就是7001
slaveof 192.168.150.101 7001

然后连接r3,让其以r1为master

1
2
3
4
# 连接r3
docker exec -it r3 redis-cli -p 7003
# 认r1主,也就是7001
slaveof 192.168.150.101 7001

然后连接r1,查看集群状态:

1
2
3
4
# 连接r1
docker exec -it r1 redis-cli -p 7001
# 查看集群状态
info replication

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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与其连接:

  • slave0port7002,也就是r2节点
  • slave1port7003,也就是r3节点

测试

依次在r1r2r3节点上执行下面命令:

1
2
3
set num 123

get num

结论:

  • r1 能写(set)
  • r2、r3 只能读(get)

表明主从同步成功,并实现了读写分离

分类 延迟 读写分离 高可用 易于搭建
单节点
主从架构 ✅(写 master、读 slave) ❌(主挂了无法自动切换)
哨兵模式 ✅(自动切换主节点) ⚠️
集群模式 ✅(数据分片+冗余) ❌(复杂)

1.3.主从同步原理

在刚才的主从测试中,我们发现r1上写入Redis的数据,在r2r3上也能看到,这说明主从之间确实完成了数据同步。

那么这个同步是如何完成的呢?

img

1.3.1.全量同步

全量同步是Redis 主从同步中最关键的基础流程,尤其是在 第一次建立主从关系时。

img

  • bgsave是后台执行的命令,执行后会将redis在内存中的所有数据持久化到硬盘中,保存到RDB文件
  • repl_baklog中存放的是所有master执行的命令,用于给salve节点发送未收到的命令

这里有一个问题,master如何得知salve是否是第一次来同步呢?

有几个概念,可以作为判断依据:

  • Replication Id:简称replid,是数据集的标记,replid一致则是同一数据集。每个master都有唯一的replidslave则会继承master节点的replid(根据replid是否一致来判断是不是第一次同步:如果是第一次同步,主从节点的replid不同,如果是断开重连,主从节点的replid相同)
  • offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slaveoffset小于masteroffset,说明slave数据落后于master,需要更新。
全量同步的触发时机

主从建立连接后,从节点会向主节点发送同步请求,其中包括:

  • 自己当前的 replication id(replid)
  • 当前的 offset

主节点据此判断是否可以进行增量同步。如果以下任一情况满足,就会触发全量同步:

  1. 主从 replid 不一致 → 意味着这是一个新的 slave,必须进行全量同步。
  2. 从节点 offset 太旧,主节点 backlog 已无法满足增量需求 → 只能全量同步。

因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致。流程如图:

img

img

完整流程描述:

  • slave节点请求增量同步
  • master节点判断replid,发现不一致,拒绝增量同步
  • master将完整内存数据生成RDB(Redis Database Backup file是Redis数据持久化的一种方式,也被称为Redis数据快照),发送RDBslave
  • slave清空本地数据,加载masterRDB
  • masterRDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
  • slave执行接收到的命令,保持与master之间的同步

来看下r1节点的运行日志:

img

再看下r2节点执行replicaof命令时的日志:

img

从日志也可以看出:

  • Master 日志会显示启动了 bgsave
  • Slave 日志会显示加载了 RDB 文件。
  • 同步完成后两者 replid 一致,可以进行增量同步。

与我们描述的完全一致。

核心数据结构说明
概念 含义
replid Master 的唯一标识(数据集唯一性)
offset 同步偏移量,记录同步到哪了
repl_backlog 主节点的命令缓存日志,保存最近写命令,用于增量同步
RDB Redis 快照持久化文件,包含全量数据
类型 特点
全量同步 第一次连接,或丢失太多数据;清空数据 → 加载 RDB → 执行 backlog
增量同步 replid 一致 + offset 可续接,直接发送 backlog 中缺失命令即可

1.3.2.增量同步

img

全量同步需要先做RDB,然后将RDB文件通过网络传输给slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步

什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:

img

那么master怎么知道slave与自己的数据差异在哪里呢?

img

1.3.3.repl_baklog原理

master怎么知道slave与自己的数据差异在哪里呢?

这就要说到全量同步时的repl_baklog文件了。这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。

repl_baklog中会记录Redis处理过的命令及offset,包括master当前的offset,和slave已经拷贝到的offset(将slave的offset存入其中,标记起来,再次增量同步查找slave,如果找不到就进行全量同步):

img

slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。

随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset:

img

直到数组被填满:

img

此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分:

img

但是,如果slave出现网络阻塞,导致master的offset远远超过了slave的offset

img

如果master继续写入新数据,master的offset就会覆盖repl_baklog中旧的数据,直到将slave现在的offset也覆盖:

img

棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset都没有了,无法完成增量同步了。只能做全量同步

repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于repl_baklog做增量同步,只能再次全量同步。

img

模拟增量同步的几个场景

场景1:正常增量同步

  1. slave 掉线一小会儿,master 继续接收写入,repl_backlog 记录新命令。
  2. slave 恢复连接,发来自己的 offset。
  3. master 在 repl_backlog 中找到这部分数据,推送给 slave。

增量同步成功完成。

场景2:repl_backlog 写满了

  • 随着主节点不停接收新写入,repl_backlog 也在持续写入。
  • 因为是固定大小的环形数组,旧数据会被新数据覆盖
  • 如果覆盖的数据包含 slave 尚未同步的 offset,那么 slave 想要增量同步时找不到数据。

❌ 增量同步失败,只能重新做 全量同步

关于slave 恢复连接时发送自己的 offset 是怎么回事

img

img

img

img

项目 内容
增量同步原理 master 根据 repl_backlog 中记录的 offset 和命令做数据补发
数据结构 repl_backlog 是环形缓冲区,保存主节点处理过的命令及 offset
增量条件 slave 的 offset 对应数据必须还在缓冲区中
失败场景 slave offset 对应的数据被覆盖了,只能回退到全量同步
优势 相比全量同步更高效,节省资源

1.4.主从同步优化

img

可以从以下几个方面来优化Redis主从就集群:

1️⃣ 启用无磁盘复制:

repl-diskless-sync yes在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。

默认行为(未优化)

  • 当 Redis 主节点要进行

    全量复制

    时,它会:

    1. 生成 RDB 文件(快照)
    2. 将 RDB 写入磁盘
    3. 再从磁盘读取 RDB
    4. 通过网络发送给从节点

问题

  • 整个过程涉及到两次磁盘 IO(写磁盘、读磁盘);
  • 如果主节点内存很大(如 10G 以上),磁盘 IO 非常吃力;
  • 还会与 AOF 写入发生冲突,导致 主节点响应变慢或卡顿

优化做法

  • 加入配置:repl-diskless-sync yes
  • 改为:主节点将 RDB 直接写入 socket 发送给从节点不落盘

📌 结果:减少磁盘 IO,提高同步效率,减轻主节点压力。

2️⃣ 减少单节点内存大小(建议 ≤ 8GB)

  • Redis 使用 RDB 或 AOF 持久化时需要将整个数据集复制或遍历;
  • 如果 Redis 单节点内存太大,全量复制、GC 等会导致长时间卡顿;
  • 降低每个 Redis 实例的数据量,有利于复制性能和故障恢复速度。

📌 建议:将数据合理拆分在多个 Redis 实例中,分担压力。

3️⃣ 增大 repl_backlog 大小(默认仅 1MB)

repl_backlog 是增量同步的缓冲区,当从节点短暂掉线时,重新连接会发送自己的 offset 请求主节点补发。

问题

  • 如果 backlog 太小,从节点断线期间写操作太多,backlog 数据被覆盖;
  • 导致无法进行增量同步,被迫进行全量同步。

优化做法

  • 增加 repl_backlog_size,如设置为 64MB 或更高;
  • 提高断线后可容忍的 offset 回滚时间,尽可能避免全量复制

📌 效果:提升从节点的故障恢复能力,减少全量复制发生概率。

4️⃣ 控制从节点数量,采用“主-从-从”链式结构

问题

  • 一个主节点带太多从节点,会导致:
    • 写操作传播慢;
    • 全量同步同时进行,拉爆主节点资源。

解决方案

  • 使用 主-从-从架构 架构,如:

img

img

  • 让部分 slave 挂载到其他 slave 上,减少 master 压力。

📌 结果:降低主节点连接压力,提高系统稳定性和扩展能力。

总结

优化点 作用 效果
repl-diskless-sync 跳过 RDB 落盘 降低 IO,提升性能
减少实例内存 控制快照大小 提高稳定性
增大 repl_backlog_size 保留更多写操作记录 优先增量同步,避免全量
链式主从架构 控制主节点压力 提升集群扩展能力

几道常见的面试题?

1.简述全量同步和增量同步区别?

  • 全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
  • 增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave

img

2.什么时候执行全量同步?

  • slave节点第一次连接master节点时
  • slave节点断开时间太久,repl_baklog中的offset已经被覆盖时

img

3.什么时候执行增量同步?

  • slave节点断开又恢复,并且在repl_baklog中能找到offset时

img

2.Redis哨兵

img

主从结构中master节点的作用非常重要,一旦故障就会导致集群不可用。那么有什么办法能保证主从集群的高可用性呢?

2.1.哨兵工作原理

Redis提供了哨兵Sentinel)机制来监控主从集群监控状态,确保集群的高可用性。

2.1.1.哨兵作用

哨兵集群作用原理图:

img

哨兵的作用如下:

  • 状态监控Sentinel 会不断检查您的masterslave是否按预期工作
  • 故障恢复(failover):如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后会成为slave
  • 状态通知Sentinel充当Redis客户端的服务发现来源,当集群发生failover时,会将最新集群信息推送给Redis的客户端
功能 描述
状态监控 不断 ping master 和 slave,确认是否健康
故障转移 master 宕机后,从 slave 中选一个晋升为 master
状态通知 客户端连接哨兵,获取最新的主节点信息

那么问题来了,哨兵如何知道Redis节点是否宕机?哨兵如何选举新的master?哨兵如何实现故障转移?在下文我们一一讲解

2.1.2.状态监控-Sentinel 如何判断主节点故障?

Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个节点发送ping命令,并通过实例的响应结果来做出判断:

img

img

如何选出新的 master?

一旦发现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 断连时间是否过长(> down_after_milliseconds * 10)
slave-priority 值越小优先(默认都是1,0表示不参与选举)
offset 越大说明数据越新(越靠前)
run_id 字典序越小者优先

问题来了,当选出一个新的master后,该如何实现身份切换呢?

大概分为两步:

  • 在多个sentinel中选举一个leader
  • leader执行failover

2.1.3.选举leader

首先,Sentinel集群要选出一个执行failover的Sentinel节点,可以成为leader。要成为leader要满足两个条件:

  • 最先获得超过半数的投票
  • 获得的投票数不小于quorum

而sentinel投票的原则有两条:

  • 优先投票给目前得票最多的
  • 如果目前没有任何节点的票,就投给自己

img

比如有3个sentinel节点,s1s2s3,假如s2先投票:

  • 此时发现没有任何人在投票,那就投给自己。s2得1票
  • 接着s1s3开始投票,发现目前s2票最多,于是也投给s2s2得3票
  • s2称为leader,开始故障转移

不难看出,谁先投票,谁就会称为****leader,那什么时候会触发投票呢?

答案是第一个确认master客观下线的人会立刻发起投票,一定会成为****leader

sentinel找到leader以后,该如何完成failover呢?

2.1.4.failover 故障恢复

我们举个例子,有一个集群,初始状态下7001为master,7002和7003为slave

img

假如master发生故障,slave1当选。则故障转移的流程如下:

1)sentinel(leader)给备选的slave1节点发送slaveof no one命令,让该节点成为master

img

2)sentinel给所有其它slave发送slaveof 192.168.150.101 7002 命令,让这些节点成为新master的slave节点,也就是7002slave节点,开始从新的master上同步数据。

img

3)最后,当故障节点恢复后会接收到哨兵信号,执行slaveof 192.168.150.101 7002命令,成为slave

img

sentinel直接修改故障节点的配置文件,故障节点恢复后自动就会成为7002的slave

img

img

img

总结:Redis 哨兵机制就是一套 基于投票机制实现的自动故障恢复系统,它负责监控、选举和通知,确保 Redis 集群在 master 宕机时能自动恢复服务,保证高可用。

补充一个细节:

有个疑问,如果主节点7001挂了,会重新选择一个作为主节点(假如是7002),如果7002也挂掉还会在进行选择主节点吗(假如有很多redis从节点),因为前面哨兵配置文件里面,指定了集群的主节点信息 (7001),重新选择的主节点7002会被哨兵监控吗

img

img

img

img

2.2.搭建哨兵集群

首先,我们停掉之前的redis集群,避免和接下来搭建集群产生冲突:

1
2
3
4
5
# 老版本DockerCompose
docker-compose down

# 新版本Docker
docker compose down

我们找到课前资料提供的sentinel.conf文件

1
2
3
4
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的ip

  • sentinel monitor hmaster 192.168.150.101 7001 2
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13

    :指定集群的主节点信息

    - `hmaster`:主节点名称,自定义,任意写
    - `192.168.150.101 7001`:主节点的ip和端口
    - `2`:认定`master`下线时的`quorum`值(三个哨兵,2个认为主观下线就是客观下线)

    - `sentinel down-after-milliseconds hmaster 5000`:声明master节点超时多久后被标记下线

    - `sentinel failover-timeout hmaster 60000`:在第一次故障转移失败后多久再次重试(故障恢复超时时间)

    我们在虚拟机的`/root/redis`目录下新建3个文件夹:`s1``s2``s3`:

mkdir s1 s2 s3

1
2
3

将课前资料提供的`sentinel.conf`文件分别拷贝一份到3个文件夹中。

cp s1/sentinel.conf s2
cp s1/sentinel.conf s3

1
2
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”]

1
2
3

直接运行命令,启动集群:

docker-compose up -d

1
2
3
4
5
6
7

运行结果:

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-447-1024x242.png)

我们以s1节点为例,查看其运行日志

docker logs -f 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
1
2
3
4
5
6
7
8
9
10
11
12
13

可以看到`sentinel`已经联系到了`7001`这个节点,并且与其它几个哨兵也建立了链接。哨兵信息如下:

- `27001`:`Sentinel ID`是`8e91bd24ea8e5eb2aee38f1cf796dcb26bb88acf`
- `27002`:`Sentinel ID`是`5bafeb97fc16a82b431c339f67b015a51dad5e4f`
- `27003`:`Sentinel ID`是`56546568a2f7977da36abd3d2d7324c6c3f06b8d`

### 2.3.演示failover

接下来,我们演示一下当主节点故障时,哨兵是如何完成集群故障恢复(failover)的。

我们连接`7001`这个`master`节点,然后通过命令让其休眠60秒,模拟宕机:

连接7001这个master节点,通过sleep模拟服务宕机,60秒后自动恢复

docker exec -it r1 redis-cli -p 7001 DEBUG sleep 60

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

稍微等待一段时间后,会发现sentinel节点触发了`failover`

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-448-1024x440.png)

### 2.4.总结-Redis哨兵的几个面试题

1.Sentinel的三个作用是什么?

- 集群监控
- 故障恢复
- 状态通知

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-456.png)

2.Sentinel如何判断一个redis实例是否健康?

- 每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线(`sdown`
- 如果大多数sentinel都认为实例主观下线,则判定服务客观下线(`odown`

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-457.png)

3.故障转移步骤有哪些?

- 首先要在`sentinel`中选出一个`leader`,由leader执行`failover`
- 选定一个`slave`作为新的`master`,执行`slaveof noone`,切换到master模式
- 然后让所有节点都执行`slaveof` 新master
- 修改故障节点配置,添加`slaveof` 新master

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-458.png)

4.sentinel选举leader的依据是什么?

- 票数超过sentinel节点数量1半
- 票数超过quorum数量
- 一般情况下最先发起failover的节点会当选

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-459.png)

5.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`)。

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-460.png)

### 2.5.RedisTemplate连接哨兵集群的三大步骤

分为三步:

- 1)引入依赖
- 2)配置哨兵地址
- 3)配置读写分离

#### 2.5.1.引入 Spring Data Redis 依赖

就是SpringDataRedis的依赖:

org.springframework.boot spring-boot-starter-data-redis
1
2
3
4
5
6
7
8

- 作用:这是 Spring Boot 官方提供的 Redis 支持,自动集成了底层 Redis 客户端(默认使用 Lettuce,也可以切换为 Jedis)。
- 自动配置:引入后,Spring Boot 会自动识别你在 `application.yml` 中的 Redis 配置,创建好 `RedisTemplate` 等常用 Bean。

#### 2.5.2.配置哨兵地址

连接哨兵集群与传统单点模式不同,不再需要设置每一个redis的地址,而是直接指定哨兵地址:

spring: redis: sentinel: master: hmaster # 集群名 nodes: # 哨兵地址列表 - 192.168.150.101:27001 - 192.168.150.101:27002 - 192.168.150.101:27003
1
2
3
4
5
6
7
8
9

- `master: hmaster`:这个 `hmaster` 是哨兵监控的主节点名字,和哨兵配置文件中的 `sentinel monitor` 后面的名字保持一致。
- `nodes`:这里配置的是**哨兵节点地址**,客户端通过哨兵发现主从节点,并自动完成主从切换的连接管理。
- 重点:客户端不再关注主从节点的实际 IP 和端口,只要连接哨兵,由哨兵来告诉客户端谁是主谁是从。

#### 2.5.3.配置读写分离策略

最后,还要配置读写分离,让java客户端将写请求发送到master节点,读请求发送到slave节点。定义一个bean即可:

@Bean public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){ return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

#### 为什么要配置这个?

- 默认情况下,Redis 所有读写都会走主节点,压力集中在主节点上,slave 没有发挥作用。
- 通过设置 `ReadFrom.REPLICA_PREFERRED`,可以让**读请求优先走从节点**,只有当从节点都不可用时才走主节点,真正实现 **读写分离、负载均衡**

| 策略名 | 行为描述 |
| ------------------- | ------------------------------------------------------------ |
| `MASTER` | 所有请求只读主节点 |
| `MASTER_PREFERRED` | 优先读主节点,主不可用时才读从节点 |
| `REPLICA` | 所有请求只读从节点(主节点永远不会被用来读) |
| `REPLICA_PREFERRED` | 优先读从节点,从节点都挂了才读主节点(推荐策略,兼顾性能与可用性) |

#### 总结

这套配置完成了:

- 通过哨兵集群自动发现主从节点
- 实现客户端自动切换主节点的能力
- 实现了读写分离,提高性能与可用性

## 3.Redis分片集群

主从模式可以解决高可用、高并发读的问题。但依然有两个问题没有解决:

- 海量数据存储
- 高并发写

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-465.png)

要解决这两个问题就需要用到分片集群了。分片的意思,就是把数据拆分存储到不同节点,这样整个集群的存储数据量就更大了。

Redis分片集群的结构如图:

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-453-1024x658.png)

分片集群特征:

| 特性 | 描述 |
| --------------------------- | -------------------------------------- |
| 多个 Master | 每个 Master 负责部分数据(不同槽位) |
| 每个 Master 有多个 Slave | 高可用 |
| Master 之间互相监控健康状态 | 类似于 Sentinel 的作用 |
| 客户端可以访问任意节点 | 节点会重定向到正确的主节点(数据所在) |

### 3.1.搭建分片集群

Redis分片集群最少也需要3个master节点,由于我们的机器性能有限,我们只给每个master配置1个slave,形成最小的分片集群:

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-454-1024x506.png)

![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-455-1024x488.png)

#### 3.1.1.集群配置

分片集群中的Redis节点必须开启集群模式,一般在配置文件中添加下面参数:

port 7000 cluster-enabled yes cluster-config-file nodes.conf cluster-node-timeout 5000 appendonly yes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

AOF(append only file)持久化:以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中命令,达到恢复数据的目的。与RDB相比可以简单描述为改记录数据为记录数据产生的过程

其中有3个我们没见过的参数:

- `cluster-enabled`:是否开启集群模式
- `cluster-config-file`:集群模式的配置文件名称,无需手动创建,由集群自动维护
- `cluster-node-timeout`:集群中节点之间心跳超时时间

| 参数 | 含义 |
| ---------------------- | ----------------------------------- |
| `cluster-enabled yes` | 开启集群模式 |
| `cluster-config-file` | 集群元数据文件(由 Redis 自动管理) |
| `cluster-node-timeout` | 节点心跳超时时间 |
| `appendonly yes` | 开启 AOF 持久化,保障数据不丢失 |

一般搭建部署集群肯定是给每个节点都配置上述参数,不过考虑到我们计划用`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”]

1
2
3
4
5
6
7

**注意**:使用Docker部署Redis集群,network模式必须采用host(采用主机网络连接,省去了网络转换的开销)

#### 3.1.2.启动集群

进入`/root/redis-cluster`目录,使用命令启动redis:

docker-compose up -d

1
2
3

启动成功,可以通过命令查看启动进程:

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]

1
2
3
4
5

可以发现每个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

1
2
3
4
5
6
7
8
9

命令说明:

- `redis-cli --cluster`:代表集群操作命令

- `create`:代表是创建集群

- ```
--cluster-replicas 1

:指定集群中每个

1
master

的副本个数为1

  • 此时节点总数 ÷ (replicas + 1) 得到的就是master的数量n。因此节点列表中的前n个节点就是master,其它节点都是slave节点,随机分配到不同master

输入命令后控制台会弹出下面的信息:

img

这里展示了集群中masterslave节点分配情况,并询问你是否同意。节点信息如下:

  • 7001master,节点id后6位是da134f
  • 7002master,节点id后6位是862fa0
  • 7003master,节点id后6位是ad5083
  • 7004slave,节点id后6位是391f8b,认ad5083(7003)为master
  • 7005slave,节点id后6位是e152cd,认da134f(7001)为master
  • 7006slave,节点id后6位是4a018a,认862fa0(7002)为master

输入yes然后回车。会发现集群开始创建,并输出下列信息:

img

接着,我们可以通过命令查看集群状态:

1
redis-cli -p 7001 cluster nodes

结果:

img

你会看到每个节点的角色、状态、IP、槽位分配等信息:

  • Master 拥有哈希槽
  • Slave 会有 -> master 的从属关系

总结一下

优势 描述
横向扩展 每个 Master 只负责一部分数据(slot),突破单机容量限制
高并发写 写操作打散到多个 Master
高可用 Master 挂了,Slave 接替
自动分片 通过哈希槽机制自动决定数据放哪个节点

3.2.散列插槽

数据要分片存储到不同的Redis节点,肯定需要有分片的依据,这样下次查询的时候才能知道去哪个节点查询。很多数据分片都会采用一致性hash算法。而Redis则是利用散列插槽(hash slot)的方式实现数据分片。

img

在Redis集群中,共有16384个hash slots,集群中的每一个master节点都会分配一定数量的hash slots。具体的分配在集群创建时就已经指定了:

img

如图中所示:

  • Master[0],本例中就是7001节点,分配到的插槽是0~5460
  • Master[1],本例中就是7002节点,分配到的插槽是5461~10922
  • Master[2],本例中就是7003节点,分配到的插槽是10923~16383

img

img

实验:插槽机制演示

1.你连接到 7001,然后尝试设置一个 key:

1
2
3
4
5
6
# 进入容器
docker exec -it r1 bash
# 进入redis-cli
redis-cli -p 7001
# 测试
set user jack

会发现报错了:

1
MOVED 5474 127.0.0.1:7002

说明:

  • 计算出 key=user 属于 slot=5474
  • 5474 属于 7002,所以提示你去找 7002

2.正确使用方式

Redis 提供了集群模式下的客户端支持:-c 参数

1
2
3
4
# 通过7001连接集群
redis-cli -c -p 7001
# 存入数据
set user jack

结果如下:

img

可以看到,客户端自动跳转到了5474这个slot所在的7002节点。

3.测试带 {} 的 key

1
2
3
4
5
# 试一下key中带{}
set user:{age} 21

# 再试一下key中不带{}
set age 20

结果如下:

img

  • 这两个 key 计算出的 slot 都是 741,因此会被路由到同一个节点。
  • 这个特性可以用于 确保多个 key 路由到同一个节点,以支持 Lua 脚本、多 key 操作等。

img

3.3.故障转移

img

分片集群的节点之间会互相通过ping的方式做心跳检测,超时未回应的节点会被标记为下线状态。当发现master下线时,会将这个master的某个slave提升为master。

  1. 观察集群状态(使用 watch 命令)
1
watch docker exec -it r1 redis-cli -p 7001 cluster nodes

这条命令的意思是:每隔几秒自动刷新一次 cluster nodes 状态,查看各节点角色变化。

2.故意让 7002 主节点“宕机”输入下面命令:

1
docker exec -it r2 redis-cli -p 7002 DEBUG sleep 30

这条命令是让 7002 这个主节点“睡眠”30秒,模拟它宕机了,期间无法响应任何请求或心跳包。

3.Redis 发现 7002 无响应,自动触发故障转移

img

过了一段时间后,7002原本的小弟7006变成了master

img

而7002被标记为slave,而且其master正好是7006,主从地位互换。

img

img

Redis 分片集群中的故障转移是自动完成的,主要依据:

条件 描述
心跳丢失 节点间定期互相 ping,如果连续超时,说明可能故障
过期时间 节点一段时间内未恢复,则被标记为 FAIL
有从节点 从节点可用,才可以进行主从切换
多数节点一致 故障判定要多数节点达成共识,避免误判

3.4.总结-Redis分片集群几个面试题

1.Redis分片集群如何判断某个key应该在哪个实例?

  • 将16384个插槽分配到不同的实例
  • 根据key计算哈希值,对16384取余
  • 余数作为插槽,寻找插槽所在实例即可

img

2.如何将同一类数据固定的保存在同一个Redis实例?

  • Redis计算key的插槽值时会判断key中是否包含{},如果有则基于{}内的字符计算插槽
  • 数据的key中可以加入{类型},例如key都以{typeId}为前缀,这样同类型数据计算的插槽一定相同

img

3.5.Java客户端连接分片集群

RedisTemplate底层同样基于lettuce实现了分片集群的支持,而使用的步骤与哨兵模式基本一致,参考2.5节

1)引入redis的starter依赖

2)配置分片集群地址

3)配置读写分离

与哨兵模式相比,其中只有分片集群的配置方式略有差异,如下:

1
2
3
4
5
6
7
8
9
10
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 类似于Java的Map结构,适合存储对象

还有一些高级数据类型,比如Bitmap、HyperLogLog、GEO等,其底层都是基于上述5种基本数据类型。因此在Redis的源码中,其实只有5种数据类型。

4.1.RedisObject:Redis中的“对象头”

Redis 中每个键值对(无论类型)都被封装成一个通用的结构 RedisObject。它像 Java 中的对象,C语言中的 struct。

它本身不保存数据,而是通过 ptr 指针指向真正的数据地址。

结构大概是这样的:

img

RedisObject的关键字段:

  • type: 数据类型(String、List等)
  • encoding: 数据的编码方式(比如 int、embstr、ziplist 等)
  • ptr: 指向真正的数据地址

说明
Redis 对不同类型数据会采用不同的编码方式,提升内存效率与操作效率。

可以看到整个结构体中并不包含真实的数据,仅仅是对象头信息,内存占用的大小为4+4+24+32+64 = 128bit

也就是16字节,然后指针ptr指针指向的才是真实数据存储的内存地址。所以RedisObject的内存开销是很大的。

属性中的encoding就是当前对象底层采用的数据结构编码方式,可选的有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 intembstrraw
LIST LinkedList和ZipList(3.2以前)、QuickList(3.2以后)
SET intsetHT
ZSET ZipList(7.0以前)、Listpack(7.0以后)、HTSkipList
HASH ZipList(7.0以前)、Listpack(7.0以后)、HT

img

4.2.SkipList

SkipList 是一个带有多级索引的链表,适用于需要“有序访问”和“快速查找”的场景,Redis 的 SortedSet 就是用它实现的

跳表 vs 普通链表:

  • 普通链表查找慢,要挨个找
  • 跳表支持跳跃查找,多个“高度”,类似电梯结构,查找效率更高(对标 BST)

传统链表只有指向前后元素的指针,因此只能顺序依次访问。如果查找的元素在链表中间,查询的效率会比较低。而SkipList则不同,它内部包含跨度不同的多级指针,可以让我们跳跃查找链表中间的元素,效率非常高。

其结构如图:

img

我们可以看到1号元素就有指向3、5、10的多个指针,查询时就可以跳跃查找。例如我们要找大小为14的元素,查找的流程是这样的:

img

  • 首先找元素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,刚好于目标一致,找到。

这种多级指针的查询方式就避免了传统链表的逐个遍历导致的查询效率下降问题。在对有序数据做随机查询和排序时效率非常高。

跳表的结构体如下:

1
2
3
4
5
6
7
8
typedef struct zskiplist {
// 头尾节点指针
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 最大的索引层级
int level;
} zskiplist;

可以看到SkipList主要属性是header和tail,也就是头尾指针,因此它是支持双向遍历的。

跳表中节点的结构体如下:

1
2
3
4
5
6
7
8
9
typedef struct zskiplistNode {
sds ele; // 节点存储的字符串
double score;// 节点分数,排序、查找用
struct zskiplistNode *backward; // 前一个节点指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 下一个节点指针
unsigned long span; // 索引跨度
} level[]; // 多级索引数组
} zskiplistNode;

每个节点中都包含ele和score两个属性,其中score是得分,也就是节点排序的依据。ele则是节点存储的字符串数据指针。

其内存结构如下:

img

4.3.SortedSet

SortedSet 是 Redis 中用于排序的集合,其特点是:

  • 每个元素包含 value + score
  • 支持根据 score 排序,并根据 value 快速查找。

img

为实现上述功能,底层结构用了两种:

  1. HashTable(查找 score by value)
  2. SkipList(根据 score 排序和范围查询)

所以,它结构如下:

1
2
3
4
typedef struct zset {
dict *dict; // HashTable 映射关系
zskiplist *zsl; // 跳表,用于排序
} zset;

其内存结构如图:

img

img

面试题: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,也就是压缩列表来代替HashTableSkipList

不过,ZipList存在连锁更新问题,因此而在Redis7.0版本以后,ZipList又被替换为Listpack(紧凑列表)。

img

面试题为什么 ZSet 要用跳表,而不是平衡树(如 AVL 或红黑树)?

img

5.Redis内存回收

Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。

我们可以通过修改redis.conf文件,添加下面的配置来配置Redis的最大内存:

1
maxmemory 1gb

img

5.1.内存过期处理

存入Redis中的数据可以配置过期时间,到期后再次访问会发现这些数据都不存在了,也就是被过期清理了。

5.1.1.过期命令

Redis中通过expire命令可以给KEY设置TTL(过期时间),例如:

1
2
3
4
# 写入一条数据
set num 123
# 设置20秒过期时间
expire num 20

不过set命令本身也可以支持过期时间的设置:

1
2
# 写入一条数据并设置20s过期时间
set num EX 20

当过期时间到了以后,再去查询数据,会发现数据已经不存在。

5.1.2.过期策略

那么问题来了:

  • Redis如何判断一个KEY是否过期呢?
  • Redis又是何时删除过期KEY的呢?

Redis不管有多少种数据类型,本质是一个KEY-VALUE的键值型数据库,而这种键值映射底层正式基于HashTable来实现的,在Redis中叫做Dict.

来看下RedisDB的底层源码:

1
2
3
4
5
6
7
8
9
10
11
typedef struct redisDb {
dict dict; / The keyspace for this DB , 也就是存放KEYVALUE的哈希表*/
dict *expires; /* 同样是哈希表,但保存的是设置了TTLKEY,及其到期时间*/
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;

现在回答第一个问题:

面试题

1.Redis如何判断KEY是否过期呢?

:在Redis中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。

img

img

2.Redis是何时删除过期KEY的呢?

Redis并不会在KEY过期时立刻删除KEY,因为要实现这样的效果就必须给每一个过期的KEY设置时钟,并监控这些KEY的过期状态。无论对CPU还是内存都会带来极大的负担。

img

Redis的过期KEY删除策略有两种:

  • 惰性删除
  • 周期删除

img

惰性删除,顾明思议就是过期后不会立刻删除。那在什么时候删除呢?

Redis会在每次访问KEY的时候判断当前KEY有没有设置过期时间,如果有,过期时间是否已经到期。对应的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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影响(控制了Redis每秒钟执行定时器的频率),默认为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%,再进行一次抽样,否则结束

两种删除策略总结对比

策略 触发方式 优点 缺点
惰性删除 访问 key 时触发 高效,只处理必要的 key 不访问就不删,易内存泄漏
周期删除 定时任务触发 自动清理,防内存积压 非实时,有一定延迟

img

5.2.内存淘汰策略

img

对于某些特别依赖于Redis的项目而言,仅仅依靠过期KEY清理是不够的,内存可能很快就达到上限。因此Redis允许设置内存告警阈值,当内存使用达到阈值时就会主动挑选部分KEY删除以释放更多内存。这叫做内存淘汰机制。

5.2.1.Redis 什么时候会触发内存淘汰?

每次客户端执行命令时(如 setget 等),Redis 都会先判断当前内存是否超过阈值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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;
}
}
}

如果超出,就会尝试调用 performEvictions() 进行淘汰。

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,基于LFU算法进行淘汰
策略名 描述
noeviction 不淘汰,内存满时直接报错,默认策略
volatile-ttl 仅淘汰设置了 TTL 的 key,优先淘汰 TTL 最小的
allkeys-random 所有 key 中随机淘汰
volatile-random TTL key 中随机淘汰
allkeys-lru 所有 key 中淘汰最久未使用的(近似LRU)
volatile-lru TTL key 中淘汰最久未使用的
allkeys-lfu 所有 key 中淘汰访问频率最低的(近似LFU)
volatile-lfu TTL key 中淘汰访问频率最低的

比较容易混淆的有两个算法:

  • LRULeast Recently Used),最近最久未使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
  • LFULeast Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
项目 描述
LRU(Least Recently Used) 淘汰最近最久未使用的 key。优先级 = 当前时间 - 最后访问时间
LFU(Least Frequently Used) 淘汰访问频率最低的 key。优先级 = 访问次数(带时间衰减)

Redis怎么知道某个KEY的最近一次访问时间或者是访问频率呢?

RedisObject的结构

img

Redis 使用 RedisObject 结构体中的 lru 字段来记录访问信息:

  • LRU 模式:记录访问时间(24 bit)
  • LFU 模式:高16位记录访问时间(分钟),低8位记录“逻辑访问次数”(不是每次访问都加一)

LFU 计数规则:

  1. 随机生成 [0,1) 数 R;
  2. 计算概率 P = 1 / (old_count旧次数 * lfu_log_factor + 1)lfu_log_factor + 1默认为10;
  3. 若 R < P,则计数器 +1(最大 255);
  4. 每隔一段时间会衰减 count(默认每 1 分钟 -1)

img

这也是官方给出的真正LRU与近似LRU的结果对比:

img

你可以在图表中看到三种颜色的点形成三个不同的带,每个点就是一个加入的KEY

  • 浅灰色带是被驱逐的对象
  • 灰色带是没有被驱逐的对象
  • 绿色带是被添加的对象

5.2.3总结

Redis 内存满了之后会通过内存淘汰机制来释放内存。它支持 8 种策略,比如 LRU、LFU、随机等,区别在于是对所有 key 还是仅限设置了 TTL 的 key。Redis 不是对所有 key 做全量排序,而是通过抽样+候选池的方式来快速选出要淘汰的 key,效率很高。

如果业务对内存敏感,推荐使用 allkeys-lfu,这能最大程度上保留热门数据,清理冷门数据,比较适合高频访问的场景。

img

5.3总结 -Redis内存回收的几道面试题

1.面试题Redis如何判断KEY是否过期呢?

:在Redis中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。

img

2.面试题Redis何时删除过期KEY?如何删除?

img

:Redis的过期KEY处理有两种策略,分别是惰性删除和周期删除。

惰性删除是指在每次用户访问某个KEY时,判断KEY的过期时间:如果过期则删除;如果未过期则忽略。

周期删除有两种模式:

  • SLOW模式:通过一个定时任务,定期的抽样部分带有TTL的KEY,判断其是否过期。默认情况下定时任务的执行频率是每秒10次,但每次执行不能超过25毫秒。如果执行抽样后发现时间还有剩余,并且过期KEY的比例较高,则会多次抽样。
  • FAST模式:在Redis每次处理NIO事件之前,都会抽样部分带有TTL的KEY,判断是否过期,因此执行频率较高。但是每次执行时长不能超过1ms,如果时间充足并且过期KEY比例过高,也会多次抽样

3.面试题当Redis内存不足时会怎么做

img

:这取决于配置的内存淘汰策略,Redis支持很多种内存淘汰策略,例如LRU、LFU、Random. 但默认的策略是直接拒绝新的写入请求。而如果设置了其它策略,则会在每次执行命令后判断占用内存是否达到阈值。如果达到阈值则会基于配置的淘汰策略尝试进行内存淘汰,直到占用内存小于阈值为止。

4.面试题那你能聊聊LRULFU

img

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,找出其中逻辑访问次数最小的,将其淘汰。

5.面试题逻辑访问次数是如何计算的

img

:由于记录访问次数的只有8bit,即便是无符号数,最大值只有255,不可能记录真实的访问次数。因此Redis统计的其实是逻辑访问次数。这其中有一个计算公式,会根据当前的访问次数做计算,结果要么是次数+1,要么是次数不变。但随着当前访问次数越大,+1的概率也会越低,并且最大值不超过255.

除此以外,逻辑访问次数还有一个衰减周期,默认为1分钟,即每隔1分钟逻辑访问次数会-1。这样逻辑访问次数就能基本反映出一个key的访问热度了。

6.缓存问题

Redis经常被用作缓存,而缓存在使用的过程中存在很多问题需要解决。例如:

  • 缓存的数据一致性问题
  • 缓存击穿
  • 缓存穿透
  • 缓存雪崩

6.1.缓存一致性

img

我们先看下目前企业用的最多的缓存模型。

缓存的通用模型

有三种:

  • Cache Aside
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10

    :有缓存调用者自己维护数据库与缓存的一致性。即:

    - 查询时:命中则直接返回,未命中则查询数据库并写入缓存
    - 更新时:更新数据库并删除缓存,查询时自然会更新缓存

    ![img](https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-520.png)

    - ```
    Read/Write Through
    :数据库自己维护一份缓存,底层实现对调用者透明。底层实现: - 查询时:命中则直接返回,未命中则查询数据库并写入缓存 - 更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据库

img

  • Write Behind Cahing:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库

img

目前企业中使用最多的就是Cache Aside模式,因为实现起来非常简单。但缺点也很明显,就是无法保证数据库与缓存的强一致性。为什么呢?我们一起来分析一下。

img

关键问题:更新时缓存和数据库的操作顺序

那到底是先更新数据库再删除缓存,还是先删除缓存再更新数据库呢?

现在假设有两个线程,一个来更新数据,一个来查询数据。我们分别分析两种策略的表现。

我们先分析策略1,先删除缓存再更新数据库

img

img

异常情况

img

由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高。因此不推荐这种方案。

再来看策略2,先更新数据库再删除缓存

正常情况

img

异常情况

img

最佳实践总结

  • 使用 Cache Aside
  • 写操作时,先更新数据库,再删除缓存
  • 给缓存加上合理的过期时间,作为最终一致性的兜底保障;
  • 如果对强一致性要求极高,需要考虑加锁控制(比如分布式锁),但性能会下降。

什么是缓存一致性问题?如何解决

img

6.2.缓存穿透

img

假如有不怀好意的人,开启很多线程频繁的访问一个数据库中也不存在的数据。由于缓存不可能生效,那么所有的请求都访问数据库,可能就会导致数据库因过高的压力而宕机。

解决这个问题有两种思路:

  • 缓存空值
  • 布隆过滤器

6.2.1.缓存空值

简单来说,就是当我们发现请求的数据即不存在与缓存,也不存在与数据库时,将空值缓存到Redis,避免频繁查询数据库。实现思路如下:

img

优点:

  • 实现简单,维护方便

缺点:

  • 额外的内存消耗

img

6.2.2.布隆过滤器

img

原理:
布隆过滤器是一种空间效率极高的集合判断算法,用于判断“某个元素是否存在于集合中”。

  • 每个合法的数据在写入数据库前,会被加入布隆过滤器。
  • 读取请求到来时,如果布隆过滤器认为“不存在”,直接拒绝访问数据库。

特点:

  • 判断“存在”时可能误判(即存在也可能是假的)
  • 判断“不存在”时一定准确(肯定不存在)

好处:

  • 可以大幅减少无效请求对数据库的压力

布隆过滤器工作机制(简要):

img

布隆过滤维护一个超大 bit 数组,所有位默认是 0

img

然后还需要Khash函数,将元素基于这些hash函数做运算的结果映射到bit数组的不同位置,并将这些位置置为1,例如现在k=3:

  • hello经过运算得到3个角标:1、5、12
  • world经过运算得到3个角标:8、17、21
  • java经过运算得到3个角标:17、25、28

则需要将每个元素对应角标位置置为1:

img

此时,我们要判断元素是否存在,只需要再次基于Khash函数做运算, 得到K个角标,判断每个角标的位置是不是1:

  • 只要全是1,就证明元素存在
  • 任意位置为0,就证明元素一定不存在

假如某个元素本身并不存在,也没添加到布隆过滤器过。但是由于存在hash碰撞的可能性,这就会出现这个元素计算出的角标已经被其它元素置为1的情况。那么这个元素也会被误判为已经存在。

因此,布隆过滤器的判断存在误差:

  • 当布隆过滤器认为元素不存在时,它肯定不存在
  • 当布隆过滤器认为元素存在时,它可能存在,也可能不存在

bit数组越大、Hash函数K越复杂,K越大时,这个误判的概率也就越低。由于采用bit数组来标示数据,即便4,294,967,296bit位,也只占512mb的空间

我们可以把数据库中的数据利用布隆过滤器标记出来,当用户请求缓存未命中时,先基于布隆过滤器判断。如果不存在则直接拒绝请求,存在则去查询数据库。尽管布隆过滤存在误差,但一般都在0.01%左右,可以大大减少数据库压力。

使用布隆过滤后的流程如下(布隆过滤器和缓存空对象一起使用可以兜底):

img

最终推荐做法:

可以将两种方式结合使用:

布隆过滤器拦截非法请求(防止穿透)
缓存空值兜底合法但不存在的请求(减少无意义数据库访问)

方法 原理 优点 缺点
缓存空值 空对象写入 Redis 简单 占内存
布隆过滤器 过滤非法请求 高效、节省资源 有误判率

img

6.3.缓存雪崩

img

img

img

常见的解决方案有:

  • 给不同的Key的TTL添加随机值,这样KEY的过期时间不同,不会大量KEY同时过期
  • 利用Redis集群提高服务的可用性,避免缓存服务宕机
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存(比如浏览器级别缓存、nginx级别缓存、jvm本地缓存等,然后就是redis缓存和数据库),比如先查询本地缓存,本地缓存未命中再查询Redis,Redis未命中再查询数据库。即便Redis宕机,也还有本地缓存可以抗压力

如何解决缓存雪崩问题?

方案 描述
设置随机 TTL 给不同的 key 设置不同的过期时间(加上随机数),让它们错峰过期
Redis 高可用部署 使用 Redis Sentinel 或集群模式,提高 Redis 的容灾能力
限流+降级 当 Redis 宕机时,限制请求流量,或者给出降级响应(如“稍后再试”)
多级缓存 添加本地缓存(JVM、浏览器、nginx)作为第一道防线,减少 Redis 压力
预热机制 Redis 重启后,提前把热点数据重新加载进缓存,避免刚启动时就雪崩

什么是缓存雪崩如何解决

img

6.4.缓存击穿

img

img

如上图所示:

  • 线程 1 发现缓存没命中,去查数据库;
  • 数据库访问较慢,线程 1 还没构建缓存;
  • 线程 2、3、4 紧随其后,也发现没缓存,于是都去数据库查询
  • 数据库被压垮!
  • 这就是典型的“击穿”场景。

解决方案

方案一:互斥锁(加锁防止重复构建)

核心思路:

  • 同一时间只允许一个线程去重建缓存,其他线程等待或返回旧数据。

常见实现:

  • 使用分布式锁(如 Redis 的 SETNX + TTL)控制;
  • 加锁后,只有一个线程能访问数据库并刷新缓存;
  • 其他线程等待或快速返回旧值。

优点:

  • 精确控制,避免并发访问数据库。
  • 保证一致性

缺点:

  • 锁竞争激烈时可能会影响性能;
  • 可能有死锁风险

方案二:逻辑过期(缓存中保存一个过期时间)

核心思路:

  • 缓存中存储的数据除了value外,还有一个字段是过期时间(expireTime);
  • 读取缓存时,如果没有过期就直接返回;
  • 如果过期了,则先返回旧数据,同时异步刷新缓存(比如由一个后台线程来做);

📌 优点:

  • 数据不会完全失效,用户始终能读到旧数据,避免并发打到数据库;
  • 提高系统鲁棒性。
  • 线程无需等待性能较好

📌 缺点:

  • 代码复杂度高一些(需要维护逻辑过期和后台刷新线程);
  • 数据可能是“旧的”,不是实时的。(不保证一致性)
  • 有额外内存消耗

基于互斥锁的方案如图:

img

逻辑过期的思路如图:

img

方案 原理 优点 缺点
互斥锁 加锁控制只允许一个线程构建缓存 精确控制,防止并发重建 性能影响大,锁竞争问题
逻辑过期 缓存中增加过期时间字段 永不失效,不打爆数据库 实现复杂,有数据延迟风险

6.5.面试总结 -缓存问题

1.面试题如何保证缓存的****双写一致性

:缓存的双写一致性很难保证强一致,只能尽可能降低不一致的概率,确保最终一致。我们项目中采用的是Cache Aside模式。简单来说,就是在更新数据库之后删除缓存;在查询时先查询缓存,如果未命中则查询数据库并写入缓存。同时我们会给缓存设置过期时间作为兜底方案,如果真的出现了不一致的情况,也可以通过缓存过期来保证最终一致。

img

追问:为什么不采用延迟双删机制?

:延迟双删的第一次删除并没有实际意义,第二次采用延迟删除主要是解决数据库主从同步的延迟问题,我认为这是数据库主从的一致性问题,与缓存同步无关。既然主节点数据已经更新,Redis的缓存理应更新。而且延迟双删会增加缓存业务复杂度,也没能完全避免缓存一致性问题,投入回报比太低。

2.面试题如何解决缓存穿透问题

img

:缓存穿透也可以说是穿透攻击,具体来说是因为请求访问到了数据库不存在的值,这样缓存无法命中,必然访问数据库。如果高并发的访问这样的接口,会给数据库带来巨大压力。

我们项目中都是基于布隆过滤器来解决缓存穿透问题的,当缓存未命中时基于布隆过滤器判断数据是否存在。如果不存在则不去访问数据库。

当然,也可以使用缓存空值的方式解决,不过这种方案比较浪费内存。

3.面试题如何解决缓存雪崩问题

:缓存雪崩的常见原因有两个,第一是因为大量key同时过期。针对问这个题我们可以可以给缓存key设置不同的TTL值,避免key同时过期。

第二个原因是Redis宕机导致缓存不可用。针对这个问题我们可以利用集群提高Redis的可用性。也可以添加多级缓存,当Redis宕机时还有本地缓存可用。

img

4.面试题如何解决缓存击穿问题

:缓存击穿往往是由热点Key引起的,当热点Key过期时,大量请求涌入同时查询,发现缓存未命中都会去访问数据库,导致数据库压力激增。解决这个问题的主要思路就是避免多线程并发去重建缓存,因此方案有两种。

第一种是基于互斥锁,当发现缓存未命中时需要先获取互斥锁,再重建缓存,缓存重建完成释放锁。这样就可以保证缓存重建同一时刻只会有一个线程执行。不过这种做法会导致缓存重建时性能下降严重。

第二种是基于逻辑过期,也就是不给热点Key设置过期时间,而是给数据添加一个过期时间的字段。这样热点Key就不会过期,缓存中永远有数据。

查询到数据时基于其中的过期时间判断key是否过期,如果过期开启独立新线程异步的重建缓存,而查询请求先返回旧数据即可。当然,这个过程也要加互斥锁,但由于重建缓存是异步的,而且获取锁失败也无需等待,而是返回旧数据,这样性能几乎不受影响。

需要注意的是,无论是采用哪种方式,在获取互斥锁后一定要再次判断缓存是否命中,做dubbo check. 因为当你获取锁成功时,可能是在你之前有其它线程已经重建缓存了。

img

6.6一张表搞懂缓存问题

问题名称 定义 成因 解决方案 适用场景
缓存一致性 缓存与数据库的数据不一致 数据库更新了但缓存没更新或延迟更新 先更新数据库再删缓存;合理设置 TTL;接受最终一致性 对一致性要求高的系统,如订单状态
缓存穿透 请求的是数据库和缓存都没有的数据 恶意请求或查询空值 使用布隆过滤器拦截;缓存空值 请求参数无效或不常见,比如无效用户ID
缓存雪崩 大量缓存同一时间过期,导致请求打爆数据库 设置了相同过期时间;Redis 宕机 过期时间加随机;使用本地缓存兜底;部署高可用 Redis 集群 秒杀活动、大量缓存集中设置一致过期时间场景
缓存击穿 某个热点 key 过期,瞬间大量请求打到数据库 热点数据刚好过期,高并发同时请求 加互斥锁防止重复构建;逻辑过期+后台异步重建 热点商品详情页、用户资料页等

面试篇完结撒花