Loading

49-Hystrix

1. 微服务中的雪崩效应

https://www.cnblogs.com/rjzheng/

Service A 的流量波动很大,流量经常会突然性增加!那么在这种情况下,就算 Service A 能扛得住请求,Service B 和 Service C 未必能扛得住这突发的请求。

此时,如果 Service C 因为抗不住请求,变得不可用。那么 Service B 的请求也会阻塞,慢慢耗尽 Service B 的线程资源,Service B 就会变得不可用。紧接着,Service A 也会不可用,这一过程如下图所示:

微服务中,一个请求可能需要多个微服务接口才能实现,会形成复杂的调用链路。站在某一个微服务的角度看,上游微服务对它的调用叫做“扇入”,它对下游微服务的调用叫做“扇出”。

  • 扇入:代表着该微服务被调用的次数,扇入大,说明该模块复用性好;
  • 扇出:该微服务调用其他微服务的个数,扇出大,说明业务逻辑复杂;

扇入大是一个好事,扇出大不一定是好事。

在微服务架构中,一个应用可能会有多个微服务组成,微服务之间的数据交互通过远程过程调用完成。这就带来一个问题,假设微服务 A 调用微服务 B 和微服务 C,微服务 B 和微服务 C 又调用其它的微服务,这就是所谓的“扇出”。如果扇出的链路上某个微服务的调用响应时间过长或者不可用,对微服务 A的调用就会占用越来越多的系统资源,进而引起系统崩溃,所谓的“雪崩效应”。

如图中所示,最下游简历微服务响应时间过长,大量请求阻塞,大量线程不会释放,会导致服务器资源耗尽,最终导致上游服务甚至整个系统瘫痪。

如上所示的“一个服务失败,导致整条链路的服务都失败”的情形,我们称之为「服务雪崩」。那么,「服务熔断」和「服务降级」就可以视为解决服务雪崩的手段之一

2. 雪崩效应的解决方案

下面,我们介绍三种技术手段应对微服务中的雪崩效应,这三种手段都是从系统可用性、可靠性角度出发,尽量防止系统整体缓慢甚至瘫痪。

2.1 服务熔断

那么,什么是服务熔断呢?

熔断机制是应对雪崩效应的一种微服务链路保护机制。我们在各种场景下都会接触到“熔断”这两个字。高压电路中,如果某个地方的电压过高,熔断器就会熔断,对电路进行保护。股票交易中,如果股票指数过高,也会采用熔断机制,暂停股票的交易。同样,在微服务架构中,熔断机制也是起着类似的作用。当扇出链路的某个微服务不可用或者响应时间太长时,熔断该节点微服务的调用,进行服务的降级,快速返回错误的响应信息。当检测到该节点微服务调用响应正常后,恢复调用链路。

需要说明的是熔断其实是一个框架级的处理,那么这套熔断机制的设计,基本上业内用的是〈断路器模式〉,如 Martin Fowler 提供的状态转换图如下所示:

  1. 「断路器」本身使一种开关装置,最开始处于 closed 状态,一旦检测到错误到达一定阈值,便转为 open 状态;
  2. 这时候会有个 reset timeout,到了这个时间了,会转移到 half open 状态;
  3. 尝试放行一部分请求到后端,一旦检测成功便回归到 closed 状态,即恢复服务;

业内目前流行的熔断器很多,例如阿里出的 Sentinel 以及最多人使用的 Hystrix。在 Hystrix 中,对应配置如下:

# 滑动窗口的大小,默认为20
circuitBreaker.requestVolumeThreshold=
# 过多长时间,熔断器再次检测是否开启,默认为5000,即5s钟
circuitBreaker.sleepWindowInMilliseconds=
# 错误率,默认50%
circuitBreaker.errorThresholdPercentage=

注意:

  1. 服务熔断重点在“断”,切断对下游服务的调用;
  2. 服务熔断和服务降级往往是一起使用的,Hystrix 就是这样。

每当 20 个请求中,有 50% 失败时,熔断器就会打开,此时再调用此服务,将会直接返回失败,不再调远程服务。直到 5s 之后,重新检测该触发条件,判断是否把熔断器关闭,或者继续打开。

这些属于框架层级的实现,我们只要实现对应接口就好!

2.2 服务降级

那么,什么是服务降级呢?

服务降级一般是从整体考虑,就是当某个服务熔断之后,服务器将不再被调用,此刻客户端可以自己准备一个本地的 fallback 回调,返回一个缺省值(也叫做兜底数据),这样做,虽然服务水平下降,但好歹可用,比直接挂掉要强!

这里有两种场景:

  • 当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,增加响应速度。
  • 当下游的服务因为某种原因不可用,上游主动调用本地的一些降级逻辑,避免卡顿,迅速返回给用户。

降级按照是否⾃动化可分为:

  • ⾃动开关降级(超时、失败次数、故障、限流)
  • ⼈⼯开关降级(秒杀、电商⼤促等)

⾃动降级分类⼜分为 :

  1. 超时降级:主要配置好超时时间和超时重试次数和机制,并使⽤异步机制探测回复情况。
  2. 失败次数降级:主要是⼀些不稳定的 api,当失败调⽤次数达到⼀定阀值⾃动降级,同样要使⽤异步机制探测回复情况。
  3. 故障降级:⽐如要调⽤的远程服务挂掉了(⽹络故障、DNS 故障、http 服务返回错误的状态码、rpc 服务抛出异常),则可以直接降级。降级后的处理⽅案有:默认值(⽐如库存服务挂了,返回默认现货)、兜底数据(⽐如⼴告挂了,返回提前准备好的⼀些静态⻚⾯)、缓存(之前暂存的⼀些缓存数据)。
  4. 限流降级:当我们去秒杀或者抢购⼀些限购商品时,此时可能会因为访问量太⼤⽽导致系统崩溃,此时开发者会使⽤限流来进⾏限制访问量,当达到限流阀值,后续请求会被降级;降级后的处理⽅案可以是:排队⻚⾯(将⽤户导流到排队⻚⾯等⼀会重试)、⽆货(直接告知⽤户没货了)、错误⻚(如活动太⽕爆了,稍后重试)。

其实乍看之下,很多人还是不懂熔断和降级的区别!其实应该要这么理解:

服务降级有很多种降级方式!如开关降级、限流降级、熔断降级。服务熔断属于降级方式的一种!降级的⽬的在于应对系统⾃身的故障,⽽熔断的⽬的在于应对当前系统依赖的外部系统或者第三⽅系统的故障。

可能有的人不服,觉得熔断是熔断、降级是降级,分明是两回事啊!其实不然,因为从实现上来说,熔断和降级必定是一起出现。因为当发生下游服务不可用的情况,这个时候为了对最终用户负责,就需要进入上游的降级逻辑了。因此,将熔断降级视为降级方式的一种,也是可以说的通的!

我撇开框架,以最简单的代码来说明!上游代码如下:

try {
    // 调用下游的 helloWorld 服务
    xxxRpc.helloWorld();
} catch(Exception e){
    // 因为熔断,所以调不通
    doSomething();
}

注意看,下游的 helloWorld 服务因为熔断而调不通。此时上游服务就会进入 catch 里头的代码块,那么 catch 里头执行的逻辑,你就可以理解为降级逻辑!

服务降级大多是属于一种业务级别的处理。当然,我这里要讲的是另一种降级方式,也就是开关降级!这也是我们生产上常用的另一种降级方式!

做法很简单,做个开关,然后将开关放配置中心!在配置中心更改开关,决定哪些服务进行降级。至于配置变动后,应用怎么监控到配置发生了变动,这就不是本文该讨论的范围。

那么,在应用程序中部下开关的这个过程,业内也有一个名词,称为「埋点」。那接下来最关键的一个问题,哪些业务需要埋点?

  1. 简化执行流程:自己梳理出核心业务流程和非核心业务流程。然后在非核心业务流程上加上开关,一旦发现系统扛不住,关掉开关,结束这些次要流程;
  2. 关闭次要功能:一个微服务下肯定有很多功能,那自己区分出主要功能和次要功能。然后次要功能加上开关,需要降级的时候,把次要功能关了吧!
  3. 降低一致性:假设,你在业务上发现执行流程没法简化了,愁啊!也没啥次要功能可以关了,桑心啊!那只能降低一致性了,即将核心业务流程的同步改异步,将强一致性改最终一致性!

2.3 服务限流

服务降级是当服务出问题或者影响到核心流程的性能时,暂时将服务屏蔽掉,待高峰或者问题解决后再打开;但是有些场景并不能用服务降级来解决,比如秒杀业务这样的核心功能,这个时候可以结合服务限流来限制这些场景的并发/请求量限流措施也很多,比如:

  • 限制总并发数(比如数据库连接池、线程池);
  • 限制瞬时并发数(如 Nginx 限制瞬时并发连接数);
  • 限制时间窗口内的平均速率(如 Guava 的 RateLimiter、Nginx 的 limit_req 模块,限制每秒的平均速率);
  • 限制远程接口调用速率、限制 MQ 的消费速率等。

3. Hystrix

3.1 快速开始

宣言“defend your app”是由 Netflix 开源的一个延迟和容错库,用于隔离访问远程系统、服务或者第三方库,防止级联失败,从而
提升系统的可用性与容错性。Hystrix 主要通过以下几点实现延迟和容错。

  • 包裹请求:使用 @HystrixCommand 包裹对依赖的调用逻辑;
  • 跳闸机制:当某服务的错误率超过一定的阈值时,Hystrix 可以跳闸,停止请求该服务一段时间;
  • 资源隔离:Hystrix 为每个依赖都维护了一个小型的线程池(舱壁模式)。如果该线程池已满, 发往该依赖的请求就被立即拒绝,而不是排队等待,从而加速失败判定;
  • 监控:Hystrix 可以近乎实时地监控运行指标和配置的变化,例如成功、失败、超时、以及被拒绝的请求等;
  • 回退机制:当请求失败、超时、被拒绝,或当断路器打开时,执行回退逻辑。回退逻辑由开发人员自行提供,例如返回一个缺省值;
  • 自我修复:断路器打开一段时间后,会自动进入“半开”状态。

工程引入 Hystrix:

  1. pom.xml 增加 Hystrix 依赖
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
    </dependency>
    
  2. 主启动类增加注解 @EnableCircuitBreaker
  3. 业务方法增加注解 @HystrixCommand,并配置注解属性。

3.2 舱壁模式

如果不进行任何设置,所有熔断方法使用一个 Hystrix 线程池(10 个线程),那么这样的话会导致问题,这个问题并不是扇出链路微服务不可用导致的,而是我们的线程机制导致的,如果方法 A 的请求把 10 个线程都用了,方法 2 请求处理的时候压根都没法去访问 B,因为没有线程可用,并不是 B 服务不可用。

为了避免问题服务请求过多导致正常服务无法访问,Hystrix 不是采用增加线程数,而是单独的为每一个控制方法创建一个线程池的方式,这种模式叫做“舱壁模式”,也是线程隔离的手段。

测试代码:

/**
 * 提供者模拟处理超时,调用方法添加 Hystrix 控制
 * @param userId
 * @return
 */
// 使用@HystrixCommand注解进行熔断控制
@HystrixCommand(
    // 线程池标识,要保持唯一,不唯一的话就共用了
    threadPoolKey = "findResumeOpenStateTimeout",
    // 线程池细节属性配置
    threadPoolProperties = {
        @HystrixProperty(name="coreSize",value = "1"),   // 线程数
        @HystrixProperty(name="maxQueueSize",value="20") // 等待队列长度
    },
    // commandProperties熔断的一些细节属性配置
    commandProperties = {
        // 每一个属性都是一个HystrixProperty
        @HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds",value="2000")
    }
)
@GetMapping("/checkStateTimeout/{userId}")
public Integer findResumeOpenStateTimeout(@PathVariable Long userId) {
    String url = "http://lagou-service-resume/resume/openstate/" + userId;
    Integer forObject = restTemplate.getForObject(url, Integer.class);
    return forObject;
}


@GetMapping("/checkStateTimeoutFallback/{userId}")
@HystrixCommand(
    threadPoolKey = "findResumeOpenStateTimeoutFallback",
    threadPoolProperties = {
        @HystrixProperty(name="coreSize",value = "2"),
        @HystrixProperty(name="maxQueueSize",value="20")
    },
    commandProperties = {
        @HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds",value="2000")
    },
    fallbackMethod = "myFallBack"  // 回退方法
)
public Integer findResumeOpenStateTimeoutFallback(@PathVariable Long userId) {
    String url = "http://lagou-service-resume/resume/openstate/" + userId;
    Integer forObject = restTemplate.getForObject(url, Integer.class);
    return forObject;
}

查看线程情况:

3.3 工作流程

(1)当调用出现问题时,开启一个时间窗(10s)

(2)在这个时间窗内,统计调用次数是否达到最小请求数?

  • 如果没有达到,则重置统计信息,回到第 1 步
  • 如果达到了,则统计失败的请求数占所有请求数的百分比,是否达到阈值?
    • 如果达到,则跳闸(不再请求对应服务)
    • 如果没有达到,则重置统计信息,回到第1步

(3)如果跳闸,则会开启一个活动窗口(默认 5s),每隔 5s,Hystrix 会让一个请求通过,到达那个问题服务,看是否调用成功,如果成功,重置断路器回到第 1 步,如果失败,回到第 3 步。

/**
 * 8s内,请求次数达到2个,并且失败率在50%以上,就跳闸;跳闸后活动窗口设置为3s
 */
@HystrixCommand(commandProperties = {
    // => Hystrix高级配置,定制工作过程细节
    // 统计时间窗口定义
    @HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "8000"),
    // 统计时间窗口内的最小请求数
    @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "2"),
    // 统计时间窗口内的错误数量百分比阈值
    @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"),
    // 自我修复时的活动窗口长度
    @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "3000")
})
  • 快照窗口值:断路器确定是否打开需要统计一些请求和错误数据,而统计的时间范围就是快照时间窗,默认为最近的 10 s;
  • 请求总数阈值:再快找时间窗内,必须满足请求总数阈值才有资格熔断。默认为 20,意味着在 10s 内,如果该 hystrix 命令的调用次数不足 20 次,即使所有的请求都超时或其他原因失败,断路器都不会打开;
  • 错误百分比阈值:当请求总数在快照时间窗内超过了阈值,比如发生了 30 次调用,如果在这 30 次调用中,有 15 次发生了超时异常,也就是超过 50% 的错误百分比,在默认设定 50% 阈值情况下,这时候断路器会打开。

若断路器打开,再有请求调用的时候,将不会调用主逻辑,而是直接调用降级 fallback。通过断路器,实现了自动地发现错误并将降级逻辑切换为主逻辑,减少响应延迟的效果。

我们上述通过注解进行的配置也可以配置在配置文件中(全局生效):

hystrix:
  command:
    default:
      circuitBreaker:
        # 强制打开熔断器(默认false),若该属性设为true,强制断路器进入open状态,将会拒绝所有的请求。
        forceOpen: false
        # 触发熔断错误比例阈值,默认值50%
        errorThresholdPercentage: 50
        # 熔断后休眠时长,默认值5s
        sleepWindowInMilliseconds: 3000
        # 熔断触发最小请求次数,默认值是 20
        requestVolumeThreshold: 2
      execution:
        isolation:
          thread:
            # 熔断超时设置,默认为 1s
            timeoutInMilliseconds: 2000

基于 SpringBoot 的健康检查观察跳闸状态 http://<IP>:<PORT>/actuator/health

# 暴露健康检查等端点接口
management:
  endpoints:
    web:
      exposure:
        include: "*"
  # 暴露健康接口的细节
  endpoint:
    health:
      show-details: always

原来的主逻辑要如何恢复呢?

对于这一问题,hystrix 也为我们实现了自动恢复功能。当断路器打开,对主逻辑进行熔断之后,hystrix 会启动一个休眠时间窗,在这个时间窗内,降级逻辑是临时的成为主逻辑,当休眠时间窗到期,断路器将进入半开状态,释放一次请求到原来的主逻辑上,如果此次请求正常返回,那么断路器将闭合,主逻辑恢复。但如果这次请求依旧有问题,断路器将持续打开,休眠时间窗重新计时。

3.4 补充

因为 Feign 的请求:其实是 Hystrix + Ribbon。Hystrix 在最外层,然后再到 Ribbon,最后里面的是 http 请求。所以说,Hystrix 的熔断时间必须大于 Ribbon 的(ConnectTimeout + ReadTimeout)。而如果 Ribbon 开启了重试机制,还需要乘以对应的重试次数,保证在 Ribbon 里的请求还没结束时,Hystrix 的熔断时间不会超时。

4. Hystrix 监控

4.1 Dashboard

正常状态是 UP,跳闸是一种状态 CIRCUIT_OPEN,可以通过 /health 查看,前提是工程中需要引入 SpringBoot 的 actuator(健康监控),它提供了很多监控所需的接口,可以对应用系统进行配置查看、相关功能统计等。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

如果我们想看到 Hystrix 相关数据,比如有多少请求、多少成功、多少失败、多少降级等,那么引入 SpringBoot 健康监控之后,访问 /actuator/hystrix.stream 接口可以获取到监控的文字信息,但是不直观,所以 Hystrix 官方还提供了基于图形化的 DashBoard(仪表板)监控平台。Hystrix 仪表板可以显示每个断路器(被 @HystrixCommand 的方法)的状态。

(1)新建一个监控服务,导入依赖;

<dependencies>
    <!--hystrix-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
    </dependency>
    <!--hystrix 仪表盘-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
</dependencies>

(2)启动类添加 @EnableHystrixDashboard 激活仪表盘;

@SpringBootApplication
@EnableDiscoveryClient
@EnableHystrixDashboard
public class HystrixDashboard9000 {
    public static void main(String[] args) {
        SpringApplication.run(HystrixDashboard9000.class, args);
    }
}

(3)dashboard#配置文件;

server:
  port: 9000
Spring:
  application:
    name: lagou-cloud-hystrix-dashboard
eureka:
  client:
    serviceUrl:
      defaultZone: http://eurekaservera:8761/eureka/,http://eurekaserverb:8762/eureka/
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${spring.application.name}:${server.port}:@project.version@

(4)在被监测的微服务中注册监控 Servlet;

/**
 * 在被监控的微服务中注册一个 serlvet,后期通过访问这个 servlet 来获取该服务的 Hystrix 监控数据
 * [前提] 被监控的微服务需要引入 springboot 的 actuator 功能
 * @return
 */
@Bean
public ServletRegistrationBean getServlet(){
    HystrixMetricsStreamServlet streamServlet = new HystrixMetricsStreamServlet();
    ServletRegistrationBean registrationBean = new ServletRegistrationBean(streamServlet);
    registrationBean.setLoadOnStartup(1);
    registrationBean.addUrlMappings("/actuator/hystrix.stream");
    registrationBean.setName("HystrixMetricsStreamServlet");
    return registrationBean;
}

(5)被监控微服务发布之后,可以直接访问监控 servlet,但是得到的数据并不直观,后期可以结合仪表盘更友好的展示;

(6)访问 http://localhost:9000/hystrix,输入监控的微服务端点地址,展示监控的详细数据,比如监控服务消费者 http://localhost:8090/actuator/hystrix.stream

  • 曲线波动图:记录了 2min 内该方法上流量的变化波动图,判断流量上升或者下降的趋势;
  • 实心圆:
    • 大小:代表请求流量的大小,流量越大球越大;
    • 颜色:代表请求处理的健康状态,从绿色到红色递减,绿色代表健康,红色就代表很不健康;

4.2 Turbine

之前,我们针对的是一个微服务实例的 Hystrix 数据查询分析,在微服务架构下,一个微服务的实例往往是多个(集群化)。比如自动投递微服务:

实例1(hystrix) ip1:port1/actuator/hystrix.stream
实例2(hystrix) ip2:port2/actuator/hystrix.stream
实例3(hystrix) ip3:port3/actuator/hystrix.stream

按照已有的方法,我们就可以结合 Dashboard 仪表盘每次输入一个监控数据流 url,进去查看。那么,手工操作能否被自动功能替代?

Hystrix Turbine(涡轮)聚合监控

微服务架构下,一个微服务往往部署多个实例,如果每次只能查看单个实例的监控,就需要经常切换很不方便,在这样的场景下,我们可以使用 Hystrix Turbine 进行聚合监控,它可以把相关微服务的监控数据聚合在一起,便于查看。

(1)新建项目 lagou-cloud-hystrix-turbine-9001,引入依赖坐标;

<dependencies>
    <!-- Hystrix Turbine 聚合监控 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-turbine</artifactId>
    </dependency>

    <!--
      引入 Eureka 客户端的两个原因:
        1. 微服务架构下的服务都尽量注册到服务中心去,便于统一管理;
        2. 后续在当前 turbine 项目中我们需要配置 turbine 聚合的服务。比如,我们
           希望聚合 cloud-service-autodeliver 服务的各个实例的 hystrix 数据流,
           那随后我们就需要在 application.yml 中配置这个服务名,那么 turbine 获取
           服务下具体实例的数据流的时候需要ip和port等实例信息,那么怎么根据服务名称
           获取到这些信息呢?从 Eureka 服务注册中心获取!
    -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
</dependencies>

(2)将需要进行 Hystrix 监控的多个微服务配置起来,在 application.yml 中开启 Turbine 及进行相关配置;

server:
  port: 9001
Spring:
  application:
    name: lagou-cloud-hystrix-turbine
eureka:
  client:
    serviceUrl:
      defaultZone: http://eurekaservera:8761/eureka/,http://eurekaserverb:8762/eureka/
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${spring.application.name}:${server.port}:@project.version@
# Turbine 配置
turbine:
  # appCofing 配置需要聚合的服务名称,比如这里聚合自动投递微服务的 hystrix 监控数据
  # 如果要聚合多个微服务的监控数据,那么可以使用英文逗号拼接,比如 a,b,c
  appConfig: cloud-service-autodeliver
  # 集群默认名称
  clusterNameExpression: "'default'"

(3)在当前项目启动类上添加注解 @EnableTurbine,开启仪表盘以及 Turbine 聚合;

(4)浏览器访问 Turbine http://localhost:9001/turbine.stream 就可以看到监控数据了;

(5)我们通过 dashboard 查看数据更直观,把刚才的地址输入 dashboard 地址栏;

5. 源码分析

(1)@EnableCircuitBreaker 激活了熔断功能,那么该注解就是 Hystrix 源码追踪的入口;

(2)注入 org.springframework.cloud.netflix.hystrix.HystrixCircuitBreakerConfiguration

(3)重点分析环绕通知方法

(4)GenericCommand 中根据元数据信息重写了两个很核心的方法,一个是 run 方法封装了对原始目标方法的调用,另外一个是 getFallBack 方法封装了对回退方法的调用。另外,在 GenericCommand 的上层类(GenericCommand → AbstractHystrixCommand → HystrixCommand → AbstractCommand)构造函数中会完成资源的初始化,比如线程池。

(5)接下来回到环绕通知方法那张截图,查看具体方法的执行;

(6)另外,GenericCommand 方法中根据元数据信息等重写了 run 方法(对目标方法的调用)、getFallback 方法(对回退方法的调用),在 RxJava 处理过程中会完成对这两个方法的调用。

posted @ 2022-04-10 16:47  tree6x7  阅读(45)  评论(0编辑  收藏  举报