{T}

服务调用失败的处理策略与实践

在微服务架构的应用中,微服务之间一般有两种类型的交互方式,一种是使用消息中间件的异步消息模式,也就是第 14 节中提到的事件驱动设计,微服务之间进行交互的是消息、事件和命令;另外一种是基于 REST 或 gRPC 的同步调用,微服务之间通过 API 调用来进行交互。从设计的角度来说,事件驱动的方式更加适合于微服务架构的应用,但是在实际开发中,基于 REST 或 gRPC 的 API 同步调用的使用更加广泛。这主要是因为 API 调用的方式可以与开发中常用的方法调用进行类比,更容易理解和实现。本节介绍的是服务调用时如何进行错误处理,以 REST API 来说明。

服务 API 调用

服务 API 调用是微服务之间最直接的交互方式。由于不同微服务有各自界定的上下文和内部的模型,相互之间只能通过开放 API 来访问。在实现某些业务场景时,一个微服务可能需要调用其他微服务的 API。比如,在示例应用中,行程管理服务在创建行程时需要与行程验证服务和支付服务进行交互。如果不使用基于异步消息传递的 Saga 来实现,就必须由行程管理服务来调用另外两个服务的 API。

服务的 API 调用可以与代码中常见的方法调用进行类比,只不过调用方式改成了发送 HTTP 请求并解析响应内容,这其中还涉及请求和响应内容的序列化与反序列化。如果采用 API 优先的设计,可以直接从 OpenAPI 文档中通过 Swagger 工具生成调用 API 的客户端,屏蔽底层 HTTP 请求的细节,可以直接等同于方法调用。

在下面对 API 调用的描述中,发送请求的一方称为请求方,接收请求的一方称为响应方。

API 调用的失败场景

由于 API 调用发生在两个独立运行的微服务之间,可能出现错误的情况要比一般的方法调用要多,主要分为下表中的几类。

错误类型 说明

网络错误 由于网络原因,造成请求方的请求无法发送到响应方。造成的原因可能是网络连接中断,或者是响应方已崩溃

协议错误 请求方所发送的请求格式,不满足双方所达成的交互协议的要求

应用错误 响应方在处理请求时产生的错误

网络错误指的是由于网络原因,而造成请求方的请求无法发送到响应方。产生该错误的原因,可能是请求方和响应方之间的网络连接出现问题,也有可能是响应方已崩溃,或者是在重新启动的过程中。这样的错误会在底层的 TCP/IP 协议栈中抛出异常。在 Java 中,与这样的错误相关的是 SocketException 异常类型及其子类型 ConnectException 和 NoRouteToHostException。

协议错误指的是请求方和响应方在数据传输的协议上产生了不一致,其中一方没有遵循协议的要求。这类错误分成客户端错误和服务器端错误两种。客户端错误指的是客户端发送的请求内容不满足协议的格式要求,比如非法的内容格式、错误的参数类型或数据格式;服务器端错误指的是服务器发送的响应内容不满足协议的格式要求,比如非法的内容格式。

应用错误指的是应用在处理请求时产生的内部错误,与每个应用的业务逻辑紧密相关,比如请求的内容虽然满足协议的要求,但是无法通过应用的验证,或是由于应用所依赖的外部服务产生了错误。

上述三类错误可以从另外一个维度分成两个类别:当网络错误产生时,请求方发送的 HTTP 请求无法得到响应;而协议错误和应用错误则可以得到响应,但是 HTTP 响应中包含的是表示错误的状态码。

HTTP 协议使用不同的状态码来表示响应的状态。HTTP 客户端可以根据响应的状态码来进行不同的处理。HTTP 状态码中表示错误的是以 4 开头的客户端错误,和以 5 开头的服务器端错误。

下表是以 4 开头的 HTTP 状态码的说明。

状态码 名称 说明

400 Bad Request 非法的请求

401 Unauthorized 未授权的访问请求,没有包含认证信息或认证信息无效

403 Forbidden 被禁止的访问请求

404 Not Found 访问的资源不存在

405 Method Not Allowed 不支持的 HTTP 方法

409 Conflict 资源的状态产生冲突

412 Precondition Failed 服务器无法满足客户端在 HTTP 头中指定的前置条件

415 Unsupported Media Type 请求内容使用了不支持的媒体类型

422 Unprocessable Entity 无法处理的请求实体

429 Too Many Requests 客户端在一段时间内的请求数量过多

下表是以 5 开头的 HTTP 状态码的说明。

状态码 名称 描述

500 Internal Server Error 未预期的服务器内部错误

501 Not Implemented 暂时未实现的功能

502 Bad Gateway 当服务器作为网关或代理时,接收到来自上游服务器的无效响应

503 Service Unavailable 服务器暂时无法处理请求

504 Gateway Timeout 当服务器作为网关或代理时,上游服务器没有在指定的时间内返回响应

在 API 调用的错误处理中,很重要的一点是根据 HTTP 状态码来应用不同的处理策略。当一个 HTTP 请求出现错误时,最直接的处理方法是出错,抛出异常之后由应用代码进行处理;另外一种做法是对请求的响应结果进行缓存,当出现错误时,在缓存中查找同样请求的上一次响应值,作为这次请求的响应。最后一种做法是使用固定值来作为返回结果。

错误处理策略

当 API 调用发生错误时,可以采取不同的错误处理策略。

重试

当 API 调用出错时,一种常见的处理方式是重试。重试在某些情况下是有作用的,比如由于服务器突发的过大负载,或是与依赖的支撑服务的连接出现暂时性中断。当再次发送请求时,服务器可能已经从故障中恢复,可以正常处理请求。在应用运行时,总是会有各种突发的瞬时性的问题,重试则可以解决这些问题。

只有幂等的请求才可以进行重试,也就是说,重复的请求不会产生副作用。HTTP 中的 GET 请求从语义上来说是幂等的,在实现中,也应该避免在处理 GET 请求时改变资源的状态。对于 POST、PUT 和 DELETE 这样的请求,是否幂等取决于请求的语义和实现方式。对于非幂等的请求,重试时需要谨慎注意。比如,在进行支付的 API 调用时,如果调用超时,尽管客户端会出错,但实际的支付操作可能已经正常完成。客户端如果进行重试,可能会造成重复支付的情况。

在重试时可以有不同的策略,典型的策略包括固定时间间隔指数回退间隔,对于重试的次数,也应该设置上限。在 Java 中,推荐使用 Spring Retry 库来进行重试。在下面的代码中,RetryTemplate 是 Spring Retry 提供的重试模板类,通过构建器来创建。它在遇到 SampleServiceException 异常时会进行重试,最大的重试次数是 3,每次重试之间的间隔是 1 秒钟。RetryTemplate 的 execute 方法用来执行需要进行重试的代码逻辑。

code
public class RetryExample {
  public void doRetry() {
    final SampleService service = new SampleService();
    final RetryTemplate template = RetryTemplate.builder()
        .maxAttempts(3)
        .fixedBackoff(1000)
        .retryOn(SampleServiceException.class)
        .build();
    final String result = template.execute(context -> service.test());
    System.out.println(result);
  }
}

设置网络超时

HTTP 协议采用的是请求-响应的模式。在获取到服务器端返回的响应之前,客户端需要进行等待。在等待响应时,要避免无限制地等待,所有的 HTTP 请求都需要加上超时时间。如果超过给定的时间间隔,仍然没有接收到响应,则需要进入超时错误的异常处理。这可以避免长时间的请求占用过多的资源。

常用的 HTTP 客户端都提供了对请求超时的支持。默认的超时设置不一定满足应用的要求,需要根据被调用服务的特征来进行调整,过长或过短的超时设置都有问题。

应用请求限制

请求限制用来限制一段时间内允许的最大请求的数量。通过限制请求数量,可以避免负载过大造成服务崩溃。

使用断路器

断路器的概念来自电气工程领域。断路器平时处于闭合状态,当电路中的电流过大时,断路器会打开,从而切断电路,保护电路中的元器件不受影响。在 API 调用中,断路器可以作为是否允许调用进行的开关。正常情况下,断路器处于闭合状态,API 调用可以正常进行;当 API 调用在一段时间内频繁出错时,断路器会打开,从而阻止 API 调用的进行。当断路器处于打开状态时,所有 API 调用会直接出错。在一段时间过后,客户端可以进行重试。如果 API 调用成功,那么闭合断路器。

断路器负责监控成功和失败的 API 调用的数量。当一段时间内的错误率超过指定的阈值时,这通常意味着响应方处于不可用的状态,新的调用请求也大概率会失败。与其让所有请求因为超时而失败,还不如直接失败,断路器可以避免服务之间的级联失败。

在示例应用中,对于接收到的行程创建请求,行程管理服务需要调用支付服务来完成支付。当由于请求过多,造成支付服务的响应时间变长之后,新的请求会由于等待支付服务的返回结果,而处于阻塞状态,直到出现超时错误才能恢复。这会造成行程管理服务中堆积大量未处理的请求,可能造成该服务的崩溃。在使用了断路器之后,当监测到支付服务出现问题时,新的请求会直接出错,从而可以快速地进行处理。

服务器端错误处理

响应方需要处理客户端产生的错误。通常的 REST API 实现框架都提供了相应的支持。以 Spring MVC 为例,如果请求的 URL 查询参数或请求内容的格式不正确,Spring MVC 会自动返回 400 错误。在处理请求中产生的异常,Spring MVC 也会进行捕获,并返回 500 错误,应用代码也可以添加自定义的错误处理逻辑。

在下面代码中,OrderService 的 findOrder 方法在找不到 orderId 所对应的 Order 对象时,会抛出运行时异常 OrderNotFoundException。

code
@Service
public class OrderService {
  public Order findOrder(final String orderId) {
    throw new OrderNotFoundException(orderId);
  }
}

在对应的 Spring MVC 的 REST 控制器 OrderController 类中,getOrder 方法并不需要进行异常处理。handleException 方法上的 @ExceptionHandler 注解,用来声明该方法包含了对 OrderNotFoundException 异常的处理逻辑。在异常产生时,handleException 方法的返回值会作为 HTTP 响应的内容。

code
@RestController
@RequestMapping("/order")
public class OrderController {
  @Autowired
  OrderService orderService;
  @GetMapping("{orderId}")
  public Order getOrder(@PathVariable("orderId") final String orderId) {
    return this.orderService.findOrder(orderId);
  }
  @ExceptionHandler(OrderNotFoundException.class)
  public ResponseEntity<Void> handleException(final OrderNotFoundException exception) {
    return ResponseEntity.notFound().build();
  }
}

另外一种做法是通过 @ControllerAdvice 注解来添加全局的异常处理器。下面代码中的 handleException 方法对 PaymentException 异常进行了处理。

code
@ControllerAdvice
public class GlobalExceptionHandler {
  @ExceptionHandler
  public ResponseEntity<String> handleException(final PaymentException exception) {
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(exception.getMessage());
  }
}

客户端错误处理

在客户端处理 API 调用错误时,根据调用方式的不同,有不同的处理方式。

使用 HTTP 客户端

最直接的做法是使用 Java 中的 HTTP 客户端来发送请求和处理响应。在下面的代码中,PaymentGateway 的 makePayment 方法使用 OkHttp 来发送 HTTP 请求。如果发送 HTTP 请求时产生 IOException 异常,这说明产生了网络相关的错误;如果可以得到 HTTP 响应,则根据 HTTP 状态码来判断是否出错。对于这两种出错的情况,都抛出运行时异常 PaymentException。

code
@Service
public class PaymentGateway {
  public static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
  private final OkHttpClient client = new OkHttpClient();
  public String makePayment(final String orderId) {
    final String url = "https://httpbin.org/status/" + orderId;
    final RequestBody body = RequestBody.create(JSON, "{}");
    final Request request = new Request.Builder()
        .url(url)
        .post(body)
        .build();
    try (final Response response = this.client.newCall(request).execute()) {
      if (response.isSuccessful()) {
        return response.body().string();
      } else {
        throw new PaymentException(orderId);
      }
    } catch (final IOException e) {
      throw new PaymentException(orderId);
    }
  }
}

使用 Swagger 客户端

如果从 OpenAPI 文档中产生了 Swagger 客户端代码,可以直接使用。下面代码中的 TripServiceProxy 类使用行程管理服务生成的 TripApi 来调用服务,只需要捕获 ApiException 异常就可以处理错误。

code
@Component
public class TripServiceProxy {
  private final TripApi tripApi = new TripApi();
  public void createTrip(final CreateTripRequest request) throws ApiException {
    this.tripApi.createTrip(request);
  }
}

有一些流行的开源库可以用来处理 API 调用失败。Java 中常用的库包括 Netflix 的 Hystrix、阿里巴巴的 Sentinel 和 resilience4j。Hystrix 是这一领域的先行者,很多目前广泛流行的理念都来自这个库。不过目前 Netflix 已经不再开发 Hystrix,这个项目目前处于维护状态。阿里巴巴的 Sentinel 是另外一个流行的选择。本节将对这两个库进行简要地介绍。

Hystrix

Hystrix 把对 API 的调用抽象成命令的执行。首先需要为每个 API 调用请求创建 HystrixCommand 类的子类,类型参数 R 是命令的返回值类型。

下面代码中的 MakePaymentCommand 类封装了对 PaymentGateway 的 makePayment 方法的调用。在构造器中,设置了命令的分组和超时时间。命令执行的逻辑封装在 run 方法中,当命令执行出现错误时,getFallback 方法的返回值会作为命令的执行结果。

code
public class MakePaymentCommand extends HystrixCommand<String> {
  private final PaymentGateway paymentGateway;
  private final String orderId;
  public MakePaymentCommand(final PaymentGateway paymentGateway,
      final String orderId) {
    super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("Payment"))
        .andCommandPropertiesDefaults(
            HystrixCommandProperties.Setter()
                .withExecutionTimeoutInMilliseconds(10000)));
    this.paymentGateway = paymentGateway;
    this.orderId = orderId;
  }
  @Override
  protected String run() throws Exception {
    return this.paymentGateway.makePayment(this.orderId);
  }
  @Override
  protected String getFallback() {
    return "fallback";
  }
}

HystrixCommand 类提供了不同的方式来执行命令,如下表所示。

方法 返回值类型 说明

execute R 同步的命令执行

queue Future 异步的命令执行

observe Observable 使用RxJava进行反应式执行

在下面的代码中,创建了一个新的 MakePaymentCommand 对象,并调用 execute 方法来进行同步命令执行。

code
public String makePaymentHystrixSync(final String orderId) {
  return new MakePaymentCommand(this.paymentGateway, orderId).execute();
}

Hystrix 支持对命令执行结果的缓存。HystrixCommand 的子类可以覆写 getCacheKey 方法来提供缓存的键的名称。在启用了缓存之后,命令执行时会首先尝试从缓存中读取之前保存的值来作为响应。一般的做法是在处理 GET 请求时读取缓存值,而处理 POST、PUT 和 PATCH 请求时会使得缓存的值无效。

Hystrix 提供了一个仪表板界面来监控 API 调用的状态,该仪表板的界面如下图所示,从中可以看到 MakePaymentCommand 命令的执行结果的统计信息。

Sentinel

Sentinel 使用资源(Resource)来描述需要被保护的对象,最常用的资源是 Java 中的方法调用。通过 SphU.entry 和 Entry 的 exit 方法来分别定义保护的起点和终点。在下面的代码中,SphU.entry 方法的参数是资源的名称,在使用了 try-with-resources 语句之后,不再需要显式的调用 Entry 的 exit 方法。BlockException 异常表明方法调用被阻止运行,在处理该异常时可以添加相应的错误处理逻辑。下面的代码展示了 Sentinel 的基本用法。

code
public String makePaymentSentinel(final String orderId) {
  try (final Entry ignored = SphU.entry("payment")) {
    return this.paymentGateway.makePayment(orderId);
  } catch (final BlockException ex) {
    return "fallback";
  }
}

Sentinel 可以通过规则来进行流量控制,这可以避免服务调用由于负载过大而崩溃。在下面的代码中,每个 FlowRule 对象表示一个规则,规则应用在指定的资源上。

code
static {
  final List<FlowRule> rules = new ArrayList<>();
  final FlowRule rule1 = new FlowRule();
  rule1.setResource("payment");
  rule1.setCount(20);
  rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
  rules.add(rule1);
  FlowRuleManager.loadRules(rules);
}

Sentinel 也提供了功能强大的仪表板,如下图所示。

总结

微服务之间可以通过同步 API 调用来进行交互。本节以 REST API 为例,说明了服务调用失败时的处理策略,包括重试和使用断路器等,还介绍了 Hystrix 和 Sentinel 这两个开源库。通过本节的学习,你可以掌握在 API 调用时如何对可能出现的错误情况进行处理,以及使用 Hystrix 或 Sentinel 来帮助你解决问题。