分布式缓存
学习目标
- Redis持久化
- Redis主从
- Redis哨兵
- Redis分片集群
分布式缓存
单机Redis存在的问题
基于Redis集群解决单机Redis存在的问题 单机的Redis存在四大问题:

1.Redis持久化
1.1.RDB持久化 🍐
RDB持久化
Redis有两种持久化方案:
- RDB持久化
- AOF持久化

RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。
简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。
快照文件称为RDB文件,默认是保存在当前运行目录。
执行时机

RDB持久化在四种情况下会执行:
- 执行save命令
- 执行bgsave命令
- Redis停机时
- 触发RDB条件时
1)save命令
执行下面的命令,可以立即执行一次RDB:

save命令会导致主进程执行RDB,这个过程中其它所有命令都会被阻塞。只有在数据迁移时可能用到。
2)bgsave命令
下面的命令可以异步
执行RDB:

这个命令执行后会开启独立进程完成RDB,主进程
可以持续处理用户请求,不受影响
。
3)停机时
Redis停机时会执行一次save命令,实现RDB持久化。
4)触发RDB条件
Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:
# 900秒内,如果至少有1个key被修改,则执行bgsave , 如果是save "" 则表示禁用RDB
save 900 1
save 300 10
save 60 10000 60秒后,如果至少发生了10000次写入,则存一次
- 距离上一次写入操作多少秒后至少发生了多少次写入次数,则保存一次db
- 注意1:二者要必须同时满足,即写入次数和发生时间
- 比如距离上次写入过了59秒,在59秒内累积发生了9999次写入,那么在60秒的时候并不会发生写入操作,因为60秒的时候9999 < 10000,等到了300秒后,因为9999 > 100,条件都满足,所以会在距离上次写入300秒的时候发生一次写入db操作。
RDB的其它配置也可以在redis.conf文件中设置:
# 是否压缩 ,建议不开启,压缩也会消耗cpu,磁盘的话不值钱,看redis主机性能如何
rdbcompression yes
# RDB文件名称,可以修改,但是修改后,之前的文件无法加载
dbfilename dump.rdb
# 文件保存的路径目录,可以修改,默认当前文件夹下
dir ./
2.RDB原理

bgsave
开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入 RDB 文件。
fork采用的是copy-on-write
技术:
- 当主进程执行读操作时,访问共享内存;
- 当主进程执行写操作时,则会拷贝一份数据,执行写操作性能消耗。
小结
面试题:RDB方式bgsave的基本流程?
- fork主进程得到一个子进程,共享内存空间
- 子进程读取内存数据并写入新的RDB文件
- 用新RDB文件替换旧的RDB文件
面试题:RDB会在什么时候执行?save 60 1000代表什么含义?
- 默认是服务停止时
- 代表60秒内至少执行1000次修改则触发RDB
面试题:RDB的缺点?
- RDB执行间隔时间长,两次RDB之间写入数据有丢失的风险
- fork子进程、压缩、写出RDB文件都比较耗时
1.2.AOF持久化
AOF持久化
AOF原理
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

记录指令,以及指令的长度 如$3表示长度为3
AOF配置
AOF默认是关闭的 ,需要修改redis.conf
配置文件来开启AOF:
# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"
建议先关闭RDB 配置文件中,配置
save ""
AOF的命令记录的频率也可以通过redis.conf文件来配:
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
三种策略对比:🎯

3.AOF文件重写
因为是记录命令,AOF文件会比RDB文件大的多 。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。 通过执行bgrewriteaof命令
,可以让AOF文件执行重写功能,用最少的命令达到相同效果。减少体积

如图:AOF原本有三个命令,但是
set num 123 和 set num 666
都是对num的操作,第二次会覆盖第一次的值,因此第一个命令记录下来没有意义。 所以重写命令后,AOF文件内容就是:mset name jack num 666
Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
# AOF文件比上次文件 增长超过多少百分比则触发重写,翻一倍
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
平时使用默认配置即可
总结
面试题:RDB与AOF的区别
RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。

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

具体搭建流程参考课前资料:https://b11et3un53m.feishu.cn/wiki/Jck7w4GBSia4sukQn1vc9s3anMf

本节主要目的是理解主从架构的特点和存储数据,因此使用win系统快速搭建redis集群,方便演示:
课程资料:
- Redis_6379_master1 是主Redis,端口是6379
- 其余从主机,类似启动

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

这里有一个问题,master如何得知salve是第一次来连接呢??
有几个概念,可以作为判断依据:
- Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
- offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
因此slave做数据同步,必须向master声明自己的replication id 和offset,master才可以判断到底需要同步哪些数据。
因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。
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之间的同步

2.2.2.增量同步
全量同步需要先做RDB,然后将RDB文件通过网络传输个slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步。
什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:

那么master怎么知道slave与自己的数据差异在哪里呢?
2.2.3.repl_backlog原理
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继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖:

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

2.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时
面试题:如果master的从节点太多,压力很大,有什么优化措施?
3.Redis哨兵
3.1.哨兵原理
哨兵原理
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。
1.集群结构和作用
哨兵的结构如图:

哨兵的作用如下:
- 监控:Sentinel 会不断检查您的master和slave是否按预期工作
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
- 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端
2.集群监控原理
Sentinel基于心跳机制 ❤️监测服务状态,每隔1秒
向集群的每个实例发送ping命令:
主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

哨兵也是redis服务端
3.集群故障恢复原理
一旦发现master故障💔,sentinel需要在salve👷中选择一个作为新的master👸,选择依据是这样的:
- 1.首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
- 2.然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
- 3.如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
- 4.最后是判断slave节点的运行id大小,越小优先级越高。
当选出一个新的master后,该如何实现切换呢?
流程如下:👇
- sentinel给备选的slave1节点发送slaveof no one永不为奴命令,让该节点成为master
- sentinel给所有其它slave发送slaveof 192.168.150.101 7002 命令,让这些slave成为新master的从节点,开始从新的master上同步数据。
- 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点

小结
Sentinel的三个作用是什么?
- 监控
- 故障转移
- 通知
Sentinel如何判断一个redis实例是否健康?
- 每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线
- 如果大多数sentinel都认为实例主观下线,则判定服务下线
故障转移步骤有哪些?
- 首先选定一个slave作为新的master,执行slaveof no one
- 然后让所有节点都执行slaveof 新master
- 修改故障节点配置,添加slaveof 新master
3.2.搭建哨兵集群
搭建哨兵集群
具体搭建流程参考课前资料《Redis集群.md》:

搭建哨兵集群在企业生产过程中,主要是运维人员的职责,本章节主要目的是理解哨兵机制的运行流程和特点,因此使用现有的资料,在win下快速搭建哨兵集群: 👈 👈
案例结构:一主二从三哨兵

启动流程:
- 先启动1主二从:6379 6380 6381
- 修改哨兵配置,下图2
- 然后启动3哨兵:26379,26380,26381 下图3图很大,建议,右击新开窗口查看
资料说明:


参考上述步骤,修改另外2个哨兵配置文件

演示一下当主节点故障时,哨兵是如何完成集群故障恢复(failover)
总结
课堂作业
面试题:Sentinel的三个作用是什么?
- 集群监控
- 故障恢复
- 状态通知🎤
面试题:Sentinel如何判断一个redis实例是否健康?
- 每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线(sdown)
- 如果大多数sentinel都认为实例主观下线,则判定服务客观下线(odown)
面试题:故障转移步骤有哪些?
- 首先要在sentinel中选出一个leader,由leader执行failover
- 选定一个slave作为新的master,执行slaveof noone,切换到master模式
- 然后让所有节点都执行slaveof 新master
- 修改故障节点配置,添加slaveof 新master
面试题:sentinel选举leader的依据是什么?
- 票数超过sentinel节点数量1半
- 票数超过quorum数量
- 一般情况下最先发起failover的节点会当选
面试题:sentinel从slave中选取master的依据是什么?
- 首先会判断slave节点与master节点断开时间长短,如果超过down-after-milliseconds * 10则会排除该slave节点
- 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举(默认都是1)。
- 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
- 最后是判断slave节点的run_id大小,越小优先级越高(通过info server可以查看run_id)。
选学,自学
3.3.RedisTemplate连接哨兵集群RedisTemplate连接哨兵集群
在Sentinel集群监管下的Redis主从集群,其节点会因为自动故障转移而发生变化,Redis的客户端必须感知这种变化,及时更新连接信息。Spring的RedisTemplate底层利用lettuce实现了节点的感知和自动切换。
下面,我们通过一个测试来实现RedisTemplate集成哨兵机制。
代码操作
3.3.1.导入Demo工程
首先,我们引入课前资料提供的Demo工程:

3.3.2.引入依赖
在项目的pom文件中引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
3.3.3.配置Redis地址
然后在配置文件application.yml中指定redis的sentinel相关信息:
spring:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.150.101:27001
- 192.168.150.101:27002
- 192.168.150.101:27003
3.3.4.配置读写分离
在项目的启动类中,添加一个新的bean:
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
这个bean中配置的就是读写策略,包括四种:
- MASTER:从主节点读取
- MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
- REPLICA:从slave(replica)节点读取
- REPLICA _PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master
4.Redis分片集群
4.1.搭建分片集群
搭建分片集群

主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
- 海量数据存储问题
- 高并发写的问题
使用分片集群可以解决上述问题,如图:

分片集群特征:
- 集群中有多个master,每个master保存不同分片数据 ,解决海量数据存储问题
- 每个master都可以有多个slave节点 ,确保高可用
- master之间通过ping监测彼此健康状态 ,类似哨兵作用
- 客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点
具体搭建流程参考课前资料《Redis集群.md》:

搭建分片集群在企业生产过程中,主要是运维人员的职责,本章节主要目的是理解分片集群的运行流程和特征,因此使用现有的资料,在win下快速搭建分片集群: 👈 👈
案例结构:三主三从
启动流程:
- 修改配置配置,下图:
资料3
资料4
图 - 然后一次性启动6个redis节点,分别为6380--6385下图:
图5
图很大,建议,右击新开窗口查看
此时只有6个单独的节点,还没有形成集群,没有主从之分
- 集群搭建,在
Redis-x64-5.0.14.1
目录下(start6380.bat文件同级目录)图6-图7,cmd运行以下命令:
redis-cli --cluster create 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 127.0.0.1:6385 --cluster-replicas 1
启动好之后开始创建集群模式。redis-cli很方便。
cluster-replicas 1
的意思是 为每个master创建一个副本
- 查看集群状态 和节点状态 图8-图9
# 连接任何一个节点 -c 就是集群的意思 cluster info 是查看集群状态
redis-cli -h 127.0.0.1 -p 6380 -c
cluster info
# 查看节点信息
cluster nodes
- 插入和获取数据图10
# 连接任何一个节点 -c 就是集群的意思 cluster info 是查看集群状态
redis-cli -h 127.0.0.1 -p 6380 -c
# 插入数据
set name yangeit
结果:
127.0.0.1:6380> set name yangeit
-> Redirected to slot [5798] located at 127.0.0.1:6381
OK
#slot 插槽
# 发现name=yangeit 数据并没有在6380的节点,而是在6381的节点
# 登录到6384节点
D:\javasoftware\redisnodes\fengpian\Redis-x64-5.0.14.1>redis-cli -h 127.0.0.1 -p 6384 -c
127.0.0.1:6384>
# 执行get name指令
127.0.0.1:6384> get name
-> Redirected to slot [5798] located at 127.0.0.1:6381
"yangeit"
#结果发现:重定向到了6381节点获得了yangeit数据
- 知识拓展(如果没有设置密码,略)
## 设置密码
config set requirepass "密码"
##回车后,输入以下命令:
AUTH "密码"
## 获取密码
config get requirepass










总结
课堂作业
- 面试题:分片集群特征有哪些?🎤
4.2.散列插槽
散列插槽
Redis插槽(Redis Slots)是用于分片的概念,它在Redis集群中用于将数据均匀分散到多个Redis节点上。每个插槽对应一个特定的数据范围,Redis使用哈希槽(Hash Slot)算法来确定键(Key)应该存储在哪个插槽中。
Redis的插槽机制允许横向扩展和分布式数据存储,以提高性能和可扩展性。
每个Redis集群节点都负责维护一部分插槽,并负责处理与这些插槽相关的键。这使得Redis集群能够处理大量的数据,而不会因单一节点的容量限制而导致性能下降。
关于Redis插槽的一些要点:
- Redis插槽的数量是固定的,默认有
16384
个插槽(0-16383)。 - 插槽的分配是自动的,Redis会尝试平均分配插槽到可用的节点上。
- 插槽的分配信息可以使用CLUSTER SLOTS命令查看。
- Redis的插槽机制允许在集群中添加或删除节点,以进行横向扩展或缩减。
- 添加或删除插槽的数量通常需要对Redis集群进行重新分片,这可能需要一些操作。
关于增加插槽的数量,Redis的插槽数量是在集群初始化时固定的,通常情况下不建议更改插槽的数量,因为它会涉及到对整个集群的重新分片操作,可能导致数据的迁移和集群的不稳定。
插槽原理
Redis会把每一个master节点映射到0~16383共16384个插槽(hash slot)上,查看集群信息时就能看到:

数据key不是与节点绑定,而是与插槽绑定 。redis会根据key的有效部分计算插槽值,分两种情况:
- key中包含"{}",且“{}”中至少包含1个字符,“{}”中的部分是有效部分
- key中不包含“{}”,整个key都是有效部分
例如:key是name,那么就根据name计算,如果是{huyan}num,则根据huyan计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值。

如图,在6380这个节点执行set name yangeit时,对name做hash运算,对16384取余,得到的结果是5798,因此要存储到6381节点。
到了6384后,执行get name
时,对num做hash运算,对16384取余,得到的结果是5798,因此重定向到6381

总结
Redis如何判断某个key应该在哪个实例?
- 将16384个插槽分配到不同的实例
- 根据key的有效部分计算哈希值,对16384取余
- 余数作为插槽,寻找插槽所在实例即可
如何将同一类数据固定的保存在同一个Redis实例?
- 这一类数据使用相同的有效部分,例如key都以
{typeId}
为前缀
选学,可以自行学习哦
4.3.集群伸缩集群伸缩
redis-cli --cluster提供了很多操作集群的命令,可以通过下面方式查看:

比如,添加节点的命令:

--cluster-slave 身份定了 -- cluster-master_id 是谁的奴隶
需求分析
需求:向集群中添加一个新的master节点,并向其中存储 num = 10
- 启动一个新的redis实例,端口为6386
- 添加6386到之前的集群,并作为一个master节点
- 给6386节点分配插槽,使得num这个key可以存储到6386实例
这里需要两个新的功能:
- 添加一个节点到集群中
- 将部分插槽分配到新插槽
代码操作
接下来在win11的系统下操作
2.创建新的redis实例
- 查看当前实例
redis-cli.exe -p 6380 cluster nodes

2.创建一个将任何一个文件夹复制一下 6386,该问:

- 修改配置文件:

配置文件名字,也改成6386
4.编写启动脚本---启动


目前还只有6个节点
3.添加新节点到redis
添加节点的语法如下:

执行命令:
redis-cli.exe --cluster add-node 127.0.0.1:6386 127.0.0.1:6385

通过命令查看集群状态:
redis-cli.exe -p 6385 cluster nodes
如图,6386加入了集群,并且默认是一个master节点:

但是,可以看到6386节点的插槽数量为0,因此没有任何数据可以存储到6386上
选学,可以自行学习哦
4.转移插槽我们要将num存储到6386节点,因此需要先看看num的插槽是多少:

如上图所示,num的插槽为2765.
我们可以将0~3000的插槽从6380转移到6386,命令格式如下:

具体命令如下:
建立连接:
redis-cli.exe --cluster reshard 127.0.0.1:6380
得到下面的反馈:

询问要移动多少个插槽,我们计划是3000个:
新的问题来了:

那个node来接收这些插槽??
显然是6386,那么6386节点的id是多少呢?

复制这个id,然后拷贝到刚才的控制台后:

这里询问,你的插槽是从哪里移动过来的?
- all:代表全部,也就是三个节点各转移一部分
- 具体的id:目标节点的id
- done:没有了
这里我们要从6380获取,因此填写6380的id:

填完后,点击done,这样插槽转移就准备好了:

确认要转移吗?输入yes:
然后,通过命令查看结果:

目的达成。
4.4.故障转移
故障转移
集群初识状态是这样的:

其中6380,6386,6381,6382都是master,我们计划让6382宕机。
代码操作
1.自动故障转移
当集群中有一个master宕机会发生什么呢?
直接停止一个redis实例,例如6382:
直接差叼窗口
1)首先是该实例与其它实例失去连接
2)然后是疑似宕机:

3)最后是确定下线,自动提升一个slave为新的master:

4)当6382再次启动,就会变为一个slave节点了:

选学,可以参考下面的步骤学习哦
2.手动故障转移利用cluster failover命令可以手动让集群中的某个master宕机,切换到执行cluster failover命令的这个slave节点,实现无感知的数据迁移。其流程如下:

这种failover命令可以指定三种模式:
- 缺省:默认的流程,如图1~6歩
- force:省略了对offset的一致性校验
- takeover:直接执行第5歩,忽略数据一致性、忽略master状态和其它master的意见
案例需求:在6382这个slave节点执行手动故障转移,重新夺回master地位
步骤如下:
1)利用redis-cli连接6382这个节点
2)执行cluster failover命令
如图:
redis-cli -p 6382
CLUSTER FAILOVER
效果:

选学,自行学习
4.5.RedisTemplate访问分片集群RedisTemplate访问分片集群
RedisTemplate底层同样基于lettuce实现了分片集群的支持,而使用的步骤与哨兵模式基本一致:
- 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
在项目的启动类中,添加一个新的bean:
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
5.Redis 数据结构
RedisObject
我们常用的 Redis 数据类型有 5 种,分别是:
- String
- List
- Set
- SortedSet
- Hash
还有一些高级数据类型,比如 Bitmap、HyperLogLog、GEO 等,其底层都是基于上述 5 种基本数据类型。因此在 Redis 的源码中,其实只有 5 种数据类型。
5.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 专业课程
5.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 则是节点存储的字符串数据指针。
其内存结构如下:

5.3.SortedSet了解
Redis 源码中 zset
,也就是 SortedSet
的结构体如下:
typedef struct zset {
dict *dict; // dict,底层就是HashTable
zskiplist *zsl; // 跳表
} zset;
其内存结构如图:

总结
课堂作业
- 为什么Redis中使用SkipList(跳表)?🎤
- 在 Redis 的源码中,有几种数据类型?
6.Redis 内存回收
内存回收
前言
Redis 之所以性能强,最主要的原因就是基于内存存储。然而单节点的 Redis 其内存大小不宜过大,会影响持久化或主从同步性能。
我们可以通过修改 redis.conf 文件,添加下面的配置来配置 Redis 的最大内存:
maxmemory 1gb
当内存达到上限,就无法存储更多数据了。因此,Redis 内部会有两套内存回收的策略:
- 内存过期策略
- 内存淘汰策略
6.1.内存过期处理
存入 Redis 中的数据可以配置过期时间,到期后再次访问会发现这些数据都不存在了,也就是被过期清理了。
1️⃣ 过期命令
Redis 中通过 expire
命令可以给 KEY 设置 TTL
(过期时间),例如:
# 写入一条数据
set num 123
# 设置20秒过期时间
expire num 20
不过 set 命令本身也可以支持过期时间的设置:
# 写入一条数据并设置20s过期时间
set num EX 20
当过期时间到了以后,再去查询数据,会发现数据已经不存在。
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 并不会在 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%,再进行一次抽样,否则结束
6.2.内存淘汰策略
对于某些特别依赖于 Redis 的项目而言,仅仅依靠过期 KEY 清理是不够的,内存可能很快就达到上限。因此 Redis 允许设置内存告警阈值,当内存使用达到阈值时就会主动挑选部分 KEY 删除以释放更多内存。这叫做内存淘汰机制。
1️⃣ 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;
}
}
}
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
Recently
Used
),最近最久未使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。 - LFU(L
east
Frequently
Used
),最少频率使用。会统计每个 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
。
- 浅灰色带是被驱逐的对象
- 灰色带是没有被驱逐的对象
- 绿色带是被添加的对象
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 不要设置过期时间,在活动结束后手动删除。
基于互斥锁的方案如图:

逻辑过期的思路如图:

其他面试题
初级级别:
- 什么是Redis的持久化,为什么我们需要持久化?
- 请解释Redis的主从架构,主节点和从节点各自承担什么角色?
- 如何进行Redis的数据备份?
- 什么是Redis的RDB快照持久化,它的优点和缺点是什么?
- 什么是Redis的AOF持久化,它的优点和缺点是什么?
中级级别:
- 请解释Redis主从复制的同步原理,包括全同步和增量同步。
- 什么是Redis哨兵机制,它的作用是什么,如何配置和使用?
- 请讨论Redis的分片集群是如何工作的,有什么优势和挑战?
- 什么是散列插槽(Hash Slot),它在Redis集群中的作用是什么?
- 在Redis集群中,如何进行集群的伸缩(扩展或缩减集群规模)?
高级级别:
- Redis的RDB快照和AOF持久化在哪些场景下更适合使用,如何选择合适的持久化方式?
- 在Redis主从复制中,如果主节点宕机,会如何处理,从节点如何晋升为主节点?
- Redis哨兵机制的工作原理是什么,如何配置多个哨兵来实现高可用性?
- Redis集群中的数据分片和散列插槽是如何实现的,有什么注意事项?
- 请解释Redis的数据分片策略,例如一致性哈希和哈希槽分片。