分布式事务

CAP定理

1998年,加州大学的计算机科学家Eric Brewer提出,分布式系统有三个指标:

  • Consistency(一致性)
  • Availability(可用性)
  • Partition tolerance(分区容错性)

Eric Brewer认为,分布式系统无法同时满足这三个指标

这个结论就叫做CAP定理

Consistency

Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致

比如现在包含两个节点,其中的初始数据是一致的:

whiteboard_exported_image (23)

当修改其中一个节点的数据时,两者的数据产生了差异:

whiteboard_exported_image (24)

要想保住一致性,就必须实现node01 到 node02的数据 同步:

whiteboard_exported_image (25)

Availability

Availability(可用性):用户访问分布式系统时,读或写操作总能成功。只能读不能写,或者只能写不能读,或者两者都不能执行,说明系统弱可用或不可用

Partition tolerance

Partition(分区):因为网络故障或其他原因导致分布式系统中的部分节点与其他节点失去连接,形成独立分区

whiteboard_exported_image (26)

如上图,node01和node02之间网关畅通,但是与node03之间网络断开。于是node03成为一个独立的网络分区;node01和node02在一个网络分区

Tolerance(容错):即便是系统出现网络分区,整个系统也要持续对外提供服务

三者间的矛盾

在分布式系统中,网络不能100%保证畅通,也就是说网络分区的情况一定会存在。而系统必须要持续运行,对外提供服务。所以**分区容错性(P)是硬性指标,所有分布式系统都要满足。而在设计分布式系统时要取舍的就是一致性(C)和可用性(A)了**

假如现在出现了网络分区,如图:

whiteboard_exported_image (27)

由于网络故障,当数据写入node01时,可以与node02完成数据同步,但是无法同步给node03。现在有两种选择:

  • 允许用户任意读写,保证可用性。但由于node03无法完成同步,就会出现数据不一致的情况。满足AP
  • 不允许用户写,可以读,直到网络恢复,分区消失。这样就确保了一致性,但牺牲了可用性。满足CP

BASE理论

BASE理论是对CAP的一种解决思路,包含三个思想:

  • Basically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用
  • Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态
  • Eventually Consistency(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致

简单来说,BASE理论就是一种取舍的方案,不再追求完美,而是最终达成目标。因此解决分布式事务的思想也是这样,有两个方向:

  • AP思想:各个子事务分别执行和提交,无需锁定数据。允许出现结果不一致,然后采用弥补措施恢复,实现最终一致即可。例如AT模式就是如此
  • CP思想:各个子事务执行后不要提交,而是等待彼此结果,然后同时提交或回滚。在这个过程中锁定资源,不允许其它人访问,数据处于不可用状态,但能保证一致性。例如XA模式

AT模式的脏写问题

先回顾一下AT模式的流程,AT模式也分为两个阶段:

第一阶段是记录数据快照,执行并提交事务:

1f905adc-c231-4e0c-8ad2-a0c400827cb3

第二阶段根据阶段一的结果来判断:

  • 如果每一个分支事务都成功,则事务已经结束(因为阶段一已经提交),因此删除阶段一的快照即可
  • 如果有任意分支事务失败,则需要根据快照恢复到更新前数据。然后删除快照
bf4073ce-66bf-49f4-8395-b564a768365c

这种模式在大多数情况下(99%)并不会有什么问题,不过在极端情况下,特别是多线程并发访问AT模式的分布式事务时,有可能出现脏写问题,如图:

651420a2-4ae7-4f5f-acf5-952a4749fd72

解决思路就是引入了**全局锁**的概念。在释放DB锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据

cc2c985f-db9a-457d-889f-f7b876df81a6

具体可以参考官方文档:Seata AT 模式 | Apache Seata

以上的解决思路是在事务1和事务2都被Seata管理的情况下。假设存在另一种场景,事务1由Seata管理,而事务2却没有,在这种情况下,Seata也给出了解决方案:

PixPin_2025-12-01_13-28-17

在事务前先记录一次原始数据的快照,在恢复快照前再记录一次最新数据的快照,对比两次快照可以发现数据不一致。说明在事务的二阶段前,有其他事务进行了数据的操作,那么此时就需要人工进行介入了(属于兜底方案,发生概率很低)

TCC模式

TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:

  • try:资源的检测和预留;
  • confirm:完成资源操作业务;要求 try 成功 confirm 一定要能成功。
  • cancel:预留资源释放,可以理解为try的反向操作。

TCC模式原理

举例:一个扣减用户余额的业务。假设账户A原来余额是100元,需要余额扣减30元

  • 阶段一(Try):检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30元

如图,初始余额如下:

cb9262d8-c346-4796-a014-6af20a4d839c

余额充足,可以冻结:

4376dc0b-769d-4a24-b2d2-ef78336697c3

此时,总金额 = 冻结金额 + 可用金额,数量依然是100不变。事务直接提交无需等待其它事务

  • 阶段二(Confirm):假如要提交(Confirm),之前可用金额已经扣减,并转移到冻结金额。因此可用金额不变,直接冻结金额扣减30即可

922d1c6b-ea3a-44f6-b77f-95b0c715dbd7

此时,总金额 = 冻结金额 + 可用金额 = 0 + 70 = 70元

  • 阶段二(Cancel):如果要回滚(Cancel),则释放之前冻结的金额,也就是冻结金额扣减30,可用余额增加30

290552b0-39cf-45e3-8cd9-2a6351b79403

简单来说,TCC通过阶段一资源的预留,阶段二对预留资源进行操作,所以无需加锁对性能产生影响

下图是TCC模式的工作模型:

PixPin_2025-12-01_13-51-49

事务悬挂和空回滚

假如一个分布式事务中包含两个分支事务,try阶段,一个分支成功执行,另一个分支事务阻塞

fd5368b7-5bd6-4a00-bdaf-fdfd81a99b7c

如果阻塞时间太长,可能导致全局事务超时而触发二阶段的cancel操作。两个分支事务都会执行cancel操作:

4325d059-c4dd-41ca-a333-c736ac2a43a9

要知道,其中一个分支是未执行try操作的,直接执行了cancel操作,反而会导致数据错误。因此,这种情况下,尽管cancel方法要执行,但其中不能做任何回滚操作,这就是空回滚

对于整个空回滚的分支事务,将来try方法阻塞结束依然会执行。但是整个全局事务其实已经结束了,因此永远不会再有confirm或cancel,也就是说这个事务执行了一半,处于悬挂状态,这就是业务悬挂问题

以上问题都需要在编写try、cancel方法时处理

XA、AT、TCC对比

XA模式:

  • ✅ 实现简单,学习成本低
  • ✅ 强一致性保证
  • ❌ 性能较差,资源锁定时间长

AT模式:

  • ✅ 无业务侵入,开发简单
  • ✅ 性能较好
  • ❌ 一致性保证相对较弱

TCC模式:

  • ✅ 性能最优,控制精细
  • ✅ 最终一致性可控
  • ❌ 开发复杂度最高

选择建议:

  • 新手团队或对一致性要求极高:选择 XA 模式
  • 快速开发且性能要求适中:选择 AT 模式
  • 高性能核心业务且有经验团队:选择 TCC 模式

总结

TCC模式的每个阶段是做什么的?

  • Try:资源检查和预留
  • Confirm:业务执行和提交
  • Cancel:预留资源的释放

TCC的优点是什么?

  • 一阶段完成直接提交事务,释放数据库资源,性能好
  • 相比AT模型,无需生成快照,无需使用全局锁,性能最强
  • 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库(比如Redis)

TCC的缺点是什么?

  • 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
  • 软状态,事务是最终一致
  • 需要考虑Confirm和Cancel的失败情况,做好幂等处理、事务悬挂和空回滚处理

最大努力通知

最大努力通知是一种最终一致性的分布式事务解决方案。顾名思义,就是通过消息通知的方式来通知事务参与者完成业务执行,如果执行失败会多次通知。无需任何分布式事务组件接入PixPin_2025-12-01_14-06-32

实际上,最大努力通知也可以不依赖于消息中间件。比如服务二暴露一个接口,服务一执行完本地事务后调用服务二的接口,服务二执行完事务后再返回结果给服务一(不过耦合度会较高)

注册中心

环境隔离

企业实际开发中,往往会搭建多个运行环境,例如:开发环境、测试环境、发布环境。不同环境之间需要隔离。或者不同项目使用了同一套Naco,不同项目之间要做环境隔离

类似于SpringBoot中的application.yaml,通过application-dev、application-test、applicaiton-local进行配置的隔离

因此,Nacos提供了基于namespace的环境隔离功能。具体的隔离层次如图所示:

b99645db-a07b-4f8a-9a1f-d274e34f6c5a

说明:

  • Nacos中可以配置多个namespace,相互之间完全隔离。默认的namespace名为public
  • namespace下还可以继续分组,也就是group ,相互隔离。 默认的group是DEFAULT_GROUP
  • group之下就是服务和配置了

创建namespace

nacos提供了一个默认的namespace,叫做public

26bef30e-aeac-4735-8b63-57f6197c4aa8

默认所有的服务和配置都属于这个namespace,当然也可以自己创建新的namespace

ecf0c2b2-5a69-44a4-b319-8a8a09d8dce4

然后填写表单:

7ef5d77c-fa5e-4d1c-b762-1b9448722173

添加完成后,可以在页面看到新建的namespace,并且Nacos自动生成了一个命名空间id:

8401c9af-2d1a-4b11-a897-c40a88ee1af9

切换到配置列表页,会发现dev这个命名空间下没有任何配置:

4387015f-3d6e-4d63-a3b1-67ee6558a10c

因为之前添加的所有配置都在public下:

75459f23-ff28-4028-898f-77f005abdb48

微服务配置namespace

默认情况下,所有的微服务注册发现、配置管理都是走public这个命名空间。如果要指定命名空间则需要修改application.yaml文件。

比如,修改item-service服务的bootstrap.yml文件,添加服务发现配置,指定其namespace

1
2
3
4
5
6
7
8
9
10
11
spring:
application:
name: item-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.150.101 # nacos地址
discovery: # 服务发现配置
namespace: 8c468c63-b650-48da-a632-311c75e6d235 # 设置namespace,必须用id
# 。。。略

启动item-service,查看服务列表,会发现item-service出现在dev下:

28c275ed-6fa2-4c32-80ba-04e192afbaef

而其它服务则出现在public下:

bbfdf800-fe8d-4066-8417-5e8187fff7fa

此时访问http://localhost:8082/doc.html,基于swagger做测试:

15253cd4-b193-4301-8a35-a9b51686ab3d

会发现查询结果中缺少商品的最新价格信息。

查看服务运行日志:

129523de-6ee1-466c-8ced-131300512847

会发现cart-service服务在远程调用item-service时,并没有找到可用的实例。这证明不同namespace之间确实是相互隔离的,不可访问

当将namespace切换回public,或者统一都是以dev时访问恢复正常

分级模型

在一些大型应用中,同一个服务可以部署很多实例。而这些实例可能分布在全国各地的不同机房。由于存在地域差异,网络传输的速度会有很大不同,因此在做服务治理时需要区分不同机房的实例

例如item-service,可以部署3个实例:

  • 127.0.0.1:8081
  • 127.0.0.1:8082
  • 127.0.0.1:8083

假如这些实例分布在不同机房,例如:

  • 127.0.0.1:8081,在上海机房
  • 127.0.0.1:8082,在上海机房
  • 127.0.0.1:8083,在杭州机房

Nacos中提供了集群(cluster)的概念,来对应不同机房。也就是说,一个服务(service)下可以有很多集群(cluster),而一个集群(cluster)中下又可以包含很多实例(instance

如图:

9253403b-4ff5-405b-83e4-8b7fe35a314e

因此,结合上一节学习的namespace命名空间的知识,任何一个微服务的实例在注册到Nacos时,都会生成以下几个信息,用来确认当前实例的身份,从外到内依次是:

  • namespace:命名空间
  • group:分组
  • service:服务名
  • cluster:集群
  • instance:实例,包含ip和端口

这就是nacos中的服务分级模型

在Nacos内部会有一个服务实例的注册表,是基于Map实现的,其结构与分级模型的对应关系如下:

52c38437-1b84-4e23-8173-92a3f981dec7

查看nacos控制台,会发现默认情况下所有服务的集群都是default:

0a67bc05-98c1-4580-83b4-2634c66d5662

如果要修改服务所在集群,只需要修改bootstrap.yml即可:

1
2
3
4
5
spring:
cloud:
nacos:
discovery:
cluster-name: BJ # 集群名称,自定义

Eureka

Eureka是Netflix公司开源的一个服务注册中心组件,早期版本的SpringCloud都是使用Eureka作为注册中心。由于Eureka和Nacos的starter中提供的功能都是基于SpringCloudCommon规范,因此两者使用起来差别不大

课前资料中提供了一个Eureka的demo:

a35d2167-8735-4adf-9185-9191668a78b8

用idea打开查看一下:

78cdcdbb-c7e2-484c-bd9f-1378b6ef083d

结构说明:

  • eureka-server:Eureka的服务端,也就是注册中心。没错,Eureka服务端要自己创建项目
  • order-service:订单服务,是一个服务调用者,查询订单的时候要查询用户
  • user-service:用户服务,是一个服务提供者,对外暴露查询用户的接口

JDK版本需要为1.8

启动以后,访问localhost:10086即可查看到Eureka的控制台,相对于Nacos来说简陋了很多:

ea747079-20e3-4aa4-ac27-4ae1901e131a

微服务引入Eureka的方式也极其简单,分两步:

  • 引入eureka-client依赖
  • 配置eureka地址

接下来就是编写OpenFeign的客户端了

Eureka和Nacos对比

Eureka和Nacos都能起到注册中心的作用,用法基本类似。但还是有一些区别的,例如:

  • Nacos支持配置管理,而Eureka则不支持。

而且服务注册发现上也有区别,来做一个实验:

停止user-service服务,然后观察Eureka控制台,会发现很长一段时间过去后,Eureka服务依然没有察觉user-service的异常状态。

这与Eureka的健康检测机制有关。在Eureka中,健康检测的原理如下:

  • 微服务启动时注册信息到Eureka,这点与Nacos一致
  • 微服务每隔30秒向Eureka发送心跳请求,报告自己的健康状态。Nacos中默认是5秒一次
  • Eureka如果90秒未收到心跳,则认为服务疑似故障,可能被剔除Nacos中则是15秒超时,30秒剔除
  • Eureka如果发现超过85%比例的服务都心跳异常,会认为是自己的网络异常,暂停剔除服务的功能
  • Eureka每隔60秒执行一次服务检测和清理任务Nacos是每隔5秒执行一次

综上,会发现Eureka是尽量不剔除服务,避免“误杀”,宁可放过一千,也不错杀一个。这就导致当服务真的出现故障时,迟迟不会被剔除,给服务的调用者带来困扰

不仅如此,当Eureka发现服务宕机并从服务列表中剔除以后,**并不会将服务列表的变更消息推送给所有微服务**。而是等待微服务自己来拉取时发现服务列表的变化。而微服务每隔30秒才会去Eureka更新一次服务列表,进一步推迟了服务宕机时被发现的时间

而Nacos中微服务除了自己定时去Nacos中拉取服务列表以外,Nacos还会在服务列表变更时**主动推送最新的服务列表给所有的订阅者**

综上,Eureka和Nacos的相似点有:

  • 都支持服务注册发现功能
  • 都有基于心跳的健康监测功能
  • 都支持集群,集群间数据同步默认是AP模式,即最全高可用性

Eureka和Nacos的区别有:

  • Eureka的心跳是30秒一次,Nacos则是5秒一次
  • Eureka如果90秒未收到心跳,则认为服务疑似故障,可能被剔除。Nacos中则是15秒超时,30秒剔除。
  • Eureka每隔60秒执行一次服务检测和清理任务;Nacos是每隔5秒执行一次。
  • Eureka只能等微服务自己每隔30秒更新一次服务列表;Nacos即有定时更新,也有在服务变更时的广播推送
  • Eureka仅有注册中心功能,而Nacos同时支持注册中心、配置管理
  • Eureka和Nacos都支持集群,而且默认都是AP模式

远程调用

负载均衡原理

自SpringCloud2020版本开始,SpringCloud弃用Ribbon,改用Spring自己开源的Spring Cloud LoadBalancer了。OpenFeign、GateWay都已经与其整合

OpenFeign在整合SpringCloudLoadBalancer时,与手动服务发现、负载均衡的流程类似:

图片1

  1. 获取serviceId,也就是服务名称
  2. 根据serviceId拉取服务列表
  3. 利用负载均衡算法选择一个服务
  4. 重构请求的URL路径,发起远程调用

源码跟踪

首先,在com.hmall.cart.service.impl.CartServiceImpl中的queryMyCarts方法中打一个断点。然后在swagger页面请求购物车列表接口

进入断点后,观察ItemClient这个接口:

42e3e63c-c119-4885-a836-2e6b90d063f6

会发现ItemClient是一个代理对象,而代理的处理器则是SentinelInvocationHandler。这是因为项目中引入了Sentinel导致

进入SentinelInvocationHandler类中的invoke方法看看:

0a095005-26a2-40c6-bdf8-23820bd580c7

可以看到这里是先获取被代理的方法的处理器MethodHandler,接着,Sentinel就会开启对簇点资源的监控:

f2ad3a6e-d581-404a-9c7f-4e62d551f1d6

开启Sentinel的簇点资源监控后,就可以调用处理器了,尝试跟入,会发现有两种实现:

79e93731-cce0-4341-a760-324f1700b80f

这其实就是OpenFeign远程调用的处理器了。继续跟入会进入SynchronousMethodHandler这个实现类:

bc2a3ec9-ecec-46b9-8b41-fdaec7627684

在上述方法中,会循环尝试调用executeAndDecode()方法,直到成功或者是重试次数达到Retryer中配置的上限

继续跟入executeAndDecode()方法:

e3696e40-4e8d-4b80-8060-9bb95e058ebb

executeAndDecode()方法最终会利用client去调用execute()方法,发起远程调用

这里的client的类型是feign.Client接口,其下有很多实现类:

095824f7-c10e-4692-9f01-0ba335d0999d

由于项目中整合了seata,所以这里client对象的类型是SeataFeignBlockingLoadBalancerClient,内部实现如下:

7d029eb9-8d12-48df-b98b-23e5d160b6d0

这里直接调用了其父类,也就是FeignBlockingLoadBalancerClientexecute方法,来看一下:

9bde006e-9f61-4136-84e0-f0dfa06dfee5

整段代码中核心的有4步:

  • 从请求的URI中找出serviceId
  • 利用loadBalancerClient,根据serviceId做负载均衡,选出一个实例ServiceInstance
  • 用选中的ServiceInstanceipport替代serviceId,重构URI
  • 向真正的URI发送请求

所以负载均衡的关键就是这里的loadBalancerClient,类型是org.springframework.cloud.client.loadbalancer.LoadBalancerClient,这是Spring-Cloud-Common模块中定义的接口,只有一个实现类:

49ff9e52-98de-4397-9b6b-0130617eddd6

而这里的org.springframework.cloud.client.loadbalancer.BlockingLoadBalancerClient正是Spring-Cloud-LoadBalancer模块下的一个类:

c20073ed-d4a7-46d0-abbb-c26655b8a189

继续跟入其BlockingLoadBalancerClient#choose()方法:

b2d804e8-d710-4e38-af8d-f31618d93d42

图中代码的核心逻辑如下:

  • 根据serviceId找到这个服务采用的负载均衡器(ReactiveLoadBalancer),也就是说我们可以给每个服务配不同的负载均衡算法。
  • 利用负载均衡器(ReactiveLoadBalancer)中的负载均衡算法,选出一个服务实例

ReactiveLoadBalancerSpring-Cloud-Common组件中定义的负载均衡器接口规范,而Spring-Cloud-Loadbalancer组件给出了两个实现:

1078ef5a-ecf5-4bb8-bf63-949c2452066d

默认的实现是RoundRobinLoadBalancer,即轮询负载均衡器。负载均衡器的核心逻辑如下:

227f9ecc-609b-4e40-8db0-6067b8d0f95c

核心流程就是两步:

  • 利用ServiceInstanceListSupplier#get()方法拉取服务的实例列表,这一步是采用响应式编程
  • 利用本类,也就是RoundRobinLoadBalancergetInstanceResponse()方法挑选一个实例,这里采用了轮询算法来挑选

这里的ServiceInstanceListSupplier有很多实现:

42aa12d6-da27-44a7-b8c0-dab43b80978a

其中CachingServiceInstanceListSupplier采用了装饰模式,加了服务实例列表缓存,避免每次都要去注册中心拉取服务实例列表。而其内部是基于DiscoveryClientServiceInstanceListSupplier来实现的

在这个类的构造函数中,就会异步的基于DiscoveryClient去拉取服务的实例列表:

7604a5c6-4b81-46b7-8e8c-aa28d6f42870

流程梳理

根据之前的分析,会发现Spring在整合OpenFeign的时候,实现了org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient类,其中定义了OpenFeign发起远程调用的核心流程。也就是四步:

  • 获取请求中的serviceId
  • 根据serviceId负载均衡,找出一个可用的服务实例
  • 利用服务实例的ipport信息重构url
  • 向真正的url发起请求

而具体的负载均衡则是不是由OpenFeign组件负责。而是分成了负载均衡的接口规范,以及负载均衡的具体实现两部分。

负载均衡的接口规范是定义在Spring-Cloud-Common模块中,包含下面的接口:

  • LoadBalancerClient:负载均衡客户端,职责是根据serviceId最终负载均衡,选出一个服务实例
  • ReactiveLoadBalancer:负载均衡器,负责具体的负载均衡算法

OpenFeign的负载均衡是基于Spring-Cloud-Common模块中的负载均衡规则接口,并没有写死具体实现。这就意味着以后还可以拓展其它各种负载均衡的实现。

不过目前SpringCloud中只有Spring-Cloud-Loadbalancer这一种实现。

Spring-Cloud-Loadbalancer模块中,实现了Spring-Cloud-Common模块的相关接口,具体如下:

  • BlockingLoadBalancerClient:实现了LoadBalancerClient,会根据serviceId选出负载均衡器并调用其算法实现负载均衡。
  • RoundRobinLoadBalancer:基于轮询算法实现了ReactiveLoadBalancer
  • RandomLoadBalancer:基于随机算法实现了ReactiveLoadBalancer

这样一来,整体思路就非常清楚了,流程图如下:

whiteboard_exported_image (28)

NacosRule

之前分析源码的时候发现负载均衡的算法是有ReactiveLoadBalancer来定义的,它的实现类有三个:

92e9f502-02c1-4e4b-800d-a389c1a4d253

其中RoundRobinLoadBalancerRandomLoadBalancer是由Spring-Cloud-Loadbalancer模块提供的,而NacosLoadBalancer则是由Nacos-Discorvery模块提供的

默认采用的负载均衡策略是RoundRobinLoadBalancer,那想要切换负载均衡策略该怎么办?

修改负载均衡策略

查看源码会发现,Spring-Cloud-Loadbalancer模块中有一个自动配置类:

5b3ad301-159c-49c8-bfd0-85e550a4ed94

其中定义了默认的负载均衡器:

7d018dd5-80b5-4e17-9ce1-5510f6d4f936

这个Bean上添加了@ConditionalOnMissingBean注解,也就是说如果自定义了这个类型的bean,则负载均衡的策略就会被改变

hm-cart模块中的添加一个配置类:

98f79632-7b6d-4b67-9990-4fd8e55e1807

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.hmall.cart.config;

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.loadbalancer.NacosLoadBalancer;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.loadbalancer.core.ReactorLoadBalancer;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;

public class OpenFeignConfig {

@Bean
public ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(
Environment environment, NacosDiscoveryProperties properties,
LoadBalancerClientFactory loadBalancerClientFactory) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new NacosLoadBalancer(
loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class), name, properties);
}

}

注意这个配置类千万不要加@Configuration注解,也不要被SpringBootApplication扫描到

由于这个OpenFeignConfig没有加@Configuration注解,也就没有被Spring加载,因此是不会生效的。所以接下来,要在启动类上通过注解来声明这个配置

有两种做法:

  • 全局配置:对所有服务生效
1
@LoadBalancerClients(defaultConfiguration = OpenFeignConfig.class)
  • 局部配置:只对某个服务生效
1
2
3
@LoadBalancerClients({
@LoadBalancerClient(value = "item-service", configuration = OpenFeignConfig.class)
})

选择全局配置:

132508c1-88d1-4ef3-90ca-6995c79034e0

DEBUG重启后测试,会发现负载均衡器的类型确实切换成功:

a5dd058c-9ca5-43b7-a50c-5860af57cb43

集群优先

RoundRobinLoadBalancer是轮询算法,RandomLoadBalancer是随机算法,那么NacosLoadBalancer是什么负载均衡算法呢?

通过源码来分析一下,先看第一部分:

f192d702-9a38-495f-a5a0-2120fe354265

这部分代码的大概流程如下:

  • 通过ServiceInstanceListSupplier获取服务实例列表
  • 获取NacosDiscoveryProperties中的clusterName,也就是yml文件中的配置,代表当前服务实例所在集群信息(参考分级模型这一节内容)
  • 然后利用stream的filter过滤找到被调用的服务实例中与当前服务实例clusterName一致的。简单来说就是服务调用者与服务提供者要在一个集群

为什么?

假如现在有两个机房,都部署有item-servicecart-service服务:

c429a9f9-17e1-490a-aa57-704b37027e20

假如这些服务实例全部都注册到了同一个Nacos。现在,杭州机房的cart-service要调用item-service,会拉取到所有机房的item-service的实例。调用时会出现两种情况:

  • 直接调用当前机房的item-service
  • 调用其它机房的item-service

本机房调用几乎没有网络延迟,速度比较快。而跨机房调用,如果两个机房相距很远,会存在较大的网络延迟。因此,应该尽可能避免跨机房调用,优先本地集群调用:

57cea13c-0134-40c7-88b7-af82970de0f9

现在的情况是这样的:

  • cart-service所在集群是default
  • item-service的8081、8083所在集群的default
  • item-service的8084所在集群是BJ

cart-service访问item-service时,应该优先访问8081和8082,重启cart-service,测试一下:

181c4ebb-9658-4f1c-86cd-521d66d624bd

可以看到原本是3个实例,经过筛选后还剩下2个实例。

查看Debug控制台:

9928c4ef-b7ea-4ebe-a2ec-0c6c45709de8

同集群的实例还剩下两个,接下来就需要做负载均衡了,具体用的是什么算法呢?

权重配置

继续跟踪NacosLoadBalancer源码:

e62b966b-b968-46df-83ba-12e38297a77d

那么问题来了, 这个权重是怎么配的呢?

打开nacos控制台,进入item-service的服务详情页,可以看到每个实例后面都有一个编辑按钮:

59727cc4-9c02-41f5-bea5-187464469793

点击,可以看到一个编辑表单:

3a4c66d3-54ab-463a-80a3-c2d42126a5d0

将这里的权重修改为5:

7dfd4127-0882-49e6-b80d-48e3b63d8fde

访问10次购物车接口,可以发现大多数请求都访问到了8083这个实例

服务保护

在SpringCloud的早期版本中采用的服务保护技术叫做Hystix,不过后来被淘汰,替换为Spring Cloud Circuit Breaker,其底层实现可以是Spring RetryResilience4J

不过在国内使用较多还是SpringCloudAlibaba中的Sentinel组件

接下来就分析一下Sentinel组件的一些基本实现原理以及它与Hystix的差异

线程隔离

无论是Hystix还是Sentinel都支持线程隔离。不过其实现方式不同

线程隔离有两种方式实现:

  • 线程池隔离:给每个服务调用业务分配一个线程池,利用线程池本身实现隔离效果
  • 信号量隔离:不创建线程池,而是计数器模式,记录业务使用的线程数量,达到信号量上限时,禁止新的请求

Sentinel的线程隔离就是基于信号量隔离实现的,而Hystix两种都支持,但默认是基于线程池隔离

如图:

499f1a1e-91e2-4794-9c5a-dd2f59e9b231

两者的优缺点如下:

2893e14a-a523-4e57-878a-38581387af5e

面试题:Sentinel的线程隔离与Hystix的线程隔离有什么差别?

答:

线程隔离可以采用线程池隔离或者信号量隔离

Hystix默认是基于线程池实现的线程隔离,每一个被隔离的业务都要创建一个独立的线程池,线程过多会带来额外的CPU开销,性能一般,但是隔离性更强

Sentinel则是基于信号量隔离的原理,这种方式不用创建线程池,性能较好,但是隔离性一般

滑动窗口算法

在熔断功能中,需要统计异常请求或慢请求比例,也就是计数。在限流的时候,要统计每秒钟的QPS,同样是计数。可见计数算法在熔断限流中的应用非常多。sentinel中采用的计数器算法就是滑动窗口计数算法

固定窗口计数

要了解滑动窗口计数算法,必须先知道固定窗口计数算法,其基本原理如图:

87575099-5e28-41c2-bfdb-3dda657f0580

说明:

  • 将时间划分为多个窗口,窗口时间跨度称为Interval,本例中为1000ms;
  • 每个窗口维护1个计数器,每有1次请求就将计数器+1。限流就是设置计数器阈值,本例为3,图中红线标记
  • 如果计数器超过了限流阈值,则超出阈值的请求都被丢弃

示例:

1a979a2e-41a7-4be9-9973-a2790a36b687

明:

  • 第1、2秒,请求数量都小于3,没问题
  • 第3秒,请求数量为5,超过阈值,超出的请求被拒绝

但是考虑一种特殊场景,如图:

42a300db-a33d-42c5-9a56-a119c0bbb608

说明:

  • 假如在第5、6秒,请求数量都为3,没有超过阈值,全部放行
  • 但是,如果第5秒的三次请求都是在4.55秒之间进来;第6秒的请求是在55.5之间进来。那么从第4.5~5.之间就有6次请求!也就是说每秒的QPS达到了6,远超阈值

这就是固定窗口计数算法的问题,它只能统计当前某1个时间窗的请求数量是否到达阈值,无法结合前后的时间窗的数据做综合统计

滑动窗口计数

固定时间窗口算法中窗口有很多,其跨度和位置是与时间区间绑定,因此是很多固定不动的窗口。而滑动时间窗口算法中只包含1个固定跨度的窗口,但窗口是可移动动的,与时间区间无关

具体规则如下:

  • 窗口时间跨度Interval大小固定,例如1秒
  • 时间区间跨度为Interval / n ,例如n=2,则时间区间跨度为500ms(Sentinel内部默认就是2,可以修改)
  • 窗口会随着当前请求所在时间currentTime移动,窗口范围从currentTime-Interval时刻之后的第一个时区开始,到currentTime所在时区结束

如图所示:

028a1203-0dcc-49b1-9acd-0dba68c34637

限流阈值依然为3,绿色小块就是请求,上面的数字是其currentTime值。

  • 在第1300ms时接收到一个请求,其所在时区就是1000~1500
  • 按照规则,currentTime-Interval值为300ms,300ms之后的第一个时区是5001000,因此窗口范围包含两个时区:5001000、1000~1500,也就是粉红色方框部分
  • 统计窗口内的请求总数,发现是3,未达到上限

若第1400ms又来一个请求,会落在1000~1500时区,虽然该时区请求总数是3,但滑动窗口内总数已经达到4,因此该请求会被拒绝:

fb8f97bc-8d4a-4fe6-8b6a-d7234d7fc6c9

假如第1600ms又来的一个请求,处于15002000时区,根据算法,滑动窗口位置应该是10001500和1500~2000这两个时区,也就是向后移动:

1e9dd3c3-9580-4b47-a2c1-71792f70146c

这就是滑动窗口计数的原理,解决了之前所说的问题。而且滑动窗口内划分的时区越多,这种统计就越准确

令牌桶算法

限流的另一种常见算法是令牌桶算法。Sentinel中的热点参数限流正是基于令牌桶算法实现的。其基本思路如图:

efc3ffb8-5d7c-486d-a2dd-f731e8450390

说明:

  • 以固定的速率生成令牌,存入令牌桶中,如果令牌桶满了以后,多余令牌丢弃
  • 请求进入后,必须先尝试从桶中获取令牌,获取到令牌后才可以被处理
  • 如果令牌桶中没有令牌,则请求等待或丢弃

基于令牌桶算法,每秒产生的令牌数量基本就是QPS上限

当然也有例外情况,例如:

  • 某一秒令牌桶中产生了很多令牌,达到令牌桶上限N,缓存在令牌桶中,但是这一秒没有请求进入。
  • 下一秒的前半秒涌入了超过2N个请求,之前缓存的令牌桶的令牌耗尽,同时这一秒又生成了N个令牌,于是总共放行了2N个请求。超出了设定的QPS阈值。

因此,在使用令牌桶算法时,尽量不要将令牌上限设定到服务能承受的QPS上限。而是预留一定的波动空间,这样才能应对突发流量

漏桶算法

漏桶算法与令牌桶相似,但在设计上更适合应对并发波动较大的场景,以解决令牌桶中的问题

简单来说就是请求到达后不是直接处理,而是先放入一个队列。而后以固定的速率从队列中取出并处理请求。之所以叫漏桶算法,就是把请求看做水,队列看做是一个漏了的桶

如图:

cb6437b6-0491-49ab-a876-c673925222f5

说明:

  • 将每个请求视作”水滴”放入”漏桶”进行存储;
  • “漏桶”以固定速率向外”漏”出请求来执行,如果”漏桶”空了则停止”漏水”;
  • 如果”漏桶”满了则多余的”水滴”会被直接丢弃。

漏桶的优势就是流量整型,桶就像是一个大坝,请求就是水。并发量不断波动,就如图水流时大时小,但都会被大坝拦住。而后大坝按照固定的速度放水,避免下游被洪水淹没

因此,不管并发量如何波动,经过漏桶处理后的请求一定是相对平滑的曲线:

0be46ef0-a625-4ab8-81ec-098c45e4cc6b

sentinel中的限流中的排队等待功能正是基于漏桶算法实现的

Sentinel和Gateway限流区别

限流算法常见的有三种实现:滑动时间窗口、令牌桶算法、漏桶算法。Gateway则采用了基于Redis实现的令牌桶算法,而Sentinel内部却比较复杂:

  • 默认限流模式是基于滑动窗口算法,另外Sentinel中断路器的计数也是基于滑动窗口算法
  • 限流后可以快速失败和排队等待,其中排队等待基于漏桶算法
  • 而热点参数限流则是基于令牌桶算法