日志级别与最佳实践

日志级别的混乱是团队中常见的「技术债」:日志满天飞,关键信息被淹没;或者日志太少,排查时找不到需要的信息。

问题往往不是不知道有哪些日志级别,而是不知道什么时候该用哪个级别。本文给出一个清晰的决策框架。

日志级别定义

四级日志体系

级别英文中文定义产生频率
ERRORError错误需要立即处理的错误
WARNWarning警告潜在问题,不需要立即处理
INFOInformation信息重要的业务里程碑
DEBUGDebug调试详细的开发调试信息极高

错误级别判断标准

使用以下三个问题判断一条日志应该是什么级别:

1. 用户是否受到影响?
   └── 是 → ERROR
   └── 否 → 继续

2. 需要人工干预吗?
   └── 是 → ERROR
   └── 否 → WARN

3. 是否是异常情况(但预期可能发生)?
   └── 是 → WARN
   └── 否 → INFO 或 DEBUG

各级别使用场景

ERROR:错误需要立即处理

ERROR 意味着系统出现了需要人工干预的问题。用户可能已经受到影响,或即将受到影响。

ERROR
// ✅ 正确:外部依赖完全失败,用户请求无法完成
log.error("Payment service unavailable after 3 retries: orderId={}, error={}",
    orderId, e.getMessage(), e);

// ✅ 正确:数据一致性被破坏
log.error("Order state inconsistent: expected=PAID, actual=PENDING, orderId={}",
    orderId);

// ✅ 正确:无法继续处理的致命错误
log.error("Database connection pool exhausted after timeout", e);

// ❌ 错误:一个请求的参数错误是用户问题,不是 ERROR
log.error("Invalid parameter: userId={}", userId);  // 应该是 WARN

WARN:警告潜在问题

WARN 意味着系统发现了一个潜在问题,不需要立即处理,但值得关注。可能是配置异常、资源接近上限、或业务逻辑的非预期情况。

WARN
// ✅ 正确:资源接近上限,需要关注但不是故障
log.warn("Connection pool utilization high: used={}/total={}, threshold=80%",
    used, total);

// ✅ 正确:配置使用默认值,可能不是预期行为
log.warn("Cache TTL not configured, using default 300 seconds");

// ✅ 正确:业务降级,核心功能仍可用但性能下降
log.warn("Redis unavailable, falling back to local cache. Performance may degrade.");

// ✅ 正确:重试成功前的失败尝试(记录最后一次)
log.warn("Payment retry succeeded after 2 attempts: orderId={}, totalAttempts=3",
    orderId);

// ✅ 正确:业务规则校验失败(用户输入问题)
log.warn("Order cancelled by user: orderId={}, reason=USER_REQUESTED", orderId);

// ❌ 错误:框架的内部 WARN 应该被抑制或降级
log.warn("Slow query detected: duration=100ms, sql={}", sql);  // 框架日志

INFO:业务里程碑

INFO 是正常业务流程中的关键节点,用于审计、追踪和了解系统运行状态。

INFO
// ✅ 正确:请求入口和出口
log.info("Request received: method={}, uri={}, traceId={}",
    request.getMethod(), request.getRequestURI(), traceId);

log.info("Request completed: status={}, duration={}ms, traceId={}",
    response.getStatus(), duration, traceId);

// ✅ 正确:关键业务操作
log.info("Order placed: orderId={}, amount={}, paymentMethod={}",
    order.getId(), order.getAmount(), order.getPaymentMethod());

log.info("Payment completed: transactionId={}, amount={}, status={}",
    transaction.getId(), transaction.getAmount(), transaction.getStatus());

// ✅ 正确:系统状态变化
log.info("Application started: version={}, port={}", version, port);
log.info("Configuration reloaded: keys={}", changedKeys);

// ✅ 正确:定时任务执行
log.info("Daily report generation completed: records={}, duration={}s",
    recordCount, durationSeconds);

// ❌ 错误:循环内的日志(会产生海量日志)
for (Order order : orders) {
    log.info("Processing order: {}", order.getId());  // 应该用 DEBUG
}

// ❌ 错误:每个方法都记录入口
public void doSomething() {
    log.info("Entering doSomething");  // 噪音太大
    // ...
}

DEBUG:开发调试

DEBUG 是开发调试和详细分析用的信息,生产环境通常关闭。

DEBUG
// ✅ 正确:方法入参和出参(仅开发环境)
log.debug("Entering method: calculateOrderTotal, params: orderId={}, items={}",
    orderId, items);
log.debug("Method result: total={}", total);

// ✅ 正确:详细的执行路径(用于定位 bug)
log.debug("Cache miss for key: {}", cacheKey);
log.debug("Cache hit for key: {}, value: {}", cacheKey, value);

// ✅ 正确:循环中的详细状态
log.debug("Processing item: index={}, itemId={}, status={}",
    index, item.getId(), item.getStatus());

// ✅ 正确:SQL 语句和参数
log.debug("Executing query: sql={}, params={}", sql, params);

日志内容设计

好日志的标准

包含足够上下文

// ❌ 错误:上下文不足,不知道是哪个订单、哪个用户
log.info("Order cancelled");

// ✅ 正确:包含足够定位问题的信息
log.info("Order cancelled: orderId={}, userId={}, reason={}, traceId={}",
    orderId, userId, reason, traceId);

结构化数据优于字符串拼接

// ❌ 错误:字符串拼接,解析困难
log.info("User " + userId + " placed order " + orderId);

// ✅ 正确:参数化日志,结构清晰
log.info("User order placed: userId={}, orderId={}, amount={}",
    userId, orderId, amount);

异常日志要包含堆栈

// ❌ 错误:只记录消息,丢失堆栈信息
log.error("Payment failed: " + e.getMessage());

// ✅ 正确:传入异常对象,保留完整堆栈
log.error("Payment failed for orderId={}", orderId, e);

环境差异配置

Logback 环境配置

logback-spring.xml
<!-- 开发环境:全部开启 -->
<springProfile name="dev">
    <root level="DEBUG">
        <appender-ref ref="CONSOLE"/>
    </root>
</springProfile>

<!-- 生产环境:INFO 及以上 -->
<springProfile name="prod">
    <root level="INFO">
        <appender-ref ref="ASYNC_CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

    <!-- 框架日志降级 -->
    <logger name="org.springframework" level="WARN"/>
    <logger name="org.hibernate" level="WARN"/>
    <logger name="org.apache.catalina" level="WARN"/>
</springProfile>

<!-- 测试环境:DEBUG -->
<springProfile name="test">
    <root level="DEBUG">
        <appender-ref ref="CONSOLE"/>
    </root>
</springProfile>

常见反模式

反模式一:日志级别滥用

// ❌ 所有日志都是 INFO,ERROR 和 WARN 被淹没
log.info("Entering method");
log.info("Exiting method");
log.info("If condition reached");

// ✅ 正确:区分重要事件和普通路径
log.info("Order created: orderId={}", orderId);
log.debug("Validation passed: fields={}", fields);

反模式二:日志成为性能瓶颈

// ❌ 字符串拼接在日志级别检查之前执行
log.info("Processing user: " + buildUserSummary(user));  // 拼接总是执行

// ✅ 正确:参数化,拼接只在 INFO 开启时执行
log.info("Processing user: userId={}", user.getId());

// ✅ 正确:先检查级别
if (log.isInfoEnabled()) {
    log.info("Processing user: {}", buildUserSummary(user));
}

反模式三:日志中的敏感信息

// ❌ 错误:记录密码、Token、身份证等敏感信息
log.info("User login: userId={}, password={}", userId, password);

// ✅ 正确:脱敏处理
log.info("User login: userId={}, hasPassword=true", userId);

日志级别升级机制

对于持续出现但不需要立即处理的 ERROR,可以考虑自动升级

public class EscalatingLogger {

    private final Logger logger;
    private final Map<String, AtomicInteger> errorCount = new ConcurrentHashMap<>();

    public void error(String msg, Object... params) {
        String key = msg;
        int count = errorCount.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();

        if (count == 1) {
            logger.error(msg, params);
        } else if (count == 10) {
            // 10 次重复错误后升级为告警(通过 metrics)
            metrics.increment("error.escalated", tag("message", msg));
            logger.warn("Error repeated {} times: {}", count, msg);
        }
        // 超过阈值后降低频率
        if (count > 100 && count % 100 == 0) {
            logger.error("Error still repeating: count={}, {}", count, msg);
        }
    }
}

质量判断标准

读完本节后,你应该能够回答:

  1. 使用三个问题判断日志级别的标准是什么?请分别用 ERROR、WARN、INFO、DEBUG 各举一个具体例子。
  2. 为什么说「字符串拼接在日志级别检查之前执行」是一个性能反模式?应该如何正确处理?
  3. 为什么框架日志(如 Spring、Hibernate)在生产环境应该降级为 WARN?这些框架的 DEBUG 日志会产生多少噪音?
  4. 在 ERROR 日志中,传入异常对象和只传入异常消息有什么区别?为什么堆栈信息如此重要?
  5. 如何设计一个日志级别升级机制,防止同一错误反复刷屏?