日志最佳实践

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

日志基础

日志大致分为两类

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

日志门面提供了标准的日志输出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...

相关推荐
2402_8575893623 分钟前
Spring Boot新闻推荐系统设计与实现
java·spring boot·后端
J老熊32 分钟前
Spring Cloud Netflix Eureka 注册中心讲解和案例示范
java·后端·spring·spring cloud·面试·eureka·系统架构
CoderJia程序员甲34 分钟前
重学SpringBoot3-集成Redis(四)之Redisson
java·spring boot·redis·缓存
Benaso35 分钟前
Rust 快速入门(一)
开发语言·后端·rust
sco528235 分钟前
SpringBoot 集成 Ehcache 实现本地缓存
java·spring boot·后端
我爱学Python!37 分钟前
面试问我LLM中的RAG,秒过!!!
人工智能·面试·llm·prompt·ai大模型·rag·大模型应用
OLDERHARD1 小时前
Java - LeetCode面试经典150题 - 矩阵 (四)
java·leetcode·面试
原机小子1 小时前
在线教育的未来:SpringBoot技术实现
java·spring boot·后端
吾日三省吾码1 小时前
详解JVM类加载机制
后端
慕明翰1 小时前
Springboot集成JSP报 404
java·开发语言·spring boot