前言
最新做项目,发现一些历史遗留问题,典型的是日志打印的配置问题,其实都是些简单问题,但是往往简单问题引起严重的事故,比如日志打印阻塞工作线程,以logback和log4j2为例。logback实际上是springboot的官方默认日志实现框架,承载SLF4J-API,所以基于java开发的云原生项目基本上就是logback打印日志,logback异步appender日志的打印架构

可以看到consoleAppender实际上也是异步(非同步)的范畴
准备demo
springboot的demo,这个可以用Spring官方的脚手架生成

其中启动的console日志就是logback打印的,虽然我们没有配置logback的xml。而现实情况是需要配置文件的,毕竟需要异步打印日志,日志切割,保存时间等都需要配置,关键还要日志格式。
<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="false">
    <!--定义日志文件的存储地址 可以使用环境变量或者系统变量占位-->
    <property name="LOGBACK_HOME" value="/opt/xxx" />
    <!--控制台输出 -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度,%msg:日志消息,%n是换行符-->
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
        </encoder>
    </appender>
    <!-- 文件 滚动日志 -->
    <appender name="fileLog"  class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 当前日志输出路径、文件名 -->
        <file>${log.path}/app.log</file>
        <!--日志输出格式-->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <!--历史日志归档策略-->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 历史日志: 归档文件名 -->
            <fileNamePattern>${log.path}/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <!--单个文件的最大大小-->
            <maxFileSize>200MB</maxFileSize>
            <!--日志文件保留天数-->
            <maxHistory>10</maxHistory>
        </rollingPolicy>
    </appender>
    <!-- 文件 异步日志(async) -->
    <appender name="ASYNC_LOG" class="ch.qos.logback.classic.AsyncAppender" >
        <!-- 不丢失日志.默认的256/5,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
        <discardingThreshold>0</discardingThreshold>
        <!-- 更改默认的队列的长度,默认值为256 -->
        <queueSize>1024</queueSize>
        <neverBlock>true</neverBlock>
        <!-- 添加附加的appender,最多只能添加一个 -->
        <appender-ref ref="fileLog" />
    </appender>
    <!--自定义日志级别 myibatis可以输出SQL-->
    <logger name="com.apache.ibatis" level="TRACE"/>
    <logger name="java.sql.Connection" level="DEBUG"/>
    <logger name="java.sql.Statement" level="DEBUG"/>
    <logger name="java.sql.PreparedStatement" level="DEBUG"/>
    <!-- 日志输出级别 -->
    <root level="INFO">
        <appender-ref ref="STDOUT" />
        <appender-ref ref="ASYNC_LOG"/>
    </root>
</configuration>笔者参考各个网站简单写了一个xml
原理
那么为什么日志打印会阻塞工作线程了,不是异步的嘛吗?异步是没错,但是异步解耦却是依赖队列,不同于分布式MQ,本地队列在某些配置时是阻塞的,所以异步日志实际上是半同步,有点像MySQL的复制原理,架构设计实际上很多地方非常相似。如果不想丢日志,可以提高消费队列日志线程的数量,增加CPU资源消耗。
参考logback官方文档:Chapter 4: Appenders

这个就是前言的代码分析,console也是属于异步范畴。
AsyncAppender配置如下

这里关键有3个配置
- queueSize,队列的大小,默认256
- discardingThreshold,队列数量剩余数,达到或者小于,就丢弃TRACE DEBUG INFO日志
- neverBlock,从不阻塞,队列满就丢日志
 
最简单的架构图,写文件是异步线程,但是写queue是同步的
源码分析
ch.qos.logback.classic.AsyncAppender,关键还是父类ch.qos.logback.core.AsyncAppenderBase
/**
 * In order to optimize performance this appender deems events of level TRACE,
 * DEBUG and INFO as discardable. See the
 * <a href="http://logback.qos.ch/manual/appenders.html#AsyncAppender">chapter
 * on appenders</a> in the manual for further information.
 *
 *
 * @author Ceki Gülcü
 * @since 1.0.4
 */
public class AsyncAppender extends AsyncAppenderBase<ILoggingEvent> {
    boolean includeCallerData = false;
    /**
     * Events of level TRACE, DEBUG and INFO are deemed to be discardable.
     * 定义丢弃日志的级别,文档写的就是这里实现的
     * 
     * @param event
     * @return true if the event is of level TRACE, DEBUG or INFO false otherwise.
     */
    protected boolean isDiscardable(ILoggingEvent event) {
        Level level = event.getLevel();
        return level.toInt() <= Level.INFO_INT;
    }
    protected void preprocess(ILoggingEvent eventObject) {
        eventObject.prepareForDeferredProcessing();
        if (includeCallerData)
            eventObject.getCallerData();
    }这个类核心还是,按照级别丢日志的定义,比如queue能存储的大小少于1/5时,那些级别日志丢弃 ,再看父类启动的时候分析初始值,发现queue是
ArrayBlockingQueue
 
 
定义了队列discardingThreshold的值,注意:这个是队列数信号量,不是百分比,发现一些业务配置20,😄
public static final int DEFAULT_QUEUE_SIZE = 256; int queueSize = DEFAULT_QUEUE_SIZE;
默认队列数256,建议配置大一点,根据内存分配情况,过大会OOM
在分析日志入队列的过程
 
分析
ArrayBlockingQueue
    /**
     * Inserts the specified element at the tail of this queue if it is
     * possible to do so immediately without exceeding the queue's capacity,
     * returning {@code true} upon success and {@code false} if this queue
     * is full.  This method is generally preferable to method {@link #add},
     * which can fail to insert an element only by throwing an exception.
     *
     * @throws NullPointerException if the specified element is null
     */
    public boolean offer(E e) {
        Objects.requireNonNull(e);
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            if (count == items.length)
                return false;
            else {
                enqueue(e);
                return true;
            }
        } finally {
            lock.unlock();
        }
    }队列数满,直接丢弃,所以不阻塞,丢失日志。
log4j2
log4j2实际上根据各方测试说,比logback性能强一些,但是也会出现同样的问题
官方文档:Log4j – Log4j 2 Appenders

apache开源的文档管理要好一些,写的很详细,而且有详细的说明和示例,不过设计原理都差不多
总结
实际上这个问题是使用问题,非常简单,不过越是简单的使用,却可能出现致命问题,一般公司都会统一脚手架或者统一规范的方式来实现标准的日志配置文件,防止错误配置导致业务问题,不知道未来Java虚拟线程大规模使用会不会缓解日志打印阻塞工作线程的问题,毕竟调度更优,不过如果线程池满载,虚拟线程也是无能为力。还是需要在丢日志和存储消费日志的能力作取舍。










