SpringCloud 微服务入门:服务调用流程解析
目录
1、引言
在当今的软件开发领域,微服务架构已经成为一种热门的设计模式。与传统的单体架构相比,微服务架构通过将系统拆分为多个小而独立的服务,使得开发、部署和维护变得更加灵活。这种架构设计不仅能够适应现代互联网应用的快速变化需求,还能在一定程度上缓解开发团队之间的协作冲突。
但同时,微服务也引入了许多新的挑战,其中最核心的一个问题就是服务之间的相互调用。在单体架构中,模块之间的调用通常是本地方法的调用,简单而高效。而在微服务架构中,不同服务运行在独立的进程中,甚至可能分布在不同的服务器上,服务之间的通信需要通过网络来实现,这无疑增加了复杂性。
在这篇文章中,我们将围绕微服务架构的基础概念展开,重点探讨其与单体项目的主要不同点,尤其是服务之间的调用逻辑。通过一个简单的案例,我们会展示 SpringCloud 框架下服务调用的完整流程,帮助你快速理解微服务。
2、微服务基础概念
2.1、框架总览
首先要了解什么是微服务就需要对其整体框架有一个全局的概念,下面这张概述图详细的展示了微服务的大体框架:
可以发现微服务涉及的相关组件还是蛮多的,相较于单体项目终端直接与数据库相连,中间最多加个网关,微服务由于其服务之间的隔离性,导致其相互调用成了一个比较麻烦的问题,随之而来的还有一大堆分布式事务相关的安全问题。
本文主要对控制面、治理面和数据面进行讲解,涉及到的组件有:
- Nacos
- Sentinel
- GateWay 网关
- OpenFeign
- RabbitMQ
2.2、微服务与单体架构的对比
特性 | 单体架构 | 微服务架构 |
开发 | 所有模块集中在一个代码库中,协作紧密 | 各模块独立开发,松耦合 |
部署 | 整体部署,一次更新影响整个系统 | 独立部署,每个服务可以单独更新 |
扩展 | 水平扩展整个应用,浪费资源 | 精确扩展特定服务,资源利用率更高 |
技术栈 | 单一技术栈,统一性强 | 多样化技术栈,根据服务需求灵活选择 |
3、微服务的最大特点:服务之间的调用
在微服务架构中,服务之间的调用是系统运行的核心,也是微服务架构与单体架构最大的区别之一。由于服务运行在不同的进程中,甚至分布在不同的服务器上,其通信方式需要考虑网络传输的复杂性。以下是微服务调用的几个关键要素以及相关组件:
3.1、服务注册
在微服务架构中,每个服务可能动态变化(如启动、关闭或迁移)。为了实现服务之间的通信,首先需要一个机制来注册和发现服务。
3.1.1、注册中心原理
举个例子,在微服务远程调用的过程中,包括两个角色:
- 服务提供者:提供接口供其它微服务访问,比如
item-service
- 服务消费者:调用其它微服务提供的接口,比如
cart-service
在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:
流程如下:
- 服务启动时就会注册自己的服务信息(服务名、IP、端口)到注册中心
- 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1 个服务可能多实例部署)
- 调用者自己对实例列表负载均衡,挑选一个实例
- 调用者向该实例发起远程调用
当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?
- 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳请求)
- 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,将其从服务的实例列表中剔除
- 当服务有新实例启动时,会发送注册服务请求,其信息会被记录在注册中心的服务实例列表
- 当注册中心服务列表变更时,会主动通知微服务,更新本地服务列表
3.1.2、Nacos 注册中心
目前开源的注册中心框架有很多,国内比较常见的有:
- Eureka:Netflix 公司出品,目前被集成在 SpringCloud 当中,一般用于 Java 应用
- Nacos:Alibaba 公司出品,目前被集成在 SpringCloudAlibaba 中,一般用于 Java 应用
- Consul:HashiCorp 公司出品,目前集成在 SpringCloud 中,不限制微服务语言
由于 Nacos 是国内产品,中文文档比较丰富,而且同时具备配置管理功能,所以使用较多。下面来讲一下怎么部署 Nacos,推荐是使用 linux 虚拟机结合 docker 来进行部署,这里就不详细演示怎么安装 docker 了,有需要的可以去看一下我之前写的博客,这里就直接参考 Nacos 官网进行安装:
解压到非中文路径下:
打开编辑,在开头处加入如下内容:
/* |
然后打开你的数据库将脚本导入创建 nacos 数据库:
修改数据库连接默认配置:
启动后访问 localhost:8848/nacos :
3.1.3、服务注册
接下来就是在你的项目中添加依赖并进行配置:
<!--nacos 服务注册发现--> |
spring: |
3.2、服务间通信方式
微服务之间的通信主要有以下两种方式:
- 同步调用(HTTP REST):使用轻量级的 HTTP 协议,适合请求-响应模式。SpringCloud 提供了 OpenFeign 组件用于简化 REST 调用,通过声明式接口实现服务间的远程调用。
- 异步调用(消息队列):通过消息中间件实现异步通信和解耦,适合事件驱动的场景。RabbitMQ:一个流行的开源消息队列,支持 AMQP 协议,具有高性能、可靠性和灵活性,常用于实现微服务间的异步通信和事件通知。
3.2.1、OpenFeign
其实远程调用的关键点就在于四个:
- 请求方式
- 请求路径
- 请求参数
- 返回值类型
所以,OpenFeign 就利用 SpringMVC 的相关注解来声明上述 4 个参数,然后基于动态代理帮我们生成远程调用的代码,而无需我们手动再编写,非常方便。
需要注意的是,调用方也要像刚才那样引入 nacos 依赖并进行配置,接下来,我们就通过一个快速入门的案例来体验一下 OpenFeign 的便捷吧。
首先还是在调用方引入依赖:
<!--openFeign--> |
注意这里服务之间的调用涉及到了负载均衡,所以也要把 loadbalancer 的依赖也引入,关于负载均衡的讲解放到了后面。
在调用者的启动类上添加注解,启动 OpenFeign 功能:
然后新建一个 client 包,里面放访问客户端:
这里只需要声明接口,无需实现方法,因为 OpenFeign 基于 Nacos 的远程调用原理是 声明式 HTTP 调用 + 动态服务发现,通过动态代理、Nacos 的服务注册与发现,以及负载均衡等机制实现服务之间的无缝通信。具体流程后面会讲到。
接口中的几个关键信息:
- @FeignClient(“item-service”) :声明服务名称
- @GetMapping :声明请求方式
- @GetMapping(“/items”) :声明请求路径
- @RequestParam(“ids”) Collection
ids :声明请求参数 - List
:返回值类型 有了上述信息,OpenFeign 就可以利用动态代理帮我们实现这个方法,并且向 http://item-service/items 发送一个 GET 请求,携带 ids 为请求参数,并自动将返回值处理为 List
。 我们只需要直接调用这个方法,即可实现远程调用了。
当然涉及到多线程必然要考虑性能的问题,OpenFeign 也支持线程池,在调用者端引入依赖并进行配置即可生效还是非常方便的。
#.xml |
#.yaml |
3.2.2、RabbitMQ
前面我们了解了同步调用,在讲解异步调用之前,首先还是来看一下它们各自的优缺点:
特性 | 同步调用 | 异步调用 |
定义 | 调用方发出请求后会等待响应完成,才能继续执行后续逻辑。 | 调用方发出请求后无需等待响应,可立即继续执行其他任务。 |
通信协议 | 通常基于 HTTP 或 RPC | 通常基于消息队列(如 RabbitMQ、Kafka 等)。 |
耦合度 | 服务间紧密耦合,调用方需要直接知道服务提供方的信息。 | 服务间松耦合,通过消息中间件解耦。 |
实时性 | 响应实时性高,适合对结果有即时需求的场景。 | 延迟容忍度高,适合对结果实时性要求不高的场景。 |
可靠性 | 如果服务不可用,调用会失败,需要结合重试机制或熔断处理。 | 消息通常持久化存储,可靠性高,即使目标服务暂时不可用,消息不会丢失。 |
异步调用方式其实就是基于消息通知的方式,一般包含三个角色:
- 消息发送者:投递消息的人,就是原来的调用方
- 消息 Broker:管理、暂存、转发消息,你可以把它理解成微信服务器
- 消息接收者:接收和处理消息的人,就是原来的服务提供方
在异步调用中,发送者不再直接同步调用接收者的业务接口,而是发送一条消息投递给消息 Broker。然后接收者根据自己的需求从消息 Broker 那里订阅消息。每当发送方发送消息后,接受者都能获取消息并处理。这样,发送消息的人和接收消息的人就完全解耦了。
核心思想就是只将关键服务同步调用,而其他一些不太重要的就交给 Broker 异步调用!
当然,异步通信也并非完美无缺,它存在下列缺点:
- 完全依赖于 Broker 的可靠性、安全性和性能
- 架构复杂,后期维护和调试麻烦
安装过程这里就不演示了,推荐使用 docker 容器隔离环境,登录后界面如图:
RabbitMQ 对应的架构如图:
其中包含几个概念:
- publisher:生产者,也就是发送消息的一方
- consumer:消费者,也就是消费消息的一方
- queue:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理
- exchange:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。
- virtual host:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的 exchange、queue(有点像 mysql 里的 database,不同的项目的 database 相互隔离)
上述这些东西都可以在 RabbitMQ 的管理控制台来管理.
其中交换机和队列要建立绑定关系后才能传递信息,这个时候如果有消费者监听了 MQ 的队列,自然就能收到消息了。
3.2.3、SpringAMQP
将来我们开发业务功能的时候,肯定不会在控制台收发消息,而是应该基于编程的方式。由于 RabbitMQ 采用了 AMQP 协议,因此它具备跨语言的特性。任何语言只要遵循 AMQP 协议收发消息,都可以与 RabbitMQ 交互。并且 RabbitMQ 官方也提供了各种不同语言的客户端。
但是,RabbitMQ 官方提供的 Java 客户端编码相对复杂,一般生产环境下我们更多会结合 Spring 来使用。而 Spring 的官方刚好基于 RabbitMQ 提供了这样一套消息收发的模板工具:SpringAMQP。并且还基于 SpringBoot 对其实现了自动装配,使用起来非常方便。
SpringAMQP 提供了三个功能:
- 自动声明队列、交换机及其绑定关系
- 基于注解的监听器模式,异步接收消息
- 封装了 RabbitTemplate 工具,用于发送消息
SpringAMQP 如何收发消息?
1、引入 spring-boot-starter-amqp 依赖
<!--AMQP依赖,包含RabbitMQ--> |
2、配置 rabbitmq 服务端信息
spring: |
3、利用 RabbitTemplate 发送消息
|
4、利用@RabbitListener 注解声明要监听的队列,监听消息
// 利用RabbitListener来声明要监听的队列信息 |
当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。此时就可以使用 work 模型,多个消费者共同处理消息处理,消息处理的速度就能大大提高了,简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。
交换机的类型有很多其特点也是各不相同,不仅如此关于 RabbitMQ 还有很多的内容,像如何确保 MQ 消息的可靠性,以及消息发送失败后的处理方案和延迟消息,这里就不详细展开了。
3.3、负载均衡
我们知道微服务间远程同步调用都是由 OpenFeign 帮我们完成的,甚至帮我们实现了服务列表之间的负载均衡。但具体负载均衡的规则是什么呢?何时做的负载均衡呢?
接下来我们一起来分析一下。
3.3.1、源码跟踪
首先来梳理一下远程调用的步骤:
整体流程如下图所示:
可以看到
FeignBlockingLoadBalancerClient
是一个适配器,内部使用了LoadBalancerClient
来实现服务实例的选择和请求的负载均衡处理。
我们跟进去发现 LoadBalancerClient 接口
只有一个实现类就是 BlockingLoadBalancerClient:
其中的 choose 方法实现了负载均衡:
我们继续跟进:
ReactiveLoadBalancer 是 Spring-Cloud-Common 组件中定义的负载均衡器接口规范,而 Spring-Cloud-Loadbalancer 组件给出了两个实现:
默认的实现是 RoundRobinLoadBalancer,即轮询负载均衡器。负载均衡器的核心逻辑如下:
这里的 ServiceInstanceListSupplier(服务拉取)也有很多实现:
其中 CachingServiceInstanceListSupplier 采用了装饰模式,加了服务实例列表缓存,避免每次都要去注册中心拉取服务实例列表。而其内部是基于
DiscoveryClientServiceInstanceListSupplier
来实现的。在这个类的构造函数中,就会异步的基于 DiscoveryClient 去拉取服务的实例列表:
3.3.2、NacosRule
之前分析源码的时候我们发现负载均衡的算法是有
ReactiveLoadBalancer
来定义的,我们发现它的实现类有三个:
其中
RoundRobinLoadBalancer
和RandomLoadBalancer
是由Spring-Cloud-Loadbalancer
模块提供的,而NacosLoadBalancer
则是由Nacos-Discorvery
模块提供的。默认采用的负载均衡策略是
RoundRobinLoadBalancer
,那如果我们要切换负载均衡策略该怎么办?
查看源码会发现,Spring-Cloud-Loadbalancer
模块中有一个自动配置类:
其中定义了默认的负载均衡器:
这个 Bean 上添加了
@ConditionalOnMissingBean
注解,也就是说如果我们自定义了这个类型的 bean,则负载均衡的策略就会被改变。这个配置类千万不要加@Configuration 注解,也不要被 SpringBootApplication 扫描到。
由于这个 OpenFeignConfig 没有加@Configuration 注解,也就没有被 Spring 加载,因此是不会生效的。接下来,我们要在启动类上通过注解来声明这个配置。
RoundRobinLoadBalancer
是轮询算法,RandomLoadBalancer
是随机算法,那么NacosLoadBalancer
是什么负载均衡算法呢?
简单来说
NacosLoadBalancer
是一个基于权重的加权平均算法,我们打开 nacos 控制台,进入item-service
的服务详情页,可以看到每个实例后面都有一个编辑按钮,在这里就可以修改每个服务的权重了:
3.4、 服务容错机制
由于微服务通信依赖于网络,可能会出现超时、失败等问题。服务容错机制可以保证系统的稳定性。
3.4.1、服务保护方案
微服务保护的方案有很多,比如:
- 请求限流
- 线程隔离
- 服务熔断
这些方案或多或少都会导致服务的体验上略有下降,比如请求限流,降低了并发上限;线程隔离,降低了可用资源数量;服务熔断,降低了服务的完整度,部分服务变的不可用或弱可用。因此这些方案都属于服务降级的方案。但通过这些方案,服务的健壮性得到了提升,接下来,我们就逐一了解这些方案的原理。
请求限流
服务故障最重要原因,就是并发太高!解决了这个问题,就能避免大部分故障。当然,接口的并发不是一直很高,而是突发的。因此请求限流,就是限制或控制接口访问的并发流量,避免服务因流量激增而出现故障。
请求限流往往会有一个限流器,数量高低起伏的并发请求曲线,经过限流器就变的非常平稳。这就像是水电站的大坝,起到蓄水的作用,可以通过开关控制水流出的大小,让下游水流始终维持在一个平稳的量。
线程隔离
当一个业务接口响应时间长,而且并发高时,就可能耗尽服务器的线程资源,导致服务内的其它接口受到影响。所以我们必须把这种影响降低,或者缩减影响的范围。线程隔离正是解决这个问题的好办法。
服务熔断
线程隔离虽然避免了雪崩问题,但故障服务依然会拖慢服务调用方的接口响应速度,这个时候就需要进行熔断以拒绝调用该接口。
3.4.2、Sentinel
Sentinel 是阿里巴巴开源的一款 分布式系统流量防护组件,主要用于实现流量控制(Flow Control)、熔断降级(Circuit Breaking)和系统自适应保护(System Adaptive Protection)等功能,是保障微服务系统稳定性的重要工具。
下载地址:
java -Dserver.port=8090 -Dcsp.sentinel.dashboard.server=localhost:8090 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar |
建议是写成.bat 文件然后双击启动:
访问http://localhost:8090页面,就可以看到 sentinel 的控制台了:
需要输入账号和密码,默认都是:sentinel
然后就是整合到你的项目中去,引入依赖并修改配置文件
<!--sentinel--> |
spring: |
3.4.3、请求限流
直接在 sentinel 设置就行:
3.4.4、线程隔离
总的处理能力等于并发线程数乘上单机 QPS
3.4.5、服务熔断
3.5、分布式事务
3.5.1、Seata
解决分布式事务的方案有很多,但实现起来都比较复杂,因此我们一般会使用开源的框架来解决分布式事务问题。在众多的开源分布式事务框架中,功能最完善、使用最多的就是阿里巴巴在 2019 年开源的 Seata 了。
其实分布式事务产生的一个重要原因,就是参与事务的多个分支事务互相无感知,不知道彼此的执行状态。因此解决分布式事务的思想非常简单:
就是找一个统一的事务协调者,与多个分支事务通信,检测每个分支事务的执行状态,保证全局事务下的每一个分支事务同时成功或失败即可。大多数的分布式事务框架都是基于这个理论来实现的。
Seata 也不例外,在 Seata 的事务管理中有三个重要的角色:
- TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - 资源管理器:管理分支事务,与 TC 交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
Seata 的工作架构如图所示:
其中,TM 和 RM 可以理解为 Seata 的客户端部分,引入到参与事务的微服务依赖中即可。将来 TM 和 RM 就会协助微服务,实现本地分支事务与 TC 之间交互,实现事务的提交或回滚。而 TC 服务则是事务协调中心,是一个独立的微服务,需要单独部署。
将 seata 部署然后访问(需要注意,要确保 nacos、mysql 都在同一个网络中):
微服务集成 Seata
<!--seata--> |
seata: |
3.5.2、XA 模式
RM 一阶段的工作:
- 注册分支事务到 TC
- 执行分支业务 sql 但不提交
- 报告执行状态到 TC
TC 二阶段的工作:
TC 检测各分支事务执行状态
- 如果都成功,通知所有 RM 提交事务
- 如果有失败,通知所有 RM 回滚事务
RM 二阶段的工作:
- 接收 TC 指令,提交或回滚事务
进行测试,可以发现事务成功回滚:
XA 模式的优点是什么?
- 事务的强一致性,满足 ACID 原则
- 常用数据库都支持,实现简单,并且没有代码侵入
XA 模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
3.5.3、AT 模式
AT 模式同样是分阶段提交的事务模型,不过缺弥补了 XA 模型中资源锁定周期过长的缺陷。
Seata 的 AT 模型:
阶段一 RM 的工作:
- 注册分支事务
- 记录 undo-log(数据快照)
- 执行业务 sql 并提交
- 报告事务状态
阶段二提交时 RM 的工作:
- 删除 undo-log 即可
阶段二回滚时 RM 的工作:
- 根据 undo-log 恢复数据到更新前
AT 模式与 XA 模式的最大区别:
维度 | AT 模式 | XA 模式 |
锁定范围 | 业务逻辑层的状态,锁定时间短 | 数据库资源(行或表),锁定时间长 |
一致性 | 最终一致性,通过补偿机制实现 | 强一致性,依赖数据库的两阶段提交 |
4、案例演示:微服务调用的完整流程
这一部分我将通过消息队列对服务之间的异步调用进行演示,同时讲一下延迟消息的实现。
先让我们来看一下什么是延迟消息:
其中 ttl.queue 队列由于没用消费者监听,那么它的消息就会成为死信,我们将其绑定到对应的死信交换机上,这个时候如果由消费者来监听就能够成功接收消息,但是此时距离消息发送已经过去了一段时间,所以产生了延时。
但是!基于死信队列虽然可以实现延迟消息,但太麻烦了。因此 RabbitMQ 社区提供了一个延迟消息插件来实现相同的效果。
rabbitmq/rabbitmq-delayed-message-exchange: Delayed Messaging for RabbitMQ (github.com)https://github.com/rabbitmq/rabbitmq-delayed-message-exchangehttps://github.com/rabbitmq/rabbitmq-delayed-message-exchange这样我们就能够使用注解的方式直接发送延时消息了:
介绍完了延时消息,下面进行案例演示,以下是项目流程图:
这里描述主要流程,涉及三个服务:订单服务、支付服务 和 商品服务。当用户提交订单后,系统将依次完成以下步骤:
- 扣减库存:调用商品服务扣减对应商品的库存。
- 创建支付订单:订单服务创建支付订单,并跳转至支付服务。
- 支付处理:用户在支付服务中完成支付,支付成功后,支付服务通过消息通知订单服务支付成功。
- 消息丢失处理:如果支付服务长时间未向订单服务返回支付成功的消息(可能由于消息丢失),订单服务会通过延迟消息机制检测到异常。
- 关闭订单:订单服务将支付订单状态修改为 已关闭,并调用支付服务将支付状态同步为 已取消。
- 恢复库存:订单服务调用商品服务,恢复相应的商品库存。
这样,通过延迟消息机制和状态回滚,系统能够有效应对消息丢失,确保最终一致性。
改造下单业务,发送延迟消息:
// 5.发送延迟消息,检测订单支付状态 |
这里将 交换机 和 Routing key 写成了一个常量类:
public interface MQConstants { |
然后编写监听消息,查询支付状态:
|
从这个类绑定的交换机不难看出它就是用来处理延时消息后的逻辑的,其中查询支付状态已经实现,我们拿到支付状态后如果已支付就可以直接对订单的状态进行更改,但是如果消息超时了就需要进行回滚操作也就是其中的 cancelOrder 方法,下面就来实现这个方法。
cancelOrder
|
其中的更新支付状态以及回复库存的接口还需要我们实现
payClient
|
payController
|
payServiceImpl
|
itemClient
|
itemController
|
itemServiceImpl
|
至此所有功能开发完毕,进行测试看商品数据是否恢复,支付状态是否更新:
5、结语
微服务架构为现代应用提供了更高的灵活性和可扩展性,同时也带来了更多的挑战。在本文中,我们通过实际案例,探讨了服务调用的全过程,分析了同步和异步调用的不同场景,并介绍了如何利用负载均衡、服务注册与发现等关键技术来实现微服务间的高效通信。
尽管微服务架构能极大提升开发和部署的效率,但在实际应用中,我们仍需面对分布式事务、服务治理等问题。未来的学习中,深入理解这些问题并探索解决方案,将帮助我们构建更可靠、更高效的分布式系统。
希望这篇文章能为你在微服务的学习和实践上提供一些思路,也期待我们在这个领域不断积累经验,迎接更多的挑战。