目录
本文章可为想要了解或学习微服务的同学提供入门导学。
提到微服务或SpringCloud,许多同学在学习前多会认为这是一个很难的技术,但实则不然。
微服务的开发多是微服务的搭建,配置和联调,与实际的业务开发并无多大关联,核心业务开发我们在SpringBoot时期就已经学会了,所以不必担心。
下面本文会由浅入深的说明微服务概念以及微服务大致体系,看完解了大致体系再去深入学习每一部分微服务组件的开发将会事半功倍。
本文主要以概念性讲解为主,帮助读者了解微服务开发体系,对于其中的各个组件开发代码不作深入展示和讲解。
什么是微服务?
故事的开头,我们首先要了解一下什么是微服务。
回顾一下我们之前是如何开发SpringBoot项目的:大致都是使用SpringBoot脚手架初始化项目,然后连接数据库,接着就可以为项目中的所有功能模块进行编码开发了。
在上面的开发过程中(如开发电商项目),我们将所有功能模块,例如用户,商品,订单等功能模块代码都放到一起,最后开发完毕后整体打成Jar包然后拿去部署,这样的开发架构就是"单体架构"。
单体架构
我们在开发小型项目时,使用单体架构是有优势的,可以快速响应需求构建出接口。
但是对于中大型项目,或是项目中某功能模块对于性能要求很高的情况下,微服务是更合适的。
思考一下:如果后端工程比较庞大,需要和多人协同开发单体架构是否合适?
对于单体架构,所有功能模块都放到一起,这样协同开发时都去操作同一块开发区域,势必会造成许多代码冲突,和分工边界不清等问题。
关于性能问题:如一个电商项目,如果我们开发为单体架构,所有模块都整合在一起打包部署到一台服务器上,当商品模块的请求量很大导致tomcat服务器资源损耗严重,导致项目整体的访问速度下降,但是其他如用户,订单模块请求量很小,他们本不应该卡顿,但是因为都绑定到一起,所以也受到了牵连。
微服务架构
在知道了单体架构所面临如上的一些局限性后,我们来了解一下微服务架构(分布式架构)。
在单体架构中将完整模块代码(mapper,service,controller)从中分离出来,组合成可单独运行的独立项目,该分离出的独立模块就是一个微服务。
例如:我们从单体架构中抽取出用户相关的模块代码(mapper,service,controller),整合到一个全新的SpringBoot项目中,该项目仅包含用户相关的模块代码,并且创建一个仅包含用户数据的数据库供给该项目,使得该项目可以独立运行,这就是用户微服务。
关于协作开发:当我们项目使用微服务架构进行开发后,我们就可以基于不同的微服务进行分工开发,极大避免了同时操作同一代码区域造成的代码冲突问题。
关于性能问题:我们的微服务拆分后每一个模块就是一个完整独立的项目,开发完毕后每一个服务都要部署到不同服务器上,因此当其中的某个微服务出现"问题"时,就减小了对与其他服务的影响了。
由此,我们就了解了什么是微服务,无非就是将我们原先的单体架构项目中的模块进行拆分,拆分出来的独立可运行的模块就是一个微服务。
延申出的问题
1. 当我们进行拆分之后,微服务之间如何联调?
也就是说,我们之前在单体架构开发时,订单模块想要调用用户模块代码只需要引入用户相关的Bean进行方法调用就可以。
但是在微服务架构中,我们不同的模块代码会被拆分出去,订单微服务并没有用户相关的代码可调用,这就需要考虑微服务之间如何进行调用的问题。
2. 前端该如何访问接口地址?
在单体架构开发时,我们仅需要定义一个后端运行的端口给前端访问就行,但是在微服务项目中,每一个模块都是一个独立的项目,都有不同的IP和端口,如此之多的后端接口地址,前端该如何编写统一的请求路径?
面对这些拆分后的微服务开发问题,如今都有对应的解决方案和微服务组件,我们只需要会搭建使用即可。
什么是SpringCloud?
在上面我们说到从单体架构转到微服务架构之后,会有一系列的微服务开发问题,这些问题都已经有成熟的解决方案和微服务组件给到我们。
但是这些解决方案和微服务组件多是由各大大小小的公司或组织研发的,可以说是零零星星,如果我们自己一个个去对接到自己的微服务项目,那么显然是麻烦且耗时的。
因此我们在进行微服务开发时,多使用SpringCloud框架来引用各种组件解决微服务中遇到的各种问题。
SpringCloud框架可以说是目前Java领域最全面的微服务组件的集合,SpringCloud依托于SpringBoot的自动装配能力,大大降低了其项目搭建、组件使用的成本。对于没有自研微服务组件能力的中小型企业,使用SpringCloud全家桶来实现微服务开发可以说是最合适的选择。
简而言之,SpringCloud就是可以轻松帮助我们组装各种微服务组件,来解决各种微服务问题的框架。
下面,我会循序渐进的说明微服务开发中会遇到的问题,以及遇到问题时该使用哪个微服务组件进行解决的方式进行讲解。
远程调用
问题:在服务拆分之后,我们项目中的每一个模块都会形成完整的项目,这就要考虑如何进行互相调用,如在下单(订单服务)时需要查询商品的库存信息(商品服务)。
以我们在SpringBoot开发的经验,我们可以想到使用RestTemplate来进行HTTP的请求发送获取数据,这是可行的,只不过会有一些局限性。
例如使用RestTemplate进行微服务的远程调用代码:
// 使用远程调用商品服务获取商品数据列表
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
"192.168.1.12:8080/items?id=1",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<ItemDTO>>() {},
Map.of("ids", CollUtil.join(itemIds, ","))
);
if(!response.getStatusCode().is2xxSuccessful()){
return;
}
List<ItemDTO> items = response.getBody();
可以看到,我们在其中把目标微服务的IP给写死了,如果目标微服务有多实例,也就是集群部署,那么我们把IP写死并不明智。
服务注册和发现
问题:在上面的远程调用中,我们发现在调用其他微服务时不能动态发现获取他们的IP和端口,也不能知道对方微服务的运行状态,这并不理想。
为了解决上面的问题,因此引入了"注册中心"这一组件来解决问题。
这注册中心,实际上可以看作我们微服务项目中的微服务管理者,所有微服务在启动后都需要跟注册中心报告自身运行的地址和状态。
注册中心里面存在两个角色:服务提供者 和 服务消费者
如我们订单服务在下单时需要调用商品服务,那么订单服务就是消费者,而商品服务就是服务提供者。
在一般的大型项目中,服务的提供者一般是多个的,例如部署多个商品服务,这样能承受并处理更大的请求量。
注册中心还能实时的监测每一个微服务的状态(心跳检测)以保证整体微服务的正常运行。
知道了注册中心,当我们再进行远程调用时,就可以从注册中心去获取目标微服务的地址了,获取到的服务地址也是经过注册中心验证过可用的。
例如使用注册中心组件"nacos"去动态获取微服务实例进行调用:
// 引入注册中心组件客户端
private final DiscoveryClient discoveryClient;
//使用nacos注册中心去实现动态获取服务提供者URI
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
// 使用远程调用商品服务获取商品数据列表
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
instance.getUri() + "/items?ids={ids}",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<ItemDTO>>() {},
Map.of("ids", CollUtil.join(itemIds, ","))
);
if(!response.getStatusCode().is2xxSuccessful()){
return;
}
List<ItemDTO> items = response.getBody();
常见的注册中心组件有:Eureka , Nacos,Consul
OpenFeign
在上面的远程调用代码中,即使我们配合nacos进行动态获取了微服务地址进行调用,但是调用代码还是太复杂了。
因为我们在单体架构时想要调用,只不过是调用一个方法去获取数据就行,而在上面我们要去构造请求,处理响应的数据。
为了简化上面的远程调用过程,我们可以使用OpenFeign组件。
OpenFeign用于封装我们微服务调用之间的远程请求调用,省略了我们自己构建负载均衡的请求和处理响应的操作。
使用OpenFeign的成本是很低的,因为他和我们在控制层Controller中定义的MVC接口基本一样。
例如当我们引入OpenFeign依赖之后,我们去定义一个远程调用接口:
// 商品微服务相关的远程调用接口
@FeignClient("item-service")
public interface ItemClient {
@GetMapping("/items")
List<ItemDTO> getItemByIds(@RequestParam("ids") Collection<Long> ids);
}
当定义了上面的接口之后,我们就可以引入然后调用,上面一大串远程调用代码就可以缩减为两行:
// 使用openfeign做远程调用
// 引入
private final ItemClient itemClient;
// 调用,执行远程调用
List<ItemDTO> items = itemClient.getItemByIds(itemIds);
因此,使用了OpenFeign组件之后,我们的远程调用代码将会变得很简单实用。
网关
当了解了远程调用和注册中心之后,我们的后端微服务就能够联调开发了。
但是这时候我们把目光看到前端和后端的联调上,似乎就出现了问题。
后端微服务如此之多,前端该如何访问?
或者用黑马的通俗例子:外部人员想要访问某小区的某住户但是不知道地址。
这些住户就相当于我们的各个微服务,门卫大爷就相当于网关,而外来人员就是前端。
门卫大爷不仅充当了路由(带路)作用还有身份验证的作用。
我们微服务体系中也需要门卫大爷这么一个角色与前端进行协调路由和权限校验。
这就是网关及其作用,网关也是一个微服务。
有了网关这个"门卫"之后,我们前端只用记住并向网关这个地址发送请求就行,不需要记住其他微服务的地址,只需要给网关发送请求,网关就会帮助转发给指定的微服务处理并收集响应给前端。
如何路由?
也就是说前端请求发过来时,网关如何知道请求该给哪一个微服务进行处理。
实际上我们在实际开发时,需要我们声明一张"路由表"进行映射。
在路由表上会定义"什么样的请求该给哪个微服务处理"。
例如下面的路由表定义:
routes:
- id: item
uri: lb://item-service
predicates:
- Path=/items/**
- id: user
uri: lb://user-service
predicates:
- Path=/users/**
配置属性含义如下:
id
:路由的唯一标示predicates
:路由断言,其实就是匹配条件uri
:路由目标地址,lb://
代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。
在上面的示例路由表中,我们的匹配条件(predicates)使用了Path,代表根据请求路径进行区分匹配,以items开头的所有请求,交给item-service微服务进行处理。而users开头的交给user-service微服务处理。
predicates路由断言的方式有很多,我们上面使用了Path模式去匹配请求地址。
更多的匹配方式:
用户信息的传递
最后还有一个问题,就是登录用户信息该如何传递?
我们的用户登录是在用户微服务进行的,登录成功会将token信息返回给前端。
前端下次请求时会携带token到网关,网关校验后决定是否放行转发。
我们网关转发时应该在请求头携带从token中解析出的用户信息一并发送给目标微服务。
这个解析并将用户信息封装到请求头向下传递的动作,就需要在网关的过滤器中进行:
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取Request
ServerHttpRequest request = exchange.getRequest();
// 判断请求是否需要拦截
if(isExclude(request.getPath().toString())){
// 包含在排除的路径,直接放行
return chain.filter(exchange);
}
// 需要检查登录状态,取出token进行校验
String token = null;
List<String> headers = request.getHeaders().get("authorization");
if(!CollUtils.isEmpty(headers)){
token = headers.get(0);
}
// 解析出token中的用户信息
Long userId = null;
try {
userId = jwtTool.parseToken(token);
}catch (UnauthorizedException e){
// token解析校验失败,构造失败的响应信息返回
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 鉴权成功,转发时传递用户信息
System.out.println("解析出用户ID:" + userId);
String userInfo = userId.toString();
// 将用户信息封装到请求头里向下传递
ServerWebExchange ex = exchange.mutate()
.request(b -> b.header("user-info", userInfo))
.build();
return chain.filter(ex);
}
这样,在下游微服务就能在请求头中获取到登录用户的信息了。
关于微服务之间远程调用时用户信息的传递,是使用OpenFeign提供的专属拦截器,在OpenFeign发送请求前将当前微服务知道的用户信息存放到请求头中,然后再进行远程调用进行用户信息的传递。
当学到这,就以及具备微服务开发的基本操作了。
再往下就是对于微服务开发便利性和健壮性的考虑,属于锦上添花的操作。
配置管理
面临的问题:在微服务中有很多服务配置是重复的,以及当我们想要更改微服务中的某些配置项信息需要重新整个服务。
配置共享:在微服务中,如swagger,日志或者数据库的连接信息等配置我们都是放到yaml配置文件中,其中很大一部分配置在每一个微服务中都重复的书写,这样会造成冗余和修改成本高的问题。
配置热更新:在配置文件中存在有关业务的一些配置,例如超时时间,这样的配置我们在服务启动后如果更改,是需要重新启动服务的,这会导致服务的短暂不可用和耗费时间。
因此我们可以使用nacos解决上述的问题,没错 就是注册中心组件nacos。
定义配置共享?
我们将共享的配置在nacos中编写,然后在想要使用共享配置的微服务中声明引入即可。
在共享配置中,可能有某个值是不一样的,例如数据库连接的配置,其中的数据库服务器地址和数据库名不一致,其他的都一样。
这时候我们就可以在共享配置中将变化的值定位变量,在具体微服务中定义自身想要填写的值即可。
例如在nacos中定义如下共享配置:
spring:
datasource:
url: jdbc:mysql://${db.host}:${db.port}/${db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${db.un}
password: ${db.pw}
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
global-config:
db-config:
update-strategy: not_null
id-type: auto
其中在定义mysql的连接信息时,使用了{变量}的方式预定义填入值,我们在具体微服务中将值进行声明就行。
拉取共享配置
在nacos上定义了共享配置之后,我们具体微服务如何拉取共享配置到微服务中呢?
我们了解一下SpringCloud的启动读取配置流程:
项目启动后,首先会加载bootstrap.yaml配置文件,从中读取nacos的连接信息,以及需要读取的共享配置。
例如下面的bootstrap.yaml配置文件:
spring:
application:
name: cart-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.33.147 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
上面配置就定义了nacos的连接信息,以及需要读取的共享配置文件。
当拉取下来之后,再与我们本地的yaml配置文件合并填充变量值:
db:
host: 192.168.33.147
port: 3306
database: mydb
un: root
pw: root
配置热更新
原理操作和上面的共享配置差不多,都是在nacos上定义配置文件然后与本地微服务配置做关联。
当想要热更新某配置值时直接在nacos在线控制台更改配置,即可实现配置热更新。
只不过需要使用配置类的方式与nacos配置文件进行关联。
如在nacos定义一个热更新配置文件:
hm:
cart:
maxAmount: 1 # 购物车商品数量上限
在微服务中定义配置类进行关联:
@Data
@Component
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
private Integer maxAmount;
}
这样,当我们想要热更新该属性时,直接在nacos在线控制台进行更改即可,无需修改本地代码文件。
服务保护
在微服务的开发中,虽然能承受更大的并发请求,但是有时也会出现问题。
下面我们就来看看微服务中"意外情况"和服务保护方案。
"雪崩"问题(级联失败)
微服务中存在许多远程调用的关系,有些调用关系还不只一层,也就是存在级联调用。
例如服务1-2-3-4 ,服务1-4-5连接都达到了多级调用。
如果服务4出现异常,那么服务4的上游调用链必然也会受到牵连。
造成的级联失败的原因与tomcat服务器的资源有关,当服务4出现异常,那么服务3的调用就会阻塞,这是如果从上游还有源源不断的请求下来,那么就都会阻塞在服务3,不断消耗服务3上的tomcat线程资源,导致服务3也会变得非常慢甚至down机。
然后一样的问题又会席卷服务2, 服务1。
如上,整个微服务群中与购物车服务、商品服务等有调用关系的服务可能都会出现问题,最终导致整个集群不可用。
这就是雪崩问题。
解决方案
微服务保护的方案有很多,比如:
请求限流
线程隔离
服务熔断
这些方案或多或少都会导致服务的体验上略有下降,比如请求限流,降低了并发上限;
线程隔离,降低了可用资源数量;
服务熔断,降低了服务的完整度,部分服务变的不可用或弱可用。
因此这些方案都属于服务降级的方案。但通过这些方案,服务的健壮性得到了提升,
请求限流
服务故障最重要原因,就是并发太高!解决了这个问题,就能避免大部分故障。
当然,接口的并发不是一直很高,而是突发的。因此请求限流,就是限制或控制接口访问的并发流量,避免服务因流量激增而出现故障。
请求限流往往会有一个限流器,数量高低起伏的并发请求曲线,经过限流器就变的非常平稳。这就像是水电站的大坝,起到蓄水的作用,可以通过开关控制水流出的大小,让下游水流始终维持在一个平稳的量。
线程隔离
线程隔离是指给微服务中指定的业务分配有限的线程,当某业务出现故障不至于把整个微服务给拖垮。
例如有一个购物车服务有查询购物车的业务,还有其他业务。
当查询购物车业务调用下游服务出现问题时,由于我们设置了线程隔离,确定了最大线程可用数,当达到最大阈值就不再消耗服务器线程资源,就避免了购物车微服务中其他的业务受影响。
服务熔断
线程隔离虽然避免了购物车微服务整体down掉,但是商品服务如果短时间内故障依然存在,那么我们购物车微服务中的查询购物车业务势必也会一直不可用。
可见,我们应该察觉到服务的故障,然后给出降级的处理策略。
以及在如果发现了业务出故障,还继续调用去浪费线程隔离时设置的线程池内剩余线程是不合理的。
因此引出服务熔断的技术操作。
发现故障和熔断:当购物车微服务执行业务查询购物车时,在调用商品服务指定次数后应当进行统计异常比例,如果异常比例高于设定阈值,应当熔断掉。
故障恢复:服务不可能一直处于熔断状态,这样会导致业务的停滞,服设定在指务熔断策是可以定时间之后,发送一次检测请求,查看检测请求的响应还是否异常,如果异常解除,则解除熔断状态。
降级策略(fallback):当统计后检测到服务异常,应立即熔断业务,但是如果直接熔断而不做任何响应返回,那么上一级调用得到空的结果也不太好,因此我们可以写一个fallback逻辑,也就是说,我们可以给该业务编写一套备用的逻辑代码或返回数据,例如当查询商品时如果出现故障,我们的fallback中可以返回默认的商品数据先去展示。
分布式事务
在单体架构中我们也处理过事务,主要目的就是为了保证数据一致性。
但在微服务中,我们需要考虑的事务一致性就更广一些。
例如商品下单过程:
如上,商品下单过程涉及到3个微服务,由交易微服务发起,远程调用购物车服务和库存服务。
在下单过程中,如果创建订单执行成功,清理购物车执行成功,然后到最后的扣减库存失败,交易服务因为是远程调用者,因此能感知到然后回滚撤销订单的创建,但是购物车服务已经执行了清理,也没办法感知到扣减库存的失败。这就会导致数据不一致的问题。
主要原因是因为微服务不同于单体架构,各服务都在自己独立的运行环境中,难感知到同一业务线中其他服务的执行状态。这就是分布式事务的问题。
引出概念,
全局事务:下单的过程涉及到三个服务各自事务的组合就是全局事务。
分支事务:其中各个微服务的业务操作(如创建订单)就是一个分支事务。
Seata
Seata是在众多的开源分布式事务框架中,功能最完善、使用最多的组件。
其实分布式事务产生的一个重要原因,就是参与事务的多个分支事务互相无感知,不知道彼此的执行状态。因此解决分布式事务的思想非常简单:
就是找一个统一的事务协调者,与多个分支事务通信,检测每个分支事务的执行状态,保证全局事务下的每一个分支事务同时成功或失败即可。大多数的分布式事务框架都是基于这个理论来实现的。
Seata解决分布式事务的原理
在Seata的事务管理中有三个重要的角色:
TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
RM (Resource Manager) - 资源管理器:管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
解决过程:TM在全局事务开始时会向TC报告事务开始,然后执行到分支事务后分支执行的结果会报告给TC,知道分支事务都执行完毕后,TM会向TC报告事务结果,然后TC会去统计该全局事务中的所有分支事务的执行状态,这样就可以统一操作使数据一致了。
主要原理就是加了一层TC 事务协调者,他可以去监测同一全局事务下的所有分支事务的执行结果,根据结果去同一提交或回滚,保障了数据一致性。
在Seata分布式事务解决方案中,对于最终的事务一致性操作有着几种实现。
下面我会说明 XA 和 TA两种常见的模式,让读者更好的理解分布式事务处理的原理过程。
XA模式
该模式下为强一致性。
过程描述:全局事务开始TM向TC报告,向下执行到RM分支事务执行SQL但不提交,继续向下执行其他分支事务,执行到最后全局事务TM向TC报告事务结果,TC去检查该全局事务下的所有分支事务的执行状态,如果成功,则让所有分支事务提交,否则回滚。
缺点:因为分支事务执行SQL后不提交,导致需要锁定数据库资源,等待二阶段结束才释放,性能较差。
优点:事务强一致性,实现简单,没有代码侵入。
TA模式
该模式下为最终一致性。
过程描述:全局事务开始TM向TC报告,向下执行到分支事务,首先会将当前数据的快照进行保存到undo日志中,然后执行SQL并提交,接着将执行结果报告给TC,继续执行其他分支事务直到结果,TM向TC报告全局事务结果,TC检测所有分支事务的执行状态,如果都成功,则删除undo日志,如果有失败则根据undo日志恢复数据(回滚)。
缺点:数据会存在短暂不一致的情况,因为是立即提交,在该全局事务未完成的时间内如果又来另一个查询请求读取数据,而后该全局事务如果回滚,则会导致中间来的查询出现"脏读"现象。以及因为要保存undo日志,会占用更多的内存。
优点:没有锁定数据资源,性能更高。
两种模式需要根据实际业务的需求来选择,并无优劣之分。