likes
comments
collection
share

java异常的精细化管理-理论篇

作者站长头像
站长
· 阅读数 10

前言

在日常开发中,代码除了处理正常的业务逻辑,大多数情况其实是在与异常打交道,我们可以将异常分为大概以下三类:

  • 业务异常:请求参数不合理、数据处理结果有问题等等;比如电商网站下单买东西,库存没有了,导致下单失败;
  • 代码异常:空指针异常、索引越界异常等;这个就是开发人员编码不健壮导致的异常,是完全可以避免的;
  • 不可控异常:网络异常、请求超时异常等等;这种异常是开发人员不可控的,需要第三方配合才能够避免;

但是这篇文章要讲的当然不仅仅是上述这种粗粒度的异常管理,我们还需要在此基础上做到更加精细化的管理,以便帮助我们能够敏锐地感知业务的变化,并且辅助我们更好地优化代码;我们期望能够做到以下几点:

  1. 能够从异常监控中了解到出现了哪些异常,从异常日志中能够定位到出问题的类和方法;
  2. 能够通过异常监控了解到是哪个业务接口出问题了;
  3. 能够从异常监控中了解到整个业务接口的调用链路;
  4. 能够获取业务处理节点的输入参数,以便我们能够复现该异常;

下面我们来一步一步分析如何循序渐进地做到上述几点要求;

我们需要一个统一的异常处理规范

我们需要一个统一的异常处理规范,这一点非常重要,这是我们能够实现第一步的一个基础,一定不要忽视它;

把最应该抛的异常抛出来

我们想要在监控或者日志中了解到出现了哪些异常,这里的异常可不仅仅是你在代码当中throw出来的那个异常,而是真正导致业务中断的异常;比如下面这段代码:

String response = HttpClientUtil.request("http://www.xx.com")
try {
  return JsonUtil.parse(response, Map.class);
}catch(Exception e){
  return null;
}

我们在做json字符串解析时可能产生异常,上述这段代码在遇到异常时直接返回了null,这样处理貌似时没有问题的,但是却容易引发另一个异常,那就是空指针异常。

如果要在JsonParseExceptionNullPointerException之间做个选择的话,我肯定是选择抛出JsonParseException,毕竟这关系到一个开发人员的基本尊严;除此之外,我们还需要考虑到,一旦因此抛出NullPointerException,那么其实我们并不能直接发现问题的根因,你或许需要一段时间才能找到最终是因为JsonParseException导致返回null,这或许会让你因此而懊恼;

或许除了返回null,聪明的程序员还有别的处理方式:

String response = HttpClientUtil.request("http://www.xx.com")
try {
  return JsonUtil.parse(response, Map.class);
}catch(Exception e){
  // 也可以抛自定义的异常
  throw new RuntimeException("解析失败了,好好反思一下!");
}

这样的处理方式说不上有什么很大的好处,但是从源码上看确实很好理解,它告诉我们解析失败了,但是具体是什么原因解析失败了,我们一无所获,这显然不是一个很好的处理方式;

所以我们需要一个标准来衡量我们应该如何抛出真正的异常:

  • 我们在遇到异常时,首先应该判断当前业务逻辑是否还有挽回的余地,如果当前异常并不能影响我们业务的继续执行,那么它就不能算是真正的异常;若是当前业务已经无法挽回了,说明这就是真正的异常,那么就应该果断抛出异常;
  • 我们抛出的异常可能会因为项目规范要求抛出自定义的异常,这个时候请不要直接替换掉真正的异常,而是应该带着真正的异常一起抛出,这样能够让你在问题排查的时候获得更多有用信息;(ps:参考RuntimeException(String message, Throwable cause)构造函数)

异常的出现到底影响了哪个业务?

当一个异常在程序中抛出来时,是可以从栈帧日志中看出来具体是哪个类里面的方法抛出来的异常,但是却很难判断是哪个业务接口出问题了,我们需要更多的日志信息,根据上下文去做出判断才能确定问题的影响范围;例如下面这个例子:

  /**
   * 根据日期查询订单列表
   *
   * @param dateTimeStr
   * @return
   */
  @GetMapping("/queryOrderListByDate")
  public List<Order> queryOrderListByDate(String dateTimeStr) {
    LocalDate localDate = DateUtils.parseDateStr(dateTimeStr);
    return orderService.queryOrderListByDate(localDate);
  }

  /**
   * 根据日期查询账单列表
   *
   * @param dateTimeStr
   * @return
   */
  @GetMapping("/queryBillListByDate")
  public List<Bill> queryBillListByDate(String dateTimeStr) {
    LocalDate localDate = DateUtils.parseDateStr(dateTimeStr);
    return billService.queryBillListByDate(localDate);
  }

上面两个接口都是根据日期查询相关的业务数据,在查询之前都需要针对日期字符串进行解析,所以会使用到工具类DateUtils,假如用户输入的日期不合法,那么就会在工具类中抛出异常:

public final class DateUtils {

  private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

  public static LocalDate parseDateStr(String dateStr) {
    try {
      return LocalDate.parse(dateStr, DATE_TIME_FORMATTER);
    } catch (Exception e) {
      throw new RuntimeException("日期格式化出错啦!", e);
    }
  }
}

这样抛出的异常让我们很难快速定位到底出错的是查询订单列表还是查询账单列表,想要定位影响范围,需要费心找一下日志上下文;比如我们传一个不合法的参数2023-06-17-12,抛出的异常如下:

2023-06-17 23:30:50.075 ERROR 41253 --- [ctor-http-nio-2] a.w.r.e.AbstractErrorWebExceptionHandler : [4ebea4f4-2]  500 Server Error for HTTP GET "/test/queryOrderListByDate?dateTimeStr=2023-06-17-12"

java.lang.RuntimeException: 日期格式化出错啦!
  at com.example.exception_watcher.utils.DateUtils.parseDateStr(DateUtils.java:20) ~[classes/:na]
  Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
  *__checkpointHTTP GET "/test/queryOrderListByDate?dateTimeStr=2023-06-17-12" [ExceptionHandlingWebHandler]
Original Stack Trace:
    at com.example.exception_watcher.utils.DateUtils.parseDateStr(DateUtils.java:20) ~[classes/:na]
    at com.example.exception_watcher.controller.TestController.queryOrderListByDate(TestController.java:66) ~[classes/:na]
    at com.example.exception_watcher.controller.TestController$$FastClassBySpringCGLIB$$5d109e6d.invoke(<generated>) ~[classes/:na]

这样的错误日志我们或许还比较容易看出来是哪个业务接口出问题了,但是对于以下几种情况抛出的异常,开发人员是很难能从日志中快速定位影响的是哪个业务:

1.业务接口与异常抛出在同一个线程,并且栈帧调用特别深的情况下抛出的异常;

java异常的精细化管理-理论篇

2.业务接口与异常抛出不在同一个线程,异常抛出后无法根据上下文确定影响的业务接口;

java异常的精细化管理-理论篇 我们要想从异常中了解到影响的业务,势必要从创建异常的时候就知道当前处理的是哪一个业务,这样我们才能从抛出的异常中确定影响范围;那么就要求无论业务入口与异常抛出是否在同一个线程,我们在创建异常时,都能够从线程上下文中获取业务标记,并且随异常一起抛出;这样就能够满足我们的第二个要求,能够从异常中了解是哪个业务出问题了;

上述要求需要解决以下两个问题:

1.确定业务标记的切入点;

从调用方的维度来分类的话,可以分为内部调用(定时任务)与外部调用(开放api),最终的业务入口依然是方法级别的,通过动态代理的方式来注入业务标记是再好不过的了;但是如何确定业务入口,这个问题应该交给开发人员自己,就像spring里面的@GetMapping一样,开发人员更清楚业务入口在哪儿;

2.业务标记如何在上下文进行传递;

如果业务执行链路在同一个线程,那么这件事情挺好办的,java开发人员多半会采用ThreadLocal技术来解决这个问题,但是我们面临的另一种情况是线程间上下文传递,这或许需要一些更强的工具,比如transmittable-thread-local工具;

异常出现在业务执行的哪个阶段?

当一个异常被抛出时,我们可以从日志中看到调用链,就如上面的日志一样,我们看到了很多影响我们判断的噪音,我们只需要一些关键性业务节点的信息,至于每个方法的出栈入栈信息,我们没有必要关注;

假如异常能够反馈在规则2的关键性业务节点出异常了,那么我们就能快速确定经过哪些业务节点处理后该查询结果在规则2处理出错,而不是规则1规则3,也不是参数校验阶段;因为我们的任何业务处理阶段都可能调用一些公共的代码逻辑,导致我们无法判断到底是在哪个关键性处理阶段抛出的异常;

java异常的精细化管理-理论篇 但是对于一个业务调用需要在多个线程中处理的情况来说,不可能从异常栈帧日志中观察出整个业务的调用链路;所以我们遇到的依然是业务链路标记在上下文中传输的问题;

java异常的精细化管理-理论篇 我们期望在线程池中处理的业务出现异常的时候,能够知道调用链路是这样的:

【参数校验】⇒【获取价格】⇒【存储DB】

我们能够从上面的调用链路中知道是在【参数校验】下的【存储DB】业务处理阶段出问题了,而不是【查询缓存】、【查询DB】、【规则过滤】等阶段;

要想完成这样一个业务链路的跟踪,关键的技术依然是【动态代理+上下文传输】,如何定义业务处理节点是需要交给开发人员的,毕竟代码的编写与问题的定位都是开发人员操作。通过以上的分析,我们考虑提供自定义注解的方式来标记业务处理节点,通过动态代理拦截带有业务标记的注解,并将我们需要捕捉的信息(比如线程名称、业务标记等)注入上下文中,当业务处理有问题时,我们会构建自定义异常(也可能会出现一些我们没有捕捉到的异常,这需要我们逐步优化代码),我们构建的自定义异常是可以从上下文中获取之前注入的链路信息,并随异常一起抛出来,那么我们就可以统一对异常实现更细粒度的管理了;

如何复现异常?

线上服务抛出的某些异常可能是偶发性的,想要通过复现异常的方式来定位具体问题就变得有点困难,但是如果我们在异常发生的时候就收集好当前方法的输入参数,那么将大大地降低我们复现异常的难度。

为了达到上面的期望,我们有必要在上下文中暂存当前方法的输入参数,当发生异常时,我们将把上下文参数放进自定义异常中,随着异常一起抛出来,这样我们就能够在日志中获取距离异常抛出点最近的输入参数,那么复现异常就变得非常简单了;

小结

异常的精细化管理是从业务的维度出发,通过针对异常的细粒度追踪,帮助开发人员快递定位问题及其影响范围,及时发现不健壮的业务逻辑并推动这部分代码优化;要想达到上述期望效果,需要相关工具的开发以及开发人员的配合使用;

最后依然需要重申一点,统一的异常处理规范是需要整个团队达成共识的:

1.发现异常首先需要判断是否中断当前业务处理,如果业务可以不受影响,那么不抛出异常,否则请直接抛出该异常,避免返回null等默认值造成其他异常;

2.抛出自定义异常或试图通过其他异常替换当前异常时,请一定把当前异常封装好一起抛出,避免异常掩盖,丢失真正的异常;