一、引言
日志是生产环境排查问题的眼睛。写得好的日志能让你快速定位问题,写得不好的日志只会增加排查难度。
本文分享 5 条生产环境日志规范,每条都有反面示例和正面示例,看完就能用。
二、规范 1:每条日志必须带 TraceId
2.1 反面示例
@Slf4j
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
log.info("开始查询订单,id={}", id);
Order order = orderService.findById(id);
log.info("查询订单完成,order={}", order);
return OrderDTO.from(order);
}
}
问题:没有 TraceId,无法串联一次请求的所有日志。当多个请求同时进来时,日志会混在一起,很难分辨哪些日志属于同一个请求。
2.2 正面示例
@Slf4j
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
log.info("开始查询订单,id={}", id);
Order order = orderService.findById(id);
log.info("查询订单完成,order={}", order);
return OrderDTO.from(order);
}
}
配置 logback.xml:
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
配置 TraceIdFilter(详见完整配置示例):
@Component
public class TraceIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}
}
}
效果:每条日志都会自动带上 TraceId,无需手动传递。
2024-01-15 10:30:45.123 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.c.OrderController - 开始查询订单,id=1
2024-01-15 10:30:45.145 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.s.OrderService - 查询订单,id=1
2024-01-15 10:30:45.167 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.c.OrderController - 查询订单完成,order=Order(id=1, ...)
2.3 为什么这样设计
- 请求串联:通过 TraceId 可以快速找到一次请求的所有日志
- 无需手动传递:使用 MDC + AOP 自动注入,业务代码零侵入
- 微服务链路追踪:配合 Zipkin、SkyWalking 等工具,实现跨服务追踪
三、规范 2:入参出参分开打
3.1 反面示例
@Slf4j
@Service
public class OrderService {
public OrderDTO createOrder(OrderCreateRequest request) {
log.info("创建订单,request={}, result={}", request, doCreate(request));
return doCreate(request);
}
private OrderDTO doCreate(OrderCreateRequest request) {
// 业务逻辑
return new OrderDTO();
}
}
问题:入参和出参混在一起打印,而且 doCreate() 被调用了两次。更重要的是,如果 doCreate() 抛出异常,入参日志也不会打印。
3.2 正面示例
@Slf4j
@Service
public class OrderService {
public OrderDTO createOrder(OrderCreateRequest request) {
log.info("创建订单入参,userId={}, productId={}, quantity={}",
request.getUserId(), request.getProductId(), request.getQuantity());
OrderDTO result = doCreate(request);
log.debug("创建订单出参,orderId={}, amount={}",
result.getOrderId(), result.getAmount());
return result;
}
private OrderDTO doCreate(OrderCreateRequest request) {
// 业务逻辑
return new OrderDTO();
}
}
效果:
2024-01-15 10:30:45.123 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.s.OrderService - 创建订单入参,userId=1001, productId=2001, quantity=2
2024-01-15 10:30:45.167 [http-nio-8080-exec-1] [a1b2c3d4e5f6] DEBUG c.e.s.OrderService - 创建订单出参,orderId=3001, amount=99.98
3.3 为什么这样设计
- 入参用 INFO:方便排查问题时快速找到请求参数
- 出参用 DEBUG:生产环境通常关闭 DEBUG,避免日志过多
- 入参出参分开:即使出参异常,入参也能正常打印
- 避免重复计算:先计算结果,再打印日志
四、规范 3:异常日志必须包含完整堆栈和业务上下文
4.1 反面示例
@Slf4j
@Service
public class OrderService {
public OrderDTO createOrder(OrderCreateRequest request) {
try {
return doCreate(request);
} catch (Exception e) {
log.error("创建订单失败");
throw e;
}
}
}
问题:只打印了错误信息,没有堆栈,也没有业务上下文。排查问题时无法知道是哪一行代码出错,也不知道是哪个用户的请求。
4.2 正面示例
@Slf4j
@Service
public class OrderService {
public OrderDTO createOrder(OrderCreateRequest request) {
try {
return doCreate(request);
} catch (Exception e) {
log.error("创建订单失败,userId={}, productId={}, quantity={}",
request.getUserId(), request.getProductId(), request.getQuantity(), e);
throw new BusinessException("创建订单失败", e);
}
}
}
效果:
2024-01-15 10:30:45.145 [http-nio-8080-exec-1] [a1b2c3d4e5f6] ERROR c.e.s.OrderService - 创建订单失败,userId=1001, productId=2001, quantity=2
java.lang.NullPointerException: null
at com.example.service.OrderService.doCreate(OrderService.java:45)
at com.example.service.OrderService.createOrder(OrderService.java:28)
at com.example.controller.OrderController.createOrder(OrderController.java:35)
...
4.3 为什么这样设计
- 完整堆栈:可以快速定位到出错的代码行
- 业务上下文:知道是哪个用户的请求出错,方便排查业务问题
- 异常传递:使用自定义异常包装,保留原始堆栈信息
五、规范 4:敏感信息必须脱敏后再打日志
5.1 反面示例
@Slf4j
@Service
public class UserService {
public UserDTO getUser(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
log.info("查询用户成功,user={}", user);
return UserDTO.from(user);
}
}
@Data
@Entity
public class User {
private Long id;
private String name;
private String phone;
private String idCard;
private String address;
}
问题:直接打印整个 User 对象,会把手机号、身份证等敏感信息暴露在日志中,违反数据安全法规。
5.2 正面示例
@Slf4j
@Service
public class UserService {
public UserDTO getUser(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
log.info("查询用户成功,id={}, name={}, phone={}, idCard={}",
user.getId(),
user.getName(),
DesensitizeUtils.maskPhone(user.getPhone()),
DesensitizeUtils.maskIdCard(user.getIdCard()));
return UserDTO.from(user);
}
}
public class DesensitizeUtils {
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 18) {
return idCard;
}
return idCard.substring(0, 6) + "**********" + idCard.substring(16);
}
public static String maskEmail(String email) {
if (email == null || !email.contains("@")) {
return email;
}
String[] parts = email.split("@");
String username = parts[0];
if (username.length() <= 2) {
return username + "****@" + parts[1];
}
return username.substring(0, 2) + "****" + "@" + parts[1];
}
}
效果:
2024-01-15 10:30:45.123 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.s.UserService - 查询用户成功,id=1001, name=张三, phone=138****1234, idCard=110101**********12
5.3 为什么这样设计
- 数据安全:保护用户隐私,符合 GDPR、个人信息保护法等法规要求
- 避免泄露:即使日志被泄露,敏感信息也无法被识别
- 统一工具类:使用工具类统一处理脱敏逻辑,避免重复代码
六、规范 5:关键路径加耗时日志
6.1 反面示例
@Slf4j
@Service
public class OrderService {
public OrderDTO createOrder(OrderCreateRequest request) {
// 业务逻辑
Order order = new Order();
order.setUserId(request.getUserId());
// ...
order = orderRepository.save(order);
// 调用支付服务
paymentService.pay(order.getId(), order.getAmount());
// 发送消息
messageService.sendOrderCreated(order.getId());
return OrderDTO.from(order);
}
}
问题:没有记录各个环节的耗时,当接口响应变慢时,无法知道是哪个环节出了问题。
6.2 正面示例
@Slf4j
@Service
public class OrderService {
public OrderDTO createOrder(OrderCreateRequest request) {
StopWatch stopWatch = new StopWatch("createOrder");
stopWatch.start("保存订单");
Order order = new Order();
order.setUserId(request.getUserId());
// ...
order = orderRepository.save(order);
stopWatch.stop();
stopWatch.start("调用支付");
paymentService.pay(order.getId(), order.getAmount());
stopWatch.stop();
stopWatch.start("发送消息");
messageService.sendOrderCreated(order.getId());
stopWatch.stop();
log.info("创建订单耗时统计:{}", stopWatch.prettyPrint());
return OrderDTO.from(order);
}
}
效果:
2024-01-15 10:30:45.167 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.s.OrderService - 创建订单耗时统计:StopWatch 'createOrder': running time = 4567800 ns
---------------------------------------------
ns % Task name
---------------------------------------------
1234500 26.9% 保存订单
2345600 51.4% 调用支付
0987700 21.6% 发送消息
6.3 AOP 自动记录耗时
@Aspect
@Component
public class MethodTimeAspect {
@Around("execution(* com.example.service..*.*(..))")
public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
StopWatch stopWatch = new StopWatch(methodName);
stopWatch.start(methodName);
try {
return joinPoint.proceed();
} finally {
stopWatch.stop();
log.info("方法耗时统计:{}", stopWatch.prettyPrint());
}
}
}
6.4 为什么这样设计
- 性能排查:快速定位慢方法,找到性能瓶颈
- 监控告警:结合监控系统,可以设置耗时阈值告警
- 优化依据:有了耗时数据,才能针对性地进行优化
七、完整配置示例
7.1 logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<logger name="com.example" level="DEBUG" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
</configuration>
7.2 MDC TraceId 过滤器
@Component
public class TraceIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}
}
}
7.3 脱敏工具类
public final class DesensitizeUtils {
private DesensitizeUtils() {}
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 18) {
return idCard;
}
return idCard.substring(0, 6) + "**********" + idCard.substring(16);
}
public static String maskEmail(String email) {
if (email == null || !email.contains("@")) {
return email;
}
String[] parts = email.split("@");
String username = parts[0];
if (username.length() <= 2) {
return username + "****@" + parts[1];
}
return username.substring(0, 2) + "****" + "@" + parts[1];
}
public static String maskBankCard(String bankCard) {
if (bankCard == null || bankCard.length() < 16) {
return bankCard;
}
return bankCard.substring(0, 4) + "********" + bankCard.substring(bankCard.length() - 4);
}
}
7.4 AOP 耗时记录切面
@Aspect
@Component
public class MethodTimeAspect {
private static final Logger log = LoggerFactory.getLogger(MethodTimeAspect.class);
@Around("execution(* com.example.service..*.*(..))")
public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
long startTime = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
if (duration > 100) {
log.warn("方法耗时较长,className={}, methodName={}, duration={}ms",
className, methodName, duration);
} else {
log.debug("方法耗时,className={}, methodName={}, duration={}ms",
className, methodName, duration);
}
}
}
}
八、总结
8.1 5 条规范总结
日志规范总结:
┌─────────────────────────────────────────────────────┐
│ │
│ 规范 1:每条日志必须带 TraceId │
│ └── 使用 MDC + AOP 自动注入,无需手动传递 │
│ │
│ 规范 2:入参出参分开打 │
│ └── 入参用 INFO,出参用 DEBUG │
│ │
│ 规范 3:异常日志必须包含完整堆栈和业务上下文 │
│ └── 带上 userId、orderId 等关键信息 │
│ │
│ 规范 4:敏感信息必须脱敏后再打日志 │
│ └── 手机号、身份证、银行卡、邮箱等需脱敏 │
│ │
│ 规范 5:关键路径加耗时日志 │
│ └── 使用 StopWatch 或 AOP 自动记录 │
│ │
└─────────────────────────────────────────────────────┘
8.2 日志级别使用原则
日志级别使用原则:
┌─────────────────────────────────────────────────────┐
│ │
│ ERROR:系统错误,需要立即处理 │
│ └── 数据库连接失败、消息发送失败、业务异常 │
│ │
│ WARN:警告信息,需要关注但不需要立即处理 │
│ └── 方法耗时较长、配置过期、降级处理 │
│ │
│ INFO:关键业务流程,生产环境开启 │
│ └── 请求入参、订单创建成功、用户登录 │
│ │
│ DEBUG:详细调试信息,生产环境关闭 │
│ └── 请求出参、中间状态、详细流程 │
│ │
│ TRACE:最详细的追踪信息,仅开发环境使用 │
│ └── 方法调用栈、变量详细值 │
│ │
└─────────────────────────────────────────────────────┘
8.3 写日志的黄金法则
写日志的黄金法则:
1. 可搜索:日志必须包含 TraceId、userId、orderId 等关键信息
2. 可读:日志格式清晰,信息完整
3. 安全:敏感信息必须脱敏
4. 适度:不要过多,也不要过少
5. 一致:团队使用统一的日志规范
💡 互动话题:你们团队的日志规范是什么?有没有遇到过日志写得不好导致排查困难的情况?欢迎在评论区分享!
