【log4j2】将运行时变量注入日志、附性能对比与生产案例(动态日志实战)
文章目录
- 为什么需要动态日志
- ThreadContext方式
- 自定义Converter方式
- 生产环境实践
- 性能对比(ThreadContext vs 直接传参)
更多相关内容可查看
业务背景:在已成型很多年的项目的基础上,进行日志改造,例如在程序中已经通过lg4j2打印了很多日志了,通过改每一条输出日志不显示,可以通过插件挂载,动态读取的方式来改造日志
对log4j2日志不了解的可查看主页log4j2相关文章,此篇不做详细log4j2框架讲解
官网地址:https://www.docs4dev.com/docs/zh/log4j2/2.x/all/manual-index.html
为什么需要动态日志
在分布式系统中,我们经常需要在日志中自动记录以下信息:
• 用户ID、请求链路TraceID
• 线程池任务编号
• 动态业务参数(如订单号、商户ID)
传统硬编码方式:
logger.info("用户{}下单,订单号:{}", userId, orderId); // 每个日志都要手动传参
痛点:
- 代码重复率高
- 修改成本大(如新增字段需修改所有日志点)
- 无法统一处理敏感信息脱敏
ThreadContext方式
ThreadContext(旧称MDC)是Log4j2提供的线程级变量存储,原理如下:
组件 | 作用 |
---|---|
ThreadContext | 基于ThreadLocal的键值对存储,生命周期与线程绑定 |
%X{key} | 日志模板中通过占位符引用变量 |
Put/Remove | 变量操作API,支持嵌套调用(如子线程继承父线程上下文) |
直接put后要clear
// 注入变量
ThreadContext.put("traceId", UUID.randomUUID().toString());
ThreadContext.put("userId", "U10001");// 日志模板(log4j2.xml配置)
<PatternLayout pattern="%d [%X{traceId}] [%X{userId}] %m%n"/>// 输出效果
2023-08-20 14:00 [a1b2c3d4] [U10001] 用户登录成功try {ThreadContext.put("key", "value");logger.info("业务操作..."); // 可读取%X{key}
} finally {ThreadContext.clear(); // 必须清理避免内存泄漏!
}//也可以通过过滤器在请求结束后统一清理
public class CleanContextFilter implements Filter {@Overridepublic void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {try {chain.doFilter(req, res);} finally {ThreadContext.clearAll(); // 确保每个请求结束后清理}}
}
Spring 环境Aop实现
@Aspect
@Component
public class ThreadContextAspect {@Around("execution(* com..service.*.*(..))")public Object cleanContext(ProceedingJoinPoint pjp) throws Throwable {try {// 可以在此统一注入公共变量ThreadContext.put("app", "order-service");return pjp.proceed();} finally {ThreadContext.clearAll();}}
}
自定义Converter方式
通过自定义Converter实现:
<PatternLayout pattern="%d %X{traceId} %mask{%X{phoneNumber}} %m%n"/>
@Plugin(name = "mask", category = "Converter")
public class MaskConverter extends LogEventPatternConverter {@Overridepublic void format(LogEvent event, StringBuilder output) {String phone = event.getContextData().getValue("phoneNumber");output.append(phone.substring(0, 3) + "****" + phone.substring(7));}
}
与APM系统集成
// 将ThreadContext变量注入Span标签(SkyWalking示例)
ActiveSpan.tag("user_id", ThreadContext.get("userId"));
生产环境实践
问题:一个产品多个客户,不同的客户不同的日志需求,所以不能改原来的代码,只能通过别的方式去解决这个问题
//在流量的入口放入,这样在进行请求或者响应的时候就会自己注入日志当中
ThreadContext.put("GlobalSeqNo", businessSerialNumber);
ThreadContext.put("UUID", UUID.randomUUID().toString());
//在流量的出口clear,避免内存泄漏
ThreadContext.clearAll();
具体的logj2配置如下,其中%X{UUID}|%X{GlobalSeqNo}配置就是通过这个功能去实现的
<?xml version="1.0" encoding="UTF-8"?>
<!--status : 这个用于设置log4j2自身内部的信息输出,可以不设置,当设置成trace时,会看到log4j2内部各种详细输出monitorInterval : Log4j能够自动检测修改配置文件和重新配置本身, 设置间隔秒数。
-->
<Configuration status="WARN" monitorInterval="10" packages="com.server.util"><Properties><!-- 配置日志文件输出目录 --><Property name="LOG_HOME">/opt/log</Property><Property name="ARCHIVE_HOME">${LOG_HOME}/aaa</Property><Property name="APP_Name" value="sss"/><!-- 版本号属性 --><Property name="LOG_VERSION">v01</Property><!-- 主机号 --><Property name="HOSTNAME">xxx</Property><Property name="Pattern">[%d{yyyy-MM-dd HH:mm:ss.SSS}]|%level|${APP_Name}|%X{UUID}|%X{GlobalSeqNo}|%pid|%thread|%class{1.}:%line|%X{NestedLevel}|%msg%n</Property></Properties><Appenders><!--这个输出控制台的配置--><Console name="STDOUT" target="SYSTEM_OUT"><!-- 控制台只输出level及以上级别的信息(onMatch),其他的直接拒绝(onMismatch) --><ThresholdFilter level="info" onMatch="ACCEPT" onMismatch="DENY"/><!-- 输出日志的格式 --><PatternLayout pattern="${Pattern}"/></Console><RollingRandomAccessFile name="FILE"immediateFlush="true" fileName="${LOG_HOME}/proc-${xxx}-${APP_Name}-${HOSTNAME}-1.log"filePattern="${ARCHIVE_HOME}/proc-${xxx}-${APP_Name}-${HOSTNAME}-%d{yyyyMMdd}-%i.log.zip"></RollingRandomAccessFile></Appenders>
性能对比(ThreadContext vs 直接传参)
• 日志量:100万条
• 线程数:50并发
• 硬件:4C8G VM
结果数据
方式 | 耗时(ms) | 内存占用(MB) | 适用场景 |
---|---|---|---|
直接传参 | 1,200 | 150 | 简单业务,少量日志 |
ThreadContext | 1,450 | 180 | 需要上下文传递的复杂场景 |
ThreadContext + 清空 | 1,500 | 160 | 严格防泄漏的生产环境 |
结论:
ThreadContext有约20%的性能损耗,但在需要跨线程/组件传递上下文时不可替代。
生产建议:在网关层统一注入基础字段(如traceId),业务层按需添加动态参数。