第四节:Spring Cloud 核心组件(二)

tech2025-04-26  9

此博客用于个人学习,来源于网上,对知识点进行一个整理。

1. Hystrix:

Hystix 是 Netflix 开源的一个延迟和容错库,用于隔离访问远程服务、第三方库,防止出现级联失败。

1.1 背景——雪崩问题:

微服务中,服务间调用关系错综复杂,一个请求,可能需要调用多个微服务接口才能实现,会形成非常复杂的调用链路:

如图,一次业务请求,需要调用 A、P、H、I 四个服务,这四个服务又可能调用其它服务。

如果此时,某个服务出现异常:

例如微服务 I 发生异常,请求阻塞,用户不会得到响应,则 tomcat 的这个线程不会释放,于是越来越多的用户请求到来,越来越多的线程会阻塞:

服务器支持的线程和并发数有限,请求一直阻塞,会导致服务器资源耗尽,从而导致所有其它服务都不可用,形成雪崩效应。

Hystix解决雪崩问题的手段有两个:

线程隔离服务熔断

1.2 线程隔离,服务降级:

线程隔离: Hystrix 为每个依赖服务调用分配一个小的线程池,如果线程池已满调用将被立即拒绝,默认不采用排队,从而加速失败判定时间。用户的请求将不再直接访问服务,而是通过线程池中的空闲线程来访问服务,如果线程池已满,或者请求超时,则会进行降级处理。服务降级: 优先保证核心服务,而非核心服务不可用或弱可用。用户的请求故障时,不会被阻塞,更不会无休止的等待或者看到系统崩溃,至少可以看到一个执行结果(例如返回友好的提示信息) 。服务降级虽然会导致请求失败,但是不会导致阻塞,而且最多会影响这个依赖服务对应的线程池中的资源,对其它服务没有响应。

触发Hystix服务降级的情况:

线程池已满请求超时

1.3 降级实践:

1)引入依赖:

首先在 itcast-service-consumer 的 pom.xml 中引入 Hystrix 依赖。

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-hystrix</artifactId> </dependency>

2)开启熔断:

在启动类上添加注解 @EnableCircuitBreaker。但其实 Spring 为我们提供了一个组合注解——@SpringCloudApplication,该注解包含配置类常用的几个注解——@SpringBootApplication 和 @EnableDiscoverClient。

@SpringCloudApplication public class ItcastServiceConsumerApplication { @Bean @LoadBalanced public RestTemplate restTemplate(){ return new RestTemplate(); } public static void main(String[] args) { SpringApplication.run(ItcastServiceConsumerApplication.class, args); } }

3)编写降级逻辑:

改造 itcast-service-consumer,当目标服务的调用出现故障,我们希望快速失败,给用户一个友好提示。因此需要提前编写好失败时的降级处理逻辑,要使用 @HystixCommond 注解。

@Controller @RequestMapping("consumer/user") public class UserController { @Autowired private RestTemplate restTemplate; @GetMapping @ResponseBody @HystrixCommand(fallbackMethod = "queryUserByIdFallBack") //用来声明一个降级逻辑的方法 public String queryUserById(@RequestParam("id") Long id) { String user = this.restTemplate.getForObject("http://service-provider/user/" + id, String.class); return user; } public String queryUserByIdFallBack(Long id){ return "请求繁忙,请稍后再试!"; } }

注意:熔断的降级逻辑方法必须跟正常逻辑方法保证:相同的参数列表和返回值声明。失败逻辑中返回 User 对象没有太大意义,一般会返回友好提示。所以我们把 queryById 的方法改造为返回String,比如 Json 数据。这样失败逻辑中返回一个错误说明,会比较方便。

当 itcast-service-provder 正常提供服务时,访问与以前一致。但是当我们将 itcast-service-provider 停机时,会发现页面返回了降级处理信息:请求繁忙,请稍后再试!

1.4 降级优化:

1)默认 FallBack:

把 Fallback 写在了某个业务方法上,如果这样的方法很多,那岂不是要写很多。所以我们可以把 Fallback 配置加在类上,实现默认 Fallback:

@Controller @RequestMapping("consumer/user") @DefaultProperties(defaultFallback = "fallBackMethod") // 指定一个类的全局熔断方法 public class UserController { @Autowired private RestTemplate restTemplate; @GetMapping @ResponseBody @HystrixCommand // 标记该方法需要熔断 public String queryUserById(@RequestParam("id") Long id) { String user = this.restTemplate.getForObject("http://service-provider/user/" + id, String.class); return user; } /** * 熔断方法 * 返回值要和被熔断的方法的返回值一致 * 熔断方法不需要参数 * @return */ public String fallBackMethod(){ return "请求繁忙,请稍后再试!"; } }

2)设置超时:

Hystix 的默认超时时长为1,我们可以通过配置修改这个值:我们可以通过hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds 来设置 Hystrix 超时时间。

hystrix: command: default: execution: isolation: thread: timeoutInMilliseconds: 6000 # 设置hystrix的超时时间为6000ms

1.5 服务熔断:

熔断状态机3个状态:

Closed:关闭状态,所有请求都正常访问。Open:打开状态,所有请求都会被降级。Hystix 会对请求情况计数,当一定时间内失败请求百分比达到阈值,则触发熔断,断路器会完全打开。默认失败比例的阈值是50%,请求次数最少不低于20次。Half Open:半开状态,open 状态不是永久的,打开后会进入休眠时间(默认是5s)。随后断路器会自动进入半开状态。此时会释放部分请求通过,若这些请求都是健康的,则会完全关闭断路器,否则继续保持打开,再次进行休眠计时。

1.6 熔断实践:

在 consumer 的调用业务中加入一段逻辑:

@GetMapping("{id}") @HystrixCommand public String queryUserById(@PathVariable("id") Long id){ //如果参数是id为1,一定失败,其它情况都成功。 if(id == 1){ throw new RuntimeException("太忙了"); } String user = this.restTemplate.getForObject("http://service-provider/user/" + id, String.class); return user; }

当我们疯狂访问 id 为1的请求时(超过20次),就会触发熔断。断路器会断开,一切请求都会被降级处理。此时你访问 id 为2的请求,会发现返回的也是失败,而且失败时间很短,只有几毫秒左右。

我们可以通过配置修改熔断策略:

circuitBreaker: requestVolumeThreshold: 10 #触发熔断的最小请求次数,默认20 sleepWindowInMilliseconds: 10000 #触发熔断的失败请求最小占比,默认50% errorThresholdPercentage: 50 #休眠时长,默认是5000毫秒

2. Feign:

Ribbon 的负载均衡功能,大大简化了远程调用时的代码,但是存在一个问题:可能以后需要编写类似的大量重复代码,格式基本相同,无非参数不一样。解决方案就是 Feign。

2.1 简介:

Feign 可以把 Rest 的请求进行隐藏,伪装成类似 SpringMVC 的 Controller 一样。你不用再自己拼接 url,拼接参数等等操作,一切都交给 Feign 去做。

2.2 Feign 实践:

1)导入依赖:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>

2)开启 Feign 功能:

在启动类上,添加注解 @EnableFeignClients,开启 Feign 功能。

@SpringCloudApplication @EnableFeignClients // 开启feign客户端 public class ItcastServiceConsumerApplication { public static void main(String[] args) { SpringApplication.run(ItcastServiceConsumerApplication.class, args); } }

删除 RestTemplate:Feign 已经自动集成了 Ribbon 负载均衡的 RestTemplate 。所以,此处不需要再注册 RestTemplate。

3)添加 Feign 的客户端:

在 itcast-service-consumer 工程中,添加 UserClient 接口。

@FeignClient(value = "service-provider") // 标注该类是一个feign接口 public interface UserClient { @GetMapping("user/{id}") User queryById(@PathVariable("id") Long id); } 首先这是一个接口,Feign 会通过动态代理,帮我们生成实现类,与跟 mybatis 的 mapper 类似。@FeignClient:声明这是一个 Feign 客户端,类似 @Mapper 注解,同时通过 value 属性指定服务名称。接口中的定义方法,完全采用 SpringMVC 的注解,Feign 会根据注解帮我们生成 URL,并访问获取结果。

改造原来的调用逻辑,调用 UserClient 接口:

@Controller @RequestMapping("consumer/user") public class UserController { @Autowired private UserClient userClient; @GetMapping @ResponseBody public User queryUserById(@RequestParam("id") Long id){ User user = this.userClient.queryUserById(id); return user; } }

2.3 Hystrix 支持:

Feign 默认也有对 Hystrix 的集成,默认情况下是关闭的。我们需要通过下面的参数来开启:(在 itcast-service-consumer 工程添加配置内容)。

feign: hystrix: enabled: true # 开启Feign的熔断功能

但是,Hystrix 中对 FallBack 的配置与之前不一样:

1)定义一个类 UserClientFallback,实现刚才编写的 UserClient,作为 fallback 的处理类。

@Component public class UserClientFallback implements UserClient { @Override public User queryById(Long id) { User user = new User(); user.setUserName("服务器繁忙,请稍后再试!"); return user; } }

2)然后在 UserFeignClient 中,指定刚才编写的实现类。

@FeignClient(value = "service-provider", fallback = UserClientFallback.class) // 标注该类是一个feign接口 public interface UserClient { @GetMapping("user/{id}") User queryUserById(@PathVariable("id") Long id); }

3. Zuul 网关:


3.1 存在的问题:

之前介绍过的组件各司其职——Spring Cloud Netflix 中的 Eureka 实现了服务注册中心以及服务注册与发现;而服务间通过 Ribbon 或 Feign 实现服务的消费以及均衡负载。为了使得服务集群更为健壮,使用 Hystrix 的融断机制来避免在微服务架构中个别服务出现异常时引起的故障蔓延。

在该架构中,我们的服务集群包含:内部服务 Service A 和 Service B,他们都会注册与订阅服务至 Eureka Server,而 Open Service 是一个对外的服务,通过均衡负载公开至服务调用方。我们把焦点聚集在对外服务这块,直接暴露我们的服务地址,这样的实现存在以下问题:

破坏了服务无状态特点。

为了保证对外服务的安全性,我们需要实现对服务访问的权限控制,而开放服务的权限控制机制将会贯穿并污染整个开放服务的业务逻辑,这会带来的最直接问题是,破坏了服务集群中 REST API 无状态的特点。从具体开发和测试的角度来说,在工作中除了要考虑实际的业务逻辑之外,还需要额外考虑对接口访问的控制处理。

无法直接复用既有接口。

当我们需要对一个即有的集群内访问接口,实现外部服务访问时,我们不得不通过在原有接口上增加校验逻辑,或增加一个代理调用来实现权限控制,无法直接复用原有的接口。

3.2 简介:

为了解决上面这些问题,我们需要将权限控制这样的东西从我们的服务单元中抽离出去,而最适合这些逻辑的地方就是处于对外访问最前端的地方,我们需要一个更强大一些的均衡负载器的服务网关。

服务网关是微服务架构中一个不可或缺的部分。通过服务网关统一向外系统提供 REST API 的过程中,除了具备服务路由、均衡负载功能之外,它还具备了权限控制等功能。Spring Cloud Netflix 中的 Zuul 就担任了这样的一个角色,为微服务架构提供了前门保护的作用,同时将权限控制这些较重的非业务逻辑内容迁移到服务路由层面,使得服务集群主体能够具备更高的可复用性和可测试性。

3.3 Zuul 加入后的架构:

不管是来自于客户端(PC或移动端)的请求,还是服务内部调用。一切对服务的请求都会经过 Zuul 这个网关,然后再由网关来实现鉴权、动态路由等等操作。Zuul 就是我们服务的统一入口。

3.4 Zuul 实践:

1)创建工程,添加 zuul 依赖。

2)编写配置:

server: port: 10010 #服务端口 spring: application: name: api-gateway #指定服务名

3)编写引导类:

通过 @EnableZuulProxy 注解开启 Zuul 的功能。

@SpringBootApplication @EnableZuulProxy // 开启网关功能 public class ItcastZuulApplication { public static void main(String[] args) { SpringApplication.run(ItcastZuulApplication.class, args); } }

4)编写路由规则:

需要用 Zuul 来代理 service-provider 服务,映射规则:

server: port: 10010 #服务端口 spring: application: name: api-gateway #指定服务名 zuul: routes: service-provider: # 这里是路由id,随意写 path: /service-provider/** # 这里是映射路径 url: http://127.0.0.1:8081 # 映射路径对应的实际url地址

3.5 面向服务的路由:

在刚才的路由规则中,我们把路径对应的服务地址写死了。如果同一服务有多个实例的话,这样做显然就不合理了。应该根据服务的名称,去 Eureka 注册中心查找服务对应的所有实例列表,然后进行动态路由。

进行工程的优化:

1)添加 Eureka 客户端依赖:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency>

2)添加 Eureka 配置,获取服务信息:

eureka: client: registry-fetch-interval-seconds: 5 # 获取服务列表的周期:5s service-url: defaultZone: http://127.0.0.1:10086/eureka

3)开启 Eureka 客户端发现功能:

@SpringBootApplication @EnableZuulProxy // 开启Zuul的网关功能 @EnableDiscoveryClient public class ZuulDemoApplication { public static void main(String[] args) { SpringApplication.run(ZuulDemoApplication.class, args); } }

4)修改映射配置,通过服务名称获取:

已经有了 Eureka 客户端,我们可以从 Eureka 获取服务的地址信息,因此映射时无需指定 IP 地址,而是通过服务名称来访问,而且 Zuul 已经集成了 Ribbon 的负载均衡功能。

zuul: routes: service-provider: # 这里是路由id,随意写 path: /service-provider/** # 这里是映射路径 serviceId: service-provider # 指定服务名称

3.6 简化的路由配置:

在刚才的配置中,我们的规则是这样的:

zuul.routes..path=/xxx/**: 来指定映射路径。<route> 是自定义的路由名。zuul.routes..serviceId=service-provider:来指定服务名。

而大多数情况下,我们的 <route> 路由名称往往和服务名会写成一样的。因此 Zuul 就提供了一种简化的配置语法:zuul.routes.<serviceI>=<path>

比方说上面我们关于 service-provider 的配置可以简化为一条:

zuul: routes: service-provider: /service-provider/** # 这里是映射路径

3.7 路由前缀:

可以通过 zuul.prefix=/api 来指定了路由的前缀,这样在发起请求时,路径就要以 /api 开头。

zuul: routes: service-provider: /service-provider/** service-consumer: /service-consumer/** prefix: /api # 添加路由前缀

3.8 过滤器:

Zuul 作为网关的其中一个重要功能,就是实现请求的鉴权,而这个功能往往是通过 Zuul 提供的过滤器来实现的。

ZuulFilter:

ZuulFilter是过滤器的顶级父类。

public abstract ZuulFilter implements IZuulFilter{ abstract public String filterType(); abstract public int filterOrder(); boolean shouldFilter();// 来自IZuulFilter Object run() throws ZuulException;// IZuulFilter } shouldFilter:返回一个 Boolean 值,判断该过滤器是否需要执行,返回 true 执行,返回 false 不执行。run:过滤器的具体业务逻辑。filterType:返回字符串,代表过滤器的类型。包含以下4种: pre:请求在被路由之前执行。route:在路由请求时调用。post:在 route 和 errror 过滤器之后调用。error:处理请求时发生错误调用。 filterOrder:通过返回的 int 值来定义过滤器的执行顺序,数字越小优先级越高。

过滤器执行生命周期:

正常流程: 请求到达首先会经过 pre 类型过滤器,而后到达 route 类型,进行路由,请求就到达真正的服务提供者,执行请求,返回结果后,会到达 post 过滤器。而后返回响应。

异常流程:

整个过程中,pre 或者 route 过滤器出现异常,都会直接进入 error 过滤器,在 error 处理完毕后,会将请求交给 POST 过滤器,最后返回给用户。如果是 error 过滤器自己出现异常,最终也会进入 POST 过滤器,将最终结果返回给请求客户端。如果是 POST 过滤器出现异常,会跳转到 error 过滤器,但是与 pre 和 route 不同的是,请求不会再到达 POST 过滤器了。

使用场景:

请求鉴权:一般放在 pre 类型,如果发现没有访问权限,直接就拦截了。异常处理:一般会在 error 类型和 post 类型过滤器中结合来处理。服务调用时长统计:pre 和 post 结合使用。

3.9 自定义过滤器:

基本逻辑:如果请求中有 access-token 参数,则认为请求有效,放行。

@Component public class LoginFilter extends ZuulFilter { /** * 过滤器类型,前置过滤器 * @return */ @Override public String filterType() { return "pre"; } /** * 过滤器的执行顺序 * @return */ @Override public int filterOrder() { return 1; } /** * 该过滤器是否生效 * @return */ @Override public boolean shouldFilter() { return true; } /** * 登陆校验逻辑 * @return * @throws ZuulException */ @Override public Object run() throws ZuulException { // 获取zuul提供的上下文对象 RequestContext context = RequestContext.getCurrentContext(); // 从上下文对象中获取请求对象 HttpServletRequest request = context.getRequest(); // 获取token信息 String token = request.getParameter("access-token"); // 判断 if (StringUtils.isBlank(token)) { // 过滤该请求,不对其进行路由 context.setSendZuulResponse(false); // 设置响应状态码,401 context.setResponseStatusCode(HttpStatus.SC_UNAUTHORIZED); // 设置响应信息 context.setResponseBody("{\"status\":\"401\", \"text\":\"request error!\"}"); } // 校验通过,把登陆信息放入上下文信息,继续向后执行 context.set("token", token); return null; } }
最新回复(0)