前言
Java开发者对于日志框架,想必都不陌生。我自己使用过的有Log4j、logback。作为Java开发者,应该都遇到因日志包冲突导致的异常问题,排查过程也或多或少知晓 Java日志接口包、桥接包、产品包的混乱关系,本篇目的是为了对日志框架的发展历史,以及Logback的使用及异步调优进行简单介绍,提供一类日志问题排查思路。
日志发展历史
- 1996年初,E.U.SEMPER(欧洲安全电子市场)定义并实现了 Log4j,其一贡献成员 Ceki Gülcü,也是后来Logback的设计者。
- 后来 Log4j 成为 Apache 项目之一
- 2002年2月,Sun公司发布Java1.4的同时,推出日志产品 Java Util Logging(简称JUL)
- 2002年8月,Apache 定义日志接口 Jakarta Commons Logging(简称JCL),Log4j便是 JCL 的实现产品之一
- 2005年,Ceki Gülcü 定义日志接口 Simple Logging Facade for Java(简称Slf4j),为了兼容历史已存在的日志产品包,Ceki Gülcü 分别对 JUL 和 Log4j 手写了桥接包 slf4j-jdk14、slf4j-log4j12
- 2006年,Ceki Gülcü 基于 slf4j 接口定义,手写了 Logback 日志产品
- 2012年,Apache 针对 Slf4j 推出了新的日志产品 Log4j2(与Log4j1.x不兼容)
先贴一张常用日志包之间的关系图,由接口包指向产品包的箭头,意为实现;由产品包指向接口包、以及接口包之间的箭头,意为桥接。目前主流方案是slf4j+logback 或 slf4j+log4j2。
桥接包实现
桥接包——使用了[桥接]设计模式打的jar包。桥接模式主要目标是解耦抽象与实现,使得两者可以独立地变化。以 log4j-to-slf4j 举例,它存在的意义就是将log4j-api 的接口定义,与 log4j 的实现隔离开。举例:项目A中使用了log4j-api(接口) + log4j-core(实现),如果中途期望将日志产品 log4j 切换成 logback,但又不期望对项目中所有Logger的引用做调整,这时候需要做以下的步骤:移除 log4j-core(原实现)、引入 log4j-to-slf4j(桥接)、slf4j-api(桥接目标接口)、logback-core+logback-classic(桥接目标实现)。
首先看下 log4j-to-slf4j 包结构。
了解< SPI机制 >的话,看到 META-INF/services/org.apache.logging.log4j.spi.Provider 第一反应便是借助 SPI 将 Log4j 实现类引入,实现类为同包下的 org.apache.logging.slf4j.SLF4JProvider
package org.apache.logging.slf4j;
import org.apache.logging.log4j.spi.Provider;
public class SLF4JProvider extends Provider {
public SLF4JProvider() {
super(15, "2.6.0", SLF4JLoggerContextFactory.class, MDCContextMap.class);
}
}
对比 log4j-core 实现类 org.apache.logging.log4j.core.impl.Log4jProvider,优先级更高(15>10)。
package org.apache.logging.log4j.core.impl;
import org.apache.logging.log4j.spi.Provider;
public class Log4jProvider extends Provider {
public Log4jProvider() {
super(10, "2.6.0", Log4jContextFactory.class);
}
}
接下来看 log4j-to-slf4j 对 log4j-api 定义的 org.apache.logging.log4j.spi.LoggerContextFactory 和 org.apache.logging.log4j.spi.LoggerContext扩展实现
package org.apache.logging.slf4j;
import java.net.URI;
import org.apache.logging.log4j.spi.LoggerContext;
import org.apache.logging.log4j.spi.LoggerContextFactory;
import org.apache.logging.log4j.status.StatusLogger;
import org.apache.logging.log4j.util.LoaderUtil;
public class SLF4JLoggerContextFactory implements LoggerContextFactory {
private static final StatusLogger LOGGER = StatusLogger.getLogger();
private static final LoggerContext context = new SLF4JLoggerContext();
public SLF4JLoggerContextFactory() {
boolean misconfigured = false;
try {
LoaderUtil.loadClass("org.slf4j.helpers.Log4jLoggerFactory");
misconfigured = true;
} catch (ClassNotFoundException var3) {
LOGGER.debug("org.slf4j.helpers.Log4jLoggerFactory is not on classpath. Good!");
}
// log4j实现循环依赖拦截
if (misconfigured) {
throw new IllegalStateException("slf4j-impl jar is mutually exclusive with log4j-to-slf4j jar (the first routes calls from SLF4J to Log4j, the second from Log4j to SLF4J)");
}
}
public LoggerContext getContext(final String fqcn, final ClassLoader loader, final Object externalContext, final boolean currentContext) {
return context;
}
public LoggerContext getContext(final String fqcn, final ClassLoader loader, final Object externalContext, final boolean currentContext, final URI configLocation, final String name) {
return context;
}
}
package org.apache.logging.slf4j;
import org.apache.logging.log4j.message.MessageFactory;
import org.apache.logging.log4j.spi.ExtendedLogger;
import org.apache.logging.log4j.spi.LoggerContext;
import org.apache.logging.log4j.spi.LoggerRegistry;
import org.slf4j.LoggerFactory;
public class SLF4JLoggerContext implements LoggerContext {
private final LoggerRegistry<ExtendedLogger> loggerRegistry = new LoggerRegistry();
public ExtendedLogger getLogger(final String name) {
if (!this.loggerRegistry.hasLogger(name)) {
// 使用 slf4j-api 获取Logger后,再借助 SLF4JLogger 包装一层适配
this.loggerRegistry.putIfAbsent(name, (MessageFactory)null, new SLF4JLogger(name, LoggerFactory.getLogger(name)));
}
return this.loggerRegistry.getLogger(name);
}
}
通过扩展 Log4j 开放的 LoggerContext 的实现类,使用 SLF4JLogger 把原本 org.apache.logging.log4j.Logger 包装了一层后注册进去,后续对 Log4j Logger 的调用,就被桥接到 slf4j 的 Logger ,至此 log4j-api 到 slf4j-api 的桥接完成。
为了避免桥接 slf4j 后,又引入实现了 slf4j 版的Log4j(无限循环),SLF4JLoggerContextFactory 在构造器内尝试加载了 log4j-slf4j-impl(可以从最初的关系图中看出实现关系) 中的一个类,如果加载成功(即classpath 存在了Log4j 对 slf4j 接口的实现包),则抛出异常阻断项目启动。
接下来再引入一个 slf4j 的日志产品包即可,例如 logback。
Logback实践
由于 slf4j + logback 的简洁易用性,Spring-boot 默认集成了 Logback 日志框架。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
Logback定义了Logger, Appender 和 Encoder(在0.9.19版本后,已经推荐使用Encoder,之前使用Layout) 3个主要的组件。开发者借助日志框架提供的多种实现类,通过自定义日志级别、日志格式等完成日志的输出。
从 Encoder 开始说起,从接口的定义可以看出,主要负责将日志时间转为字节数据输出。对应配置中<encoder>标签, Logback默认的 Encoder 实现为 ch.qos.logback.classic.encoder.PatternLayoutEncoder,所以如果不特殊指定class,默认便是 PatternLayoutEncoder。
package ch.qos.logback.core.encoder;
import ch.qos.logback.core.spi.ContextAware;
import ch.qos.logback.core.spi.LifeCycle;
public interface Encoder<E> extends ContextAware, LifeCycle {
byte[] headerBytes();
byte[] encode(E event);
byte[] footerBytes();
}
通过查看 PatternLayoutEncoder 源码可以发现有一个 pattern 的成员变量,用于指定日志的输出格式,对应 <encoder> 配置的子标签 <pattern>。Logback 的配置实质就是将标签指定的class实例化,并借助子标签给实例对象赋值(setter);通过源码还可以发现,PatternLayoutEncoder 的 Layout 默认使用 ch.qos.logback.classic.PatternLayout。
PatternLayout,通过组合"%+转换符"来指定日志输出格式,以下为常用的转换符释义,其余转换符可以看 PatternLayout 中成员变量 DEFAULT_CONVERTER_MAP(定义了转换符-转换处理类的映射关系)
转换符 | 释义 |
%s | 日期 |
%thread 或 %t | 线程名 |
%msg 或 %m | 开发者打印的日志内容 |
%level | 日志级别。%-5level表示从左输出最多5个字符宽度,不足右侧补空格 |
%n | 日志换行 |
%logger | 输出日志所在类名,默认显示全限定名,可追加 {数字} 来压缩长度 |
%date 或 %d | 打印时机器时间,追加{yyyy-MM-dd HH:mm:ss.SSS}指定时间格式 |
%line 或 %L | 输入日志所在类的行数 |
接下来是 Appender ,它主要承担了日志输出职责。除了日志输出,自身可以被定义一个 name,用于 Logger 引用。
package ch.qos.logback.core;
import ch.qos.logback.core.spi.ContextAware;
import ch.qos.logback.core.spi.FilterAttachable;
import ch.qos.logback.core.spi.LifeCycle;
public interface Appender<E> extends LifeCycle, ContextAware, FilterAttachable<E> {
String getName();
void doAppend(E event) throws LogbackException;
void setName(String name);
}
常用的有控制台输出、日志文件输出等,实现类如下。
类型 | 实现类 | 属性设置 |
控制台 | ch.qos.logback.core.ConsoleAppender | 一般只需要配置输出格式即可 |
文件 | ch.qos.logback.core.FileAppender | 常用属性如下
|
滚动文件 | ch.qos.logback.core.rolling.RollingFileAppender | 在FileAppender基础上新增属性如下
|
异步Appender | net.logstash.logback.appender.LoggingEventAsyncDisruptorAppender | 日志异步化,需指定appender实例 |
考虑到大多Java应用都是在服务器上长期运行,日志记录也就几乎伴随了 Java 应用运行的整个生命周期。
考虑这样一件事:日志记录的目的是为了排查问题,但日志输出是持续的,如果不周期性的清理,机器的磁盘总会有100%的一天。所以日志框架设计的时候,也要考虑到怎么辅助开发者定期清理日志。
比如可以通过周期性的将日志归档,通过将历史日志的文件名打上日期的标识,这样开发者便可以按照日期定时清理N天前的日志文件。上述表格 ch.qos.logback.core.rolling.RollingFileAppender 也就成了最常用的 Appender 实现类,其中 rollingPolicy 属性支持设置滚动策略,logback 提供的实现类如下:
实现类 | 作用 | 属性设置 |
ch.qos.logback.core.rolling.TimeBasedRollingPolicy | 基于时间的滚动策略 |
|
ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy | 【最常用】基于时间和文件大小的滚动策略
|
在TimeBasedRollingPolicy基础上增加
|
ch.qos.logback.core.rolling.FixedWindowRollingPolicy | 基于固定窗口的滚动策略 (异步调优会用到) |
|
经过上面的一个概念引入,接下来看下 一份 Logback 配置文件,应该比较好理解
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="APPLICATION-APPENDER"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<!--输出的日志文件名-->
<file>application.log</file>
<encoder>
<!--输出的日志格式-->
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<!--滚动策略-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!--滚动生成的日志文件名-->
<fileNamePattern>application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>7</maxHistory>
<maxFileSize>50MB</maxFileSize>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
</appender>
<logger name="APPLICATION" level="debug" additivity="false">
<!--appender引用-->
<appender-ref ref="APPLICATION-APPENDER"/>
</logger>
<!--全局日志级别-->
<root level="INFO">
<!--如果子logger additivity=true,父logger会叠加输出日志-->
<appender-ref ref="APPLICATION-APPENDER"/>
</root>
</configuration>
对配置中常用的标签介绍下
标签 | 属性 |
<root> | 可包含0到多个<appender-ref>
|
<logger> |
|
<appender> |
|
<encoder> | 用于指定日志输出格式 |
<rollingPolicy> | RollingFileAppender实现特殊标签,用于指定日志滚动策略(例如基础时间、存储大小等) |
接下来开发者只要在业务代码中,按照定义的 logger name 获取到 Logger,调用API输出日志即可。
package com.example.LogDemo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogDemo {
private static Logger logger = LoggerFactory.getLogger("APPLICATION");
public static void main(String[] args) {
logger.info("this is one log event");
}
}
当然,你也看到过如下获取 Logger 的方式,区别就在于一个是指定了 Logger name,另一个入参则是任意的 Class 对象,日志框架是尝试用类的全限定名,来获取 Logger,但配置里并没有声明这样一个 <logger class="com.example.LogDemo">,那日志框架的又是使用哪个 Logger 进行日志输出呢?
package com.example.LogDemo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogDemo {
private static Logger logger = LoggerFactory.getLogger(LogDemo.class);
public static void main(String[] args) {
logger.info("this is one log event");
}
}
Logback 的 Logger 命名是有层级结构(Log4j也有,其余日志框架没有使用过,不太清楚) 。如果某个 Logger 的 name加上".",是另一个 Logger name的前缀,则前者是后者的父级,<root> 是所有 Logger 的顶级父,整体的结构类似 Java 的继承关系(Logger未指定的属性会继承自父级Logger)。全局的 Logger 缓存在 ch.qos.logback.classic.LoggerContext 中。
如果在获取 Logger 时,name未存在于 LoggerContext 缓存中,会将入参 name 按"."划分层级自顶向下寻找 Logger,对应上述例子便是: com -> com.example -> com.example.LogDemo(本例底层会生成3个Logger)。如果不存在则用父级 Logger 属性“复刻”一个子 Logger,并放置到缓存中,对应源码如下
public class LoggerContext extends ContextBase implements ILoggerFactory, LifeCycle {
final Logger root;
private Map<String, Logger> loggerCache;
public LoggerContext() {
super();
this.loggerCache = new ConcurrentHashMap<String, Logger>();
this.root = new Logger(Logger.ROOT_LOGGER_NAME, null, this);
this.root.setLevel(Level.DEBUG);
loggerCache.put(Logger.ROOT_LOGGER_NAME, root);
// 省略其余代码
}
public final Logger getLogger(final Class<?> clazz) {
return getLogger(clazz.getName());
}
@Override
public final Logger getLogger(final String name) {
if (name == null) {
throw new IllegalArgumentException("name argument cannot be null");
}
// if we are asking for the root logger, then let us return it without
// wasting time
if (Logger.ROOT_LOGGER_NAME.equalsIgnoreCase(name)) {
return root;
}
int i = 0;
Logger logger = root;
// check if the desired logger exists, if it does, return it
// without further ado.
Logger childLogger = (Logger) loggerCache.get(name);
// if we have the child, then let us return it without wasting time
if (childLogger != null) {
return childLogger;
}
// if the desired logger does not exist, them create all the loggers
// in between as well (if they don't already exist)
String childName;
// 按照"."层级逐层获取
while (true) {
int h = LoggerNameUtil.getSeparatorIndexOf(name, i);
if (h == -1) {
childName = name;
} else {
childName = name.substring(0, h);
}
// move i left of the last point
i = h + 1;
synchronized (logger) {
childLogger = logger.getChildByName(childName);
if (childLogger == null) {
childLogger = logger.createChildByName(childName);
loggerCache.put(childName, childLogger);
incSize();
}
}
logger = childLogger;
if (h == -1) {
return childLogger;
}
}
}
}
自此,Logback大致的架构以及使用已经介绍完毕,接下来看下日志配置同步转异步的配置。
Logback异步日志调优
首先日志记录仅仅是为了排查问题,真正服务于业务代码,所以日志的输出不应该影响业务的运行,特别是不能拉上业务的执行耗时。所以日志框架设计的时候便定义了异步 Appender 接口。这里调优使用了 Logstash 提供的工具包。
通过配置发现,仅仅在原本的<appender>外又套了一层<appender>,也就是使用 net.logstash.logback.appender.LoggingEventAsyncDisruptorAppender 又包装了一层(“装饰器模式”的设计思想)。
日志打印本质就是生产者-消费者模式,开发者负责生产日志Event,日志框架负责消费Event,将其转化为字节序列化输出。LoggingEventAsyncDisruptorAppender 高性能的原理,就是内部借助无锁队列 Distuptor ,开发者打印日志本质就是往队列中追加 Event,LoggingEventAsyncDisruptorAppender 再将队列中的 Event 进行异步的输出。
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="ASYNC-APPLICATION" class="net.logstash.logback.appender.LoggingEventAsyncDisruptorAppender">
<waitStrategyType>blocking</waitStrategyType>
<includeCallerData>false</includeCallerData>
<appender name="APPLICATION"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<!--输出的日志文件名-->
<file>application.log</file>
<encoder>
<!--输出的日志格式-->
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<!--滚动策略-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!--滚动生成的日志文件名-->
<fileNamePattern>application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>7</maxHistory>
<maxFileSize>50MB</maxFileSize>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
</appender>
</appender>
<logger name="APPLICATION" level="debug" additivity="false">
<!--appender引用-->
<appender-ref ref="ASYNC-APPLICATION"/>
</logger>
<!--全局日志级别-->
<root level="INFO">
<!--如果子logger additivity=true,父logger会叠加输出日志-->
<appender-ref ref="ASYNC-APPLICATION"/>
</root>
</configuration>
这是网上找到的性能测试对比(https://github.com/wsargent/slf4j-benchmark),从打印的 ops/ms指标,可直观的发现日志从同步->异步,提升是十分明显的。