Java项目基础架构(三)| 日志统一处理

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 核心业务(订单/支付/用户)审计

注:

  1. 两张表不会重复,互补覆盖通用日志+核心业务审计;
  2. 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>
相关推荐
再__努力1点36 分钟前
【50】OpenCV背景减法技术解析与实现
开发语言·图像处理·人工智能·python·opencv·算法·计算机视觉
qq_5895681037 分钟前
Maven学习记录
java·开发语言
大飞哥~BigFei37 分钟前
rabbitmq-spring-boot-start2.0.0重磅重构升级
java·重构·rabbitmq
2501_9419820538 分钟前
高可靠API架构的三大核心支柱
java·大数据·spring
努力也学不会java39 分钟前
【docker】Docker Image(镜像)
java·运维·人工智能·机器学习·docker·容器
豐儀麟阁贵42 分钟前
9.2连接字符串
java·开发语言·算法
浩瀚地学42 分钟前
【Java】方法
java·开发语言·经验分享·笔记
E_ICEBLUE42 分钟前
使用 Java 将 PowerPoint 转换为 PDF 的完整指南
java·开发语言·pdf·powerpoint·ppt
网安老伯43 分钟前
计算机网络:网络安全(网络安全概述)
开发语言·数据库·python·计算机网络·web安全·网络安全·php