问题说明:这个题目主要考察对SpringCloud的组件基本了解
难易程度:简单
参考话术:
SpringCloud包含的组件很多,有很多功能是重复的。其中最常用组件包括:
•注册中心组件:Eureka、Nacos等
•负载均衡组件:Ribbon
•远程调用组件:OpenFeign
•网关组件:Zuul、Gateway
•服务保护组件:Hystrix、Sentinel
•服务配置管理组件:SpringCloudConfig、Nacos
问题说明:考察对Nacos数据分级结构的了解,以及Nacos源码的掌握情况
难易程度:一般
参考话术:
Nacos采用了数据的分级存储模型,最外层是Namespace,用来隔离环境。然后是Group,用来对服务分组。接下来就是服务(Service)了,一个服务包含多个实例,但是可能处于不同机房,因此Service下有多个集群(Cluster),Cluster下是不同的实例(Instance)。
对应到Java代码中,Nacos采用了一个多层的Map来表示。结构为Map
package com.nacos;
import org.junit.Test;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class NacosStructure {
@Test
public void testNacosStructure() {
// 实例
Instance personInfo = new Instance("personInfo");
Instance finance = new Instance("finance");
// 集群(一个地区的机房)
Cluster SZ = new Cluster("SZ");
SZ.getInstance(personInfo);
SZ.getInstance(finance);
// 其中服务组是环境隔离的
NameSpace dev01 = new NameSpace("dev01");
// 集群是部署在服务组中
dev01.putService("01-personInfo", new Service("personInfo"));
dev01.putNameSpace("dev01", dev01.getService("01-personInfo"));
System.out.println(dev01);
}
}
class NameSpace {
private String nameSpaceId;
private Map<String, Map<String, Service>> nameSpaceMap = new HashMap();
private Map<String, Service> groupMap = new HashMap<>();
public void putNameSpace(String nameSpaceId, Map<String, Service> serviceMap) {
nameSpaceMap.put(nameSpaceId, serviceMap);
}
public void putService(String groupId, Service service) {
this.groupMap.put(groupId, service);
}
public Map<String, Service> getService(String groupId) {
return this.groupMap;
}
public NameSpace(String nameSpaceId) {
this.nameSpaceId = nameSpaceId;
}
@Override
public String toString() {
return "NameSpace{" +
"nameSpaceId='" + nameSpaceId + '\'' +
", nameSpaceMap=" + nameSpaceMap +
", groupMap=" + groupMap +
'}';
}
}
class Service {
private String name;
Map<String, Cluster> service = new HashMap();
public Service(String name) {
this.name = name;
}
/**
* 往服务中添加集群
*
* @param c
*/
public void putCluster(Cluster c) {
this.service.put(c.getName(), c);
}
/**
* 往服务中删除集群
*
* @param c
*/
public void deleteCluster(Cluster c) {
this.service.remove(c.getName());
}
@Override
public String toString() {
return "Service{" +
"name='" + name + '\'' +
", service=" + service +
'}';
}
}
class Cluster {
private String name;
private Set<Instance> instance = new HashSet<>();
public Cluster(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
/**
* 往集群中添加实例
*
* @param in
*/
public void getInstance(Instance in) {
this.instance.add(in);
}
/**
* 集群中删除实例
*
* @param in
*/
public void removeInstance(Instance in) {
this.instance.remove(in);
}
@Override
public String toString() {
return "Cluster{" +
"name='" + name + '\'' +
", instance=" + instance +
'}';
}
}
class Instance {
private String name;
public Instance(String name) {
this.name = name;
}
@Override
public String toString() {
return "Instance{" +
"name='" + name + '\'' +
'}';
}
}
要研究Nacos源码自然不能用打包好的Nacos服务端jar包来运行,需要下载源码自己编译来运行。
Nacos的GitHub地址:https://github.com/alibaba/nacos
课前资料中已经提供了下载好的1.4.2版本的Nacos源码:
如果需要研究其他版本的同学,也可以自行下载:
大家找到其release页面:https://github.com/alibaba/nacos/tags,找到其中的1.4.2.版本:
点击进入后,下载Source code(zip):
我们的课前资料提供了一个微服务Demo,包含了服务注册、发现等业务。
导入该项目后,查看其项目结构:
结构说明:
将之前下载好的Nacos源码解压到cloud-source-demo项目目录中:
然后,使用IDEA将其作为一个module来导入:
1)选择项目结构选项:
然后点击导入module:
在弹出窗口中,选择nacos源码目录:
然后选择maven模块,finish:
Nacos底层的数据通信会基于protobuf对数据做序列化和反序列化。并将对应的proto文件定义在了consistency这个子模块中:
我们需要先将proto文件编译为对应的Java代码。
protobuf的全称是Protocol Buffer,是Google提供的一种数据序列化协议,这是Google官方的定义:
Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据序列化,很适合做数据存储或 RPC 数据交换格式。它可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。
可以简单理解为,是一种跨语言、跨平台的数据传输格式。与json的功能类似,但是无论是性能,还是数据大小都比json要好很多。
protobuf的之所以可以跨语言,就是因为数据定义的格式为.proto
格式,需要基于protoc编译为对应的语言。
Protobuf的GitHub地址:https://github.com/protocolbuffers/protobuf/releases
我们可以下载windows版本的来使用:
另外,课前资料也提供了下载好的安装包:
解压到任意非中文目录下,其中的bin目录中的protoc.exe可以帮助我们编译:
然后将这个bin目录配置到你的环境变量path中,可以参考JDK的配置方式:
进入nacos-1.4.2的consistency模块下的src/main目录下:
然后打开cmd窗口,运行下面的两个命令:
protoc --java_out=./java ./proto/consistency.proto
protoc --java_out=./java ./proto/Data.proto
如图:
会在nacos的consistency模块中编译出这些java代码:
nacos服务端的入口是在console模块中的Nacos类:
然后填写应用信息:
Main class:com.alibaba.nacos.Nacos
VM options: -Dnacos.standalone=true
将order-service和user-service服务启动后,可以查看nacos控制台:
服务注册到Nacos以后,会保存在一个本地注册表中,其结构如下:
首先最外层是一个Map,结构为:Map
:
Map
,代表分组及组内的服务。一个组内可以有多个服务
Service
,内部也包含一个Map
,一个服务下可以有多个集群
Cluster
类型,包含集群的具体信息。一个集群中可能包含多个实例,也就是具体的节点信息,其中包含一个Set
,就是该集群下的实例的集合
每一个服务去注册到Nacos时,就会把信息组织并存入这个Map中。
Nacos提供了服务注册的API接口,客户端只需要向该接口发送请求,即可实现服务注册。
**接口说明:**注册一个实例到Nacos服务。
请求类型:POST
请求路径:/nacos/v1/ns/instance
请求参数:
名称 | 类型 | 是否必选 | 描述 |
---|---|---|---|
ip | 字符串 | 是 | 服务实例IP |
port | int | 是 | 服务实例port |
namespaceId | 字符串 | 否 | 命名空间ID |
weight | double | 否 | 权重 |
enabled | boolean | 否 | 是否上线 |
healthy | boolean | 否 | 是否健康 |
metadata | 字符串 | 否 | 扩展信息 |
clusterName | 字符串 | 否 | 集群名 |
serviceName | 字符串 | 是 | 服务名 |
groupName | 字符串 | 否 | 分组名 |
ephemeral | boolean | 否 | 是否临时实例 |
错误编码:
错误代码 | 描述 | 语义 |
---|---|---|
400 | Bad Request | 客户端请求中的语法错误 |
403 | Forbidden | 没有权限 |
404 | Not Found | 无法找到资源 |
500 | Internal Server Error | 服务器内部错误 |
200 | OK | 正常 |
首先,我们需要找到服务注册的入口。
Nacos引入实例的路径是:/nacos/v1/ns/instance
那我们就需要找相同的路径,发现在src/main/java/com/alibaba/nacos/naming/controllers/InstanceController.java
目录下:
并且请求类型是POST,那我们需要找PostMapping,方法register就是注册中心的入口
问题说明:考察对Nacos源码的掌握情况
难易程度:难
参考话术:
Nacos内部接收到注册的请求时,不会立即写数据,而是将服务注册的任务放入一个阻塞队列就立即响应给客户端。然后利用线程池读取阻塞队列中的任务,异步来完成实例更新,从而提高并发写能力。
这里是临时实例
问题说明:考察对Nacos源码的掌握情况
难易程度:难
参考话术:
Nacos在更新实例列表时,会采用CopyOnWrite技术,首先将旧的实例列表拷贝一份,然后更新拷贝的实例列表,再用更新后的实例列表来覆盖旧的实例列表。
这样在更新的过程中,就不会对读实例列表的请求产生影响,也不会出现脏读问题了。
相当于并发过程中,修改的是新实例列表(对旧列表的拷贝),而读取的是旧实例列表。两者互不影响。
对同一个服务的多个实例采用synchronized锁,串行执行,保证写的安全性
实例的注册采用单线程,异步调用
单线程,保证写的安全性
问题说明:考察对Nacos、Eureka的底层实现的掌握情况
难易程度:难
参考话术:
Nacos与Eureka有相同点,也有不同之处,可以从以下几点来描述:
问题说明:考察对限流算法的掌握情况
难易程度:难
参考话术:
限流:对应用服务器的请求做限制,避免因过多请求而导致服务器过载甚至宕机。
限流算法常见的有三种实现:
1.滑动时间窗口
2.令牌桶算法
3.漏桶算法
Gateway则采用了基于Redis实现的令牌桶算法。
而Sentinel内部却比较复杂:
固定窗口计数器算法
固定窗口计数器算法概念如下:
● 将时间划分为多个窗口,窗口时间跨度称为Interval,本例中为1000ms;
● 每个窗口维护一个计数器,每有-次请求就将计数器加一,限流就是设置计数器阈值,本例为3
● 如果计数器超过 了限流阈值,则超出阈值的请求都被丢弃。
滑动窗口计数器算法
滑动窗口计数器算法会将-一个窗口划分为n个更小的区间,例如
● 窗口时间跨度Interval为1秒;区间数量n = 2,则每个小区间时间跨度为500ms
● 限流阈值依然为3,时间窗口(1秒)内请求超过阈值时,超出的请求被限流
窗口会根据当前请求所在时间(currentTime) 移动,窗口范围是从(currentTime-Interval)之后的第一个时区开始,到currentTime所在时区结束。
令牌桶算法
令牌桶算法说明:
● 以固定的速率生成令牌, 存入令牌桶中,如果令牌桶满了以后,多余令牌丢弃
● 请求进入后, 必须先尝试从桶中获取令牌,获取到令牌后才可以被处理
● 如果令牌桶中没有令牌,则请求等待或丢弃
漏桶算法
漏桶算法说明: .
● 将每个请求视作"水滴 放入"漏桶进行存储;
● "漏桶"以固定速率向外"漏"出请求来执行, 如果"漏桶"空了则停止"漏水”;
● 如果"漏桶"满了则多余的"水滴”会被直接丢弃。
可以理解为请求在桶内排队等待
Sentinel在实现漏桶时,采用了排队等待模式:
让所有请求进入一个队列中,然后按照阈值允许的时间间隔依次执行。并发的多个请求必须等待,
预期的等待时长=最近一次请求的预期等待时间+允许的间隔。
如果请求预期的等待时间超出最大时长,则会被拒绝。
例如: QPS=5,意味着每200ms处理一个队列中的请求; timeout = 2000,意味着预期等待超过2000ms的请求会被拒绝并抛出异常。
限流算法对比
问题说明:考察对线程隔离方案的掌握情况
难易程度:一般
参考话术:
Hystix默认是基于线程池实现的线程隔离,每一个被隔离的业务都要创建一个独立的线程池,线程过多会带来额外的CPU开销,性能一般,但是隔离性更强。
Sentinel是基于信号量(计数器)实现的线程隔离,不用创建线程池,性能较好,但是隔离性一般。
话术:
kafka是以吞吐量高而闻名,不过其数据稳定性一般,而且无法保证消息有序性。我们公司的日志收集也有使用,业务模块中则使用的RabbitMQ。
阿里巴巴的RocketMQ基于Kafka的原理,弥补了Kafka的缺点,继承了其高吞吐的优势,其客户端目前以Java为主。但是我们担心阿里巴巴开源产品的稳定性,所以就没有使用。
RabbitMQ基于面向并发的语言Erlang开发,吞吐量不如Kafka,但是对我们公司来讲够用了。而且消息可靠性较好,并且消息延迟极低,集群搭建比较方便。支持多种协议,并且有各种语言的客户端,比较灵活。Spring对RabbitMQ的支持也比较好,使用起来比较方便,比较符合我们公司的需求。
综合考虑我们公司的并发需求以及稳定性需求,我们选择了RabbitMQ。
话术:
RabbitMQ针对消息传递过程中可能发生问题的各个地方,给出了针对性的解决方案:
话术:
消息堆积问题产生的原因往往是因为消息发送的速度超过了消费者消息处理的速度。因此解决方案无外乎以下三点:
1)提高消费者处理速度
消费者处理速度是由业务代码决定的,所以我们能做的事情包括:
优点:成本低,改改代码即可
缺点:开启线程池会带来额外的性能开销,对于高频、低时延的任务不合适。推荐任务执行周期较长的业务。
2)增加更多消费者
一个队列绑定多个消费者,共同争抢任务,自然可以提供消息处理的速度。
优点:能用钱解决的问题都不是问题。实现简单粗暴
缺点:问题是没有钱。成本太高
3)增加队列消息存储上限
在RabbitMQ的1.8版本后,加入了新的队列模式:Lazy Queue
这种队列不会将消息保存在内存中,而是在收到消息后直接写入磁盘中,理论上没有存储上限。可以解决消息堆积问题。
优点:磁盘存储更安全;存储无上限;避免内存存储带来的Page Out问题,性能更稳定;
缺点:磁盘存储受到IO性能的限制,消息时效性不如内存模式,但影响不大。
话术:
其实RabbitMQ是队列存储,天然具备先进先出的特点,只要消息的发送是有序的,那么理论上接收也是有序的。不过当一个队列绑定了多个消费者时,可能出现消息轮询投递给消费者的情况,而消费者的处理顺序就无法保证了。
因此,要保证消息的有序性,需要做的下面几点:
话术:
消息重复消费的原因多种多样,不可避免。所以只能从消费者端入手,只要能保证消息处理的幂等性就可以确保消息不被重复消费。
而幂等性的保证又有很多方案:
话术:
要实现RabbitMQ的高可用无外乎下面两点:
话术:
RabbitMQ能解决的问题很多,例如:
redis支持更丰富的数据类型
(支持更复杂的应用场景):Redis不仅仅支持简单的k/v类型的数据,同时还提供list,set,zset,hash等数据结构的存储。memcache支持简单的数据类型,String。Redis支持数据的持久化
,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而Memecache把数据全部存在内存之中。集群模式
:memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 redis 目前是原生支持 cluster 模式的.Redis使用单线程
:Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的多路 IO 复用模型。面试官:Redis采用单线程,如何保证高并发?
面试话术:
Redis快的主要原因是:
面试官:这样做的好处是什么?
面试话术:
单线程优势有下面几点:
相关资料:
1)RDB 持久化
RDB持久化可以使用save或bgsave,为了不阻塞主进程业务,一般都使用bgsave,流程:
下面是一些和 RDB 持久化相关的配置:
save 60 10000
:如果在 60 秒内有 10000 个 key 发生改变,那就执行 RDB 持久化。stop-writes-on-bgsave-error yes
:如果 Redis 执行 RDB 持久化失败(常见于操作系统内存不足),那么 Redis 将不再接受 client 写入数据的请求。rdbcompression yes
:当生成 RDB 文件时,同时进行压缩。dbfilename dump.rdb
:将 RDB 文件命名为 dump.rdb。dir /var/lib/redis
:将 RDB 文件保存在/var/lib/redis
目录下。当然在实践中,我们通常会将stop-writes-on-bgsave-error
设置为false
,同时让监控系统在 Redis 执行 RDB 持久化失败时发送告警,以便人工介入解决,而不是粗暴地拒绝 client 的写入请求。
RDB持久化的优点:
RDB 持久化的缺点:
2)AOF 持久化
可以使用appendonly yes
配置项来开启 AOF 持久化。Redis 执行 AOF 持久化时,会将接收到的写命令追加到 AOF 文件的末尾,因此 Redis 只要对 AOF 文件中的命令进行回放,就可以将数据库还原到原先的状态。
与 RDB 持久化相比,AOF 持久化的一个明显优势就是,它可以提高数据的持久性 (durability)。因为在 AOF 模式下,Redis 每次接收到 client 的写命令,就会将命令write()
到 AOF 文件末尾。
然而,在 Linux 中,将数据write()
到文件后,数据并不会立即刷新到磁盘,而会先暂存在 OS 的文件系统缓冲区。在合适的时机,OS 才会将缓冲区的数据刷新到磁盘(如果需要将文件内容刷新到磁盘,可以调用fsync()
或fdatasync()
)。
通过appendfsync
配置项,可以控制 Redis 将命令同步到磁盘的频率:
always
:每次 Redis 将命令write()
到 AOF 文件时,都会调用fsync()
,将命令刷新到磁盘。这可以保证最好的数据持久性,但却会给系统带来极大的开销。no
:Redis 只将命令write()
到 AOF 文件。这会让 OS 决定何时将命令刷新到磁盘。everysec
:除了将命令write()
到 AOF 文件,Redis 还会每秒执行一次fsync()
。在实践中,推荐使用这种设置,一定程度上可以保证数据持久性,又不会明显降低 Redis 性能。然而,AOF 持久化并不是没有缺点的:Redis 会不断将接收到的写命令追加到 AOF 文件中,导致 AOF 文件越来越大。过大的 AOF 文件会消耗磁盘空间,并且导致 Redis 重启时更加缓慢。为了解决这个问题,在适当情况下,Redis 会对 AOF 文件进行重写,去除文件中冗余的命令,以减小 AOF 文件的体积。在重写 AOF 文件期间, Redis 会启动一个子进程,由子进程负责对 AOF 文件进行重写。
可以通过下面两个配置项,控制 Redis 重写 AOF 文件的频率:
auto-aof-rewrite-min-size 64mb
auto-aof-rewrite-percentage 100
上面两个配置的作用:当 AOF 文件的体积大于 64MB,并且 AOF 文件的体积比上一次重写之后的体积大了至少一倍,那么 Redis 就会执行 AOF 重写。
优点:
缺点:
面试话术:
Redis 提供了两种数据持久化的方式,一种是 RDB,另一种是 AOF。默认情况下,Redis 使用的是 RDB 持久化。
RDB持久化文件体积较小,但是保存数据的频率一般较低,可靠性差,容易丢失数据。另外RDB写数据时会采用Fork函数拷贝主进程,可能有额外的内存消耗,文件压缩也会有额外的CPU消耗。
ROF持久化可以做到每秒钟持久化一次,可靠性高。但是持久化文件体积较大,导致数据恢复时读取文件时间较长,效率略低
面试话术:
Redis集群可以分为主从集群和分片集群两类。
主从集群一般一主多从,主库用来写数据,从库用来读数据。结合哨兵,可以再主库宕机时从新选主,目的是保证Redis的高可用。
分片集群是数据分片,我们会让多个Redis节点组成集群,并将16383个插槽分到不同的节点上。存储数据时利用对key做hash运算,得到插槽值后存储到对应的节点即可。因为存储数据面向的是插槽而非节点本身,因此可以做到集群动态伸缩。目的是让Redis能存储更多数据。
1)主从集群
主从集群,也是读写分离集群。一般都是一主多从方式。
Redis 的复制(replication)功能允许用户根据一个 Redis 服务器来创建任意多个该服务器的复制品,其中被复制的服务器为主服务器(master),而通过复制创建出来的服务器复制品则为从服务器(slave)。
只要主从服务器之间的网络连接正常,主从服务器两者会具有相同的数据,主服务器就会一直将发生在自己身上的数据更新同步 给从服务器,从而一直保证主从服务器的数据相同。
哨兵节点
,当master宕机时,哨兵会从salve节点选出一个新的主。2)分片集群
主从集群中,每个节点都要保存所有信息,容易形成木桶效应。并且当数据量较大时,单个机器无法满足需求。此时我们就要使用分片集群了。
集群特征:
每个节点都保存不同数据
所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽.
节点的fail是通过集群中超过半数的节点检测失效时才生效.
客户端与redis节点直连,不需要中间proxy层连接集群中任何一个可用节点都可以访问到数据
redis-cluster把所有的物理节点映射到[0-16383]slot(插槽)上,实现动态伸缩
为了保证Redis中每个节点的高可用,我们还可以给每个节点创建replication(slave节点),如图:
支持多种类型的数据结构,主要区别是value存储的数据格式不同:
string:最基本的数据类型,二进制安全的字符串,最大512M。
list:按照添加顺序保持顺序的字符串列表。
set:无序的字符串集合,不存在重复的元素。
sorted set:已排序的字符串集合。
hash:key-value对格式
相关资料:
参考:http://redisdoc.com/topic/transaction.html
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的。Redis会将一个事务中的所有命令序列化,然后按顺序执行。但是Redis事务不支持回滚操作,命令运行出错后,正确的命令会继续执行。
MULTI
: 用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个待执行命令队列中EXEC
:按顺序执行命令队列内的所有命令。返回所有命令的返回值。事务执行过程中,Redis不会执行其它事务的命令。DISCARD
:清空命令队列,并放弃执行事务, 并且客户端会从事务状态中退出WATCH
:Redis的乐观锁机制,利用compare-and-set(CAS)原理,可以监控一个或多个键,一旦其中有一个键被修改,之后的事务就不会执行使用事务时可能会遇上以下两种错误:
maxmemory
设置了最大内存限制的话)。
为什么 Redis 不支持回滚(roll back)?
以下是这种做法的优点:
鉴于没有任何机制能避免程序员自己造成的错误, 并且这类错误通常不会在生产环境中出现, 所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。
面试话术:
Redis事务其实是把一系列Redis命令放入队列,然后批量执行,执行过程中不会有其它事务来打断。不过与关系型数据库的事务不同,Redis事务不支持回滚操作,事务中某个命令执行失败,其它命令依然会执行。
为了弥补不能回滚的问题,Redis会在事务入队时就检查命令,如果命令异常则会放弃整个事务。
因此,只要程序员编程是正确的,理论上说Redis会正确执行所有事务,无需回滚。
面试官:如果事务执行一半的时候Redis宕机怎么办?
Redis有持久化机制,因为可靠性问题,我们一般使用AOF持久化。事务的所有命令也会写入AOF文件,但是如果在执行EXEC命令之前,Redis已经宕机,则AOF文件中事务不完整。使用 redis-check-aof
程序可以移除 AOF 文件中不完整事务的信息,确保服务器可以顺利启动。