Sentinel(微服务保护)
雪崩问题
雪崩问题指的是由于调用链路中的某个服务故障,导致链路中所有的微服务都不可用,比如说服务A依赖服务B和C,服务C出了问题,那么服务A中所有关于服务C的请求就会没有响应,一直处于等待状态,而前端又在不断请求,这样迟早有一天我们的服务A的所有资源都会被往服务C的请求占满,最终导致所有依赖服务A的服务都不可用,接着其他服务也出现这样的问题,导致我们的项目寄了,这就是雪崩问题
雪崩的问题的解决往往有四种方式,分别是超时处理、舱壁模式、熔断降级、流量控制
超时处理指的是设定每次响应的超时时间,如果超过这段时间没有响应则返回错误信息,但如果请求处理速度高于设定的超时时间,则该模式仍然有可能会导致我们的微服务发生雪崩问题
其次是舱壁模式,该模式会限定每个业务最多能够使用的线程数,来避免耗尽我们的整个服务器的资源,因此该模式也叫做线程隔离,但是这个模式存在资源浪费的问题,比如说我们服务A调用服务B和C,服务C挂了,但是我们的服务服务A仍然会有一定的关于服务C的线程,会不断请求服务C,这就造成的资源的浪费
然后是熔断降级,其会有断路器来统计业务中执行的异常比利,如果超出了设定的比值则会直接熔断该业务,也就是拦截访问该业务的一切请求,这样就不会造成资源的浪费
最后是流量控制,其能够限制业务访问的QPS(QPS指的是每秒处理的请求数),来防止因为流量激增而导致的服务故障
服务保护技术对比
目前市面上常用的服务保护技术,有Sentinel和Hystrix,但是我们主要使用Sentinel,我们这一节来对比下这两者
Sentinel的隔离策略是信号量隔离,而Hystrix则支持线程池隔离和信号量隔离,但默认是前者。线程池隔离指的是给每一个请求都创建一个独立的线程池,其隔离性较好,但是会导致其创建出许多的线程池而导致效率降低。而信号量隔离指的是其不会创建独立的线程池,而是对当前业务所用线程进行统计,指定某个业务只能使用多少线程,其不会创建新的线程池,只使用默认的线程池,其隔离性相对没这么好,但其效率更高
Sentinel对于熔断降级的策略有两种,一种是慢调用,第二种是异常比例,后者指的是请求发送异常的比例,而前者指的是服务过慢处理与正常处理的比例,Hystrix则只支持后者的策略
限流指的是流量控制,Sentinel支持基于QPS或者是调用关系甚至于是热点配置的限流,而Hystrix则支持地比较有限
流量整形指的是让突发流量转换成匀速的、稳定的流量,在Sentinel支持慢启动、匀速排队模式,而Hystrix则不支持
Sentinel的控制台开箱即用、具有可配置的规则等很多功能,而Hystrix则是不完善的
Sentinel安装与使用
首先来认识下Sentinel
安装Sentinel,首先要使用我们提供的对应的jar包,直接在该当前目录下打开cmd窗口,然后输入java -jar sentinel-dashboard-1.8.1.jar 即可启动Sentinel
默认情况下其端口号是8080,账号和密码都是sentinel,如果想要指定启动时的端口号或者是起码,在启动时添加具体的配置命令即可
当然,现在会直接访问啥也看不了,因此我们需要引入我们之前的案例
首先我们需要重新创建我们的数据库的代码,来看看其表结构
然后我们需要引入我们的cloud-demo,引入之后别忘了修改对应的配置,要令其连接我们的虚拟机
然后引入我的项目,为了让我们的项目成功启动,我们需要启动我们的nacos,端口号为8080
然后我们要整合我们的Sentinel,首先我们需要让被Sentinel管理的服务中引入对应的依赖
然后我们需要对应的服务中配置控制台的地址,接着我们打开Sentinel的首页,然后在另一个网页中再访问我们原来的服务,就可以让我们的Sentinel监控到了
簇点链路
在Sentinel中,链路中被监控的每一个接口就是一个资源,默认情况下其会监控SpringMVC的每一个端点,所谓端点就是方法,而且只有Controller层的方法,那么每一个被监控的端点就是资源
流控、熔断都是针对簇点链路中的资源来进行设置的,如果我们希望其他层的方法也被Sentinel监控,需要使用Sentinel提供的特定注解
流控模式
首先我们来做流控模式的快速入门案例,点击簇点链路中对应的资源的流控按钮,即可弹出表单添加流控规则
来做一个入门案例
这里我们的测试需要使用jemeter测试,课程资料里已经提供了,还提供了测试用的对应文件,我们只要进入金额jmeter然后打开对应的文件即可点击提供好的规则进行测试
流控模式一共有三种,默认是直接流控模式,其会统计当前资源的请求,当触发阈值时直接对当前资源限流,也是默认使用的模式,一般来说,使用这种模式,我们要对谁进行直接限流就直接对其添加流控规则即可
第二种是关联模式,其会统计与当前资源的另一个资源,当其触发阈值时,就对当资源限流,简单理解为别人有病我吃药
第三种是链路模式,其会统计从指定链路访问到本资源的请求,当触发阈值时,就对指定的链路限流
关联模式
比如当用户支付时需要修改订单状态,如果有用户需要同时查询订单,查询和修改会争抢数据库锁,因此当修改业务触发阈值时,需要对查询的业务做限流
我们这里首先要在controller层创建两个用于模拟的方法
@GetMapping("/query")
public String queryOrder(){
return "查询订单成功";
}
@GetMapping("/update")
public String updateOrder(){
return "更新订单成功";
}
想要令其被监测到,需要先在网页中访问这两个请求,然后我们对其添加流控规则
我们这里希望当update的资源访问的QPS超过5时,就对query请求进行限流。
记住,我们要对谁进行限流,我们就选择谁,我们这里本质是要对query请求进行限流,所以我们要对query资源选择流控
选择关联,并将关联资源设置为update即可
最后通过jemeter测试即可
链路模式
链路模式可以针对多个请求链路中的某一个链路进行限流
比方说查询订单和创建订单都需要查询商品,那么链路流控就可以针对查询订单到查询商品的请求进行统计并设置限流,这样查询的请求如果过多,我们就对查询限流,防止我们的新增业务出现问题
那么首先我们需要在Service层中添加一个查询方法,不用实现业务
@SentinelResource("goods")
public void queryGoods(){
System.err.println("查询商品");
}
值得一提的是,Sentinel只会标记Controller中的方法为资源,如果需要标记其他方法,需要使用SentinelResource注解,可以自己指定名字,指定的名字会变成我们的资源名
并且Sentinel默认会将Controller方法做context整合,导致我们在Sentinel中看不到多个链路,此时我们需要修改application.yml的配置,关闭其context整合
然后我们在控制层中分别在创建两个对应的方法并令其调用service层的方法
@GetMapping("/query")
public String queryOrder(){
//查询商品
orderService.queryGoods();
//查询订单
System.out.println("查询订单");
return "查询订单成功";
}
@GetMapping("/save")
public String saveOrder(){
//查询商品
orderService.queryGoods();
//查询订单
System.out.println("新增订单");
return "新增订单成功";
}
接着我们这里对goods资源进行流控,选择链路模式,我们本质是希望对查询做限流,所以我们在入口资源里写入query
最后我们通过jemeter测试会发现我们的查询方法是会被限流为2个的,但是我们的新增方法是不受影响,会全部进行处理的
流控效果
流控效果指的是请求到达流控阈值时应该采取的措施,有快速失败、warm up、排队等待三种
快速失败指的是一旦到达阈值,新的请求会即可会拒绝并抛出FlowException异常,是默认的处理方式
warm up是预测模式,同样会拒绝会抛出异常,但是这个模式中的阈值会从小到大逐渐增长至最大
排队等待则是让所有的请求放到一个队列中排队执行,并且两个请求的间隔不能小于指定时长,一旦小于,则会拒绝并抛出异常
warm up
warm up也叫预热模式,一般用于应对服务冷启动,指定最开始的请求阈值,然后在指定时间后提高到最大值
来看看其案例实现
其选择表单如下所示
那么此时我们一旦重启我们的服务,那么达到阈值之后其就会进行预热,并将处理的请求数量提升至最大
排队等待
排队等待可以让所有请求都进入到队列中等待执行,在进入队列时其会先进行等待时间的计算,如果计算得出等待时间超出指定的最大等待时间,那么就回去拒绝该请求
来实现下面的案例
那么其添加表单如下
最后我们来看一下总结
热点参数限流
热点参数限流可以统计访问某个资源的所有请求,判断其是否超过了某个阈值,若超过则进行限流
我们上面的配置是针对hot这个资源的第0号(也就是第一个参数做统计),每秒钟相同参数值的请求数不可以超过5,否则就会进行限流
当然,有时候我们的需要一些例外,对一些特定的参数需要给其更大的阈值,比如说商品里有一些商品比较火,而有一些则总是没什么访问,因此其还提供了特例的设置选项
设置特例需要首先指定参数类型,只支持java中的基本参数类型,如果是pojo类型那就没法了,其下具体指定参数值和限流的阈值再新增即可
来看看案例实现
注意热点参数的限流对默认的SpringMVC资源是无效的,如果我们想要令其生效,则需要加入SentinelResource注解,名字自己指定即可
@SentinelResource("hot")
@GetMapping("{orderId}")
public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
// 根据id查询订单并返回
return orderService.queryOrderById(orderId);
}
我们设置时只要选择对应的资源的热点选项,然后设置资源名即可,不过由于我们的这里的前端页面展示有bug,所以我们推荐到热点规则中去自定义
我们这里指定要进行热点限流的资源名字为hot,指定统计其第一个参数,设定其阈值为2,窗口时长为1,即1s内对该资源的请求重复数量不可以超过2个,否则就进行限流
然后指定其他参数例外项,对于一些特殊的参数,我们给其更加的阈值,当然,参数的位置还是我们之前设置好的第一个参数
隔离和降级
限流虽然可以避免故障,但是我们也要学习发生故障之后的处理,故障发生之后,为了避免雪崩问题,一般来说有两种方式,分别是线程隔离和熔断降级
Feign整合Sentinel
在SpringCloud中,微服务调用都是通过Feign来实现的,因此我们需要将Feign整合到Sentinel中
整合的方式非常简单,只要在对应的服务中打开feign的配置即可,这样我们就可以使用Sentinel的隔离和降级操作了,并且是使用Feign来调用
其次我们需要编写我们失败后的降级逻辑,这个简单来说就是失败之后需要返回给用户一些默认的信息,而不是啥都没有,有两种方式,我们这里选择FallbackFactory的方式,该方式可以对远程调用的异常做处理
那么首先我们要做的事情是定义具体的类,由于我们之前将我们的代码都抽取到了feing-api中,因此我们也在该处定义类,首先定义我们的类
我们可以写入我们的代码如下
@Slf4j
public class UserClientFallbackFactory implements FallbackFactory<UserClient> {
@Override
public UserClient create(Throwable throwable) {
return id -> {
log.error("查询用户异常",throwable);
return new User();
};
}
}
然后我们在对应的配置类中将其注册为Bean,然后在接口中定义使用我们刚刚创建的接口,即可启动我们的失败降级逻辑
在上面的一切搞定之后,我们只要重写开启服务,就可以到Sentinel中看到其簇点链路中加载出了Feign的接口路径,此时就说明我们的整合已经成功了
接着我们就可以通过该接口来设置我们的隔离规则了
线程隔离
线程隔离有两种方式实现,一种是线程池隔离,另一种是信号量隔离
线程池隔离指的是给每一个访问请求要访问的其他服务的请求都创建一个线程池,每个线程池内有指定的线程数量,每个请求只可拿指定的线程池里的一个线程,这样即使服务出了故障,最多也就将某个线程池的线程耗尽而已,不会影响到其他服务。而信号量隔离则是指定某个请求,只允许该类请求拿取一定的线程
线程隔离优点在于其支持主动超时和异步调用,其缺点是额外的开销大,适用的场景为低扇出的场景,也就是要调用其他服务少的服务
而信号量隔离的优点在于其没有额外的开销,缺点在于其不支持主动超市和异步调用,适用场景为高频调用和高扇出
线程隔离,其实就是我们之前所说的舱壁模式
接着我们来实现一个案例
直接在Feign接口中设置线程隔离的规则即可,我们这里指定线程数,并指定其为2
然后我们在Jemeter中测试,查看结果树就可以知道,有一些多出的线程,是只能得到我们事先设置的返回的默认用户的
熔断降级
熔断降级的核心部件是断路器,其执行过程如下图所示
默认情况下,熔断降级的服务是处于关闭状态,不拦截任何请求,但是当我们的服务到达失败阈值时,断路器就会启动,熔断该服务,拦截访问该服务的一切请求,当熔断时间结束时就处于Half-Open状态,其会尝试放行一次请求,如果该请求失败,则重新进入熔断,反之则回到启动前状态
熔断降级的策略有三种,分别是慢调用、异常比例和异常数
慢调用是指统计业务处理比预期时间要久的请求,统计其比例,若超过一定的比例则执行熔断
慢调用
首先我们为了实现我们后面的案例,我们现在给我们的UserClient的查询用户设置对应的特殊代码,令其id为1时出发处理速度变慢,id为2时抛出异常
那么我们可以写入我们的代码如下
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id,
@RequestHeader(value = "Truth", required = false) String truth) throws InterruptedException {
if(id==1){
// 休眠,触发熔断
Thread.sleep(60);
}else if(id==2){
throw new RuntimeException("故意出错,触发熔断");
}
return userService.queryById(id);
}
我们设置我们的降级规则也是直接对Feign资源接口进行设置,在其表单内填入内容如下
上面代表的内容是,请求处理时间超过50ms的请求就是慢调用,在1000ms内,统计五个请求,只要五个请求里慢调用的比例不小于0.4就发生熔断,每次熔断的时间设置为5s
异常比例和异常数
异常比例则是统计发生异常的请求数量与正常请求数量的比例,到达指定比值就执行熔断,而异常数则是直接指定发生异常的数量,一旦到达该数量就进行熔断
首先需要构建一个抛出异常的特殊处理代码,这个前面已经刚做过了
在Feign接口中新增降级规则中填入如下内容即可进行对应的指定
最后我们来看看总结
授权规则
在授权规则中可以对调用方的来源做控制,有白名单和黑名单两种方式
对于白名单而言,我们允许谁访问,那么就在对应的流控应用中填写对应网关的名称即可,这样其他非白名单的访问就会被拒绝
尽管我们的网关已经有拒绝非法请求的功能了,但是可能会存在我们的服务地址泄露的情况,非法用户可以跳过网关直接访问服务器,因此服务器也需要做合法性检验
授权规则实现
在Sentinel中是通过RequestOriginPaser接口来获取来源请求的,该方法默认只返回null,因此需要我们自定义
比方说我们在request中获取名为origin的请求头,如果不为空,则返回该请求头,若为空则赋值为blank字符串,通过这种标记方式来区分是否是白名单的请求
那么我们可以创建一个新的类,并写入其代码如下
@Component
public class HeaderOriginParser implements RequestOriginParser {
@Override
public String parseOrigin(HttpServletRequest request) {
// 1.获取请求头
String origin = request.getHeader("origin");
// 2.非空判断
if(StringUtils.isEmpty(origin)){
origin = "blank";
}
return origin;
}
}
不过遗憾的是,无论是不是来自网关的请求,其都没有origin的请求头,因此我们需要自己添加,我们可以利用gateway服务中的过滤器来添加对应的请求头,按照如下的规则在gateway服务中的配置中添加配置即可
然后我们给对应的接口资源添加对应的授权规则即可
这样就只有来自网关的请求能够被通过,通过浏览器的请求都会被阻塞,网关请求需要加入对应的验证信息,否则无法通过
自定义异常结果
默认情况下,当发生对应的拦截时抛出的对应异常都是直接到客户端的,这对用户来说并不友好,因此我们需要自定义异常
Sentinel中自定义异常使用的接口是BlockExceptionHandler,其下有多个种类的异常,分别对应不同场景
我们需要自定义异常,首先需要实现该接口并在其下定义对应的异常返回结果,其代码就如下所示
这份代码在我们的学习资料里有提供,不需要我们手写
规则持久化
Sentinel中的各种规则默认都是保存到内存中的,是非持久化的保存,这样会导致我们每次重启Sentinel就会清除所有规则
在Sentinel中有两种持久化模式,分别是pull模式和push模式
pull模式是通过控制台将骨子额推送到Sentinel的客户端,而客户端会将配置规则保存在本地文件或者数据库中,在内存中的规则会定期去本地文件或者是数据库中查询并更新本地规则,这个方法存在延时性的问题,因此我们并不推荐
push模式是控制台将配置规则推送到远程配置中心,比如Nacos,Sentinel客户端会监听Nacos,来获取配置变更的推送消息,完成本地的配置更新,这也是我们所推荐的方式
要实现push模式,首先我们需要修改OrderService,让其监听Nacos中的sentinel规则配置
先在OrderService服务中引入对应的Nacos依赖
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
然后在order-service中的application.yml文件配置nacos地址及监听的配置信息:
spring:
cloud:
sentinel:
datasource:
flow:
nacos:
server-addr: localhost:8848 # nacos地址
dataId: orderservice-flow-rules
groupId: SENTINEL_GROUP
rule-type: flow # 还可以是:degrade、authority、param-flow
我们这里只配置了flow,也就是流控的配置,如果我们需要配置对应的其他标签,比如如果要设置降级的话就应该要改用标签degrade
server-addr是nacos的地址,而dataId和groupId都是确定nacos的配置文件的设置,后者是群组名称,前者是配置名称,忘了相关的知识点的话可以去nacos中复习
如果我们想要使用push模式来进行规则持久化,那么我们需要自己来对Sentinel自定义,这个过程极度麻烦,就不搞了,我们这里已经有配置好的Sentinel了,直接使用即可
使用时我明明使用下面的命令,可以配置我们的Sentinel端口为8848
java -jar -Dnacos.addr=localhost:8848 sentinel-dashboard.jar
此时我们进入Sentinel可以发现其下已经有了对应的Nacos流控规则的配置
只要我们往其中配置流控规则,就可以实现持久化了,当然我们这里只是实现一个流控规则,后续还有很多需要自己实现,以后我们直接拿别人做好的Sentinel开用就完了
Seata(微服务事务组件)
分布式事务问题
数据库中的事务需要实现ACID原则,但是在分布式的事务中,这个原则的实现就会出现一些问题
先来看看我们分布式服务的下单的对应流程
演示该事务需要导入seata_demo的项目,并且创建对应的数据库,最后利用PostMan来发送对应请求进行测试
但是这里值得一提的是,我们的项目出现了整不明白的Bug,发送请求时一直得到错误响应,这就算了,控制台还尼玛不报异常信息,整了贼勾八久都搞不懂到底咋回事,我实在是懒得整了,所以下面的课程直接看着学,不求项目的可用性
最后终于解决了这个问题了,这个问题的原因居然是我们的postman的headers设置错误了,真的给我整不会了,能想到这个问题的解决办法主要依赖于这个文章m.yf-zs.com/zixun/11143…,这个事情也是提醒了我们一件事情,有时候问题出现了之后,真别总想着后端有问题,该想想可能是工具有问题,或者是一个个排查,别总是对着一个地方死磕
服务窗口中没有对应的模块的解决方法:blog.csdn.net/m0_50814496…
我们第一次发送一个正常的下单请求,我们的服务可以正确处理,但是如果我们发送一个购买数量超过了库存数量的请求,结果是就是该请求失败,但是却发生了对应的扣款,此时我们的ACID原则是不满足的
这就是分布式事务存在的问题,为了解决这个问题,我们需要学习CAP定理
CAP定理
CAP定理简单来说就是分布式系统有三个指标,分别是Consistency(一致性),Avaliability(可用性),Partition tolerance(分区容错性) ,一个分布式系统无法同时满足这三个指标,该结论就是CAP原理
下面是CAP特性的科普,首先是一致性,指的是用户访问分布式系统中的任意结点,得到的数据都必须一致
可用性指的是用户访问集群中的任意健康结点都必须得到响应
分区容错指的是在集群出现某个结点故障而发生的分区情况时,整个集群也需要持续对外提供服务
下面是CAP定理的总结
BASE理论
Base理论是对CAP问题的一种解决思路,其包含三种思想,分别是基本可用、软状态和最终一致性
我们可以借鉴通过BASE理论通过AP模式或者是CP模式来实现分布式事务中的ACID
为了实现分布式事务的一致性,各个子系统间必须要能感知到彼此的事务状态,因此需要一个事务协调者来协调每一个事务的参与者
此时子系统的事务就称为分支事务,有关联的各个分支事务组合在一起称为全局事务
初识Seata
首先我们来认识下Seata
Seata的事务管理中有三个重要角色,分别是事务协调者(TC)、事务管理器(TM)和资源管理器(RM)
执行事务时首先调用TM,TM首先定义对应的全局事务的范围并调用TC来开启对应的全局事务,每个全局事务里的分支事务的执行依赖的是RM,RM执行完分支事务之后会想TC报告分支事务的状态,全部执行完毕之后,TM会报告给TC执行完全局事务,TC会对每一个RM提交的结果进行判断,若全部成功则成功,若失败则会执行对应的回滚
上面讲述的是Seata大体的架构,Seata内部并不与其完全一致
Seata提供了四种不同的分布式解决方案,分别是XA模式、TCC模式、AT模式和SAGA模式
Seata的部署和集成
首先我们要下载seata-server包,地址在http://seata.io/zh-cn/blog/download.html
当然,课前资料也准备好了:
在非中文目录解压缩这个zip包,其目录结构如下:
首先我们要修改conf目录下的registry.conf文件:
registry {
# tc服务的注册中心类,这里选择nacos,也可以是eureka、zookeeper等
type = "nacos"
nacos {
# seata tc 服务注册到 nacos的服务名称,可以自定义
application = "seata-tc-server"
serverAddr = "127.0.0.1:8848"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "SH"
username = "nacos"
password = "nacos"
}
}
config {
# 读取tc服务端的配置文件的方式,这里是从nacos配置中心读取,这样如果tc是集群,可以共享配置
type = "nacos"
# 配置nacos地址等信息
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
我们这里首先要配置的registry的配置,其下需要指定对应的注册中心类,我们这里选择nacos,其他的注册中心的内容直接删掉,nacos中我们指定服务名,然后指定nacos的地址,接着指定组别的名称,我们这里令其在默认分组中,命名空间不指定则是默认的public,我们指定其集群名称SH
下面的config指定的则是统一配置的配置,因为后期我们的Seata服务可能是集群的,到时候肯定也需要对应的配置管理,放到本地不方便,所以我们交给nacos管理。我们同样指定其为nacos,下面也是指定对应的nacos地址和命名空间,这里还需要指定用户名和密码,最后指定的内容则配置的文件名,这是在Nacos中需要配置的对应文件名
为了让tc服务的集群可以共享配置,我们选择了nacos作为统一配置中心,因此服务端配置文件seataServer.properties文件需要在nacos中配好
格式如下:
配置内容如下:
# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://192.168.88.128:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=root
store.db.password=root
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
# 事务、日志等配置
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
server.maxRollbackRetryTimeout=-1
server.rollbackRetryTimeoutUnlockEnable=false
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
# 客户端与服务端传输方式
transport.serialization=seata
transport.compressor=none
# 关闭metrics功能,提高性能
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898
我们在该配置文件中指定的内容就是我们的数据的存储方式,我们这里指定为数据库,并且设定了数据库的链接方式和数据库地址
当然,上面的数据库地址、用户名、密码都需要修改成你自己的数据库信息
注意:tc服务在管理分布式事务时,需要记录事务相关数据到数据库中,你需要提前创建好这些表。
新建一个名为seata的数据库,运行课前资料提供的sql文件:
这些表主要记录全局事务、分支事务、全局锁信息:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 分支事务表
-- ----------------------------
DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table` (
`branch_id` bigint(20) NOT NULL,
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`resource_group_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`branch_type` varchar(8) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`status` tinyint(4) NULL DEFAULT NULL,
`client_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime(6) NULL DEFAULT NULL,
`gmt_modified` datetime(6) NULL DEFAULT NULL,
PRIMARY KEY (`branch_id`) USING BTREE,
INDEX `idx_xid`(`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- 全局事务表
-- ----------------------------
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`status` tinyint(4) NOT NULL,
`application_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_service_group` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`timeout` int(11) NULL DEFAULT NULL,
`begin_time` bigint(20) NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`xid`) USING BTREE,
INDEX `idx_gmt_modified_status`(`gmt_modified`, `status`) USING BTREE,
INDEX `idx_transaction_id`(`transaction_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
SET FOREIGN_KEY_CHECKS = 1;
搞定了之后我们进入bin目录,运行其中的seata-server.bat即可:
启动成功后,seata-server应该已经注册到nacos注册中心了。
打开浏览器,访问nacos地址:http://localhost:8848,然后进入服务列表页面,可以看到seata-tc-server的信息:
接着我们来做微服务集成Seata
首先我们在对应的服务中引入对应的依赖,Spring中最开始就有对应的依赖,但是版本比较低,因此我们排除掉并引入新版本的依赖
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>
接着我们来配置对应的配置文件,直接配置对应的命名空间,组别和服务, 但是我们这里的集群名称不可以直接填写,我们这里首先写入对应的事务组名称,然后设置事务组名称的映射并指定对应的集群名称,这样就可以通过事务组映射来找到对应的集群
配置如下
seata:
registry:
type: nacos
nacos:
server-addr: localhost:8848
namespace: ""
group: DEFAULT_GROUP
application: seata-tc-server
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组名称
service:
vgroup-mapping: # 事务组与cluster的映射关系
seata-demo: SH
最后的总结如下
XA模式
下面我们正式来学习Seata内的四种事务处理模式,首先我们来学习XA模式,先来看看其执行过程
在XA模式原理中,存在两个阶段,第一个阶段会让事务协调者TC令所有RM准备执行事务并返回执行的结果,第二阶段TC会检查所有RM的返回结果,如果存在失败的情况,则执行回滚
其完整的执行过程是事务管理器TM首先向TC开启全局事务,然后调用对应的分支的资源管理器RM,RM会向事务协调者TC先注册对应的分支事务,当RM执行完毕之后会向TC报告事务执行的结果,全部RM执行完毕之后TM会向TC提交事务执行完毕的结果,然后TC会检查每一个分支的结果,如果有失败则会执行全局事务回滚,如果成功则让所有RM执行提交
来看看其图述
最后来看看其优缺点总结
然后我们要实现我们的XA模式,实现XA模式的方式也非常简单,在Seata中的starter中已经完成了XA模式的自动装配了,我们只需要做简单配置即可
首先在对应的每一个服务的配置文件中加入对弈的开启XA模式的代码
data-source-proxy-mode: XA
然后设置我们的全局事务的入口,给其添加对应的GlobalTransactional注解即可,这里是create方法,最后经过测试我们会发现我们的模式成功实行了
AT模式
然后我们来讲解AT模式,AT模式是分阶段提交的事务模型,但是其弥补了XA模型中资源锁定周期过长的问题
AT模式和XA的执行过程大体一直,但是不同的是,在AT模式中,RM一旦执行完毕之后就会立刻提交,并且同时生成一个修改之前的快照,最后TC检查对应的RM的结果,如果失败则会令其恢复到快照记录的数据,若成功则将该快照删除,该过程是异步执行的,这样可以有效提高效率
来看看总结
脏写问题
AT模式存在脏写问题,什么是脏写问题?来看下面的例子
事务1执行时首先需要获取DB锁并且保存快照,然后需要提交事务释放DB锁,此时可能DB锁被另外的事务获取,同样进行数据的修改,此时事务1如果要执行回滚会回滚到事务1执行之前的状态,这就会导致事务2的执行结果无意义化
为了解决这个问题,AT模式中引入了全局锁概念
当事务执行时会获取DB锁,而当事务提交事务时,则需要获取全局锁,全局锁由TC管理,其会记录哪个事务获取了当前的锁,当其他事务同样想要提交事务时,由于全局锁已经被事务1占有了,则无法顺利执行,一段时间后前天事务会超时并释放其占有的DB锁,而事务1获得对应的DB锁之后执行对应的回滚或提交后就会释放全局锁,此时其他事务就可以顺序执行了
尽管AT模式和XA模式都占据锁,但由于后者占据的是DB锁并不执行提交而是进行等待导致其他事务完全无法对对应的其他数据进行修改,而前者占据的是全局锁,且交由TC管理,允许其他非Seata管理的全局事务或者是对不同行不同列的数据进行修改,因此AT模式的效率仍然远高于XA模式
对应极端情况下,数据被其他Seata管理的全局事务修改的情况,AT模式的应对方式是创建两个快照,分别是修改前和修改后的快照,当数据要进行恢复时,其会拿这两份快照,并拿修改后的快照的数据和当前数据进行对比,如果不同,则说明有人修改了当前的数据,其会记录对应的异常,并发送警告,让人工介入来解决该数据问题
最后我们来看看总结
然后我们来实现我们的AT模式,首先我们要导入对应的课前资料,给我们的项目一个存放对应的数据快照的位置,然后我们只要修改对应的配置文件,将其事务模式修改为AT即可
TCC模式
接着我们来学习TCC模式,其与AT模式非常相似,不同的是TCC是通过人工编码的方式来实现数据恢复的,其相比于AT模式,拥有更加好的效率
实现TCC模式需要实现三个方法,分别是Try、Confirm和Cancel方法,TCC模式也是因此得名
TCC模式的原理在于执行Try方法时预先对资源进行对应的检测和预留,比如扣款时先检查余额是否充足,若充足则冻结该金额,然后在提交方法Confirm中则会删除该冻结金额,这样如果其他事务也同时执行扣款事务,其也会冻结一部分金额,进行提交时也只删除该对应的冻结金额,回滚时也是恢复对应的冻结金额,这样两个事务之间就不会产生冲突
接着我们来看看TCC模式的原理,和之前的没啥不同这里就不提了
最后我们来看看总结
接着我们来实现对应的案例,我们这里只改造account-service服务,令其用TCC来实现分布式的事务
这里除了TCC三个方法之外我们还需要额外做一些处理,首先要做的事情是保证幂等性,也就是无论我们删除几次回滚几次,最终得到的数据应该都是要一致的,不会因重复调用而产生问题。其次是要允许空回滚,和拒绝业务悬挂,要实现这个两个需求我们首先需要理解他们的概念
空回滚,加上有两个RM,第一个正确执行,而第二个的Try阶段就阻塞了,最终TC会因为第二个RM超时执行而要求RM执行回滚,此时第二个RM压根就没有实行Try操作,这时的cancel就不能执行回滚,这就是空回滚,解决空回滚的方法是直接令其结束该方法即可
业务悬挂指的是RM的Try方法的阻塞状态恢复之后继续执行Try,但是全局事务已经结束了,此时RM就永远不可能执行confirm或cancel,这就是业务悬挂,解决业务悬挂情况应当阻止空回滚后的try操作,避免悬挂
接着我们来进行对应的业务分析,首先我们需要创建对应的数据库,然后通过对对应表的操作来实现我们的各种业务和需求
Try业务的实现比较简单,就是要记录冻结的金额和事务状态记录到对应的冻结表中并执行对应的扣减,而Confirm业务的实现则是根据id来删除对应的冻结记录,Cancel回滚业务直接将冻结金额修改为0,状态改为对应回滚状态
空回滚的判断则是直接根据id从查询表,如果为null则说明Try方法还没有执行,此时需要进行空回滚。避免业务悬挂也是通过id查询对应的表,如果已经存在对应的数据,则说明Cancel已经执行过了,此时需要拒绝try方法的执行
分析完之后我们正式来实现对应的代码,首先我们需要声明对应的TCC接口
在对应的服务模块的service包下创建对应的接口类并写入代码如下
package cn.itcast.account.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
@LocalTCC
public interface AccountTCCService {
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm",rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
首先我们需要往类中添加LcoalTCC注解,这样才能代表其是一个TCC接口,然后在其下具体实现对应的方法,方法都是由对应的注解实现的
我们要实现Try方法,加入TwoPhaseBusinessAction注解,其下指定TCC三个方法的对应方法名,然后我们实现这三个方法,这三个方法里如果有一些数据是需要在其他方法中使用的,可以使用BusinessActionContextParamete并指定对应的数据名,这样其会将数据保存到上下文对象中,这样其他方法也可以通过对应的关键字来取得这些数据
之后我们可以实现我们的方法如下,注意我们这里已经提前创建好了所需的表和对应的结构体,因此我们这里可以直接引用
最后我们的deduct方法一般来说是要先判断余额是否足够的,但是我们这里不需要,因为我们的表建立的时候就规定了余额不能为负,如果是负就会直接报错,因此我们这里可以不必做对应的判断
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
// 0.获取事务id
String xid = RootContext.getXID();
// 1.判断freeze中是否有冻结记录,如果有,一定是CANCEL执行过,要拒绝业务
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if(oldFreeze!=null){
// CANCEL执行过,拒绝业务
return;
}
// 1.扣减可用余额
accountMapper.deduct(userId,money);
// 2.记录冻结金额,事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
// 1.获取事务id
String xid = ctx.getXid();
// 2.根据id删除冻结记录
int count = freezeMapper.deleteById(xid);
return count==1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
// 0.查询冻结记录
String xid = ctx.getXid();
String userId = ctx.getActionContext("userId").toString();
AccountFreeze freeze = freezeMapper.selectById(xid);
// 1.空回滚的判断,判断freeze是否为null,为null证明try没执行,需要空回滚
if(freeze==null){
//证明try没执行,需要空回滚
freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
// 2.幂等判断
if(freeze.getState()==AccountFreeze.State.CANCEL){
//已经处理过一次CANCEL了,无需重复处理
return true;
}
// 1.恢复可用数据
accountMapper.refund(freeze.getUserId(),freeze.getFreezeMoney());
// 2.将冻结金额清零,状态改回CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count==1;
}
}
最后我们这里值得一提的是,不是什么业务都适合TTC模式的,比如下单业务,这个下单就增加一单的,这怎么冻结是吧?因此我们使用TCC模式也是要看人下菜碟的
Saga模式
Saga模式是长事务解决方案,其特点如下
该模式了解下即可,我们这里就不实现了,然后我们来看看四种模式的对比
Redis
我们先来说说单点Redis存在的问题,主要有以下问题
简而言之,单点Redis存在数据都是问题、并发能力问题、存储能力问题和故障恢复问题,针对这四个问题我们都提供了相应的解决方案,我们下面就来学习具体解决这四个问题的方法
Redis持久化
Redis持久化提供了RDB和AOF两种,这个我们之前已经学过了,我们这里快速过一遍
RDB
首先我们来看看RDB,推荐使用bgsave
RDB可以在redis.conf文件中找到对应的配置信息并进行设置
在Redis中,数据会存储在物理内存中,但是Redis并不可以直接对物理内存的数据进行操作,其会创建一个虚拟内存,虚拟内存会维护一个页表,页表中保存虚拟进程和物理内存的操作映射,这样就可以在虚拟内存中通过页表来实现对物理内存的操作
basave原理是在开始时会fork主进程到子进程,子进程同样有一个页表,这样我们就只需要一个页表就实现了物理内存的共享,子进程在保存时为了防止读写冲突产生的脏写问题,会将要保存的数据设置为只读,如果此时主进程要执行写操作,则会拷贝一份只读数据到B副本中,对B副本进行写操作,然后读写的操作都会转移到B副本中,当然,保存的进程也是
但是在极端情况下可能出现每次保存时都有写的情况发生,这样会使我们保存所需的空间大大增加,因此我们安装Redis时,不可以令其占据所有的内存空间,要给其预留存放数据文件的空间
AOF
先来看看AOF
我们可以在配置文件中对AOF进行对应的设置
AOF可以使用bgrewirteaof命令执行重写
最后我们来看看这两种持久化方式的优缺点,一般来说,我们都是这两种方式结合使用来保证实现数据的持久化的
Redis主从
接着我们来学习解决单点Redis并发能力低的问题,我们一般是通过主从搭建,实现读写分离来解决这个问题
首先我们要做的事情当然是安装Redis主从结点
安装与启动
首先我们来看看我们主从集群的结构图
共包含三个节点,一个主节点,两个从节点
这里我们会在同一台虚拟机中开启3个redis实例,模拟主从集群,信息如下:
| IP | PORT | 角色 |
|---|---|---|
| 192.168.150.101 | 7001 | master |
| 192.168.150.101 | 7002 | slave |
| 192.168.150.101 | 7003 | slave |
因为我们没这么多虚拟机,所以我们这里直接开三个不同端口的Redis进行模拟
要在同一台虚拟机开启3个实例,必须准备三份不同的配置文件和目录,配置文件所在目录也就是工作目录。
首先我们在tmp文件夹中创建三个文件夹,名字分别叫7001、7002、7003:
# 进入/tmp目录
cd /tmp
# 创建目录
mkdir 7001 7002 7003
如图:
然后修改redis-6.2.4/redis.conf文件,将其中的持久化模式改为默认的RDB模式,AOF保持关闭状态,这是我们实现Redis主从集群的必要条件
# 开启RDB
# save ""
save 3600 1
save 300 100
save 60 10000
# 关闭AOF
appendonly no
然后我们要拷贝配置文件到每个实例目录
然后将redis-6.2.4/redis.conf文件拷贝到三个目录中(在/tmp目录执行下列命令)
# 方式一:逐个拷贝
cp redis-6.2.4/redis.conf 7001
cp redis-6.2.4/redis.conf 7002
cp redis-6.2.4/redis.conf 7003
# 方式二:管道组合命令,一键拷贝
echo 7001 7002 7003 | xargs -t -n 1 cp redis-6.2.4/redis.conf
接着我们需要修改每个实例的端口、工作目录
修改每个文件夹内的配置文件,将端口分别修改为7001、7002、7003,将rdb文件保存位置都修改为自己所在目录(在/tmp目录执行下列命令):
sed -i -e 's/6379/7001/g' -e 's/dir .//dir /tmp/7001//g' 7001/redis.conf
sed -i -e 's/6379/7002/g' -e 's/dir .//dir /tmp/7002//g' 7002/redis.conf
sed -i -e 's/6379/7003/g' -e 's/dir .//dir /tmp/7003//g' 7003/redis.conf
接着我们要修改每个实例的声明IP
虚拟机本身有多个IP,为了避免将来混乱,我们需要在redis.conf文件中指定每一个实例的绑定ip信息,格式如下:
# redis实例的声明 IP
replica-announce-ip 192.168.150.101
注意我们这里的IP要改成我们的自己的虚拟机的IP,否则没法用
每个目录都要改,我们一键完成修改(在/tmp目录执行下列命令):
# 逐一执行
sed -i '1a replica-announce-ip 192.168.150.101' 7001/redis.conf
sed -i '1a replica-announce-ip 192.168.150.101' 7002/redis.conf
sed -i '1a replica-announce-ip 192.168.150.101' 7003/redis.conf
# 或者一键修改
printf '%s\n' 7001 7002 7003 | xargs -I{} -t sed -i '1a replica-announce-ip 192.168.150.101' {}/redis.conf
然后我们可以开启多个窗口然后开启我们的Redis,接着我们就需要开启主从关系
现在三个实例还没有任何关系,要配置主从可以使用replicaof 或者slaveof(5.0以前)命令。
有临时和永久两种模式:
-
修改配置文件(永久生效)
- 在redis.conf中添加一行配置:
slaveof <masterip> <masterport>
- 在redis.conf中添加一行配置:
-
使用redis-cli客户端连接到redis服务,执行slaveof命令(重启后失效):
slaveof <masterip> <masterport>
注意:在5.0以后新增命令replicaof,与salveof效果一致。
这里我们值得一提的是,我们可以随意进入一个对应的redis客户端,然后设置主结点和副节点
这里我们为了演示方便,使用方式二
通过redis-cli命令连接7002,执行下面命令:
# 连接 7002
redis-cli -p 7002
# 执行slaveof
slaveof 192.168.150.101 7001
通过redis-cli命令连接7003,执行下面命令:
# 连接 7003
redis-cli -p 7003
# 执行slaveof
slaveof 192.168.150.101 7001
注意,如果我们连接成功,在对应的窗口中是可以看到success关键字的
然后连接 7001节点,查看集群状态:
# 连接 7001
redis-cli -p 7001
# 查看状态
info replication
结果如下
执行下列操作以测试:
- 利用redis-cli连接7001,执行
set num 123 - 利用redis-cli连接7002,执行
get num,再执行set num 666 - 利用redis-cli连接7003,执行
get num,再执行set num 888
可以发现,只有在7001这个master节点上可以执行写操作,7002和7003这两个slave节点只能执行读操作。
数据同步原理
主从Redis第一进行的同步是全量同步,全量同步会给副节点发送RDB文件,并将发送过程中的所有命令记录到repl_baklog中一并发给副节点,副节点接受完毕之后执行即可实现与主结点的数据同步
每一个结点都会有自己的唯一标识,也就是id,在这里全称为Replication Id,其是数据集的标记,id一致则说明是同一数据集
每一个Master都有唯一的replid,而slave则会继承master结点的id,所以判断第一次同步数据是通过判断id是否一致来判断的
最后我们来看看总结
同时还有offset偏移量的概念,执行增量同步通过偏移量来确定同步的内容。主节点中的偏移量会随着写入数据的增多而增大,当副节点请求同步时,会根据对应的偏移量来决定需要进行增量同步的数据
当偏移量增大到一定值时,会归零重置,只要副节点的偏移量没有被主节点的偏移量超越一周,就可以实现增量同步,反之,则需要进行全量同步
最后我们来看看看总结
Redis哨兵
如果我们只设置master和slave结点,那么当我们的master结点宕机时,数据就会遭受不可逆的损害,为了避免这个问题,我们需要设置Redis的哨兵
Redis中提供了对应的哨兵机制来实现主从集群的自动故障恢复,我们这里的哨兵基于Sentinel实现
哨兵的作用一般有三,分别是监控、自动故障恢复和通知
Sentinel哨兵基于心跳机制来监测服务状态,每隔1s向每个实例发送ping命令,如果某个哨兵发现某实例没有在规定时间内响应,则认为其是主观下线,如果超过指定数量的哨兵都认为该实例主观下线,则该实例则会被认为客观下线
一旦某个实例被认为客观下线且该实例是mater结点,则会选举新的master结点,其选择其他结点作为主节点时的依据如下图
其实现故障转移的步骤如下,当客观下线的结点恢复之后,其会成为新的slave结点并向master结点同步数据
最后我们来看看总结
安装与启动
然后我们来实现Redis的哨兵,首先我们来看看我们的集群结构
三个sentinel实例信息如下:
| 节点 | IP | PORT |
|---|---|---|
| s1 | 192.168.88.128 | 27001 |
| s2 | 192.168.88.128 | 27002 |
| s3 | 192.168.88.128 | 27003 |
当然,这里的IP地址也是我们的虚拟机的IP地址
要在同一台虚拟机开启3个实例,必须准备三份不同的配置文件和目录,配置文件所在目录也就是工作目录。
我们创建三个文件夹,名字分别叫s1、s2、s3:
# 进入/tmp目录
cd /tmp
# 创建目录
mkdir s1 s2 s3
如图:
然后我们在s1目录创建一个sentinel.conf文件,添加下面的内容:
port 27001
sentinel announce-ip 192.168.88.128
sentinel monitor mymaster 192.168.88.128 7001 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
dir "/tmp/s1"
解读:
-
port 27001:是当前sentinel实例的端口 -
sentinel monitor mymaster 192.168.88.128 2:指定主节点信息mymaster:主节点名称,自定义,任意写192.168.150.101 7001:主节点的ip和端口2:选举master时的quorum值
然后将s1/sentinel.conf文件拷贝到s2、s3两个目录中(在/tmp目录执行下列命令):
# 方式一:逐个拷贝
cp s1/sentinel.conf s2
cp s1/sentinel.conf s3
# 方式二:管道组合命令,一键拷贝
echo s2 s3 | xargs -t -n 1 cp s1/sentinel.conf
修改s2、s3两个文件夹内的配置文件,将端口分别修改为27002、27003:
sed -i -e 's/27001/27002/g' -e 's/s1/s2/g' s2/sentinel.conf
sed -i -e 's/27001/27003/g' -e 's/s1/s3/g' s3/sentinel.conf
为了方便查看日志,我们打开3个ssh窗口,分别启动3个redis实例,启动命令:
# 第1个
redis-sentinel s1/sentinel.conf
# 第2个
redis-sentinel s2/sentinel.conf
# 第3个
redis-sentinel s3/sentinel.conf
然后我们要实现RedisTemplate的哨兵模式,这个还是比较简单的,首先我们需要引入我们的测试工程redis-demo
接着我们在其下引入对应的依赖并配置sentinel的相关信息
然后我们在配置文件中指定对应的master名称和集群的信息
spring:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.88.128:27001
- 192.168.88.128:27002
- 192.168.88.128:27003
接着我们在对应的配置文件中配置对应的哨兵的读取模式,我们这里选择使用第二种,优先从主节点读取,不可用时从副节点读取
然后我们开启该服务,在浏览器中进行对应的访问就可以通过实现设置好的方法来实现对缓存的写入,当我们请求读的时候,服务器首先会和对应的哨兵Sentinel建立联系,然后会通过对应的哨兵获取连接池,由于是读操作,其会和所有的Redis建立连接,然后会从其中采取订阅的形式从一个实例中获取所需要的数据
如果是写操作的话,那么就只会和主结点的Redis建立连接,因为只有主结点允许写操作
Redis分片集群
Redis哨兵模式无法解决海量数据数据存储和高并发写的问题,为了解决这些问题,我们需要学习Redis的分片集群,先来看看其介绍
安装与启动
那么接着我们来实现Redis的分片集群
分片集群需要的节点数量较多,这里我们搭建一个最小的分片集群,包含3个master节点,每个master包含一个slave节点,结构如下:
这里我们会在同一台虚拟机中开启6个redis实例,模拟分片集群,信息如下:
| IP | PORT | 角色 |
|---|---|---|
| 192.168.88.128 | 7001 | master |
| 192.168.88.128 | 7002 | master |
| 192.168.88.128 | 7003 | master |
| 192.168.88.128 | 8001 | slave |
| 192.168.88.128 | 8002 | slave |
| 192.168.88.128 | 8003 | slave |
然后我们要删除之前的7001、7002、7003这几个目录,重新创建出7001、7002、7003、8001、8002、8003目录:
# 进入/tmp目录
cd /tmp
# 删除旧的,避免配置干扰
rm -rf 7001 7002 7003
# 创建目录
mkdir 7001 7002 7003 8001 8002 8003
接着在/tmp下准备一个新的redis.conf文件,内容如下:
port 6379
# 开启集群功能
cluster-enabled yes
# 集群的配置文件名称,不需要我们创建,由redis自己维护
cluster-config-file /tmp/6379/nodes.conf
# 节点心跳失败的超时时间
cluster-node-timeout 5000
# 持久化文件存放目录
dir /tmp/6379
# 绑定地址
bind 0.0.0.0
# 让redis后台运行
daemonize yes
# 注册的实例ip
replica-announce-ip 192.168.150.101
# 保护模式
protected-mode no
# 数据库数量
databases 1
# 日志
logfile /tmp/6379/run.log
这些配置都是一些集群的Redis的实例所必要的配置,当然,我们后面还需要对这些内容进行相应的改动,比如对应的端口号和ip地址,否则其会因为和实际的环境不对应而无法使用
将这个文件拷贝到每个目录下:
# 进入/tmp目录
cd /tmp
# 执行拷贝
echo 7001 7002 7003 8001 8002 8003 | xargs -t -n 1 cp redis.conf
修改每个目录下的redis.conf,将其中的6379修改为与所在目录一致:
# 进入/tmp目录
cd /tmp
# 修改配置文件
printf '%s\n' 7001 7002 7003 8001 8002 8003 | xargs -I{} -t sed -i 's/6379/{}/g' {}/redis.conf
因为已经配置了后台启动模式,所以可以直接启动服务:
# 进入/tmp目录
cd /tmp
# 一键启动所有服务
printf '%s\n' 7001 7002 7003 8001 8002 8003 | xargs -I{} -t redis-server {}/redis.conf
通过ps查看状态:
ps -ef | grep redis
发现服务都已经正常启动:
如果要关闭所有进程,可以执行命令:
ps -ef | grep redis | awk '{print $2}' | xargs kill
或者(推荐这种方式):
printf '%s\n' 7001 7002 7003 8001 8002 8003 | xargs -I{} -t redis-cli -p {} shutdown
此时虽然服务已经启动了,但是目前每个服务之间都是独立的,没有任何关联,我们接下来要创建他们的集群练习
在Redis5.0之前创建集群比较麻烦,5.0之后集群管理命令都集成到了redis-cli中
我们使用的是Redis6.2.4版本,集群管理以及集成到了redis-cli中,格式如下:
redis-cli --cluster create --cluster-replicas 1 192.168.88.128:7001 192.168.88.128:7002 192.168.88.128:7003 192.168.88.128:8001 192.168.88.128:8002 192.168.88.128:8003
命令说明:
redis-cli --cluster或者./redis-trib.rb:代表集群操作命令create:代表是创建集群--replicas 1或者--cluster-replicas 1:指定集群中每个master的副本个数为1,此时节点总数 ÷ (replicas + 1)得到的就是master的数量。因此节点列表中的前n个就是master,其它节点都是slave节点,随机分配到不同master
我们这里要重点提一下上面的命令原理,首先指定的集群命令中的create,然后我们需要指定每个结点要跟随的奴隶结点的数量,使用--cluster-replicas命令,我们这里指定为1,就意味着每一个主结点跟一个副结点,其会根据该设置的跟随数量来确定出主副结点,比如这里每一个主结点跟一个副节点,则一组有2个结点,总共有6个结点,因此有3个主结点,三个副结点,那么其就会将前三个结点作为主节点,后三个结点作为副节点
运行后的样子:
这里输入yes,则集群开始创建:
我们也可以通过命令可以查看集群状态:
redis-cli -p 7001 cluster nodes
集群操作时,需要给redis-cli加上-c参数才可以:
redis-cli -c -p 7001
这点要注意
散列插槽
Redis中会把每一个mater节点都映射到0~16383个插槽上,查看集群信息时可以看到每一个主结点所分配的插槽,数据不是与结点进行绑定,而是和插槽进行绑定,redis会根据key的有效部分来计算插槽值并将对应的值存于该插槽中
计算插槽值时有两种情况,一种是key只能够包含{},计算时会根据{}内的值来计算插槽值,若没有则根据整个key来计算插槽值
一般来说如果我们需要将某个一类值全部存储到某个固定的插槽中,我们可以通过在相同类型的数据中指定相同的大括号值的方式来实现我们的目的
之所以要将数据与散列插槽绑定而不与结点绑定,是因为这样当我们的结点宕机的时候,我们仍然可以转移散列插槽的值到健康结点中,这样就增加了我们的结构的灵活性,降低了其耦合度
最后我们来看看总结
先来看看添加结点的命令
添加结点的命令需要指定新结点的ip地址和端口号,并且要指定一个当前的集群中的结点,这样新加入的结点才可以知道其他结点所在的位置并通知他们新结点加入的消息
如果不加附加的参数,默认新添加的结点是主结点,附加则可以令其成为副节点并可以指定其属于的主节点
接着我们通过一个例子来加深理解
那么我们首先要创建对应的7004文件夹,并且拷贝配置文件文件和修改对应的配置文件,然后启动该实例,这些事情我们就不演示了
然后我们输入命令redis-cli --cluster add-node 192.168.88.128:7004 192.168.128.88:7001即可让该结点加入到Redis中,我们也可以通过redis-cli -p 任意结点的端口号 cluster nodes命令来查看我们当前的集群结点情况
此时虽然加入了,但是我们的新结点还没有任何的插槽值,此时是无法加入任何的值到该结点中的,我们给其分配插槽值的命令是redis-cli --cluster reshard 任意结点的ip:端口号,回车之后会提示我们想要移动多少个插槽,这个值自己选,然后我们要输入接受该插槽的id,就这个ID会在上面的消息中一并出现,我们复制并输入即可,然后我们需要输入被转移插槽的id,转移完成之后输入done即可完成转移
然后我们就可以成功往新加入的结点中插入值了
最后我们来做一个练习
这里我们就提一点,删除集群中的某个结点必须要将其下的插槽值进行转移,转移之后擦可以进行删除
故障转移
如果集群中的一个主节点宕机,那么首先会认为其是疑似宕机,到后面一直没恢复就是确定宕机下线了,此时会自动提升一个slave为新的master
当然,有时候我们也需要执行手动数据迁移,比如说可能我们的某个实例的硬件需要更换,此时需要先将其转移到另一个结点上,这时就需啊哟手动的数据迁移
手动的数据迁移默认方式会经过下面五步,其中可以指定force和takeover方式,但是一般来说我们都推荐使用默认方式
要执行手动故障转移,则需要进入要转移的结点中,然后输入cluster failover,这样其就会自动执行对应的手动数据迁移,下面的案例就通过这个步骤执行
最后我们要通过RedisTemplate来访问分片集群,其实非常简单,同样是引入依赖配置读写分离,只是配置方式略有差异而已
这是其配置代码
spring:
redis:
cluster:
nodes:
- 192.168.88.128:7001
- 192.168.88.128:7002
- 192.168.88.128:7003
- 192.168.88.128:8001
- 192.168.88.128:8002
- 192.168.88.128:8003
Redis分布式锁
下面的代码模拟了Redis中处理商品买卖的场景,该代码在单体架构时可以保证线程安全,从而能够处理并发情况
但是在微服务架构中,由于每一个请求转发到Tomcat中都有一个独立的JVM,此时如果有两个请求转发到两个Tomcat中,那么两个请求就可能会出现同时处理的情况,这时可能两个请求都处理了,然而却商品数目却只减少一个
为了实现分布式锁,我们可以线程设置lockKey,每次进入时调用setIfExist命令,若不存在该字符串,则执行业务,之后再释放锁
由于Redis的线程模型是单线程的,所以无论有多少个请求到达Redis,Redis都会对其进行按照时间顺序进行排队然后按顺序执行,加锁成功的线程执行,不成功的线程返回
不过上面的代码只是幼儿园级别的分布式锁实现,存在许多Bug,比方说,可能释放锁时出现异常导致锁不被释放,或者是执行业务的过程中锁超时,导致线程安全被破坏
然而上面的代码再高并发的场景下也会出现问题,业务时间的不确定可能会导致我们的锁在实际执行的过程中出现永久失效的情况,解决的方法也是给每一个线程分配一个唯一标识,每次确定锁与自己的标识一致再删除,但是即使如此,也还有由于定时长度不确定带来的效率问题
为了解决这个问题,我们可以在业务的主线程获得锁之后开辟一个分线程搞一个定时器,定时器每隔过期时间的三分之一检测是否还有持有锁,若持有则重置时间,反之则无事发生,业务执行完毕锁和定时器都一起释放。
这是其中一个简单的解决方案,但实际上,很多这种业务的解决方案已经由redisson框架实现完毕了,因此我们接下来就学习这个框架
redisson
要使用该框架首先要在pom文件中导入,然后进行Bean创建即可使用
使用该框架我们只需要将代码改成上面的形式即可实现我们的需求,简单不少说实话
这是我们上面的代码的执行过程
不过我们此时还是存在问题的,第一个问题就是如果请求在执行过程中Redis主节点宕机了且来不及和副节点做同步的话,此时还是会破坏我们的线程安全,其次是在超高并发的场景下这个效率仍然是不可接受的。前者可以使用Zookeeper解决,后者可以将库存分段进行业务处理的方式解决