微服务:源起、发展和基础
通常跟微服务相对的是单体应用,即将所有功能都打包成在一个独立单元的应用程序。从单体应用到微服务并不是一蹴而就的,这是一个逐渐演变的过程。
演变
几年前,的超时管理后台。
我们整理一下功能清单:
- 网站
- 用户注册、登录功能
- 商品展示
- 下单
- 管理后台
- 用户管理
- 商品管理
- 订单管理
总体架构图如下:
随后,增加了促销管理模块和移动端
这一阶段存在很多不合理的地方:
- 网站和移动端应用有很多相同业务逻辑的重复代码。
- 数据有时候通过数据库共享,有时候通过接口调用传输。接口调用关系杂乱。
- 单个应用为了给其他应用提供接口,渐渐地越改越大,包含了很多本来就不属于它的逻辑。应用边界模糊,功能归属混乱。
- 管理后台在一开始的设计中保障级别较低。加入数据分析和促销管理相关功能后出现性能瓶颈,影响了其他应用。
- 数据库表结构被多个应用依赖,无法重构和优化。
- 所有应用都在一个数据库上操作,数据库出现性能瓶颈。特别是数据分析跑起来的时候,数据库性能急剧下降。
- 开发、测试、部署、维护愈发困难。即使只改动一个小功能,也需要整个应用一起发布。有时候发布会不小心带上了一些未经测试的代码,或者修改了一个功能后,另一个意想不到的地方出错了。为了减轻发布可能产生的问题的影响和线上业务停顿的影响,所有应用都要在凌晨三四点执行发布。发布后为了验证应用正常运行,还得盯到第二天白天的用户高峰期……
- 团队出现推诿扯皮现象。关于一些公用的功能应该建设在哪个应用上的问题常常要争论很久,最后要么干脆各做各的,或者随便放个地方但是都不维护。
抽离公共组件
公共服务:
- 用户服务
- 商品服务
- 促销服务
- 订单服务
- 数据分析服务
各个应用后台只需从这些服务获取所需的数据,从而删去了大量冗余的代码,就剩个轻薄的控制层和前端。这一阶段的架构如下:
这个阶段只是将服务分开了,数据库依然是共用的,所以一些烟囱式系统的缺点仍然存在:
- 数据库成为性能瓶颈,并且有单点故障的风险。
- 数据管理趋向混乱。即使一开始有良好的模块化设计,随着时间推移,总会有一个服务直接从数据库取另一个服务的数据的现象。
- 数据库表结构可能被多个服务依赖,牵一发而动全身,很难调整。
因此,可以把数据库也拆了——
完全拆分后各个服务可以采用异构的技术。比如数据分析服务可以使用数据仓库作为持久化层,以便于高效地做一些统计计算;商品服务和促销服务访问频率比较大,因此加入了缓存机制等。
故障定位
而微服务架构整个应用分散成多个服务,定位故障点非常困难。小明一个台机器一台机器地查看日志,一个服务一个服务地手工调用。经过十几分钟的查找,小明终于定位到故障点:促销服务由于接收的请求量太大而停止响应了。其他服务都直接或间接地会调用促销服务,于是也跟着宕机了。在微服务架构中,一个服务故障可能会产生雪崩效用,导致整个系统故障。
- 微服务架构整个应用分散成多个服务,定位故障点非常困难。
- 稳定性下降。服务数量变多导致其中一个服务出现故障的概率增大,并且一个服务故障可能导致整个系统挂掉。事实上,在大访问量的生产场景下,故障总是会出现的。
- 服务数量非常多,部署、管理的工作量很大。
- 开发方面:如何保证各个服务在持续开发的情况下仍然保持协同合作。
- 测试方面:服务拆分后,几乎所有功能都会涉及多个服务。原本单个程序的测试变为服务间调用的测试。测试变得更加复杂。
监控:
链路跟踪:记录每个用户请求时,微服务内部产生了多少服务调用,及其调用关系。
- 网关 - 权限控制,服务治理
- 拆分成微服务后,出现大量的服务,大量的接口,使得整个调用关系乱糟糟的。为了应对这些情况,微服务的调用需要一个把关的东西,也就是网关。在调用者和被调用者中间加一层网关,每次调用时进行权限校验。另外,网关也可以作为一个提供服务接口文档的平台。
- 粒度问题
- 最粗粒度的方案是整个微服务一个网关,微服务外部通过网关访问微服务,微服务内部则直接调用;
- 最细粒度则是所有调用,不管是微服务内部调用或者来自外部的调用,都必须通过网关。折中的方案是按照业务领域将微服务分成几个区,区内直接调用,区间通过网关调用。
- 动态扩容:
- 最粗暴的(也是最常用的)故障处理策略就是冗余。一般来说,一个服务都会部署多个实例,这样一来能够分担压力提高性能,二来即使一个实例挂了其他实例还能响应。
- 首先,需要部署一个服务发现服务,它提供所有已注册服务的地址信息的服务。然后各个应用服务在启动时自动将自己注册到服务发现服务上。并且应用服务启动后会实时(定期)从服务发现服务同步各个应用服务的地址列表到本地。服务发现服务也会定期检查应用服务的健康状态,去掉不健康的实例地址。 这样新增实例时只需要部署新实例,实例下线时直接关停服务即可,服务发现会自动检查服务实例的增减。
- 也可以用于负载均衡
- 熔断、降级、限流:
- 降级:当下游服务停止工作后,如果该服务并非核心业务,则上游服务应该降级,以保证核心业务不中断。比如网上超市下单界面有一个推荐商品凑单的功能,当推荐模块挂了后,下单功能不能一起挂掉,只需要暂时关闭推荐功能即可。
- 限流:一个服务挂掉后,上游服务或者用户一般会习惯性地重试访问。这导致一旦服务恢复正常,很可能因为瞬间网络流量过大又立刻挂掉,在棺材里重复着仰卧起坐。因此服务需要能够自我保护——限流。
- 限流策略有很多
- 最简单的比如当单位时间内请求数过多时,丢弃多余的请求。
- 另外,也可以考虑分区限流。仅拒绝来自产生大量请求的服务的请求。
- 例如商品服务和订单服务都需要访问促销服务,商品服务由于代码问题发起了大量请求,促销服务则只限制来自商品服务的请求,来自订单服务的请求则正常响应。
- 限流策略有很多
基础
微服务的拆分
- 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高。
低耦合:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖,或者依赖接口的稳定性要强。
拆分方式
- 纵向拆分:按照项目的功能模块来拆分
- 横向拆分:各个功能模块之间有没有公共的业务部分,如果有将其抽取出来作为通用服务
工程结构
- 完全解耦:每一个微服务都创建为一个独立的工程,甚至可以使用不同的开发语言来开发,项目完全解耦。
- Maven聚合:整个项目为一个Project,然后每个微服务是其中的一个Module(服务之间会耦合,编译时间长)
- 拆分方式
- 也就是这个服务相关得springboot工程搬过来,包括
application
等
- 也就是这个服务相关得springboot工程搬过来,包括
服务调用
购物车业务中需要查询商品业务得信息,但商品信息查询的逻辑全部迁移到了
item-service
服务,导致我们无法查询。
把原本本地方法调用,改造成跨微服务的远程调用(RPC,即Remote Produce Call)。
我们可以使用Spring提供的RestTemplate
进行调用
ResTemplate
在config下面定义RemoteCallConfig
1 | package com.hmall.cart.config; |
随后,编写代码进行查询(实质就是发送Http请求)
- 包含四部分信息:
- ① 请求方式
- getForObject:发送Get请求并返回指定类型对象
- PostForObject:发送Post请求并返回指定类型对象
- put:发送PUT请求
- delete:发送Delete请求
- exchange:发送任意类型请求,返回ResponseEntity
- ② 请求路径
- ③ 请求参数
- ④ 返回值类型
- ① 请求方式
在这个过程中,
item-service
提供了查询接口,cart-service
利用Http请求调用该接口。因此item-service
可以称为服务的提供者,而cart-service
则称为服务的消费者或服务调用者。
服务注册与发现:注册中心
上一章节介绍的“RestTemplate”进行服务调用的方法存在一些问题。
试想一下,假如商品微服务被调用较多,为了应对更高的并发,我们进行了多实例部署,如图
此时,每个
item-service
的实例其IP或端口不同,问题来了:
- item-service这么多实例,cart-service如何知道每一个实例的地址?
- http请求要写url地址,
cart-service
服务到底该调用哪个实例呢?- 如果在运行过程中,某一个
item-service
实例宕机,cart-service
依然在调用该怎么办?- 如果并发太高,
item-service
临时多部署了N台实例,cart-service
如何知道新实例的地址?
注册中心Naco
注册中心、服务提供者、服务消费者三者间关系如下:
流程如下:
- 服务启动时就会注册自己的服务信息(服务名、IP、端口)到注册中心
- 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1个服务可能多实例部署)
- 调用者自己对实例列表负载均衡,挑选一个实例
- 调用者向该实例发起远程调用
当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?
- 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳请求)
- 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,将其从服务的实例列表中剔除
- 当服务有新实例启动时,会发送注册服务请求,其信息会被记录在注册中心的服务实例列表
- 当注册中心服务列表变更时,会主动通知微服务,更新本地服务列表
可以基于Docker来部署Nacos的注册中心
服务注册:服务提供者注册服务
接下来,我们把item-service
注册到Nacos,步骤如下:
- 引入依赖
- 在
item-service
的pom.xml
中添加依赖:
- 在
1 | <!--nacos 服务注册发现--> |
- 配置Nacos地址
- 在
item-service
的application.yml
中添加nacos地址配置:
- 在
1 | spring: |
- 重启
- 为了测试一个服务多个实例的情况,我们再配置一个
item-service
的部署实例: - 然后配置启动项,注意重命名并且配置新的端口,避免冲突:
- 重启
item-service
的两个实例: - 访问nacos控制台,可以发现服务注册成功(一行数据,其中实例数=2)
- 为了测试一个服务多个实例的情况,我们再配置一个
服务发现:服务消费者发现服务
注:任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者
步骤如下:
引入依赖
- 服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。
1
2
3
4
5<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
- 服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。
配置Nacos地址
- 在
cart-service
的application.yml
中添加nacos地址配置:
- 在
1 | spring: |
- 发现并调用服务
- 服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用
- 我们通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用
OpenFeign
OpenFeign就利用SpringMVC的相关注解来声明远程调用得4个参数,然后基于动态代理帮我们生成远程调用的代码,而无需我们手动再编写
快速入门
在cart-service
服务的pom.xml中引入OpenFeign
的依赖和loadBalancer
依赖:
1 | <!--openFeign--> |
接下来,我们在cart-service
的CartApplication
启动类上添加注解 @EnableFeignClients,启动OpenFeign功能:
编写OpenFeign客户端:在cart-service
中,定义一个新的接口,编写Feign客户端,其中代码如下:
1 | package com.hmall.cart.client; |
这里只需要声明接口,无需实现方法。接口中的几个关键信息
@FeignClient("item-service")
:声明服务名称@GetMapping
:声明请求方式@GetMapping("/items")
:声明请求路径@RequestParam("ids") Collection<Long> ids
:声明请求参数List<ItemDTO>
:返回值类型
我们只需要直接调用这个方法,即可实现远程调用了。
网关技术
微服务拆分后,每个微服务都独立部署,这就存在一些问题:
- 每个微服务都需要编写登录校验、用户信息获取的功能吗?
- 当微服务之间调用时,该如何传递用户信息?
网关路由
网关就是网络的关口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的 路由和转发 以及 数据安全的校验。
SpringCloudGateway为例
快速入门
创建网关
首先,我们要在hmall下创建一个新的module,命名为hm-gateway,作为网关微服务
在hm-gateway
模块的pom.xml
文件中引入依赖:
1 |
|
编写启动类代码,如下:
1 | package com.hmall.gateway; |
配置路由
接下来,在hm-gateway
模块的resources
目录新建一个application.yaml
文件,内容如下:
1 | server: |
路由过滤
路由规则的定义语法如下:
1 | spring: |
网关登录校验
微服务拆分后,每个微服务都独立部署,不再共享数据。也就意味着每个微服务都需要做登录校验,这显然不可取。
我们的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,(1)每个微服务都需要知道JWT的秘钥,不安全(2)每个微服务重复编写登录校验代码、权限校验代码,麻烦
既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:
- 只需要在网关和用户服务保存秘钥
- 只需要在网关开发登录校验功能
网关过滤器
最终请求转发是有一个名为NettyRoutingFilter
的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter
之前,这就符合我们的需求了
网关过滤器链中的过滤器有两种:
GatewayFilter
:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route
.GlobalFilter
:全局过滤器,作用范围是所有路由,不可配置。
两种过滤器的方法1
2
3
4
5
6
7/**
* 处理请求并将其传递给下一个过滤器
* @param exchange 当前请求的上下文,其中包含request、response等各种数据
* @param chain 过滤器链,基于它向下传递请求
* @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
Gateway
内置的GatewayFilter
过滤器使用起来非常简单,无需编码只要在yaml文件中简单配置即可。而且其作用范围也很灵活,配置在哪个Route
下,就作用于哪个Route
.
内置过滤器的使用
例如,有一个过滤器叫做AddRequestHeaderGatewayFilterFacotry
,顾明思议,就是添加请求头的过滤器,可以给请求添加一个请求头并传递到下游微服务。
使用的使用只需要在application.yaml中这样配置:
1 | spring: |
如果想要让过滤器作用于所有的路由,则可以这样配置:
1 | spring: |
自定义过滤器的定义
自定义GatewayFilter
自定义GatewayFilter
不是直接实现GatewayFilter
,而是实现AbstractGatewayFilterFactory
。
【普通使用】
1 |
|
注意:该类的名称一定要以
GatewayFilterFactory
为后缀!
然后在yaml配置中这样使用:
1 | spring: |
【动态参数】
动态配置参数:
1 |
|
然后在yaml文件中使用:
1 |
|
自定义GlobalFilter
自定义GlobalFilter则简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数:
1 |
|
网关获取用户信息
由于网关发送请求到微服务依然采用的是Http
请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。
因此,我们需要:
- 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
- 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行
【保存用户到请求头】
【定义拦截器】
1 | package com.hmall.common.interceptor; |
【配置登录拦截器】
1 | package com.hmall.common.config; |
【配置包扫描】
需要注意的是,这个配置类默认是不会生效的,因为它所在的包是com.hmall.common.config
,与其它微服务的扫描包不一致,无法被扫描到,因此无法生效。
基于SpringBoot的自动装配原理,我们要将其添加到resources
目录下的META-INF/spring.factories
文件中。内容如下:
1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ |
OpenFeign传递用户信息
适用于微服务之间传递用户信息。
上面讲的是正常前端发出请求,经过网关,传递给微服务A
这节讲的是 前端发出请求,经过网关,传递给微服务A,微服务A要调用微服务B。其中,A—B之间用户信息的传递
微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?
这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor
1 | public interface RequestInterceptor { |
在com.hmall.api.config.DefaultFeignConfig
中添加一个Bean:
1 |
|
配置共享技术
Nacos不仅仅具备注册中心功能,也具备配置管理的功能:
微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新。
配置共享
读取Nacos配置是SpringCloud上下文(ApplicationContext
)初始化时处理的,发生在项目的引导阶段。然后才会初始化SpringBoot上下文,去读取application.yaml
。
也就是说引导阶段,application.yaml
文件尚未读取,根本不知道nacos 地址,该如何去加载nacos中的配置文件呢?
SpringCloud在初始化上下文的时候会先读取一个名为bootstrap.yaml
(或者bootstrap.properties
)的文件,如果我们将nacos地址配置到bootstrap.yaml
中,那么在项目引导阶段就可以读取nacos中的配置了。
配置热更新
以“购物车商品数量上限更新”为例子——
在Nacos配置内容如下:
1 | hm: |
代码添加属性读取类:
1 | package com.hmall.cart.config; |
业务中加载属性读取类
MQ消息队列
概念
同步调用
- 扩展性差
- 业务性能:因为是类似于串行调用,所以需要一直等
- 级联失败:
- 由于我们是基于OpenFeign调用交易服务、通知服务。当交易服务、通知服务出现故障时,整个事务都会回滚,交易失败。这其实就是同步调用的级联失败问题。
- 但是,我们假设用户余额充足,扣款已经成功,此时我们应该确保支付流水单更新为已支付,确保交易成功。毕竟收到手里的钱没道理再退回去吧。不能因为短信通知、更新订单状态失败而回滚整个事务
异步调用
不管后期增加了多少消息订阅者,作为支付服务来讲,执行问扣减余额、更新支付流水状态后,发送消息即可。业务耗时仅仅是这三部分业务耗时,仅仅100ms,大大提高了业务性能。
RabbitMQ与基础
SpringAMQP与快速入门
基础模型
Spring的官方刚好基于RabbitMQ提供了这样一套消息收发的模板工具:SpringAMQP
SpringAMQP提供了三个功能:
- 自动声明队列、交换机及其绑定关系
- 基于注解的监听器模式,异步接收消息
- 封装了RabbitTemplate工具,用于发送消息
首先,为项目工程安装依赖。
【消息发送】
在publisher
服务的application.yml
中添加配置:
1 | spring: |
然后在publisher
服务中编写测试类SpringAmqpTest
,并利用RabbitTemplate
实现消息发送:
1 | package com.itheima.publisher.amqp; |
【消息接收】
在consumer
服务的application.yml
中添加配置:
1 | spring: |
然后在consumer
服务的com.itheima.consumer.listener
包中新建一个类SpringRabbitListener
,代码如下:
1 | package com.itheima.consumer.listener; |
WorkQueues模型
让多个消费者绑定到一个队列,共同消费队列中的消息。
类似于一个生产者、多个消费者。食物只可被一个消费者消费
可用于负载均衡。
【能者多劳】
默认情况下,消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。
修改consumer服务的application.yml文件,添加配置,实现能者多劳,可以有效避免消息积压问题。:
1 | spring: |
- 多个消费者绑定到一个队列,同一条消息只会被一个消费者处理
- 通过设置prefetch来控制消费者预取的消息数量
Fanout(扇出)交换机——广播
一个发送者,多个消费者(已经绑定队列),交换机把消息发送给绑定过的所有队列
【消息发送】
需要指明交换机名称
1 |
|
【消息接收】
在consumer服务的SpringRabbitListener中添加两个方法,作为消费者,基本一致
1 |
|
Direct交换机
根据路由消息的关键字(分类字/Key)来判定发送给哪个(些)通道
【消息发送】
convertAndSend
的第二个参数就是这个消息的key
1 |
|
由于使用的red这个key,所以两个消费者都收到了消息
Topic交换机
效果与Direct交换器完全类似,另外Topic
类型Exchange
可以让队列在绑定BindingKey
的时候使用通配符!
- 规则:
- 关键词:
BindingKey
一般都是有一个或多个单词组成- 多个单词之间以
.
分割,例如:item.insert
- 通配符规则:
#
:匹配一个或多个词,0个词似乎也算(item.#
:能够匹配item.spu.insert
或者item.spu
)*
:匹配不多不少恰好1个词(item.*
:只能匹配item.spu
)
- 关键词:
【消息发送】
1 | /** |
声明队列和交换机
队列和交换机是程序员定义的,项目上线又要交给运维去创建。为避免交接错误,推荐的做法是由程序启动时检查队列和交换机是否存在,如果不存在自动创建。
SpringAMQP提供了一个
Queue类
,用来创建队列SpringAMQP还提供了一个Exchange接口,来表示所有不同类型的交换机
而在绑定队列和交换机时,则需要使用BindingBuilder来创建Binding对象
【实战】基于注解的创建和声明
Direct模式——1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void listenDirectQueue1(String msg){
System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}
public void listenDirectQueue2(String msg){
System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}
Topic模式——1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void listenTopicQueue1(String msg){
System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】");
}
public void listenTopicQueue2(String msg){
System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
}
消息转换器
Spring的消息发送代码接收的消息体是一个Object,我们希望将其转换为JSON
因此可以使用JSON方式来做序列化和反序列化。
【依赖引入】
在publisher
和consumer
两个服务中都引入依赖:
1 | <dependency> |
注意,如果项目中引入了spring-boot-starter-web
依赖,则无需再次引入Jackson
依赖。
【启动类改造】
配置消息转换器,在publisher
和consumer
两个服务的启动类中添加一个Bean即可:
1 |
|
【消费者改造】
我们在consumer服务中定义一个新的消费者,publisher是用Map发送,那么消费者也一定要用Map接收,格式如下:
1 |
|
MQ进阶
TBD