# Redis 面试篇

# Redis 主从

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

# 主从集群结构

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

img

如图所示,集群中有一个 master 节点、两个 slave 节点(现在叫 replica)。当我们通过 Redis 的 Java 客户端访问主从集群时,应该做好路由:

  • 如果是写操作,应该访问 master 节点,master 会自动将数据同步给两个 slave 节点
  • 如果是读操作,建议访问各个 slave 节点,从而分担并发压力

# 主从同步原理

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

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

# 全量同步

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

img

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

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

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

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

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

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

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

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

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

img

完整流程描述:

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

# 增量同步

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

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

img

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

# repl_baklog 原理

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

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

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

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 做增量同步,只能再次全量同步。

# 主从同步优化

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

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

  • 在 master 中配置 repl-diskless-sync yes 启用无磁盘复制,避免全量同步时的磁盘 IO。
  • Redis 单节点上的内存占用不要太大,减少 RDB 导致的过多磁盘 IO
  • 适当提高 repl_baklog 的大小,发现 slave 宕机时尽快实现故障恢复,尽可能避免全量同步
  • 限制一个 master 上的 slave 节点数量,如果实在是太多 slave,则可以采用 主-从-从 链式结构,减少 master 压力

主-从-从 架构图:

img

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

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

什么时候执行全量同步?

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

什么时候执行增量同步?

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

# Redis 哨兵

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

# 哨兵工作原理

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

# 哨兵作用

哨兵集群作用原理图:

img

哨兵的作用如下:

  • 状态监控Sentinel 会不断检查您的 masterslave 是否按预期工作
  • 故障恢复(failover):如果 master 故障, Sentinel 会将一个 slave 提升为 master 。当故障实例恢复后会成为 slave
  • 状态通知Sentinel 充当 Redis 客户端的服务发现来源,当集群发生 failover 时,会将最新集群信息推送给 Redis 的客户端

那么问题来了, Sentinel 怎么知道一个 Redis 节点是否宕机呢?

# 状态监控

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

  • 主观下线(sdown):如果某 sentinel 节点发现某 Redis 节点未在规定时间响应,则认为该节点主观下线。
  • 客观下线 (odown):若超过指定数量(通过 quorum 设置)的 sentinel 都认为该节点主观下线,则该节点客观下线。quorum 值最好超过 Sentinel 节点数量的一半,Sentinel 节点数量至少 3 台。

如图:

img

一旦发现 master 故障,sentinel 需要在 salve 中选择一个作为新的 master,选择依据是这样的:

  • 首先会判断 slave 节点与 master 节点断开时间长短,如果超过 down-after-milliseconds * 10 则会排除该 slave 节点
  • 然后判断 slave 节点的 slave-priority 值,越小优先级越高,如果是 0 则永不参与选举(默认都是 1)。
  • 如果 slave-prority 一样,则判断 slave 节点的 offset 值,越大说明数据越新,优先级越高
  • 最后是判断 slave 节点的 run_id 大小,越小优先级越高( 通过info server可以查看run_id )。

对应的官方文档如下:

https://redis.io/docs/management/sentinel/#replica-selection-and-priority

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

大概分为两步:

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

# 选举 leader

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

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

而 sentinel 投票的原则有两条:

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

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

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

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

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

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

# failover

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

img

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

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

img

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

img

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

img

# 总结

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 的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

# 配置哨兵地址

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

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 即可:

@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 分片集群的结构如图:

img

分片集群特征:

  • 集群中有多个 master,每个 master 保存不同分片数据 ,解决海量数据存储问题
  • 每个 master 都可以有多个 slave 节点 ,确保高可用
  • master 之间通过 ping 监测彼此健康状态 ,类似哨兵作用
  • 客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点

# 散列插槽

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

详见官方文档:

https://redis.io/docs/management/scaling/#redis-cluster-101

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

img

如图中所示:

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

当我们读写数据时,Redis 基于 CRC16 算法对 keyhash 运算,得到的结果与 16384 取余,就计算出了这个 keyslot 值。然后到 slot 所在的 Redis 节点执行读写操作。

不过 hash slot 的计算也分两种情况:

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

例如:

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

我们来测试一下,先于 7001 建立连接:

# 进入容器
docker exec -it r1 bash
# 进入redis-cli
redis-cli -p 7001
# 测试
set user jack

会发现报错了:

img

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

说好的任意节点都可以读写呢?

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

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

结果如下:

img

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

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

# 试一下key中带{}
set user:{age} 21

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

结果如下:

img

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

# 故障转移

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

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

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

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

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

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

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

img

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

img

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

# 总结

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

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

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

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

# Java 客户端连接分片集群

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

1)引入 redis 的 starter 依赖

2)配置分片集群地址

3)配置读写分离

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

spring:
  redis:
    cluster:
      nodes:
        - 192.168.150.101:7001
        - 192.168.150.101:7002
        - 192.168.150.101:7003
        - 192.168.150.101:8001
        - 192.168.150.101:8002
        - 192.168.150.101:8003

# Redis 数据结构

我们常用的 Redis 数据类型有 5 种,分别是:

  • String
  • List
  • Set
  • SortedSet
  • Hash

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

# RedisObject

不管是任何一种数据类型,最终都会封装为 RedisObject 格式,它是一种结构体,C 语言中的一种结构,可以理解为 Java 中的类。

结构大概是这样的:

img

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

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

属性中的 encoding 就是当前对象底层采用的数据结构编码方式,可选的有 11 种之多:

编号编码方式说明
0OBJ_ENCODING_RAWraw 编码动态字符串
1OBJ_ENCODING_INTlong 类型的整数的字符串
2OBJ_ENCODING_HThash 表(也叫 dict)
3OBJ_ENCODING_ZIPMAP已废弃
4OBJ_ENCODING_LINKEDLIST双端链表
5OBJ_ENCODING_ZIPLIST压缩列表
6OBJ_ENCODING_INTSET整数集合
7OBJ_ENCODING_SKIPLIST跳表
8OBJ_ENCODING_EMBSTRembstr 编码的动态字符串
9OBJ_ENCODING_QUICKLIST快速列表
10OBJ_ENCODING_STREAMStream 流
11OBJ_ENCODING_LISTPACK紧凑列表

Redis 中的 5 种不同的数据类型采用的底层数据结构和编码方式如下:

数据类型编码方式
STRINGintembstrraw
LISTLinkedList和ZipList (3.2 以前)、 QuickList (3.2 以后)
SETintsetHT
ZSETZipList (7.0 以前)、 Listpack (7.0 以后)、 HTSkipList
HASHZipList (7.0 以前)、 Listpack (7.0 以后)、 HT

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

https://b11et3un53m.feishu.cn/wiki/Jck7w4GBSia4sukQn1vc9s3anMf#U9EadvPvAoXS2Fx8ziDcjT57npf

# SkipList

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

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

传统链表只有指向前后元素的指针,因此只能顺序依次访问。如果查找的元素在链表中间,查询的效率会比较低。而 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,刚好于目标一致,找到。

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

跳表的结构体如下:

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 则是节点存储的字符串数据指针。

其内存结构如下:

img

# SortedSet

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

:SortedSet 是有序集合,底层的存储的每个数据都包含 element 和 score 两个值。score 是得分,element 则是字符串值。SortedSet 会根据每个 element 的 score 值排序,形成有序集合。

它支持的操作很多,比如:

  • 根据 element 查询 score 值
  • 按照 score 值升序或降序查询 element

要实现根据 element 查询对应的 score 值,就必须实现 element 与 score 之间的键值映射。SortedSet 底层是基于 HashTable 来实现的。

要实现对 score 值排序,并且查询效率还高,就需要有一种高效的有序数据结构,SortedSet 是基于跳表实现的。

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

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

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

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

其内存结构如图:

img

# Redis 内存回收

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

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

maxmemory 1gb

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

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

# 内存过期处理

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

# 过期命令

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

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

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

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

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

# 过期策略

那么问题来了:

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

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

来看下 RedisDB 的底层源码:

typedef struct redisDb {
    dict dict;                 / The keyspace for this DB , 也就是存放KEY和VALUE的哈希表*/
    dict *expires;              /* 同样是哈希表,但保存的是设置了TTL的KEY,及其到期时间*/
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS /
    int id;                     / Database ID, 0 ~ 15 /
    long long avg_ttl;          / Average TTL, just for stats /
    unsigned long expires_cursor; / Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

现在回答第一个问题:

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

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

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

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

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

  • 惰性删除
  • 周期删除

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

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

// db.c
// 寻找要执行写操作的key
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
    // 检查key是否过期,如果过期则删除
    expireIfNeeded(db,key);
    return lookupKey(db,key,flags);
}

// 寻找要执行读操作的key
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
    robj *val;
    // 检查key是否过期,如果过期则删除
    if (expireIfNeeded(db,key) == 1) {
        // 略 ...
    }
    val = lookupKey(db,key,flags);
    if (val == NULL)
        goto keymiss;
    server.stat_keyspace_hits++;
    return val;
}

周期删除:顾明思议是通过一个定时任务,周期性的抽样部分过期的 key,然后执行删除。

执行周期有两种:

  • **SLOW 模式:**Redis 会设置一个定时任务 serverCron() ,按照 server.hz 的频率来执行过期 key 清理
  • **FAST 模式:**Redis 的每个事件循环前执行过期 key 清理(事件循环就是 NIO 事件处理的循环)。

SLOW 模式规则:

  • ① 执行频率受 server.hz 影响,默认为 10,即每秒执行 10 次,每个执行周期 100ms。
  • ② 执行清理耗时不超过一次执行周期的 25%,即 25ms.
  • ③ 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期
  • ④ 如果没达到时间上限(25ms)并且过期 key 比例大于 10%,再进行一次抽样,否则结束

FAST 模式规则(过期 key 比例小于 10% 不执行):

  • ① 执行频率受 beforeSleep() 调用频率影响,但两次 FAST 模式间隔不低于 2ms
  • ② 执行清理耗时不超过 1ms
  • ③ 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期
  • ④ 如果没达到时间上限(1ms)并且过期 key 比例大于 10%,再进行一次抽样,否则结束

# 内存淘汰策略

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

# 内存淘汰时机

那么问题来了,当内存达到阈值时执行内存淘汰,但问题是 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;
        }
    }
}

# 淘汰策略

好了,知道什么时候尝试淘汰了,那具体 Redis 是如何判断该淘汰哪些 Key 的呢?

Redis 支持 8 种不同的内存淘汰策略:

  • noeviction : 不淘汰任何 key,但是内存满时不允许写入新数据,默认就是这种策略。
  • volatile-ttl : 对设置了 TTL 的 key,比较 key 的剩余 TTL 值,TTL 越小越先被淘汰
  • allkeys-random :对全体 key ,随机进行淘汰。也就是直接从 db->dict 中随机挑选
  • volatile-random :对设置了 TTL 的 key ,随机进行淘汰。也就是从 db->expires 中随机挑选。
  • allkeys-lru : 对全体 key,基于 LRU 算法进行淘汰
  • volatile-lru : 对设置了 TTL 的 key,基于 LRU 算法进行淘汰
  • allkeys-lfu : 对全体 key,基于 LFU 算法进行淘汰
  • volatile-lfu : 对设置了 TTL 的 key,基于 LFI 算法进行淘汰

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

  • LRU L east R ecently U sed ),最近最久未使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
  • LFU L east F requently U sed ),最少频率使用。会统计每个 key 的访问频率,值越小淘汰优先级越高。

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

还记不记得之前讲过的 RedisObject 的结构?

回忆一下:

img

其中的 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 的结果对比:

img

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

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

# 总结

面试题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 模式,因为实现起来非常简单。但缺点也很明显,就是无法保证数据库与缓存的强一致性。为什么呢?我们一起来分析一下。

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

原因很简单,假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。

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

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

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

正常情况

img

异常情况

img

异常情况说明:

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

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

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

正常情况

img

异常情况

img

异常情况说明:

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

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

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

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

同时我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。

# 缓存穿透

什么是缓存穿透呢?

我们知道,当请求查询缓存未命中时,需要查询数据库以加载缓存。但是大家思考一下这样的场景:

如果我访问一个数据库中也不存在的数据。会出现什么现象?

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

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

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

  • 缓存空值
  • 布隆过滤器

# 缓存空值

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

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

# 缓存雪崩

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

img

常见的解决方案有:

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

# 缓存击穿

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

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

img

如上图所示:

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

常见的解决方案有两种:

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

基于互斥锁的方案如图:

img

逻辑过期的思路如图:

img

# 面试总结

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# 微服务面试篇

基于 SpringCloud 的组件分类,主要包括:

  • 分布式事务
  • 注册中心
  • 远程调用
  • 服务保护

# 分布式事务

分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务,例如:

  • 跨数据源的分布式事务
  • 跨服务的分布式事务
  • 综合情况

我们之前解决分布式事务问题是直接使用 Seata 框架的 AT 模式,但是解决分布式事务问题的方案远不止这一种。

# CAP 定理

解决分布式事务问题,需要一些分布式系统的基础知识作为理论指导,首先就是 CAP 定理。

1998 年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:

  • Consistency(一致性)
  • Availability(可用性)
  • Partition tolerance (分区容错性)

它们的第一个字母分别是 CAP 。Eric Brewer 认为任何分布式系统架构方案都不可能同时满足这 3 个目标,这个结论就叫做 CAP 定理。

为什么呢?

# 一致性

Consistency (一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。

比如现在包含两个节点,其中的初始数据是一致的:

image-20250701145608465

当我们修改其中一个节点的数据时,两者的数据产生了差异:

image-20250701145659049

要想保住一致性,就必须实现 node01 到 node02 的数据 同步:

image-20250701145740534

# 可用性

Availability (可用性):用户访问分布式系统时,读或写操作总能成功。

只能读不能写,或者只能写不能读,或者两者都不能执行,就说明系统弱可用或不可用。

# 分区容错

Partition ,就是分区,就是当分布式系统节点之间出现网络故障导致节点之间无法通信的情况:

image-20250701145802457

如上图,node01 和 node02 之间网关畅通,但是与 node03 之间网络断开。于是 node03 成为一个独立的网络分区;node01 和 node02 在一个网络分区。

Tolerance ,就是容错,即便是系统出现网络分区,整个系统也要持续对外提供服务。

# 矛盾

在分布式系统中,网络不能 100% 保证畅通,也就是说网络分区的情况一定会存在。而我们的系统必须要持续运行,对外提供服务。所以分区容错性( P )是硬性指标,所有分布式系统都要满足。而在设计分布式系统时要取舍的就是一致性( C )和可用性( A )了。

假如现在出现了网络分区,如图:

image-20250701145848995

由于网络故障,当我们把数据写入 node01 时,可以与 node02 完成数据同步,但是无法同步给 node03。现在有两种选择:

  • 允许用户任意读写,保证可用性。但由于 node03 无法完成同步,就会出现数据不一致的情况。满足 AP
  • 不允许用户写,可以读,直到网络恢复,分区消失。这样就确保了一致性,但牺牲了可用性。满足 CP

可见,在分布式系统中, AC 之间只能满足一个。

# BASE 理论

既然分布式系统要遵循 CAP 定理,那么问题来了,我到底是该牺牲一致性还是可用性呢?如果牺牲了一致性,出现数据不一致该怎么处理?

人们在总结系统设计经验时,最终得到了一些心得:

  • Basically Available 基本可用:分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
  • Soft State**(软状态):** 在一定时间内,允许出现中间状态,比如临时的不一致状态。
  • Eventually Consistent**(最终一致性)**:虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

以上就是 BASE 理论。

简单来说,BASE 理论就是一种取舍的方案,不再追求完美,而是最终达成目标。因此解决分布式事务的思想也是这样,有两个方向:

  • AP 思想:各个子事务分别执行和提交,无需锁定数据。允许出现结果不一致,然后采用弥补措施恢复,实现最终一致即可。例如 AT 模式就是如此
  • CP 思想:各个子事务执行后不要提交,而是等待彼此结果,然后同时提交或回滚。在这个过程中锁定资源,不允许其它人访问,数据处于不可用状态,但能保证一致性。例如 XA 模式

# AT 模式的脏写问题

我们先回顾一下 AT 模式的流程,AT 模式也分为两个阶段:

第一阶段是记录数据快照,执行并提交事务:

image-20250701150011759

第二阶段根据阶段一的结果来判断:

  • 如果每一个分支事务都成功,则事务已经结束(因为阶段一已经提交),因此删除阶段一的快照即可
  • 如果有任意分支事务失败,则需要根据快照恢复到更新前数据。然后删除快照

image-20250701150041402

这种模式在大多数情况下(99%)并不会有什么问题,不过在极端情况下,特别是多线程并发访问 AT 模式的分布式事务时,有可能出现脏写问题,如图:

image-20250701150110531

解决思路就是引入了全局锁的概念。在释放 DB 锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据。

image-20250701150139422

具体可以参考官方文档:

https://seata.io/zh-cn/docs/dev/mode/at-mode.html

# TCC 模式

TCC 模式与 AT 模式非常相似,每阶段都是独立事务,不同的是 TCC 通过人工编码来实现数据恢复。需要实现三个方法:

  • try :资源的检测和预留;
  • confirm :完成资源操作业务;要求 try 成功 confirm 一定要能成功。
  • cancel :预留资源释放,可以理解为 try 的反向操作。

# 流程分析

举例,一个扣减用户余额的业务。假设账户 A 原来余额是 100,需要余额扣减 30 元。

阶段一( Try ):检查余额是否充足,如果充足则冻结金额增加 30 元,可用余额扣除 30

初始余额:

image-20250701150205864

余额充足,可以冻结:

image-20250701150226472

此时,总金额 = 冻结金额 + 可用金额,数量依然是 100 不变。事务直接提交无需等待其它事务。

阶段二(Confirm):假如要提交(Confirm),之前可用金额已经扣减,并转移到冻结金额。因此可用金额不变,直接冻结金额扣减 30 即可:

image-20250701150251239

此时,总金额 = 冻结金额 + 可用金额 = 0 + 70 = 70 元

阶段二 (Canncel):如果要回滚(Cancel),则释放之前冻结的金额,也就是冻结金额扣减 30,可用余额增加 30

image-20250701150312296

# 事务悬挂和空回滚

假如一个分布式事务中包含两个分支事务,try 阶段,一个分支成功执行,另一个分支事务阻塞

image-20250701150333370

如果阻塞时间太长,可能导致全局事务超时而触发二阶段的 cancel 操作。两个分支事务都会执行 cancel 操作:

image-20250701150355661

要知道,其中一个分支是未执行 try 操作的,直接执行了 cancel 操作,反而会导致数据错误。因此,这种情况下,尽管 cancel 方法要执行,但其中不能做任何回滚操作,这就是空回滚

对于整个空回滚的分支事务,将来 try 方法阻塞结束依然会执行。但是整个全局事务其实已经结束了,因此永远不会再有 confirm 或 cancel,也就是说这个事务执行了一半,处于悬挂状态,这就是业务悬挂问题。

以上问题都需要我们在编写 try、cancel 方法时处理。

# 总结

TCC 模式的每个阶段是做什么的?

  • Try:资源检查和预留
  • Confirm:业务执行和提交
  • Cancel:预留资源的释放

TCC 的优点是什么?

  • 一阶段完成直接提交事务,释放数据库资源,性能好
  • 相比 AT 模型,无需生成快照,无需使用全局锁,性能最强
  • 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库

TCC 的缺点是什么?

  • 有代码侵入,需要人为编写 try、Confirm 和 Cancel 接口,太麻烦
  • 软状态,事务是最终一致
  • 需要考虑 Confirm 和 Cancel 的失败情况,做好幂等处理、事务悬挂和空回滚处理

# 注册中心

本章主要学习 Nacos 中的一些特性和原理,以及与 Eureka 的功能对比。

# 环境隔离

企业实际开发中,往往会搭建多个运行环境,例如:

  • 开发环境
  • 测试环境
  • 预发布环境
  • 生产环境

这些不同环境之间的服务和数据之间需要隔离。

还有的企业中,会开发多个项目,共享 nacos 集群。此时,这些项目之间也需要把服务和数据隔离。

因此,Nacos 提供了基于 namespace 的环境隔离功能。具体的隔离层次如图所示:

image-20250701150426072

说明:

  • Nacos 中可以配置多个 namespace ,相互之间完全隔离。默认的 namespace 名为 public
  • namespace 下还可以继续分组,也就是 group ,相互隔离。 默认的 group 是 DEFAULT_GROUP
  • group 之下就是服务和配置了

# 创建 namespace

nacos 提供了一个默认的 namespace ,叫做 public

image-20250701150452450

默认所有的服务和配置都属于这个 namespace ,当然我们也可以自己创建新的 namespace

image-20250701150511504

然后填写表单:

image-20250701150529340

添加完成后,可以在页面看到我们新建的 namespace ,并且 Nacos 为我们自动生成了一个命名空间 id:

image-20250701150547056

我们切换到配置列表页,你会发现 dev 这个命名空间下没有任何配置:

image-20250701150603795

因为之前我们添加的所有配置都在 public 下:

image-20250701150620744

# 微服务配置 namespace

默认情况下,所有的微服务注册发现、配置管理都是走 public 这个命名空间。如果要指定命名空间则需要修改 application.yml 文件。

比如,我们修改 item-service 服务的 bootstrap.yml 文件,添加服务发现配置,指定其 namespace

spring:
  application:
    name: item-service # 服务名称
  profiles:
    active: dev
  cloud:
    nacos:
      server-addr: 192.168.150.101 # nacos地址
      discovery: # 服务发现配置
        namespace: 8c468c63-b650-48da-a632-311c75e6d235 # 设置namespace,必须用id
      # 。。。略

启动 item-service ,查看服务列表,会发现 item-service 出现在 dev 下:

image-20250701150641144

而其它服务则出现在 public 下:

image-20250701150658181

此时访问 http://localhost:8082/doc.html ,基于 swagger 做测试:

image-20250701150718278

会发现查询结果中缺少商品的最新价格信息。

我们查看服务运行日志:

image-20250701150738359

会发现 cart-service 服务在远程调用 item-service 时,并没有找到可用的实例。这证明不同 namespace 之间确实是相互隔离的,不可访问。

当我们把 namespace 切换回 public ,或者统一都是以 dev 时访问恢复正常。

# 分级模型

在一些大型应用中,同一个服务可以部署很多实例。而这些实例可能分布在全国各地的不同机房。由于存在地域差异,网络传输的速度会有很大不同,因此在做服务治理时需要区分不同机房的实例。

例如 item-service,我们可以部署 3 个实例:

  • 127.0.0.1:8081
  • 127.0.0.1:8082
  • 127.0.0.1:8083

假如这些实例分布在不同机房,例如:

  • 127.0.0.1:8081,在上海机房
  • 127.0.0.1:8082,在上海机房
  • 127.0.0.1:8083,在杭州机房

Nacos 中提供了集群( cluster )的概念,来对应不同机房。也就是说,一个服务( service )下可以有很多集群( cluster ),而一个集群( cluster )中下又可以包含很多实例( instance )。

如图:

image-20250701150807265

因此,结合我们上一节学习的 namespace 命名空间的知识,任何一个微服务的实例在注册到 Nacos 时,都会生成以下几个信息,用来确认当前实例的身份,从外到内依次是:

  • namespace:命名空间
  • group:分组
  • service:服务名
  • cluster:集群
  • instance:实例,包含 ip 和端口

这就是 nacos 中的服务分级模型。

在 Nacos 内部会有一个服务实例的注册表,是基于 Map 实现的,其结构与分级模型的对应关系如下:

image-20250701150826571

查看 nacos 控制台,会发现默认情况下所有服务的集群都是 default:

image-20250701150845706

如果我们要修改服务所在集群,只需要修改 bootstrap.yml 即可:

spring:
  cloud:
    nacos:
      discovery:
        cluster-name: BJ # 集群名称,自定义

我们修改 item-servicebootstrap.yml ,然后重新创建一个实例:

image-20250701150905472

再次查看 nacos:

image-20250701150924124

发现 8084 这个新的实例确实属于 BJ 这个集群了。

# Eureka

Eureka 是 Netflix 公司开源的一个服务注册中心组件,早期版本的 SpringCloud 都是使用 Eureka 作为注册中心。由于 Eureka 和 Nacos 的 starter 中提供的功能都是基于 SpringCloudCommon 规范,因此两者使用起来差别不大。

课前资料中提供了一个 Eureka 的 demo:

image-20250701150943216

我们可以用 idea 打开查看一下:

image-20250701150956426

结构说明:

  • eureka-server :Eureka 的服务端,也就是注册中心。没错,Eureka 服务端要自己创建项目
  • order-service :订单服务,是一个服务调用者,查询订单的时候要查询用户
  • user-service :用户服务,是一个服务提供者,对外暴露查询用户的接口

启动以后,访问 localhost:10086 即可查看到 Eureka 的控制台,相对于 Nacos 来说简陋了很多:

image-20250701151017770

微服务引入 Eureka 的方式也极其简单,分两步:

  • 引入 eureka-client 依赖
  • 配置 eureka 地址

接下来就是编写 OpenFeign 的客户端了,怎么样?是不是跟 Nacos 用起来基本一致。

# Eureka 和 Nacos 对比

Eureka 和 Nacos 都能起到注册中心的作用,用法基本类似。但还是有一些区别的,例如:

  • Nacos 支持配置管理,而 Eureka 则不支持。

而且服务注册发现上也有区别,我们来做一个实验:

我们停止 user-service 服务,然后观察 Eureka 控制台,你会发现很长一段时间过去后,Eureka 服务依然没有察觉 user-service 的异常状态。

这与 Eureka 的健康检测机制有关。在 Eureka 中,健康检测的原理如下:

  • 微服务启动时注册信息到 Eureka,这点与 Nacos 一致。
  • 微服务每隔 30 秒向 Eureka 发送心跳请求,报告自己的健康状态。Nacos 中默认是 5 秒一次。
  • Eureka 如果 90 秒未收到心跳,则认为服务疑似故障,可能被剔除。Nacos 中则是 15 秒超时,30 秒剔除。
  • Eureka 如果发现超过 85% 比例的服务都心跳异常,会认为是自己的网络异常,暂停剔除服务的功能。
  • Eureka 每隔 60 秒执行一次服务检测和清理任务;Nacos 是每隔 5 秒执行一次。

综上,你会发现 Eureka 是尽量不剔除服务,避免 “误杀”,宁可放过一千,也不错杀一个。这就导致当服务真的出现故障时,迟迟不会被剔除,给服务的调用者带来困扰。

不仅如此,当 Eureka 发现服务宕机并从服务列表中剔除以后,并不会将服务列表的变更消息推送给所有微服务。而是等待微服务自己来拉取时发现服务列表的变化。而微服务每隔 30 秒才会去 Eureka 更新一次服务列表,进一步推迟了服务宕机时被发现的时间。

而 Nacos 中微服务除了自己定时去 Nacos 中拉取服务列表以外,Nacos 还会在服务列表变更时主动推送最新的服务列表给所有的订阅者。

综上,Eureka 和 Nacos 的相似点有:

  • 都支持服务注册发现功能
  • 都有基于心跳的健康监测功能
  • 都支持集群,集群间数据同步默认是 AP 模式,即最全高可用性

Eureka 和 Nacos 的区别有:

  • Eureka 的心跳是 30 秒一次,Nacos 则是 5 秒一次
  • Eureka 如果 90 秒未收到心跳,则认为服务疑似故障,可能被剔除。Nacos 中则是 15 秒超时,30 秒剔除。
  • Eureka 每隔 60 秒执行一次服务检测和清理任务;Nacos 是每隔 5 秒执行一次。
  • Eureka 只能等微服务自己每隔 30 秒更新一次服务列表;Nacos 即有定时更新,也有在服务变更时的广播推送
  • Eureka 仅有注册中心功能,而 Nacos 同时支持注册中心、配置管理
  • Eureka 和 Nacos 都支持集群,而且默认都是 AP 模式

# 远程调用

我们知道微服务间远程调用都是有 OpenFeign 帮我们完成的,甚至帮我们实现了服务列表之间的负载均衡。但具体负载均衡的规则是什么呢?何时做的负载均衡呢?

接下来我们一起来分析一下。

# 负载均衡原理

在 SpringCloud 的早期版本中,负载均衡都是有 Netflix 公司开源的 Ribbon 组件来实现的,甚至 Ribbon 被直接集成到了 Eureka-client 和 Nacos-Discovery 中。

但是自 SpringCloud2020 版本开始,已经弃用 Ribbon,改用 Spring 自己开源的 Spring Cloud LoadBalancer 了,我们使用的 OpenFeign 的也已经与其整合。

接下来我们就通过源码分析,来看看 OpenFeign 底层是如何实现负载均衡功能的。

# 源码跟踪

要弄清楚 OpenFeign 的负载均衡原理,最佳的办法肯定是从 FeignClient 的请求流程入手。

首先,我们在 com.hmall.cart.service.impl.CartServiceImpl 中的 queryMyCarts 方法中打一个断点。然后在 swagger 页面请求购物车列表接口。

进入断点后,观察 ItemClient 这个接口:

img

你会发现 ItemClient 是一个代理对象,而代理的处理器则是 SentinelInvocationHandler 。这是因为我们项目中引入了 Sentinel 导致。

我们进入 SentinelInvocationHandler 类中的 invoke 方法看看:

img

可以看到这里是先获取被代理的方法的处理器 MethodHandler ,接着,Sentinel 就会开启对簇点资源的监控:

img

开启 Sentinel 的簇点资源监控后,就可以调用处理器了,我们尝试跟入,会发现有两种实现:

img

这其实就是 OpenFeign 远程调用的处理器了。继续跟入会进入 SynchronousMethodHandler 这个实现类:

img

在上述方法中,会循环尝试调用 executeAndDecode() 方法,直到成功或者是重试次数达到 Retryer 中配置的上限。

我们继续跟入 executeAndDecode() 方法:

img

executeAndDecode() 方法最终会利用 client 去调用 execute() 方法,发起远程调用。

这里的 client 的类型是 feign.Client 接口,其下有很多实现类:

img

由于我们项目中整合了 seata,所以这里 client 对象的类型是 SeataFeignBlockingLoadBalancerClient ,内部实现如下:

img

这里直接调用了其父类,也就是 FeignBlockingLoadBalancerClientexecute 方法,来看一下:

img

整段代码中核心的有 4 步:

  • 从请求的 URI 中找出 serviceId
  • 利用 loadBalancerClient ,根据 serviceId 做负载均衡,选出一个实例 ServiceInstance
  • 用选中的 ServiceInstanceipport 替代 serviceId ,重构 URI
  • 向真正的 URI 发送请求

所以负载均衡的关键就是这里的 loadBalancerClient,类型是 org.springframework.cloud.client.loadbalancer.LoadBalancerClient ,这是 Spring-Cloud-Common 模块中定义的接口,只有一个实现类:

img

而这里的 org.springframework.cloud.client.loadbalancer.BlockingLoadBalancerClient 正是 Spring-Cloud-LoadBalancer 模块下的一个类:

img

我们继续跟入其 BlockingLoadBalancerClient#choose() 方法:

img

图中代码的核心逻辑如下:

  • 根据 serviceId 找到这个服务采用的负载均衡器( ReactiveLoadBalancer ),也就是说我们可以给每个服务配不同的负载均衡算法。
  • 利用负载均衡器( ReactiveLoadBalancer )中的负载均衡算法,选出一个服务实例

ReactiveLoadBalancerSpring-Cloud-Common 组件中定义的负载均衡器接口规范,而 Spring-Cloud-Loadbalancer 组件给出了两个实现:

img

默认的实现是 RoundRobinLoadBalancer ,即轮询负载均衡器。负载均衡器的核心逻辑如下:

img

核心流程就是两步:

  • 利用 ServiceInstanceListSupplier#get() 方法拉取服务的实例列表,这一步是采用响应式编程
  • 利用本类,也就是 RoundRobinLoadBalancergetInstanceResponse() 方法挑选一个实例,这里采用了轮询算法来挑选。

这里的 ServiceInstanceListSupplier 有很多实现:

img

其中 CachingServiceInstanceListSupplier 采用了装饰模式,加了服务实例列表缓存,避免每次都要去注册中心拉取服务实例列表。而其内部是基于 DiscoveryClientServiceInstanceListSupplier 来实现的。

在这个类的构造函数中,就会异步的基于 DiscoveryClient 去拉取服务的实例列表:

img

# 流程梳理

根据之前的分析,我们会发现 Spring 在整合 OpenFeign 的时候,实现了 org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient 类,其中定义了 OpenFeign 发起远程调用的核心流程。也就是四步:

  • 获取请求中的 serviceId
  • 根据 serviceId 负载均衡,找出一个可用的服务实例
  • 利用服务实例的 ipport 信息重构 url
  • 向真正的 url 发起请求

而具体的负载均衡则是不是由 OpenFeign 组件负责。而是分成了负载均衡的接口规范,以及负载均衡的具体实现两部分。

负载均衡的接口规范是定义在 Spring-Cloud-Common 模块中,包含下面的接口:

  • LoadBalancerClient :负载均衡客户端,职责是根据 serviceId 最终负载均衡,选出一个服务实例
  • ReactiveLoadBalancer :负载均衡器,负责具体的负载均衡算法

OpenFeign 的负载均衡是基于 Spring-Cloud-Common 模块中的负载均衡规则接口,并没有写死具体实现。这就意味着以后还可以拓展其它各种负载均衡的实现。

不过目前 SpringCloud 中只有 Spring-Cloud-Loadbalancer 这一种实现。

Spring-Cloud-Loadbalancer 模块中,实现了 Spring-Cloud-Common 模块的相关接口,具体如下:

  • BlockingLoadBalancerClient :实现了 LoadBalancerClient ,会根据 serviceId 选出负载均衡器并调用其算法实现负载均衡。
  • RoundRobinLoadBalancer :基于轮询算法实现了 ReactiveLoadBalancer
  • RandomLoadBalancer :基于随机算法实现了 ReactiveLoadBalancer

这样一来,整体思路就非常清楚了,流程图如下:

暂时无法在飞书文档外展示此内容

# NacosRule

之前分析源码的时候我们发现负载均衡的算法是有 ReactiveLoadBalancer 来定义的,我们发现它的实现类有三个:

image-20250701151102091

其中 RoundRobinLoadBalancerRandomLoadBalancer 是由 Spring-Cloud-Loadbalancer 模块提供的,而 NacosLoadBalancer 则是由 Nacos-Discorvery 模块提供的。

默认采用的负载均衡策略是 RoundRobinLoadBalancer ,那如果我们要切换负载均衡策略该怎么办?

# 修改负载均衡策略

查看源码会发现, Spring-Cloud-Loadbalancer 模块中有一个自动配置类:

image-20250701151124013

其中定义了默认的负载均衡器:

image-20250701151147093

这个 Bean 上添加了 @ConditionalOnMissingBean 注解,也就是说如果我们自定义了这个类型的 bean,则负载均衡的策略就会被改变。

我们在 hm-cart 模块中的添加一个配置类:

image-20250701151201669

代码如下:

package com.hmall.cart.config;

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.loadbalancer.NacosLoadBalancer;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.loadbalancer.core.ReactorLoadBalancer;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;

public class OpenFeignConfig {

    @Bean
    public ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(
            Environment environment, NacosDiscoveryProperties properties,
            LoadBalancerClientFactory loadBalancerClientFactory) {
        String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new NacosLoadBalancer(
                loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class), name, properties);
    }

}

注意

这个配置类千万不要加 @Configuration 注解,也不要被 SpringBootApplication 扫描到。

由于这个 OpenFeignConfig 没有加 @Configuration 注解,也就没有被 Spring 加载,因此是不会生效的。接下来,我们要在启动类上通过注解来声明这个配置。

有两种做法:

  • 全局配置:对所有服务生效
@LoadBalancerClients(defaultConfiguration = OpenFeignConfig.class)
  • 局部配置:只对某个服务生效
@LoadBalancerClients({
        @LoadBalancerClient(value = "item-service", configuration = OpenFeignConfig.class)
})

我们选择全局配置:

image-20250701151227363

DEBUG 重启后测试,会发现负载均衡器的类型确实切换成功:

image-20250701151246569

# 集群优先

RoundRobinLoadBalancer 是轮询算法, RandomLoadBalancer 是随机算法,那么 NacosLoadBalancer 是什么负载均衡算法呢?

我们通过源码来分析一下,先看第一部分:

image-20250701151311129

这部分代码的大概流程如下:

  • 通过 ServiceInstanceListSupplier 获取服务实例列表
  • 获取 NacosDiscoveryProperties 中的 clusterName ,也就是 yml 文件中的配置,代表当前服务实例所在集群信息(参考 2.2 小节,分级模型)
  • 然后利用 stream 的 filter 过滤找到被调用的服务实例中与当前服务实例 clusterName 一致的。简单来说就是服务调用者与服务提供者要在一个集群

为什么?

假如我现在有两个机房,都部署有 item-servicecart-service 服务:

image-20250701151327210

假如这些服务实例全部都注册到了同一个 Nacos。现在,杭州机房的 cart-service 要调用 item-service ,会拉取到所有机房的 item-service 的实例。调用时会出现两种情况:

  • 直接调用当前机房的 item-service
  • 调用其它机房的 item-service

本机房调用几乎没有网络延迟,速度比较快。而跨机房调用,如果两个机房相距很远,会存在较大的网络延迟。因此,我们应该尽可能避免跨机房调用,优先本地集群调用:

image-20250701151342300

现在的情况是这样的:

  • cart-service 所在集群是 default
  • item-service 的 8081、8083 所在集群的 default
  • item-service 的 8084 所在集群是 BJ

cart-service 访问 item-service 时,应该优先访问 8081 和 8082,我们重启 cart-service ,测试一下:

image-20250701151359548

可以看到原本是 3 个实例,经过筛选后还剩下 2 个实例。

查看 Debug 控制台:

image-20250701151416528

同集群的实例还剩下两个,接下来就需要做负载均衡了,具体用的是什么算法呢?

# 权重配置

我们继续跟踪 NacosLoadBalancer 源码:

image-20250701151437512

那么问题来了, 这个权重是怎么配的呢?

我们打开 nacos 控制台,进入 item-service 的服务详情页,可以看到每个实例后面都有一个编辑按钮:

image-20250701151452854

点击,可以看到一个编辑表单:

image-20250701151509339

我们将这里的权重修改为 5:

image-20250701151525183

访问 10 次购物车接口,可以发现大多数请求都访问到了 8083 这个实例。

# 服务保护

在 SpringCloud 的早期版本中采用的服务保护技术叫做 Hystix ,不过后来被淘汰,替换为 Spring Cloud Circuit Breaker ,其底层实现可以是 Spring RetryResilience4J

不过在国内使用较多还是 SpringCloudAlibaba 中的 Sentinel 组件。

接下来,我们就分析一下 Sentinel 组件的一些基本实现原理以及它与 Hystix 的差异。

# 线程隔离

首先我们来看下线程隔离功能,无论是 Hystix 还是 Sentinel 都支持线程隔离。不过其实现方式不同。

线程隔离有两种方式实现:

  • 线程池隔离:给每个服务调用业务分配一个线程池,利用线程池本身实现隔离效果
  • 信号量隔离:不创建线程池,而是计数器模式,记录业务使用的线程数量,达到信号量上限时,禁止新的请求

如图:

img

两者的优缺点如下:

img

Sentinel 的线程隔离就是基于信号量隔离实现的,而 Hystix 两种都支持,但默认是基于线程池隔离。

# 滑动窗口算法

在熔断功能中,需要统计异常请求或慢请求比例,也就是计数。在限流的时候,要统计每秒钟的 QPS,同样是计数。可见计数算法在熔断限流中的应用非常多。sentinel 中采用的计数器算法就是滑动窗口计数算法。

# 固定窗口计数

要了解滑动窗口计数算法,我们必须先知道固定窗口计数算法,其基本原理如图:

img

说明:

  • 将时间划分为多个窗口,窗口时间跨度称为 Interval ,本例中为 1000ms;
  • 每个窗口维护 1 个计数器,每有 1 次请求就将计数器 +1 。限流就是设置计数器阈值,本例为 3,图中红线标记
  • 如果计数器超过了限流阈值,则超出阈值的请求都被丢弃。

示例:

img

说明:

  • 第 1、2 秒,请求数量都小于 3,没问题
  • 第 3 秒,请求数量为 5,超过阈值,超出的请求被拒绝

但是我们考虑一种特殊场景,如图:

img

说明:

  • 假如在第 5、6 秒,请求数量都为 3,没有超过阈值,全部放行
  • 但是,如果第 5 秒的三次请求都是在 4.55 秒之间进来;第 6 秒的请求是在 55.5 之间进来。那么从第 4.5~5. 之间就有 6 次请求!也就是说每秒的 QPS 达到了 6,远超阈值。

这就是固定窗口计数算法的问题,它只能统计当前某 1 个时间窗的请求数量是否到达阈值,无法结合前后的时间窗的数据做综合统计。

因此,我们就需要滑动时间窗口算法来解决。

# 滑动窗口计数

固定时间窗口算法中窗口有很多,其跨度和位置是与时间区间绑定,因此是很多固定不动的窗口。而滑动时间窗口算法中只包含 1 个固定跨度的窗口,但窗口是可移动动的,与时间区间无关。

具体规则如下:

  • 窗口时间跨度 Interval 大小固定,例如 1 秒
  • 时间区间跨度为 Interval / n ,例如 n=2,则时间区间跨度为 500ms
  • 窗口会随着当前请求所在时间 currentTime 移动,窗口范围从 currentTime-Interval 时刻之后的第一个时区开始,到 currentTime 所在时区结束。

如图所示:

img

限流阈值依然为 3,绿色小块就是请求,上面的数字是其 currentTime 值。

  • 在第 1300ms 时接收到一个请求,其所在时区就是 1000~1500
  • 按照规则,currentTime-Interval 值为 300ms,300ms 之后的第一个时区是 5001000,因此窗口范围包含两个时区:5001000、1000~1500,也就是粉红色方框部分
  • 统计窗口内的请求总数,发现是 3,未达到上限。

若第 1400ms 又来一个请求,会落在 1000~1500 时区,虽然该时区请求总数是 3,但滑动窗口内总数已经达到 4,因此该请求会被拒绝:

img

假如第 1600ms 又来的一个请求,处于 15002000 时区,根据算法,滑动窗口位置应该是 10001500 和 1500~2000 这两个时区,也就是向后移动:

img

这就是滑动窗口计数的原理,解决了我们之前所说的问题。而且滑动窗口内划分的时区越多,这种统计就越准确。

# 令牌桶算法

限流的另一种常见算法是令牌桶算法。Sentinel 中的热点参数限流正是基于令牌桶算法实现的。其基本思路如图:

img

说明:

  • 以固定的速率生成令牌,存入令牌桶中,如果令牌桶满了以后,多余令牌丢弃
  • 请求进入后,必须先尝试从桶中获取令牌,获取到令牌后才可以被处理
  • 如果令牌桶中没有令牌,则请求等待或丢弃

基于令牌桶算法,每秒产生的令牌数量基本就是 QPS 上限。

当然也有例外情况,例如:

  • 某一秒令牌桶中产生了很多令牌,达到令牌桶上限 N,缓存在令牌桶中,但是这一秒没有请求进入。
  • 下一秒的前半秒涌入了超过 2N 个请求,之前缓存的令牌桶的令牌耗尽,同时这一秒又生成了 N 个令牌,于是总共放行了 2N 个请求。超出了我们设定的 QPS 阈值。

因此,在使用令牌桶算法时,尽量不要将令牌上限设定到服务能承受的 QPS 上限。而是预留一定的波动空间,这样我们才能应对突发流量。

# 漏桶算法

漏桶算法与令牌桶相似,但在设计上更适合应对并发波动较大的场景,以解决令牌桶中的问题。

简单来说就是请求到达后不是直接处理,而是先放入一个队列。而后以固定的速率从队列中取出并处理请求。之所以叫漏桶算法,就是把请求看做水,队列看做是一个漏了的桶。

如图:

img

说明:

  • 将每个请求视作 "水滴" 放入 "漏桶" 进行存储;
  • "漏桶" 以固定速率向外 "漏" 出请求来执行,如果 "漏桶" 空了则停止 " 漏水”;
  • 如果 "漏桶" 满了则多余的 "水滴" 会被直接丢弃。

漏桶的优势就是流量整型,桶就像是一个大坝,请求就是水。并发量不断波动,就如图水流时大时小,但都会被大坝拦住。而后大坝按照固定的速度放水,避免下游被洪水淹没。

因此,不管并发量如何波动,经过漏桶处理后的请求一定是相对平滑的曲线:

img

sentinel 中的限流中的排队等待功能正是基于漏桶算法实现的。

尝试用自己的语言回答下列面试题:

  • SpringCloud 有哪些常用组件?分别是什么作用?
  • 服务注册发现的基本流程是怎样的?
  • Eureka 和 Nacos 有哪些区别?
  • Nacos 的分级存储模型是什么意思?
  • OpenFeign 是如何实现负载均衡的?
  • 什么是服务雪崩,常见的解决方案有哪些?
  • Hystix 和 Sentinel 有什么区别和联系?
  • 限流的常见算法有哪些?
  • 什么是 CAP 理论和 BASE 思想?
  • 项目中碰到过分布式事务问题吗?怎么解决的?
  • AT 模式如何解决脏读和脏写问题的?
  • TCC 模式与 AT 模式对比,有哪些优缺点
  • RabbitMQ 是如何确保消息的可靠性的?
  • RabbitMQ 是如何解决消息堆积问题的?
更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Ruler 微信支付

微信支付

Ruler 支付宝

支付宝