Spring Boot 怎么打印日志
在Spring Boot 中,RequestFilter
、@Aspect
等多种机制都可以用来打印日志。那么在实际项目中应该选择哪种,或者如何组合使用呢?
我们碰到的问题
我们项目中用到了平台组提供的二方包来实现日志功能,该二方包分别使用了 @Aspect
、@RequestBodyAdvice
/@ResponseBodyAdvice
实现记录日志及监控信息报送的能力,但在实践中我们发现了一些问题:
@Aspect
方案 :- 拦截 Controller 方法(项目中有使用
@ExceptionHandler
做全局异常处理)。 - 问题:
- 日志时机问题:
@Aspect
通常在@ExceptionHandler
之前执行,导致记录的响应是原始方法的(如果正常返回)或原始异常(如果抛出),而不是经过异常处理器包装后的最终响应。 @Valid
校验失败:如果请求参数校验失败,Controller 方法体不会执行,@Aspect
无法拦截到方法执行,导致日志丢失。- 信息不全:难以直接获取真实客户端 IP (尤其在代理后),也无法方便记录整个请求的完整耗时。
- 拦截 Controller 方法(项目中有使用
RequestBodyAdvice
/ResponseBodyAdvice
方案 :- 使用这两个 Advice 记录请求和响应体。
- 问题 :
- 条件限制:他们代码中的
supports()
方法判断MethodParameter
参数必须包含某个自定义注解。当发生异常并由@ExceptionHandler
处理时,MethodParameter
指向的是异常处理方法,该方法没有所需注解,导致异常响应日志丢失。
- 条件限制:他们代码中的
通用且推荐的日志打印方法:Filter + Aspect + ExceptionHandler 协同
为了克服单一方案的局限性,获得包含请求、处理、响应(包括异常情况)的完整日志链,推荐结合使用以下三种机制:
Filter
(如OncePerRequestFilter
) : 作为请求处理的入口和出口 ,负责记录请求/响应的全局概览信息 ,如完整耗时、真实 IP、请求/响应头、请求/响应体(安全处理后)、设置 MDC 请求 ID 等。@Aspect
: 深入业务处理核心 ,记录 Controller/Service 方法级别的上下文信息,如类名、方法名、参数、返回值、原始异常等。@ExceptionHandler
(在@RestControllerAdvice
中) : 负责统一处理异常 ,构建最终返回给客户端的错误响应。
日志打印核心实现
以下是 Filter
和 Aspect
的具体代码实现,它们构成了日志记录的核心部分。
1. Request Logging Filter (RequestLoggingFilter.java
)
此 Filter 负责记录请求的入口、出口、基本信息、耗时,并处理请求/响应体和敏感信息过滤。
点击查看 RequestLoggingFilter.java 代码
java
package com.example.logging;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.*;
import java.util.stream.Collectors;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 确保此 Filter 最先执行
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
private static final String REQUEST_ID_HEADER = "X-Request-ID";
private static final String MDC_REQUEST_ID_KEY = "requestId";
private static final int MAX_PAYLOAD_LENGTH = 1024; // 日志中记录 Body 的最大长度 (字节)
private static final Collection<String> EXCLUDED_PATHS = Collections.singletonList("/actuator"); // 不记录日志的路径
// 常见的二进制内容类型,避免记录 Body
private static final List<MediaType> BINARY_CONTENT_TYPES = Arrays.asList(
MediaType.IMAGE_GIF,
MediaType.IMAGE_JPEG,
MediaType.IMAGE_PNG,
MediaType.APPLICATION_OCTET_STREAM,
MediaType.APPLICATION_PDF
// 可根据需要添加更多类型
);
// 需要过滤掉的敏感 Header 名称(小写)
private static final Set<String> SENSITIVE_HEADERS = Set.of(
"authorization",
"cookie",
"proxy-authorization",
"x-csrf-token" // 示例,根据实际情况添加
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (shouldExclude(request)) {
filterChain.doFilter(request, response);
return;
}
// 使用 ContentCachingWrapper 包装请求和响应,以便可以重复读取 Body
// 注意:这会增加内存消耗,因为 Body 内容被缓存在内存中
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
long startTime = System.currentTimeMillis();
String requestId = determineRequestId(request);
MDC.put(MDC_REQUEST_ID_KEY, requestId); // 放入 MDC,供后续日志使用
// 在请求处理前记录请求信息
logRequest(requestWrapper, requestId);
try {
filterChain.doFilter(requestWrapper, responseWrapper); // 继续过滤器链和请求处理
} finally {
// 在请求处理后记录响应信息
logResponse(responseWrapper, startTime, requestId);
// !! 非常重要:必须调用 copyBodyToResponse() 才能将缓存的响应体写回实际的输出流
responseWrapper.copyBodyToResponse();
MDC.remove(MDC_REQUEST_ID_KEY); // 清理 MDC
}
}
private void logRequest(ContentCachingRequestWrapper request, String requestId) {
String method = request.getMethod();
String uri = request.getRequestURI();
String queryString = request.getQueryString();
if (queryString != null) {
uri += "?" + queryString;
}
String clientIp = getClientIpAddr(request); // 获取真实客户端 IP
String headers = getFilteredHeaders(request.getHeaderNames(), request::getHeaders); // 获取过滤后的 Header
String requestBody = getBody(request.getContentAsByteArray(), request.getCharacterEncoding(), request.getContentType()); // 获取处理后的 Body
log.info("REQUEST START ===> Request-ID: {}, Method: {}, URI: {}, ClientIP: {}, Headers: [{}], Body: {}",
requestId, method, uri, clientIp, headers, requestBody);
}
private void logResponse(ContentCachingResponseWrapper response, long startTime, String requestId) {
long duration = System.currentTimeMillis() - startTime;
int status = response.getStatus();
String headers = getFilteredHeaders(response.getHeaderNames(), response::getHeaders); // 获取过滤后的 Header
String responseBody = getBody(response.getContentAsByteArray(), response.getCharacterEncoding(), response.getContentType()); // 获取处理后的 Body
log.info("REQUEST END <=== Request-ID: {}, Status: {}, Duration: {}ms, Headers: [{}], Body: {}",
requestId, status, duration, headers, responseBody);
}
// 获取真实客户端 IP (考虑代理)
private String getClientIpAddr(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 处理 X-Forwarded-For 可能包含多个 IP 的情况
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
// 获取过滤后的 Header 字符串
private String getFilteredHeaders(Collection<String> headerNames, HeaderValueProvider valueProvider) {
return headerNames.stream()
.filter(headerName -> !SENSITIVE_HEADERS.contains(headerName.toLowerCase())) // 过滤敏感 Header
.map(headerName -> headerName + ": " + Collections.list(valueProvider.getHeaders(headerName)))
.collect(Collectors.joining(", "));
}
// 函数式接口用于传递 request::getHeaders 或 response::getHeaders
@FunctionalInterface
interface HeaderValueProvider {
Enumeration<String> getHeaders(String name);
}
// 获取请求/响应体字符串(带截断和二进制处理)
private String getBody(byte[] content, String characterEncoding, String contentType) {
if (content.length > 0) {
if (isBinaryContent(contentType)) {
return "[BINARY BODY Size: " + content.length + " bytes]";
}
try {
// 限制记录的 Body 长度
int length = Math.min(content.length, MAX_PAYLOAD_LENGTH);
String body = new String(content, 0, length, characterEncoding);
if (content.length > MAX_PAYLOAD_LENGTH) {
body += "...(truncated)";
}
// 替换换行符,使日志更紧凑
return body.replace("\n", "\\n").replace("\r", "\\r");
} catch (UnsupportedEncodingException e) {
log.warn("Could not read body with encoding {}: {}", characterEncoding, e.getMessage());
return "[INVALID ENCODING]";
} catch (Exception e) {
log.error("Error reading body", e);
return "[ERROR READING BODY]";
}
}
return "[EMPTY BODY]";
}
// 判断是否为二进制内容类型
private boolean isBinaryContent(String contentTypeHeader) {
if (contentTypeHeader == null) {
return false;
}
try {
MediaType mediaType = MediaType.parseMediaType(contentTypeHeader);
return BINARY_CONTENT_TYPES.stream().anyMatch(binaryType -> binaryType.includes(mediaType));
} catch (Exception e) {
// Ignore parsing errors
return false;
}
}
// 判断是否应排除此请求的日志记录
private boolean shouldExclude(HttpServletRequest request) {
String path = request.getRequestURI();
return EXCLUDED_PATHS.stream().anyMatch(path::startsWith);
}
// 决定请求 ID (优先从 Header 获取)
private String determineRequestId(HttpServletRequest request) {
String requestId = request.getHeader(REQUEST_ID_HEADER);
if (requestId == null || requestId.isEmpty()) {
requestId = UUID.randomUUID().toString();
}
return requestId;
}
}
2. Logging Aspect (LoggingAspect.java
)
此 Aspect 拦截 Controller 方法,记录方法调用细节、参数和返回值或原始异常。
点击查看 LoggingAspect.java 代码
java
package com.example.logging;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Aspect
@Component
public class LoggingAspect {
private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);
private final ObjectMapper objectMapper; // 注入 ObjectMapper 以便序列化
public LoggingAspect(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
// 定义切点:拦截所有标注了 @RestController 的类中的所有公共方法
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *) && execution(public * *(..))")
public void controllerMethods() {}
// 方法执行前记录:类名、方法名、参数(序列化为 JSON)
@Before("controllerMethods()")
public void logBeforeMethod(JoinPoint joinPoint) {
String className = joinPoint.getSignature().getDeclaringTypeName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
// 注意:序列化可能影响性能,且需确保 DTO 不含敏感信息(或已用 @JsonIgnore 等处理)
String argsString = Arrays.stream(args)
.map(this::toJsonSafely)
.reduce((s1, s2) -> s1 + ", " + s2)
.orElse("");
log.info("CONTROLLER ==> Class: {}, Method: {}, Args: [{}]", className, methodName, argsString);
}
// 方法成功返回后记录:类名、方法名、返回值(序列化为 JSON)
@AfterReturning(pointcut = "controllerMethods()", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
String className = joinPoint.getSignature().getDeclaringTypeName();
String methodName = joinPoint.getSignature().getName();
// 注意:序列化可能影响性能,且需确保 DTO 不含敏感信息
String resultString = toJsonSafely(result);
log.info("CONTROLLER <== Class: {}, Method: {}, Result: {}", className, methodName, resultString);
}
// 方法抛出异常后记录(在 @ExceptionHandler 处理前):类名、方法名、异常类型、异常消息
@AfterThrowing(pointcut = "controllerMethods()", throwing = "exception")
public void logAfterThrowing(JoinPoint joinPoint, Throwable exception) {
String className = joinPoint.getSignature().getDeclaringTypeName();
String methodName = joinPoint.getSignature().getName();
log.error("CONTROLLER EXCEPTION <== Class: {}, Method: {}, Exception: {}, Message: {}",
className, methodName, exception.getClass().getName(), exception.getMessage());
// 注意:此处仅记录异常消息。完整的堆栈跟踪应由 ExceptionHandler 或更上层记录。
// 如果需要在此处记录堆栈(不推荐,可能导致日志冗余),请取消注释下行:
// log.error("Stack trace:", exception);
}
// 安全地将对象转为 JSON 字符串
private String toJsonSafely(Object obj) {
if (obj == null) {
return "null";
}
// 避免尝试序列化 Servlet API 对象
if (obj instanceof HttpServletRequest || obj instanceof HttpServletResponse) {
return obj.getClass().getSimpleName();
}
try {
// 考虑对序列化结果的大小进行限制,如果可能出现超大对象
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize object to JSON for logging: {}", e.getMessage());
return "[Serialization Error]";
} catch (Exception e) {
// 处理其他可能的序列化问题 (例如懒加载异常)
log.error("Unexpected error during JSON serialization for logging", e);
return "[Unexpected Serialization Error: " + e.getClass().getSimpleName() + "]";
}
}
}
全局异常处理与日志记录
主要职责是处理未捕获的异常并向客户端返回统一格式的错误响应。
在这里,我们可以捕获特定或通用的异常,构建包含错误码、请求 ID 等信息的 ErrorResponse
DTO,并在返回给客户端之前 ,将这个最终的错误响应信息连同完整的异常堆栈记录到服务器日志中。
1. Global Exception Handler (GlobalExceptionHandler.java
)
点击查看 GlobalExceptionHandler.java 代码
java
package com.example.exception;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.dao.DataAccessException; // 示例:引入特定异常类型
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String MDC_REQUEST_ID_KEY = "requestId"; // 确保与 Filter 中使用的 Key 一致
// 处理 @Valid 校验失败异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) {
String validationErrors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> "'" + error.getField() + "': " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
String businessMessage = "Input validation failed.";
// 技术细节:提供清晰的字段错误信息是安全的
String technicalDetails = "Validation errors: [" + validationErrors + "]";
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
ErrorCode.VALIDATION_ERROR.getCode(),
businessMessage,
technicalDetails,
request.getRequestURI(),
MDC.get(MDC_REQUEST_ID_KEY) // 从 MDC 获取请求 ID
);
// 服务器日志记录详细信息,包括原始异常堆栈
log.warn("EXCEPTION HANDLED (Validation): Path: {}, Response: {}",
request.getRequestURI(), errorResponse, ex); // 记录详细异常 ex
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
// 处理特定业务或技术异常(示例:数据库访问异常)
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDatabaseExceptions(DataAccessException ex, HttpServletRequest request) {
String businessMessage = "A database error occurred.";
// 技术细节:优先使用异常类型名称。谨慎使用 ex.getMessage()。
// !! 警告: ex.getMessage() 或 ex.getMostSpecificCause().getMessage() 可能泄露 SQL 语句或表结构等敏感信息 !!
String technicalDetails = "Exception: " + ex.getClass().getSimpleName(); // 较安全
// String technicalDetails = "Exception: " + ex.getClass().getName() + ": " + ex.getMostSpecificCause().getMessage(); // 风险较高
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(), // 或根据情况返回 503 Service Unavailable 等
ErrorCode.DATABASE_ERROR.getCode(),
businessMessage,
technicalDetails,
request.getRequestURI(),
MDC.get(MDC_REQUEST_ID_KEY)
);
// 服务器日志记录详细信息
log.error("EXCEPTION HANDLED (Database): Path: {}, Response: {}",
request.getRequestURI(), errorResponse, ex); // 记录详细异常 ex
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
// 处理所有其他未捕获的异常 (通用 Catch-all)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex, HttpServletRequest request) {
String businessMessage = "An unexpected internal error occurred.";
// 技术细节:优先使用异常类型名称。谨慎使用 ex.getMessage()。
// !! 警告: ex.getMessage() 可能泄露内部实现细节 !!
String technicalDetails = "Exception: " + ex.getClass().getSimpleName(); // 较安全
// String technicalDetails = "Exception: " + ex.getClass().getName() + ": " + ex.getMessage(); // 风险较高
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
businessMessage,
technicalDetails,
request.getRequestURI(),
MDC.get(MDC_REQUEST_ID_KEY)
);
// 服务器日志记录详细信息
log.error("EXCEPTION HANDLED (Generic): Path: {}, Response: {}",
request.getRequestURI(), errorResponse, ex); // 记录详细异常 ex
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
2. Error Response DTO (ErrorResponse.java
)
定义统一的错误响应结构体。
点击查看 ErrorResponse.java 代码
java
package com.example.exception;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
// 在序列化为 JSON 时,只包含非 null 的字段
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
private final Instant timestamp; // 错误发生时间戳
private final int status; // HTTP 状态码
private final String errorCode; // 自定义的应用错误码
private final String message; // 面向客户端开发者的业务/功能性错误描述
private final String technicalDetails; // (可选) 提供给客户端的技术性线索(谨慎使用!)
private final String path; // 发生错误的请求路径
private final String requestId; // 用于追踪和关联日志的请求 ID
public ErrorResponse(int status, String errorCode, String message, String technicalDetails, String path, String requestId) {
this.timestamp = Instant.now();
this.status = status;
this.errorCode = errorCode;
this.message = message;
this.technicalDetails = technicalDetails; // 确保传入的内容是安全的
this.path = path;
this.requestId = requestId;
}
// --- Getters ---
public Instant getTimestamp() { return timestamp; }
public int getStatus() { return status; }
public String getErrorCode() { return errorCode; }
public String getMessage() { return message; }
public String getTechnicalDetails() { return technicalDetails; }
public String getPath() { return path; }
public String getRequestId() { return requestId; }
// 重写 toString() 以便在 Handler 的日志中打印结构化信息 (可选, 但推荐)
@Override
public String toString() {
return "ErrorResponse{" +
"timestamp=" + timestamp +
", status=" + status +
", errorCode='" + errorCode + '\'' +
", message='" + message + '\'' +
", technicalDetails='" + technicalDetails + '\'' +
", path='" + path + '\'' +
", requestId='" + requestId + '\'' +
'}';
}
}
3. Error Code Enum (ErrorCode.java
)
统一定义应用错误码。
点击查看 ErrorCode.java 代码
java
package com.example.exception;
// 定义应用级别的错误码
public enum ErrorCode {
VALIDATION_ERROR("VAL-001", "Input validation failed"),
RESOURCE_NOT_FOUND("RES-001", "Requested resource not found"),
AUTHENTICATION_FAILURE("AUTH-001", "Authentication failed"),
UNAUTHORIZED("AUTH-003", "Unauthorized access"),
INTERNAL_SERVER_ERROR("SYS-500", "Internal server error"),
DATABASE_ERROR("DB-001", "Database operation failed"),
SERVICE_UNAVAILABLE("SYS-503", "Service temporarily unavailable");
// 可根据业务需要添加更多错误码
private final String code;
private final String defaultMessage; // 可以有一个默认消息
ErrorCode(String code, String defaultMessage) {
this.code = code;
this.defaultMessage = defaultMessage;
}
public String getCode() {
return code;
}
public String getDefaultMessage() {
return defaultMessage;
}
}
配置说明
-
启用 AspectJ : 确保你的项目包含
spring-boot-starter-aop
依赖,并且 AspectJ 自动代理已启用(通常是默认的)。如果需要显式启用,可以在配置类上添加@EnableAspectJAutoProxy
。 -
配置日志格式 (例如
logback-spring.xml
) : 为了让 MDC 中的requestId
显示在日志中,需要修改日志格式配置。在pattern
中加入%X{requestId}
(或其他你定义的 MDC key)。点击查看 logback-spring.xml 配置示例
xml<configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{requestId}] - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="STDOUT" /> </root> <logger name="com.example" level="DEBUG" additivity="false"> <appender-ref ref="STDOUT" /> </logger> </configuration>
经验分享
- 慢交易排查 : 如上配置后,所有请求的开始、结束以及耗时都会被记录,并且包含唯一的
Request-ID
。你可以使用 Shell 工具(如grep
和awk
)来分析日志文件。例如:- 查找特定请求的所有日志:
grep 'Request-ID: <your-request-id>' application.log
- 查找耗时超过阈值(如 500ms)的请求:
grep 'REQUEST END <===' application.log | awk -F'[ ,:]+' '$15 > 500 {print $0}'
(注意:这里的字段索引$15
取决于你的确切日志格式,需要根据实际情况调整,它代表Duration: XXXms
中的XXX
)
- 查找特定请求的所有日志:
- 高并发性能与异步日志 :
- 在高并发场景下,同步的日志记录(尤其是写磁盘)可能会成为性能瓶颈,阻塞业务线程。如果压测发现开关日志对性能影响显著,应考虑使用异步日志。
AsyncAppender
(Logback/Log4j2 都提供): 它将日志事件放入内存队列,由单独的后台线程负责实际的 I/O 操作(写入文件或发送到远程)。- 注意 :
AsyncAppender
不能消除所有日志开销(如日志对象的创建、序列化可能仍在原线程),但能显著减少 I/O 等待。- 存在日志丢失风险 :如果应用异常崩溃,内存队列中未处理的日志可能会丢失。需要配置合理的队列大小和优雅停机策略(如 Logback 的
shutdownHook
)。 - 可能需要调优:队列大小 (
queueSize
),以及队列满时的策略(discardingThreshold
设为 0 表示不丢弃,队列满时阻塞,非 0 则开始丢弃旧日志;neverBlock
设为 true 表示队列满时直接丢弃新日志)。根据系统的重要性和性能要求进行权衡。