单体架构缺点:
- 团队协作成本高:试想一下,你们团队数十个人同时协作开发同一个项目,由于所有模块都在一个项目中,不同模块的代码之间物理边界越来越模糊。最终要把功能合并到一个分支,你绝对会陷入到解决冲突的泥潭之中。
- 系统发布效率低:任何模块变更都需要发布整个系统,而系统发布过程中需要多个模块之间制约较多,需要对比各种文件,任何一处出现问题都会导致发布失败,往往一次发布需要数十分钟甚至数小时。
- 系统可用性差:单体架构各个功能模块是作为一个服务部署,相互之间会互相影响,一些热点功能会耗尽系统资源,导致其它服务低可用。
SpringCloud依托于SpringBoot的自动装配能力,大大降低了其项目搭建、组件使用的成本。
服务拆分原则、工程结构
- 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高。
- 低耦合:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖,或者依赖接口的稳定性要强。
拆分方式
- 纵向拆分:按照项目的功能模块来拆分
- 横向拆分:是看各个功能模块之间有没有公共的业务部分,如果有将其抽取出来作为通用服务。
工程结构:
- 独立project
- Maven聚合
独立项目和Maven聚合是两种不同的项目结构,它们在软件开发中有各自的应用场景。
- 独立项目:独立项目是指不受其他项目的影响而进行选择的项目,也就是说,这个项目的接受既不要求也不排除其他的投资项目。独立项目的特点决定了在对独立项目进行评价时有以下几个特性:每个项目只需进行自身经济性的评价。
- Maven聚合:Maven聚合工程,也称为多模块Maven工程,允许在一个父Maven工程下创建多个子模块。这些子模块可以相互依赖,共同构建一个大型的项目或系统。每个模块可以独立编译、测试和打包,从而提高了项目的模块化程度,降低了各个模块之间的耦合度,有利于项目的维护和扩展。
对于复杂的Maven项目,一般建议采用多模块的方式来设计开发,便于后期维护管理。但是构建项目时,如果每次都需要按模块一个一个进行构建会十分麻烦,而Maven的聚合功能就可以很好的解决这个问题,当用户对聚合模块执行构建任务时,会对所有被其聚合的模块自动地依次进行构建任务。
RestTemplateRestTemplate
是 Spring 提供的用于访问 Rest 服务的客户端,RestTemplate
提供了多种便捷访问远程 Http 服务的方法,能够大大提高客户端的编写效率。
调用RestTemplate的API发送请求,常见方法有:
- getForObject:发送Get请求并返回指定类型对象
- PostForObject:发送Post请求并返回指定类型对象
- put:发送PUT请求
- delete:发送Delete请求
- exchange:发送任意类型请求,返回ResponseEntity
首先,你需要在你的项目中添加 RestTemplate
的 Bean:
@Bean//可以写在application启动类
public RestTemplate restTemplate() {
return new RestTemplate();
}
在需要使用restTemplate的类中注入rest template,但是不推荐,建议使用构造器注入的方式
接下来,你就可以使用 RestTemplate
的方法(如 getForObject
、postForObject
等)来调用其他服务的接口了。例如,你可以这样调用一个 GET 接口:
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
"http://localhost:8081/items?ids={ids}",//请求路径
HttpMethod.GET,//请求方式
null,//请求实体,这里是get,所以不用
new ParameterizedTypeReference<List<ItemDTO>>() {
},//返回值类型
Map.of("ids", CollUtil.join(itemIds, ","))//URL参数,指定路径的ids的值
);
Nacos和eureka的区别
- nacos与eureka的共同点
- 都支持服务注册和服务拉取
- 都支持服务提供者心跳方式做健康检测
- nacos与eureka的区别
- nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
- 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
- nacos支持服务列表变更的消息推送模式,服务列表更新更及时
- nacos集群默认采用ap方式,当集群中存在非临时实例时,采用cp模式;eureka采用ap方式
Nacos启动nacos
D:\idea\projects\nacos\bin,有启动代码,cmd,然后cv
服务注册
- 引入Nacos discovery依赖
<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
#为什么这里没有版本号
#因为父工程有spring cloud alibaba的依赖,这个依赖里面有Nacos的版本号
- 配置Nacos地址
spring:
application:
name: item-service # 服务名称
cloud:
nacos:
server-addr: localhost:8848 # nacos地址
配置多实例的不同端口号
服务发现
服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用
@Service
public class ItemServiceClient {
private final RestTemplate restTemplate;
private final DiscoveryClient discoveryClient;
@Autowired
public ItemServiceClient(RestTemplate restTemplate, DiscoveryClient discoveryClient) {
this.restTemplate = restTemplate;
this.discoveryClient = discoveryClient;
}
//这个构造可以在类上使用@RequiredArgsConstructor注解(因为都final)
public String getItemById(Long id) {
// 获取服务实例列表
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
if (instances.isEmpty()) {
throw new RuntimeException("No instances available for item-service");
}
// 选择一个实例(这里简单地选择随机负载均衡策略)
ServiceInstance instance = instances.get(new Random().nextInt(instances.size()));
String url = instance.getUri().toString() + "/items/" + id;
// 使用RestTemplate进行服务调用
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
return response.getBody();
}
}
DiscoveryClient和OpenFeign的优缺点DiscoveryClient
和 OpenFeign
通常是在 Spring Cloud 微服务架构中使用的两个组件,它们各自有不同的用途和特点。DiscoveryClient
是 Spring Cloud 提供的一个接口,用于从服务发现机制中获取服务实例的信息。它通常与 Eureka、Consul 或 Zookeeper 这样的服务注册与发现组件一起工作。通过 DiscoveryClient
,服务可以获取到其他服务的地址、端口以及其他注册信息,从而实现服务间的解耦和动态服务路由。OpenFeign
是一个声明式的、模板化的 HTTP 客户端,用于简化微服务之间的 HTTP 请求。OpenFeign
允许开发者通过接口加注解的方式,轻松实现服务间的调用,并提供了负载均衡、服务熔断等机制。
DiscoveryClient 优缺点
优点:
- 服务发现: 自动从服务注册中心获取服务实例信息,便于服务间的调用。
- 动态性: 当服务实例发生变化时(如服务启动、关闭或故障),能够动态更新服务列表。
- 集成性: 与 Spring Cloud 生态系统的其他组件(如 Ribbon、Hystrix)集成良好。也有负载均衡
缺点:
- 依赖性: 需要配合服务注册中心使用,如 Eureka 或 Consul。
- 局限性: 功能相对单一,主要关注服务发现,不直接提供服务调用的实现。
OpenFeign 优缺点
优点:
- 声明式服务调用: 通过接口加注解的方式定义服务消费者,简化了服务间的 HTTP 调用。
- 负载均衡: 内置了负载均衡的机制,如与 Ribbon 结合使用。
- 服务熔断: 可以集成 Hystrix 进行服务熔断和降级,提高系统的稳定性。
- 模板化: OpenFeign 通过其注解和配置,提供了请求和响应处理的模板化,减少了样板代码。
缺点:
- 性能开销: 由于
OpenFeign
的封装和动态代理机制,可能会有一些性能开销。 - 调试难度: 由于服务调用是通过代理实现的,当出现问题时可能需要更多的工具和知识来进行调试。
在选择使用DiscoveryClient
和OpenFeign
时,应根据具体的业务需求和系统架构来决定。
OpenFeign(快速使用,cart购物车要调用item)1. 添加依赖
首先,您需要在项目的pom.xml
文件中添加OpenFeign和loadbalance的依赖。
<!--openFeign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--负载均衡器-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
2. 启用FeignClients
在您的Spring Boot应用程序的主类或配置类上添加@EnableFeignClients
注解,以启用Feign客户端。
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@EnableFeignClients
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(YourApplication.class, args);
}
}
3. 创建Feign客户端接口
创建一个接口,并在接口上使用@FeignClient
注解来指定调用的服务名称。在接口内部,定义方法来映射到远程服务的具体API。
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "item-service")//服务名称
public interface ItemFeignClient {
@GetMapping("/items")// 请求方式、请求路径
List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
}
- @RequestParam(“ids”) Collection ids :声明请求参数
- List:返回值类型
4. 使用Feign客户端
在需要的地方注入ItemFeignClient
并调用定义好的方法。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class CartService {
@Autowired
private ItemFeignClient itemFeignClient;
public String fetchDataFromRemoteService() {
return itemFeignClient.queryItemByIds(List.of(1,2,3);
}
}
5. 配置Feign(选用)
您可能还需要配置Feign的日志级别、超时设置等。这可以在application.properties
或application.yml
文件中完成。
# application.properties
logging.level.package.of.your.feign.client=DEBUG
feign.client.config.default.connectTimeout=5000
feign.client.config.default.readTimeout=5000
OpenFeign(底层原理和使用连接池优化)OpenFeign 底层确实使用 HTTP 客户端来发送请求,但它支持连接池。默认情况下,OpenFeign 使用的是 JDK 的 HttpURLConnection
,而 HttpURLConnection
本身并不提供连接池的功能。但是,OpenFeign 允许您集成其他 HTTP 客户端库,如 Apache HttpClient 或 OkHttp,这些库支持连接池。
集成 Apache HttpClient
要在 OpenFeign 中使用 Apache HttpClient 并启用连接池,您需要添加相应的依赖,并在配置中指定使用 Apache HttpClient。
- 添加依赖
在pom.xml
文件中添加 Apache HttpClient 的依赖。
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
</dependency>
- 配置 Apache HttpClient
在application.properties
或application.yml
文件中配置 Apache HttpClient。
# application.properties
feign.httpclient.enabled=true
也可以自定义连接池设置,可以创建一个 Apache HttpClient 的 Bean。
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class HttpClientConfig {
@Bean
public CloseableHttpClient httpClient() {
return HttpClients.custom()
// 自定义连接池设置
.build();
}
}
集成 OkHttp
- 添加依赖
在pom.xml
文件中添加 OkHttp 的依赖。
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
- 配置 OkHttp
在配置文件中启用 OkHttp。
# application.properties
feign.okhttp.enabled=true
也可以自定义 OkHttp 的配置。
import okhttp3.OkHttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OkHttpConfig {
@Bean
public OkHttpClient okHttpClient() {
return new OkHttpClient.Builder()
// 自定义连接池设置
.build();
}
}
通过集成这些 HTTP 客户端库,OpenFeign 能够利用连接池,从而提高性能并优化资源使用。选择合适的 HTTP 客户端库取决于您的具体需求和偏好。
OpenFeign(服务抽取)cart需要调用item查询,所以写了一个Feign客户端接口来调用item的服务
其他的module也可能要用
两种抽取思路:
- 思路1:抽取到微服务之外的公共module,抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高
- 思路2:每个微服务自己抽取一个module,抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低
- 创建module(名为api-module);和拆分的每一个服务module平级
- 引入依赖
- 将之前实现的Feign客户端移动该module下,还有这个Feign使用到的实体类,比如说调用item的服务,你需要item的实体类
- 需要使用该Feign客户端(例子中的item查询功能的)引入module(api-module)依赖
- 此时会发生报错;因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包(也就是新建的module)下,扫描不到ItemClient。
解决方法:
在需要使用module的Feign客户端的application的@EnableFeignClients(basePackages=“com.hmall.api.client”)注解声明扫描包
OpenFeign(日志)OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。而且其日志级别有4级:
- NONE:不记录任何日志信息,这是默认值。
- BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
- HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据
Feign默认的日志级别就是NONE,所以默认我们看不到请求日志。
步骤
- 在hm-api模块下新建一个配置类,定义Feign的日志级别
package com.hmall.api.config;
import feign.Logger;
import org.springframework.context.annotation.Bean;
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.FULL;
}
}
- 局部生效:在api-module某个FeignClient(Feign客户端)中配置,只对当前FeignClient生效
@FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)
//声明要调用的服务名称和日志级别
- 全局生效:在application启动类的@EnableFeignClients中配置,针对该服务module的调用的所有FeignClient生效
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)
网关(概念和快速入门)网关就是网络的关口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验
前端请求不能直接访问微服务,而是要请求网关:
- 网关可以做安全控制,也就是登录身份校验,校验通过才放行
- 通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去
在SpringCloud当中,提供了两种网关实现方案:
- Netflix Zuul:Netflix出品,基于Servlet阻塞式编程。目前已经淘汰
- SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强
快速入门
网关本身也是一个独立的微服务,因此也需要创建一个模块开发功能。大概步骤如下:
- 创建网关微服务
- 引入SpringCloudGateway、NacosDiscovery依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>hmall</artifactId>
<groupId>com.heima</groupId>
<version>1.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>hm-gateway</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<!--common-->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-common</artifactId>
<version>1.0.0</version>
</dependency>
<!--网关-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos discovery-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--负载均衡-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 编写启动类
package com.hmall.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
- 配置网关路由
server:
port: 8080
spring:
application:
name: gateway
cloud:
nacos:
server-addr: localhost:8848
gateway:
routes:
- id: item # 路由规则id,自定义,唯一,建议与微服务的名称保持一致
uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 这里是以请求路径作为判断规则
- id: cart
uri: lb://cart-service
predicates:
- Path=/carts/**
- id: user
uri: lb://user-service
predicates:
- Path=/users/**,/addresses/**
- id: trade
uri: lb://trade-service
predicates:
- Path=/orders/**
- id: pay
uri: lb://pay-service
predicates:
- Path=/pay-orders/**
路由规则属性
四个属性含义如下:
- id:路由的唯一标示
- predicates:路由断言,其实就是匹配条件
- filters:路由过滤条件,对请求或响应作特殊处理
- uri:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问
SpringCloudGateway中支持的路由断言类型有很多:
过滤器规则:
如果这里附近出现user context,他其实是一个ThreadLocal的实现
网关(登录校验)
- 如何在网关转发到微服务之前进行登录校验
- 网关如何将用户信息传递给微服务
- 如何在微服务之间传递用户信息
都是通过修改HTTP请求头,但是实现的方式不一样
- 客户端请求进入网关后由HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler去处理。
- WebHandler则会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(后面称为Filter)。
- 图中Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为pre和post两部分,分别会在请求路由到微服务之前和之后被执行。
- 只有所有Filter的pre逻辑都依次顺序执行通过后,请求才会被路由到微服务。(最后一个Filter是NettyRoutingFilter)
- 微服务返回结果后,再倒序执行Filter的post逻辑。
- 最终把响应结果返回。
我们需要定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter之前
如何网关内自定义过滤器?
网关过滤器链中的过滤器有两种:
- GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.
- GlobalFilter:全局过滤器,作用范围是所有路由,不可配置。
package org.scnu.movie.entity;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.Map;
public class JwtUtils {
private static String signKey = "catjwt";
private static Long expire = 43200000L;
/**
* 生成JWT令牌
* @param claims JWT第二部分负载 payload 中存储的内容
* @return
*/
public static String generateJwt(Map<String, Object> claims){
String jwt = Jwts.builder()
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, signKey)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
return jwt;
}
/**
* 解析JWT令牌
* @param jwt JWT令牌
* @return JWT第二部分负载 payload 中存储的内容
*/
public static Claims parseJWT(String jwt){
Claims claims = Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(jwt)
.getBody();
return claims;
}
}
package com.hmall.gateway.filter;
import com.hmall.common.exception.UnauthorizedException;
import com.hmall.common.utils.CollUtils;
import com.hmall.gateway.config.AuthProperties;
import com.hmall.gateway.util.JwtTool;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final JwtTool jwtTool;
//这个jwtTool的实现可以参考上面的代码
private final AuthProperties authProperties;//这个是从配置中获取要排除的路径的一个实体类
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
//由 Spring Framework 提供的一个工具类,用于解析和匹配 Ant 风格的路径表达式。
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1.获取Request
ServerHttpRequest request = exchange.getRequest();
// 2.判断是否不需要拦截
if(isExclude(request.getPath().toString())){//自定义方法
// 无需拦截,直接放行
return chain.filter(exchange);
}
// 3.获取请求头中的token
String token = null;
List<String> headers = request.getHeaders().get("authorization");
//因为允许一个key对应多个值,所以指定“authorization”key后,可能返回多个值,所以使用List
if (!CollUtils.isEmpty(headers)) {
token = headers.get(0);//因为我们知道这个只有1个value,所以直接取第一个
}
// 4.校验并解析token
Long userId = null;
try {
userId = jwtTool.parseToken(token);
} catch (UnauthorizedException e) {
// 如果无效,拦截
ServerHttpResponse response = exchange.getResponse();
response.setRawStatusCode(HttpStatus.UNAUTHORIZED);
//上面一句等同于response.setRawStatusCode(401);
return response.setComplete();//到这里中指,后续的拦截器不会执行了,返回值是Mono
}
// TODO 5.如果有效,传递用户信息
System.out.println("userId = " + userId);
//修改这个请求头,往请求头填写userId
ServerWebExchange newexchange = exchange.mutate()
.request(builder -> builder.header("user_id",userId.toString()))
.build();
// 6.放行
return chain.filter(newexchange);
}
private boolean isExclude(String antPath) {
for (String pathPattern : authProperties.getExcludePaths()) {
if(antPathMatcher.match(pathPattern, antPath)){
return true;
}
}
return false;
}
@Override
public int getOrder() {
return 0;
}
}
现在对于微服务集群来说,前端的返回信息就有userId了,但是每个微服务都有获取登录用户的需求,因此拦截器我们直接写在hm-common中,并写好自动装配。这样微服务只需要引入hm-common就可以直接具备拦截器功能,无需重复编写。
import java.util.HashMap;
import java.util.Map;
/**
* ThreadLocal 工具类
*/
@SuppressWarnings("all")
//用于告诉编译器忽略特定类型的警告
public class ThreadLocalUtil {
//提供ThreadLocal对象,
private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();
//根据键获取值
public static <T> T get(){
return (T) THREAD_LOCAL.get();
}
//存储键值对
public static void set(Object value){
THREAD_LOCAL.set(value);
}
//清除ThreadLocal 防止内存泄漏
public static void remove(){
THREAD_LOCAL.remove();
}
}
package com.hmall.common.interceptor;
public class UserInfoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的用户信息
String userInfo = request.getHeader("user-id");
// 2.判断是否为空
if (StrUtil.isNotBlank(userInfo)) {
// 不为空,保存到ThreadLocal
ThreadLocalUtil.set(userInfo);
//userInfo是一个工具类
}
// 3.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
ThreadLocalUtil.move();
}
}
package com.hmall.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor());
//.addPathPatterns("/**") // 对所有路径进行拦截
//.excludePathPatterns("/user/login"); // 对"/user/login"路径不进行拦截
//registry.addInterceptor(new MyInterceptor2());
// 你可以继续添加更多的拦截器
}
}
需要注意的是,这个配置类默认是不会生效的,因为它所在的包是com.hmall.common.config,与其它微服务的扫描包不一致,无法被扫描到,因此无法生效;使用@ComponentScan注解
- **使用basePackages属性:**指定要扫描的包的名称
@SpringBootApplication
@ComponentScan(basePackages = {"com.example.package1", "com.example.package2"})
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
- **使用basePackageClasses属性:**指定包含组件的类或接口所在的类,Spring会扫描这些类所在的包
@SpringBootApplication
@ComponentScan(basePackageClasses = {ClassInTargetPackage1.class, ClassInTargetPackage2.class})
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
最后,所有要使用拦截器的微服务模块在pom文件上添加common模块的依赖
OpenFeign传递用户前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。
但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。
由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
因为微服务之间调用是基于OpenFeign来实现的,所以需要实现OpenFeign发起的请求自动携带登录用户信息
要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor
所有由OpenFeign发起的请求都会先调用拦截器处理请求
这个拦截器接口应该和OpenFeign放在同一个module下(看OpenFeign的服务抽取);(注意,和登录校验的拦截器不是一个module)
由于FeignClient全部都是在hm-api模块,因此我们在hm-api模块的com.hmall.api.config.DefaultFeignConfig(这个配置类在OpenFeign日志类章节)中编写这个拦截器(加一个Bean):
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 获取登录用户
Long userId = ThreadLocal.get();
//这里需要引入包含ThreadLocal的模块依赖,也就是common模块
//这个get函数写的很空,需要具体问题具体实现,主要就是类型不平配的问题
if(userId == null) {
// 如果为空则直接跳过
return;
}
// 如果不为空则放入请求头中,传递给下游微服务
template.header("user-id", userId.toString());
}
};
}
别忘了,这个配置类要加到Application
Nacos统一配置管理将配置交给nacos管理的步骤
- 在nacos中添加配置文件
spring:
datasource:
url: jdbc:mysql://${hm.db.host:192.168.150.101}:${hm.db.port:3306}/${hm.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${hm.db.un:root}
password: ${hm.db.pw:123}
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
global-config:
db-config:
update-strategy: not_null
id-type: auto
#注意这里的jdbc的相关参数并没有写死,例如:
- 数据库ip:通过${hm.db.host:192.168.150.101}配置了默认值为192.168.150.101,同时允许通过${hm.db.host}来覆盖默认值
- 数据库端口:通过${hm.db.port:3306}配置了默认值为3306,同时允许通过${hm.db.port}来覆盖默认值
- 数据库database:可以通过${hm.db.database}来设定,无默认值
#读取nacos配置文件的微服务需要设定配置文件中的变量值(也可以使用默认值)
logging:
level:
com.hmall: debug
pattern:
dateformat: HH:mm:ss:SSS
file:
path: "logs/${spring.application.name}"
knife4j:
enable: true
openapi:
title: ${hm.swagger.title:黑马商城接口文档}
description: ${hm.swagger.description:黑马商城接口文档}
email: ${hm.swagger.email:zhanghuyi@itcast.cn}
concat: ${hm.swagger.concat:虎哥}
url: https://www.itcast.cn
version: v1.0.0
group:
default:
group-name: default
api-rule: package
api-rule-resources:
- ${hm.swagger.package}
hm:
db:
host: 192.168.1.101
port: 3306
database: my_database
un: my_username
pw: my_password
#写在默认的配置文件就好(applicaiton.yml)
- 在微服务中引入nacos的config依赖
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
- 在微服务中添加bootstrap.yml,配置nacos地址,当前环境,服务名称,文件后缀名。这些决定了程序启动时去nacos读取哪个文件,因为
spring:
application:
name: cart-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.150.101 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
然后把application.yml文件中重复的配置信息删除掉
nacos热更新
Nacos的配置热更新能力了,分为两步:
- 在Nacos中添加配置
- 在微服务读取配置
配置的文件名格式:
[服务名]-[spring.active.profile].[后缀名]
文件名称由三部分组成:
- 服务名:我们是购物车服务,所以是cart-service
- spring.active.profile:就是spring boot中的spring.active.profile,可以省略,则所有profile共享该配置
- 后缀名:例如yaml
hm:
cart:
maxAmount: 1 # 购物车商品数量上限
- 方式1:cart-service服务模块创建一个配置读取类:
package com.hmall.cart.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
private Integer maxAmount;
}
//实际上,这个配置读取类和这个从配置文件中获取变量值的实体类功能上没有多大区别;
但是为了方便性(获取配置文件中的一系列变量值),其添加了ConfigurationProperties注解;让他变成了配置类
在要使用这个变量值的地方进行注入
最好使用构造器注入(@RequiredArgsConstructor注解)
- 方式2:直接在使用变量值的类上加上注解:@RefreshScope和在变量上加@Value注解
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Service;
@Service
@RefreshScope
public class CartService {
@Value("${hm.cart.maxAmount:0}") // 默认值为0
private Integer maxAmount;
}
nacos配置共享
微服务启动时,会去nacos读取多个配置文件,例如:
- [spring.application.name]-[spring.profiles.active].yaml,例如:userservice-dev.yaml
- [spring.application.name].yaml,例如:userservice.yaml
而[spring.application.name].yaml不包含环境,因此可以被多个环境共享。
- 在nacos中添加一个userservice.yaml文件:
- 在user-service服务中,修改PatternProperties类,读取新添加的属性:
在user-service服务中,修改UserController,添加一个方法: - 修改UserApplication2这个启动项,改变其profile值:2使用的是环境test,1使用的环境是dev
进行访问,发现两个都有envSharedValue - 配置的优先级:
雪崩问题查询购物车的业务,假如商品服务业务并发较高,占用过多Tomcat连接。可能会导致商品服务的所有接口响应时间增加,延迟变高,甚至是长时间阻塞直至查询失败。
此时查询购物车业务需要查询并等待商品查询结果,从而导致查询购物车列表业务的响应时间也变长,甚至也阻塞直至无法访问。而此时如果查询购物车的请求较多,可能导致购物车服务的Tomcat连接占用较多,所有接口的响应时间都会增加,整个服务性能很差, 甚至不可用。
依次类推,整个微服务群中与购物车服务、商品服务等有调用关系的服务可能都会出现问题,最终导致整个集群不可用。
雪崩问题解决方案(服务保护方案)
- 请求限流,降低了并发上限
服务故障最重要原因,就是并发太高!解决了这个问题,就能避免大部分故障。当然,接口的并发不是一直很高,而是突发的。因此请求限流,就是限制或控制接口访问的并发流量,避免服务因流量激增而出现故障。
请求限流往往会有一个限流器,数量高低起伏的并发请求曲线,经过限流器就变的非常平稳。这就像是水电站的大坝,起到蓄水的作用,可以通过开关控制水流出的大小,让下游水流始终维持在一个平稳的量。 - 线程隔离,降低了可用资源数量;
当一个业务接口响应时间长,而且并发高时,就可能耗尽服务器的线程资源,导致服务内的其它接口受到影响。所以我们必须把这种影响降低,或者缩减影响的范围。线程隔离正是解决这个问题的好办法。
为了避免某个接口故障或压力过大导致整个服务不可用,我们可以限定每个接口可以使用的资源范围,也就是将其“隔离”起来。
如图所示,我们给查询购物车业务限定可用线程数量上限为20,这样即便查询购物车的请求因为查询商品服务而出现故障,也不会导致服务器的线程资源被耗尽,不会影响到其它接口。 - 服务熔断,降低了服务的完整度,部分服务变的不可用或弱可用。
线程隔离虽然避免了雪崩问题,但故障服务(商品服务)依然会拖慢购物车服务(服务调用方)的接口响应速度。而且商品查询的故障依然会导致查询购物车功能出现故障,购物车业务也变的不可用了。
所以,我们要做两件事情:
- 编写服务降级逻辑:就是服务调用失败后的处理逻辑,根据业务场景,可以抛出异常,也可以返回友好提示或默认数据。
- 异常统计和熔断:统计服务提供方的异常比例,当比例过高表明该接口会影响到其它服务,应该拒绝调用该接口,而是直接走降级逻辑。
Sentinel(服务保护技术)请求限流,线程隔离Sentinel是阿里巴巴开源的一款服务保护框架,目前已经加入SpringCloudAlibaba中。
Sentinel 的使用可以分为两个部分:
- 核心库(Jar包):不依赖任何框架/库,能够运行于 Java 8 及以上的版本的运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持。在项目中引入依赖即可实现服务限流、隔离、熔断等功能。
- 控制台(Dashboard):Dashboard 主要负责管理推送规则、监控、管理机器信息等。
- 下载jar包:https://github.com/alibaba/Sentinel/releases
- 运行,将jar包放在任意非中文、不包含特殊字符的目录下,重命名为sentinel-dashboard.jar
- 然后运行如下命令启动控制台:
java -Dserver.port=8090 -Dcsp.sentinel.dashboard.server=localhost:8090 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar
https://github.com/alibaba/Sentinel/wiki/%E5%90%AF%E5%8A%A8%E9%85%8D%E7%BD%AE%E9%A1%B9
上面是启动的其他配置项
- 访问http://localhost:8090页面,就可以看到sentinel的控制台了:
账号和密码,默认都是:sentinel
微服务整合
- 引入sentinel依赖
<!--sentinel-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
- 配置控制台
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090
- 然后重启微服务module,然后进行访问该模块。sentinel的客户端就会将服务访问的信息提交到sentinel-dashboard控制台。并展示出统计信息
簇点链路,就是单机调用链路,是一次请求进入服务后经过的每一个被Sentinel监控的资源。默认情况下,Sentinel会监控SpringMVC的每一个Endpoint(接口)。我们可以对其进行限流、熔断、隔离等保护措施。
可以选择打开Sentinel的请求方式前缀,把请求方式 + 请求路径作为簇点资源名:
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090
http-method-specify: true # 开启请求方式前缀
请求限流
在簇点链路后面点击流控按钮,即可对其做限流配置:
在 Sentinel 中,新增流控规则的阈值类型主要有两种:QPS 和并发线程数。
- QPS:QPS(Queries-per-second)是每秒查询率,也就是每秒的请求数量。在 Sentinel 中,当调用某个接口的 QPS 达到设定的阈值时,就会进行限流。例如,如果 QPS 阈值设为 3,那么单位时间内能访问接口的次数 <= 3,超过 3 次则表示不满足流控规则,会被拒绝访问。
- 并发线程数:并发线程数是指同时处理请求的线程数量。在 Sentinel 中,当调用某个接口的并发线程数达到设定的阈值时,也会进行限流。例如,如果线程数阈值设为 3,那么单位时间内,允许开启的线程数为 3,换句话说,请求并发数 <= 3,超过 3 表示超过流控限制,会触发流控限制。
这两种阈值类型的主要区别在于,QPS 是对单位时间内请求接口次数的限制,而并发线程数则是对单位时间内请求并发数的限制。
线程隔离
因为线程隔离是对于调用其他服务的时候进行的隔离,调用其他服务又是基于OpenFeign实现的;在使用 OpenFeign 调用其他服务时,如果被调用的服务出现问题,调用服务的线程可能会被阻塞。因此,我们需要对 OpenFeign 接口进行线程隔离,以确保即使被调用的服务出现问题,也不会影响到调用服务的其他线程。
- 这里修改的是调用者的,也就是消费者Feign;不是服务提供者的!
feign:
sentinel:
enabled: true # 开启feign对sentinel的支持
默认情况下SpringBoot项目的tomcat最大线程数是200,允许的最大连接是8492,单机测试很难打满。
需要修改消费者的配置文件
server:
port: 8082
tomcat:
threads:
max: 50 # 允许的最大线程数
accept-count: 50 # 最大排队等待数量
max-connections: 100 # 允许的最大连接
- 然后重启消费者(cart-service)服务,可以看到查询商品的FeignClient自动变成了一个簇点资源:
- 配置线程隔离,对这个openFeign进行流控
注意,这里勾选的是并发线程数限制,也就是说这个查询功能最多使用5个线程,而不是5QPS。如果查询商品的接口每秒处理2个请求,则5个线程的实际QPS在10左右,而超出的请求自然会被拒绝。
Sentinel(服务保护技术)Fallback、服务熔断
- 在完成请求限流,线程隔离的情况下;我们应该对请求失败的请求也要进行反馈;也就是给失败的请求设置一个降级处理逻辑
- 对于延迟较高的服务调用,会让本服务也会变慢,且浪费资源,我们应该对这种不太健康的服务调用直接停止,走降级逻辑,避免影响当前业务;也就是将这个服务调用的接口直接熔断
编写降级逻辑
- 方式一:FallbackClass,无法对远程调用的异常做处理
- 方式二:FallbackFactory,可以对远程调用的异常做处理,我们一般选择这种方式。
方式二的实现步骤
- 在hm-api(OpenFeign实现了服务抽取)模块中给ItemClient定义降级处理类,实现FallbackFactory:当其能调用的线程都被占用,导致新的请求无法被处理时,就会触发这段代码。
package com.hmall.api.client.fallback;
import com.hmall.api.client.ItemClient;
import com.hmall.api.dto.ItemDTO;
import com.hmall.api.dto.OrderDetailDTO;
import com.hmall.common.exception.BizIllegalException;
import com.hmall.common.utils.CollUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import java.util.Collection;
import java.util.List;
// 使用Slf4j进行日志记录
@Slf4j
// ItemClientFallback类实现了FallbackFactory接口,用于创建ItemClient的备用实例
public class ItemClientFallback implements FallbackFactory<ItemClient> {
@Override
// 当ItemClient的方法调用失败时,会调用create方法创建一个备用的ItemClient实例
public ItemClient create(Throwable cause) {
return new ItemClient() {
@Override
// queryItemByIds方法用于查询商品信息
public List<ItemDTO> queryItemByIds(Collection<Long> ids) {
// 如果远程调用失败,记录错误日志
log.error("远程调用ItemClient#queryItemByIds方法出现异常,参数:{}", ids, cause);
// 查询购物车允许失败,查询失败,返回空集合
return CollUtils.emptyList();
}
@Override
// deductStock方法用于扣减库存
public void deductStock(List<OrderDetailDTO> items) {
// 库存扣减业务需要触发事务回滚,查询失败,抛出异常
throw new BizIllegalException(cause);
}
};
}
}
- 在hm-api模块中的com.hmall.api.config.DefaultFeignConfig类中将ItemClientFallback注册为一个Bean;
@Bean
public ItemClientFallback itemClientFallback() {
return new ItemClientFallback();
}
- 在hm-api模块中的ItemClient接口中使用ItemClientFallbackFactory:
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "item-service", fallbackFactory = ItemClientFallback.class) // 服务名称,指定降级处理的工厂
public interface ItemFeignClient {
@GetMapping("/items") // 请求方式、请求路径
List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
}
服务降级,保留一定的线程去调用那个健康度不高的接口,没有请求到线程的请求就执行fallback;
熔断,发现这个接口健康度不高,直接fallback所有对于这个接口的请求
openFeign的颗粒度是接口级别的
服务熔断
对于商品服务这种不太健康的接口,我们应该停止调用,直接走降级逻辑,避免影响到当前服务。也就是将商品查询接口熔断。当商品服务接口恢复正常后,再允许调用。这其实就是断路器的工作模式了。
Sentinel中的断路器不仅可以统计某个接口的慢请求比例,还可以统计异常请求比例。当这些比例超出阈值时,就会熔断该接口,即拦截访问该接口的一切请求,降级处理;当该接口恢复正常时,再放行对于该接口的请求。
断路器的工作状态切换有一个状态机来控制:
状态机包括三个状态:
- closed:关闭状态,断路器放行所有请求,并开始统计异常比例、慢请求比例。超过阈值则切换到open状态
- open:打开状态,服务调用被熔断,访问被熔断服务的请求会被拒绝,快速失败,直接走降级逻辑。Open状态持续一段时间后会进入half-open状态
- half-open:半开状态,放行一次请求,根据执行结果来判断接下来的操作。
- 请求成功:则切换到closed状态
- 请求失败:则切换到open状态
在控制台通过点击簇点后的熔断按钮来配置熔断策略:
- RT超过200毫秒的请求调用就是慢调用
- 统计最近1000ms内的最少5次请求,如果慢调用比例不低于0.5,则触发熔断
- 熔断持续时长20s
利用Nacos实现Sentinel配置持久化
- 添加依赖
<!-- Sentinel Datasource 依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-datasource</artifactId>
</dependency>
<!-- Sentinel Datasource Nacos 依赖 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
- Sentinel配置规则持久化至Nacos_sentinel-datasource-nacos-CSDN博客
- 默认 Nacos 适配的 dataId 和 groupId 约定如下:
- groupId: SENTINEL_GROUP
- 规则配置 dataId: {appName}-flow-rules,比如应用名为 appA,则 dataId 为 appA-flow-rules
spring:
cloud:
sentinel:
datasource:
# 自定义命名
flow-rule:
# 支持多种持久化数据源:file、nacos、zk、apollo、redis、consul
nacos:
# naco服务地址
server-addr: localhost:8848
# 命名空间,根据环境配置
namespace: public
# 这里我做了一下细分,不同规则设置不同groupId
group-id: SENTINEL_FLOW_GROUP
# 仅支持JSON和XML类型
data-id: ${spring.application.name}-flow-rules.json
# 规则类型:flow、degrade、param-flow、system、authority
rule-type: flow
# nacos开启了认证需要配置username、password
# username: nacos
# password: nacos
[{
"clusterMode": false,
"controlBehavior": 0,
"count": 5.0,
"grade": 1,
"limitApp": "default",
"resource": "/sentinel/flow",
"strategy": 0
}]
分布式事务(Seata)> 在分布式系统中,如果一个业务需要多个服务合作完成,而且每一个服务都有事务,多个事务必须同时成功或失败,这样的事务就是分布式事务。其中的每个服务的事务就是一个分支事务。整个业务称为全局事务。
在众多的开源分布式事务框架中,功能最完善、使用最多的就是阿里巴巴在2019年开源的Seata了。
解决分布式事务的思想非常简单:
就是找一个统一的事务协调者,与多个分支事务通信,检测每个分支事务的执行状态,保证全局事务下的每一个分支事务同时成功或失败即可。大多数的分布式事务框架都是基于这个理论来实现的。
Seata的事务管理中有三个重要的角色:
- TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - 资源管理器:管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
其中,TM和RM可以理解为Seata的客户端部分,引入到参与事务的微服务依赖中即可。将来TM和RM就会协助微服务,实现本地分支事务与TC之间交互,实现事务的提交或回滚。
而TC服务则是事务协调中心,是一个独立的微服务,需要单独部署。
Seata的三大模块包括TM(Transaction Manager,事务管理器)、RM(Resource Manager,资源管理器)和TC(Transaction Coordinator,事务协调器)。下面是这三个模块之间的运行机制:
- TM(Transaction Manager):事务管理器,它是事务的发起者,负责定义全局事务的范围,并根据TC维护的全局事务和分支事务状态,做出开始事务、提交事务、回滚事务的决议。
- RM(Resource Manager):资源管理器,它是资源的管理者(这里可以将其理解为各服务使用的数据库)。它负责管理分支事务上的资源,向TC注册分支事务,汇报分支事务状态,驱动分支事务的提交或回滚。
- TC(Transaction Coordinator):事务协调器,它是事务的协调者(这里指的是Seata服务器),主要负责维护全局事务和分支事务的状态,驱动全局事务提交或回滚。
在Seata中,分布式事务的执行流程如下:
- TM开启分布式事务(TM向TC注册全局事务记录);
- 按业务场景,编排数据库、服务等事务内资源(RM向TC汇报资源准备状态);
- TM结束分布式事务,事务一阶段结束(TM通知TC提交/回滚分布式事务);
- TC汇总事务信息,决定分布式事务是提交还是回滚;
- TC通知所有RM提交/回滚资源,事务二阶段结束。
分布式服务(Seata,部署TC服务和微服务集成Seata)
- 准备数据库表
Seata支持多种存储模式,但考虑到持久化的需要,我们一般选择基于数据库存储。
https://ciblc2bukyk.feishu.cn/wiki/ZA8CwvemgiyVU4kp0Btc6sWMnGb?fromScene=spaceOverview
- 将配置文件放到虚拟机/root目录
- Docker部署,将要使用的容器放到同一网络,然后执行
docker run --name seata \
-p 8099:8099 \
-p 7099:7099 \
-e SEATA_IP=192.168.150.101 \
-v ./seata:/seata-server/resources \
--privileged=true \
--network hm-net \
-d \
seataio/seata-server:1.5.2
- 微服务引入Nacos和Seata依赖
<!--统一配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
- Nacos控制台增加配置文件:shared-seata.yaml
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 192.168.150.101:8848 # nacos地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-server # seata服务名称
username: nacos
password: nacos
tx-service-group: hmall # 事务组名称
service:
vgroup-mapping: # 事务组与tc集群的映射关系
hmall: "default"
- 在要使用Seata和Nacos的微服务模块增加bootstrap.yaml配置文件
spring:
application:
name: trade-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.150.101 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
- dataId: shared-seata.yaml # 共享seata配置
- 删除微服务模块中applicaiton.yaml文件中与Nacos的配置文件中重复的内容
server:
port: 8085
feign:
okhttp:
enabled: true # 开启OKHttp连接池支持
sentinel:
enabled: true # 开启Feign对Sentinel的整合
hm:
swagger:
title: 交易服务接口文档
package: com.hmall.trade.controller
db:
database: hm-trade
- 测试
找到trade-service模块下的com.hmall.trade.service.impl.OrderServiceImpl类中的createOrder方法,也就是下单业务方法。
将其上的@Transactional注解改为Seata提供的@GlobalTransactional
:::warning
@GlobalTransactional注解就是在标记事务的起点,将来TM就会基于这个方法判断全局事务范围,初始化全局事务。
:::
分布式事务(Seata,XA模式):::success
Seata支持四种不同的分布式事务解决方案:
- XA
- TCC
- AT
- SAGA
:::
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范提供了支持。
:::success
XA规范,依赖于关系型数据库;例如:Oracle、MySQL、PostgreSQL
所以,
- 文档型数据库:
- MongoDB:以JSON或BSON格式存储数据,非常适合存储半结构化数据。
- CouchDB:另一个流行的文档型数据库,它使用JSON来存储数据,并提供了强大的查询功能。
- 键值存储数据库:
- Redis:一个开源的、基于内存的键值存储系统,广泛用于缓存、消息队列等场景。
- Amazon DynamoDB:由亚马逊提供的一个完全托管的NoSQL数据库服务。
- 列族数据库:
- Apache HBase:基于Google的Bigtable模型,适用于大数据应用。
- Cassandra:由Facebook开发,后被开源,适用于处理大量数据的分布式系统。
- 图形数据库:
- Neo4j:一个图形数据库管理系统,适用于处理复杂的关系网络。
- Amazon Neptune:亚马逊的图形数据库服务,用于存储和查询高度互连的数据。
- 时间序列数据库:
- InfluxDB:专门用于处理时间序列数据的数据库,适用于物联网和实时分析应用。
- Prometheus:一个开源的系统监控和警报工具包,也包含时间序列数据库功能。
是不支持XA规范的
:::
XA规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。
正常情况:
异常情况:
一阶段:
- 事务协调者通知每个事务参与者执行本地事务
- 本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交,继续持有数据库锁
二阶段:
- 事务协调者基于一阶段的报告来判断下一步操作
- 如果一阶段都成功,则通知所有事务参与者,提交事务
- 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
Seata的XA模型
Seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图:
:::success
:::
RM一阶段的工作:
- 注册分支事务到TC
- 执行分支业务sql但不提交
- 报告执行状态到TC
TC二阶段的工作:
- TC检测各分支事务执行状态
- 如果都成功,通知所有RM提交事务
- 如果有失败,通知所有RM回滚事务
RM二阶段的工作:
- 接收TC指令,提交或回滚事务
XA模式的优点是什么?
- 事务的强一致性,满足ACID原则
- 常用数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
使用XA模式
- 在配置文件中指定要采用的分布式事务模式。我们可以在Nacos中的共享shared-seata.yaml配置文件中设置:
seata:
data-source-proxy-mode: XA
- 利用@GlobalTransactional标记分布式事务的入口方法:
后续的那些事务要加@Transactional注解
分布式事务(Seata,AT模式)AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。
:::success
:::
阶段一RM的工作:
- 注册分支事务
- 记录undo-log(数据快照)
- 执行业务sql并提交
- 报告事务状态
阶段二提交时RM的工作:
- 删除undo-log即可
阶段二回滚时RM的工作:
- 根据undo-log恢复数据到更新前
Seata的AT模式通过以下方式确保分布式事务的回滚仅影响属于该事务的数据:
- 全局锁:在Seata-AT模式下,一阶段本地事务提交前,需要确保先拿到全局锁。如果拿不到全局锁,则不能提交本地事务³。这种机制确保了多个分支事务在并发执行时不会互相干扰,从而保证了数据的一致性³。
- 二阶段提交:Seata的AT模式包含两个阶段。第一阶段包括提交业务数据和回滚日志(undoLog),第一阶段具体流程如下图²。第二阶段是完全异步化的并且完全由Seata控制,Seata根据所有事务参与者的提交情况决定二阶段如何处理²。如果所有事务提交成功,则二阶段的任务就是删除一阶段生成的undoLog,并释放全局锁²。如果部分事务参与者提交失败,则需要根据undoLog对已经注册的事务分支进行回滚,并释放全局锁²。
参考资料
(1) 分布式事务Seata-AT模式详解:事务提交与回滚的全过程. https://cloud.baidu.com/article/3271892.
(2) 分布式事务 Seata AT模式原理与实战 - 知乎. https://zhuanlan.zhihu.com/p/314991447.
(3) 分布式事务Seata-AT模式的事务提交和事务回滚全过程 … https://blog.csdn.net/qq_38038472/article/details/134703219.
(4) Seata AT模式,二阶段回滚时,待回滚数据被别的事务 … https://blog.csdn.net/m0_47066332/article/details/122073027.
(5) Seata-AT模式原理详解:分布式事务的可靠保障. https://cloud.baidu.com/article/3211867.
:::success
简述AT模式与XA模式最大的区别是什么?
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
- XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
- XA模式强一致;AT模式最终一致
可见,AT模式使用起来更加简单,无业务侵入,性能更好。因此企业90%的分布式事务都可以用AT模式来解决。
:::
在分布式事务处理中,“锁定资源”通常指的是确保在事务执行期间,特定的数据或数据库行被锁定,以防止其他并发事务同时修改这些数据。这种锁定机制是确保事务隔离性和一致性的关键部分。XA模式和AT模式中,锁定资源的含义和实现方式有所不同:
- XA模式中的锁定资源:
- 在XA模式下,分布式事务的一阶段不提交事务,而是锁定资源。这意味着在事务的执行过程中,涉及的数据会被数据库级别的锁所保护。这种锁定通常是由数据库管理系统(DBMS)直接支持的,遵循XA规范的两阶段提交(2PC)协议。
- 在XA模式中,锁定资源发生在事务的第一阶段。在这个阶段,数据库会确保事务所需的所有资源都被锁定,防止其他事务修改这些资源。只有在第二阶段,当事务协调器确认所有参与的事务分支都可以提交时,这些锁才会被释放,资源才被正式提交。
- AT模式中的锁定资源:
- 相比之下,AT模式在一阶段直接提交事务,不锁定资源。这意味着在AT模式下,事务的执行过程中数据不会被数据库级别的锁长时间锁定。AT模式通过生成数据的前后镜像(beforeImage和afterImage)来实现数据的一致性和回滚。
- 在AT模式中,锁定资源是通过全局锁来实现的,这是一种轻量级的锁定机制,用于防止同一时间有多个事务修改同一数据。全局锁是在业务数据被修改前由Seata框架管理的,它确保了在事务提交前不会有其他事务修改同样的数据。
总结来说,XA模式中的“锁定资源”通常指的是数据库级别的锁定,这种锁定在事务的第一阶段就已经发生,并一直持续到事务的第二阶段完成。而在AT模式中,“锁定资源”是通过Seata框架管理的全局锁来实现的,它是一种更为轻量级和细粒度的锁定机制,是应用层面的锁定。主要用于防止并发事务间的数据冲突。
数据库层面锁定和应用层面锁定的概念及其区别:
- 数据库层面锁定:
- 发生在数据库管理系统(DBMS)层面。
- 当一个事务在数据库中执行时,数据库会自动锁定涉及的数据项,防止其他并发事务同时修改这些数据。
- 这种锁定通常由数据库系统内部管理,如Oracle、MySQL等,它们都有自己的锁定机制来保证事务的ACID属性。
- 数据库层面的锁定可以非常细粒度,例如锁定单个行或列。
- 这种锁定方式的优点是能够直接利用数据库的高级功能和事务管理能力,保证数据的一致性和完整性。但其缺点是在高并发环境下可能会导致大量的锁争用和死锁,从而影响性能。
- 应用层面锁定:
- 发生在应用程序层面,由应用程序代码或框架(如Seata)管理。
- 应用层面的锁定通常是通过编程方式实现的,例如在代码中显式地获取和释放锁。
- 与数据库层面的锁定不同,应用层面的锁定不直接依赖于数据库的锁定机制。
- 这种锁定的粒度可以由应用程序开发者控制,可能不如数据库层面的锁定那么细粒度。
- 应用层面锁定的优点是可以减少数据库层面的锁争用,提高并发性能,尤其是在分布式系统中。但其缺点是增加了应用程序的复杂性,并且需要开发者更仔细地管理锁,以避免死锁和数据不一致问题。
总结来说,数据库层面锁定和应用层面锁定的主要区别在于锁定的发生层面和管理方式。数据库层面锁定由DBMS管理,提供细粒度的控制,但可能导致性能问题;应用层面锁定由应用程序或框架管理,可能提供更好的性能,但增加了锁定的复杂性。选择哪种锁定方式取决于具体的应用场景和需求。
使用AT模式:
- 添加资料中的seata-at.sql到微服务对应的数据库中,使用到快照的都需要加
- 修改Nacos的配置文件,将Seata的事务模式修改为AT模式(AT模式其实是默认模式)
分布式锁(Redis解决方案)定义:满足分布式系统或集群模式下多进程可见并且互斥的锁
核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行
分布式锁需要满足以下条件:
- 可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
- 互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
- 高可用:程序不易崩溃,时时刻刻都保证较高的可用性
- 高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
- 安全性:安全也是程序中必不可少的一环
常见的分布式锁:
- MySQL,性能一般
- Redis
- Zookeeper
Redis实现分布式锁的步骤
- 获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
- 释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间
核心思路:
我们利用redis 的setNx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的线程,等待一定时间后重试即可
键存在就代表锁存在,键的值一般是获取到锁的用户的id;如果无键,则无锁
Redis分布式锁误删
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除
解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除
更为极端的误删:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html
使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了Redis提供的调用函数,lua脚本语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
# 执行 set name jack
redis.call('set', 'name', 'jack')
用Redis命令来调用脚本,调用脚本的常见命令如下:
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
总结:
基于Redis的分布式锁实现思路:
- 利用set nx ex获取锁,并设置过期时间,保存线程标示
- 释放锁时先判断线程标示是否与自己一致,一致则删除锁
- 特性:
- 利用set nx满足互斥性
- 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
- 特性:
笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题
分布式锁(Redission)上一个模块中Redis解决分布式锁有以下问题
主从一致性这个可能是主这边释放了锁,但是没能同步过去,导致在从查询的时候发现有锁;
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redission提供了分布式锁的多种多样的功能
redission可重入锁原理
在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。
在redission中,我们的也支持支持可重入锁
在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有
Redisson的分布式锁实现:在Redisson的分布式锁中,锁的信息被存储在Redis的一个哈希结构中,其中大key表示锁的名称,小key表示持有锁的线程的标识。当线程尝试获取锁时,会执行一个Lua脚本,这个脚本会检查锁是否存在,以及锁是否已经被当前线程持有。如果锁不存在,或者锁已经被当前线程持有,那么脚本会更新锁的信息,并返回nil,表示获取锁成功;否则,脚本会返回锁的剩余生存时间,表示获取锁失败。
Redisson的源码Lua脚本解析:Redisson用来实现分布式锁的一部分。这个脚本接收三个参数:**KEYS[1]**是锁的名称,**ARGV[1]**是锁的失效时间,**ARGV[2]**是由id和threadId组成的字符串,表示持有锁的线程的标识。脚本首先检查锁是否存在,如果不存在,就创建一个新的锁,并设置其失效时间;如果锁存在,并且是由当前线程持有,就增加锁的计数器,并更新其失效时间;如果锁存在,但不是由当前线程持有,就返回锁的剩余生存时间
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
Redission锁重试和watchdog机制
- 锁重试机制:在Redisson的分布式锁实现中,如果一个线程尝试获取锁但是锁已经被其他线程持有,那么这个线程会进入等待状态,并定期尝试重新获取锁。这就是所谓的锁重试机制。这个机制可以通过配置来调整,例如,你可以设置重试的间隔和最大重试次数等。这种机制的目的是为了提高系统的整体吞吐量,通过让线程等待并重试,而不是立即失败并返回,可以使得线程有机会在未来获取到锁,从而继续执行它的任务。
- Watchdog机制:Watchdog是Redisson提供的一个自动续租服务,用于防止锁在被持有的过程中过期。当一个线程成功获取一个锁后,Watchdog会在后台启动一个定时任务,定期自动为锁续租,以确保锁在被持有的过程中不会过期。默认情况下,Watchdog的续租间隔是锁的过期时间的1/3。当锁被释放后,Watchdog会自动停止对应的定时任务。这种机制的目的是为了防止因为线程执行时间过长或者因为其他原因(比如GC暂停)导致的锁提前释放的问题。
Redission锁的mutilock原理
为了提高redis的可用性,我们会搭建集群或者主从,现在以主从为例
此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。
redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。