<audio id="audio" title="13 | 日志：日志记录真没你想象的那么简单" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/e5/83c4a643db07af639fd5c4476632d1e5.mp3"></audio>

你好，我是朱晔。今天，我和你分享的是，记录日志可能会踩的坑。

一些同学可能要说了，记录日志还不简单，无非是几个常用的API方法，比如debug、info、warn、error；但我就见过不少坑都是记录日志引起的，容易出错主要在于三个方面：

- 日志框架众多，不同的类库可能会使用不同的日志框架，如何兼容是一个问题。
- 配置复杂且容易出错。日志配置文件通常很复杂，因此有些开发同学会从其他项目或者网络上复制一份配置文件，但却不知道如何修改，甚至是胡乱修改，造成很多问题。比如，重复记录日志的问题、同步日志的性能问题、异步记录的错误配置问题。
- 日志记录本身就有些误区，比如没考虑到日志内容获取的代价、胡乱使用日志级别等。

Logback、Log4j、Log4j2、commons-logging、JDK自带的java.util.logging等，都是Java体系的日志框架，确实非常多。而不同的类库，还可能选择使用不同的日志框架。这样一来，日志的统一管理就变得非常困难。为了解决这个问题，就有了SLF4J（Simple Logging Facade For Java），如下图所示：

<img src="https://static001.geekbang.org/resource/image/97/fe/97fcd8b55e5288c0e9954f070f1008fe.png" alt="">

SLF4J实现了三种功能：

- 一是提供了统一的日志门面API，即图中紫色部分，实现了中立的日志记录API。
- 二是桥接功能，即图中蓝色部分，用来把各种日志框架的API（图中绿色部分）桥接到SLF4J API。这样一来，即便你的程序中使用了各种日志API记录日志，最终都可以桥接到SLF4J门面API。
- 三是适配功能，即图中红色部分，可以实现SLF4J API和实际日志框架（图中灰色部分）的绑定。SLF4J只是日志标准，我们还是需要一个实际的日志框架。日志框架本身没有实现SLF4J API，所以需要有一个前置转换。Logback就是按照SLF4J API标准实现的，因此不需要绑定模块做转换。

需要理清楚的是，虽然我们可以使用log4j-over-slf4j来实现Log4j桥接到SLF4J，也可以使用slf4j-log4j12实现SLF4J适配到Log4j，也把它们画到了一列，**但是它不能同时使用它们，否则就会产生死循环。jcl和jul也是同样的道理。**

虽然图中有4个灰色的日志实现框架，但我看到的业务系统使用最广泛的是Logback和Log4j，它们是同一人开发的。Logback可以认为是Log4j的改进版本，我更推荐使用。所以，关于日志框架配置的案例，我都会围绕Logback展开。

Spring Boot是目前最流行的Java框架，它的日志框架也用的是Logback。那，为什么我们没有手动引入Logback的包，就可以直接使用Logback了呢？

查看Spring Boot的Maven依赖树，可以发现spring-boot-starter模块依赖了spring-boot-starter-logging模块，而spring-boot-starter-logging模块又帮我们自动引入了logback-classic（包含了SLF4J和Logback日志框架）和SLF4J的一些适配器。其中，log4j-to-slf4j用于实现Log4j2 API到SLF4J的桥接，jul-to-slf4j则是实现java.util.logging API到SLF4J的桥接：

<img src="https://static001.geekbang.org/resource/image/4c/e6/4c44672d280b8a30be777b78de6014e6.png" alt="">

接下来，我就用几个实际的案例和你说说日志配置和记录这两大问题，顺便以Logback为例复习一下常见的日志配置。

## 为什么我的日志会重复记录？

日志重复记录在业务上非常常见，不但给查看日志和统计工作带来不必要的麻烦，还会增加磁盘和日志收集系统的负担。接下来，我和你分享两个重复记录的案例，同时帮助你梳理Logback配置的基本结构。

**第一个案例是，logger配置继承关系导致日志重复记录**。首先，定义一个方法实现debug、info、warn和error四种日志的记录：

```
@Log4j2
@RequestMapping(&quot;logging&quot;)
@RestController
public class LoggingController {
    @GetMapping(&quot;log&quot;)
    public void log() {
        log.debug(&quot;debug&quot;);
        log.info(&quot;info&quot;);
        log.warn(&quot;warn&quot;);
        log.error(&quot;error&quot;);
    }
}

```

然后，使用下面的Logback配置：

- 第11和12行设置了全局的日志级别为INFO，日志输出使用CONSOLE Appender。
- 第3到7行，首先将CONSOLE Appender定义为ConsoleAppender，也就是把日志输出到控制台（System.out/System.err）；然后通过PatternLayout定义了日志的输出格式。关于格式化字符串的各种使用方式，你可以进一步查阅[官方文档](http://logback.qos.ch/manual/layouts.html#conversionWord)。
- 第8到10行实现了一个Logger配置，将应用包的日志级别设置为DEBUG、日志输出同样使用CONSOLE Appender。

```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
    &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
        &lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
            &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
        &lt;/layout&gt;
    &lt;/appender&gt;
    &lt;logger name=&quot;org.geekbang.time.commonmistakes.logging&quot; level=&quot;DEBUG&quot;&gt;
        &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
    &lt;/logger&gt;
    &lt;root level=&quot;INFO&quot;&gt;
        &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
    &lt;/root&gt;
&lt;/configuration&gt;

```

这段配置看起来没啥问题，但执行方法后出现了日志重复记录的问题：

<img src="https://static001.geekbang.org/resource/image/2c/15/2c6f45bbbe06c1ed26b514e7ac873b15.png" alt="">

从配置文件的第9和12行可以看到，CONSOLE这个Appender同时挂载到了两个Logger上，一个是我们定义的&lt;logger&gt;，一个是&lt;root&gt;，由于我们定义的&lt;logger&gt;继承自&lt;root&gt;，**所以同一条日志既会通过logger记录，也会发送到root记录，因此应用package下的日志出现了重复记录。**

后来我了解到，这个同学如此配置的初衷是实现自定义的logger配置，让应用内的日志暂时开启DEBUG级别的日志记录。其实，他完全不需要重复挂载Appender，去掉&lt;logger&gt;下挂载的Appender即可：

```
&lt;logger name=&quot;org.geekbang.time.commonmistakes.logging&quot; level=&quot;DEBUG&quot;/&gt;

```

如果自定义的&lt;logger&gt;需要把日志输出到不同的Appender，比如将应用的日志输出到文件app.log、把其他框架的日志输出到控制台，可以设置&lt;logger&gt;的additivity属性为false，这样就不会继承&lt;root&gt;的Appender了：

```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
    &lt;appender name=&quot;FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
        &lt;file&gt;app.log&lt;/file&gt;
        &lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
            &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
        &lt;/encoder&gt;
    &lt;/appender&gt;
    &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
      &lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
            &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
      &lt;/layout&gt;
   &lt;/appender&gt;
    &lt;logger name=&quot;org.geekbang.time.commonmistakes.logging&quot; level=&quot;DEBUG&quot; additivity=&quot;false&quot;&gt;
        &lt;appender-ref ref=&quot;FILE&quot;/&gt;
    &lt;/logger&gt;
   &lt;root level=&quot;INFO&quot;&gt;
      &lt;appender-ref ref=&quot;CONSOLE&quot; /&gt;
   &lt;/root&gt;
&lt;/configuration&gt;

```

**第二个案例是，错误配置LevelFilter造成日志重复记录。**

一般互联网公司都会使用ELK三件套来统一收集日志，有一次我们发现Kibana上展示的日志有部分重复，一直怀疑是Logstash配置错误，但最后发现还是Logback的配置错误引起的。

这个项目的日志是这样配置的：在记录日志到控制台的同时，把日志记录按照不同的级别记录到两个文件中：

```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
   &lt;property name=&quot;logDir&quot; value=&quot;./logs&quot; /&gt;
   &lt;property name=&quot;app.name&quot; value=&quot;common-mistakes&quot; /&gt;
   &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
      &lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
         &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
      &lt;/layout&gt;
   &lt;/appender&gt;
   &lt;appender name=&quot;INFO_FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
      &lt;File&gt;${logDir}/${app.name}_info.log&lt;/File&gt;
      &lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&gt;
         &lt;level&gt;INFO&lt;/level&gt;
      &lt;/filter&gt;
      &lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
         &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
         &lt;charset&gt;UTF-8&lt;/charset&gt;
      &lt;/encoder&gt;
   &lt;/appender&gt;
   &lt;appender name=&quot;ERROR_FILE&quot; class=&quot;ch.qos.logback.core.FileAppender
&quot;&gt;
      &lt;File&gt;${logDir}/${app.name}_error.log&lt;/File&gt;
      &lt;filter class=&quot;ch.qos.logback.classic.filter.ThresholdFilter&quot;&gt;
         &lt;level&gt;WARN&lt;/level&gt;
      &lt;/filter&gt;
      &lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
         &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
         &lt;charset&gt;UTF-8&lt;/charset&gt;
      &lt;/encoder&gt;
   &lt;/appender&gt;
   &lt;root level=&quot;INFO&quot;&gt;
      &lt;appender-ref ref=&quot;CONSOLE&quot; /&gt;
      &lt;appender-ref ref=&quot;INFO_FILE&quot;/&gt;
      &lt;appender-ref ref=&quot;ERROR_FILE&quot;/&gt;
   &lt;/root&gt;
&lt;/configuration&gt;

```

这个配置文件比较长，我带着你一段一段地看：

- 第31到35行定义的root引用了三个Appender。
- 第5到9行是第一个ConsoleAppender，用于把所有日志输出到控制台。
- 第10到19行定义了一个FileAppender，用于记录文件日志，并定义了文件名、记录日志的格式和编码等信息。最关键的是，第12到14行定义的LevelFilter过滤日志，将过滤级别设置为INFO，目的是希望_info.log文件中可以记录INFO级别的日志。
- 第20到30行定义了一个类似的FileAppender，并使用ThresholdFilter来过滤日志，过滤级别设置为WARN，目的是把WARN以上级别的日志记录到另一个_error.log文件中。

运行一下测试程序：

<img src="https://static001.geekbang.org/resource/image/e9/4e/e940f1310e70b65ff716dc81c9901d4e.png" alt="">

可以看到，_info.log中包含了INFO、WARN和ERROR三个级别的日志，不符合我们的预期；error.log包含了WARN和ERROR两个级别的日志。因此，造成了日志的重复收集。

你可能会问，这么明显的日志重复为什么没有及时发现？一些公司使用自动化的ELK方案收集日志，日志会同时输出到控制台和文件，开发人员在本机测试时不太会关心文件中记录的日志，而在测试和生产环境又因为开发人员没有服务器访问权限，所以原始日志文件中的重复问题并不容易发现。

为了分析日志重复的原因，我们来复习一下ThresholdFilter和LevelFilter的配置方式。

分析ThresholdFilter的源码发现，当日志级别大于等于配置的级别时返回NEUTRAL，继续调用过滤器链上的下一个过滤器；否则，返回DENY直接拒绝记录日志：

```
public class ThresholdFilter extends Filter&lt;ILoggingEvent&gt; {
    public FilterReply decide(ILoggingEvent event) {
        if (!isStarted()) {
            return FilterReply.NEUTRAL;
        }
    
        if (event.getLevel().isGreaterOrEqual(level)) {
            return FilterReply.NEUTRAL;
        } else {
            return FilterReply.DENY;
        }
    }
}

```

在这个案例中，把ThresholdFilter设置为WARN，可以记录WARN和ERROR级别的日志。

LevelFilter用来比较日志级别，然后进行相应处理：如果匹配就调用onMatch定义的处理方式，默认是交给下一个过滤器处理（AbstractMatcherFilter基类中定义的默认值）；否则，调用onMismatch定义的处理方式，默认也是交给下一个过滤器处理。

```
public class LevelFilter extends AbstractMatcherFilter&lt;ILoggingEvent&gt; {
	public FilterReply decide(ILoggingEvent event) {
	    if (!isStarted()) {
	        return FilterReply.NEUTRAL;
	    }


	    if (event.getLevel().equals(level)) {
	        return onMatch;
	    } else {
	        return onMismatch;
	    }
	}
}

public abstract class AbstractMatcherFilter&lt;E&gt; extends Filter&lt;E&gt; {
    protected FilterReply onMatch = FilterReply.NEUTRAL;
    protected FilterReply onMismatch = FilterReply.NEUTRAL;
}

```

和ThresholdFilter不同的是，LevelFilter仅仅配置level是无法真正起作用的。**由于没有配置onMatch和onMismatch属性，所以相当于这个过滤器是无用的，导致INFO以上级别的日志都记录了。**

定位到问题后，修改方式就很明显了：配置LevelFilter的onMatch属性为ACCEPT，表示接收INFO级别的日志；配置onMismatch属性为DENY，表示除了INFO级别都不记录：

```
&lt;appender name=&quot;INFO_FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
	&lt;File&gt;${logDir}/${app.name}_info.log&lt;/File&gt;
	&lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&gt;
		&lt;level&gt;INFO&lt;/level&gt;
		&lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;
		&lt;onMismatch&gt;DENY&lt;/onMismatch&gt;
	&lt;/filter&gt;
	...
&lt;/appender&gt;

```

这样修改后，_info.log文件中只会有INFO级别的日志，不会出现日志重复的问题了。

## 使用异步日志改善性能的坑

掌握了把日志输出到文件中的方法后，我们接下来面临的问题是，如何避免日志记录成为应用的性能瓶颈。这可以帮助我们解决，磁盘（比如机械磁盘）IO性能较差、日志量又很大的情况下，如何记录日志的问题。

我们先来测试一下，记录日志的性能问题，定义如下的日志配置，一共有两个Appender：

- FILE是一个FileAppender，用于记录所有的日志；
- CONSOLE是一个ConsoleAppender，用于记录带有time标记的日志。

```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
    &lt;appender name=&quot;FILE&quot; class=&quot;ch.qos.logback.core.FileAppender&quot;&gt;
        &lt;file&gt;app.log&lt;/file&gt;
        &lt;encoder class=&quot;ch.qos.logback.classic.encoder.PatternLayoutEncoder&quot;&gt;
            &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
        &lt;/encoder&gt;
    &lt;/appender&gt;
    &lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
        &lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
            &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
        &lt;/layout&gt;
        &lt;filter class=&quot;ch.qos.logback.core.filter.EvaluatorFilter&quot;&gt;
            &lt;evaluator class=&quot;ch.qos.logback.classic.boolex.OnMarkerEvaluator&quot;&gt;
                &lt;marker&gt;time&lt;/marker&gt;
            &lt;/evaluator&gt;
            &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;
            &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;
        &lt;/filter&gt;
    &lt;/appender&gt;
    &lt;root level=&quot;INFO&quot;&gt;
        &lt;appender-ref ref=&quot;FILE&quot;/&gt;
        &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
    &lt;/root&gt;
&lt;/configuration&gt;

```

不知道你有没有注意到，这段代码中有个EvaluatorFilter（求值过滤器），用于判断日志是否符合某个条件。

在后续的测试代码中，我们会把大量日志输出到文件中，日志文件会非常大，如果性能测试结果也混在其中的话，就很难找到那条日志。所以，这里我们使用EvaluatorFilter对日志按照标记进行过滤，并将过滤出的日志单独输出到控制台上。在这个案例中，我们给输出测试结果的那条日志上做了time标记。

配合使用标记和EvaluatorFilter，实现日志的按标签过滤，是一个不错的小技巧。

如下测试代码中，实现了记录指定次数的大日志，每条日志包含1MB字节的模拟数据，最后记录一条以time为标记的方法执行耗时日志：

```
@GetMapping(&quot;performance&quot;)
public void performance(@RequestParam(name = &quot;count&quot;, defaultValue = &quot;1000&quot;) int count) {
    long begin = System.currentTimeMillis();
    String payload = IntStream.rangeClosed(1, 1000000)
            .mapToObj(__ -&gt; &quot;a&quot;)
            .collect(Collectors.joining(&quot;&quot;)) + UUID.randomUUID().toString();
    IntStream.rangeClosed(1, count).forEach(i -&gt; log.info(&quot;{} {}&quot;, i, payload));
    Marker timeMarker = MarkerFactory.getMarker(&quot;time&quot;);
    log.info(timeMarker, &quot;took {} ms&quot;, System.currentTimeMillis() - begin);
}

```

执行程序后可以看到，记录1000次日志和10000次日志的调用耗时，分别是6.3秒和44.5秒：

<img src="https://static001.geekbang.org/resource/image/7e/52/7ee5152dedcbb585f23db49571bacc52.png" alt="">

对于只记录文件日志的代码了来说，这个耗时挺长的。为了分析其中原因，我们需要分析下FileAppender的源码。

FileAppender继承自OutputStreamAppender，查看OutputStreamAppender源码的第30到33行发现，**在追加日志的时候，是直接把日志写入OutputStream中，属于同步记录日志：**

```
public class OutputStreamAppender&lt;E&gt; extends UnsynchronizedAppenderBase&lt;E&gt; {
	private OutputStream outputStream;
	boolean immediateFlush = true;
	@Override
    protected void append(E eventObject) {
        if (!isStarted()) {
            return;
        }
        subAppend(eventObject);
    }

    protected void subAppend(E event) {
        if (!isStarted()) {
            return;
        }
        try {
            //编码LoggingEvent
            byte[] byteArray = this.encoder.encode(event);
            //写字节流
            writeBytes(byteArray);
        } catch (IOException ioe) {
            ...
        }
    }

    private void writeBytes(byte[] byteArray) throws IOException {
        if(byteArray == null || byteArray.length == 0)
            return;
        
        lock.lock();
        try {
            //这个OutputStream其实是一个ResilientFileOutputStream，其内部使用的是带缓冲的BufferedOutputStream
            this.outputStream.write(byteArray);
            if (immediateFlush) {
                this.outputStream.flush();//刷入OS
            }
        } finally {
            lock.unlock();
        }
    }
}

```

分析到这里，我们就明白为什么日志大量写入时会耗时这么久了。那，有没有办法实现大量日志写入时，不会过多影响业务逻辑执行耗时，影响吞吐量呢？

办法当然有了，使用Logback提供的AsyncAppender即可实现异步的日志记录。AsyncAppende类似装饰模式，也就是在不改变类原有基本功能的情况下为其增添新功能。这样，我们就可以把AsyncAppender附加在其他的Appender上，将其变为异步的。

定义一个异步Appender ASYNCFILE，包装之前的同步文件日志记录的FileAppender，就可以实现异步记录日志到文件：

```
&lt;appender name=&quot;ASYNCFILE&quot; class=&quot;ch.qos.logback.classic.AsyncAppender&quot;&gt;
    &lt;appender-ref ref=&quot;FILE&quot;/&gt;
&lt;/appender&gt;
&lt;root level=&quot;INFO&quot;&gt;
    &lt;appender-ref ref=&quot;ASYNCFILE&quot;/&gt;
    &lt;appender-ref ref=&quot;CONSOLE&quot;/&gt;
&lt;/root&gt;

```

测试一下可以发现，记录1000次日志和10000次日志的调用耗时，分别是735毫秒和668毫秒：

<img src="https://static001.geekbang.org/resource/image/98/e1/98d1633d83734f9b8f08c3334b403ce1.png" alt="">

性能居然这么好，你觉得其中有什么问题吗？异步日志真的如此神奇和万能吗？当然不是，因为这样并没有记录下所有日志。**我之前就遇到过很多关于AsyncAppender异步日志的坑，这些坑可以归结为三类：**

- 记录异步日志撑爆内存；
- 记录异步日志出现日志丢失；
- 记录异步日志出现阻塞。

为了解释这三种坑，我来模拟一个慢日志记录场景：首先，自定义一个继承自ConsoleAppender的MySlowAppender，作为记录到控制台的输出器，写入日志时休眠1秒。

```
public class MySlowAppender extends ConsoleAppender {
    @Override
    protected void subAppend(Object event) {
        try {
            // 模拟慢日志
            TimeUnit.MILLISECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        super.subAppend(event);
    }
}

```

然后，在配置文件中使用AsyncAppender，将MySlowAppender包装为异步日志记录：

```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
&lt;appender name=&quot;CONSOLE&quot; class=&quot;org.geekbang.time.commonmistakes.logging.async.MySlowAppender&quot;&gt;
		&lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
            &lt;pattern&gt;[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n&lt;/pattern&gt;
		&lt;/layout&gt;
	&lt;/appender&gt;
	&lt;appender name=&quot;ASYNC&quot; class=&quot;ch.qos.logback.classic.AsyncAppender&quot;&gt;
		&lt;appender-ref ref=&quot;CONSOLE&quot; /&gt;
	&lt;/appender&gt;
	&lt;root level=&quot;INFO&quot;&gt;
		&lt;appender-ref ref=&quot;ASYNC&quot; /&gt;
	&lt;/root&gt;
&lt;/configuration&gt;

```

定义一段测试代码，循环记录一定次数的日志，最后输出方法执行耗时：

```
@GetMapping(&quot;manylog&quot;)
public void manylog(@RequestParam(name = &quot;count&quot;, defaultValue = &quot;1000&quot;) int count) {
    long begin = System.currentTimeMillis();
    IntStream.rangeClosed(1, count).forEach(i -&gt; log.info(&quot;log-{}&quot;, i));
    System.out.println(&quot;took &quot; + (System.currentTimeMillis() - begin) + &quot; ms&quot;);
}

```

执行方法后发现，耗时很短但出现了日志丢失：我们要记录1000条日志，最终控制台只能搜索到215条日志，而且日志的行号变为了一个问号。

<img src="https://static001.geekbang.org/resource/image/5f/70/5fe1562b437b8672e4b1e9e463a24570.png" alt="">

出现这个问题的原因在于，AsyncAppender提供了一些配置参数，而我们没用对。我们结合相关源码分析一下：

- includeCallerData用于控制是否收集调用方数据，默认是false，此时方法行号、方法名等信息将不能显示（源码第2行以及7到11行）。
- queueSize用于控制阻塞队列大小，使用的ArrayBlockingQueue阻塞队列（源码第15到17行），默认大小是256，即内存中最多保存256条日志。
- discardingThreshold是控制丢弃日志的阈值，主要是防止队列满后阻塞。默认情况下，队列剩余量低于队列长度的20%，就会丢弃TRACE、DEBUG和INFO级别的日志。（参见源码第3到6行、18到19行、26到27行、33到34行、40到42行）
- neverBlock用于控制队列满的时候，加入的数据是否直接丢弃，不会阻塞等待，默认是false（源码第44到68行）。这里需要注意一下offer方法和put方法的区别，当队列满的时候offer方法不阻塞，而put方法会阻塞；neverBlock为true时，使用offer方法。

```
public class AsyncAppender extends AsyncAppenderBase&lt;ILoggingEvent&gt; {
    boolean includeCallerData = false;//是否收集调用方数据
    protected boolean isDiscardable(ILoggingEvent event) {
        Level level = event.getLevel();
        return level.toInt() &lt;= Level.INFO_INT;//丢弃&lt;=INFO级别的日志
    }
    protected void preprocess(ILoggingEvent eventObject) {
        eventObject.prepareForDeferredProcessing();
        if (includeCallerData)
            eventObject.getCallerData();
    }
}
public class AsyncAppenderBase&lt;E&gt; extends UnsynchronizedAppenderBase&lt;E&gt; implements AppenderAttachable&lt;E&gt; {

    BlockingQueue&lt;E&gt; blockingQueue;//异步日志的关键，阻塞队列
    public static final int DEFAULT_QUEUE_SIZE = 256;//默认队列大小
    int queueSize = DEFAULT_QUEUE_SIZE;
    static final int UNDEFINED = -1;
    int discardingThreshold = UNDEFINED;
    boolean neverBlock = false;//控制队列满的时候加入数据时是否直接丢弃，不会阻塞等待

    @Override
    public void start() {
       	...
        blockingQueue = new ArrayBlockingQueue&lt;E&gt;(queueSize);
        if (discardingThreshold == UNDEFINED)
            discardingThreshold = queueSize / 5;//默认丢弃阈值是队列剩余量低于队列长度的20%，参见isQueueBelowDiscardingThreshold方法
        ...
    }

    @Override
    protected void append(E eventObject) {
        if (isQueueBelowDiscardingThreshold() &amp;&amp; isDiscardable(eventObject)) { //判断是否可以丢数据
            return;
        }
        preprocess(eventObject);
        put(eventObject);
    }

    private boolean isQueueBelowDiscardingThreshold() {
        return (blockingQueue.remainingCapacity() &lt; discardingThreshold);
    }

    private void put(E eventObject) {
        if (neverBlock) { //根据neverBlock决定使用不阻塞的offer还是阻塞的put方法
            blockingQueue.offer(eventObject);
        } else {
            putUninterruptibly(eventObject);
        }
    }
    //以阻塞方式添加数据到队列
    private void putUninterruptibly(E eventObject) {
        boolean interrupted = false;
        try {
            while (true) {
                try {
                    blockingQueue.put(eventObject);
                    break;
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }
        } finally {
            if (interrupted) {
                Thread.currentThread().interrupt();
            }
        }
    }
}  

```

看到默认队列大小为256，达到80%容量后开始丢弃&lt;=INFO级别的日志后，我们就可以理解日志中为什么只有215条INFO日志了。

我们可以继续分析下异步记录日志出现坑的原因。

- queueSize设置得特别大，就可能会导致OOM。
- queueSize设置得比较小（默认值就非常小），且discardingThreshold设置为大于0的值（或者为默认值），队列剩余容量少于discardingThreshold的配置就会丢弃&lt;=INFO的日志。这里的坑点有两个。一是，因为discardingThreshold的存在，设置queueSize时容易踩坑。比如，本例中最大日志并发是1000，即便设置queueSize为1000同样会导致日志丢失。二是，discardingThreshold参数容易有歧义，它不是百分比，而是日志条数。对于总容量10000的队列，如果希望队列剩余容量少于1000条的时候丢弃，需要配置为1000。
- neverBlock默认为false，意味着总可能会出现阻塞。如果discardingThreshold为0，那么队列满时再有日志写入就会阻塞；如果discardingThreshold不为0，也只会丢弃&lt;=INFO级别的日志，那么出现大量错误日志时，还是会阻塞程序。

可以看出queueSize、discardingThreshold和neverBlock这三个参数息息相关，务必按需进行设置和取舍，到底是性能为先，还是数据不丢为先：

- 如果考虑绝对性能为先，那就设置neverBlock为true，永不阻塞。
- 如果考虑绝对不丢数据为先，那就设置discardingThreshold为0，即使是&lt;=INFO的级别日志也不会丢，但最好把queueSize设置大一点，毕竟默认的queueSize显然太小，太容易阻塞。
- 如果希望兼顾两者，可以丢弃不重要的日志，把queueSize设置大一点，再设置一个合理的discardingThreshold。

以上就是日志配置最常见的两个误区了。接下来，我们再看一个日志记录本身的误区。

## 使用日志占位符就不需要进行日志级别判断了？

不知道你有没有听人说过：SLF4J的{}占位符语法，到真正记录日志时才会获取实际参数，因此解决了日志数据获取的性能问题。你觉得，这种说法对吗？

为了验证这个问题，我们写一段测试代码：有一个slowString方法，返回结果耗时1秒：

```
private String slowString(String s) {
    System.out.println(&quot;slowString called via &quot; + s);
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
    }
    return &quot;OK&quot;;
}

```

如果我们记录DEBUG日志，并设置只记录&gt;=INFO级别的日志，程序是否也会耗时1秒呢？我们使用三种方法来测试：

- 拼接字符串方式记录slowString；
- 使用占位符方式记录slowString；
- 先判断日志级别是否启用DEBUG。

```
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;debug1&quot;);
log.debug(&quot;debug1:&quot; + slowString(&quot;debug1&quot;));
stopWatch.stop();
stopWatch.start(&quot;debug2&quot;);
log.debug(&quot;debug2:{}&quot;, slowString(&quot;debug2&quot;));
stopWatch.stop();
stopWatch.start(&quot;debug3&quot;);
if (log.isDebugEnabled())
    log.debug(&quot;debug3:{}&quot;, slowString(&quot;debug3&quot;));
stopWatch.stop();

```

可以看到，前两种方式都调用了slowString方法，所以耗时都是1秒：

<img src="https://static001.geekbang.org/resource/image/fb/83/fbaac87cad19b2136e6f9f99bbc43183.png" alt="">

使用占位符方式记录slowString的方式，同样需要耗时1秒，是因为这种方式虽然允许我们传入Object，不用拼接字符串，但也只是延迟（如果日志不记录那么就是省去）了日志参数对象.toString()和字符串拼接的耗时。

在这个案例中，除非事先判断日志级别，否则必然会调用slowString方法。**回到之前提的问题，使用{}占位符语法不能通过延迟参数值获取，来解决日志数据获取的性能问题。**

除了事先判断日志级别，我们还可以通过lambda表达式进行延迟参数内容获取。但，SLF4J的API还不支持lambda，因此需要使用Log4j2日志API，把Lombok的@Slf4j注解替换为@Log4j2注解，这样就可以提供一个lambda表达式作为提供参数数据的方法：

```
@Log4j2
public class LoggingController {
...
log.debug(&quot;debug4:{}&quot;, ()-&gt;slowString(&quot;debug4&quot;));

```

像这样调用debug方法，签名是Supplier&lt;?&gt;，参数会延迟到真正需要记录日志时再获取：

```
void debug(String message, Supplier&lt;?&gt;... paramSuppliers);

public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message,
        final Supplier&lt;?&gt;... paramSuppliers) {
    if (isEnabled(level, marker, message)) {
        logMessage(fqcn, level, marker, message, paramSuppliers);
    }
}
protected void logMessage(final String fqcn, final Level level, final Marker marker, final String message,
        final Supplier&lt;?&gt;... paramSuppliers) {
    final Message msg = messageFactory.newMessage(message, LambdaUtil.getAll(paramSuppliers));
    logMessageSafely(fqcn, level, marker, msg, msg.getThrowable());
}

```

修改后再次运行测试，可以看到这次debug4并不会调用slowString方法：

<img src="https://static001.geekbang.org/resource/image/6c/ab/6c44d97b24fa51ec249759cb62828aab.png" alt="">

其实，我们只是换成了Log4j2 API，真正的日志记录还是走的Logback框架。没错，这就是SLF4J适配的一个好处。

## 重点回顾

我将记录日志的坑，总结为框架使用配置和记录本身两个方面。

Java的日志框架众多，SLF4J实现了这些框架记录日志的统一。在使用SLF4J时，我们需要理清楚其桥接API和绑定这两个模块。如果程序启动时出现SLF4J的错误提示，那很可能是配置出现了问题，可以使用Maven的dependency:tree命令梳理依赖关系。

Logback是Java最常用的日志框架，其配置比较复杂，你可以参考官方文档中关于Appender、Layout、Filter的配置，切记不要随意从其他地方复制别人的配置，避免出现错误或与当前需求不符。

使用异步日志解决性能问题，是用空间换时间。但空间毕竟有限，当空间满了之后，我们要考虑是阻塞等待，还是丢弃日志。如果更希望不丢弃重要日志，那么选择阻塞等待；如果更希望程序不要因为日志记录而阻塞，那么就需要丢弃日志。

最后，我强调的是，日志框架提供的参数化日志记录方式不能完全取代日志级别的判断。如果你的日志量很大，获取日志参数代价也很大，就要进行相应日志级别的判断，避免不记录日志也要花费时间获取日志参数的问题。

今天用到的代码，我都放在了GitHub上，你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。

## 思考与讨论

1. 在第一小节的案例中，我们把INFO级别的日志存放到_info.log中，把WARN和ERROR级别的日志存放到_error.log中。如果现在要把INFO和WARN级别的日志存放到_info.log中，把ERROR日志存放到_error.log中，应该如何配置Logback呢？
1. 生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理，以避免单个文件太大，同时保留一定天数的历史日志，你知道如何配置吗？可以在[官方文档](http://logback.qos.ch/manual/appenders.html#RollingFileAppender)找到答案。

针对日志记录和配置，你还遇到过其他坑吗？我是朱晔，欢迎在评论区与我留言分享，也欢迎你把这篇文章分享给你的朋友或同事，一起交流。
