Redis主从

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离

主从集群结构

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

1280X1280 (4)

搭建主从集群

在同一个虚拟机中利用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文件来构建主从集群:

ce41d630-bc2e-4f6f-aaa6-ee8912341a22

文件内容如下:

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:6.2.7
container_name: r1
network_mode: "host"
entrypoint: ["redis-server", "--port", "7001"]
r2:
image: redis:6.2.7
container_name: r2
network_mode: "host"
entrypoint: ["redis-server", "--port", "7002"]
r3:
image: redis:6.2.7
container_name: r3
network_mode: "host"
entrypoint: ["redis-server", "--port", "7003"]

注意1:视频中用的redis镜像是latest,目前镜像版本已更新,需要将docker-compose.yaml的redis镜像版本设置为6.2.7

注意2:搭建Redis集群时,网络模式需要为”host”(不通过网桥=>宿主机网络的流程,而是直接连接到宿主机网络上)

将其上传至虚拟机的/root/redis目录下,执行命令,运行集群:

1
docker compose up -d

查看docker容器,发现都正常启动了:

b4e6b299-7669-4bf9-aff9-d522600357f1

由于采用的是host模式,看不到端口映射。不过能直接在宿主机通过ps命令查看到Redis进程:

7c91b7e8-06ed-4802-afbb-673c6fcfda9e

建立集群

虽然启动了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命令(写操作),其它两个节点只能执行get命令(读操作)。也就是说读写操作已经分离了。

主从同步原理

全量同步

主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点,流程:

5f021cbd-577a-4d25-8997-9773ff77272f

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

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

  • **Replication Id**:简称replid,是数据集的标记,replid一致则是同一数据集。每个master都有唯一的replidslave则会继承master节点的replid
  • **offset**:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slaveoffset小于masteroffset,说明slave数据落后于master,需要更新

因此slave做数据同步,必须向master声明自己的replication id offsetmaster才可以判断到底需要同步哪些数据

由于在执行slaveof命令之前,所有redis节点都是master,有自己的replidoffset

当第一次执行slaveof命令,与master建立主从关系时,发送的replidoffset是自己的,与master肯定不一致

master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了

master会将自己的replidoffset都发送给这个slaveslave保存这些信息到本地。自此以后slavereplid就与master一致了

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

b774819c-631a-482d-adaf-080b81ecad3d

完整流程描述:

  • slave节点请求增量同步
  • master节点判断replid,发现不一致,拒绝增量同步
  • master将完整内存数据生成RDB,发送RDBslave
  • slave清空本地数据,加载masterRDB
  • masterRDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
  • slave执行接收到的命令,保持与master之间的同步

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

30f1f875-99c1-40c2-87e2-0cbf64f67160

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

388a7e64-ef47-4348-9849-0516dce97084

增量同步

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

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

d359ffbc-514c-4058-ad5e-12926ae64531

repl_baklog原理

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

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

repl_baklog中会记录Redis处理过的命令及offset,包括master当前的offset,和slave已经拷贝到的offset

ff94e95c-9492-46ec-bc5e-636acf41dd3c

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

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

f36883d0-5086-4287-ab9a-0ebe005559a5

直到数组被填满:

7d29a7e9-0e6c-4630-9d48-0fccaafd1992

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

ec2606a9-c80c-42af-a07e-ebb71b99b50a

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

45278ff9-32bf-4eb1-9165-6c5dcc95435c

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

d9939d4d-a443-453e-8487-9182c1117f39

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

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

redis_replication_backlog

主从同步优化

主从同步可以保证主从数据的一致性,非常重要

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

  • 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO
    • tips:虽然Redis的数据是存在于内存中的,但默认情况下,主从复制时master会生成RDB临时文件并发送给slave,slave将该临时文件保存在磁盘后再读取并同步数据。而无磁盘复制就是不生成RDB文件,master直接将序列化数据发送给slave进行同步
  • Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO(也就是减少单节点上保存的数据)
  • 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
    • tips:repl_backlog 存储在内存中,增大其大小会直接增加 Redis 内存占用
  • 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力

主-从-从架构图:

c4f31e9a-8d55-42fa-bdaa-ba9578b21c0a

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

  • 全量同步: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哨兵

哨兵原理

Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。哨兵的具体作用如下:

  • 监控:Sentinel会不断检查master和slave是否按预期工作
  • 自动故障切换:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后,依旧以新的master为主
  • 通知:当集群发生故障转移时,Sentinel会将最新节点角色信息推送给Redis的客户端

534bd447-686d-47f0-9dbe-2d8a92f9813c

服务状态监控

Sentinel基于心跳机制检测服务状态,每隔一秒向集群的每个实例发送ping命令:

  • 主观下线:如果某Sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线
  • 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半

选举新的master

一旦发现master故障,sentinel需要在slave中选择一个作为新的master,选择依据如下:

  • 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
    • 简单来说,就是担心断开时间太久而导致slave数据不同步
  • 然后判断slave节点的slave-priority值,值越小优先级越高,如果是0则永远不参与选举
  • 如果slave-priority一样,则判断slave节点的offset值,越大则说明数据越新,优先级越高
  • 最后是判断slave节点的运行id(run-id)大小,越小优先级越高(本质就是随机挑选一个,属于兜底方案)

选举leader

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

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

而sentinel投票的原则有两条:

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

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

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

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

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

如何实现故障转移

当选中了其中一个slave为新的master后(例如slave1),故障转移的步骤如下:

  • sentinel给备选的slave1节点发送slaveof no one命令,让该命令成为master
  • sentinel给所有其他slave发送slaveof 192.168.150.101 7002,让这些slave成为新master的从节点,开始从新的master上同步数据
  • 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点

流程图如下:

c86216cd-cdaa-4f6f-b745-769d3b6261da

d9fb992c-1ff5-4c04-bfc7-f1c147ef0696

c7c1a0c8-90f6-4658-b9a2-3b354edbec81

63a76737-bde5-40bf-a5b4-bcaf1c2ec60c

搭建哨兵集群

首先,停掉之前的redis集群:

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

# 新版本Docker
docker compose down

然后,找到课前资料提供的sentinel.conf文件:

45d9552c-f3ad-4cfd-b92c-ed8cbee66cd6

其内容如下:

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(需要改为自己的ip)
  • sentinel monitor hmaster 192.168.150.101 7001 2:指定集群的主节点信息
    • hmaster:主节点名称,自定义,任意写
    • 192.168.150.101 7001:主节点的ip和端口(需要改为自己的ip)
    • 2:认定master下线时的quorum
  • sentinel down-after-milliseconds hmaster 5000:声明master节点超时多久后被标记下线
  • sentinel failover-timeout hmaster 60000:在第一次故障转移失败后多久再次重试

在虚拟机的/root/redis目录下新建3个文件夹:s1s2s3:

aa7351bd-330c-4dc6-83d4-c5d84e8a8b68

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

接着修改docker-compose.yaml文件,内容如下:

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
version: "3.2"

services:
r1:
image: redis:6.2.7
container_name: r1
network_mode: "host"
entrypoint: ["redis-server", "--port", "7001"]
r2:
image: redis:6.2.7
container_name: r2
network_mode: "host"
entrypoint: ["redis-server", "--port", "7002", "--slaveof", "192.168.150.101", "7001"]
r3:
image: redis:6.2.7
container_name: r3
network_mode: "host"
entrypoint: ["redis-server", "--port", "7003", "--slaveof", "192.168.150.101", "7001"]
s1:
image: redis:6.2.7
container_name: s1
volumes:
- /root/redis/s1:/etc/redis
network_mode: "host"
entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27001"]
s2:
image: redis:6.2.7
container_name: s2
volumes:
- /root/redis/s2:/etc/redis
network_mode: "host"
entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27002"]
s3:
image: redis:6.2.7
container_name: s3
volumes:
- /root/redis/s3:/etc/redis
network_mode: "host"
entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27003"]

注意:视频中用的redis镜像是latest,目前镜像版本已更新,需要将docker-compose.yaml的redis镜像版本设置为6.2.7

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

1
docker-compose up -d

运行结果:

f40776c4-390c-4b92-b6de-6b9c6a4f5fe2

演示failover

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

1
2
# 连接7001这个master节点,通过sleep模拟服务宕机,60秒后自动恢复
docker exec -it r1 redis-cli -p 7001 DEBUG sleep 60

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

b190361c-0773-492c-a0ae-b8a352b2d8a6

总结

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

RedisTemplate连接哨兵集群

分为三步:

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

引入依赖

就是SpringDataRedis的依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置哨兵地址

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

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

配置读写分离

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

1
2
3
4
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

这个bean中配置的就是读写策略,包括四种:

  • MASTER:从主节点读取
  • MASTER_PREFERRED:优先从master节点读取,master不可用才读取slave
  • REPLICA:从slave节点读取
  • REPLICA_PREFERRED:优先从slave节点读取,所有的slave都不可用才读取master

Redis分片集群

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

  • 海量数据存储问题(单节点Redis不推荐配置过大内存)
  • 高并发写的问题(单个主节点无法承受高并发写入)

使用分片集群可以解决以上问题,分片集群特征:

  • 集群中有多个master,每个master保存不同数据
  • 每个master都可以有多个slave节点
  • master之间通过ping检测彼此健康状态
  • 客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点
b37687d1-19dc-440d-8b79-a4af190770f2

虽然分片集群可以解决海量数据存储问题、高并发问题,但是缺点也很明显,就是架构过于庞大复杂而带来的成本上升。所以一般只有大型企业或项目才会采用这种方式

搭建分片集群

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

9adb9ff3-b113-452e-b806-434f582336e7

对于标准的 Redis 分片集群部署,通常不需要额外配置哨兵,因为集群本身就提供了所需的高可用功能

计划部署的节点信息如下:

容器名 角色 IP 映射端口
r1 master 192.168.150.101 7001
r2 master 192.168.150.101 7002
r3 master 192.168.150.101 7003
r4 slave 192.168.150.101 7004
r5 slave 192.168.150.101 7005
r6 slave 192.168.150.101 7006

集群配置

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

1
2
3
4
5
port 7000
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
  • cluster-enabled:是否开启集群模式
  • cluster-config-file:集群模式的配置文件名称,无需手动创建,由集群自动维护
  • cluster-node-timeout:集群中节点之间心跳超时时间

一般搭建部署集群肯定是给每个节点都配置上述参数,不过考虑到我们计划用docker-compose部署,因此可以直接在启动命令中指定参数,偷个懒

在虚拟机的/root目录下新建一个redis-cluster目录,然后在其中新建一个docker-compose.yaml文件,内容如下:

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
version: "3.2"

services:
r1:
image: redis:6.2.7
container_name: r1
network_mode: "host"
entrypoint: ["redis-server", "--port", "7001", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r2:
image: redis:6.2.7
container_name: r2
network_mode: "host"
entrypoint: ["redis-server", "--port", "7002", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r3:
image: redis:6.2.7
container_name: r3
network_mode: "host"
entrypoint: ["redis-server", "--port", "7003", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r4:
image: redis:6.2.7
container_name: r4
network_mode: "host"
entrypoint: ["redis-server", "--port", "7004", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r5:
image: redis:6.2.7
container_name: r5
network_mode: "host"
entrypoint: ["redis-server", "--port", "7005", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
r6:
image: redis:6.2.7
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:

1
docker-compose up -d

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

1
2
3
4
5
6
7
8
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模式运行。不过节点与节点之间并未建立连接

接下来,使用命令创建集群:

1
2
3
4
5
6
# 进入任意节点容器
docker exec -it r1 bash
# 然后,执行命令
redis-cli --cluster create --cluster-replicas 1 \
192.168.0.104:7001 192.168.0.104:7002 192.168.0.104:7003 \
192.168.0.104:7004 192.168.0.104:7005 192.168.0.104:7006

命令说明:

  • redis-cli --cluster:代表集群操作命令
  • create:代表是创建集群
  • --cluster-replicas 1 :指定集群中每个master的副本个数为1
    • 此时节点总数 ÷ (replicas + 1) 得到的就是master的数量n。因此节点列表中的前n个节点就是master,其它节点都是slave节点,随机分配到不同master

ip地址注意修改为自己的虚拟机地址

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

84d88852-4113-4cb9-b253-f672e71d705f

这里展示了集群中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然后回车。会发现集群开始创建,并输出下列信息:

f003a764-46c3-4b7e-af03-2b1f5effa168

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

1
redis-cli -p 7001 cluster nodes

结果:

d2fb4194-0074-4c36-9094-f8a344b53959

散列插槽

在Redis集群中,共有16384个hash slots,集群中的每一个master节点都会分配一定数量的hash slots:

79d26d13-443d-4e46-8324-0369a1b703c8

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

Redis数据不是与节点绑定,而是与插槽slot绑定。当读写数据时,Redis基于CRC16算法对Key做hash运算,得到的结果与16384取余,就计算出了这个key的slot值,然后到slot所在的Redis节点执行读写操作

Slot(插槽):相当于书架上的位置编号
Key(键):相当于书的完整书名
Value(值):相当于书的具体内容

Redis在计算key的hash值时是不一定根据整个key计算,分两种情况:

  • 当key中包含{}时,根据{}之间的字符串计算hash slot
  • 当key中不包含{}时,则根据整个key字符串计算hash slot

例如:

  • key是user,则根据user来计算hash slot
  • key是user:{age},则根据age来计算hash slot

分片集群建立连接测试

先于7001建立连接进行测试:

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

会发现报错了:

3d3fc161-164e-4ad4-9a48-f346f963be96

提示信息:MOVED 5474,其实就是经过计算,得出user这个keyhash slot5474,而5474是在7002节点,不能在7001上写入!!

这是因为连接的方式有问题,**连接集群时,要加-c参数**:

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

结果如下:

00865969-66ab-4c00-abbf-7d42c1792369

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

现在,添加一个新的key,这次加上{}

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

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

结果如下:

26013284-43f9-4a51-af9d-cd58c40465fa

可以看到user:{age}age计算出的slot都是741

总结

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

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

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

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

故障转移

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

先打开一个控制台窗口,利用命令监测集群状态:

1
watch docker exec -it r1 redis-cli -p 7001 cluster nodes

命令前面的watch可以每隔一段时间刷新执行结果,方便实时监控集群状态变化

接着,利用命令让某个master节点休眠。比如这里让7002节点休眠,打开一个新的ssh控制台,输入下面命令:

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

可以观察到,集群发现7002宕机,标记为下线:

77fbf8e5-ec13-44bd-9523-bbf3984caf98

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

6f874fba-b144-4b8b-9a5e-addc19139708

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

Redis数据结构

RedisObject

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象,源码如下:

7dbe3a8c-8cd3-4685-b720-6cdd6489d5c4

可以看到整个结构体中并不包含真实的数据,仅仅是对象头信息,内存占用的大小为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 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

这些数据类型比较复杂,所以这里重点讲解几个面试会问的,其它的可以查看黑马程序员发布的Redis专业课程:

SkipList

SkipList(跳表)首先是链表,但与传统链表相比有几点差异:

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同

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

ac80d3af-c78d-4029-aa54-f917c6472b50

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

f3d744df-dfdd-4a2c-ad13-08e122ff648b

  • 首先找元素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则是节点存储的字符串数据指针

其内存结构如下(重点关注这张图即可):

16305dc2-6c59-4f6e-be4a-677198725e5a

总结

SkipList的特点:

  • 跳跃表是一个有序的双向链表,每个节点都包含score和ele值,其中ele保存的就是实际的数据
  • 节点按照score值排序,score值一样则按照ele字典排序
  • 每个节点都可以包含多层指针,层数是1~32之间的随机数
  • 不同层指针到下一个节点的跨度不同,层级越高跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单。但空间复杂度更高

SortedSet

Redis源码中zset,也就是SortedSet的结构体如下:

1
2
3
4
typedef struct zset {
dict *dict; // dict,底层就是HashTable
zskiplist *zsl; // 跳表
} zset;

其内存结构如图:

bd0dd136-7018-48d2-b039-6d8588058162

面试相关

面试题:Redis的SortedSet底层的数据结构是怎样的?

  • 首先SortedSet需要能存储score和member值,而且要快捷的根据member查询score,因此底层有一个哈希表,以member为键(key),以score为值(value)
  • 其次SortedSet还需要能根据score排序,因此底层还维护了一个跳表
  • 当需要根据member查询score时,就去哈希表中查询
  • 当需要根据score排序查询时,则基于跳表查询

(简单理解:key-value相关操作就通过哈希表,值的直接操作通过跳表【不一定正确】)

加分项:因为SortedSet底层需要用到两种数据结构,对内存占用比较高。因此Redis底层会对SortedSet中的元素大小做判断。如果元素个数小于128每个元素都小于64字节,SortedSet底层会采用ZipList,也就是压缩列表来代替HashTableSkipList

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

Redis内存回收

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

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

1
maxmemory 1gb

当内存达到上限,就无法存储更多数据了。因此,Redis内部会有两套内存回收的策略:

  • 内存过期策略
  • 内存淘汰策略

内存过期处理

过期命令

Redis提供了expire命令,给key设置TTL(Time-To-Live,存活时间):

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

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

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

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

过期策略

那么问题来了:

  • 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 , 也就是存放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有没有设置过期时间,如果有,过期时间是否已经到期。对应的源码如下:

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影响,默认为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%,再进行一次抽样,否则结束

总结

RedisKey的TTL记录方式:

  • 在RedisDB中通过一个哈希表记录每个Key的到期时间

过期Key的删除策略:

  • 惰性删除:每次查找key时判断是否过期,如果过期则删除
  • 周期删除:定期抽样部分key,判断是否过期,如果过期则删除

周期删除的两种模式:

  1. SLOW模式执行频率默认为10,每次不超过25ms
  2. FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

内存淘汰策略

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

内存淘汰时机

那么问题来了,当内存达到阈值时执行内存淘汰,但问题是Redis什么时候会执去判断内存是否达到预警呢?

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;
}
}
}

淘汰策略

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 不淘汰任何 key,内存满时不允许写入新数据,默认策略
随机淘汰 allkeys-random 对全体 key,随机进行淘汰,直接从 db->dict 中随机挑选
volatile-random 对设置了 TTL 的 key,随机进行淘汰,从 db->expires 中随机挑选
LRU算法 allkeys-lru 对全体 key,基于 LRU 算法进行淘汰
volatile-lru 对设置了 TTL 的 key,基于 LRU 算法进行淘汰
LFU算法 allkeys-lfu 对全体 key,基于 LFU 算法进行淘汰
volatile-lfu 对设置了 TTL 的 key,基于 LFU 算法进行淘汰
TTL优先 volatile-ttl 对设置了 TTL 的 key,比较 key 的剩余 TTL 值,TTL 越小越先被淘汰

前缀为allkey:表示对所有的key进行操作

前缀为volatile:表示对设置了TTL的key进行操作

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

  • LRU(**Least Recently Used),最近最少未使用**。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高
  • LFU(**Least Frequently Used),最少频率使用**。会统计每个key的访问频率,值越小淘汰优先级越高

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

在Redis数据结构这一章,提到了RedisObject的结构:

15bb52e9-f4dc-4233-86ad-4a331829975c

其中的lru就是记录最近一次访问时间和访问频率的。当然,你选择LRULFU时的记录方式不同:

  • LRU:以秒为单位记录最近一次访问时间,长度24bit
  • LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数

时间就不说了,那么逻辑访问次数又是怎么回事呢?8位无符号数字最大才255,访问次数超过255怎么办?

这就要聊起Redis的逻辑访问次数算法了,LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:

  • ① 生成[0,1)之间的随机数R
  • ② 计算 1/(旧次数 * lfu_log_factor + 1),记录为Plfu_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的结果对比:

434628d3-6126-4a00-905d-00f8aeacedea

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

总结

面试题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的访问热度了

缓存问题

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

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

缓存一致性

缓存的通用模型有三种:

  • Cache Aside:由缓存调用者自己维护数据库与缓存的一致性。即:
    • 查询时:命中则直接返回,未命中则查询数据库并写入缓存
    • 更新时:更新数据库并删除缓存,查询时自然会更新缓存
  • Read/Write Through:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:
    • 查询时:命中则直接返回,未命中则查询数据库并写入缓存
    • 更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据库
  • Write Behind Cahing:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库

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

缓存指的就是Redis,数据库指的就是例如MySQL、SQL Server这种传统数据库

Cache Aside的写操作是要在更新数据库的同时删除缓存,那为什么不选择更新数据库的同时更新缓存,而是删除呢?

原因很简单,假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是**延迟更新**,什么时候有人查询了,什么时候更新(相当于只有最后一次更新并查询的操作,才是有效更新)

增删改操作中,只有删除操作才需要将Redis和数据库同时进行,而增和改只有在查询时才会同步数据库数据

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

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

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

正常情况

c3667631-6771-42ce-aac6-9f7f01bc801c

异常情况

6b86e464-9dd2-49bb-a8a1-ca13846208f3

异常情况说明:

  • 线程1删除缓存后,还没来得及更新数据库,
  • 此时线程2来查询,发现缓存未命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
  • 然后线程1更新数据库,此时数据库是新数据,缓存是旧数据

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

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

正常情况

0f2898e0-30c5-4dbd-abab-d8dce83222ba

异常情况

ee43dc03-1653-43fc-892d-9b7ded54f345

异常情况说明:

  • 线程1查询缓存未命中,于是去查询数据库,查询到旧数据
  • 线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存
  • 线程1执行写入缓存的操作,写入旧数据

可以发现,异常状态发生的概率极为苛刻,线程1必须是查询数据库已经完成,但是缓存尚未写入之前。线程2要完成更新数据库同时删除缓存的两个操作。要知道线程1执行写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低

总结

综上,添加缓存的目的是为了提高系统性能,而要付出的代价就是缓存与数据库的强一致性。如果要求数据库与缓存的强一致,那就需要加锁避免并行读写。但这就降低了性能,与缓存的目标背道而驰。

因此不管任何缓存同步方案最终的目的都是尽可能保证最终一致性,降低发生不一致的概率。采用先更新数据库再删除缓存的方案,已经将这种概率降到足够低,目的已经达到了

缓存一致性策略的最佳实践方案:

  1. 低一致性需求:使用Redis的Key过期清理方案
  2. 高一致性需求:主动更新,并以超时剔除作为兜底方案
    • 读操作:
      • 缓存命中则直接返回
      • 缓存未命中则查询数据库,并写入缓存,设定超时时间
    • 写操作:
      • 先写入数据库,然后删除缓存
      • 要确保数据库与缓存操作的原子性

缓存穿透

当请求查询缓存未命中时,需要查询数据库以加载缓存。但是思考一下这样的场景:如果访问一个数据库中也不存在的数据。会出现什么现象?

由于数据库中不存在该数据,那么缓存中肯定也不存在。因此不管请求该数据多少次,缓存永远不可能建立,请求永远会直达数据库

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

常见的解决方案有两种:

  • 缓存空对象
  • 布隆过滤

缓存空对象

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

a6529a2d-a5c1-4797-ae3a-4d9ccd615dfd

优点:

  • 实现简单,维护方便(最常见的方案)

缺点:

  • 额外的内存消耗(所以需要设置TTL,且TTL值较小)

布隆过滤

布隆过滤是一种数据统计的算法,用于检索一个元素是否存在一个集合中

一般判断集合中是否存在元素,都会先把元素保存到类似于树、哈希表等数据结构中,然后利用这些结构查询效率高的特点来快速匹配判断。但是随着元素数量越来越多,这种模式对内存的占用也越来越大,检索的速度也会越来越慢。而布隆过滤的内存占用小,查询效率却很高(因为只需要判断数据是否存在,而不需要存储实际的数据)

布隆过滤首先需要一个很长的bit数组,默认数组中每一位都是0

91037001-1aea-4656-a8bd-14553d009cd6

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

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

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

0364ef4b-4a54-4349-b573-d6c77238bbb3

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

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

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

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

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

也正是因为不存的元素一定不存在,所以即使有恶意攻击不存在的数据,也不会导致缓存穿透

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

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

使用布隆过滤后的流程如下:

f1bdbf65-9d1f-46fa-aa65-a4f7d48f57a9

最完美的方案就是将布隆过滤和缓存空对象两者结合

  1. 大部分情况下,布隆过滤器都可以正确判断数据的存在或不存在
  2. 当发生误判时(也就是说布隆过滤器认为数据存在,但是实际数据不存在),数据库中不存在数据,则缓存空对象并设置TTL值。这样即使攻击者发现了可以引起哈希碰撞的值,也有兜底方案去防范
  3. 而又因为哈希碰撞的概率极低,所以缓存空对象的数据也不会太多,可以大大减小内存压力

但这种方案复杂度较高,适合大型企业或项目

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

e9c678ef-fd04-4d5f-849e-0f5e67f05346

常见的解决方案有:

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

以上方案互不冲突,所以可以结合使用

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个高并发访问并且缓存重建业务较复杂的Key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击

由于采用的是Cache Aside模式,当缓存失效时需要下次查询时才会更新缓存。当某个key缓存失效时,如果这个key是热点key,并发访问量比较高。就会在一瞬间涌入大量请求,都发现缓存未命中,于是都会去查询数据库,尝试重建缓存。可能一瞬间就把数据库压垮了

d2f35108-97a8-446f-bbd8-ed8035184f03

如上图所示:

  • 线程1发现缓存未命中,准备查询数据库,重建缓存,但是因为数据比较复杂,导致查询数据库耗时较久
  • 在这个过程中,一下次来了3个新的线程,就都会发现缓存未命中,都去查询数据库
  • 数据库压力激增

常见的解决方案有两种:

  • 互斥锁:给重建缓存逻辑加锁,避免多线程同时指向
  • 逻辑过期:热点key不要设置过期时间,在活动结束后手动删除

互斥锁

基于互斥锁的方案如图:

45f923bc-9121-4765-bfe2-bec9cc85b576

互斥锁最明显的问题就是,当某个线程获得互斥锁后,其他线程需要等待该线程将数据写入缓存,导致性能下降

逻辑过期

热点key不要设置过期时间,在活动结束后手动删除

逻辑过期的思路如图:

f6860545-15f9-4ac7-9cec-0eead72c61d5

当获取锁失败后,不会进行等待而是直接返回旧数据

方案总结

解决方案 优点 缺点
互斥锁 - 没有额外的内存消耗
- 保证一致性
- 实现简单
- 线程需要等待,性能受影响
- 可能有死锁风险
逻辑过期 - 线程无需等待,性能较好 - 不保证一致性
- 有额外内存消耗
- 实现复杂

缓存面试题总结

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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