1.微服务架构
1.准备工作
1.1.将单体项目打包到docker
首先我们要将一个单体项目打包到docker来方便后续我们进行维护和修改,将其改造成一个微服务项目,我们将项目文件在linux部署以后,就可以开始我们的准备工作了,关于将项目部署到linux,可以看我这篇博客
1.2认识微服务
要认识微服务,首先要搞清楚和单体架构的区别
单体架构
单体架构就是一个项目完成所有功能,所有代码都是糅合在一起的,所有功能都集成在一个服务器上,这种架构的特点是非常简单,
如果是一个简单项目,就可用单体项目来完成
如果是业务越来越多,功能越来越复杂,缺点就暴露的很明显
1.团队协作成本高:代码量大带来的开发难度大,而且多人开发间很难协调,代码间容易有冲突
2.系统发布效率低:如果我们项目功能越来越多,代码打包编译所需要的时间越来越多
3.系统可用性差: 由于所有功能都是集成在一起的,有些功能由于并发量不同而且重要程度不同,就会造成资源分配不均匀,如访问量高的资源被其他访问量低的高并发接口分配走导致无法访问等
解释一下高并发:
高并发(High Concurrency)是指系统在短时间内处理大量并发请求的能力。在互联网应用中,高并发通常指的是在同一时刻有大量用户同时访问系统或服务。高并发场景常见于电商平台的秒杀活动、社交媒体的热点事件、在线游戏的服务器等。
高并发的特点:
-
大量请求:系统需要在短时间内处理大量的用户请求。
-
资源竞争:多个请求可能同时竞争相同的资源,如数据库、缓存等。
-
响应时间:系统需要在合理的时间内响应每个请求,避免用户等待过久。
-
稳定性:系统需要在高负载下保持稳定,避免崩溃或性能下降。
可以得知,单体架构只适合这种功能相对简单,规模小的项目,如果是访问量大,功能多,开发人员多的项目就不适合
微服务架构
以黑马商城为例:
==>
将单体架构的项目的每个模块拆分成一个个独立的模块
独立的模块尽量满足如下需求:
粒度小就是每个模块只负责本模块需要实现的功能
团队自治就是每一个项目都有一个单独的完整的(包含开发运维等)小团对来负责
服务自治就是部署的时候也要取分别去打包去部署
这就是微服务的理念
这样既保证了服务间的高内聚低耦合,也不会互相影响因为每个服务都有自己的服务器等配置
但是由于服务间的关系更加复杂,我们运维就会更加麻烦,我们可以用到一些微服务的技术栈来解决
1.3 SpringCloud
我们点开springcloud的官网可以看到,左侧栏里都是各类公司的springcloud的组件,是依靠spring的技术来对这些公司的组件进行整合,于是就有了完整的springCloud
同时也有版本要求
我们一般用到就是第二个Jubilee版本
那么我们需要去指定每个组件对应的版本吗,不用
我们在项目里指定了springboot版本后就,在pom文件中再创建一个管理依赖的maven依赖
其中这个标签可以点进去
<artifactId>spring-cloud-dependencies</artifactId>
dian点进去以后可以看到管理了很多组件的依赖,整个所有springcloud组件在这里都有对应的版本
因此我们引入了dencies后就不需要自己去引入依赖了
2.单体项目拆分
以黑马商城为例
我们先要将它们的模块熟悉并根据功能划分
2.1服务拆分的原则
2.2拆分服务
我们了解到了项目模块后,将其划分好模块后,用纵向拆分;
每一个模块作一个独立的服务
其中有两种工程结构:
1.独立Project
我们原先的项目可以看出来只有一个Project
我们拆分以后每一个微服务都是一个Project,对应到idea就是一个独立的窗口,到磁盘中就是一个独立的目录,它们是完全分开的,没有关联的,耦合度就可以降到最低
我们所有文件的Project放到一个文件夹里进行创建,这样它们就被一个文件管理起来了
从磁盘上来讲,它们是一起的,从项目和代码仓库来讲,它们是分开的,使用与大型项目
2.Maven聚合
将整个项目作为一个project,原先的一个个模块是Project的Module ,就是我们项目中Maven管理的方式,每个模块就是一个Module
此时我们每个Module就是一个微服务,看似和我们的单体架构差不多,但是我们每个微服务都在不同的Module中,而运行,部署都是分开的,只有代码是在一起的,所以它们耦合度更高
更加适合小型项目
2.3拆分商品服务
1,我们先创建一个子模块
2.拷贝依赖,我们直接拷贝我们其他子工程的依赖到这个微服务中
再删除一些不需要用如加密等的插件即可
3.复制yml文件并修改配置
将其他工程的yml文件复制到这个微服务,再修改一下配置,如端口(防止冲突),还有微服务名称,以及将里面带有父文件的路径改成当前的路径
我们还要通过每个微服务配置一个database来做数据隔离
由于我们的数据库部署在虚拟机中,我们要保持虚拟机的连接
剩下的contorller等文件我们就直接根据需求把当前微服务需要的拷贝过来就可以了
这就是我们微服务的拆解和配置
最后,如果我们要运行单个的微服务就可以用Alt+8的方式调用一个微服务启动
2.4.远程调用
如果我们在一个微服务中要需要另外一个微服务得到的信息如商品列表等,由于两个微服务是完全分割开的,所以它们无法直接进行调用
但是这两个微服务虽然物理上是分割开的,但是这两个微服务网络是联通的,微服务拆分后数据产生了隔离,无法本地直接调用,所以我们用网络来传输这两个微服务的数据
所以我们要通过java代码发起网络请求,如前端向后端发起请求一样,只不过变成了后端发起请求。
首先我们要了解前端如何向后端发起请求
前端向后端发起的请求为http这种格式,我们java代码就是要模拟这种格式,我们可以在浏览器看到,请求要包含请求方式,请求地址,url路径还有url里的端口,资源路径还有请求参数
我们模拟请求后发给另外一个微服务,就可以拿到另外一个微服务的信息
RestTemplate可以将我们发过来的Json转成java类型
1.首先我们将工具注入到bean中
2.注入bean以后我们就可以在任意的地方使用了
我们用lombok来进行注入
然后定义方法来根据参数来获取数据了
如我们现在要通过一组id来获得这组id的商品信息,就需要做如下配置
特别说明
new ParameterizedTypeReference<List<ItemDTO>>(){
}
是获得Class<ItemDTO> List的一种方法
然后我们就可以在这个方法里获得另外一个微服务获取到的数据了
最后运行后可以看到,我们在一个微服务中调用一个方法,另外一个微服务的方法也根据这个微服务发出的请求来获取了一次数据
最后微服务的拆分和调用运行另外一个微服务的方法的运用就到这
3.服务治理
刚才我们可以看到:一个服务在需要另外一个服务所需要的数据时,需要我们去主动请求别的服务并响应给这个服务
但是如果有很多服务都需要请求这个服务的参数,这个服务的压力就会比较大,我们为了能承受这种高并发的请求,会把这个服务部署多份,每个服务也可以称为一个实例,这种就是多实例部署,我们一般开发就会选择多实例部署
但是问题也接踵而至,我们在请求时,如果ip地址是写死的,有多个实例我们无法灵活地去访问这多个实例,而且如果我们写死的ip的容器出现了错误,我们的请求也会请求不到数据
而且,如果我们有了新的容器也无法去访问这些地址
这种问题就是服务治理问题
我们要解决服务治理问题,就有了注册中心原理
3.1注册中心原理
根据微服务原理我们可以知道,每个服务都可以是服务的调用者(请求别的服务的数据),也可以是服务的提供者(响应数据给其他服务),但是调用者不知道提供者的ip,我们就可以用一个注册中心来配置这些
1.提供者去注册中心注册服务信息,根据功能罗列这些容器的地址。
2.调用者订阅需要的功能,调用者就可以根据功能提供的地址选择地址,这个过程叫做:负载均衡
3.选择地址用到就是各种算法来进行随机选择,轮询选择,保证每个服务被访问的概率都相近
4.随后我们的调用者就会根据这种方式来远程调用这个服务
5.同时我们的提供者会对注册中心发出续约心跳来表明这个服务还能够使用,如果没有这个过程,注册中心就会认为这个服务宕机了(即注册服务信息发生变更),就不会提供这个服务,就不会再给调用者提供这个服务了,同时还会为正在使用这个服务的调用者推送变更,剔除这个宕机的服务在调用列表中
6,如果有了新的服务,只要启动了就会注册,同时触发推送功能
这就是注册中心的原理
3.2Nacos注册中心
我们要使用上一节的注册中心,就需要去这种注册中心的组件,例如Nacos
要使用Nacos,首先要有一个Nacos表,因为Nacos本身就有信息靠数据库储存
这里我们采用docker部署Nacos,将部署文件放到根目录上,文件内容如下:
PREFER_HOST_MODE=hostname
MODE=standalone
SPRING_DATASOURCE_PLATFORM=mysql
MYSQL_SERVICE_HOST=[YourIPAdress]
MYSQL_SERVICE_DB_NAME=nacos
MYSQL_SERVICE_PORT=3306
MYSQL_SERVICE_USER=root
MYSQL_SERVICE_PASSWORD=123
MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
PREFER_HOST_MODE=hostname
MODE=standalone
SPRING_DATASOURCE_PLATFORM=mysql
MYSQL_SERVICE_HOST=192.168.231.129
MYSQL_SERVICE_DB_NAME=nacos
MYSQL_SERVICE_PORT=3306
MYSQL_SERVICE_USER=root
MYSQL_SERVICE_PASSWORD=123
MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
由于可能会下载地比较慢,我们也可以直接在网上找镜像文件来上传到本地
随后加载镜像即可
docker load -i nacos.tar
随后加载容器,运行如下代码:
docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
nacos/nacos-server:v2.1.0-slim
docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
nacos/nacos-server
最后就可以看到运行成功
查询运行日志就可以看到运行成功
3.3服务注册
maven依赖
<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
yml文件地址
spring:
application:
name: item-service # 服务名称
cloud:
nacos:
server-addr: 192.168.150.101:8848 # nacos地址
如果我们想要多实例部署,只需要复制这个实例,再配置一个新的端口号以防端口冲突
运行
就可以看到一个nacos注册的信息了
3.3服务发现
首先前两步和前面一样。
配置一个服务发现的方法,放在需要请求另外一个服务的类上,需要这个数据时直接调用方法
最后就可以使用另外一个服务的数据了
4.OpenFeign
我们之前的服务注册的功能实现非常复杂,我们后续的跨服务操作很难以这种操作来实现
所以我们有了OpenFeign来为我们用封装好了的方法来代替我们复杂的操作
4.1OpenFeign的入门使用
这个客户端可以利用注解来更方便地对其他微服务发送请求
可以看到这个依赖就为我们提供了各类负载均衡器,然后通过注解来开启这个功能
随后编写接口来代替我们之前复杂的操作,接口我们创建一个类,在类上声明,随后直接在请求类中注入并使用方法即可
可以看到,@FeignClient的传参就可以知道请求者的端口,随后的请求方式和路径都代替了先前的请求路径,然后传参就表明了ids,调用方法时的传参就是ids的传参,就可与发出请求,最后结果是json,自动转成对象返回给List,从而获取数据
如图,用List接收表明返回结果是一个List的数据,我们可以根据实际需要来更改
这段简洁的代码就代替我们实现了如下功能
在定义好了接口后,我们就可以直接在请求类中注入并使用方法来获取数据了
就解决了上述的复杂操作了
4.2.OpenFeign连接池
我们在之前的使用中可以看到,我们定义的接口itemClient能直接拿到其对象,就说明是一个代理对象
但是OpenFeign底层是用的IO流的方式来进行发送数据的,效率比较低,因此,用OpenFegin来可以很好地解决这个问题,来提高项目的性能
引入连接池以后我们运行项目可以看到,用于发送请求的delegate变成了连接池的方式来连接
4.3最佳实践
1.
如我们在使用OpenFeign时,每种业务都要去编写对应的Client接口和里面的方法,但是很多方法都是类似的,因为业务要求也类似,这时代码复用性就比较低,而且耦合性高
例如,我们现在有两个微服务需要调用一个微服务里的的数据,我们可以将这个微服务继续拆分,将其拆分成几个模块,在需要调用itemClient时,我们将坐标放入需要请求数据的模块中,在需要调用时就可以直接调用,就可以降低耦合
2.
我们创建一个单独的微服务,创建多个包,客户端包专门用来存放每个微服务需要用的客户端,
dto包专门用来存放公共dto,config包用来存放公共配置,需要用时我们引入依赖即可,这样我们就可以不用拆分原有的模块了,和上面的方法不同,但是增加了代码耦合度
但是我们上面一个微服务需要引用另外一个微服务的类或者配置或者dto时,都可以利用公共包来导入依赖来完成
同时我们一定要注意,在要调用公共包里的类时,由于FeignClient不在扫描范围内,导致无法使用,我们进行如下操作来解决
同时我们一定要注意,在要调用公共包里的类时,我们在这个APPlication里添加下面注解:
如要调用client包里的类
4.4OpenFeign日志输出
可以在pom文件里看到,我们目前日志的级别时debug
我们在需要定义这个日志的Application里定义如上方法和定义完成bean以后
运行就可以看到日志了
5.网关
我们在单体项目的时候,前端在请求后端数据时,只需要请求一个端口就可以请求到这些数据了
但是我们在微服务架构时,由于有多个端口,前端可能不知道请求哪个端口,而且在上线后,这些端口可能会发生变化
而且每个服务都需要做登录校验信息,这样就会很麻烦,并且有密钥泄漏的风险
这个时候我们就要引用网关了
网关就是网络的关口,负责请求的路由,转发,身份校验。
我们通过这个网关进行中间操作,再利用注册中心,网关就可与拉取注册中心的服务,来配合使用
我们只需要暴露给前端一个网关的地址,然后用网关作为路由,来进行更细致的操作
我们一般使用Gateway
5.1网关的路由
网关作为一个中继点有路由的作用,代替繁多的微服务接收一个前端请求,再根据请求来对对应的微服务做请求
网关的服务拉取和微服务的服务注册都可以自动完成,但是网关判断请求属于哪个服务就和业务有关,需要我们开发者来进行处理
我们要配置路由规则,其中最重要的是predicates
我们可以看到,每个微服务都配置一套流程:id uri predicates 来进行对每个微服务配套匹配处理,剩下的就交给网关来处理即可
流程:
我们一般给整个微服务配置一个网关包
然后在yaml文件中将端口定义为8080,这个端口就是前端访问的端口
同时配置nacos加入负载均衡器,来配合我们网关
还有,如果一个微服务要配置多个规则,匹配多个目标,如item有两个controller,我们就可以在规则部分多加一个路径,每个路径名以mapping接口为准
最后如图所示
最后就可以利用网关来为我们的多个微服务密钥检测做路由了,并实现了负载均衡
路由的属性
我们可以在官网上查到更多高级用法,如用filters来对请求头做处理
5.2网关登录校验
由于每个微服务都是独立的,因此我们需要为每个微服务配备一个jwt认证的类,而且也有密钥泄漏的风险,因此我们可以利用网关来做jwt校验,然后再进入微服务中
可以通过每个服务都配备的路由断言来进行匹配,首先我们要了解网关请求处理的流程:
可以看到,过滤器链的执行顺序是从pre到post,因此我们的jwt校验环节要在pre中,去自定义一个过滤器,来完成利用网关来对每一个请求的校验,我们把这个过滤器放到过滤器链的顶部,实现如果开始就出现了错误,就不继续向下执行
但是这个流程也有很多问题需要解决
5.2.1.如何在网关转发之前做登录校验
1.自定义过滤器
在网关内自定义一个过滤器,保证过滤器执行顺序在NettyRoutingFilter之前
如在之前的演示中,我们看到在定义网关时,有一个filtes的参数可以配置
我们来解析每种过滤器:
第一个方法参数exchange,来保存网关内部的一些共享参数的,如request,response,后续的过滤器链都可以从中来读取参数和存储数据
第二个方法参数是过滤器链,当前过滤器执行完后,要调用过滤器链的下一个过滤器
返回值Mono,我们这部分执行完都属于过滤器链中的pre部分,执行完pre部分后,将请求发给微服务,然后post给客户端,但是post的时间一般会比较长,所以利用Mono来调用一个回调函数,这样就不用等待了
我们在原来的网关微服务中创建一个类MyGlobalFilter
然后实现GlobalFilter接口,在方法里来实现jwt认证
如上方法就是对token来进行校验,最后通过chain来返回exchange即请求头给过滤器链中的下一个
实现登录校验
在config文件里有三个是为登录校验服务的
现在我们完成了自定义过滤器的编写,现在我们来实现登录校验功能
在原先的单体项目中,我们利用jks来进行对token进行加密和解析
在ymal文件中,我们可以看到如上配置,password就是利用jks来进行解析的密钥,同时也有一个config类来加载这些属性
同时通过一个SecurityConfig类来实现加密操作
然后在yaml文件下的jwt配置下,还有一段配置
就是对过滤信息进行排除的配置,类似拦截器对登录操作不拦截
最后在AuthProperties类中对这些信息进行处理,includePaths:拦截,excludePaths:不拦截
最后在JwtTool中配置生成jwt和校验token的操作
将这些类拷贝到我们的网关类里,再来编写我们的登录校验逻辑Mono
5.2.2.网关如何将用户信息传递给微服务
我们处理请求后,如何将用户的id username等信息传递给用户,我们在单体项目中一般用ThreadLocal来储存这个线程的信息,但是微服务架构每个微服务都部署在不同的tomcat服务器上,无法用此方法来储存
我们知道一般jwt校验的token里面包含着用户的id username等信息,而网关到微服务之间传递的jwt是不变的,我们就可以利用每次请求的请求头来获取本次请求的用户信息
5.2.3.网关如何将用户信息传递给微服务
由于微服务之间也要传递用户,我们就可以利用之前单体项目的思想,由于进行了登录校验,我们同时将用户信息保存到了请求头中,我们可以利用拦截器将获取用户信息保存到ThreadLocal中,
在过滤器中完成传递用户信息的编写
将获取到的用户信息写入请求头后,就可以定义拦截器了,但是我们不可能在每个微服务中都定义一个拦截器,我们把拦截器放到公共类中
配置拦截器获取用户信息
最后注册拦截器即可
再配置一个文件来加载我们的配置类
此外,我们由于Mvc类里实现了网关的拦截器,而且在commen包里即在微服务里生效,也在网关里生效,我们想让他在微服务里生效而不是网关,否则就会报错,找不到webMvcConfigurer
我们利用springMvc里的注解来设置,这个注解是表示实现下面类时的条件,让其在微服务里生效就使用DispatcherServlet.class来做条件,这个条件是微服务里的,网关里没有,因此就可以实现只在微服务里生效的操作了
5.2.4OpenFeign传递用户
我们在实际运用时,微服务间也会传递用户信息
OpenFeign中提供了一个拦截器接口,所有由OpenFeign发起的请求都会先调用拦截器处理请求
定义一个包来专门存放API
现在我们定义一个注册类来将上述类都放入到bean中
最后在启动类上加上
就可以实现微服务间的用户信息传递了
6.配置管理
我们一个微服务项目内部会有很多微服务,而这些微服务有很多相同的配置,同时维护成本高
这时我们可以利用Nacos做一个配置管理,将相同的配置放进Nacos中,微服务要用到这个配置就可以去里面读取配置
6.1共享配置
我们先将相同的配置,最基础的一些配置添加到Nacos中
将我们的相同部分的yml文件里的配置复制到Nacos里的配置管理菜单里的配置列表
然后在列表里添加内容,将每种配置单独地复制到一个内容里
注意有些配置信息不要写死,写成动态的有助于后续修改
我们在添加了共享配置后,微服务此时无法知道我们添加了这些配置到了Nacos里 ,我们微服务要去拉取Nacos里的信息,如上面的流程图所述
此时我们微服务还是通过读取本地的yml文件来加载配置,我们需要用Nacos取代微服务的本地配置,流程如图所示:
可以看到一开时启动后先加载bootstrap.yml文件,因为我们的SpringCloud是在SpringBoot里的yml文件里,但是我们先启动SpringCloud,因此避免找不到配置,我们定义一个yml文件将SpringCloud配置放到里面,再将SpirngBoot里的这个配置删除即可
同时可以看到我们NacosConfig的流程是用SpringCloud来先加载共享文件,再去加载SpringBoot的本地文件
我们这个配置管理是nacos-config是做配置管理的,我们原先在yml文件里的是discovery做服务发现的,可以看到两个是不同配置,然后下图可以看出是对应每个id来进行配置的
我们只需要在对应的微服务包中配置好信息后,新建bootstrap.yaml文件做好配置后,就可以在其他yaml文件里删除重复的信息了
6.2配置热更新
我们可以发现,原先配置的bootstrap文件就是这种格式
第二个条件有两种方法,一般使用第一种
现在我们又一个需求
首先我们在这个微服务下的config包中定义一个文件来读取需要热更新的配置属性
随后我们在需要这个动态配置的类中注入这个类
再将需要动态读取的方法进行改造
最后在Nacos里添加一个配置,这个配置里包含最大数量
后续我们要想修改配置只需要在nacos网址中编辑这个配置信息即可,不需要修改java代码还有重启微服务
6.3动态路由
我们此时的路由信息是写死在网关里的,当网关启动的时候就会读取网关里的路由信息,由于每次请求都要经过网关,处理量非常大,而且每次都要读取文件信息
所以路由都是在启动网关时加载网关里的的路由信息,将其放到缓存里,再去处理请求的时候直接读取缓存里的信息就可以大大提高访问性能
但是,由于信息放到了缓存中,如果我们路由信息发生了变更,我们只改配置文件也不行,因为是读取的缓存里的信息,必须重启网关才能生效.但是网关是微服务的入口,不能随便重启
因此我们需要路由动态读取配置
我们可以在Nacos官方文档里查询有关信息
监听配置实例:
dataId和group就是需要动态路由的网关配置的nacosID和组
配置动态路由
1.配置文件
在网关微服务中添加nacos配置管理和读取bootstrap文件的依赖
2.引入bootstrap文件
由于我们有了公共配置,所以原先的getway的一些依赖就可以不需要了
3.创建一个包,来储存动态路由配置
随后根据需要路由的网关的id和group来做配置
package com.hmall.gateway.route;
import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.nacos.NacosConfigManager;
import com.alibaba.nacos.api.config.listener.Listener;
import com.alibaba.nacos.api.exception.NacosException;
import com.hmall.common.utils.CollUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import javax.annotation.PostConstruct;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
@Slf4j
@Component
@RequiredArgsConstructor
public class DynamicRouteLoader {
private final RouteDefinitionWriter writer;
private final NacosConfigManager nacosConfigManager;
// 路由配置文件的id和分组
private final String dataId = "gateway-routes.json";
private final String group = "DEFAULT_GROUP";
// 保存更新过的路由id
private final Set<String> routeIds = new HashSet<>();
@PostConstruct
public void initRouteConfigListener() throws NacosException {
// 1.注册监听器并首次拉取配置
String configInfo = nacosConfigManager.getConfigService()
.getConfigAndSignListener(dataId, group, 5000, new Listener() {
@Override
public Executor getExecutor() {
return null;
}
@Override
public void receiveConfigInfo(String configInfo) {
updateConfigInfo(configInfo);
}
});
// 2.首次启动时,更新一次配置
updateConfigInfo(configInfo);
}
private void updateConfigInfo(String configInfo) {
log.debug("监听到路由配置变更,{}", configInfo);
// 1.反序列化
List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
// 2.更新前先清空旧路由
// 2.1.清除旧路由
for (String routeId : routeIds) {
writer.delete(Mono.just(routeId)).subscribe();
}
routeIds.clear();
// 2.2.判断是否有新的路由要更新
if (CollUtils.isEmpty(routeDefinitions)) {
// 无新路由配置,直接结束
return;
}
// 3.更新路由
routeDefinitions.forEach(routeDefinition -> {
// 3.1.更新路由
writer.save(Mono.just(routeDefinition)).subscribe();
// 3.2.记录路由id,方便将来删除
routeIds.add(routeDefinition.getId());
});
}
}
以上就是微服务最常用的一些组件的功能
7.微服务保护和分布式事务
7.1雪崩问题
如果一个服务要调用另外一个服务,形成的调用链,如果一个服务出现了问题,调用它的和被他调用的就会出现问题,就会引起其他服务也出现问题,例如一个请求时间过长,其他请求也被卡在这,形成阻塞,资源分配不均匀,导致整个服务雪崩
7.2.服务保护方案
微服务保护的方案有很多,比如:
-
请求限流
-
线程隔离
-
服务熔断
这些方案或多或少都会导致服务的体验上略有下降,比如请求限流,降低了并发上限;线程隔离,降低了可用资源数量;服务熔断,降低了服务的完整度,部分服务变的不可用或弱可用。因此这些方案都属于服务降级的方案。但通过这些方案,服务的健壮性得到了提升,
接下来,我们就逐一了解这些方案的原理。
7.2.1请求限流
请求限流就是限制访问微服务的请求的并发量,避免服务因流量激增而出现故障
7.2.2线程隔离
当一个业务接口响应时间长,而且并发高时,就可能耗尽服务器的线程资源,导致服务内的其它接口受到影响。所以我们必须把这种影响降低,或者缩减影响的范围。线程隔离正是解决这个问题的好办法。
为了避免某个接口故障或压力过大导致整个服务不可用,我们可以限定每个接口可以使用的资源范围,也就是将其“隔离”起来。
7.2.3.服务熔断
线程隔离虽然避免了雪崩问题,但故障服务(商品服务)依然会拖慢购物车服务(服务调用方)的接口响应速度。而且商品查询的故障依然会导致查询购物车功能出现故障,购物车业务也变的不可用了。
所以,我们要做两件事情:
-
编写服务降级逻辑:就是服务调用失败后的处理逻辑,根据业务场景,可以抛出异常,也可以返回友好提示或默认数据。
-
异常统计和熔断:统计服务提供方的异常比例,当比例过高表明该接口会影响到其它服务,应该拒绝调用该接口,而是直接走降级逻辑
在了解这些处理雪崩问题的方法后,我们就可以利用微服务保护技术来实现这些了
7.3Sentinel
微服务保护的技术有很多,但在目前国内使用较多的还是Sentinel,所以接下来我们学习Sentinel的使用。
首先下载Sentinel可以参考sentinel官方网站来下载
https://sentinelguard.io/zh-cn/
下载jar包以后注意其安装路径不能又中文,随后下载后运行如下命令在cmd控制台中加载配置
java -Dserver.port=8090 -Dcsp.sentinel.dashboard.server=localhost:8090 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar
配置参数统一可以参考官方文档
-
核心库(Jar包):不依赖任何框架/库,能够运行于 Java 8 及以上的版本的运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持。在项目中引入依赖即可实现服务限流、隔离、熔断等功能。
-
控制台(Dashboard):Dashboard 主要负责管理推送规则、监控、管理机器信息等。
访问http://localhost:8090页面,就可以看到sentinel的控制台了:
需要输入账号和密码,默认都是:sentinel
7.3.1微服务整合
我们在需要进行整合的模块中整合sentinel,连接sentinel-dashboard控制台
1.引入sentinel依赖
<!--sentinel-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
2.配置控制台
修改application.yaml文件,添加下面内容:
Sentinel 的使用可以分为两个部分:
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090
配置完成以后,我们访问这个模块的任意端点,sentinel的客户端就会将服务访问的信息提交到sentinel-dashboard
控制台。并展示出统计信息,就可以看到sentinel的统计信息了
点击簇点链路菜单,会看到下面的页面:
所谓簇点链路,就是单机调用链路,是一次请求进入服务后经过的每一个被Sentinel
监控的资源。默认情况下,Sentinel
会监控SpringMVC
的每一个Endpoint
(接口)。
因此,我们看到/carts这个接口路径就是其中一个簇点,我们可以对其进行限流、熔断、隔离等保护措施。
我们的购物车的操作接口必须都是/carts路径
默认情况下Sentinel会把路径作为蔟点资源的名称,也会把请求方式不同的操作识别成一个蔟点资源,这时就无法区分路径相同但请求路径不同的接口
所以我们可以选择打开Sentinel的请求方式前缀,把请求方式 + 请求路径
作为簇点资源名:
首先,在cart-service的application.yml中添加下面的配置
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090
http-method-specify: true # 开启请求方式前缀
然后,重启服务,通过页面访问购物车的相关接口,可以看到sentinel控制台的簇点链路发生了变化:
这时就可以区分请求了
7.3.2请求限流
在完成了Sentinel的基础操作后,就可以开始配置Sentinel了
在簇点链路后面点击流控按钮,即可对其做限流配置:
在弹出的菜单中这样填写:
表示把查询购物车列表的这个蔟点资源的流量限制在了每秒6个,也就是最大QPS为6
这样就可以将每个请求的流量限制住来防止发生阻塞了
7.3.3.线程隔离
限流可以降低服务器压力,尽量减少因并发流量引起的服务故障的概率,但并不能完全避免服务故障。一旦某个服务出现故障,我们必须隔离对这个服务的调用,避免发生雪崩。
比如,查询购物车的时候需要查询商品,为了避免因商品服务出现故障导致购物车服务级联失败,我们可以把购物车业务中查询商品的部分隔离起来,限制可用的线程资源:
所以可以看到,对于这种重要的中间微服务就有必要做隔离,于是就可以对模块里的FeignClient接口做线程隔离
首先需要OpenFeign整合Sentinel
修改cart-service模块的application.yml文件,开启Feign的sentinel功能:
feign:
sentinel:
enabled: true # 开启feign对sentinel的支持
所以我们需要配置一下cart-service模块的application.yml文件,修改tomcat连接:
server:
port: 8082
tomcat:
threads:
max: 50 # 允许的最大线程数
accept-count: 50 # 最大排队等待数量
max-connections: 100 # 允许的最大连接
随后重启服务,我们就可以在Sentinel里看到这个模块的FeignClient变成了一个蔟点资源了
然后我们就可以在这里进行配置隔离了
选择第二种方法来限制
注意,这里勾选的是并发线程数限制,也就是说这个查询功能最多使用5个线程,而不是5QPS。如果查询商品的接口每秒处理2个请求,则5个线程的实际QPS在10左右,而超出的请求自然会被拒绝。
7.3.4.服务熔断
由于查询商品的功能耗时较高(我们模拟了500毫秒延时),再加上线程隔离限定了线程数为5,导致接口吞吐能力有限,最终QPS只有10左右。这就导致了几个问题:
第一,超出的QPS上限的请求就只能抛出异常,从而导致购物车的查询失败。但从业务角度来说,即便没有查询到最新的商品信息,购物车也应该展示给用户,用户体验更好。也就是给查询失败设置一个降级处理逻辑。
第二,由于查询商品的延迟较高(模拟的500ms),从而导致查询购物车的响应时间也变的很长。这样不仅拖慢了购物车服务,消耗了购物车服务的更多资源,而且用户体验也很差。对于商品服务这种不太健康的接口,我们应该直接停止调用,直接走降级逻辑,避免影响到当前服务。也就是将商品查询接口熔断。
编写降级逻辑
触发限流或熔断后的请求不一定要直接报错,也可以返回一些默认数据或者友好提示,用户体验会更好。
给FeignClient编写失败后的降级逻辑有两种方式:
-
方式一:FallbackClass,无法对远程调用的异常做处理
-
方式二:FallbackFactory,可以对远程调用的异常做处理,我们一般选择这种方式。
这里我们演示方式二的失败降级处理。
步骤一:在hm-api模块中给ItemClient
定义降级处理类,实现FallbackFactory
:
实现代码:
package com.hmall.api.client.fallback;
import com.hmall.api.client.ItemClient;
import com.hmall.api.dto.ItemDTO;
import com.hmall.api.dto.OrderDetailDTO;
import com.hmall.common.exception.BizIllegalException;
import com.hmall.common.utils.CollUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import java.util.Collection;
import java.util.List;
@Slf4j
public class ItemClientFallback implements FallbackFactory<ItemClient> {
@Override
public ItemClient create(Throwable cause) {
return new ItemClient() {
@Override
public List<ItemDTO> queryItemByIds(Collection<Long> ids) {
log.error("远程调用ItemClient#queryItemByIds方法出现异常,参数:{}", ids, cause);
// 查询购物车允许失败,查询失败,返回空集合
return CollUtils.emptyList();
}
@Override
public void deductStock(List<OrderDetailDTO> items) {
// 库存扣减业务需要触发事务回滚,查询失败,抛出异常
throw new BizIllegalException(cause);
}
};
}
}
步骤二:在hm-api
模块中的com.hmall.api.config.DefaultFeignConfig
类中将ItemClientFallback
注册为一个Bean
:
步骤三:在hm-api
模块中的ItemClient
接口中使用ItemClientFallbackFactory
:
解决完这个问题后,就可以开始编写服务熔断的内容了
服务熔断
查询商品的RT较高(模拟的500ms),从而导致查询购物车的RT也变的很长。这样不仅拖慢了购物车服务,消耗了购物车服务的更多资源,而且用户体验也很差。
对于商品服务这种不太健康的接口,我们应该停止调用,直接走降级逻辑,避免影响到当前服务。也就是将商品查询接口熔断。当商品服务接口恢复正常后,再允许调用。这其实就是断路器的工作模式了。
Sentinel中的断路器不仅可以统计某个接口的慢请求比例,还可以统计异常请求比例。当这些比例超出阈值时,就会熔断该接口,即拦截访问该接口的一切请求,降级处理;当该接口恢复正常时,再放行对于该接口的请求。
断路器的工作状态切换有一个状态机来控制:
状态机包括三个状态:
-
closed:关闭状态,断路器放行所有请求,并开始统计异常比例、慢请求比例。超过阈值则切换到open状态
-
open:打开状态,服务调用被熔断,访问被熔断服务的请求会被拒绝,快速失败,直接走降级逻辑。Open状态持续一段时间后会进入half-open状态
-
half-open:半开状态,放行一次请求,根据执行结果来判断接下来的操作。
-
请求成功:则切换到closed状态
-
请求失败:则切换到open状态
-
我们可以在控制台通过点击簇点后的熔断
按钮来配置熔断策略:
在弹出的表格中这样填写:
这种是按照慢调用比例来做熔断,上述配置的含义是:
-
RT超过200毫秒的请求调用就是慢调用
-
统计最近1000ms内的最少5次请求,如果慢调用比例不低于0.5,则触发熔断
-
熔断持续时长20s
这样就完成了熔断操作
7.4分布式服务
我们首先了解项目种的下单业务的整体流程
由于订单、购物车、商品分别在三个不同的微服务,而每个微服务都有自己独立的数据库,因此下单过程中就会跨多个数据库完成业务。而每个微服务都会执行自己的本地事务:
-
交易服务:下单事务
-
购物车服务:清理购物车事务
-
库存服务:扣减库存事务
整个业务中,各个本地事务是有关联的。因此每个微服务的本地事务,也可以称为分支事务。多个有关联的分支事务一起就组成了全局事务。我们必须保证整个全局事务同时成功或失败。
我们知道每一个分支事务就是传统的单体事务,都可以满足ACID特性,但全局事务跨越多个服务、多个数据库,是否还能满足呢?
如我们将购物车里的商品提交订单,然后将某个库存改为0,此时会导致下单失败,但是购物车依然被清空了,证明事务回滚失败
事务并未遵循ACID的原则,归其原因就是参与事务的多个子业务在不同的微服务,跨越了不同的数据库。虽然每个单独的业务都能在本地遵循ACID,但是它们互相之间没有感知,不知道有人失败了,无法保证最终结果的统一,也就无法遵循ACID的事务特性了。
这就是分布式事务问题,出现以下情况之一就可能产生分布式事务问题:
-
业务跨多个服务实现
-
业务跨多个数据源实现
7.4.1Seata
解决分布式事务的方案有很多,但实现起来都比较复杂,因此我们一般会使用开源的框架来解决分布式事务问题。在众多的开源分布式事务框架中,功能最完善、使用最多的就是阿里巴巴在2019年开源的Seata了。
在事务管理中的三个重要角色
-
TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
-
TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
-
RM (Resource Manager) - 资源管理器:管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
以下是Seata的工作架构
其中,TM和RM可以理解为Seata的客户端部分,引入到参与事务的微服务依赖中即可。将来TM和RM就会协助微服务,实现本地分支事务与TC之间交互,实现事务的提交或回滚。
而TC服务则是事务协调中心,是一个独立的微服务,需要单独部署。
7.4.2部署TC服务
1.docker部署
Seata支持多种存储模式,但是一般要考虑到持久化的需要,选择基于数据库进行存储,可以在官网找到运行seata运行所需的配置文件和数据库
将配置文件拷贝到虚拟机的/root目录下,然后拉取镜像下载容器
docker run --name seata \
-p 8099:8099 \
-p 7099:7099 \
-e SEATA_IP=192.168.150.101 \
-v ./seata:/seata-server/resources \
--privileged=true \
--network hm-net \
-d \
seataio/seata-server:1.5.2
7.4.3.微服务部署
参与分布式事务的每一个微服务都需要集成Seata,我们以trade-service
为例。
为了方便各个微服务集成seata,我们需要把seata配置共享到nacos,因此trade-service
模块不仅仅要引入seata依赖,还要引入nacos依赖:
<!--统一配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
3.改造配置
首先在nacos上添加一个共享的seata配置,命名为shared-seata.yaml
:
配置如下:
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 192.168.150.101:8848 # nacos地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-server # seata服务名称
username: nacos
password: nacos
tx-service-group: hmall # 事务组名称
service:
vgroup-mapping: # 事务组与tc集群的映射关系
hmall: "default"
然后改造trade-service模块,添加bootstrap.yaml
内容如下:
spring:
application:
name: trade-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.150.101 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
- dataId: shared-seata.yaml # 共享seata配置
可以看到这里加载了共享的seata配置。
然后改造application.yaml文件,内容如下:
server:
port: 8085
feign:
okhttp:
enabled: true # 开启OKHttp连接池支持
sentinel:
enabled: true # 开启Feign对Sentinel的整合
hm:
swagger:
title: 交易服务接口文档
package: com.hmall.trade.controller
db:
database: hm-trade
随后的微服务都可以用这种方法来配置事务回滚
4.添加数据库表
seata的客户端在解决分布式事务的时候需要记录一些中间数据,保存在数据库中。因此我们要先准备一个这样的表。
我们将seata-at.sql导入到我们需要的微服务模块中,就完成了对这些服务的配置
接下来就是测试的分布式事务的时候了。
我们找到trade-service
模块下的com.hmall.trade.service.impl.OrderServiceImpl
类中的createOrder
方法,也就是下单业务方法。
将其上的@Transactional
注解改为Seata提供的@GlobalTransactional
:
就解决了分布式事务的问题了
Seata解决分布式事务的原理如下:
7.5.XA模式
Seata支持四种不同的分布式事务解决方案:
-
XA
-
TCC
-
AT
-
SAGA
这里我们以XA
模式和AT
模式来给大家讲解其实现原理。
XA
规范 是
X/Open组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM
与局部的RM
之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持
两阶段相交
A是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。
正常情况:
异常情况:
一阶段:
-
事务协调者通知每个事务参与者执行本地事务
-
本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交,继续持有数据库锁
二阶段:
-
事务协调者基于一阶段的报告来判断下一步操作
-
如果一阶段都成功,则通知所有事务参与者,提交事务
-
如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
7.5.1Seata的XA模型
Seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图:
一阶段的工作:
-
注册分支事务到
TC
-
执行分支业务sql但不提交
-
报告执行状态到
TC
TC
二阶段的工作:
-
TC
检测各分支事务执行状态-
如果都成功,通知所有RM提交事务
-
如果有失败,通知所有RM回滚事务
-
RM
二阶段的工作:
-
接收
TC
指令,提交或回滚事务
实现步骤:
首先,我们要在配置文件中指定要采用的分布式事务模式。我们可以在Nacos中的共享shared-seata.yaml配置文件中设置:
seata:
data-source-proxy-mode: XA
其次,我们要利用@GlobalTransactional
标记分布式事务的入口方法:
7.6XA模式
7.6.2.Seata的AT模型
AT
模式同样是分阶段提交的事务模型,不过缺弥补了XA
模型中资源锁定周期过长的缺陷。
基本流程图:
阶段一RM
的工作:
-
注册分支事务
-
记录undo-log(数据快照)
-
执行业务sql并提交
-
报告事务状态
阶段二提交时RM
的工作:
-
删除undo-log即可
阶段二回滚时RM
的工作:
-
根据undo-log恢复数据到更新前
流程
AT模式下,当前分支事务执行流程如下:
一阶段:
-
TM
发起并注册全局事务到TC
-
TM
调用分支事务 -
分支事务准备执行业务SQL
-
RM
拦截业务SQL,根据where条件查询原始数据,形成快照。
{
"id": 1, "money": 100
}
-
RM
执行业务SQL,提交本地事务,释放数据库锁。此时 money = 90 -
RM
报告本地事务状态给TC
二阶段:
-
TM
通知TC
事务结束 -
TC
检查分支事务状态-
如果都成功,则立即删除快照
-
如果有分支事务失败,需要回滚。读取快照数据({"id": 1, "money": 100}),将快照恢复到数据库。此时数据库再次恢复为100
-
7.7.AT与XA的区别
简述AT
模式与XA
模式最大的区别是什么?
-
XA
模式一阶段不提交事务,锁定资源;AT
模式一阶段直接提交,不锁定资源。 -
XA
模式依赖数据库机制实现回滚;AT
模式利用数据快照实现数据回滚。 -
XA
模式强一致;AT
模式最终一致
可见,AT模式使用起来更加简单,无业务侵入,性能更好。因此企业90%的分布式事务都可以用AT模式来解决。
8.RabbitMQ
RabbitMQ是一个高性能异步通讯工具,同时也是一个分布式中间件
1.MQ基础
1.1理解同步调用和异步调用的区别
微服务一旦拆分,必然涉及到服务之间的相互调用,目前我们服务之间调用采用的都是基于OpenFeign的调用。这种调用中,调用者发起请求后需要等待服务提供者执行业务返回结果后,才能继续执行后面的业务。也就是说调用者在调用过程中处于阻塞状态,因此我们称这种调用方式为同步调用,也可以叫同步通讯。但在很多场景下,我们可能需要采用异步通讯的方式,为什么呢?
-
同步通讯:就如同打视频电话,双方的交互都是实时的。因此同一时刻你只能跟一个人打视频电话。
-
异步通讯:就如同发微信聊天,双方的交互不是实时的,你不需要立刻给对方回应。因此你可以多线操作,同时跟多人聊天。
同步调用
例如我们的OpenFeign就是一个同步请求,也就是阻塞式,每个阶段的操作都必须依次执行,才能执行下一个任务,这样会浪费很多时间
目前我们采用的是基于OpenFeign的同步调用,也就是说业务执行流程是这样的:
-
支付服务需要先调用用户服务完成余额扣减
-
然后支付服务自己要更新支付流水单的状态
-
然后支付服务调用交易服务,更新业务订单状态为已支付
三个步骤依次执行。执行效率较低,而且处理高并发的请求会比较吃力
缺点如下:
-
拓展性差
-
性能下降
-
级联失败
而要解决这些问题,我们就必须用异步调用的方式来代替同步调用。
1.2异步调用
例如
异步调用方式其实就是基于消息通知的方式,一般包含三个角色:
-
消息发送者:投递消息的人,就是原来的调用方
-
消息Broker:管理、暂存、转发消息,你可以把它理解成微信服务器
-
消息接收者:接收和处理消息的人,就是原来的服务提供方
通过将消息存放到消息代理的部分里,然后发送者可以去做同步调用,节省时间
在异步调用中,发送者不再直接同步调用接收者的业务接口,而是发送一条消息投递给消息Broker。然后接收者根据自己的需求从消息Broker那里订阅消息。每当发送方发送消息后,接受者都能获取消息并处理。
这样,发送消息的人和接收消息的人就完全解耦了。
什么样的情况下用异步
核心业务用同步调用,非核心,不需要时效性的就可以用异步
如支付业务为例:
例如第一个空间余额用同步调用就比较合理,因为扣减的成功与否才能决定是否进行下一步
而更新订单和通知不是支付服务的核心业务,不需要时效性,可以用异步来代替
通过MQ做一个中间件,来将信息分发给每一个订阅了的微服务,处理各自的事务
同时,对于新需求,如提出了新的需求,比如要在支付成功后更新用户积分。支付代码完全不用变更,而仅仅是让积分服务也订阅消息即可
不管后期增加了多少消息订阅者,作为支付服务来讲,执行问扣减余额、更新支付流水状态后,发送消息即可。业务耗时仅仅是这三部分业务耗时,仅仅100ms,大大提高了业务性能。
另外,不管是交易服务、通知服务,还是积分服务,他们的业务与支付关联度低。现在采用了异步调用,解除了耦合,他们即便执行过程中出现了故障,也不会影响到支付服务(保证核心业务同步)。
综上,异步调用的优势包括:
-
耦合度更低
-
性能更好
-
业务拓展性强
-
故障隔离,避免级联失败
-
缓存消息,流量削峰填谷
当然,异步通信也并非完美无缺,它存在下列缺点:
-
完全依赖于Broker的可靠性、安全性和性能
-
架构复杂,后期维护和调试麻烦
-
不确定下游业务是否执行成功
-
不能立即得到调用结果,时效性差
1.2.技术选型
消息Broker,目前常见的实现方案就是消息队列(MessageQueue),简称为MQ.
目比较常见的MQ实现:
-
ActiveMQ
-
RabbitMQ
-
RocketMQ
-
Kafka
追求可用性:Kafka、 RocketMQ 、RabbitMQ
追求可靠性:RabbitMQ、RocketMQ
追求吞吐能力:RocketMQ、Kafka
追求消息低延迟:RabbitMQ、Kafka
一般RabbitMQ适合微服务开发
1.3.RabbitMQ安装
使用Docker来安装部署Rabbit
docker run \
-e RABBITMQ_DEFAULT_USER=itheima \
-e RABBITMQ_DEFAULT_PASS=123321 \
-v mq-plugins:/plugins \
--name mq \
--hostname mq \
-p 15672:15672 \
-p 5672:5672 \
--network hm-net\
-d \
rabbitmq:3.8-management
可以看到在安装命令中有两个映射的端口:
-
15672:RabbitMQ提供的管理控制台的端口
-
5672:RabbitMQ的消息发送处理接口
安装完成后,我们访问 http://[yourport]:15672即可看到管理控制台。首次访问需要登录,默认的用户名和密码在配置文件中已经指定了。
RabbitMQ的架构:
其中包含几个概念:
-
publisher
:生产者,也就是发送消息的一方 -
consumer
:消费者,也就是消费消息的一方 -
queue
:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理 -
exchange
:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。 -
virtual host
:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的exchange、queue
1.4收发消息
1.4.1交换机
我们打开Exchanges选项卡,可以看到已经存在很多交换机:
我们点击任意交换机,即可进入交换机详情页面。仍然会利用控制台中的publish message 发送一条消息:
这里是由控制台模拟了生产者发送的消息。由于没有消费者存在,最终消息丢失了,这样说明交换机没有存储消息的能力。
交换机的使用事项:
1.交换机只能路由消息,不能储存消息
2.交换机只会路由消息给其他绑定的队列,因此队列必须于交换机绑定
1.4.2队列
我们打开Queues
选项卡,新建一个队列:
命名为hello.queque1
再以相同的方式,创建一个队列,密码自定,最后列表如下:
此时,我们再次向amq.fanout
交换机发送一条消息。会发现消息依然没有到达队列!!
怎么回事呢?
发送到交换机的消息,只会路由到与其绑定的队列,因此仅仅创建队列是不够的,我们还需要将其与交换机绑定。
1.4.3.绑定关系
点击Exchanges
选项卡,点击amq.fanout
交换机,进入交换机详情页,然后点击Bindings
菜单,在表单中填写要绑定的队列名称:
相同的方式,将第二个队列也绑定到改交换机。
最终,绑定结果如下:
1.4.4发送消息
回到exchange页面,找到刚刚绑定的amq.fanout,进入详情页再发送消息
此时我们回到queues页面就可以看到hello.queque里有一条消息了
点击队列名称,进入详情页,查看队列详情,这次我们点击get message:
可以看到消息到达队列了:
1.5数据隔离
1.5.1用户管理
点击admin选项卡,首先会看到RabbitMQ控制台的用户管理界面:
这里的用户都是RabbitMQ的管理或运维人员。目前只有安装RabbitMQ时添加的itheima
这个用户。仔细观察用户表格中的字段,如下:
-
Name
:itheima
,也就是用户名 -
Tags
:administrator
,说明itheima
用户是超级管理员,拥有所有权限 -
Can access virtual host
:/
,可以访问的virtual host
,这里的/
是默认的virtual host
对于小型企业而言,出于成本考虑,我们通常只会搭建一套MQ集群,公司内的多个不同项目同时使用。这个时候为了避免互相干扰, 我们会利用virtual host
的隔离特性,将不同项目隔离。一般会做两件事情:
-
给每个项目创建独立的运维账号,将管理权限分离。
-
给每个项目创建不同的
virtual host
,将每个项目的数据隔离。
比如,我们给黑马商城创建一个新的用户,命名为hmall
:
你会发现此时hmall用户没有任何virtual host
的访问权限:
现在我们来进行授权
1.5.2virtual host
我们先退出登录:
切换到刚刚创建的hmall用户登录,然后点击Virtual Hosts
菜单,进入virtual host
管理页:
可以看到目前只有一个默认的virtual host
,名字为 /
。
我们可以给黑马商城项目创建一个单独的virtual host
,而不是使用默认的/
。
创建完成后如图:
由于我们是登录hmall
账户后创建的virtual host
,因此回到users
菜单,你会发现当前用户已经具备了对/hmall
这个virtual host
的访问权限了:
此时,点击页面右上角的virtual host
下拉菜单,切换virtual host
为 /hmall
:
然后再次查看queues选项卡,会发现之前的队列已经看不到了:
这就是基于virtual host
的隔离效果。
1.6SpringAMQP
SpringAMQP是一个java的客户端
将来我们开发业务功能的时候,肯定不会在控制台收发消息,而是应该基于编程的方式。由于RabbitMQ
采用了AMQP协议,因此它具备跨语言的特性。任何语言只要遵循AMQP协议收发消息,都可以与RabbitMQ
交互。并且RabbitMQ
官方也提供了各种不同语言的客户端。
但是,RabbitMQ官方提供的Java客户端编码相对复杂,一般生产环境下我们更多会结合Spring来使用。而Spring的官方刚好基于RabbitMQ提供了这样一套消息收发的模板工具:SpringAMQP。并且还基于SpringBoot对其实现了自动装配,使用起来非常方便。
SpringAMQP提供了三个功能:
-
自动声明队列、交换机及其绑定关系
-
基于注解的监听器模式,异步接收消息
-
封装了RabbitTemplate工具,用于发送消息
现在我们把这个与MQ联通的工具在java项目中运行起来
结构如图所示
包括三部分:
-
mq-demo:父工程,管理项目依赖
-
publisher:消息的发送者
-
consumer:消息的消费者
我们可以在mq-demo中看到依赖为:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.itcast.demo</groupId>
<artifactId>mq-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<modules>
<module>publisher</module>
<module>consumer</module>
</modules>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.12</version>
<relativePath/>
</parent>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--AMQP依赖,包含RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!--单元测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
这时就可以使用SpringAMQP了
我们之前是通过交换机发送消息到队列,我们也可以直接向队列发送消息,跳过交换机这一步
也就是:
- publicsher直接发送消息到队列
- 监听者处理队列中的消息
现在我们有如下需求
我们现在新建一个队列:simple.queque
添加成功了以后就可以利用java代码来收发消息了
1.6.1消息发送
首先配置MQ地址,在publisher
服务的application.yml
中添加配置:
spring:
rabbitmq:
host: 192.168.150.101 # 你的虚拟机IP
port: 5672 # 端口
virtual-host: /hmall # 虚拟主机
username: hmall # 用户名
password: 123 # 密码
然后在publisher
服务中编写测试类SpringAmqpTest
,并利用RabbitTemplate
实现消息发送:
package com.itheima.publisher.amqp;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class SpringAmqpTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testSimpleQueue() {
// 队列名称
String queueName = "simple.queue";
// 消息
String message = "hello, spring amqp!";
// 发送消息
rabbitTemplate.convertAndSend(queueName, message);
}
}
打开控制台,可以看到消息已经发送到队列中:
随后就可以实现消息接收
1.6.2消息接收
首先配置MQ地址,在consumer
服务的application.yml
中添加配置:
spring:
rabbitmq:
host: 192.168.150.101 # 你的虚拟机IP
port: 5672 # 端口
virtual-host: /hmall # 虚拟主机
username: hmall # 用户名
password: 123 # 密码
然后在consumer
服务的com.itheima.consumer.listener
包中新建一个类SpringRabbitListener
,代码如下:
package com.itheima.consumer.listener;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
public class SpringRabbitListener {
// 利用RabbitListener来声明要监听的队列信息
// 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。
// 可以看到方法体中接收的就是消息体的内容
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
System.out.println("spring 消费者接收到消息:【" + msg + "】");
}
}
然后我们就可以开始测试了
启动consumer服务,然后在publisher服务中运行测试代码,发送MQ消息。最终consumer收到消息:
1.7WorkQueues模型
Work queues,任务模型。简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。
当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。
此时就可以使用work 模型,多个消费者共同处理消息处理,消息处理的速度就能大大提高了。
接下来,我们就来模拟这样的场景。
首先,我们在控制台创建一个新的队列,命名为work.queue
:
1.7.1消息发送
这次我们循环发送,模拟大量消息堆积现象。
在publisher服务中的SpringAmqpTest类中添加一个测试方法:
/**
* workQueue
* 向队列中不停发送消息,模拟消息堆积。
*/
@Test
public void testWorkQueue() throws InterruptedException {
// 队列名称
String queueName = "simple.queue";
// 消息
String message = "hello, message_";
for (int i = 0; i < 50; i++) {
// 发送消息,每20毫秒发送一次,相当于每秒发送50条消息
rabbitTemplate.convertAndSend(queueName, message + i);
Thread.sleep(20);
}
}
1.7.2消息接收
要模拟多个消费者绑定同一个队列,我们在consumer服务的SpringRabbitListener中添加2个新的方法:
@RabbitListener(queues = "work.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(20);
}
@RabbitListener(queues = "work.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(200);
}
注意到这两消费者,都设置了Thead.sleep
,模拟任务耗时:
-
消费者1 sleep了20毫秒,相当于每秒钟处理50个消息
-
消费者2 sleep了200毫秒,相当于每秒处理5个消息
可以看出来,实施的是能者多劳的机制
可以发现,由于消费者1处理速度较快,所以处理了更多的消息;消费者2处理速度较慢,只处理了6条消息。而最终总的执行耗时也在1秒左右,大大提升。
正所谓能者多劳,这样充分利用了每一个消费者的处理能力,可以有效避免消息积压问题。
1.8交换机类型
在之前的两个测试案例中,都没有交换机,生产者直接发送消息到队列。而一旦引入交换机,消息发送的模式会有很大变化:
可以看到,在订阅模型中,多了一个exchange角色,而且过程略有变化:
-
Publisher:生产者,不再发送消息到队列中,而是发给交换机
-
Exchange:交换机,一方面,接收生产者发送的消息。另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。到底如何操作,取决于Exchange的类型。
-
Queue:消息队列也与以前一样,接收消息、缓存消息。不过队列一定要与交换机绑定。
-
Consumer:消费者,与以前一样,订阅队列,没有变化
Exchange(交换机)只负责转发消息,不具备存储消息的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!
交换机的类型有四种:
-
Fanout:广播,将消息交给所有绑定到交换机的队列。我们最早在控制台使用的正是Fanout交换机
-
Direct:订阅,基于RoutingKey(路由key)发送给订阅了消息的队列
-
Topic:通配符订阅,与Direct类似,只不过RoutingKey可以使用通配符
-
Headers:头匹配,基于MQ的消息头匹配,用的较少。
课堂中,我们讲解前面的三种交换机模式。
1.8.1FanOut交换机
Fanout,英文翻译是扇出,我觉得在MQ中叫广播更合适。
在广播模式下,消息发送流程是这样的:
-
1) 可以有多个队列
-
2) 每个队列都要绑定到Exchange(交换机)
-
3) 生产者发送的消息,只能发送到交换机
-
4) 交换机把消息发送给绑定过的所有队列
-
5) 订阅队列的消费者都能拿到消息
声明队列和交换机
在控制台创建队列fanout.queue1
:
再创建一个队列fanout.queue2
:
然后再创建一个交换机:
然后绑定两个队列到交换机:
消息发送
在publisher服务的SpringAmqpTest类中添加测试方法:
@Test
public void testFanoutExchange() {
// 交换机名称
String exchangeName = "hmall.fanout";
// 消息
String message = "hello, everyone!";
rabbitTemplate.convertAndSend(exchangeName, "", message);
}
消息接收
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
System.out.println("消费者1接收到Fanout消息:【" + msg + "】");
}
@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
System.out.println("消费者2接收到Fanout消息:【" + msg + "】");
}
1.8.2.Direct交换机
在Fanout模式中,一条消息,会被所有订阅的队列都消费。但是,在某些场景下,我们希望不同的消息被不同的队列消费。这时就要用到Direct类型的Exchange。
在Direct模型下:
-
队列与交换机的绑定,不能是任意绑定了,而是要指定一个
RoutingKey
(路由key) -
消息的发送方在 向 Exchange发送消息时,也必须指定消息的
RoutingKey
。 -
Exchange不再把消息交给每一个绑定的队列,而是根据消息的
Routing Key
进行判断,只有队列的Routingkey
与消息的Routing key
完全一致,才会接收到消息
案例需求如图:
-
声明一个名为
hmall.direct
的交换机 -
声明队列
direct.queue1
,绑定hmall.direct
,bindingKey
为blud
和red
-
声明队列
direct.queue2
,绑定hmall.direct
,bindingKey
为yellow
和red
-
在
consumer
服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2 -
在publisher中编写测试方法,向
hmall.direct
发送消息
声明队列和交换机
首先在控制台声明两个队列direct.queue1
和direct.queue2
,这里不再展示过程:
然后声明一个direct类型的交换机,命名为hmall.direct
: