0
点赞
收藏
分享

微信扫一扫

程序异常和日志的设计方法

任何一套程序都少不了异常和日志,从纯技术编码的角度来讲处理起来并不复杂。但这两块编码的目的更多是围绕非正常情况来设计的,所以从业务和运维的角度来讲想处理好日志和异常其实并不简单。所有的团队设计异常和日志的初衷都是好的,但有些团队在落地执行时并不会达到所预想的目的,甚至是为了制定而制定,产生大量无用编码的同时无形中增加了开发同学的工作量,同时也会影响程序的性能和代码的可读性。

  • 异常的目的:记录程序中异常数据,重在结果;
  • 日志的目的:记录程序中流程数据,重在过程;

以上是笔者个人的归纳,下文中也会围绕这两个核心点从理论到场景适配展开讲述。因每套系统的业务情况和复杂度都不太一样,所以建议按需入座,同时文中所描述的是笔者个人的观点,能力有限,不妥的地方欢迎指导留言以免带偏。

首先还是来一些基础,复盘一下java的API。

一、异常

如果某个方法不能按照正常的途径完成任务,就可以通过另一种路径退出方法。在这种情况下会抛出一个封装了错误信息的对象。此时,这个方法会立刻退出同时不返回任何值。另外,调用这个方法的其他代码也无法继续执行,异常处理机制会将代码执行行交给异常处理器。java程序中一般会有编译错误、逻辑错误、运行时错误(异常处理)这几种非正常的情况。

程序异常和日志的设计方法_异常处理

1.1、异常的分类

1.1.1、Error

指系统错误,java 运行时系统的内部错误和资源耗尽错误,应用程序不会抛出该类对象。如果出现了这样的错误,除了告知用户,剩下的就是尽力使程序安全的终止。

1.1.2、Exception

指程序异常,异常有两个分支,一个是运行时异常 RuntimeException,一个是CheckedException。

CheckedException

外部错误,这种异常发生在代码编译阶段,Java 编译器会强制程序去捕获此类异常,然后要求把这段可能出现异常的程序进行 try catch包装(如果在代码中没有找到相关的catch语句会抛出ThreadGroup.uncaughtException()方法,在字节码编程时会出现这种情况)。

需查异常是在方法定义时用throws关键字声明的再用catch捕获后可以进行恢复,在catch中最简单的处理方式是声明e.printStackTrace()语句。使用时两个三点:

  1. throws时不要直接抛出Exception,这样系统会从Exception子类一个一个搜索match很费时间;
  2. catch时要分层catch即要从子类到父类一层层catch;
private static void throwOldException() throws IOException {
throw new IOException("a forced exception");//需查异常
}
RuntimeException

如字面意思,指运行时错误,这种异常都发生在程序运行阶段,程序员的错误造成。API都需要继承RuntimeException,由程序员设计的异常规则。不需查异常是被设计来强化方法的约定(方法的约定指方法使用者和调用者之间的一种约定,包括调用方法和返回结果的约定等)的。

因为执行了异常检查的语块比没执行异常的语块运行速度要慢很多,所以不建议把整个方法的实现通通包装起来,在使用时几点建议可以参考:

  1. 用时间频度来确定,也就是说不会发生异常的地方不要加入catch语句;
  2. 包装可选方法,​不允许调用的方法不实现具体的功能,直接抛出UnsupportedOperationException来强制不能调用此方法。
  3. 对经常需要调用的语句不要进行异常处理,在逻辑上也显然不能作为异常来处理。可以有两种方法来代替异常处理功能:1、是使用特殊的返回值;二、是前导检查法,例如string.length在使用前先判断是否越界了。
private static void throwNestedException() throws NestedException {
try {
String s = "abc";
if(s.equals("abc")) {
throwOldException();//不需查异常
}
} catch (IOException e) {
throw new NestedException(e);
}
}

1.1.3、throws和throws的区别

位置不同

throws 用在函数上,后面跟的是异常类(可以跟多个), throw 用在函数内,后面跟的是异常对象,一次只能一个对象。

功能不同

  1. throws 用来声明异常,让调用者只知道该功能可能出现的问题,可以给出预先的处理方式;throw 抛出具体的问题对象,执行到 throw,功能就已经结束了,跳转到调用者,并将具体的问题对象抛给调用者。也就是说 throw 语句独立存在时,下面不要定义其他语句,因为执行不到;
  2. throws 表示出现异常的一种可能性,并不一定会发生这些异常;throw 则是抛出了异常,执行 throw 则一定抛出了某种异常对象;
  3. 两者都是消极处理异常的方式,只是抛出或者可能抛出异常,但是不会由函数去处理异常,真正的处理异常由函数的上层调用处理;

1.2、自定义异常的方法

1.2.1、自定义参数

class MyException2 extends Exception {
private int x;
public MyException2() {}
public MyException2(String msg) { super(msg); }
public MyException2(String msg, int x) {
super(msg);
this.x = x;
}
public int val() { return x; }
public String getMessage() {
return "Detail Message: "+ x + " "+ super.getMessage();
}
}

1.2.2、重新捕获异常

public class Rethrowing {
public static void f() throws Exception {
System.out.println("originating the exception in f()");
throw new Exception("thrown from f()");
}
public static void g() throws Exception {
try {
f();
} catch(Exception e) {
System.out.println("Inside g(),e.printStackTrace()");
e.printStackTrace(System.out);
throw e;
}
}
public static void main(String[] args) {
try {
g();
} catch(Exception e) {
System.out.println("main: printStackTrace()");
e.printStackTrace(System.out);
}
}
}

1.2.3、异常链

想要在捕获一个异常后抛出另一个异常,并且希望把原始异常的信息保存下来,这称为异常链。如果把自定义的异常链起来,应该使用initCause()方法,而不是原始的cause参数。

class DynamicFieldsException extends Exception {}

public class DynamicFields {

public Object setField(String id, Object value) throws DynamicFieldsException {
DynamicFieldsException dfe = new DynamicFieldsException();
dfe.initCause(new NullPointerException());//用此方法封装了原始的异常信息
throw dfe;
}
public static void main(String[] args) {
DynamicFields df = new DynamicFields();
try {
Object field = df.setField("d", null); // Exception
} catch(DynamicFieldsException e) {
e.printStackTrace(System.out);
}
}
}

1.2.4、异常掩盖

class VeryImportantException extends Exception {
public String toString() {
return "A very important exception!";
}
}

class HoHumException extends Exception {
public String toString() {
return "A trivial exception";
}
}

public class LostMessage {
void f() throws VeryImportantException {
throw new VeryImportantException();
}
void dispose() throws HoHumException {
throw new HoHumException();
}
public static void main(String[] args) {
try {
LostMessage lm = new LostMessage();
try {
lm.f();
} finally {
lm.dispose();
}
} catch(Exception e) {
System.out.println(e);//此处的异常被finally给替换了
}
}
}

1.2.5、把CheckedException转换为RuntimeException

class WrapCheckedException {
void throwRuntimeException(int type) {
try {
switch(type) {
case 0: throw new FileNotFoundException();
case 1: throw new IOException();
case 2: throw new RuntimeException("Where am I?");
default: return;
}
} catch(Exception e) { // Adapt to unchecked:
throw new RuntimeException(e);
}
}
}//这种技术不会导致异常丢失也不会破坏异常链

1.3、异常处理程序的架构设计

一些互联网系统的特点一般都是分布式的,而且每个应用的集群节点都不少,这类系统线上问题基本全是通过日志(包含异常日志信息和业务日志信息)来排查的,但如果从几百上千台服务中找到所需要的日志信息也并不简单,虽然有些公司会提供线上日志的搜索功能,但这个搜索功能相对通用。如果在架构设计时采用必要的手段可以做下互补收益还是很大的,本小节描述一下等者一直使用的异常处理的一种方法,此设计围绕异常发现和处理来设计的,大体的设计流程如下:

下述的设计是用在一个相对比较复杂的系统上的设计,大约cpu=4000核的一个应用集群。

程序异常和日志的设计方法_java_02

上图中几点说明如下:

  • 异常的触达(实时告警):需要额外的监控系统,通过短信、PUSH、邮件等方式触达,一般常用的是push,重要的系统也会采用短信的方式;
  • 异常处理的核心是解决问题:并不是记录异常,所以当发现问题的首要任务是处理问题,即实时处理;异常一般会在方法防御层和执行层触发,即上面提到的参数校验和核心业务执行;
  • 异常的处理如果全流程分4种处理方式:
  • 分频率的内存重试:一般可处理由于网络波动等引起的异常;
  • 异步补偿:通过发送MQ自产自销,一般重要的系统还会关联一个纠正系统,用于纠正数据不是太重要的方法错误入参或流程数据,当然了也可以有多个消息订阅方,也可关联一个纠正系统;
  • 离线任务:采用定时任务,比如退款失败这种情况,适合更长尾巴的操作;
  • 人工处理:上述流程都不能自动处理了,只能依靠人工来解决,很多是外部原因引起的,需要上下游部门一起处理,比如打款的卡号在打款过程中银行给封掉了等情况;
  • 异常表:会记录异常信息(供查询)、上下文信息(供程序纠正用);
  • 表归档:定时归档异常表数据,因为正常情况下异常表数据行不会太多,但如果由于人为因素,在高并发系统中可能分分钟就几百W的数据,没必要为异常会做分表逻辑,采用定时备份就好了;
  • 数据分析:在综合分析日志信息和异常表信息,定时督促协同部门进行程序优化;

1.3.1、关于设计的一点建议

架构设计是一个可协商和讨论的事情,重要的还有认知问题,认知的层次不同,有时就会产生杠的情况,杠多了就内卷了。本来架构设计没有标准的模式,适合团队现状和业务现状为佳,即团队达成一致就好。

其它的设计工作也类似,首先设计者一定要清醒,至少要知道设计的目的是啥。如果顶层设计正确,还要考虑落地时团队的认知情况,这会直接影响最终的落地效果,必要时要做些折衷(折衷并不意味设计妥协,拉低设计水平,所以不要轻易改变顶层的设计)。

架构设计的好坏是设计者的能力问题,设计的落地执行是一个认知问题,所以好的设计者一定本身是一个技术大牛+了解人性的人。现实情况是一般的设计人员本身是职位比较高的能力也不错,但在落地执行时往往会存在各种问题,简单点有两种解决方方案:1、协商引导让团队共同进步,2、利用职位之便强压团队执行。

所以建议设计者一定要明白以上道理,这两方面缺一不可,当然了能力不行的设计者是另外一回事了。

1.3.2、关于异常码

这东西一般来讲只能在固定团队固定系统范围内达成统一,很多时候一个团队中都会存在几种标准是正常的情况。原因很简单,1、跨团队时人家不一定会遵守你的规范,耦合性太强;2、同团队时各系统的业务不一定,很难定一个大而全的标准,而且修改起来比较麻烦。

所以建议的方式就是按业务特点来制定或是只规范技术层面的异常码(类似http中的200,404, 500这样事实上的标准编码)。或采用第三方框架内制定的编码做为团队标准。

1.4、异常小结

使用异常主要是降低错误处理代码的复杂度,如果不使用异常就要在很多地方进行处理,使用异常后只在一个地方处理即可,前提是支持链式。这种机制能有效的把”执行过程中应该做什么事”和”出了问题怎么办”的代码相分离,使代码的阅读、编写、调试工作都井井有条。

当抛出异常后,java将使用new在堆上创建异常对象,然后当前的执行路径被终止,并且从当前环境中弹出对异常对象的引用,此时异常处理机制接管程序,并在恰当的地方来继续执行。异常将每个异常点当作一个事务来考虑,如果出了问题就应该放弃整个计算过程,然后在程序中的不同恢复点进行处理。JAVA支持终止模型和恢复模型两种异常处理机制,一般采用终止模型。

作为程序员,应该在代码中进行错误检查,一般RuntimeException异常是在一个地方发生异常在另一个地方引发错误


finally在异常处理时是为了把除内存之外的资源恢复到它们的初始状态,比如已经打开的文件或网络连接等。开发异常处理的初衷是为了方便程序员处理错误。使用原则如下:

  • 在恰当的级别处理问题(在知道如何处理的情况下才捕获异常)
  • 进行少许修补,然后绕过异常发生的地方继续执行
  • 用别的数据进行计算,以代替方法预计会返回的值
  • 把当前环境下能做的事尽量做完,然后把相同/不同的异常抛给更高层
  • 终止程序
  • 进行简化(当异常模式使程序复杂时)
  • 让类库和程序更安全​

二、日志

如果说异常处理是结果,那么日志就是对过程。日志打印是一个设计问题,没有编一的标准。通常来讲一个相对比较好的代码中,日志和注释要占总代码量的三分之二或二分之一的总代码行(同时要考虑代码的可读性)。广义上来说,日志的设计基本是围绕以下方面展开的,所以在设计之初要依据目的展开设计,切不可全盘设计。

程序异常和日志的设计方法_日志处理_03

2.1、日志设计的几点说明

2.1.1、日志格式的建议

建议以kv的方式定义,同时用空格分隔,目的是:1、格式清晰可读性好;2、用固定分隔符分隔方便后续的统计处理;3、定义规范性的k有助于日志定义,比如把埋点标识定义来k=s act=xxx,这里k=s是固定值表示埋点的意思,act后面是自定义的内容表示特定的埋点。下面就日志格式做下简要的设计说明:

//完整的info日志信息如下,省略了部分内容
INFO tid=7681031554566774759 appid=orderApp ip=/172.252.0.1:57370 uri=ListByState inTime=1652598038503
INFO tid=7681031554566774759 appid=orderApp ip=/172.252.0.1:57370 uri=ListByState inTime=1652598038503 deptId=1
INFO tid=7681031554566774759 appid=orderApp ip=/172.252.0.1:57370 uri=ListByState inTime=1652598038503 deptId=1 stateListSize=2
INFO tid=7681031554566774759 appid=orderApp ip=/172.252.0.1:57370 uri=ListByState inTime=1652598038503 exec=12
  • tid:事务调用标识,区分每次调用和用于跟踪;
  • appid:调用方唯一标识
  • ip:调用方ip和端口
  • inTime:接收请求的时间戳;
  • uri:被调用接口名称
  • deptId和stateListSize:入参和过程数据
  • exec:调用耗时

2.1.2、日志的代码实现

上图中所示,在这里不建议更改第三方jar包中实现也不建议去包装第三方的实现,因为会存在耦合性。业务日志肯定是采用硬编码的实现。但对于通用的日志笔者更建议采用字节码+注释的实现,原因如下,但同样的技术实现也比较复杂。:

  • 减少开发同学的代码量;
  • 省去了规范的推广过程;
  • 可采用配置方式实现热替换;
  • 编译期注入不会产生线上问题;
  • 可细化到代码行;

下面给出一个google grpc拦截器的例子:

public class DelegateInterceptor implements ServerInterceptor {

@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata metadata, ServerCallHandler<ReqT, RespT> serverCallHandler) {
long inTime = System.currentTimeMillis();

String trackId = metadata.get(Metadata.Key.of(CONST.TRACKID_KEY, Metadata.ASCII_STRING_MARSHALLER));
if (StringUtils.isEmpty(trackId)){
trackId = String.valueOf(genLogId(System.nanoTime()));
}

StringBuilder delegateLog = new StringBuilder();
delegateLog.append("tid=").append(trackId)
.append(CONST.SPLIT_BLANK).append("appid=").append(TokenParser.appId())
.append(CONST.SPLIT_BLANK).append("ip=").append(serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR))
.append(CONST.SPLIT_BLANK).append("uri=").append(serverCall.getMethodDescriptor().getFullMethodName())
.append(LogConst.SPLIT_BLANK).append("param=").append(serverCall.getAttributes()) //TODO 打印原始入参
.append(CONST.SPLIT_BLANK).append("inTime=").append(inTime);

//保存请求时间和相关日志到请求线程中,供后面拦截器打印用
metadata.put(Metadata.Key.of(CONST.DELEGATE_LOG_KEY, Metadata.ASCII_STRING_MARSHALLER), delegateLog.toString());
metadata.put(Metadata.Key.of(CONST.DELEGATE_INTIME_KEY, Metadata.ASCII_STRING_MARSHALLER), String.valueOf(inTime));

log.info(delegateLog.toString());

//下面设置的值必须为原始值,不能自定义的变量,保持参数的纯净
DelegateCall<ReqT, RespT> serverCallDelegate = new DelegateCall<>(serverCall);
serverCallDelegate.setMetadata(metadata);
DelegateCallListener<ReqT, RespT> delegateCallListener = new DelegateCallListener<>(serverCallHandler.startCall(serverCallDelegate, metadata));
delegateCallListener.setServerCall(serverCall);

return delegateCallListener;
}
//uri=xxx.xx.InterfaceName/MethodName为了隐私问题替换掉了,同时这个信息会保存在线程上下文中提供工具类给业务代码使用
tid=7681031554566774759 appid=orderApp ip=/172.252.0.1:57370 uri=ListByState inTime=1652598038503

2.1.3、日志打印的建议

  • 有交互的地方:一般程序会对远程调用后的数据做加工,在排查问题时的第一步就是要定位是哪方出现了问题,所以尽量保留原始调用信息,打印入参和出差;
  • 关于分页信息:分页数据量比较大,全部打印会占用大量的磁盘空间,所以笔者建议对列表类的交互数据只打印size即可;

2.2、关于埋点信息的设计

埋点的设计相对来说不是太复杂,其流程主要如下:

程序异常和日志的设计方法_异常处理_04

可以事先定义好埋点日志的标识,比如上面提到的k=s act=xxx,然后通过ELK或通过大数据平台的方式来处理。这里有个关键点是日志信息一定是可被解析的,对于不符合标准的数据也不必在意,因为降噪程序会处理掉,比如:

//原始日志,最后的param是不符合要求的
tid=001 k=s act=addUser param:{name:ddd, age:19}

//在经过降噪后可以处理成
tid=001 k=s act=addUser
或对:进行处理,一般不会处理
tid=001 k=s act=addUser param={name:ddd, age:19}

2.3、关于链路跟踪的设计

链路跟踪要采用隐式传参技术,其设计有几个要点:

  • 日志中定义spanID和parentID,即为每个方法指定唯一的标识,这样通过树于构造就可以构建链路,包括分支链路;
  • 为每次调用指定一个唯一tid,用于标识特定的事务调用;
  • 分频次打印日志信息,因为日志信息多了会影响程序性能,在高并发系统中设成千分之5以下就可以;

大体设计如下:

程序异常和日志的设计方法_异常处理_05

其完整日志格式大体如下:

//调用方日志
tid=001 spanid=cspan01

//服务方日志
tid=001 spanid=sspan01 parentspanid=cspan01

tid=001 spanid=sspan02 parentspanid=sspan01
。。。。。。

上面只是一个粗略思路,其细节部分读者可以找下网上专业的文章

三、日志与异常打印的协同

这处比较简单,就是在异常时可以把异常信息做为日志信息打印出来或是存储到异常信息表中,供处理和查询。也可供大数据统计之用。

定义

class LoggingException extends Exception {
private static Logger logger = Logger.getLogger("LoggingException");
public LoggingException() {
StringWriter trace = new StringWriter();
printStackTrace(new PrintWriter(trace));
logger.severe(trace.toString());
}
}

示例

public class LoggingExceptions {
public static void main(String[] args) {
try {
throw new LoggingException();
} catch(LoggingException e) {
System.err.println("Caught " + e);
}
}
}

end,至此全部介绍完了,希望对大家能有点帮助。






举报

相关推荐

0 条评论