日志最佳实践

有点标题党了,以下是个人对于日志的一些理解

日志基础

日志大致分为两类

  • 日志门面(接口)
  • 日志实现

日志门面提供了标准的日志输出API,其底层如何实现,或者说采用什么日志组件,由开发者进行选择。当然,选择的日志组件实现了对应日志门面的接口,以下是市场上常用的日志框架

日志门面 日志实现
JCL(Jakarta Commons Logging) 、slf4j(Simple Logging Facade for Java) 、jboss-logging log4j JUL(java.util.logging) 、log4j2 、logback

slf4j、logback、log4j是出自同一人之手,log4j由于性能问题,作者觉得修改起来改动太大,干脆就直接写了logback。由于是出自同一人之后,slf4j和logback的契合度很高,springboot默认的日志选择也是slf4j+logback

log4j2出自apache,性能据说非常强悍,但出过一些漏洞

之前项目导入了很多关于日志的jar包,但是由于对这些jar包不是很了解,导致出了日志相关的问题不知道如何排查,下面简单介绍一下

这是slf4j官网的一张图:

  1. 假如我们项目中只导入了slf4j-api.jar:上面已经讲过,slf4j只是日志门面,相当于接口,没有实现的情况下,它是没有什么作用的,所以打印出null
  2. 假如我们项目中导入了slf4j-api.jar,logback-classic.jar,logback-core.jar:我们既导入了接口,也导入了实现类,那么我们就可以完美打印出日志了
  3. 假如我们项目中用的日志组件是log4j(reload4j是log4j是升级版,因为之前log4j出现了重大漏洞),那么就很尴尬了,log4j是很早之前就出来了,都不知道之后会有slf4j的出现,怎么可能实现了slf4j。不用怕,slf4j也有解决方案,它使用了一个桥接jar,slf4j-reload4j.jar,看名字就知道,将slf4j转换成了reload4j,我们再导入一个reload4j.jar就可以完美解决了

看这个描述,跟JDBC是很像的,都是利用Java的SPI机制实现的,实现SPI主要分三步

  1. 定义一个接口
  2. 提供方的META-INF/services目录下新建一个名称为接口全限定名的文本文件,内容为接口实现类的全限定名
  3. 调用方通过ServiceLoader#load方法加载接口的实现类实例

这是slf4j github 上的项目地址 github.com/qos-ch/slf4...

我们使用slf4j的时候,一般是使用如下的代码

java 复制代码
@SpringBootTest
class LogDemoApplicationTests {
​
    @Test
    void test() {
        Logger logger = LoggerFactory.getLogger(LogDemoApplication.class);
        logger.debug("hello");
    }
​
}

进入LoggerFactory.getLogger方法,会发现是通过工厂模式获得ILoggerFactory的实现类,通过实现类获取Logger

scss 复制代码
public static Logger getLogger(String name) {
    ILoggerFactory iLoggerFactory = getILoggerFactory();
    return iLoggerFactory.getLogger(name);
}

进入getILoggerFactory()方法,代码较为简易,普通的switch case,是通过获取SLF4JServiceProvider的实现类来获取ILoggerFactory的实现类

csharp 复制代码
public static ILoggerFactory getILoggerFactory() {
    return getProvider().getLoggerFactory();
}
​
static SLF4JServiceProvider getProvider() {
    // 如果是未初始化状态,进行初始化
    if (INITIALIZATION_STATE == UNINITIALIZED) {
        synchronized (LoggerFactory.class) {
            if (INITIALIZATION_STATE == UNINITIALIZED) {
                // 初始化之前,先将状态改为初始化中
                INITIALIZATION_STATE = ONGOING_INITIALIZATION;
                // 初始化
                performInitialization();
            }
        }
    }
    switch (INITIALIZATION_STATE) {
        // 初始化成功,返回对应的provider
    case SUCCESSFUL_INITIALIZATION:
        return PROVIDER;
        // 没有找到日志门面的实现,返回NOP_FALLBACK_SERVICE_PROVIDER
    case NOP_FALLBACK_INITIALIZATION:
        return NOP_FALLBACK_SERVICE_PROVIDER;
    case FAILED_INITIALIZATION:
        // 初始化失败,抛出异常
        throw new IllegalStateException(UNSUCCESSFUL_INIT_MSG);
    case ONGOING_INITIALIZATION:
        // support re-entrant behavior.
        // See also http://jira.qos.ch/browse/SLF4J-97
        return SUBST_PROVIDER;
    }
    throw new IllegalStateException("Unreachable code");
}

顺便看看performInitialization()方法中的核心代码,主要是处理三种情况

  • 无引入日志门面的实现
  • 引入多种日志门面的实现
  • 引入一种日志门面的实现
scss 复制代码
private final static void bind() {
    try {
        List<SLF4JServiceProvider> providersList = findServiceProviders();
        // 如果出现多个SLF4JServiceProvider实例,则打印警告信息
        reportMultipleBindingAmbiguity(providersList);
        // 判断是否扫描到对应的SLF4JServiceProvider实例
        if (providersList != null && !providersList.isEmpty()) {
            // 假如有多个日志门面的实现,这里是随机选择一个
            PROVIDER = providersList.get(0);
            // SLF4JServiceProvider.initialize()仅在此处调用,其它地方不会调用
            PROVIDER.initialize();
            // 标记初始化成功
            INITIALIZATION_STATE = SUCCESSFUL_INITIALIZATION;
            // 打印实际绑定的SLF4JServiceProvider
            reportActualBinding(providersList);
        } else {
            // 标记未找到日志门面的实现
            INITIALIZATION_STATE = NOP_FALLBACK_INITIALIZATION;
            // 打印警告信息
            Reporter.warn("No SLF4J providers were found.");
            Reporter.warn("Defaulting to no-operation (NOP) logger implementation");
            Reporter.warn("See " + NO_PROVIDERS_URL + " for further details.");
​
            Set<URL> staticLoggerBinderPathSet = findPossibleStaticLoggerBinderPathSet();
            reportIgnoredStaticLoggerBinders(staticLoggerBinderPathSet);
        }
        postBindCleanUp();
    } catch (Exception e) {
        failedBinding(e);
        throw new IllegalStateException("Unexpected initialization failure", e);
    }
}

于是我们知道了,SPI机制中定义的那个接口是如下的这个接口

csharp 复制代码
public interface SLF4JServiceProvider {
    // 返回ILoggerFactory,用于LoggerFactory类的绑定
    public ILoggerFactory getLoggerFactory();
​
    // 初始化日志框架的实现
    public void initialize();
}

至于加载接口的实现类实例是通过这个LazyIterator,有兴趣的同学可以看看

看完slf4j,再看看logback的实现

这是logback github 上的项目地址 github.com/qos-ch/logb...

可以清晰看到META-INF/services目录下关于SLF4JServiceProvider的实现

简单讲下代码,打印日志到控制台的代码路径为

ch.qos.logback.classic.Logger#filterAndLog_2

ch.qos.logback.classic.Logger#buildLoggingEventAndAppend

ch.qos.logback.classic.Logger#callAppenders

ch.qos.logback.classic.Logger#appendLoopOnAppenders

ch.qos.logback.core.spi.AppenderAttachableImpl#appendLoopOnAppenders

ch.qos.logback.core.UnsynchronizedAppenderBase#doAppend

ch.qos.logback.core.OutputStreamAppender#append

ch.qos.logback.core.OutputStreamAppender#subAppend

ch.qos.logback.core.OutputStreamAppender#writeOut

ch.qos.logback.core.OutputStreamAppender#writeBytes

ch.qos.logback.core.OutputStreamAppender#writeByteArrayToOutputStreamWithPossibleFlush

关键代码

csharp 复制代码
public void callAppenders(ILoggingEvent event) {
    int writes = 0;
    // ILoggingEvent包装了日志相关的信息
    // 通知logger的appender处理日志事件,如果想要自定义对日志的处理,可以继承
    // UnsynchronizedAppenderBase<ILoggingEvent>
    for (Logger l = this; l != null; l = l.parent) {
        writes += l.appendLoopOnAppenders(event);
        if (!l.additive) {
            break;
        }
    }
    // 当前logger未设置appender,在控制台打印提醒
    if (writes == 0) {
        loggerContext.noAppenderDefinedWarning(this);
    }
}
SpringBoot项目中如何使用?

引入下面的依赖就可以,默认是使用slf4j+logback

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-logging</artifactId>
</dependency>

如何打印日志

合理使用日志级别
  1. 日志级别并不是一成不变的,系统、功能上线初期,为了定位问题,可以打多点日志并且将日志级别定为info,系统、功能稳定后,可以适当将日志调整为debug,减少日志量不仅可以降低成本,更可以精准地定位问题,所以日志也是需要维护的
优雅打印日志
  • Json格式打印方法入参
scss 复制代码
public void method(Req req) {
    log.info("method,req:{}", JsonUtils.toJSONString(req));
    action();
}

系统、功能上线初期,入参日志的打印是必须的,系统、功能稳定后,可以适当去掉或将日志调整为debug,但个人认为这部分日志非常重要,会一直保留

  • 使用订单号等信息串联关键if,注意是关键if
c 复制代码
public void method(Req req) {
    
    if (a) {
      log.info("a : {}", orderCode);
    } else if (b) {
      log.info("b : {}", orderCode);
    } else {
      log.info("c : {}", orderCode);
    }
   
}

进入不同的if,代码逻辑完全不一样,数据处理的结果、返回值也会不一样,很有利于排查问题

  • 批量处理数据,打印开始、结束及处理进度
ini 复制代码
public void dataJob() {
    log.info("job start");
    Long maxId = 0L;
    List<Entity> entitylist = dao.list(maxId);
    while (!entitylist.isEmpty()) {
        for(;;) {
          action();
        }
        log.info("最大id:{}", maxId);
        entitylist = dao.list(maxId);
    }
    log.info("job end");
}

数据量太大,没有日志的话,无法预估处理时间,任务中断了都不知道

  • 杜绝麻木日志,无辨识度,排查费时费力
php 复制代码
try {
  
} catch(Exception e) {
    // 千篇一律,最好包含信息
    log.info("message: {}",e)
}
​
// 无格式化,难定位问题 
e.printStackTrace();
  • 避免打印敏感信息,例如手机号、账号密码,密钥,这个说起来简单,做起来很难,比如手机号有时候是排查问题的关键,只能尽量
  • 进行日志聚合,一目了然,常见的有

    • 请求外部系统,聚合请求体、返回值
    • 统计方法耗时,聚合开始时间、结束时间、耗时
perl 复制代码
@Override
public void httpRequest() {
    log("request ---> %s, body ---> %s, response ---> %s, elapsedTime ---> %s", requestMsg, bodyMsg, responseMsg, elapsedTime);
}
  • 监控日志需要设置阈值,麻木打印跟没打一样
perl 复制代码
@Override
public void httpRequest() {
    if (time > 1000) {
      log("request ---> %s, body ---> %s, response ---> %s, elapsedTime ---> %s", requestMsg, bodyMsg, responseMsg, elapsedTime);
    }
}
动态日志追踪

在项目中,可以适当加多一点debug类型的代码,如果线上出现了问题,可以通过配置日志级别来迅速排查问题,而不需要发版,在公司工作的都知道,发版的流程太长

ini 复制代码
# 设置某个类的日志打印级别
logging.level.类的全限定名 = debug
logging.level.类的全限定名 = info
logging.level.类的全限定名 = warn
logging.level.类的全限定名 = error
# 该类不打日志
logging.level.类的全限定名 = off
# 设置整个项目的日志打印级别
logging.level.root = DEBUG

日志告警

以下是日志告警的方式,系统的监控不仅仅是日志的告警

  1. error日志实时告警

现在一般是使用ELk处理日志,但是我们也不可能时时刻刻盯着,所以将error日志实时发送到生产告警群是较好的方式

这是logback的一个机制,通过继承UnsynchronizedAppenderBase<ILoggingEvent>接口可以对日志进行处理

scala 复制代码
public class TestAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
    public TestAppender() {
    }
​
    @Override
    public void append(ILoggingEvent event) {
        // 组装异常,告警到可以实时关注到的地方,比如生产告警群
    }
}
  1. 统计一段时间内大量出现的异常,比如一些正常的400异常,在一段时间内大量增长,纵然是预期的异常,也有可能是代码判断错了导致的

参考文档

www.slf4j.org/manual.html

www.bilibili.com/video/BV1KD...

相关推荐
江湖十年4 分钟前
Go 1.25 终于迎来了容器感知 GOMAXPROCS
后端·面试·go
这里有鱼汤31 分钟前
别傻了,这些量化策略AI 10 秒就能帮你写好
后端·python
前端小巷子1 小时前
Vue3 响应式革命
前端·vue.js·面试
Victor3562 小时前
Redis(16)Redis的有序集合(Sorted Set)类型有哪些常用命令?
后端
Victor3562 小时前
Redis(17)如何在Redis中设置键的过期时间?
后端
你我约定有三6 小时前
MyBatis--缓存详解
spring boot·缓存·mybatis
MZ_ZXD0016 小时前
springboot汽车租赁服务管理系统-计算机毕业设计源码58196
java·c++·spring boot·python·django·flask·php
你的人类朋友10 小时前
说说git的变基
前端·git·后端
阿杆10 小时前
玩转 Amazon ElastiCache 免费套餐:小白也能上手
后端
阿杆11 小时前
无服务器每日自动推送 B 站热门视频
后端