1.分布式事务
分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务,例如:
- 跨数据源的分布式事务
- 跨服务的分布式事务
- 综合情况
我们之前解决分布式事务问题是直接使用Seata框架的AT模式,但是解决分布式事务问题的方案远不止这一种。
1.1.CAP定理
解决分布式事务问题,需要一些分布式系统的基础知识作为理论指导,首先就是CAP定理
。
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:
- Consistency(一致性)
- Availability(可用性)
- Partition tolerance (分区容错性)
它们的第一个字母分别是 C、A、P。Eric Brewer认为任何分布式系统架构方案都不可能同时满足这3个目标
,这个结论就叫做 CAP 定理。
为什么呢?
1.1.1.一致性
Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致
。
比如现在包含两个节点,其中的初始数据是一致的:
当我们修改其中一个节点的数据时,两者的数据产生了差异:
要想保住一致性,就必须实现node01 到 node02的数据 同步:
1.1.2.可用性
Availability (可用性):用户访问分布式系统时,读或写操作总能成功
。
只能读不能写,或者只能写不能读,或者两者都不能执行,就说明系统弱可用或不可用。
1.1.3.分区容错
Partition,就是分区,就是当分布式系统节点之间出现网络故障导致节点之间无法通信
的情况:
如上图,node01和node02之间网关畅通,但是与node03之间网络断开。于是node03成为一个独立的网络分区;node01和node02在一个网络分区
。
Tolerance,就是容错,即便是系统出现网络分区,整个系统也要持续对外提供服务
。
1.1.4.矛盾
在分布式系统中,网络不能100%保证畅通,也就是说网络分区的情况一定会存在
。而我们的系统必须要持续运行,对外提供服务。所以分区容错性(P)是硬性指标
,所有分布式系统都要满足。而在设计分布式系统时要取舍的就是一致性(C)和可用性(A)
了。
假如现在出现了网络分区,如图:
由于网络故障,当我们把数据写入node01时,可以与node02完成数据同步,但是无法同步给node03。现在有两种选择
:
- 允许用户任意读写,
保证可用性
。但由于node03无法完成同步,就会出现数据不一致的情况。满足AP - 不允许用户写,可以读,直到网络恢复,分区消失。这样就
确保了一致性
,但牺牲了可用性。满足CP
可见,在分布式系统中,A和C之间只能满足一个
。
1.2.BASE理论
既然分布式系统要遵循CAP定理,那么问题来了,我到底是该牺牲一致性还是可用性呢
?如果牺牲了一致性,出现数据不一致该怎么处理?
人们在总结系统设计经验时,最终得到了一些心得
:
Basically Available (基本可用)
:分布式系统在出现故障时,允许损失部分可用性
,即保证核心可用
。Soft State(软状态)
:在一定时间内,允许出现中间状态
,比如临时的不一致
状态。Eventually Consistent(最终一致性)
:虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致
。
以上就是BASE理论。
简单来说,BASE理论就是一种取舍
的方案,不再追求完美,而是最终达成目标
。因此解决分布式事务的思想也是这样,有两个方向
:
AP
思想:各个子事务分别执行和提交,无需锁定数据
。允许出现结果不一致,然后采用弥补措施恢复,实现最终一致即可
。例如AT模式
就是如此CP
思想:各个子事务执行后不要提交,而是等待彼此结果,然后同时提交或回滚
。在这个过程中锁定资源,不允许其它人访问
,数据处于不可用
状态,但能保证一致性
。例如XA
模式
1.3.AT模式的脏写问题
我们先回顾一下AT模式的流程,AT模式也分为两个阶段:
第一阶段是记录数据快照
,执行并提交事务
:
第二阶段根据阶段一的结果来判断:
- 如果每一个分支事务都
成功
,则事务已经结束
(因为阶段一已经提交),因此删除
阶段一的快照
即可 - 如果有
任意
分支事务失败
,则需要根据快照恢复
到更新前数据
。然后删除快照
这种模式在大多数情况下(99%)并不会有什么问题,不过在极端情况下,特别是多线程并发访问AT模式的分布式事务时,有可能出现脏写
问题,如图:
解决思路就是引入了全局锁
的概念。在释放DB锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据
。(db锁的等待时长非常长,而事务2的全局锁的等待时长只有300毫秒,所以一般最后事务1一定会拿到全局锁和db锁
,不会想回等待进入死锁)
具体可以参考官方文档:
https://seata.apache.org/zh-cn/docs/dev/mode/at-mode/
全局锁能够限制的是都被seata统一管理的
,如果有一个操作不是seata提哦难过一管理的,是其他操作的,那么全局锁就会失效
,需要人工介入
1.4.TCC模式
TCC模式与AT模式非常相似,每阶段都是独立事务
,不同的是TCC通过人工编码来实现数据恢复
。需要实现三个方法:
try
:资源的检测和预留
;confirm
:完成资源操作业务;要求 try成功 confirm
一定要能成功。cancel
:预留资源释放,可以理解为try的反向操作
。
1.4.1.流程分析
举例,一个扣减用户余额的业务。假设账户A原来余额是100,需要余额扣减30元。
阶段一( Try ):检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30
初始余额:
余额充足,可以冻结
:
此时,总金额 = 冻结金额 + 可用金额,数量依然是100不变。事务直接提交无需等待其它事务
。
阶段二(Confirm)
:假如要提交(Confirm),之前可用金额已经扣减,并转移到冻结金额。因此可用金额不变,直接冻结金额扣减30
即可:
此时,总金额 = 冻结金额 + 可用金额 = 0 + 70 = 70元
阶段二(Canncel)
:如果要回滚(Cancel),则释放之前冻结的金额
,也就是冻结金额扣减30,可用余额增加30
1.4.2.事务悬挂和空回滚
假如一个分布式事务中包含两个分支事务,try阶段,一个分支成功执行,另一个分支事务阻塞
:
如果阻塞时间太长,可能导致全局事务超时
而触发二阶段的cancel
操作。两个分支事务都会执行cancel操作
:
要知道,其中一个分支是未执行try操作的,直接执行了cancel操作,反而会导致数据错误
。因此,这种情况下,尽管cancel方法要执行,但其中不能做任何回滚操作,这就是空回滚
。
对于整个空回滚的分支事务
,将来try方法阻塞结束依然会执行
。但是整个全局事务其实已经结束了,因此永远不会再有confirm或cancel,也就是说这个事务执行了一半,处于悬挂状态,这就是业务悬挂问题。
以上问题都需要我们在编写try、cancel方法时处理。
1.4.3.总结
TCC模式的每个阶段是做什么的?
Try
:资源检查和预留Confirm
:业务执行和提交Cancel
:预留资源的释放
TCC的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,
性能好
- 相比AT模型,无需生成快照,
无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,
可以用于非事务型数据库
TCC的缺点是什么?
有代码侵入
,需要人为编写try、Confirm和Cancel接口,太麻烦- 软状态,事务是最终一致
- 需要考虑
Confirm和Cancel的失败情况,做好幂等处理、事务悬挂和空回滚处理
1.5. 最大努力通知
除了上述的两种方式,有些企业嫌弃上述的方案,实现起来过于麻烦,所以可能会使用最大努力通知
。
2.注册中心
2.1.环境隔离
企业实际开发中,往往会搭建多个运行环境
,例如:
- 开发环境
- 测试环境
- 预发布环境
- 生产环境
这些不同环境之间的服务和数据之间需要隔离。
还有的企业中,会开发多个项目,共享nacos集群。此时,这些项目之间也需要把服务和数据隔离
。
因此,Nacos提供了基于namespace的环境隔离功能。具体的隔离层次如图所示:
说明:
- Nacos中可以配置多个
namespace
,相互之间完全隔离。默认的namespace名为public
- namespace下还可以
继续分组,也就是group
,相互隔离。默认的group是DEFAULT_GROUP
- group之下就是
服务和配置
了
2.1.1.创建namespace
nacos提供了一个默认的namespace,叫做public:
默认所有的服务和配置都属于这个namespace,当然我们也可以自己创建新的namespace
:
添加完成后,可以在页面看到我们新建的namespace
,并且Nacos为我们自动生成了一个命名空间id
:
我们切换到配置列表页,你会发现dev这个命名空间下没有任何配置
因为之前我们添加的所有配置都在public下
:
2.1.2.微服务配置namespace
默认情况下,所有的微服务注册发现、配置管理都是走public这个命名空间。如果要指定命名空间则需要修改application.yml文件
。
比如,我们修改item-service服务的bootstrap.yml
文件,添加服务发现
配置,指定其namespace
:
spring:application:name: item-service # 服务名称profiles:active: devcloud:nacos:server-addr: 192.168.150.101 # nacos地址discovery: # 服务发现配置namespace: 8c468c63-b650-48da-a632-311c75e6d235 # 设置namespace,必须用id# 。。。略
启动item-service,查看服务列表,会发现item-service出现在dev下
:
而其它服务则出现在public
下:
此时访问http://localhost:8082/doc.html,基于swagger做测试:
切换前
是能够查看
item的最新价格的
item的微服务使用了新的命名空间,
但是cart的微服务使用的是default的命名空间,就会查询不到,所以查询的newPrice就会为空
会发现查询结果中缺少商品的最新价格信息。
我们查看服务运行日志:
会发现cart-service服务在远程调用item-service时,并没有找到可用的实例。这证明不同namespace之间确实是相互隔离的,不可访问
。
当我们把namespace切换回public,或者统一都是以dev时访问恢复正常。
2.2.分级模型
在一些大型应用中,同一个服务可以部署很多实例。而这些实例可能分布在全国各地的不同机房。由于存在地域差异,网络传输的速度会有很大不同,因此在做服务治理时需要区分不同机房的实例。
例如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)
。
如图:
因此,结合我们上一节学习的namespace命名空间的知识,任何一个微服务的实例在注册到Nacos时,都会生成以下几个信息,用来确认当前实例的身份,从外到内依次是
:
- namespace:命名空间
- group:分组
- service:服务名
- cluster:集群
- instance:实例,包含ip和端口
这就是nacos中的服务分级模型。
在Nacos内部会有一个服务实例的注册表,是基于Map实现的
,其结构与分级模型的对应关系如下:
查看nacos控制台,会发现默认情况下所有服务的集群都是default
:
如果我们要修改服务所在集群,只需要修改bootstrap.yml
即可:
spring:cloud:nacos:discovery:cluster-name: BJ # 集群名称,自定义
我们修改item-service的bootstrap.yml,然后重新创建一个实例:
再次查看nacos:
发现8084这个新的实例确实属于BJ这个集群
了。
2.3 Eureka和Nacos对比
Eureka是Netflix公司开源的一个注册中心组件,目前被集成在SpringCloudNetflix这个模块下。它的工作原理与Nacos类似:
- nacos时间更短,响应更加快,能够更快发现异常
- eureka 更慢,但是变相保护了服务,因为网络出现波动很正常,不能一出现波动就断开
综上,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模式
3 远程调用
3.1 负载均衡
4.服务保护
4.1 线程隔离
首先我们来看下线程隔离功能,无论是Hystix还是Sentinel都支持线程隔离。不过其实现方式不同。
线程隔离有两种方式实现:
线程池隔离
:给每个服务调用业务分配一个线程池,利用线程池本身实现隔离效果
信号量隔离
:不创建线程池,而是计数器
模式,记录业务使用的线程数量,达到信号量上限时,禁止新的请求
如图:
两者的优缺点如下:
Sentinel
的线程隔离就是基于信号量
隔离实现的,而Hystix
两种都支持,但默认是基于线程池
隔离。
4.2.滑动窗口算法
在熔断
功能中,需要统计异常请求或慢请求
比例,也就是计数。在限流的时候,要统计每秒钟的QPS,同样是计数。可见计数算法在熔断限流中的应用非常多。sentinel中采用的计数器算法就是滑动窗口计数算法
。
4.2.1.固定窗口计数
要了解滑动窗口计数算法,我们必须先知道固定窗口计数算法,其基本原理如图:
说明:
- 将时间划分为多个窗口,
窗口时间跨度称为Interval
,本例中为1000ms; 每个窗口维护1个计数器,每有1次请求就将计数器+1
。限流就是设置计数器阈值,本例为3
,图中红线标记- 如果计数器超过了限流阈值,则
超出阈值的请求都被丢弃
。
示例:
说明:
- 第1、2秒,请求数量都小于3,没问题
- 第3秒,
请求数量为5,超过阈值,超出的请求被拒绝
但是我们考虑一种特殊场景
,如图:
说明:
- 假如在第5、6秒,请求数量都为3,没有超过阈值,全部放行
- 但是,如果第5秒的三次请求都是在4.5-5秒之间进来;第6秒的请求是在5-5.5之间进来。那么
从第4.5-5.5之间就有6次请求!也就是说每秒的QPS达到了6,远超阈值
。
这就是固定窗口计数算法的问题,它只能统计当前某1个时间窗的请求数量是否到达阈值,无法结合前后的时间窗的数据做综合统计。
因此,我们就需要滑动时间窗口算法来解决。
4.2.2.滑动窗口计数
固定时间窗口算法中窗口有很多,其跨度和位置是与时间区间绑定,因此是很多固定不动的窗口。而滑动时间窗口算法中只包含1个固定跨度的窗口,但窗口是可移动动的,与时间区间无关。
具体规则如下:
- 窗口时间跨度
Interval大小固定,例如1秒
时间区间跨度为Interval / n ,例如n=2,则时间区间跨度为500ms
- 窗口会随着当前请求所在时间currentTime移动,窗口范围从
(currentTime-Interval)时刻之后的第一个时区开始
,到currentTime所在时区结束。
如图所示:
限流阈值依然为3,绿色小块就是请求,上面的数字是其currentTime值。
- 在第1300ms时接收到一个请求,其所在时区就是1000~1500
- 按照规则,
currentTime-Interval值为300ms,300ms之后的第一个时区是500-1000,因此窗口范围包含两个时区:500-1000、1000-1500,也就是粉红色方框部分
- 统计窗口内的请求总数,发现是3,未达到上限。
若第1400ms又来一个请求,会落在1000~1500时区,虽然该时区请求总数是3,但滑动窗口内总数已经达到4
,因此该请求会被拒绝:
假如第1600ms又来的一个请求,处于1500-2000时区,根据算法,滑动窗口位置应该是1000-1500和1500~2000这两个时区,也就是向后移动:
这就是滑动窗口计数的原理,解决了我们之前所说的问题。而且滑动窗口内划分的时区越多,这种统计就越准确
。
4.3.令牌桶算法
限流的另一种常见算法是令牌桶算法。Sentinel中的热点参数限流正是基于令牌桶算法实现的。其基本思路如图: