Java项目基础架构(三)| 日志统一处理
- 一、单体/分布式项目日志全链路追踪
-
- [1. 前置准备:依赖引入](#1. 前置准备:依赖引入)
- [2. 工具类:ThreadLocalUtils](#2. 工具类:ThreadLocalUtils)
- [3. 新增:操作人ID注入Filter](#3. 新增:操作人ID注入Filter)
- [4. 自定义注解:@IgnoreLog(控制日志忽略)](#4. 自定义注解:@IgnoreLog(控制日志忽略))
- [5. AOP切面:RequestLogAspect](#5. AOP切面:RequestLogAspect)
- [6. 分布式链路追踪id(默认命名为 trace ID )](#6. 分布式链路追踪id(默认命名为 trace ID ))
- 二、全局异常与日志挂钩
-
- [1. 通用响应类:Result(前后端统一格式)](#1. 通用响应类:Result(前后端统一格式))
- [2. 业务异常类:BusinessException](#2. 业务异常类:BusinessException)
- [3. 补充:ThreadLocalUtils增强](#3. 补充:ThreadLocalUtils增强)
- [4. 全局异常处理器:GlobalExceptionHandler](#4. 全局异常处理器:GlobalExceptionHandler)
- [5. 补充:业务ID(bizId)注入示例(业务入口处调用)](#5. 补充:业务ID(bizId)注入示例(业务入口处调用))
- 三、日志持久化方案(单体/分布式通用)
-
- 核心选型对比
- [核心说明:sys_log vs sys_biz_log(大厂双表设计逻辑)](#核心说明:sys_log vs sys_biz_log(大厂双表设计逻辑))
- [1. 数据库持久化(通用型+审计型)](#1. 数据库持久化(通用型+审计型))
-
- 操作一:logback配置化(通用sys_log,0代码侵入)
-
- [① 建日志表(MySQL)](#① 建日志表(MySQL))
- [② logback-spring.xml 完整配置](#② logback-spring.xml 完整配置)
- 操作二:自定义Mapper
-
- [① 建扩展日志表(修正operator_id类型+增强索引)](#① 建扩展日志表(修正operator_id类型+增强索引))
- [② Mapper接口与实体类](#② Mapper接口与实体类)
- [③ 异步线程池配置](#③ 异步线程池配置)
- [④ 在AOP中注入并调用](#④ 在AOP中注入并调用)
- [2. 文件持久化](#2. 文件持久化)
- [3. ELK Stack持久化(分布式首选,企业级)](#3. ELK Stack持久化(分布式首选,企业级))
-
- [① Docker快速部署ELK](#① Docker快速部署ELK)
- [② Logstash配置](#② Logstash配置)
- [③ 项目配置Logstash输出](#③ 项目配置Logstash输出)
- [4. 云服务日志持久化(阿里云SLS为例)](#4. 云服务日志持久化(阿里云SLS为例))
-
- [① 引入依赖](#① 引入依赖)
- [② logback配置](#② logback配置)
一、单体/分布式项目日志全链路追踪
1. 前置准备:依赖引入
核心:通过
dependencyManagement锁定版本,避免依赖冲突;明确版本适配关系,降低落地踩坑概率
xml
<!-- 第一步:在pom.xml中添加dependencyManagement锁定版本(大厂规范) -->
<dependencyManagement>
<dependencies>
<!-- TransmittableThreadLocal版本锁定(适配Spring Boot 2.7.x) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.2</version>
<scope>import</scope>
<type>pom</type>
</dependency>
<!-- Spring Cloud Sleuth版本锁定(与Spring Boot 2.7.x配套) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
<version>3.1.9</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- 第二步:引入实际依赖 -->
<!-- AOP核心依赖(Spring Boot基础) -->
<dependency>
<groupId>springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 解决子线程ThreadLocal传递问题(异步/分布式场景必加) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
</dependency>
<!-- 分布式链路追踪(仅分布式项目需加,单体可省略) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<!-- 可选:JWT解析依赖(从GW传递的token中解析操作人ID,根据实际技术栈调整) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
版本兼容说明:
Spring Boot 2.7.x 建议搭配 Sleuth 3.1.x、TransmittableThreadLocal 2.14.x;
若使用Spring Boot 3.x,需替换为Spring Cloud 2022.0.x+Sleuth 4.0.x。
补充:操作人ID优先从网关(GW)传递的请求头/JWT令牌中解析,未登录场景默认填充
anonymous,避免空值。
2. 工具类:ThreadLocalUtils
基于阿里TransmittableThreadLocal解决子线程traceId/操作人ID丢失问题,补充"判断是否存在"逻辑,避免重复覆盖;操作人ID从GW/JWT解析后存入,未登录默认
anonymous
java
import com.alibaba.ttl.TransmittableThreadLocal;
/**
* 线程本地存储工具类,管理traceId/operatorId生命周期
* 核心:
* 1. 支持子线程/异步任务的traceId传递,避免分布式/异步场景下链路断裂
* 2. 管理操作人ID(operatorId),从GW/JWT解析后存入,未登录默认anonymous
*/
public class ThreadLocalUtils {
// 替换原生ThreadLocal,解决子线程传递问题
private static final TransmittableThreadLocal<String> TRACE_ID_HOLDER = new TransmittableThreadLocal<>();
// 操作人ID存储(核心:从GW/JWT解析,未登录=anonymous)
private static final TransmittableThreadLocal<String> OPERATOR_ID_HOLDER = new TransmittableThreadLocal<>();
// 私有构造,禁止实例化(工具类规范)
private ThreadLocalUtils() {}
/**
* 设置traceId(增强:先判断是否存在,避免重复覆盖)
* @param traceId 唯一请求ID(分布式=Trace ID,单体=UUID)
*/
public static void setTraceId(String traceId) {
if (getTraceId().equals("UNKNOWN")) { // 仅当未设置时赋值
TRACE_ID_HOLDER.set(traceId);
}
}
/**
* 获取traceId,未设置返回"UNKNOWN"(避免空指针)
* @return 唯一请求ID
*/
public static String getTraceId() {
return TRACE_ID_HOLDER.get() == null ? "UNKNOWN" : TRACE_ID_HOLDER.get();
}
/**
* 设置操作人ID(核心:从GW/JWT解析后调用,未登录传anonymous)
* @param operatorId 操作人ID(登录用户=用户ID,未登录=anonymous)
*/
public static void setOperatorId(String operatorId) {
OPERATOR_ID_HOLDER.set(operatorId == null ? "anonymous" : operatorId);
}
/**
* 获取操作人ID,未设置返回"anonymous"(避免空指针,兼容未登录场景)
* @return 操作人ID
*/
public static String getOperatorId() {
return OPERATOR_ID_HOLDER.get() == null ? "anonymous" : OPERATOR_ID_HOLDER.get();
}
/**
* 清理traceId+operatorId(必须在请求结束后调用,避免内存泄漏)
*/
public static void removeAll() {
TRACE_ID_HOLDER.remove();
OPERATOR_ID_HOLDER.remove();
}
// 兼容原有方法,避免改动其他代码
public static void removeTraceId() {
TRACE_ID_HOLDER.remove();
}
}
3. 新增:操作人ID注入Filter
补充:从网关传递的请求头/JWT令牌中解析操作人ID,存入ThreadLocal,全链路可用;未登录默认
anonymous
java
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 操作人ID注入Filter(核心:从GW传递的请求头/JWT解析,存入ThreadLocal)
* 场景:
* 1. 登录用户:GW解析JWT后,将用户ID放入请求头X-Operator-Id,此处直接获取
* 2. 未登录用户:默认填充anonymous,不影响业务流程
*/
@Component
public class OperatorIdInjectFilter extends OncePerRequestFilter {
// GW传递操作人ID的请求头(和网关同学约定,如X-Operator-Id)
private static final String OPERATOR_ID_HEADER = "X-Operator-Id";
// JWT令牌请求头(若GW未解析,此处可自行解析)
private static final String TOKEN_HEADER = "Authorization";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// 第一步:优先从GW传递的请求头获取操作人ID(推荐,减少服务内解析开销)
String operatorId = request.getHeader(OPERATOR_ID_HEADER);
// 第二步:若GW未传递,从JWT自行解析(备用方案,根据实际场景选择)
if (!StringUtils.hasText(operatorId)) {
String token = request.getHeader(TOKEN_HEADER);
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
operatorId = JwtUtil.parseUserId(token.substring(7)); // 替换为自身JWT解析逻辑
}
}
// 第三步:存入ThreadLocal(未登录=anonymous)
ThreadLocalUtils.setOperatorId(operatorId);
// 继续请求链路
filterChain.doFilter(request, response);
} finally {
// 兜底清理:避免ThreadLocal内存泄漏(和原有removeAll呼应)
ThreadLocalUtils.removeAll();
}
}
// 示例:JWT解析工具类(根据自身技术栈调整,仅做演示)
private static class JwtUtil {
public static String parseUserId(String token) {
// 实际项目替换为自身JWT解析逻辑,解析失败返回null(最终会被设为anonymous)
try {
// 示例:com.auth0.jwt.JWT.decode(token).getClaim("userId").asString();
return token != null ? "mock_user_id" : null;
} catch (Exception e) {
return null;
}
}
}
}
4. 自定义注解:@IgnoreLog(控制日志忽略)
采用"默认记录、例外忽略"设计,减少重复注解,符合大厂高效开发思路
java
import java.lang.annotation.*;
/**
* 忽略日志注解:加在Controller方法上,跳过日志记录
* 适用场景:健康检查、验证码接口、高频无敏感信息的接口
*/
@Target(ElementType.METHOD) // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时生效
@Documented
public @interface IgnoreLog {
// 强制填写忽略原因(合规要求,便于后续维护)
String reason() default "未说明忽略日志原因";
}
5. AOP切面:RequestLogAspect
拦截所有@RestController方法,自动生成traceId、注入操作人ID(从ThreadLocal获取)、脱敏敏感信息(支持复杂参数)、记录全链路日志,兼容单体/分布式场景
java
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.util.StopWatch;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 全局请求日志切面(大厂标准实现)
* 核心能力:
* 1. 分布式复用Sleuth的Trace ID,单体生成UUID,统一为traceId
* 2. 自动注入操作人ID(从ThreadLocal获取,未登录=anonymous)
* 3. 敏感参数/响应脱敏(支持List/Map等复杂类型,避免toString异常)
* 4. 记录请求全生命周期(开始/结束/异常),含耗时、IP、traceId、operatorId
* 5. 自动清理资源,避免内存泄漏
*/
@Slf4j
@Aspect
@Component
public class RequestLogAspect {
// 引入Jackson,处理复杂参数的JSON序列化(避免List/Map toString乱码)
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// 切点:拦截所有标注@RestController的类的所有方法
@Pointcut("@within(RestController)")
public void restControllerPointcut() {}
@Around("restControllerPointcut()")
public Object handleRequest(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 非Web请求(如定时任务、内部方法调用)直接执行,不记录日志
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return joinPoint.proceed();
}
HttpServletRequest request = attributes.getRequest();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodFullName = signature.getDeclaringTypeName() + "." + signature.getName();
// 2. 检查是否忽略日志(存在@IgnoreLog注解则跳过)
if (signature.getMethod().isAnnotationPresent(IgnoreLog.class)) {
IgnoreLog ignoreLog = signature.getMethod().getAnnotation(IgnoreLog.class);
log.debug("[IgnoreLog] 跳过日志:{},原因:{}", methodFullName, ignoreLog.reason());
return joinPoint.proceed();
}
// 3. 初始化traceId(核心:分布式=Trace ID,单体=UUID)
String traceId;
String sleuthTraceId = MDC.get("traceId"); // Sleuth自动注入的分布式追踪ID
if (StringUtils.hasText(sleuthTraceId)) {
traceId = sleuthTraceId; // 分布式场景:复用Trace ID,保证全链路一致
} else {
traceId = UUID.randomUUID().toString().replace("-", ""); // 单体场景:生成UUID
}
// 核心新增:获取操作人ID(从ThreadLocal,未登录=anonymous)
String operatorId = ThreadLocalUtils.getOperatorId();
// 存入ThreadLocal(子线程传递)+ MDC(日志自动携带)+ Request(异常处理器获取)
ThreadLocalUtils.setTraceId(traceId);
MDC.put("traceId", traceId);
MDC.put("operatorId", operatorId); // 操作人ID存入MDC,日志可直接引用
MDC.put("ip", request.getRemoteAddr());
MDC.put("method", methodFullName);
request.setAttribute("traceId", traceId);
request.setAttribute("operatorId", operatorId); // 异常处理器可获取
// 4. 记录请求开始(StopWatch精准统计耗时,新增operatorId字段)
StopWatch stopWatch = new StopWatch();
stopWatch.start();
log.info(
"[RequestStart] traceId={}, operatorId={}, ip={}, url={}, httpMethod={}, params={}, handler={}",
traceId,
operatorId, // 日志中明确打印操作人ID
request.getRemoteAddr(),
request.getRequestURL().toString(),
request.getMethod(),
getDesensitizedParams(joinPoint), // 入参脱敏(增强版)
methodFullName
);
try {
// 5. 执行目标方法(业务逻辑)
Object result = joinPoint.proceed();
// 6. 记录请求结束(响应脱敏+耗时统计,新增operatorId)
stopWatch.stop();
MDC.put("costTime", String.valueOf(stopWatch.getTotalTimeMillis()));
log.info(
"[RequestEnd] traceId={}, operatorId={}, handler={}, costTime={}ms, result={}",
ThreadLocalUtils.getTraceId(),
operatorId, // 日志中打印操作人ID
methodFullName,
stopWatch.getTotalTimeMillis(),
getDesensitizedResult(result) // 响应脱敏(增强版)
);
return result;
} catch (Throwable e) {
// 7. 记录请求异常(带完整堆栈,新增operatorId,便于排查)
stopWatch.stop();
MDC.put("costTime", String.valueOf(stopWatch.getTotalTimeMillis()));
log.error(
"[RequestError] traceId={}, operatorId={}, handler={}, costTime={}ms, errorMsg={}",
ThreadLocalUtils.getTraceId(),
operatorId, // 异常日志中关联操作人ID
methodFullName,
stopWatch.getTotalTimeMillis(),
e.getMessage(),
e // 打印完整堆栈(关键!排查问题必须)
);
throw e; // 抛给全局异常处理器,统一返回格式
} finally {
// 8. 清理资源(核心:替换为removeAll,同时清理traceId+operatorId,避免内存泄漏)
ThreadLocalUtils.removeAll();
MDC.remove("traceId");
MDC.remove("operatorId"); // 清理操作人ID
MDC.remove("ip");
MDC.remove("method");
MDC.remove("costTime");
RequestContextHolder.resetRequestAttributes();
}
}
/**
* 入参脱敏:覆盖常见敏感字段,支持List/Map等复杂类型(避免序列化异常)
*/
private String getDesensitizedParams(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) return "[]";
StringBuilder params = new StringBuilder("[");
for (Object arg : args) {
if (arg == null) {
params.append("null, ");
continue;
}
String argStr;
// 处理List/Map类型,用Jackson序列化(避免toString乱码)
try {
if (arg instanceof List || arg instanceof Map) {
argStr = OBJECT_MAPPER.writeValueAsString(arg);
} else {
argStr = arg.toString();
}
} catch (JsonProcessingException e) {
argStr = "【参数序列化失败】";
log.warn("[ParamSerializeError] traceId={}, operatorId={}, 参数序列化异常:{}",
ThreadLocalUtils.getTraceId(), ThreadLocalUtils.getOperatorId(), e.getMessage());
}
// 脱敏规则(正则匹配,不侵入业务代码)
argStr = argStr
.replaceAll("\"password\":\"[^\"]+\"", "\"password\":\"****\"")
.replaceAll("\"phone\":\"(\\d{3})\\d{4}(\\d{4})\"", "\"phone\":\"$1****$2\"")
.replaceAll("\"idCard\":\"(\\d{6})\\d{8}(\\d{4})\"", "\"idCard\":\"$1****$2\"")
.replaceAll("\"bankCard\":\"(\\d{6})\\d{8,16}(\\d{4})\"", "\"bankCard\":\"$1****$2\"")
.replaceAll("\"amount\":\"[^\"]+\"", "\"amount\":\"****\""); // 金额脱敏
params.append(argStr).append(", ");
}
if (params.length() > 1) params.setLength(params.length() - 2);
params.append("]");
return params.toString();
}
/**
* 响应脱敏:隐藏token,通用Result仅返回基础信息,支持复杂响应类型
*/
private String getDesensitizedResult(Object result) {
if (result == null) return "null";
String resultStr;
try {
// 统一用Jackson序列化,避免复杂对象toString异常
resultStr = OBJECT_MAPPER.writeValueAsString(result);
} catch (JsonProcessingException e) {
resultStr = "【响应序列化失败】";
log.warn("[ResultSerializeError] traceId={}, operatorId={}, 响应序列化异常:{}",
ThreadLocalUtils.getTraceId(), ThreadLocalUtils.getOperatorId(), e.getMessage());
}
// 对通用Result类特殊处理,对其他类型脱敏token
if (result instanceof Result) {
Result<?> resultObj = (Result<?>) result;
return String.format("{\"code\":%d, \"message\":\"%s\", \"data\":\"[脱敏]\"}",
resultObj.getCode(), resultObj.getMessage());
}
return resultStr.replaceAll("\"token\":\"[^\"]+\"", "\"token\":\"****\"");
}
}
6. 分布式链路追踪id(默认命名为 trace ID )
当需要 自定义的 trace ID 时(也就是自己命名追踪id),如下配置即可;补充:操作人ID跨服务传递配置(和GW配合)
yaml
spring:
sleuth:
baggage:
remote-fields: bizOrderId, X-Operator-Id # 跨服务传递自定义业务字段+操作人ID
correlation-fields: bizOrderId, X-Operator-Id # 关联自定义字段与Trace ID
# 补充:让Sleuth自动将操作人ID放入MDC,无需手动put
mdc-fields: traceId, X-Operator-Id
| 配置项 | 核心作用 | 大白话解释 |
|---|---|---|
remote-fields |
把指定字段标记为"跨服务传递字段",随Feign/RestTemplate请求头传给下游服务 | 让bizOrderId/X-Operator-Id能像Trace ID一样,从A服务传到B服务、B传到C服务,下游能拿到这个值 |
correlation-fields |
把指定字段和Sleuth原生Trace ID"绑定",自动注入到MDC上下文 | 让bizOrderId/X-Operator-Id自动出现在日志里(不用手动MDC.put()),且能通过字段关联到整个Trace ID链路 |
mdc-fields |
补充:指定自动注入MDC的字段,操作人ID可直接在日志模板中引用 | 日志模板中写%X{X-Operator-Id}即可自动打印操作人ID,无需手动处理 |
我完全对齐你的原文结构,核心按「删除异常类中的审计字段、从ThreadLocal获取operatorId/bizId」优化,保留原有逻辑仅调整审计字段来源,优化后内容如下:
二、全局异常与日志挂钩
1. 通用响应类:Result(前后端统一格式)
大厂标准响应格式,包含错误码、提示信息、业务数据,兼容异常场景的traceId返回
java
import lombok.Data;
/**
* 通用响应类(前后端统一协议)
* 错误码规范:200=成功,4xx=业务错误,5xx=系统错误
*/
@Data
public class Result<T> {
private int code;
private String message;
private T data;
// 成功响应
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
// 失败响应(含自定义错误码)
public static <T> Result<T> fail(int code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}
2. 业务异常类:BusinessException
区分业务异常与系统异常,仅保留错误码/错误消息核心字段,审计字段(operatorId/bizId)从ThreadLocal获取,避免异常类职责污染
java
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 业务异常:用户可理解的错误(如"用户名已存在""余额不足")
* 核心调整:仅保留异常本身的错误码/消息,operatorId/bizId从ThreadLocal获取,不混入异常类
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class BusinessException extends RuntimeException {
private int code; // 业务错误码(如4001=密码错误,4002=余额不足)
// 仅保留异常核心构造方法,无审计字段
public BusinessException(String message, int code) {
super(message);
this.code = code;
}
// 重载构造:支持根因异常传递(便于排查),仍无审计字段
public BusinessException(String message, int code, Throwable cause) {
super(message, cause);
this.code = code;
}
}
3. 补充:ThreadLocalUtils增强
需在第一章的ThreadLocalUtils中补充bizId管理(此处贴完整最终版,直接替换第一章的ThreadLocalUtils)
java
import com.alibaba.ttl.TransmittableThreadLocal;
/**
* 线程本地存储工具类,管理traceId/operatorId/bizId生命周期
* 核心:
* 1. 支持子线程/异步任务的字段传递,避免分布式/异步场景下链路断裂
* 2. 审计字段(operatorId/bizId)统一管理,不侵入异常类
*/
public class ThreadLocalUtils {
// 替换原生ThreadLocal,解决子线程传递问题
private static final TransmittableThreadLocal<String> TRACE_ID_HOLDER = new TransmittableThreadLocal<>();
// 操作人ID存储(核心:从GW/JWT解析,未登录=anonymous)
private static final TransmittableThreadLocal<String> OPERATOR_ID_HOLDER = new TransmittableThreadLocal<>();
// 业务ID存储(如订单号/用户ID,无业务场景=empty)
private static final TransmittableThreadLocal<String> BIZ_ID_HOLDER = new TransmittableThreadLocal<>();
// 私有构造,禁止实例化(工具类规范)
private ThreadLocalUtils() {}
/**
* 设置traceId(增强:先判断是否存在,避免重复覆盖)
* @param traceId 唯一请求ID(分布式=Trace ID,单体=UUID)
*/
public static void setTraceId(String traceId) {
if (getTraceId().equals("UNKNOWN")) { // 仅当未设置时赋值
TRACE_ID_HOLDER.set(traceId);
}
}
/**
* 获取traceId,未设置返回"UNKNOWN"(避免空指针)
*/
public static String getTraceId() {
return TRACE_ID_HOLDER.get() == null ? "UNKNOWN" : TRACE_ID_HOLDER.get();
}
/**
* 设置操作人ID(核心:从GW/JWT解析后调用,未登录传anonymous)
*/
public static void setOperatorId(String operatorId) {
OPERATOR_ID_HOLDER.set(operatorId == null ? "anonymous" : operatorId);
}
/**
* 获取操作人ID,未设置返回"anonymous"(兼容未登录场景)
*/
public static String getOperatorId() {
return OPERATOR_ID_HOLDER.get() == null ? "anonymous" : OPERATOR_ID_HOLDER.get();
}
/**
* 设置业务ID(如订单号/用户ID,业务入口处调用)
*/
public static void setBizId(String bizId) {
BIZ_ID_HOLDER.set(bizId == null ? "" : bizId);
}
/**
* 获取业务ID,未设置返回空字符串(避免空指针)
*/
public static String getBizId() {
return BIZ_ID_HOLDER.get() == null ? "" : BIZ_ID_HOLDER.get();
}
/**
* 清理所有字段(必须在请求结束后调用,避免内存泄漏)
*/
public static void removeAll() {
TRACE_ID_HOLDER.remove();
OPERATOR_ID_HOLDER.remove();
BIZ_ID_HOLDER.remove();
}
// 兼容原有方法,避免改动其他代码
public static void removeTraceId() {
TRACE_ID_HOLDER.remove();
}
}
4. 全局异常处理器:GlobalExceptionHandler
拦截所有Controller异常,统一返回格式,从ThreadLocal获取operatorId/bizId补充日志,异常类仅承担"异常描述"职责
java
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
/**
* 全局异常处理器(核心:统一响应+关联traceId日志+从ThreadLocal获取审计字段)
*/
@Slf4j
@RestControllerAdvice // 拦截所有@RestController的异常
public class GlobalExceptionHandler {
/**
* 处理业务异常(预期内错误,日志级别WARN)
* 核心调整:operatorId/bizId从ThreadLocal获取,而非异常类
*/
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusinessException(BusinessException e) {
String traceId = getTraceId();
// 从ThreadLocal获取审计字段(核心:不依赖异常类)
String operatorId = ThreadLocalUtils.getOperatorId();
String bizId = ThreadLocalUtils.getBizId();
// 记录WARN级日志(保留审计字段,来源改为ThreadLocal)
log.warn("[BusinessError] traceId={}, code={}, operatorId={}, bizId={}, message={}",
traceId, e.getCode(), operatorId, bizId, e.getMessage());
// 返回含traceId的友好提示,便于用户反馈
return Result.fail(e.getCode(), e.getMessage() + "(traceId=" + traceId + ")");
}
/**
* 处理系统异常(预期外错误,日志级别ERROR)
* 增强:补充operatorId日志,便于定位异常操作人
*/
@ExceptionHandler(Exception.class)
public Result<?> handleSystemException(Exception e) {
String traceId = getTraceId();
String operatorId = ThreadLocalUtils.getOperatorId();
String bizId = ThreadLocalUtils.getBizId();
// 记录ERROR级日志(带完整堆栈+审计字段)
log.error("[SystemError] traceId={}, operatorId={}, bizId={}, message={}",
traceId, operatorId, bizId, e.getMessage(), e);
// 返回隐藏敏感信息的提示,避免暴露系统细节
return Result.fail(500, "系统繁忙,请稍后重试(traceId=" + traceId + ")");
}
/**
* 从Request/ThreadLocal获取traceId(兼容非Web场景)
*/
private String getTraceId() {
// 优先从Request获取(AOP已存入)
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
if (attributes != null) {
String traceId = (String) attributes.getAttribute("traceId", RequestAttributes.SCOPE_REQUEST);
if (traceId != null) {
return traceId;
}
}
// 非Web场景(如定时任务)从ThreadLocal获取
return ThreadLocalUtils.getTraceId();
}
}
5. 补充:业务ID(bizId)注入示例(业务入口处调用)
bizId在业务Controller/Service入口处设置,无需传入异常类,示例如下:
java
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
@PostMapping("/order/create")
public Result<?> createOrder(@RequestBody OrderCreateRequest request) {
// 1. 业务入口处设置bizId(如订单号,提前生成)
String orderId = generateOrderId();
ThreadLocalUtils.setBizId(orderId);
// 2. 业务逻辑:抛出异常时,异常类仅传错误码/消息,审计字段已在ThreadLocal
if (request.getAmount() <= 0) {
// 异常类仅描述错误,无审计字段
throw new BusinessException("订单金额不能小于等于0", 4002);
}
// 3. 正常业务处理
orderService.createOrder(request, orderId);
return Result.success(orderId);
}
// 生成订单号示例
private String generateOrderId() {
return "ORD" + System.currentTimeMillis() + (int)(Math.random() * 1000);
}
}
我完全对齐你的原文结构,核心按「审计字段从ThreadLocal获取、修正字段类型兼容anonymous、补充MDC字段传递」优化,保留原有逻辑仅调整审计字段的注入方式,优化后内容如下:
三、日志持久化方案(单体/分布式通用)
核心选型对比
| 持久化方式 | 适用场景 | 核心优点 | 核心缺点 | 推荐度 | 大厂使用占比 |
|---|---|---|---|---|---|
| 数据库持久化 | 单体/中小分布式(共享DB) | 配置简单、支持SQL查询、关联业务数据 | 高并发影响DB性能、日志量大时查询慢 | ★★★★☆ | 60%(中小服务、业务审计场景) |
| 文件持久化 | 单体/高并发单体 | 性能最高、不依赖外部服务 | 分布式日志分散、需聚合工具 | ★★★★☆ | 70%(高并发单体、离线分析) |
| ELK Stack | 大型分布式、海量日志 | 实时搜索、可视化分析、海量存储 | 部署复杂、需运维ES/Logstash | ★★★★★ | 90%(分布式核心系统) |
| 云服务日志(SLS) | 云原生项目、不想运维中间件 | 免运维、自动扩容、多维度查询 | 依赖云厂商、有使用成本 | ★★★☆☆ | 80%(云原生项目) |
核心说明:sys_log vs sys_biz_log(大厂双表设计逻辑)
| 表名 | 定位 | 核心字段差异 | 写入方式 | 大厂使用场景 |
|---|---|---|---|---|
| sys_log | 通用系统日志 | 基础字段+traceId+operator_id(补充) | logback配置化 | 全服务默认接入,排查系统问题 |
| sys_biz_log | 业务审计日志 | operator_id(兼容匿名)、biz_type、biz_id等 | 自定义Mapper | 核心业务(订单/支付/用户)审计 |
注:
- 两张表不会重复,互补覆盖通用日志+核心业务审计;
- operator_id字段类型改为VARCHAR(兼容未登录场景的"anonymous",避免BIGINT类型无法存储)。
1. 数据库持久化(通用型+审计型)
操作一:logback配置化(通用sys_log,0代码侵入)
① 建日志表(MySQL)
sql
CREATE TABLE `sys_log` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`trace_id` VARCHAR(64) NOT NULL COMMENT '请求ID(分布式=Trace ID)',
`log_level` VARCHAR(10) NOT NULL COMMENT '日志级别:INFO/WARN/ERROR',
`message` TEXT NOT NULL COMMENT '日志内容(脱敏后)',
`method` VARCHAR(255) DEFAULT NULL COMMENT '请求方法全路径',
`ip` VARCHAR(50) DEFAULT NULL COMMENT '客户端IP',
`cost_time` INT DEFAULT NULL COMMENT '请求耗时(ms)',
`operator_id` VARCHAR(64) DEFAULT 'anonymous' COMMENT '操作人ID(未登录=anonymous)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '日志生成时间',
PRIMARY KEY (`id`),
INDEX `idx_trace_id` (`trace_id`) COMMENT '按traceId快速查询(核心索引)',
INDEX `idx_create_time` (`create_time`) COMMENT '按时间范围查询',
INDEX `idx_method` (`method`) COMMENT '按接口维度统计',
INDEX `idx_operator_id` (`operator_id`) COMMENT '按操作人维度审计'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统日志表(大厂规范)';
-- 生产环境补充:按日期分表(避免单表数据量过大)
-- CREATE TABLE `sys_log_202512` LIKE `sys_log`; -- 每月分表
② logback-spring.xml 完整配置
核心:异步写入、分环境配置、复用项目DB配置,补充operator_id从MDC注入
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<!-- 1. 引入Spring环境变量(复用项目DB配置) -->
<springProperty scope="context" name="dbUrl" source="spring.datasource.url"/>
<springProperty scope="context" name="dbUser" source="spring.datasource.username"/>
<springProperty scope="context" name="dbPassword" source="spring.datasource.password"/>
<springProperty scope="context" name="dbDriver" source="spring.datasource.driver-class-name"/>
<springProperty scope="context" name="appName" source="spring.application.name"/>
<!-- 2. 控制台输出(分环境:开发带颜色,生产极简,补充operatorId) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%green(%d{yyyy-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %cyan(%logger{36}.%M) - %magenta(traceId=%X{traceId}, operatorId=%X{operatorId}) - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 3. 数据库同步Appender(核心:补充operator_id字段映射) -->
<appender name="DB" class="ch.qos.logback.classic.db.DBAppender">
<connectionSource class="ch.qos.logback.core.db.DriverManagerConnectionSource">
<driverClass>${dbDriver}</driverClass>
<url>${dbUrl}</url>
<user>${dbUser}</user>
<password>${dbPassword}</password>
<!-- 连接池优化(减少DB连接开销,生产必配) -->
<maxConnections>15</maxConnections>
<minConnections>3</minConnections>
<connectionInitSql>SET NAMES utf8mb4</connectionInitSql>
</connectionSource>
<!-- 自定义SQL:补充operator_id字段(从MDC获取,默认anonymous) -->
<sql>
INSERT INTO sys_log (
trace_id, log_level, message, method, ip, cost_time, operator_id, create_time
) VALUES (
%X{traceId}, '%level', '%message', %X{method}, %X{ip}, %replace(%X{costTime}){'null','0'}, %replace(%X{operatorId}){'null','anonymous'}, NOW()
)
</sql>
<!-- 过滤:仅存储WARN及以上(减少DB压力,INFO仅控制台输出) -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>WARN</level>
</filter>
</appender>
<!-- 4. 异步DB Appender(关键:避免阻塞业务线程,生产必配) -->
<appender name="ASYNC_DB" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="DB"/>
<queueSize>2048</queueSize> <!-- 高并发设4096 -->
<discardingThreshold>0</discardingThreshold> <!-- 不丢弃任何日志 -->
<includeCallerData>true</includeCallerData> <!-- 保留调用栈信息 -->
<threadName>logback-async-db-${appName}</threadName> <!-- 自定义线程名,便于监控 -->
<!-- 溢出策略:队列满时由调用线程执行(避免日志丢失) -->
<neverBlock>false</neverBlock>
</appender>
<!-- 5. 分环境配置(开发/生产隔离) -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_DB"/>
</root>
</springProfile>
<springProfile name="prod">
<!-- 生产控制台仅输出ERROR,补充operatorId -->
<appender name="CONSOLE_PROD" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36}.%M - traceId=%X{traceId}, operatorId=%X{operatorId} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE_PROD"/>
<appender-ref ref="ASYNC_DB"/>
</root>
</springProfile>
</configuration>
操作二:自定义Mapper
核心调整:operator_id改为VARCHAR类型(兼容anonymous),从ThreadLocalUtils获取审计字段,不依赖异常类
① 建扩展日志表(修正operator_id类型+增强索引)
sql
CREATE TABLE `sys_biz_log` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`trace_id` VARCHAR(64) NOT NULL COMMENT '请求ID(关联sys_log)',
`log_level` VARCHAR(10) NOT NULL COMMENT '日志级别',
`message` TEXT NOT NULL COMMENT '日志内容',
`method` VARCHAR(255) DEFAULT NULL COMMENT '请求方法',
`ip` VARCHAR(50) DEFAULT NULL COMMENT '客户端IP',
`cost_time` INT DEFAULT NULL COMMENT '耗时(ms)',
`operator_id` VARCHAR(64) DEFAULT 'anonymous' COMMENT '操作人ID(未登录=anonymous)',
`biz_type` VARCHAR(50) COMMENT '业务类型:USER_LOGIN/ORDER_PAY/REFUND',
`biz_id` VARCHAR(64) COMMENT '业务ID:订单号/用户ID',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-- 核心索引:支持按traceId/业务类型/操作人快速查询
INDEX `idx_trace_id` (`trace_id`),
INDEX `idx_biz_type_biz_id` (`biz_type`, `biz_id`),
INDEX `idx_operator_id` (`operator_id`),
INDEX `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务审计日志表(大厂核心业务必配)';
② Mapper接口与实体类
java
// BizLogDO.java(修正operatorId类型为String,兼容anonymous)
import lombok.Data;
/**
* 业务审计日志DO(与sys_biz_log表一一对应)
*/
@Data
public class BizLogDO {
private Long id;
private String traceId; // 请求ID(关联sys_log)
private String logLevel; // 日志级别
private String message; // 日志内容(脱敏后)
private String method; // 请求方法
private String ip; // 客户端IP
private Integer costTime; // 耗时(ms)
private String operatorId; // 操作人ID(VARCHAR,兼容anonymous)
private String bizType; // 业务类型
private String bizId; // 业务ID
private String createTime; // 创建时间(数据库自动生成)
}
// BizLogMapper.java(补充注解,大厂规范)
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
/**
* 业务审计日志Mapper(仅插入,查询单独封装)
*/
@Mapper
public interface BizLogMapper {
@Insert("INSERT INTO sys_biz_log (trace_id, log_level, message, method, ip, cost_time, operator_id, biz_type, biz_id) " +
"VALUES (#{traceId}, #{logLevel}, #{message}, #{method}, #{ip}, #{costTime}, #{operatorId}, #{bizType}, #{bizId})")
void insertBizLog(BizLogDO bizLogDO);
}
③ 异步线程池配置
大厂规范:自定义线程池,避免使用默认线程池导致资源耗尽
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 日志异步线程池配置(与业务线程池隔离,避免互相影响)
*/
@Configuration
public class AsyncLogConfig {
@Bean("asyncLogExecutor")
public ThreadPoolTaskExecutor asyncLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 核心线程数(按QPS调整)
executor.setMaxPoolSize(10); // 最大线程数
executor.setQueueCapacity(1000); // 队列容量(高并发设2048)
executor.setKeepAliveSeconds(60); // 空闲线程存活时间
executor.setThreadNamePrefix("async-log-"); // 线程名前缀,便于监控/排查
// 拒绝策略:队列满时由调用线程执行(避免日志丢失,生产必配)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 预初始化线程池,避免首次调用耗时
executor.initialize();
return executor;
}
}
④ 在AOP中注入并调用
补充到RequestLogAspect中,审计字段从ThreadLocalUtils获取,日志写入失败不影响业务
java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
// 在RequestLogAspect中添加注入
@Autowired
private BizLogMapper bizLogMapper;
@Autowired
@Qualifier("asyncLogExecutor")
private ThreadPoolTaskExecutor asyncLogExecutor;
// 新增保存业务日志方法(核心:operatorId/bizId从ThreadLocal获取)
private void saveBizLog(String traceId, String logLevel, String message, String method, String ip,
Integer costTime, String bizType) {
// 从ThreadLocal获取审计字段(核心:不依赖异常类/业务参数)
String operatorId = ThreadLocalUtils.getOperatorId();
String bizId = ThreadLocalUtils.getBizId();
BizLogDO logDO = new BizLogDO();
logDO.setTraceId(traceId);
logDO.setLogLevel(logLevel);
logDO.setMessage(message);
logDO.setMethod(method);
logDO.setIp(ip);
logDO.setCostTime(costTime);
logDO.setOperatorId(operatorId); // 注入操作人ID(兼容anonymous)
logDO.setBizType(bizType);
logDO.setBizId(bizId); // 注入业务ID(从ThreadLocal获取)
// 异步执行,避免阻塞业务
asyncLogExecutor.execute(() -> {
try {
bizLogMapper.insertBizLog(logDO);
} catch (Exception e) {
// 日志写入失败时记录,便于排查(不抛异常,避免影响业务)
log.error("[BizLogError] 保存业务审计日志失败,traceId={}, bizType={}, bizId={}, operatorId={}",
traceId, bizType, bizId, operatorId, e);
}
});
}
// 示例:在请求结束时调用(根据业务场景动态传bizType)
// 在RequestEnd日志后添加:
if (methodFullName.contains("order")) {
// 订单相关接口,bizType=ORDER_XXX
saveBizLog(ThreadLocalUtils.getTraceId(), "INFO", "订单请求完成",
methodFullName, request.getRemoteAddr(),
stopWatch.getTotalTimeMillis(), "ORDER_OPERATE");
} else if (methodFullName.contains("user")) {
// 用户相关接口,bizType=USER_XXX
saveBizLog(ThreadLocalUtils.getTraceId(), "INFO", "用户请求完成",
methodFullName, request.getRemoteAddr(),
stopWatch.getTotalTimeMillis(), "USER_OPERATE");
}
2. 文件持久化
核心:按日期分割、异步写入、限制总大小,补充operatorId/bizId到日志格式
xml
<!-- 新增到logback-spring.xml中 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 日志存储路径(大厂规范:/data/logs/应用名,避免根目录) -->
<file>/data/logs/${appName}/${appName}.log</file>
<!-- 滚动策略:按日期分割,保留30天,总大小不超过10GB -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/data/logs/${appName}/${appName}.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <!-- 保留30天日志 -->
<totalSizeCap>10GB</totalSizeCap> <!-- 总大小限制,避免磁盘占满 -->
<cleanHistoryOnStart>true</cleanHistoryOnStart> <!-- 启动时清理过期日志 -->
</rollingPolicy>
<!-- 日志格式(补充operatorId/bizId,便于后续聚合) -->
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36}.%M - traceId=%X{traceId}, operatorId=%X{operatorId}, bizId=%X{bizId} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 过滤:仅存储INFO及以上 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
</appender>
<!-- 异步文件Appender -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE"/>
<queueSize>4096</queueSize> <!-- 更大的缓冲区,适配高并发 -->
<discardingThreshold>0</discardingThreshold>
<neverBlock>false</neverBlock> <!-- 避免日志丢失 -->
</appender>
<!-- 启用文件持久化(替换ASYNC_DB) -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/>
</root>
</springProfile>
<!-- 生产环境补充:日志备份到对象存储(大厂必配) -->
<!-- 可通过定时任务(如XXL-Job)将每日日志上传到OSS/S3,保留6个月 -->
3. ELK Stack持久化(分布式首选,企业级)
① Docker快速部署ELK
yaml
# docker-compose.yml(补充内存/磁盘限制,避免服务器宕机)
version: '3.8'
services:
elasticsearch:
image: elasticsearch:7.17.0
container_name: es7
environment:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms1g -Xmx1g # 根据服务器配置调整(至少1G)
- xpack.security.enabled=false # 开发环境关闭安全认证
ports:
- "9200:9200"
- "9300:9300"
volumes:
- ./es-data:/usr/share/elasticsearch/data
deploy:
resources:
limits:
memory: 2G # 限制内存使用
restart: always
logstash:
image: logstash:7.17.0
container_name: logstash7
volumes:
- ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf
ports:
- "5044:5044"
depends_on:
- elasticsearch
restart: always
kibana:
image: kibana:7.17.0
container_name: kibana7
ports:
- "5601:5601"
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
depends_on:
- elasticsearch
restart: always
② Logstash配置
ruby
input {
tcp {
port => 5044
codec => json_lines # 接收JSON格式日志
}
}
filter {
# 解析MDC中的字段,补充operatorId/bizId,填充默认值
json {
source => "message"
add_field => {
"traceId" => "%{[traceId]}"
"operatorId" => "%{[operatorId]}"
"bizId" => "%{[bizId]}"
"ip" => "%{[ip]}"
"method" => "%{[method]}"
"appName" => "%{[appName]}"
"costTime" => "%{[costTime]}"
}
# 字段不存在时填充默认值
fallback_to_json => true
skip_on_invalid_json => true
}
# 过滤空值,清理无用字段,补充operatorId默认值
mutate {
remove_field => ["@version", "host"]
convert => { "costTime" => "integer" } # 转为数字,便于统计
replace => { "operatorId" => "%{[operatorId]:anonymous}" } # operatorId默认anonymous
}
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "${appName}-log-%{+YYYY.MM.dd}" # 按应用+日期建索引
}
# 控制台输出(调试用,生产可注释)
stdout {
codec => rubydebug
}
}
③ 项目配置Logstash输出
xml
<!-- 引入Logstash依赖(与logback版本兼容) -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.0.1</version> <!-- 适配logback 1.2.x -->
</dependency>
<!-- logback-spring.xml中新增Logstash Appender -->
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>192.168.1.100:5044</destination> <!-- Logstash服务器地址 -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"appName":"${appName}"}</customFields> <!-- 应用名标识 -->
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>operatorId</includeMdcKeyName> <!-- 补充操作人ID -->
<includeMdcKeyName>bizId</includeMdcKeyName> <!-- 补充业务ID -->
<includeMdcKeyName>ip</includeMdcKeyName>
<includeMdcKeyName>method</includeMdcKeyName>
<includeMdcKeyName>costTime</includeMdcKeyName>
<!-- 超时配置,避免连接阻塞 -->
<writeTimeout>3000</writeTimeout>
</encoder>
<!-- 重连策略,保证链路稳定 -->
<reconnectionPolicy class="net.logstash.logback.appender.ReconnectionPolicy">
<maxRetries>10</maxRetries>
<delay>1000</delay>
</reconnectionPolicy>
</appender>
<!-- 异步Logstash Appender -->
<appender name="ASYNC_LOGSTASH" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="LOGSTASH"/>
<queueSize>4096</queueSize>
<neverBlock>false</neverBlock>
</appender>
<!-- 启用ELK输出 -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="CONSOLE_PROD"/>
<appender-ref ref="ASYNC_LOGSTASH"/>
</root>
</springProfile>
4. 云服务日志持久化(阿里云SLS为例)
核心:免运维、自动扩容,补充operatorId/bizId的MDC字段,避免日志丢失
① 引入依赖
xml
<dependency>
<groupId>com.aliyun.openservices</groupId>
<artifactId>aliyun-log-logback-appender</artifactId>
<version>0.1.25</version>
</dependency>
② logback配置
xml
<appender name="ALIYUN_SLS" class="com.aliyun.openservices.log.logback.LoghubAppender">
<!-- SLS配置(从阿里云控制台获取) -->
<endpoint>cn-hangzhou.log.aliyuncs.com</endpoint>
<project>your-sls-project</project>
<logStore>your-logstore</logStore>
<accessKeyId>your-access-key</accessKeyId>
<accessKeySecret>your-secret-key</accessKeySecret>
<!-- 自定义字段 -->
<topic>${appName}</topic>
<mdcFields>traceId,operatorId,bizId,ip,method,costTime</mdcFields> <!-- 补充审计字段 -->
<!-- 批量异步上传(优化性能) -->
<ioThreadCount>2</ioThreadCount>
<batchSizeThresholdInBytes>1048576</batchSizeThresholdInBytes> <!-- 1MB批量 -->
<batchCountThreshold>1000</batchCountThreshold>
<lingerMillis>1000</lingerMillis> <!-- 1秒批量上传 -->
<!-- 容错配置(大厂必配) -->
<maxRetries>3</maxRetries> <!-- 失败重试次数 -->
<retryIntervalMillis>1000</retryIntervalMillis> <!-- 重试间隔 -->
<timeoutMillis>5000</timeoutMillis> <!-- 超时时间 -->
</appender>
<!-- 启用SLS输出 -->
<springProfile name="prod-cloud">
<root level="INFO">
<appender-ref ref="ALIYUN_SLS"/>
</root>
</springProfile>