分布式系统理论
分布式系统是若干个独立计算机的集合,这些计算机的集合,这些计算机对于用户来说就像单个相关系统。分布式系统是由一组通过网络进行通信,为了完成共同的任务而协调工作的计算机节点组成的系统。分布式系统的出现是为了用廉价的,普通的机器完成单个计算机无法完成的计算,存储任务。其目的是:利用更多的机器,处理更多的数据。
RPC
定义: RPC是指远程过程调用,是一种进程间的通信方式,它是一种技术的思想,而不是规范。它允许程序调用另一个地址空间的过程或函数,而不是程序显式编码这个远程调用的细节。即程序员无论是调用本地还是远程的函数,本质上编写的调用代码基本相同。
RPC原理:
RPC的核心:通讯, 序列化(方便数据传输)。
序列化:数据传输需要转换。
解决这些核心问题我们可以使用Doubb。
springCloud技术概况
springCloud技术分布图:
springCloud升级:
springBoot和springCloud版本兼容查询
https://spring.io/projects/spring-cloud#learn
springCloud 对应依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
Eureka
作用: Eureka能够自动注册并发现微服务,然后对服务的状态,信息进行集中管理,这样我们需要获取其他服务的信息时,我们只需要向Eureka进行查询就可以了。
这样服务之间的强广联性就会被进一步减弱。
对应依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
<version>3.1.3</version>
</dependency>
配置application.yml文件
eureka:
client:
#由于我们是作为服务端角色,所以不需要获取服务端,改为false, 默认为true
fetch-registry: false
#暂时不需要将自己注册到eureka
register-with-eureka: false
service-url:
defaultZone: http://localhost:8888/eureka
且要在启动类中添加注解: @EnableEurekaServer
效果图:
接下来将各个服务作为客户端。
作为客户端的对应依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<version>3.1.3</version>
</dependency>
配置各个服务的application.yml,让将服务地址指向eureka服务的地址,这样才能实现注册。
客户端application.yml配置:
eureka:
client:
service-url:
defaultZone: http://localhost:8888/eureka
spring:
application:
name: ”对应名字“
效果图:
当我们的服务启动之后,每隔一段时间eureka会发送一次心跳包,这样eureka就能检测到我们的服务是否还在正常运行。
通过eureka来调用服务
例子一:
旧代码
public UserBook queryByUid(Integer uid) {
List<DbBorrow> dbBorrows = dbBorrowDao.queryByUid(uid);
RestTemplate template = new RestTemplate();
DbUser user = template.getForObject("http://localhost:8083/dbUser/" + uid, DbUser.class);
UserBook userBook = new UserBook();
userBook.setUser(user);
LinkedList<DbBook> books = new LinkedList<>();
for(int i = 0; i < dbBorrows.size(); i++){
DbBook book = template.getForObject("http://localhost:8081/dbBook/" + dbBorrows.get(i).getBid(), DbBook.class);
books.add(i, book);
}
userBook.setBook(books);
return userBook;
}
这里其实就是通过询问eureka对应的服务名来获取对应的ip地址。
负载均衡的实现
同一个服务器实际上可以注册很多个的, 但它们的端口是不同的,比如我们创建多个用户查询服务,将原有的端口进行修改,由idea中设置启动参数来决定,这样就可以创建几个同端口的相同服务了。
效果图中说明在用户服务处有多个相同的服务。
当我们要使用用户服务时,如果有第一个用户服务down掉的话,就会有另一个用户服务来执行对应的操作,防止整哥微服务不可用,大大提高了安全性。
当存在多个相同服务的时候就会通过对应的负载均衡的策略使每个服务都被调用起来,从而实现负载均衡。
新的实现代码
1.在condig包中创建一个BeanConfiguration.java。
@Configuration
public class BeanConfiguration {
@Bean
//负载均衡
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
2.在对应的service层中的使用@Autowired进行自动注入使用,将原来的ip地址改为在eureka中对应的服务名字。
@Resource
private RestTemplate template;
public UserBook queryByUid(Integer uid) {
List<DbBorrow> dbBorrows = dbBorrowDao.queryByUid(uid);
//将原来的ip地址改为在eureka中对应的服务名字
DbUser user = template.getForObject("http://user-service/dbUser/" + uid, DbUser.class);
UserBook userBook = new UserBook();
userBook.setUser(user);
LinkedList<DbBook> books = new LinkedList<>();
for(int i = 0; i < dbBorrows.size(); i++){
DbBook book = template.getForObject("http://book-service/dbBook/" + dbBorrows.get(i).getBid(), DbBook.class);
books.add(i, book);
}
userBook.setBook(books);
return userBook;
}
注册中心高可用
为了防止eureka down掉,我们可以搭建eureka集群。
效果图:
搭建eureka集群步骤:
1.修改两个eureka服务端的配置文件。
applicationn01.yml
server:
port: 9999
eureka:
instance:
#由于不支持多个localhost的eureka的服务器,但是又只能在本地测试,所有就只能自定义主机名称了
hostname: eureka01
client:
#不需要获取服务端
fetch-registry: false
#去掉register-with-eureka选项,让eureka服务器自己注册到其他的eureka服务器,这样才能相互启用
service-url:
#注意这里要填写其他的eureka服务器地址,不用写自己的
defaultZone: http://eureka02:9999/ereka
application02.yml
server:
port: 9999
eureka:
instance:
#由于不支持多个localhost的eureka的服务器,但是又只能在本地测试,所有就只能自定义主机名称了
hostname: eureka02
client:
#不需要获取服务端
fetch-registry: false
#去掉register-with-eureka选项,让eureka服务器自己注册到其他的eureka服务器,这样才能相互启用
service-url:
#注意这里要填写其他的eureka服务器地址,不用写自己的
defaultZone: http://eureka01:8888/ereka
2.启动eureka集群
在因为是本地测试所以我们要修改本地的hosts。
eureka01:
eureka02:
3.把所有的微服务在eureka集群中都注册一次
service-url:
defaultZone: http://localhost:8888/eureka, http://localhost:9999/eureka
在一个eureka down掉的时候,另一个eureka还会继续工作,这时我们就可以对应down 掉的eureka进行维修,这样就实现了高可用。
OpenFeign
OpenFeign和RestTemplate一样,也是一种HTTP客户端请求工具,但它使用起来更加便捷。
对应依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>3.1.3</version>
</dependency>
在对应的启动类上加上 @EnableFeignClients
配对应的FeignClient服务接口 。(可以单独创建一个包来存放这些服务接口)
服务接口格式:
@FeignClient("在eureka中配置的服务名字")
public interface BookClient {
//该接口里面的方法为你要调用的Controller中的方法,这些方法的路径要写全
@GetMapping("/dbBook/{id}")
DbBook queryById(@PathVariable("id") Integer id);
}
要使用此接口时可以通过@Resource进行注入。(类似Dao层的mybatis,其通过@FeignClient将接口注入到spring中)
调用此接口的Service层中的代码就要修改为下:
public UserBook queryByUid(Integer uid) {
List<DbBorrow> dbBorrows = dbBorrowDao.queryByUid(uid);
// DbUser user = template.getForObject("http://user-service/dbUser/" + uid, DbUser.class);
DbUser user = userClient.queryById(uid);
UserBook userBook = new UserBook();
userBook.setUser(user);
LinkedList<DbBook> books = new LinkedList<>();
for(int i = 0; i < dbBorrows.size(); i++){
// DbBook book = template.getForObject("http://book-service/dbBook/" + dbBorrows.get(i).getBid(), DbBook.class);
DbBook book = bookClient.queryById(dbBorrows.get(i).getBid());
books.add(i, book);
}
userBook.setBook(books);
return userBook;
}
OpenFeign服务降级
为对应的client接口创建实现类。
@Component //注入到spring中,使得BookClient能调用到此实现类
public class BookFallBackClient implements BookClient{
@Override
public DbBook queryById(Integer id) {
return new DbBook();
}
}
@Component 注入到spring中,使得UserClient能调用到此实现类
public class UserFallBackClient implements UserClient{
@Override
public DbUser queryById(Integer id) {
return new DbUser();
}
}
将此实现类添加到client的备选方案中。
在application.xml配置熔断支持
feign:
circuitbreaker:
enabled: true
效果图:
Hystrix
Hystrix服务熔断
微服务之间是可以相互调用的。
由于位于最底端的服务提供者发生了故障,那么此时会直接导致ABCD全线崩溃,就像雪崩一样。
这种情况实际上是不可避免的,由于太多的因素,比如网络故障,系统故障,硬件问题,会导致这种极端的情况发生,因此我们需要找到对应的方法来解决次问题。
为了解决分布式系统的雪崩问题,springCloud提供了Hystrix熔断组件,它就像我们家中的保险丝一样,当电流过载的时候直接熔断掉,防止危险的进一步发生,从而保障家庭用电的安全,可以想象一下,如果整条链路上的服务已经全线崩溃,这时还在不断地有大量的请求到达,想要各个服务进行处理,肯定是会使得情况越来越糟。
熔断机制是应对雪崩效应的一种微服务链路保护机制,当检测到链路的某个微服务不可用或者响应的时间太长时,会进行服务降级,进而熔断该节点微服务的调用,快速返回"错误"的响应信息,当检测到该节点微服务响应正常后恢复调用链路。
实际上,熔断就是在降级的基础上进一步形成的,也就是说,在一段时间内多次调用失败,那么就直接升级为熔断。
当需要的服务正常启动后,熔断机制就会关闭了。
Hystrix服务降级
服务降级并不会直接返回错误信息,而是可以提供一个补救的措施,正常响应给请求者,这样相当于服务依然可用,但是服务能力肯定是下降的。
对应依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
<version>2.2.10.RELEASE</version>
</dependency>
在启动类上添加注解: @EnableHystrix。
在对应的Controller层中编写编写备选方案。
//备选方案,返回一个空的对象
public ResponseEntity<UserBook> onError(Integer uid) {
UserBook userBook = new UserBook();
userBook.setUser(null);
userBook.setBook(Collections.emptyList());
return ResponseEntity.ok(userBook);
}
在对的方法上添加注解:@HystrixCommand(fallbackMethod = "备选方案名")
效果图:
Gateway路由网关
可能并不是所有的微服务都需要直接暴露给外部调用,这时我们就可以使用路由机制,添加一层防护,让所有的请求全部通过路由来转发到各个微服务,并且转发给多个相同微服务实例也可以实现负载均衡。
部署网关
对应依赖为下:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<version>3.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<version>3.1.3</version>
</dependency>
第一个是网关的依赖,第二个是跟其他微服务一样,需要注册到eureka中才能生效,不要添加web依赖,使用的是WebFlux框架。
编写配置文件
eureka:
client:
service-url:
defaultZone: http://localhost:8888/eureka, http://localhost:9999/eureka
spring:
application:
name: gateway
server:
port: 8500
继续在配置文件中配置路由功能。
spring:
cloud:
gateway:
routes:
- id: borrowService #路由的名字
uri: lb://borrow-service #路由的地址,lb表示使用负载均衡到微服务,也可以使用Http正常转发
predicates: #路由规则 断言什么请求会被路由
- Path=/dbBorrow/queryUserBook/** #只要访问这个路径,一律都被路由到上面指定的服务
在输入路径后,如果路径符合断言,就会将uri和Path进行拼接。
路由过滤器
路由过滤器支持以某中方式修改传入的HTTP请求或传出的HTTP响应,路由过滤器的范围是某个过滤器,跟之前的断言一样
修改对应的配置文件。
spring:
application:
name: gateway
cloud:
gateway:
routes:
- id: borrowService #路由的名字
uri: lb://borrow-service #路由的地址,lb表示使用负载均衡到微服务,也可以使用Http正常转发
predicates: #路由规则 断言什么请求会被路由
- Path=/dbBorrow/queryUserBook/** #只要访问这个路径,一律都被路由到上面指定的服务
- id: bookService
uri: lb://book-service
predicates:
- Path=/dbBook/**
filters: #添加过滤器
- AddRequestHeader=Test, HELLO WORLD!
在对应的Controller层中测试是否过滤成功。
效果图:
设置全局过滤器
例子:拦截没有携带指定参数的请求。
在gateway项目中创建一个实现类。
@Component
public class TestFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// exchange表示请求过来的信息
//先获取ServerHttpRequest对象,注意不是HttpServletRequest
ServerHttpRequest request = exchange.getRequest();
//打印请求携带的所有参数
System.out.println(request.getQueryParams());
//判断是否包含test参数,且参数值是否为1
List<String> test = request.getQueryParams().get("test");
if(test != null && test.contains("1")){
//将ServerHttpExchange向过滤链的下一级传递,类似javaWeb中的过滤器
return chain.filter(exchange);
} else {
//直接在这里不再向下传递,然后返回响应
return exchange.getResponse().setComplete();
}
}
}
次过滤器会作用于整个网关。
效果图:
只要路径中没有携带test=1就会被拦截。
当然过滤器会存在很多个的,所以我们手动指定过滤器之间的顺序。可以通过实现Ordered接口,重写getOredr方法来设置执行的顺序。
@Component
public class TestFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// exchange表示请求过来的信息
//先获取ServerHttpRequest对象,注意不是HttpServletRequest
ServerHttpRequest request = exchange.getRequest();
//打印请求携带的所有参数
System.out.println(request.getQueryParams());
//判断是否包含test参数,且参数值是否为1
List<String> test = request.getQueryParams().get("test");
if(test != null && test.contains("1")){
//将ServerHttpExchange向过滤链的下一级传递,类似javaWeb中的过滤器
return chain.filter(exchange);
} else {
//直接在这里不再向下传递,然后返回响应
return exchange.getResponse().setComplete();
}
}
@Override
public int getOrder() {
//返回的数字表示执行的顺序
return 0;
}
}
注意Oreder的值越小执行的优先级就越高,并且无论是配置文件里编写的单个路由过滤器还是全局过滤器,都会受到Order的影响(单个路由过滤器的Order值按从上到下的顺序从1开始递增),最终是按照Order值决定哪个路由过滤器先执行,当Order值相同时,全局路由过滤器会优先于单个路由过滤器执行。
微服务CAP原则
在一个分布式系统中存在 Consistency(一致性),Availabiity(可用性),Partition Tolerance(区分容错性)三者是不可同时保证的,最多同时保证其中的两个。
一致性:在分布式系统中的所有数据备份,在同一时刻都是相同的值。(所有的节点无论何时访问都能拿到最新的值)
可用性:系统中非故障节点收到的每个都必须得到响应。(不如我们之前使用的服务降级和熔断,其实就是一种维持可用性的措施,虽然服务器返回的是无意义的数据,但不至于用户的请求会被服务器忽略)
区分容错性:一个分布式系统里面,节点之间组成的网络本应该是相互连通的,然而可能因为一些故障(比如网络丢包等,这是很难避免的),使得一些节点之间不能连通,整个网络分成了几块区域,数据就散落在这些不相连通的区域里面。(这样就可能出现某些被分区节点存放的数据访问失败,我们需要来容忍这些不可靠的情况)
总的来说,数据存放的节点越多,分区容忍性就越高,都是要复制更新的次数就会越来越多,同时为了保证一致性,更新所有节点数据所需要的时间就越长,那么可用性就会降低。
所以存在三种方案:
springCloud Alibaba的使用
springCloud的缺点:
1.springCloud部分组件停止维护和更新了,给开发带来了不便。
2.springCloud部分开发环境复杂,没有完善的可视化界面,我们需要大量的二次开发和定制。
3.springCloud配置复杂,难上手,部分配置差别难以群分和合理应用。
springCloud Alibaba的优点:阿里使用过的组件经历了考验,性能强悍,设计合理,现在开源出来给大家使用成套的产品搭配完善的可视化界面给开发带来运维带来了极大的便利,搭建简单,学习曲线低。
spring-cloud-alibaba对应依赖:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.2.7.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Nacos
Nacos: 一个易于使用的动态服务发现、配置和服务管理平台,用于构建云原生应用程序。
作用: 将其作为服务注册中心。
Nacos作为服务注册中心对应依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
在github中下载nacos并将其复制到项目中。
在startup.sh中设置nacos让其在前台运行。
配置服务。
直接使用nacos。
后台启动nacos: bash nacos/bin/startup.sh。(以集群的方式)
关闭nacos: bash nacos/bin/shutdown.sh。
后台启动naocs: bash nacos/bin/startup.sh -m standalone。(以单例的方式启动)
nacos 的地址为: localhost:8084/nacos。
导入对应依赖。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
在子项目中添加依赖。
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>2021.1</version>
</dependency>
设置配置文件。
spring:
cloud:
#配置nacos注册中心地址
nacos:
discovery:
server-addr: lcoalhost:8848
效果图:
使用openFeign调用服务。
对应依赖为下:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>3.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
<version>3.1.1</version>
</dependency>
和原生的调用方式一样。
临时实例和非临时实例的区别:
临时实例:和eureka一样,采用心跳机制向nacos发送请求保持在线的状态,一但心跳停止,就代表实例下线,不保留实例信息。
非临时实例:由nacos主动进行联系,如果连接失败,那么不会删除实例信息,而是将健康状态设置为false,相当于对某个实例状态持续进行监控。
设置非临时实例
设置配置文件。
cloud:
nacos:
discovery:
# 修改为false 表示其是个非临时文件
ephemeral: false
对应的实例下线时会颜色会变为红色。
集群分区
对应依赖为下:
spring:
cloud:
nacos:
discovery:
#对应的集群名
cluster-name: name
效果图
在默认情况下,集群间的调用方式采用的是轮番调用,使用为了实现就近原则,我们要对配置文件进行相应配置。
spring:
cloud:
#将loadbalancer的nacos支持开启,集成nacos负载均衡
loadbalancer:
nacos:
enabled: true
在同个集群中如果存在多个相同的服务时,就会根据权重来执行对应的服务,我们可以在nacos页面中进行设置。
也可以通过配置文件进行修改。
spring:
cloud:
nacos:
discovery:
#设置权重,默认为1
weight: 2
配置中心
我们可以通过配置来加载远程配置,这样我们可以远端集中管理配置文件。
我们可以通过在nacos中,点击新建配置项,进行配置。
点击发布。
在项目中添加依赖。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.2</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
<version>2021.1</version>
</dependency>
编写bootstrap.yml文件。
spring:
application:
#去远程仓库中调用对应名字的配置
name: user-service
profiles:
active: dev
cloud:
nacos:
config:
file-extension: yml
server-addr: localhost:8848
此时两个yml文件都会被使用。
在默认情况下,在nacos中修改yml文件,对应的服务中的yml的信息并不会发生对应的改变,虽然对其做了监听。
为了能够保证在nacos中修改的yml文件立刻生效,我们需要添加注解@RefreshScope。
效果图:
原配置文件
修改后的配置文件
结果
命名空间
新建命名空间。
在配置文件中配置所属的命名空间。
spring:
cloud:
nacos:
discovery:
namespace: 对应命名空间的id
命名空间不相同的服务是不能相互调用的。
指定分组
修改配置文件。
spring:
cloud:
nacos:
discovery:
group: 对应的组名
默认为 DEFAULT_GROUP。
高可用
在本地数据库导入nacos中的sql文件。
配置conf/application.properties文件。
将cluster.conf.example重命名为cluster.conf,并对其内容进行修改,输入多个的nanos地址,这里使用内网映射。
因为要创建nacos集群,所以我们要创建多个nacos,通过复制修改好的nacos进行操作,这时我们只要再对每个nacos修改端口号就可以了。(要以集群的方式启动,可能会启动失败,多启动几次)
效果图:
我们需要一个负载均衡器来管理这些nacos,这里我们使用Nginx。
在Mac上安装Nginx。
brew install nginx
编辑Nginx。
nano /usr/local/etc/nginx/nginx.conf
编辑内容:
# 添加我们刚创建好的nacos服务器
upstream nacos-server {
#被代理的服务群,nacos-server服务群的名字
server localhost:8801;
server localhost:8802;
}
server {
#监听的端口
listen 80;
#服务名
server_name localhost;
#类似过滤器,当访问到符合此要求的路径时就会去访问对应的服务群
location /nacos {
proxy_pass http://nacos-server;
}
}
重启Nginx。
brew services restart nginx
将各个微服务的nacos的注册地址改为localhost:80/nacos。(其会实现负载均衡)
在云服务器上做反向代理的例子。
1.配置nacos的端口号和对应的数据库。
2.启动俩个nacos。
3.在Nginx中配置反向代理。
集群效果:
在每个微服务中将nacos的地址改为云服务器上Nginx反向代理的地址。
最终效果图:
Sentinel流量防卫兵
Sentinel 可以做到熔断和降级,可以取代Hystrix。
Sentinel具有以下功能:
- 丰富的适用场景:哨兵在阿里巴巴被广泛使用,在过去10年中,几乎涵盖了Double-11(11.11)购物节的所有核心场景,例如需要限制突发流量以满足系统容量的“第二次杀戮”,消息峰值剪切和山谷填充,不可靠的下游服务的断路,集群流量控制等。
- 实时监控:哨兵还提供实时监控功能。您可以实时查看一台机器的运行时信息,以及少于500个节点的集群的聚合运行时信息。
- 广泛的开源生态系统:Sentinel提供与Spring Cloud、Dubbo和gRPC等常用框架和库的开箱即用集成。您只需将适配器依赖项添加到您的服务中,即可轻松使用Sentinel。
- Polyglot支持:Sentinel为Java、Go和C++提供了原生支持。
- 各种SPI扩展:Sentinel提供易于使用的SPI扩展接口,允许您快速自定义逻辑,例如自定义规则管理、调整数据源等。
下载对应的jar包。
将jar导入到项目,为其创建一个服务。
启动项目。
添加对应的依赖。
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
<version>2021.0.1.0</version>
</dependency>
对微服务中application.yml进行配置。(实际上Sentinel是本地在管理,但我们可以连接到监控页面,这样可以图形化操作了)
spring:
cloud:
sentinel:
# 添加监控页面地址
transport:
dashboard: localhost:8858
为了提高可读性和节省空间,Sentinel只监视被调用过的微服务。所以为了让sentinel监视我们的微服务,我们需要手动的调用一次微服务。
效果图:
流量控制
我们的机器不可能无限的接受和处理客户端的请求,如果不加以限制,当发生高并发时,就会使得系统的资源很快的被耗尽。为了避免这种情况,我们可以添加流量控制,当一段时间内的流量达到一定的阀值的时候,新的请求将不会再进行处理,这样不仅可以合理地应对高并发的情况,同时也可以在一定的程度上保护服务器不受到外界的攻击。
解决方案
针对判断是否超过流量的阀值的四种算法
1.漏桶算法
2.令牌桶算法(有点像游戏里的能量条机制)
3.固定时间窗口算法
4.滑动时间窗口算法
通过Sentinel进行设置
阀值类型:QPS就是每秒中的请求数量,并发线程数是按服务当时使用的线程数据进行统计的。
流控模式:当达到阀值时,流控的对象,这里暂时只用直接。
流控效果:就是我们上面所说的三种方案。
流控模式的区别:
1.直接:只针对当前接口。
2.关联:当其他接口超过阀值时,会导致当前接口被限流。(别的接口出现问题由当前接口承担责任)
3.链路:更细粒度的限流,能精确到具体的方法。
链路模式能够更加精准的进行流量控制,链路流控模式指的是,当从指定接口过来的资源请求达到限流条件时,开启限流,这里得先讲解一下@SentinelResource的使用。
我们可以对某个方法进行限流控制,无论是谁在何处调用了它,这里需要使用到@SentinelResource,一但方法被标注,那么就会进行监控。
在调用的方法上添加注解@SentinelResource。
@SentinelResource("detail")
@Override
public UserBook queryByUid(Integer uid) {
List<DbBorrow> dbBorrows = dbBorrowDao.queryByUid(uid);
RestTemplate template = new RestTemplate();
// DbUser user = template.getForObject("http://localhost:8083/dbUser/" + uid, DbUser.class);
DbUser user = userClient.queryById(uid);
UserBook userBook = new UserBook();
userBook.setUser(user);
LinkedList<DbBook> books = new LinkedList<>();
for(int i = 0; i < dbBorrows.size(); i++){
// DbBook book = template.getForObject("http://localhost:8081/dbBook/" + dbBorrows.get(i).getBid(), DbBook.class);
DbBook book = bookClient.queryById(dbBorrows.get(i).getBid());
books.add(i, book);
}
userBook.setBook(books);
return userBook;
}
修改配置文件。
spring:
cloud:
sentinel:
#关闭Context收敛,这样被监控方法可以进行不同链路的单独监控
web-context-unify: false
运行查看效果。
对精确限流设置限流策略。
设置阀值为1,然后进行连续访问测试,发现精确限流的位置抛出了异常。
那么这个链路选项实际上就是决定只限流从哪个方向来的调用,比如我们只对borrow2这个接口对queryByUid方法的调用进行限流,那么我们就可以为其制定链路。
入口资源的设置表示:进行精确限流的路径,如果设置了入口资源,那么其他路径调用被精确限流的方法时则不会被限流。
系统保护规则
检测对应的设备来进行限流。
限流和异常处理
我们看到被限流之后返回的Sentinel默认的数据,其实我们可以返回我们自己定义的数据。
这里我们先创建好被限流状态下需要返回的内容,自定义一个请求映射。
在要返回自定义的微服务的Controller中添加自定义的错误页面。
@RequestMapping("/blocked")
public JSONObject blocked() {
JSONObject json = new JSONObject();
json.put("code", 403);
json.put("success", false);
json.put("message", "访问频率过快, 请稍后访问!");
return json;
}
在配置文件中配置限流页面返回信息。
spring:
cloud:
sentinel:
block-page: /dbUser/blocked
效果图:
对于方法级别的限流,当某个方法被限流时,会直接在后台抛出异常,那么这种情况可以通过Sentinel添加一个替代方案,这样当我们发现异常时会直接执行我们的代替方案并返回。
在service层中添加代替方案。
@SentinelResource(value = "detail", blockHandler = "blocked")
//限流之后代替返回的其他方案,这样就不会使用默认的抛出异常的方式了
@Override
public UserBook queryByUid(Integer uid) {
List<DbBorrow> dbBorrows = dbBorrowDao.queryByUid(uid);
RestTemplate template = new RestTemplate();
// DbUser user = template.getForObject("http://localhost:8083/dbUser/" + uid, DbUser.class);
DbUser user = userClient.queryById(uid);
UserBook userBook = new UserBook();
userBook.setUser(user);
LinkedList<DbBook> books = new LinkedList<>();
for(int i = 0; i < dbBorrows.size(); i++){
// DbBook book = template.getForObject("http://localhost:8081/dbBook/" + dbBorrows.get(i).getBid(), DbBook.class);
DbBook book = bookClient.queryById(dbBorrows.get(i).getBid());
books.add(i, book);
}
userBook.setBook(books);
return userBook;
}
//代替方案
public UserBook blocked(Integer uid, BlockException blockException) {
UserBook userBook = new UserBook();
userBook.setBook(Collections.emptyList());
userBook.setUser(new DbUser());
return userBook;
}
blockHandler只会处理限流的异常,而不会处理方法体内的其他代码异常。
如果要处理限流以外的其他异常,我们可以通过其他参数进行处理。
@RequestMapping("/test")
@SentinelResource(value = "test",
fallback = "except", //fallback指定出现异常是的替代方案
exceptionsToIgnore = IOException.class) //忽略注定的异常,也就是出现这些指定的异常时不回调用的替代方案
public String test() {
throw new RuntimeException("抛出异常");
}
public String except(Throwable t) {
return t.getMessage();
}
效果图:
当在@SentinelResource中同时存在 fallback和blockHandler时,在抛出限流异常范围内的异常的时候就先调用blockHandler中的替代方案,其他的时候就会调用fallback。(注意在两个都打存在的时候,因为限流会在方法执行前调用,所以在限流代替方案执行完以后还会在执行出现其异常时的代替方案)
热点参数限流
我们可以对某一热点进行精准限流,比如在某一时刻,不同参数被携带访问的频率是不一样的:
http://localhost:8082/test?a=10 访问100次
http://localhost:8082/test?b=10 访问0次
http://localhost:8082/test?c=10 访问3次
由于携带的参数a的请求比较多,我们就可以只对携带参数a的请求进行限流。
创建一个新的测试请求映射。
@RequestMapping("/test")
@SentinelResource("test") //注意这里需要添加@SentinelResource才可以,用户资源名称就使用这里定义的资源名称
public String findBorrow(@RequestParam(value = "a", required = false) String a,
@RequestParam(value = "b", required = false) String b,
@RequestParam(value = "c", required = false) String c) {
return "请求成功!" + "a = " + a + " b = " + b + " c = " + c;
}
在Senntinel中设置热点配置。(我们对a进行了限流)
效果图:
我们也可以对某个参数的特定值进行特定限流。(我们对a进行特定值限流)
效果图:
Sentinel的服务熔断和降级
为了防止链路故障,我们能进行隔离,这里我们有两种隔离方案。
1.线程池隔离
线程池隔离实际上就是对每个服务的远程调用单独开放线程池,比如服务A要调用服务B,那么只基于固定数量的线程池,这样即使在短时间内出现大量请求,由于没有线程可以分配,所以就不会导致资源耗尽。
2.信号量隔离
信号量隔离是使用Semaphore类实现的,思想基本跟上面的相同,也是限定指定的线程数量能够同时进行服务调用,但它相对于线程池开销会更小一些,使用效果同样优秀,也支持超时等,Sentinel就是采用这个方案进行隔离的。
说回我们的熔断与降级,当下游的服务因为某些原因变得不可用或响应过慢时,上游为了保证自己整体的高可用性,不再继续调用目标服务而是快速返回或执行自己的代替方案。
整个过程分为三个状态:
1.关闭:熔断器不工作,所有的请求全部该干嘛就干嘛。
2.开启:熔断器工作,所有的请求一律降级。
3.半开:尝试进行一下正常的流程,要是还是不行就继续保持开启的状态,否则关闭。
在Sentinel设置熔断规则
熔断策略
1.慢调用比例:如果出现那种半天都处理不完的调用,有可能就是服务出现故障,这个选项是按照最大效应时间(RT)进行判断的,如果一次请求的处理时间超过了指定的RT,那么就会判断为慢调用,在一个统计时长内,如果请求数目大于最小请求数目,并且被判断定为慢调用的请求比例已经超过阀值,将触发熔断,经过熔断时长之后,将会进入到半开状态进行试探。(这里和Hystrix一致)
测试代码
@RequestMapping("/borrow2/{uid}")
public String test2(@PathVariable("uid") Integer uid) throws InterruptedException {
Thread.sleep(1000);
return "hello World!";
}
在在对应的微服务中设置熔断设置。
效果图:
2.异常比例:与慢调用比例相似,不过这里判断的是出现异常的次数。
测试代码
@RequestMapping("/borrow3/{uid}")
public String test3(@PathVariable("uid") Integer uid) throws Exception {
throw new RuntimeException();
}
在Sentinel中配置熔断配置。
效果图:
3.异常数:很异常比例好像,但有明确指出异常数量。
降级策略
我们需要在@SentinelResource中配置blockHandler参数。(这里跟之前方法限流的配置是一样的,因为如果添加了@SentinelResource注解,那么这里就会进行方法级别细粒度的限制,和之前方法级别限流一样,会在降级之后直接抛出异常,如果不添加则返回默认的限流页面,blockHandler的目的就是处理这种Sentinel机制的异常,所以这里其实和之前的限流配置是一个道理,因此下面熔断配置也应该对value自定义名称的资源进行配置,才能作用到此方法上)
测试代码:
//降级测试
@RequestMapping("/borrow4/{uid}")
@SentinelResource(value = "findBorrow", blockHandler = "test")
public ResponseEntity<UserBook> findBorrow(@PathVariable("uid") Integer uid) throws Exception {
throw new RuntimeException();
}
//代替方案
public ResponseEntity<UserBook> test(Integer uid, BlockException e) {
System.out.println(e.getClass());
UserBook userBook = new UserBook();
userBook.setUser(new DbUser());
userBook.setBook(Collections.emptyList());
return ResponseEntity.ok(userBook);
}
在Sentinel中设置熔断规则。
效果图:
抛出降级异常。
openFeign支持Sentinel
前面我们使用Hystrix的时候,就可以直接对openFeign的每个接口调用单独进行服务降级,而使用Sentinel,也可以的。
在配置文件中开启支持。
feign:
sentinel:
enabled: true
和之前的openfign整合eureka的服务降级配置相似。
BookClient接口:
@FeignClient(value = "book-service",fallback = BookClientImpl.class)
public interface BookClient {
@GetMapping("/dbBook/{id}")
DbBook queryById(@PathVariable("id") Integer id);
}
BookClient替代方案:
@Component
public class BookClientImpl implements BookClient{
@Override
public DbBook queryById(Integer id) {
return new DbBook();
}
}
启用代替方案效果图:
Sentinel整合RestTemplate使用服务降级
在config进行配置。
@Configuration
public class RestTemplateConfig {
@Bean
@LoadBalanced
@SentinelRestTemplate(blockHandler = "handleException", blockHandlerClass = ExceptionUtil.class,
fallback = "对应的降级方案", fallbackClass = ExceptionUtil.class)
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
Seata与分布式事务
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
事务特性
分布式讲解方案
1.XA分布式事务协议 -2PC (两阶段提交实现)
这里的PC实际上指的是Prepare和Commit,也就是说它分为两个阶段,一个是准备一个是提交,整个过程的参与者一共有两个角色,一个是事务的执行者,一个是事务的协调者,实际上整个事务的运作需要毅力啊协调者来维持。
在准备和提交阶段,会进行:
准备阶段:
一个分布式事务是由协调者来开启的,首先协调者会向所有的事务执行者发送事务内容,等待所有的事务执行者答复。
各个事务执行者开始执行事务操作,但不会进行提交,并将undo和redo信息记录到事务日志中。
如果事务执行者执行事务成功,那么就告诉协调者成功Yes,否则告诉协调者失败No,不能提交事务。
提交阶段:
当前有的执行者都反馈完成之后,进入第二阶段。
协调者会检测各个执行者的反馈内容,如果所有的返回都是成功,那么就告诉所有的执行者可以提交事务了,最后再释放锁的资源。
如果有至少一个执行者返回失败或超时面,那么就让所有的执行者都会回滚,分布式事务执行失败。
虽然这种方式看起来比简单,但是存在以下几个问题:
1.事务协调者是非常核心的角色,一旦出现问题,将导致整个分布式不能正常运行。
2.如果提交阶段发生网络问题,导致某事务执行者没有收到协调者发来的提交命令,将导致某些执行者没提交,这样肯定是不行的。
2.XA分布式事务协议 -3PC(三阶段提交实现)
三阶段提交是在二阶段提交的基础上的改进播版本,主要是加了超时记机制,同时在协调者和执行者都引入了超时机制。
三个阶段分别进行:
CanCommit阶段:
协调者向执行者发送CanCommit请求,询问是否可以执行事务提交操作,然后开始等待执行者的响应。
ProeCommit阶段:
协调者根据执行者的反应情况来决定是否可以进入第二阶段事务的PreCommit操作。
如果所有的执行者都返回Yes,则协调者向所有的执行者发送PreCommit请求,并进入Prepared阶段,执行者接收到请求后,会执行事务操作,并将undo和redo信息记录到事务日志中,如果成功执行,则返回成功的响应。
如果所有的执行者至少有一个返回No,则协调者会向所有的执行者发送abort请求,所有的执行者在收到请求或超时一段时间没有收到任何请求时,会直接中断事务。
DoCommit阶段:
该阶段进行真正的事务提交。
协调者接收到所有执行者发送的成功响应,那么它就从PreCommit状态进入DOCommit状态,并向所有的执行者发送doCommit请求,执行者接收到doCommit请求之后,开始执行事务提交,并在完成事务提交之后释放所有的事务资源,并最后向协调者发送确认响应,协调者接收到所有执行者的确认响应之后,完成事务(如果因为网络问题导致执行者没有接收到doCommit请求,执行者会在超时之后直接提交事务,虽然执行者只是猜测协调者返回的是doCommit请求,但是因为前面的两个流程都正常执行,所以能够在一定程度上认为本次事务是成功的,因此会直接提交)
协调者没有接收到至少一个执行者发送的成功响应(可能是响应超时),那么就会执行中断事务,协调者会向所有的执行者发送abort请求,执行者接收到abort请求之后,利用其在PreCommit阶段记录的undo信息来执行事务的回滚操作,并在完成回滚操作之后释放所有的事务资源,执行者完成事务回滚之后,向协调者发送确认信息,协调者接收到参与者反馈的确认信息之后,执行事务的中断。
第三阶段的特点:
1.3PC在2PC的第一阶段和第二阶段中插入一个准备阶段,保证在最后提交阶段之前各参与节点的状态是一致的。
2.一旦参与者无法及时收到来自协调者的信息之后,会默认执行Commit,这样就不会因为协调者单方面故障导致全局出现问题。
3.但是我们知道,实际上超时之后的Commit决策本质上就是一个赌注罢了,如果此时协调者发送的是abort请求但是超时未接收,那么就会直接导致数据一致性的问题。
3.TCC (补偿事务)
补偿事务TCC就是Try,Comfirm,Cancel,它对业务有入侵性,一共分为三个阶段。
Try阶段:
比如我们需要借书时,将书籍的库存-1,并将用户的借阅量-1,但是这个操作,除了直接对库存和借阅量进行修改之外,还需要将减去的值,单独存放到冻结表中,但是此时不会创建借阅信息,也就是说只是预先把关键的东西给处理了,预留业务资源出来。
Confirm阶段:
如果Try执行成功无误,那么就进入Confirm阶段,接着之前,我们就该创建借阅信息了,只能使用Try阶段预留的业务资源,如果创建成功,那么就对Try阶段冻结的值进行解冻,整个流程就完成了,如果失败了,就会进入Cancel阶段。
Cancel阶段:
将冻结的东西还给人家,进行回滚。
TCC特点:
跟XA协议相比,TCC就没有协调者这一角色的参与了,而是自主通过上一阶段的执行情况来确保正常,充分利用了集群的优势,性能也是有很大的提升,但是缺点也很明显,它与业务具有一定的关联性,需要开发者去编写更多的补偿代码,同时并不一定所有的业务流程都适用于这种形式。
Seata机制简介
seata支持四种事务:
1.AT:标志上就是2PC的升级版,在AT模式下,用户只需要关心自己的"业务SQL"。
一阶段:seata会拦截"业务SQL",首先解析SQL语义,找到"业务SQL"要更新的业务数据,在业务数据更新之前,将其保存成"before image",然后进行"业务SQL"更新业务数据,在业务数据更新后,再将其保存到"after image",最后生成行锁。以上操作只在一个数据库事务内完成,这样保证了第一阶段操作的原子性。
二阶段如果确定提交的话,因为"业务SQL"在一阶段已经提交到数据库,所以Seata框架只需要将第一阶段保存的快照数据和行锁删掉,完成数据清除即可,当然如果需要回滚,那么就用"before image"还原业务数据,但在还原前首先要校验脏读,对比"数据库当前业务数据"和"after image",如果两份数据完全一致就说明没有脏读,可以还原业务数据,如果不一致就说明有脏读,出现脏读就需要转人工处理。
2.TCC:和我们上面讲解的思路是一样的。
3.XA:同上,但是要求数据库本身支持这种模式才可以。
4.saga:用用处理长事务,每个执行者需要实现事务的正向操作和补偿操作。
那么,以AT模式为例,我们的程序是如何才能做到不对业务进行侵入的情况下实现分布式事务能?实际上,Seata客户端,是通过对数据源进行代理实现的,使用的是DataSourceProxy类,所以在程序这边,我们只需要将对应的代理类注册到Bean即可。(0.9版本之后支持自动代理,并不需要我们手动导入)
使用file进行部署(以AT为例)
下载seata-server。
在idea中配置seata服务。
seata支持本地部署也支持服务注册与发现中心部署。(比如eureka,nacos)
seata存在着事务分组机制:
1.事务分组:seata资源逻辑,可以按微服务的需要,在应用程序(客户端)对自定义事务进行分组,每个组取一个名字。
2.集群:seata-server服务端一个或多个节点组成的集群cluster。应用程序(客户端)使用时需要指定事务逻辑分组与seata服务器集群(默认为default)的映射关系。
为啥要设计成通过事务分组再直接映射到集群?为什么不直接将事务指定到集群呢?
获取事务分组到映射集群的配置。这样设计后,事务分组可以作为资源的逻辑隔离单位,出现某集群故障时可以快速failover(故障切换),只切换对应的分组,可以把故障缩减到服务级别,但提前也是你有足够server集群。
将各个服务作为seata的客户端导入对于依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2021.0.1.0</version>
</dependency>
修改配置文件。
seata:
service:
vgroup-mapping:
#这里需要对事务组进行映射,默认组名为应用名称-seata-service-group,将其映射到default集群
#这个很关键,一定要配置,不然会找不到服务
borrow-service-seata-service-group: default
grouplist: localhost:8868
也可以设置自定义的服务分组。
seata:
service:
vgroup-mapping:
#这里需要对事务组进行映射,默认组名为应用名称-seata-service-group,将其映射到default集群
#这个很关键,一定要配置,不然会找不到服务
xx服务名xx-seata-service-group: xxx
grouplist: localhost:8868
tx-service-group: xxx
现在我们接着来配置开启分布式事务,首先在启动类上添加注解,此注解会添加一个后置处理器将数据源封装为支持分布式事务的代理数据源(1.4.2版本还是要手动添加此注解才能生效)
接着我们需要在开启分布式事务的方法上添加注解@GlobalTransactional。
因为Seata会分析修改数据的sql,同时生成对应的反向回滚sql,这个回滚记录会存放在undo_log表中,所以要求每个Client都有一个对应的undo_log表(也就是说服务连接的数据库都需要创建一个这样的表,因为我们的例子就一个数据库,所有只要创建一个表)
创建undo_log表的sql语句。
CREATE TABLE IF NOT EXISTS `undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
`ext` VARCHAR(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;
启动服务,进行测试。
第一次借阅成功。
第二次借阅失败。
查看数据库是否进行回滚。(这里进行了回滚)
我们可以打印XID,查看其对应的XID,在service层添加语句。
System.out.println(RootContext.getXID());
也可以在日志中查看。
使用nacos模式部署
我们先为Seata在nacos中配置一个命名空间。
修改seata的/conf/registry.conf文件,修改其注册和配置中的“type”,“namespace”,“password”,“username”。
注册信息配置完成之后,接着我们需要将配置文件也放到Nacos中,让Nacos管理配置,这样我们就可以对配置进行热更新了,一旦环境需要改变,只需要直接到Nacos中修改即可。
我们需要对配置导入到Nacos中,我们打开seata源码的 /script/config-center/nacos目录,这是官方提供上传脚本,我们直接运行即可。(去github下载seata源码)
在nacos中查看seata的配置。
把所有微服务的事务分组信息的配置放在nacos中,我们还需要将对应的事务组映射配置也添加上,DataId格式为service.vgroupMapping.'事务的名称'。
接下来我们要对服务端的配置进行修改,我们删除原本的seata配置,添加新的seata配置。
seata:
#注册
registry:
type: nacos
nacos:
#使用seata命名空间,这样才能找到seata服务,由于组名我们设置的是SEATA_GROUP就是默认的名字,所以就不用配了
namespace: 550e71d6-4604-4952-a24b-b0d3781d8223
username: nacos
password: nacos
#配置
config:
type: nacos
nacos:
namespace: 550e71d6-4604-4952-a24b-b0d3781d8223
username: nacos
password: nacos
启动seata服务,在nacos中对应的命名空间观察seata服务是否正常启动。
启动各个微服务,各个服务使用nacos配置成功。
测试事务效果图:
我们还可以配置一下事务会话信息的存储方式,默认是file类型的,那么就会在运行目录下创建file_store目录,我们可以将其放到数据库中存储,只需要修改一下数据即可。
默认情况的存储方式:
将会话信息存放到数据库中
修改nacos中的seata配置store.mode,store.session.mode,将存储方式改为数据库方式。
将数据库的配置信息进行修改。
1.数据库启动。
2.数据库的URL。
3.数据库用户名和密码。
创建seata数据库。
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(128),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_status` (`status`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `distributed_lock`
(
`lock_key` CHAR(20) NOT NULL,
`lock_value` VARCHAR(20) NOT NULL,
`expire` BIGINT,
primary key (`lock_key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('HandleAllSession', ' ', 0);
数据库效果图:
运行事务效果图:
分布式权限校验
因为是分布式服务,每个微服务存储的sessionb是各不相同的,而我们需要的是保证所有的微服务都能同步这些session信息,这样我们才能实现某一个微服务登录时,其他微服务都能知道。
实现上述要求的方案
方案一:我们可以在每台服务器上都复制一份Session,但这样显然是很浪费时间的,并且用户验证数据占用的内存会成倍的增加。
方案二:将Session移出服务器,用统一访问Redis或是Mysql即可,这样就能保证服务都可以同步Seesion了。
明显方案二是可行的。
每个微服务需要添加验证机制,导入对应依赖。
<!-- springSession Redis支持-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.7.0</version>
</dependency>
<!-- 添加Redis的Starter-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.7.0</version>
</dependency>
导入springSecurity框架的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.7.0</version>
</dependency>
对每个微服务的配置文件进行修改。
spring:
session:
store-type: redis
redis:
host: lcoalhost:6379
进行测试。
因为spring-security的安全机制,所以我们需要携带对应的cookies才能访问对应的微服务。
其登录页面的用户名为:user,密码在idea的运行日志中。
然后服务端会将session存放到数据库中。
但是我们服务对应的服务还是会报500错误,只是因为我们使用了RestTemplate,RestTemplate类似一个浏览器,由于该微服务调用了其他的微服务,又因为RestTemplate在访问微服务时没有携带对应的cookies,所以报出500错误。
OAuth2.0实现单点操作
前面我们虽然使用了统一存储来解决Session共享的问题,但是我们发现就算实现了Session共享,依旧存在一些问题,由于我们每个微服务都有自己的验证模板,实际上整个系统上存在冗余功能的,同时还有我们上面出现的问题,那么能否实现只在一个服务进行等录,就可以访问其他的服务能?
实际上之前的登录模式称为多点登录,而我们希望的是实现单点登录。
这里我们需要了解一种全新的登录方式:OAuth2.0,我们经常看到一些网站支持第三方登录,就是使用OAuth2.0 来实现第三方授权,基于第三方应用访问用户信息的权限。(本质上就是给别人调用自己服务接口的权限)
四种授权模式
1.客户端模式(Client Credentials)
这是最简单的一种模式,我们可以直接向验证服务器 请求可以Token,服务器拿到Token之后,才能去访问服务资源,这样资源服务器才能知道我们是谁以及是否成功登录。(不需要密码验证)
虽然这种模式比较简便,但是已经失去了用户验证的意义,压根就不是给用户校验准备的,而是更适合内部调用的场景。
2.密码模式(Resource Owner Password Credentials)
密码模式相比客户端模式,就多了用户名和密码的信息,用户需要提供对应的账号的用户名和密码,才能获取Token。
虽然这样看起来比较合理,但是会直接将账号和密码泄露给客户端,需要后台完全信任客户端不会拿账号和密码去干其他坏事,所以也不是我们常见的。(可能前端或第三方会拿着你的账号,登录你的服务干坏事,很不安全)
3.隐式授权模式(Implicit Grant)
首先用户访问页面时,会重定向到认证服务器上,接着认证服务器给用户一个认证页面,等待用户授权,用户填写信息完成授权后,认证服务器返回Token。
它适用于没有服务端的第三方应用页面,并且相比前面一种形式,验证都是在验证服务器进行的,敏感信息不会轻易泄露,但是Token依然存在泄漏的风险。
4.授权码模式(Authrization Code)
这种模式是最安全的一种模式,也是推荐使用的一种,比如我们手机上的很多APP都是使用的这种方式。
相比隐式授权模式,它并不会直接返回Token,而是返回授权码,真正的Token是通过应用服务器访问验证服务器获得的。在一开始的时候,应用服务器(客户端通过访问自己的应用服务器来进行访问其他的服务)和验证服务器之间会共享一个“secret”(没有登录的时候是没有的“secret”),这个东西没有其他人知道,而验证服务器在用户验证之后,会返回一个授权码,应用服务器最后将授权码和“secret”一起交给验证服务器进行验证,并且Token也是在服务器之间传递,不会直接给客户端。
这样就算有人中途窃取了授权码,也毫无意义,因为Token的获取必须同时携带授权码和“secret”,但是“secret”第三方是无法得知的,并且Token不会直接给客户端,大大减少了泄漏的风险。
OAth2.0不应该是那种第三方应用为了请求我们的服务而使用的吗,而我们这里需要的只是实现同一个应用内部服务之间的认证,其实我们也可以利用OAuth2.0来实现单点登录,只是少了资源服务器这个角色,客户端就是我们的整个系统,接下来就让我们来实现一下。
搭建验证服务器
第一步就是最重要的,我们需要搭建一个验证服务器,它是我们进行权限校验的核心,验证服务器有很多的第三方实现,也有Spring官方通过的实现,这里我们使用Spring官方通过的验证服务器。
导入对应依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- oauth依赖不再内置到spring-cloudy依赖中,需要指定对应的版本,且其已经整合到spring-security中了-->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.5.2.RELEASE</version>
</dependency>
修改配置文件。
server:
port: 8500
servlet:
#为了防止一会在服务间跳转导致Cookies打架。(因为所有的服务地址但是localhost,都会存JSESSIONID)
#这里修改一下context-path,这样保存的Cookie会使用指定的路径,就不会和其他服务打架了
#但注意之后的所有请求都得在最前面添加这个路径
context-path: /sso
编写spingSecurity配置类和OAuth2的配置类。
springSecurity配置类:
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();//对密码进行加密的类
auth.inMemoryAuthentication()//直接创建一个用户
.passwordEncoder(bCryptPasswordEncoder).withUser("test").password(bCryptPasswordEncoder.encode("123456")).roles();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated().and().formLogin().permitAll();//使用表单登录
}
@Bean
@Override
//这里我们需要将AuthenticationManager注册为Bean,因为我们要在OAuth2中使用它
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
OAth2配置类:
@EnableAuthorizationServer //开启验证服务器
@Configuration
public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter {
@Resource
private AuthenticationManager manager;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();//对密码进行加密的类
/**
* 这个方法是对客户端进行配置,一个验证服务器可以预设很多个客户端,
* 之后这些指定的客户端就可以按照下面指定的方式进行验证
* @param clients 客户端配置工具
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory() //这里我们直接硬编码创建,当然也可以像Security那样自定义或是使用JDBC从数据库读取
.withClient("web") //客户端名称,随便起就行
.secret(encoder.encode("654321")) //只与客户端分享的secret,随便写,但是注意要加密
.autoApprove(false) //自动审批,这里关闭,要的就是一会体验那种感觉
.scopes("book", "user", "borrow") //授权范围,这里我们使用全部all
.authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
//授权模式,一共支持5种,除了之前我们介绍的四种之外,还有一个刷新Token的模式
//这里我们直接把五种都写上,方便一会实验,当然各位也可以单独只写一种一个一个进行测试
//现在我们指定的客户端就支持这五种类型的授权方式了
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security
.passwordEncoder(encoder) //编码器设定为BCryptPasswordEncoder
.allowFormAuthenticationForClients() //允许客户端使用表单验证,一会我们POST请求中会携带表单信息
.checkTokenAccess("permitAll()"); //允许所有的Token查询请求
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(manager);
//由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式
}
}
然后我们使用测试工具进行测试。
1.首先我们从最简单的客户端模式进行测试,客户端模式只需要提供id和secret即可直接拿到Token,注意需要添加一个grant_type来表明我们的授权方式,默认请求路径为:http://localhost:8500/sso/oauth/token。
测试结果图:
我们可以通访问http://localhost:8500/sso/oauth/check_token来验证我们的Token是否有效。
2.我们进行密码模式的测试,这里的请求参数为username和password,授权模式改为passwod。
在请求头中添加Basic验证信息。
测试结果图:
Token验证(返回用户名):
3.隐式授权模式,这种模式我们需要在验证服务器上进行验证,而不是直接请求Token,验证登录请求地址:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=token。
注意response_type一定要是Token类型,这样才会返回Token,浏览器发起请求后,可以看到SpringSecurity的登录页面。
登录之后会有个错误信息,这是因为登录成功后,验证服务器需要将结果给回客户端,所以需要提供供客户端的回调地址,这样浏览器就会被重定向到指定的回调地址并且请求中回携带Token信息,这里我们随便配置一个回调信息。
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory() //这里我们直接硬编码创建,当然也可以像Security那样自定义或是使用JDBC从数据库读取
.withClient("web") //客户端名称,随便起就行
.secret(encoder.encode("654321")) //只与客户端分享的secret,随便写,但是注意要加密
.autoApprove(false) //自动审批,这里关闭,要的就是一会体验那种感觉
.scopes("book", "user", "borrow") //授权范围,这里我们使用全部all
.redirectUris("localhost:8202/login")
.authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
//授权模式,一共支持5种,除了之前我们介绍的四种之外,还有一个刷新Token的模式
//这里我们直接把五种都写上,方便一会实验,当然各位也可以单独只写一种一个一个进行测试
//现在我们指定的客户端就支持这五种类型的授权方式了
}
进行授权。
最终会将Toekn返回到指定的页面。
4.最安全的授权码模式,这种模式其实流程和隐式授权模式是一样的,当是请求的是Code类型:http:localhost:8500/sso/oauth/authorize?/client_id=web&response_type=code。
在访问此地址依旧会进入回调地址,但是这时给的就是授权码了,而不是直接返回Token。
然后我们可以使用这个授权码和secret来获取Token。
5.最后还有一个是刷新Token用的模式,当我们的Token过期时,我们就可以使用refresh_token来申请一个新的Token,当我们使用授权码模式时,在成功验证以后验证服务器会返回一个refresh_token,如果我们需要刷新一下Token,就执行下面操作。
在SecurityConfiguration配置类中将UserDetailsService注入spring容器中。
@Bean
@Override
public UserDetailsService userDetailsServiceBean() throws Exception {
return super.userDetailsServiceBean();
}
在OAuth2Configuration类中使用UserDetailsService。
@Resource
UserDetailsService service;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.userDetailsService(service)
.authenticationManager(manager);
//由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式
}
进行测试。
redis与分布式(docker模拟集群搭建)
拉取redis文件。
docker pull redis
在云服务器上创建等会需要挂载的目录和文件
- 创建data目录(存放数据文件,包括用于持久化的dump.rdb)
mkdir -p /mydata/redis/data
- 创建配置文件
mkdir -p /mydata/redis/conf
touch /mydata/redis/conf/redis.conf
创建redis容器,运行redis,并进行数据和配置文件的挂载。
docker run -p 3346:6379 --name redis -v /mydata/redis/data:/data \
-v /mydata/redis/conf/redis.conf:/etc/redis/redis.conf \
-d redis redis-server /etc/redis/redis.conf
:左边的表示云服务器的真实端口,右边表示redis容器中的端口。
--name 表示为容器设置的名称,-d 后面的名称表示镜像的名称。
进入对应的容器
docker exec -it 容器对应id或是容器名称 bash
#进入到docker中
redis-cli
#加入容器中的redis中
#也可以直接进入
docker exec -it 容器对应的id或是容器的名称 redis-cli
效果图:
docker 搭建redis集群(使用三个redis搭建集群)
在对应的目录下创建用来挂在数据的目录。
mkdir -p /mydata/redis/cluster/node1/data #集群一
mkdir -p /mydata/redis/cluster/node2/data #集群二
mkdir -p /mydata/redis/cluster/node3/data #集群三
为了方便我们这里就不去手动修改redis.conf,我们这里使用命令我们设置操作。
1.为先创建对应的redis容器。
docker create --name redis-node1 -v /mydata/redis/cluster/node1/data:/data \
-p 3346:6379 redis --cluster-enabled yes \
--cluster-config-file redis-node1.conf
首先间将存储数据目录进行挂载,配置端口,设置集群模式为true,设置集群的配置文件,做集群的时候会将一些配置添加到该配置文件中。(该文件会自动生成)
2.启动该容器。
docker start redis-node1
另一个redis的配置跟redis-node1是一样的,只要将容器名称和挂载路径进行修改即可。
现在我们只是单纯的搭建了两个单独的redis,我们还需要将两者联系起来。
因为我们设置docker中部署,所以docker 会给每一个容器分配一个ip地址,我们需要查看对应的ip才能进行联系。
查看方式。
docker inspect 对应的容器名
ip为IpAddress的参数。
执行命令创建集群。
redis-cli --cluster create 172.17.0.3:6379 172.17.0.4:6379 172.17.0.4:6379 --cluster-replicas 0
这里cluster-replicas表示主从比例,我们设置为0,就说明全部为master,如果我们需要做主从关系的话,也就是不将cluster-replicas的比例设置为0,那我们需要保证有三个以上的master(主节点),不然是无法搭建集群的。(并且搭建集群的最少节点数为3个,就是需要保证有三个master)
错误信息:
按其默认的配置,我们输入"yes" 。
测试集群是否生效
加入对应的容器。
docker exec -it redis-node1 Redis-cli -c #-c必须要加,表示以集群启动,这样在做数据库操作时不会因为插槽的限制而不完成不了操作
因为redis-node1表示负责管理5798插槽的,所以集群就中找到负责管理该插槽的节点进行set操作。
接下来我们去其他节点查看是否将数据插入成功。
我们随便在一个节点中查询对应的值,即使该节点没有需要的值,该集群会自动在节点中查找需要的值。
查询集群节点信息。
cluster nodes
如果需要删除集群的话,我们只需要删除集群中使用节点的集群配置信息redis-node*.conf。
如果需要在集群中添加从节点,我们可以执行对应命令:
redis-cli --cluster add-node 172.17.0.8:6379 --cluster-slave --cluster-master 对应的主节点的id
如果需要提高java代码获取信息的话,我们就需要将各个节点的配置进行挂载,然后修改配置信息,将保护模式关闭,并将绑定Ip注释掉。
集群模式是自带哨兵模式的。(当主节点down掉时,会将其从节点作为新的主节点)
哨兵模式的选举规则
1.首先会根据优先级进行选择,可以在配置文件中进行配置,添加"relica-priority"配置项(默认是100),越小表示的优先级就越高。
2.如果优先级一样,那就选择偏移量最大的。
3.要是还是选不出来,就选择runid(启动时随机生成的)最小的。
实现分布式锁
为了解决电商超买的问题,我们可以通过redis设置分布式锁。
#只有当key值不存在的时候才能进行设置,其实际上是set if no exists的缩写
setnx key value
利用这种特性,我们就可以在不同的服务中实现分布式锁,如果某个服务器加了锁但是卡顿了,或是直接崩溃了,那么这把锁岂不是就永远无法释放了,因此我们可以考虑添加一个过期时间。
set key value EX num NX
这里使用set命令,最后加一个NX表示使用setx的模式,和上面一样,但是可以通过EX设置过期时间,这里设置为num秒,也就是说如果num秒还没释放,那就自动删除。
上锁出现的问题
1.
2.
3.
要解决这个问题,我们可以借助一下Redisson框架,它是Redis官方推荐的java版Redis客户端,它提供的功能非常强大,也非常大,Redisson内部提供一个监控锁的看门狗。它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,它为我们提供了很多中分布式的实现,这里我们尝试使用它的分布式锁的功能。
导入依赖。
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.2.1</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.0</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.75.Final</version>
</dependency>
我们先测试没有加锁的情况。
测试代码:
import redis.clients.jedis.Jedis;
public class Main {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try(Jedis jedis = new Jedis("127.0.0.1", 6379)){
for (int j = 0; j < 100; j++) {
//每个客户端获取a然后增加a的值再写回去,如果不加锁那么肯定会出问题,在每次插入的过程中,其实内部值已经发生改变了,比如说同一时间,其获取到a的值是相同的,也就是说这么多个插入最终只是+1。
int a = Integer.parseInt(jedis.get("a")) + 1;
jedis.set("a", a+"");
}
}
}).start();
}
}
}
结果图:
没有到1000,说明出现错误了, 加上锁试试。
测试代码:
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import redis.clients.jedis.Jedis;
public class Main {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379"); //配置连接的Redis服务器,也可以指定集群
RedissonClient client = Redisson.create(config); //创建RedissonClient客户端
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try(Jedis jedis = new Jedis("127.0.0.1", 6379)){
RLock lock = client.getLock("testLock"); //指定锁的名称,拿到锁对象
for (int j = 0; j < 100; j++) {
lock.lock(); //加锁
int a = Integer.parseInt(jedis.get("a")) + 1;
jedis.set("a", a+"");
lock.unlock(); //解锁
}
}
System.out.println("结束!");
}).start();
}
}
}
效果图:
符合最终的结果。
Mysql与分布式
主从复制
当我们使用Mysql的时候,也可以采用主从复制的策略,它的实现基本和Redis相似,也可以采用增量复制的方式,Mysql会在运行的过程中,会记录二进制日志,所有的DML和DDL操作都会被记录进日志中,主数据库只需要将记录的操作复制给从库,让从库也运行一次,那么就可以实现主从复制,但是注意它不会在一开始进行全量复制,所以最好在开始主从复制之前将数据库的内容保持一致。
和Redis一样,一但我们实现了主从复制,那么就算主库出现故障,从库也能正常提供服务,并且可以实现读写分离等操作,这里我们使用一主一从方式来搭建。
通过docker 拉取镜像,通过设置两个不同的端口,启动两个mysql。
docker run -p 3346:3306 --name main_mysql -e MYSQL_ROOT_PASSWORD=123456 -d mysql
#-p 设置端口进行端口映射,-e编辑mysql root用户的密码,-d表示后太启动
docker run -p 3347:3306 --name slave_mysql -e MYSQL_ROOT_PASSWORD=123456 -d mysql
5.进入容器。
docker exec -it 容器Id或name /bin/bash
#再切目录:
cd /etc/mysql。
如果vim指令不存在,说明没有安装,我们需要进行安装。
apt-get update
apt-get install vim
修改主从mysql的配置 my.cnf。
主表中进行配置:
#如果是在同一个服务器上那就要保证server-id不同,不然会发生冲突
server-id=100
#开启二进制日志功能,因为是mater所以是必要的(名字自己定)
log-bin=mysql-bin
从表中进行配置:
#设置server_id,注意要唯一
server-id=101
#开启二进制日志功能,以备Slave作为其它Slave的Master时使用(子节点可以作为别人的主节点)
log-bin=mysql-slave-bin
#relay_log配置中继日志
relay_log=edu-mysql-relay-bin
重启docker 容器使其配置生效。
docker restart main_mysql
进入mysql。
mysql mysql -uroot -p123456
创建用户并授权允许从库服务连接主库的服务。
#创建一个用户 账号为:slave,密码为:123456
CREATE USER test IDENTIFIED WITH mysql_native_password BY '123456';
#给这个slave用户授权,授权主从复制权限和主从复制的连接
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO test;
如果报以下错误,说明已经存在主从关系了,我们需要先关闭主从关系。
stop slave;
reset master;
刷新一下mysql权限。
flush privileges
查询主库File和Position。
show master status
查看docker 给对应的容器分配的ip。
docker inspect 对应的容器名
进入从数据库,进行对指定对应的主库。
#指定ip,端口,和用户,将对应的mysql数据库设置为该mysql数据库的主节点,我们还需要指定主节点的二进制日志文件和主节点的偏移量和主节点的重连次数
change master to master_host='172.17.0.4', master_user='test', master_password='123456', master_port=3306, master_log_file='mysql-bin.000004', master_log_pos= 861, master_connect_retry=30;
#例子二:
change master to master_host='172.17.0.2', master_user='test', master_password='123456', master_port=3306, master_log_file='mysql-bin.000003', master_log_pos= 157, master_connect_retry=30;
开启主从复制功能。
start replica
使用对应命令 查看从库的状态。
show slave status \G
效果图:
分库分表
在大型的互联网系统中,可能单台的Mysql已经无法满足业务的需求,这时候就需要进行扩容了。
单台主机的硬件资源是存在瓶颈的,不可能无限制地扩展,这时候我们就得通过多台实例来进行容量的横向扩容,我们可以将数据分散储存,让多台主机共同来保存数据。
扩容方法分为有两种。
1.垂直拆分:我们的表和数据库都可以进行垂直拆分的,就是将数据库中所有的表按照业务功能进行拆分到各个数据库中(有点类似微服务),而对于一张表也可以通过外键之类的机制将其拆分为多个表。
2.水平拆分:水平拆分针对的不是表,而是数据,我们可以让很多个具有相同表的数据库存放一部分数据,相当于是将数据分散存储在各个节点上。
那么要实现这样的拆分操作,我们自行去编写代码的工作量是比较大的,因此目前实际上已经有一些解决方案了,比如我们可以使用MyCat(也就是数据库中间插件,相当于挂了一层代理,再通过MyCat进行分库分表操作数据库,只需要连接就可以使用,类似的还有ShardingSphere-Proxy)或是Sharding JDBC(应用程序中直接对SQL语句进行分析,然后转换成分库分表操作,需要我们自己编写一些逻辑代码)
Sharding JDBC
定位为轻量级Java框架,在java的JDBC层提供的额外服务,它使用客户端直连数据库,以Jar包形式提供服务,无需额外部署和依赖,可以理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。
1.适用于如何基于JDBC的ORM框架,如:JPA,Mybatis,Spring JDBC Template或直接使用JDBC。
2.支持任何第三方的数据库连接池,如 DBCP,C3P0,BoneCP,HikariCP。
3.支持任意实现JDBC规范的数据库,目前支持Mysql,Oracle,SQLServer以及任何可以使用JDBC的数据库。
水平拆分实现
导入对应依赖。
<!-- shardingJDBC的依赖-->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.1.1</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
在配置文件中配置数据源。
spring:
shardingsphere:
datasource:
#几个数据就配几个,这里是名称,格式就是名称+数字
names: db0,db1
#为每个数据源单独配置
db0:
#数据源实现类,这里默认使用HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3344/springcloud
username: root
password: 123456
db1:
#数据源实现类,这里默认使用HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3345/springcloud
username: root
password: 123456
到目前配置为止,实际上这些配置都是常规的操作,在编写代码时关注点依然放在业务上,现在我们就来编写配置文件,我们需要告诉ShardingJDBC要如何进行分片,首先明确:现在就是两个数据库都有Test表存放用户数据,我们的目标是将用户信息分别存放到这两个数据库表中。
继续修改配置文件,设置其切片模式。
spring:
shardingsphere:
rules:
sharding:
tables:
#这里填写表的名称,程序中对这张表的所有操作都会采用下面的路由方案
#比如我们上面Mybatis就是对test表进行操作,所以会走下面的路由方案
test:
#这里填写实际的路由节点,比如我们要分两个库,那么就可以把两个数据库都写上去,以及对应的表
#也可以使用表达式,比如下面的可以简写为db$->{0,1}.test
actual-data-nodes: db0.test,db1.test
#这里是分库的策略配置
database-strategy:
#这里选择标准策略,也可以配置复杂策略,基于多个键进行分片
standard:
#参与分片运算的字段,下面的算法会根据这里提供的字段进行运算
sharding-column: id
#这里填写我们下面自定义的算法名称
sharding-algorithm-name: my-alg
sharding-algorithms:
#自定义用户新的算法,名称自定义
my-alg:
#算法类型,官方内置了很多种、
type: MOD
props:
sharding-count: 2
props:
sql-show: true
进行测试。
我们在mapper中编简单的插入语句,对应其进行测试。
在测试类中编写代码。
@SpringBootTest
class ShardingtestApplicationTests {
@Autowired
UserMapper userMapper;
@Test
void contextLoads() {
for(int i = 0; i < 10; i++) {
userMapper.addUser(new User(i,"xxx", "cccc"));
}
}
}
测试结果图:
以为我们自定义的算法是通过取模的结果来存放到不同的数据库中,在图中我们可以发现取模为0时将数据存放到db0中,而取模为1时就将数据存放到db1中。
可以看到所有的SQL语句都有一个Logic SQL(这个就是我们在Mybatis里面写的,是什么就是什么)紧接着下面就是Actual SQL,也就是说每个逻辑SQL最终会根据我们的策略转换为实际SQL,它的id是0,那么实际转换出来的SQL会在db0这个数据源进行插入。
我们查看数据库中的信息。
分表查询和查询操作
现在我们在我们的数据库中有test_0,test_1两张表,表结构一样,但是我们也可以希望能够根据id取模运算的结果分别放到这两个不同的表中,其实和分库差不多。
1.逻辑表:相同结构的水平拆分数据库(表)的逻辑名称,是SQL中表的逻辑标识。如:订单数据根据主键尾数拆分为10张表,分别是t_order_0到t_order_9 ,它们的逻辑表名为t_order。
2.真实表:在水平分的数据库中真实存在的物理表,即上个例子中的t_order_0到t_order_9。
我们创建两个跟test表结构相同的test_0,test_1。
修改配置文件改为分表操作。
spring:
shardingsphere:
rules:
sharding:
tables:
test:
actual-data-nodes: db0.test_$->{0..1}
#现在我们来配置一下分表策略,注意这里是table-strategy上面是database-strategy
table-strategy:
#基本都跟之前是一样的
standard:
sharding-column: id
sharding-algorithm-name: my-alg
sharding-algorithms:
my-alg:
#这里我们演示一下INLINE方式,我们可以自行编写表达式来决定,自己编写表达式
type: INLINE
props:
#比如我们还是希望进行模2计算得到数据该去的表
#只需要给一个最终的表名称就行了test_,后面的数字是表达式取模算出的
#实际上这样写和MOD模式一模一样
algorithm-expression: test_$->{id % 2}
#没错,查询也会根据分片策略来进行,但是如果我们使用的是范围查询,那么依然会进行全量查询
#这个我们后面紧接着会讲,这里先写上吧
allow-range-query-with-inline-sharding: false
props:
sql-show: true
插入数据效果图:
在id去摸为0时就插入test_0,取模为1时就插入test_1。
接下来我们进行范围查询操作,在Mapper层中编写范围查询。
@Mapper
public interface UserMapper {
@Select("select * from test where id between #{startId} and #{endId}")
List<User> getUserById(int startId, int endId);
}
在test层做测试。
@Test
void contextLoads() {
System.out.println(userMapper.getUserById(3,5));
}
运行测试,发生报错。
这是因为默认情况下 allow-range-query-with-inline-sharding配置就是为false,就是不支持范围查询,所以我们要将其设置为true。
重新运行的效果图:
分布式序列算法
在复杂分布式系统中,特别是微服务架构中,往往需要大量的数据和信息进行唯一的标识,随着系统的复杂,数据的增多,分库分表成为了常见的方案,对数据分库分表后需要有一个唯一的Id来标识一条数据后消息(如订单号,交易流水,事件编号等),此时一个能够生成全局唯一Id的系统是非常重要的。
我们之前创建过学生信息表,图书借阅表,图书管理表,所有的信息都会有一个Id作为主键,并且这个Id有以下要求:
1.为了区别其他的数据,这个Id必须是全局唯一的。
2.主键应该尽可能的保持有序,这样会大大提高索引的查询效率。
在我们的分布式系统下有两种方案来解决此问题
1.使用UUID:UUID是由一组32位数的16进制数字随机生成的,我们可以直接使用JDK为我们提供的UUID类来实现。
@Test
void test01() {
System.out.println(UUID.randomUUID().toString());
}
效果图:
UUID的生成速度非常快,可以看到确实是能够保证唯一性,因为每都不一样,而且这样长一串那重复的几率会非常小。但是它并不满足我们上面的第二个要求,也就是说我们需要尽可能的保证有序,而这里我们得到的都是一些无序的Id。
2.雪花算法(Snowflake)
它会生成一个64bit大小的整型的Id,int肯定是装不下的。
可以看到它主要是三个部分组成的,时间+工作机器Id+序列号,时间以毫秒为单位,41个bit位能表示约为70年的时间,时间纪元从2016年11月1日零点开始,可以使用到2086年,工作机器ID其实就是节点Id,每个节点的Id都不同,那么就可以区分出来,10个bit位可以表示最多1024个节点,最后12位就是每个节点的序列号,因此每台机器每毫秒 就可以有4096个序列号。
它兼具了上面所说的唯一性和有序性了,但是依然是有缺点的,第一是时间问题,如果机器时间出现倒退,那么就会导致生成重复的Id,并且节点容量只有1024个,如果是超大规模集群,也是存在隐患的。
修改数据库的id类型,因为是要装下64的数据,所以我们要为其配置bigint类型。
设置mybatis的插入操作。
@Update("insert into test(name, password) values(#{name},#{password} )")
int addUser2(User user);
}
修改配置文件。
spring:
shardingsphere:
datasource:
#几个数据就配几个,这里是名称,格式就是名称+数字
names: db0,db1
#为每个数据源单独配置
db0:
#数据源实现类,这里默认使用HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3344/springcloud
username: root
password: 123456
db1:
#数据源实现类,这里默认使用HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3345/springcloud
username: root
password: 123456
rules:
sharding:
tables:
test:
actual-data-nodes: db0.test,db1.test
#这里还是使用分库策略
database-strategy:
standard:
sharding-column: id
sharding-algorithm-name: my-alg
#这里使用自定义的主键生成策略
key-generate-strategy:
column: id
key-generator-name: my-gen
key-generators:
#这里写我们自定义的主键生成算法
my-gen:
#使用雪花算法
type: SNOWFLAKE
props:
#工作机器ID,保证唯一就行
worker-id: 666
sharding-algorithms:
my-alg:
type: MOD
props:
sharding-count: 2
props:
sql-show: true
测试类代码。
@Test
void contextLoads() {
for(int i = 0; i < 10; i++) {
userMapper.addUser2(new User("aaa", "cccc"));
}
}
效果图:
数据库信息:
如果我们要使用UUID的话,只要在配置文件中将自定义生成主键算法的type改为UUID即可。
读写分离
在从表中的配置文件中设置开启只读模式 read-only=1。(如果你是root的话还是可以入数据的,而普通用户就只能读取了)
配置好主从关系。(前讲过了)
然后修改配置文件。
spring:
shardingsphere:
datasource:
#几个数据就配几个,这里是名称,格式就是名称+数字
names: db0,db1
#为每个数据源单独配置
db0:
#数据源实现类,这里默认使用HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3344/springcloud
username: root
password: 123456
db1:
#数据源实现类,这里默认使用HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3345/springcloud
username: root
password: 123456
rules:
#配置读写分离
readwrite-splitting:
data-sources:
#名称随便写
user-db:
#使用静态类型,动态Dynamic类型可以自动发现auto-aware-data-source-name
type: Static
props:
#配置写库(只能一个)
write-data-source-name: db0
#配置从库(多个,逗号隔开)
read-data-source-names: db1
#负载均衡策略,可以自定义
load-balancer-name: my-load
load-balancers:
#自定义的负载均衡策略
my-load:
type: ROUND_ROBIN
props:
sql-show: true
测试代码:
@Test
void contextLoads() {
for(int i = 0; i < 10; i++) {
userMapper.addUser(new User(i,"aaa", "cccc"));
}
System.out.println(userMapper.getUserById(3, 5));
}
测试效果图:
插入语句全部在主节点数据库中执行,而查询操作都在从节点数据库中操作。
RabbitMQ(消息队列)
我们之前如果需要进行远程调用,那么一般可以通过发送HTTP请求完成,而现在,我们可以使用第二种方式,就是消息队列,它能够将发送的消息放到队列中,当新的消息入列时,会通知接收方进行处理,一般消息发送称为生产者,接收方称为消费者。
这样我们所有的请求都可以直接丢到消息队列中,再由消费者取出,不再是直接连接消费者的形式了,而是加了一个中间商,这也是一种很好的解决方案,并且在高并发的情况下,消息队列也能起到综合的作用,堆积一部分请求,再由消费者来慢慢处理,而不会像直接调用那样请求蜂拥而至。
消息队列的具体实现:
在云服务器上安装和部署(在docker进行)
在docker 中拉去Ribbitmq镜像。
在docker 中运行ribbitmq。
docker run -d -p 5672:5672 -p 15672:15672 -p 25672:25672 --name rabbitmq rabbitmq
查看rabbitmq的状态。
rabbitmqctl status
接着我们还可以将Rabbitmq的管理面板开启,这样就可以在浏览器上进行实时访问和监控了。
rabbitmq-plugins enable rabbitmq_management
开启面板。
账号和密码都为:guest。
给Rabbitmq设置新的用户。
rabbitmqctl add_user 用户名 密码
给予新的用户管理员权限。
rabbitmqctl set_user_tags 用户名 administrator
消息队列的基本原理:
生产者(Publisher)和消费者(Consumer):不用多说了吧。
Channel:我们的客户端连接都会使用一个Channel,再通过Channel去访问到RabbitMQ服务器,注意通信协议不是http,而是amqp协议。
Exchange:类似于交换机一样的存在,会根据我们的请求,转发给相应的消息队列,每个队列都可以绑定到Exchange上,这样Exchange就可以将数据转发给队列了,可以存在很多个,不同的Exchange类型可以用于实现不同消息的模式。
Queue:消息队列本体,生产者所有的消息都存放在消息队列中,等待消费者取出。
Virtual Host:有点类似于环境隔离,不同环境都可以单独配置一个Virtual Host,每个Virtual Host可以包含很多个Exchange和Queue,每个Virtual Host相互之间不影响。
如果出现以下错误,需要在rabbitmq的配置文件进行更改
修改方式为下:
因为是使用docker 容器安装的,所有需要进入容器
docker exec -it rabbitmq /bin/bash
进入目录
cd /etc/rabbitmq/conf.d/
执行命令
echo management_agent.disable_metrics_collector = false > management_agent.disable_metrics_collector.conf
退出容器
exit
重启rabbitmq
docker restart rabbitmq
rabbitmq的使用
1.最简单模式:
(一个生产者->消息队列->一个消费者)
生产者只需要将数据丢入消息队列,二消费者只需要将数据从消息队列中取出,这样就实现了生产者和消费者的消息交互。
创建测试环境。
当前的用户就添加了刚刚我们新建的测试环境。
现在我们来看看交换机。
交换机列表中自动为我们新增了刚刚创建好的预设交换机,一共7个。
第一个交换机是所有虚拟主机都会自带的一个默认交换机,并且此交换机不可能删除,此交换机默认绑定所有的消息队列,如果是通过默认交换机发送消息,那么就会根据消息的"rountKey"(类似IP地址)决定发送给哪个同名的消息队列(是消息队列的名称不是它的routingKey),同时也不能显示地将消息队列绑定或解绑到此交换机。
我们可以看到详细信息中,当前交换机特性是持久化的,也就是说就算机器重启,那么此交换机也会被保留,如果不是持久化,那么一旦重启就会消失,实际上我们在列表中看到D字样就是表示此交换机是持久化的,包括消息队列也是这样的,所有自动生成的交换机都是持久化的。
第二个交换机是个直连的交换机。
这个交换机和我们刚刚介绍的默认交换机类型是一致的,并且也是持久化的,但是我们可以看到它是具有绑定关系的,如果没有指定的消息队列绑定到此交换机上,那么这个交换机会无法正常将信息存放到指定的消息队列中,也是根据对应的routingKey寻找消息队列(但是可以自定义)
创建队列。
在我创建队列的选项中的auto delete的作用是: 需要至少有一个消费者连接到这个队列,之后,一旦所有与这个队列连接的消费断开时,就会自动删除此队列。
通过默认交换机绑定我们创建的队列并将数据传入消息队列中,因为我们默认的交换机是自动绑定的,我们直接传入数据。
现在在queueTest中就存在一条数据了。
获取数据。
在获取数据位置有四种消息的处理方式。
1.Nack message requeue true:拒绝消息,也就是说不会将消息从消息队列取出,并且重新排队,一次可以拒绝多个消息。
2. Ack message requeue false:确认应答,确认后消息会从消息队列中移除,一次可以确认多个消息。
3.Reject message requeue true/false:也是拒绝此消息,但是可以指定是否重新排队。
而我们的通过绑定直接交换机也可以达到将数据传入消息队列中并取出的效果,我们在直接交换机中绑定队列并发送消息到队列中。
在对应的队列中获取消息。
删除队列中的所有消息。
删除此队列。
使用java操作消息队列
导入对应依赖。
<dependency>
<groupId>com.rabbitmq</groupId>
<artifactId>amqp-client</artifactId>
<version>5.14.2</version>
</dependency>
我们来思想一下生产者和消费者,首先是生产者,生产者负责将信息发送给消息队列。
@Test
void contextLoads() {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("127.0.0.1");
factory.setPort(5672);
factory.setUsername("test");
factory.setPassword("123456");
factory.setVirtualHost("/test");
try {
factory.newConnection();
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
}
这里我们可以直接在程序中定义并创建消息队列(实际上是和我们在管理界面创建一样的效果)客户端需要通过连接创建一个新的通道,同一个连接下可以有很多个通道,这样就不用创建很多个连接也能支持分开发送。
@Test
void contextLoads() {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("127.0.0.1");
factory.setPort(5672);
factory.setUsername("test");
factory.setPassword("123456");
factory.setVirtualHost("/test");
//创建连接
try(Connection connection = factory.newConnection();
Channel channel = connection.createChannel()){ //通过Connection创建新的Channel
//声明队列,如果此队列不存在,会自动创建
channel.queueDeclare("queueTest", false, false, false, null);
//将队列绑定到交换机
channel.queueBind("queueTest", "amq.direct", "queuekey");
//发布新的消息,注意消息需要转换为byte[]
channel.basicPublish("amq.direct", "queuekey", null, "Hello World!".getBytes());
}catch (Exception e){
e.printStackTrace();
}
}
其中queueDeclare方法的参数如下:
queue:队列的名称(默认创建后routingKey和队列名称一致)
durable:是否持久化。
exclusive:是否排他,如果一个队列被声明为排他队列,该队列仅对首次声明它的连接可见,并在连接断开时自动删除。排他队列是基于Connection可见,同一个Connection的不同Channel是可以同时访问同一个连接创建的排他队列,并且,如果一个Connection已经声明了一个排他队列,其他的Connection是不允许建立同名的排他队列的,即使该队列是持久化的,一旦Connection关闭或者客户端退出,该排他队列都会自动被删除。
autoDelete:是否自动删除。
arguments:设置队列的其他一些参数,这里我们暂时不需要什么其他参数。
其中queueBind方法参数如下:
queue:需要绑定的队列名称。
exchange:需要绑定的交换机名称。
routingKey:不用多说了吧。
其中basicPublish方法的参数如下:
exchange: 对应的Exchange名称,我们这里就使用第二个直连交换机。
routingKey:这里我们填写绑定时指定的routingKey,其实和之前在管理页面操作一样。
props:其他的配置。
body:消息本体。
接着我们运行测试代码,并在控制面板中测试。
消费者测试。
@Test
void contextLoads() throws IOException, TimeoutException {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("127.0.0.1");
factory.setPort(5672);
factory.setUsername("test");
factory.setPassword("123456");
factory.setVirtualHost("/test");
//这里不使用try-with-resource,因为消费者是一直等待新的消息到来,然后按照
//我们设定的逻辑进行处理,所以这里不能在定义完成之后就关闭连接
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
//创建一个基本的消费者
channel.basicConsume("queueTest", false, (s, delivery) -> {
//delivery里面是消息的一些内容
System.out.println(new String(delivery.getBody()));
//basicAck是确认应答,第一个参数是当前的消息标签,第二个的参数表示
//是否批量处理消息队列中所有的消息,如果为false表示只处理当前消息
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
//basicNack是拒绝应答,最后一个参数表示是否将当前消息放回队列,如果
//为false,那么消息就会被丢弃
//channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, false);
//跟上面一样,最后一个参数为false,只不过这里省了
//channel.basicReject(delivery.getEnvelope().getDeliveryTag(), false);
}, s -> {});
其中basicConsume方法参数如下:
queue - 消息队列名称,直接指定。
autoAck - 自动应答,消费者从消息队列取出数据后,需要跟服务器进行确认应答,当服务器收到确认后,会自动将消息删除,如果开启自动应答,那么消息发出后会直接删除。
deliver - 消息接收后的函数回调,我们可以在回调中对消息进行处理,处理完成后,需要给服务器确认应答。
cancel - 当消费者取消订阅时进行的函数回调,这里暂时用不到。
在springBoot整合消息队列
导入对应依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
在配置文件中修改配置。
spring:
rabbitmq:
addresses: 127.0.0.1
username: test
password: 123456
virtual-host: /test
我们创建一个配置类。
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitConfiguration {
@Bean("directExchange") //定义交换机Bean,可以很多个
public Exchange exchange(){
return ExchangeBuilder.directExchange("amq.direct").build();
}
@Bean("queueTest") //定义消息队列
public Queue queue(){
return QueueBuilder
.nonDurable("queueTest") //非持久化类型
.build();
}
@Bean("binding")
public Binding binding(@Qualifier("directExchange") Exchange exchange,
@Qualifier("queueTest") Queue queue){
//将我们刚刚定义的交换机和队列进行绑定
return BindingBuilder
.bind(queue) //绑定队列
.to(exchange) //到交换机
.with("queuekey") //使用自定义的routingKey
.noargs();
}
}
接下来我们来创建一个生产者,这里我们直接编写在测试用例中:
//RabbitTemplate为我们封装了大量的RabbitMQ操作,已经由Starter提供,因此直接注入使用即可
@Resource
RabbitTemplate template;
@Test
void publisher() {
//使用convertAndSend方法一步到位,参数基本和之前是一样的
//最后一个消息本体可以是Object类型,真是大大的方便
template.convertAndSend("amq.direct", "queueTest", "Hello World!");
}
创建消费者,创建监听器。
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest") //定义此方法为队列queueTest的监听器,一旦监听到新的消息,就会接受并处理
public void test(Message message){
//如果我们Message的类型改为String类型也是可以的,它会自动将我们的数据转换为String类型
System.out.println(new String(message.getBody()));
}
}
效果图:
我们可以往消息队列中提交数据,然后等待消费者返回信息。
@Test
void publisher() {
//使用convertAndSend方法一步到位,参数基本和之前是一样的
//最后一个消息本体可以是Object类型,真是大大的方便
Object queuekey = template.convertSendAndReceive("amq.direct", "queuekey", "Hello World!");
System.out.println("收到消费者的响应");
}
消费者的响应方式,就是通过配置监听器的监听方法的返回值来实现。
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest") //定义此方法为队列queueTest的监听器,一旦监听到新的消息,就会接受并处理
public String test(String message){
System.out.println(message);
return "消费者已经做出响应";
}
}
进行测试。
消息队列处理json数据
如果需要传入的数据为json类型时我们该怎么做呢?
在rabbitmq中配置类中将json数据转换器注入spring中。
@Configuration
public class RabbitConfiguration {
@Bean("jacksonConverter") //直接创建一个用于JSON转换的Bean
public Jackson2JsonMessageConverter converter(){
return new Jackson2JsonMessageConverter();
}
}
在消费者类中指定消息转换器。
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest", messageConverter = "jacksonConverter")
public void receiver(User user){
System.out.println(user);
}
}
进行测试。
因为我们在spring注入json转换器,所以我们可以在测试类中直接给传入数据实现类,其会自动将我们的实现类转换为json类型。
实现类:
@Data
public class User {
int id;
String name;
}
发送消息到消息队列中的测试类。
@Test
void publisher() {
template.convertAndSend("amq.direct", "queuekey",new User());
}
测试结果图:
死信队列
消息队列中的数据,如果迟迟没有消费者来处理,那么就会一直占用消息队列的空间,比如我们模拟一下抢车票的场景,用户下单高铁之后,会进行抢座,然后再进行付款,但是如果用户下单之后并没有及时的付款,这张票不可能一直让该用户占用着,因为你不买别人还要买呢,所以会在一段时间后超时,让这张票可以继续被其他人购买。
这时,我们就可以使用死信队列,将那些用户超时未付款的或是用户主动取消的订单,进行进一步的处理,以下类型的消息都会被判定为死信。
1.消息被拒绝(basic.reject/basic.nack),并且requeue = false。
2.消息TTL过期。
3.队列达到最大值。
那么如何构建这样的一种使用模式呢?实际上本质上就是一个死信交换机+绑定的死信队列,当正常队列中的消息被判定为死信时,会被发送到对应的死信交换机中,死信队列也有对应的消费者去处理消息。
这里我们直接在消息队列配置类中创建一个新的死信队列,并对其进行绑定。
@Bean("directDlExchange")
public Exchange dlExchange(){
//创建一个新的死信交换机
//在这里做配置的话,即使在交换机中没有该交换机,其也会自动被创建
return ExchangeBuilder.directExchange("dlx.direct").build();
}
@Bean("testDlQueue") //创建一个新的死信队列
public Queue dlQueue(){
return QueueBuilder
.nonDurable("testDl")
.build();
}
@Bean("dlBinding") //死信交换机和死信队列进绑定
public Binding dlBinding(@Qualifier("directDlExchange") Exchange exchange,
@Qualifier("testDlQueue") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange)
.with("queueDlKey")
.noargs();
}
再将该队列绑定为其他队列的死信队列。
@Bean("queueTest") //定义消息队列
public Queue queue(){
return QueueBuilder
.nonDurable("queueTest") //非持久化类型
.deadLetterExchange("dlx.direct")
.deadLetterRoutingKey("queueDlKey")
.build();
}
修改消费者类的配置信息,将今天的队列改为死信队列,当死信队列有消息时就进行消费。
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "testDl", messageConverter = "jacksonConverter")
public void receiver(User user){
System.out.println(user);
}
}
测试情况一
当前消息队列中存在一个消息,我们对获取其消息并将其在放到消息队列中,让死信队列处理。
可以发现死信队列处理了这个消息。
测试情况二
在rabbitmq的配置类中进行配置,配置队列中消息的生命周期。
@Bean("queueTest") //定义消息队列
public Queue queue(){
return QueueBuilder
.nonDurable("queueTest") //非持久化类型
.deadLetterExchange("dlx.direct")
.deadLetterRoutingKey("queueDlKey")
.ttl(500)
.build();
}
可以发现对消息队列发送的消息在0.5秒后就被死信队列消费了。
测试第三中情况
在rabbitmq的配置类中对消息队列的长度进行配置。
@Bean("queueTest") //定义消息队列
public Queue queue(){
return QueueBuilder
.nonDurable("queueTest") //非持久化类型
.deadLetterExchange("dlx.direct")
.deadLetterRoutingKey("queueDlKey")
.maxLength(3)//设置消息队列的长度
.build();
}
往消息队列中添加四组数据,我们可以发现第一组数据被死信队列消费了。
工作队列模式
实际上这种模式就非常适合多个工人等待新的任务到来的场景,我们的任务有很多个,一个一个丢到消息队列中,而此时个人有很多个,那么我们就可以将这些任务分配给各个工人,让他们各自负责一些任务,并且做的快的工人还可以多完成一些。(能者多劳)
我们只需要创建多个监听器即可。(这里我们就先创建两个监听器)
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest", messageConverter = "jacksonConverter")
public void receiver1(User user){
System.out.println(user);
}
@RabbitListener(queues = "queueTest", messageConverter = "jacksonConverter")
public void receiver2(User user){
System.out.println(user);
}
}
在队列中添加多个消息,观察消费者的处理情况。
可以发现消费者采用的是轮番的策略,进行消息消费的。
默认情况下,一个消费者可以同时处理250个消息,我们也可以对其进行修改。
在rabbitmq的配置类中条件监听器创建者工厂。
@Resource
private CachingConnectionFactory connectionFactory;
@Bean(name = "listenerContainer")
public SimpleRabbitListenerContainerFactory listenerContainer(){
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setPrefetchCount(1); //将PrefetchCount设定为1表示一次只能取一个
return factory;
}
在消费者类中设置对应的监听器生产者。
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest", messageConverter = "jacksonConverter", containerFactory = "listenerContainer")
public void receiver1(User user){
System.out.println("消费者1处理消息" + user);
}
@RabbitListener(queues = "queueTest", messageConverter = "jacksonConverter", containerFactory = "listenerContainer")
public void receiver2(User user){
System.out.println("消费者2处理消息" + user);
}
}
进行测试。
当我们想同时创建多个功能相同的消费者时,我们只要进行下列配置即可。
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest", messageConverter = "jacksonConverter", concurrency = "count")
public void receiver1(User user){
System.out.println("消费者1处理消息" + user);
}
}
测试效果图:
发布订阅模式
比如我们在购买了云服务器,但是最近快到期了,那么就会给你的手机和邮箱发送消息,告诉你需要续费了,但是手机短信和邮箱发送 并不是同一个业务提供的,但是现在我们又希望能够都去执行,所以就可以用到发布订阅模式,简而言之就是,发布一次,消费多个。
因为我们之前使用的是直连交换机,是一对一的关系,肯定是不行的,我们这里需要用到另一种类型的交换机,叫做fanout(扇出)类型,这是一种广播类型,消息会被广播到所有与此交换机绑定的消息队列中。
在rabbitmq配置类中进行配置,创建多个队列,并将这些对应的队列绑定到扇出交换机上。
@Configuration
public class RabbitConfiguration {
@Resource
private CachingConnectionFactory connectionFactory;
@Bean("fanoutExchange")
public Exchange exchange(){
//注意这里是fanoutExchange
return ExchangeBuilder.fanoutExchange("amq.fanout").build();
}
@Bean("queueTest1") //定义消息队列
public Queue queue1(){
return QueueBuilder
.nonDurable("queueTest1") //非持久化类型
.build();
}
@Bean("queueTest2") //定义消息队列
public Queue queue2(){
return QueueBuilder
.nonDurable("queueTest2") //非持久化类型
.build();
}
@Bean("binding1")
public Binding binding1(@Qualifier("fanoutExchange") Exchange exchange,
@Qualifier("queueTest1") Queue queue){
//将我们刚刚定义的交换机和队列进行绑定
return BindingBuilder
.bind(queue) //绑定队列
.to(exchange) //到交换机
.with("queuekey1") //使用自定义的routingKey
.noargs();
}
@Bean("binding2")
public Binding binding2(@Qualifier("fanoutExchange") Exchange exchange,
@Qualifier("queueTest2") Queue queue){
//将我们刚刚定义的交换机和队列进行绑定
return BindingBuilder
.bind(queue) //绑定队列
.to(exchange) //到交换机
.with("queuekey2") //使用自定义的routingKey
.noargs();
}
@Bean("jacksonConverter") //直接创建一个用于JSON转换的Bean
public Jackson2JsonMessageConverter converter(){
return new Jackson2JsonMessageConverter();
}
}
修改监听器。
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest1", messageConverter = "jacksonConverter")
public void receiver1(User user){
System.out.println("队列一接收到消息" + user);
}
@RabbitListener(queues = "queueTest2", messageConverter = "jacksonConverter")
public void receiver2(User user){
System.out.println("队列二接收到消息" + user);
}
}
测试结果图:
在对应的交换机中没有指定routingKey时发送数据,两个队列都会收到消息。
路由模式
我们可以在绑定时指定想要的routingKey只有生产者发送时指定了对应的routingKey才能到达对应的队列。
当然除了我们之前的一次绑定之外,同一个消息队列可以多次绑定到交换机,并且使用不同的routingKey,这样只要满足其中一个都可以被发送到此消息队列中。
在rabbitmq的配置类中进行配置。
@Configuration
public class RabbitConfiguration {
@Bean("directExchange")
public Exchange exchange(){
return ExchangeBuilder.directExchange("amq.direct").build();
}
@Bean("queueTest")
public Queue queue(){
return QueueBuilder.nonDurable("queueTest").build();
}
@Bean("binding") //使用yyds1绑定
public Binding binding(@Qualifier("directExchange") Exchange exchange,
@Qualifier("queueTest") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange)
.with("key1")
.noargs();
}
@Bean("binding2") //使用yyds2绑定
public Binding binding2(@Qualifier("directExchange") Exchange exchange,
@Qualifier("queueTest") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange)
.with("key2")
.noargs();
}
}
修改监听器。
@Component //注册为Bean
public class TestListener {
@RabbitListener(queues = "queueTest")
public void receiver1(String message) {
System.out.println("队列一接收到消息" + message);
}
}
我们在交换机中添加两条消息,分别通过不同的routingKey。
进行测试,通过不同的routingkey进入了同一个消息队列中 。
主题模式
实际上这种模式就是一种模糊匹配模式,我们可以将routingKey以模糊匹配的方式去进行转发。
我们可以使用*或#来表示:
1.*:表示容易的一个单词。
2.#:表示0个或多个单词。
修改rabbitmq的配置类。
@Configuration
public class RabbitConfiguration {
@Bean("topicExchange") //这里使用预置的Topic类型交换机
public Exchange exchange(){
return ExchangeBuilder.topicExchange("amq.topic").build();
}
@Bean("queueTest")
public Queue queue(){
return QueueBuilder.nonDurable("queueTest").build();
}
@Bean("binding")
public Binding binding2(@Qualifier("topicExchange") Exchange exchange,
@Qualifier("queueTest") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange)
.with("*.test.*")
.noargs();
}
}
在预设的topic交换机中以a.test.c 作为routingKey将数据传入对应的消息队列中。
消费者去消费了此消息。
"#"也是差不多的效果。
除了我们这里使用的默认主题交换机之外,还有一个叫做amq.rabbitmq.trace的交换机。
可以看到它也是topic类型的,那么这个交换机是做什么的呢?实际上这个是用于帮助我们记录和追踪生产者和消费者使用消息队列的交换机,它是一个内部的交换机。
接着我们需要在rabbitmq主机中将/test的追踪功能开启。
rabbitmqctl trace_on -p /test
创建新的消息队列。
将消息队列绑定到amq.rabbitmq.trace交换机上,要将生产者输入的交换机和消费者获取数据的队列全部存放到刚刚那个trace消息队列中。
我们获取trace中的消息,会得到一个交换机和消息队列。
第四种交换机类型
第四种交换机类型header,它是根据头部消息来决定的,在我们发送的消息中是可以携带一些头部消息的(类似于HTTP),我们可以根据这些头部信息来决定路由哪个消息队列中。
修改rabbitmq的配置类。
@Configuration
public class RabbitConfiguration {
@Bean("headerExchange") //注意这里返回的是HeadersExchange
public HeadersExchange exchange(){
return ExchangeBuilder
.headersExchange("amq.headers") //RabbitMQ为我们预置了两个,这里用第一个就行
.build();
}
@Bean("queueTest")
public Queue queue(){
return QueueBuilder.nonDurable("queueTest").build();
}
@Bean("binding")
public Binding binding2(@Qualifier("headerExchange") HeadersExchange exchange, //这里和上面一样的类型
@Qualifier("queueTest") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange) //使用HeadersExchange的to方法,可以进行进一步配置
//.whereAny("a", "b").exist(); 这个是只要存在任意一个指定的头部Key就行
//.whereAll("a", "b").exist(); 这个是必须存在所有指定的的头部Key
.where("test").matches("hello"); //比如我们现在需要消息的头部信息中包含test,并且值为hello才能转发给我们的消息队列
//.whereAny(Collections.singletonMap("test", "hello")).match(); 传入Map也行,批量指定键值对
}
}
将数据传入amq.header交换机,并设置头部信息,进行测试。
查看queueTest队列的消息,可以发现消息队列中的消息被消费了。
docker 搭建rabbitmq集群
下载rabbitmq的管理者版本。
docker pull rabbitmq:3.9.5-management
创建三个rabbitmq。(如果需要查看各自的控制面板的话,我们只需要为每个rabbitmq绑定15672端口)
docker run -d --hostname myRabbit1 --name rabbit1 -p 15672:15672 -p 5672:5672 -e RABBITMQ_ERLANG_COOKIE='rabbitcookie' rabbitmq:3.9.5-management
docker run -d --hostname myRabbit2 --name rabbit2 -p 5673:5672 --link rabbit1:myRabbit1 -e RABBITMQ_ERLANG_COOKIE='rabbitcookie' rabbitmq:3.9.5-management
docker run -d --hostname myRabbit3 --name rabbit3 -p 15673:5672 --link rabbit1:myRabbit1 --link rabbit2:myRabbit2 -e RABBITMQ_ERLANG_COOKIE='rabbitcookie' rabbitmq:3.9.5-management
-e RABBITMQ_ERLANG_COOKIE=‘rabbitcookie’ : 必须设置为相同,因为 Erlang节点间是通过认证Erlang cookie的方式来允许互相通信的。
–link rabbit1:myRabbit1 --link rabbit2:myRabbit2: 不要漏掉,否则会 一直处在 Cluster status of node rabbit@myRabbit3 … 没有反应。
启动完成之后,使用docker ps命令查看运行情况,确保RabbitMQ都已经启动。
将RabbitMQ节点加入到集群。
#进入rabbitmq02容器,重新初始化一下,将02节点加入到集群中
docker exec -it rabbit2 bash
rabbitmqctl stop_app
rabbitmqctl reset
rabbitmqctl join_cluster --ram rabbit@myRabbit1
#参数“--ram”表示设置为内存节点,忽略该参数默认为磁盘节点,@后面的为ip名,以为我们在启动rabbitmq时给其ip设置了新的名字,且我们以一节点作为主节点其他作为从节点。
rabbitmqctl start_app
exit
#进入rabbitmq03容器,重新初始化一下,将03节点加入到集群中
docker exec -it rabbit3 bash
rabbitmqctl stop_app
rabbitmqctl reset
rabbitmqctl join_cluster --ram rabbit@myRabbit1
rabbitmqctl start_app
exit
内存节点将所有的队列、交换器、绑定、用户等元数据定义都存储在内存中;而磁盘节点将元数据存储在磁盘中。单节点系统只允许磁盘类型的节点,否则当节点重启以后,所有的配置信息都会丢失。如果采用集群的方式,可以选择至少配置一个节点为磁盘节点,其余部分配置为内存节点,这样可以获得更快的响应。所以本集群中配置节点一位磁盘节点,节点二和节点三位内存节点。
此时我只是完成了简单的集群,接下来我们还要配置镜像队列(类型主从复制),我们这里在终端中配置,其实也可以直接在控制面板中在admin中配置对应的策略。
#随便进入一个容器
docker exec -it rabbit1 bash
#设置策略匹配所有名称的队列都进行高可用配置,且实现自动同步。
rabbitmqctl set_policy -p / ha "^" '{"ha-mode":"all","ha-sync-mode":"automatic"}'
#查询策略
rabbitmqctl list_policies -p / #查看vhost下的所有的策略(policies )
当主节点中的队列down后从节点中的队列就会被使用,且在主节点中的队列恢复以后,其会变成从队列来继续使用。
1.策略名称,我们命名为ha(高可用);
2.-p / 设置vhost,可以使用rabbitmqctl list_policies -p / 查看该vhost 下所有的策略(policies )。
3.队列名称的匹配规则,使用正则表达式表示;
4.为镜像队列的主体规则,是json字符串,分为三个属性:ha-mode | ha-params | ha-sync-mode,分别的解释如下:
ha-mode:镜像模式,分类:all/exactly/nodes,all存储在所有节点;exactly存储x个节点,节点的个数由ha-params指定;nodes指定存储的节点上名称,通过ha-params指定;
ha-params:作为参数,为ha-mode的补充;
ha-sync-mode:镜像消息同步方式:automatic(自动),manually(手动);
消息队列中间件
由于使用不同的消息队列,我们不能保证系统相同,为了注重逻辑,springCloud Stream它能够屏蔽底层实现,我们使用统一的消息队列操作方式就能操作多种不同类型的消息队列。
它屏蔽了Rabbitmq底层操作,让我们使用统一 的Input和Output形式。以Binder为中间件,这样我们就算切换了不同的消息队列,也无需修改代码,而具体某种消息队列的底层实现是交给Stream在做的。
创建两个模块,一个是生产者一个是消费者。
导入对应依赖。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
<version>3.2.4</version>
</dependency>
在生产者的配置文件中进行配置。
server:
port: 8001
spring:
cloud:
stream:
binders: #此处配置要绑定的rabbitmq服务的配置信息
cloud-server: #绑定的名称,自定义一个就行
type: rabbit #消息主件类型,这里使用rabbit,所以这粒就填写rabbit
environment: #服务器相关信息,以为是自定义的名称,所以下面会爆红,不影响运行
spring:
rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
virtual-host: /
bindings:
test-out-0: #自定义的绑定名称
destination: test.exchange #目的地,就是交换机的名称。如果不存在就会创建
在生产者的controller层编写发送消息的controller。
@RestController
public class PublisherController {
@Resource
StreamBridge streamBridge;
public String publish() {
//第一个次数其实就是Rabbitmq的交换机名称
//这个交换机的名称有些规则
//输入: <名称>-in-<index>
//输出: <名称>-out-<index>
//这里我们使用输出方式,来将数据发送到消息队列,注意这里的名称会和之后的消费者Bean名称进行对应
streamBridge.send("test-out-0", "hello world");
return "发送成功"+new Date();
}
}
编写消费者配置文件。
server:
port: 8001
spring:
cloud:
stream:
binders: #此处配置要绑定的rabbitmq服务的配置信息
cloud-server: #绑定的名称,自定义一个就行
type: rabbit #消息主件类型,这里使用rabbit,所以这粒就填写rabbit
environment: #服务器相关信息,以为是自定义的名称,所以下面会爆红,不影响运行
spring:
rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
virtual-host: /
bindings:
test-in-0:
destination: test.exchange
创建一个类用于做消费者的配置。
@Component
public class TestConsumer {
@Bean("test")//这里的注入名要和刚刚生产者的绑定名称中的名称相同
public Consumer<String> consumer() {
return System.out::println;
}
}
启动测试。
其自动帮我们创建了对应的消息队列。
消息发送以后,消费者去去消费了这个消息。
这样我们就通过springCloud Stream屏蔽底层Rabbitmq来直接进行消息的操作了。