为什么团队最好先约定一套日志写法#

日志不是“顺手打印一行字符串”,而是故障排查、审计追踪和可观测性的一部分。没有相对一致的写法时,最常见的问题不是“没有日志”,而是日志很多却无法检索、无法关联、无法长期维护。

典型表现包括:

  • message 风格混乱,同类事件写法完全不同
  • 关键上下文字段缺失,只能靠全文检索猜测问题链路
  • 敏感信息进入日志,带来合规和安全风险
  • 生产问题无法按 traceIdrequestId、订单号或用户 ID 快速定位

本文不讨论“如何随手打一行日志”,而是尝试把日志整理成一套团队更容易落地的开发约定:哪些值得优先做,哪些应尽量避免,哪些是我比较推荐的实践;同时给出 Spring Boot 项目的落地示例和支撑链接。

日志开发的基本原则#

日志约定先解决的是可用性,而不是“看起来有日志”。

我更推荐的原则是:message 只描述发生了什么,不承担完整上下文;关键上下文最好通过结构化字段输出;默认假设日志会进入集中平台并长期保留,因此内容最好可检索、可审计、可脱敏。

不建议把日志当成临时调试输出。不要依赖随手拼接字符串来补充上下文,也不要把敏感信息、不可检索的大段文本或纯噪声写进生产日志。

推荐的做法是让 message 与结构化字段组合起来回答三个问题:发生了什么、影响了什么、后续如何定位。这样日志才能服务排障、审计和关联分析。

错误示例:把所有信息拼进 message,无法按字段过滤,也无法聚合统计:

// 上下文全靠字符串拼接,检索只能依赖全文匹配
log.info("Order " + orderId + " status changed from " + oldStatus + " to " + newStatus + " for user " + userId);

正确示例message 稳定、简洁,上下文放到结构化字段,可按任意维度检索:

log.atInfo()
    .setMessage("order status changed")
    .addKeyValue("orderId", orderId)
    .addKeyValue("fromStatus", oldStatus)
    .addKeyValue("toStatus", newStatus)
    .addKeyValue("userId", userId)
    .log();

日志框架选型与使用建议#

团队在日志框架选型时通常会优先考虑解耦和可维护性。我更倾向于在应用代码中使用 SLF4J 作为日志门面,而不是直接绑定具体实现(如 Logback、Log4j2)。这样做的好处是未来切换底层实现时只需更换依赖,无需修改业务代码。

<!-- 推荐:只依赖 SLF4J API -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
</dependency>

<!-- 运行时绑定具体实现(Spring Boot 默认 Logback) -->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
</dependency>

评估具体实现时应关注:

  • 性能:异步追加器能力、吞吐量、内存占用
  • 灵活性:是否支持结构化输出、自定义 encoder、过滤器链
  • 生态:是否有成熟的 appender 支持集中式日志平台(如 OpenSearch、Kafka、GELF)

Spring Boot 项目默认使用 Logback,3.4+ 版本已内建 ECS 结构化日志支持;如果团队有 Log4j2 迁移需求,只需调整依赖桥接即可,业务代码无需改动。

选型建议:除非微基准测试明确显示 Log4j2 在你的热路径中有显著优势,否则我通常会先沿用 SLF4J 门面 + Logback。至少在 Spring Boot 项目里,这条路径和框架集成得更顺,也少一些桥接配置成本。

更值得记录什么,尽量别记录什么#

推荐优先记录的内容包括:

  • 请求进入关键边界时的入口日志,例如进入 HTTP 接口、消息消费、定时任务或批处理入口
  • 外部调用的失败、超时、重试和降级通常都应该记录;关键外部调用的完成日志也应保留,开始日志只在排查延迟或超时链路时按需开启
  • 关键状态变化,例如订单从 PENDING 进入 PAID,或任务从 RUNNING 进入 FAILED
  • 影响业务结果分支的决定,例如命中风控拒绝、库存不足终止下单、触发降级返回兜底结果
  • 需要人工介入的错误和异常
// 入口日志:记录消息消费起始
@KafkaListener(topics = "order-events")
public void onMessage(OrderEvent event) {
    log.atInfo()
        .setMessage("order event received")
        .addKeyValue("orderId", event.getOrderId())
        .addKeyValue("eventType", event.getType())
        .log();
    process(event);
}

// 关键状态变化
log.atInfo()
    .setMessage("order status changed")
    .addKeyValue("orderId", orderId)
    .addKeyValue("fromStatus", "PENDING")
    .addKeyValue("toStatus", "PAID")
    .log();

// 影响业务结果的决策分支
if (stockCheckResult.isInsufficient()) {
    log.atWarn()
        .setMessage("order rejected: insufficient stock")
        .addKeyValue("orderId", orderId)
        .addKeyValue("skuId", skuId)
        .addKeyValue("requested", quantity)
        .addKeyValue("available", stockCheckResult.getAvailable())
        .log();
    return OrderResult.rejected("INSUFFICIENT_STOCK");
}

// 外部调用失败
log.atError()
    .setMessage("payment gateway call failed")
    .addKeyValue("orderId", orderId)
    .addKeyValue("externalService", "payment-gateway")
    .addKeyValue("statusCode", response.getStatus())
    .setCause(e)
    .log();

一般不建议记录的内容包括:

  • 密码、Token、Key、完整身份证号和银行卡号
  • 完整原始请求体和响应体,尤其是包含个人信息或凭证的内容
  • 高频循环中的成功日志
  • 没有业务含义的 method entry / exit 日志
// 禁止:密码、Token 进入日志
log.info("User login: username={}, password={}", username, password);         // 不建议这样写
log.info("Request headers: {}", request.getHeaders());                         // 可能含 Authorization
log.debug("Response body: {}", objectMapper.writeValueAsString(responseBody)); // 可能含个人信息

// 禁止:高频循环里的成功日志(每秒数千次)
for (Item item : items) {
    process(item);
    log.info("item processed: {}", item.getId()); // 噪声,应去除或降为 DEBUG + 采样
}

// 禁止:无业务含义的 entry / exit
log.debug("Entering calculateDiscount()");
log.debug("Exiting calculateDiscount()");

以上边界可参考 OWASP Logging Cheat Sheet 的建议,核心目标是保证日志既可用又不泄露不该出现的数据。

日志与指标的边界#

日志不是唯一的数据源。开发者需要区分哪些内容应该记入日志,哪些应该作为指标(Metrics)上报。混淆两者会导致日志管道过载、存储成本上升,且查询效率下降。

应该作为指标而非日志的内容:

  • 负载均衡器健康检查成功(周期性、无异常信息)
  • 循环计数器(每秒处理多少条消息、完成多少个任务)
  • 定时任务的周期性执行成功
  • 纯数值型状态快照(当前线程数、连接池使用率)
// 禁止:健康检查成功写入日志(每秒一次,纯噪声)
@GetMapping("/health")
public ResponseEntity<String> health() {
    log.info("Health check passed"); // 应改为指标
    return ResponseEntity.ok("UP");
}

// 禁止:循环计数器写入日志
for (Order order : orders) {
    process(order);
    log.info("processed order #{}", order.getId()); // 1000 次/分钟,应改为 Counter 指标
}

应该作为指标的正确做法:

// 使用 Micrometer 计数器代替循环日志
private final Counter ordersProcessedCounter;

public OrderService(MeterRegistry registry) {
    this.ordersProcessedCounter = Counter.builder("orders.processed")
        .tag("type", "standard")
        .register(registry);
}

public void processBatch(List<Order> orders) {
    for (Order order : orders) {
        process(order);
        ordersProcessedCounter.increment(); // 指标,不产生日志
    }
    // 批处理完成时再写一条日志
    log.atInfo()
        .setMessage("batch order processing completed")
        .addKeyValue("totalCount", orders.size())
        .log();
}

判断原则:

特征 日志(Log) 指标(Metric)
数据模型 离散事件 聚合数值(Counter、Gauge、Histogram)
查询场景 “发生了什么、为什么” “有多频繁、趋势如何”
存储方式 全文检索、结构化字段 时序数据库
典型用途 根因分析、审计追踪 仪表盘、告警阈值
是否含上下文 是,包含业务实体和因果关系 否,仅数值和时间戳

一句话总结:需要回答"为什么"的内容记日志;需要回答"有多少"的内容记指标。

message 编写约定#

核心规则很简单:message 只描述动作,具体上下文放到结构化字段里。值班工程师看 message 应该快速知道发生了什么;需要检索、聚合和关联的内容则放到结构化字段中。

错误示例:

下面的问题不是“参数化日志不能用”,而是把关键上下文直接写进了 message 文本:

log.info("User authenticated successfully for userId={}, ip={}, ua={}", userId, ip, userAgent);

正确示例:

log.atInfo()
    .setMessage("authentication succeeded")
    .addKeyValue("userId", userId)
    .addKeyValue("ip", ip)
    .addKeyValue("userAgent", userAgent)
    .log();

推荐约定#

  • 不要用字符串拼接拼出日志内容
  • 允许使用参数化日志或 fluent API;当需要输出结构化字段时,优先使用 fluent API
  • message 保持稳定、短句、可复用
  • 同类事件使用一致的表述,避免同义不同写

性能提醒:Fluent API(atInfo().addKeyValue().log())在多数业务关键路径(INFO 及以上)中通常是更稳妥的选择,代码可读性和结构化能力都比较好。但在极致热路径(每秒数万次以上)中,链式调用产生的临时对象会增加 GC 压力,此时 DEBUG/TRACE 级别可回退到参数化日志 + isDebugEnabled() 守卫。

团队约定#

  • 本文约定 message 统一使用英文短句,避免中英混写
  • message 保持简洁,不写长句和解释性背景
  • 统一动词模式,例如 authentication succeededvalidation succeededauthentication failed
  • 同一类事件在全项目内使用相同模板,方便检索和聚合

结构化字段约定#

结构化日志的目标不是”把日志改成 JSON”,而是让同一类事件在检索、聚合和关联分析时保持稳定。message 负责描述事件本身,字段负责承载可过滤、可统计、可关联的上下文。

推荐优先使用这些字段:orderIddurationMsexternalServicestatusCoderequestId。链路追踪字段 trace.idspan.id 由 Micrometer Tracing 按 ECS 规范自动注入,使用点分隔格式;业务自定义字段统一使用 camelCase(如 orderIddurationMs),不要用 addKeyValue("traceId", ...) 手动覆盖 Micrometer 已自动写入的 trace.iduserId 这类稳定业务标识只在合规允许且确实有助于排障时记录,并按政策做脱敏、哈希或掩码处理。字段名要保持长期稳定,同一语义在全项目内只用一种写法,避免后续平台聚合和仪表盘规则失效。

字段命名一致性示例

// 禁止:同一语义用了三种写法,导致检索和仪表盘规则分裂
log.atInfo().addKeyValue("order_id", orderId)...   // 下划线写法
log.atInfo().addKeyValue("OrderId", orderId)...    // 大驼峰写法
log.atInfo().addKeyValue("orderId", orderId)...    // 小驼峰写法(团队约定统一用这种)

// 正确:全项目只用一种写法
log.atInfo()
    .setMessage("payment initiated")
    .addKeyValue("orderId", orderId)          // 稳定字段名
    .addKeyValue("externalService", "alipay")
    .addKeyValue("durationMs", elapsed)
    .addKeyValue("statusCode", 200)
    .log();

userId 脱敏示例

// 合规场景下记录 userId,按政策做掩码
String maskedUserId = userId.substring(0, 3) + "****" + userId.substring(userId.length() - 2);
log.atInfo()
    .setMessage("user login succeeded")
    .addKeyValue("userId", maskedUserId)
    .addKeyValue("ip", clientIp)
    .log();

使用 fluent API 的 addKeyValue() 时,字段会被加入当前日志事件;但它是否最终以怎样的字段类型、映射和检索行为进入 OpenSearch / Elasticsearch,取决于索引模板和平台侧配置,不是 addKeyValue() 本身决定的。换句话说,代码负责发出字段,平台负责定义 schema,而且当前激活的 encoder/layout 也必须支持这些字段输出,例如结构化 logging 输出或 Logback 的 %kvp,否则它们可能不会按预期出现在日志里。

TraceId、RequestId 与上下文传播约定#

日志最好带有可关联的上下文,否则故障排查很容易退化成靠猜。团队层面建议同时保留链路级 traceId / spanId 和请求级 requestId:前者用于跨服务串联,后者用于单次请求的稳定定位。

在 Spring Boot 体系里,traceId / spanId 优先交给 Micrometer Tracing 管理,requestIdtenantId 这类需要跨服务传播的标识,我更倾向于使用 OTel Baggage——它通过 W3C baggage header 自动随 trace 传播到下游,并可配置自动注入 MDC,从根本上解决跨线程和跨服务两个问题。Baggage 配置与跨线程传播的三种路径(虚拟线程、传统线程池、Reactor/CompletableFuture)详见 Spring Boot 3.5 Tracing 实践记录·第六节:上下文传播边界

如果场景仅限于单服务内部(不需要跨服务传播),MDC 方案仍然可用,通过 Filter 在请求入口写入并在退出时清理:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {

    private static final String REQUEST_ID_HEADER = "X-Request-Id";
    private static final String MDC_KEY = "requestId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String requestId = Optional.ofNullable(request.getHeader(REQUEST_ID_HEADER))
                .filter(s -> !s.isBlank())
                .orElse(UUID.randomUUID().toString());
        MDC.put(MDC_KEY, requestId);
        response.setHeader(REQUEST_ID_HEADER, requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove(MDC_KEY); // 确保线程归还线程池时 MDC 已清理
        }
    }
}

注意:MDC 不跨线程传播——切换线程、线程池或异步执行器时,MDC 中的字段会丢失。跨线程场景请参考上述 Tracing 文章的传播方案。

日志级别使用约定#

日志级别的边界要清晰:ERROR 表示已经发生的业务或系统失败;WARN 表示发生了异常但系统仍可继续,或需要后续跟进的降级和不一致;INFO 记录关键业务事件和状态变化;DEBUG 用于开发和联调时的诊断细节;TRACE 只保留最细粒度、最噪声化的跟踪信息。级别表达的是事件严重性,不等同于告警策略,是否触发告警要由监控规则单独决定。

各级别使用示例

// ERROR:业务流程失败,需要人工介入
log.atError()
    .setMessage("order payment failed")
    .addKeyValue("orderId", orderId)
    .addKeyValue("externalService", "payment-gateway")
    .setCause(e)
    .log();

// WARN:命中降级,系统仍可继续,但需要后续跟进
log.atWarn()
    .setMessage("inventory service degraded: using cached result")
    .addKeyValue("skuId", skuId)
    .addKeyValue("cacheAge", cacheAgeMs)
    .log();

// INFO:关键业务事件,正常流程
log.atInfo()
    .setMessage("order created")
    .addKeyValue("orderId", orderId)
    .addKeyValue("userId", maskedUserId)
    .addKeyValue("totalAmount", amount)
    .log();

// DEBUG:开发诊断细节,生产默认关闭
log.atDebug()
    .setMessage("discount rule evaluated")
    .addKeyValue("ruleId", rule.getId())
    .addKeyValue("matched", matched)
    .addKeyValue("discountRate", rate)
    .log();

常见误用示例

// 禁止:用 ERROR 记录业务校验失败(不需要人工介入)
log.error("Invalid request: missing orderId"); // 应该用 WARN 或 INFO

// 禁止:用 INFO 记录调试细节(噪声过高)
log.info("Checking discount rule #{} for user {}", ruleId, userId); // 应该用 DEBUG

// 禁止:用 WARN 记录已造成业务中断的失败
log.warn("Failed to deduct balance, order cannot proceed"); // 已影响业务,应该用 ERROR

日志消息的受众意识#

编写日志消息时应考虑谁会阅读这些日志,并根据受众调整内容:

  • 开发者:需要代码级技术细节,包括类名、方法名、行号、参数值和异常堆栈
  • DevOps/SRE:需要诊断和架构上下文,如服务依赖、延迟指标、降级状态、资源使用率
  • 支持团队/最终用户:需要清晰、可操作的指导,说明发生了什么以及下一步该做什么,避免技术术语
// 面向开发者:包含技术细节
log.atError()
    .setMessage("database connection pool exhausted")
    .addKeyValue("poolSize", pool.getMaxConnections())
    .addKeyValue("activeConnections", pool.getActiveConnections())
    .addKeyValue("waitQueueSize", pool.getWaitQueueSize())
    .setCause(e)
    .log();

// 面向 DevOps:包含系统状态和依赖
log.atWarn()
    .setMessage("upstream service timeout: using fallback response")
    .addKeyValue("service", "recommendation-service")
    .addKeyValue("timeoutMs", timeout)
    .addKeyValue("fallbackUsed", true)
    .log();

// 面向支持团队:业务友好,不含技术细节
log.atInfo()
    .setMessage("order processing delayed: payment verification in progress")
    .addKeyValue("orderId", orderId)
    .addKeyValue("estimatedCompletionMinutes", 5)
    .log();

Spring Boot 的 log groups 适合把一组相关包或组件绑成一个调试单元,便于一次性调整级别,而不是逐个 logger 修改:

logging:
  group:
    payment: com.example.payment,com.example.gateway
  level:
    payment: DEBUG   # 一次性调整整个支付模块

运行时调整日志级别时,优先使用 Actuator 的 /actuator/loggers;它支持在线调整,但是否可审计、可回滚,还取决于额外的认证、审计留痕和运维管控:

# 在线将 payment 包调到 DEBUG,无需重启
curl -X POST http://localhost:8080/actuator/loggers/com.example.payment \
     -H “Content-Type: application/json” \
     -d '{“configuredLevel”: “DEBUG”}'

不建议把”改 Kubernetes 环境变量后自动生效”当成运行时调级手段;环境变量通常只在进程启动时读取,实际生效仍需要重启 Pod。要做在线调整,应走应用暴露的日志管理接口。

异常日志约定#

异常日志的第一原则是把异常对象本身传进去,这样日志系统才能保留完整堆栈和根因链路。只打印 e.getMessage() 会丢掉调用路径,排障价值会明显下降,尤其是在多层包装异常或重试失败场景里。

错误示例:丢失堆栈,排障无法定位根因:

// 禁止:只打 message,堆栈丢失
log.error("Payment failed: {}", e.getMessage());

// 禁止:把上下文硬塞进异常字符串
log.error("Payment failed for order={}, service={}: {}", orderId, service, e.getMessage());

正确示例:异常对象传入,上下文放结构化字段:

// 正确:setCause(e) 保留完整堆栈,addKeyValue 提供可检索上下文
log.atError()
    .setMessage("payment gateway call failed")
    .addKeyValue("orderId", orderId)
    .addKeyValue("externalService", "payment-gateway")
    .addKeyValue("statusCode", response.getStatus())
    .setCause(e)
    .log();

多层包装异常示例:保留根因链路:

try {
    paymentGateway.charge(request);
} catch (GatewayTimeoutException e) {
    // 抛出业务异常时,把原始异常作为 cause 保留
    throw new PaymentFailedException("payment timed out", e);
}

// 在最外层统一记录,cause chain 完整
log.atError()
    .setMessage("order payment failed")
    .addKeyValue("orderId", orderId)
    .setCause(e) // PaymentFailedException,cause 指向 GatewayTimeoutException
    .log();

需要补充业务上下文时,不要把上下文硬塞进异常字符串里。更稳妥的做法是保留异常原文,同时通过结构化字段记录 orderIdexternalServicestatusCode 这类可检索信息。SLF4J 的 fluent logging API 和键值对写法可以直接支持这种风格,见 SLF4J Manual

简化成一句话:异常负责根因,结构化字段负责上下文。

记录故障前的上下文叙事,而非仅记录终端错误#

排障时,值班工程师需要的不仅是"最终那行 ERROR",而是导致失败的前置事件链。只记录终端错误的日志就像只看事故现场不看监控录像——知道发生了什么,但不知道为什么发生。

错误示例:只记录最终失败,缺少前置上下文:

// 只在最终失败时记录,排障时不知道为什么会走到这里
log.atError()
    .setMessage("order creation failed")
    .addKeyValue("orderId", orderId)
    .setCause(e)
    .log();

正确示例:在关键决策点和边界事件记录上下文,形成可回溯的叙事链:

// 1. 请求进入时的入口日志
log.atInfo()
    .setMessage("order creation request received")
    .addKeyValue("orderId", orderId)
    .addKeyValue("userId", maskedUserId)
    .addKeyValue("itemCount", request.getItems().size())
    .log();

// 2. 关键校验结果
if (!inventoryCheck.isAvailable()) {
    log.atWarn()
        .setMessage("order creation blocked: insufficient inventory")
        .addKeyValue("orderId", orderId)
        .addKeyValue("requestedSku", inventoryCheck.getSkuId())
        .addKeyValue("availableQty", inventoryCheck.getAvailable())
        .log();
    throw new InsufficientInventoryException(inventoryCheck.getSkuId());
}

// 3. 外部调用的关键结果
log.atInfo()
    .setMessage("payment authorization obtained")
    .addKeyValue("orderId", orderId)
    .addKeyValue("authId", paymentAuth.getAuthorizationId())
    .addKeyValue("authorizedAmount", paymentAuth.getAmount())
    .log();

// 4. 最终失败(包含完整上下文)
log.atError()
    .setMessage("order creation failed: inventory reservation timeout")
    .addKeyValue("orderId", orderId)
    .addKeyValue("step", "inventory-reservation")
    .addKeyValue("elapsedMs", elapsed)
    .setCause(e)
    .log();

排查时,通过 orderId 串联起来的日志序列清晰展示了:

[10:00:01] INFO  order creation request received         orderId=ORD-123, itemCount=3
[10:00:01] INFO  inventory check passed                  orderId=ORD-123, sku=SKU-456, available=10
[10:00:02] INFO  payment authorization obtained          orderId=ORD-123, authId=AUTH-789, amount=199.00
[10:00:05] ERROR order creation failed: inventory reservation timeout  orderId=ORD-123, elapsedMs=3000

关键原则:

  • 入口日志、关键校验、外部调用结果、状态变化这四类事件构成叙事链的主干
  • 每条链路用 orderIdrequestIdtraceId 串联
  • 不在每个方法调用都打日志,而是在改变流程走向的决策点记录
  • 正常流程可以精简,但异常发生前的最后 3-5 条相关日志必须能够还原完整上下文

性能与异步日志的边界#

异步日志的价值是把写盘、网络发送和格式化成本从业务线程中剥离出来,但代价是引入队列、额外内存占用,以及在压力过大时的丢弃风险。它适合吞吐优先的生产场景,不适合用来掩盖本来就过慢的日志设计。

如果使用 Logback AsyncAppender,需要关注几个边界:queueSize 决定缓冲能力和内存占用,discardingThreshold 决定拥塞时是否主动丢弃低优先级日志,队列满时会出现背压或丢日志的取舍,includeCallerData 则会显著增加调用栈采集成本,只有真正需要类名、行号时才开启。

Logback AsyncAppender 配置示例

<configuration>

  <!-- 同步文件 Appender -->
  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/app.log</file>
    <encoder>
      <pattern>%d{ISO8601} %-5level [%X{traceId},%X{requestId}] %logger{36} - %message %kvp%n</pattern>
    </encoder>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
      <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
      <maxHistory>30</maxHistory>
    </rollingPolicy>
  </appender>

  <!-- 异步包装:把 FILE 包进队列,业务线程不等写盘 -->
  <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE"/>
    <queueSize>512</queueSize>          <!-- 队列满则触发背压,不宜设太大 -->
    <discardingThreshold>0</discardingThreshold> <!-- 0 表示队列 80% 满时不主动丢 TRACE/DEBUG -->
    <includeCallerData>false</includeCallerData>  <!-- 采集类名/行号代价高,按需开启 -->
    <neverBlock>false</neverBlock>      <!-- false:队列满时阻塞业务线程;true:直接丢弃 -->
  </appender>

  <root level="INFO">
    <appender-ref ref="ASYNC_FILE"/>
  </root>

</configuration>

discardingThreshold 默认值是 queueSize / 5。当队列剩余容量低于此阈值时,自动丢弃 TRACE、DEBUG、INFO 级别的日志(WARNERROR 不受影响)。设为 0 表示不主动丢弃任何日志,但队列满时更容易触发背压或阻塞。生产环境应根据实际吞吐量和内存预算调整。

原则上,异步日志优先保留少量、明确、可承受的输出路径,避免把过多重型处理塞进日志线程。若业务本身依赖每条日志都必须落盘或必须实时可见,就不要指望异步层能无损解决性能问题。

性能优化关键实践#

1. 限制消息冗余度

高频或过长的日志会增加存储成本并降低检索性能。应避免:

  • 每秒数千次的循环中记录成功事件
  • message 中塞入大段 JSON 或二进制数据
  • 重复记录相同的诊断信息
// 禁止:高频循环中的成功日志
for (Order order : orders) { // 假设每秒处理数千个
    process(order);
    log.info("order processed: {}", order.getId()); // 噪声过高
}

// 推荐:只在异常或批处理完成时记录
log.atInfo()
    .setMessage("batch order processing completed")
    .addKeyValue("totalCount", orders.size())
    .addKeyValue("successCount", successCount)
    .addKeyValue("failedCount", failedCount)
    .log();

2. 原生 JSON 输出

生产环境应直接输出 JSON 格式日志,避免日志采集器(如 Filebeat、Fluentd)再用正则解析文本。Spring Boot 3.4+ 已内建 ECS JSON 支持:

logging:
  structured:
    format:
      file: ecs  # 直接输出 JSON,无需采集器解析

JSON 输出的好处包括:

  • 多行堆栈自动合并,无需额外配置
  • 字段直接可检索、可聚合、可建仪表盘
  • 减少日志采集器的 CPU 和内存开销

3. 使用 Marker 或条件守卫避免高成本操作

某些操作(如敏感数据脱敏、复杂正则匹配、大对象序列化)成本很高,不应在每次日志时都执行。推荐使用 Marker 或级别守卫:

// 错误示例:即使日志级别关闭,toString() 和序列化仍会执行
log.debug("Request body: {}", objectMapper.writeValueAsString(request));

// 正确示例 1:使用 isDebugEnabled() 守卫
if (log.isDebugEnabled()) {
    log.debug("Request body: {}", objectMapper.writeValueAsString(request));
}

// 正确示例 2:使用 Supplier 延迟求值(SLF4J 2.0+)
log.atDebug()
    .setMessage(() -> "Request body: " + serializeExpensive(request))
    .log();

// 正确示例 3:使用 Marker 控制特定日志的开关
Marker sensitiveData = MarkerFactory.getMarker("SENSITIVE");
log.atInfo().addMarker(sensitiveData)
    .setMessage("user PII accessed")
    .addKeyValue("userId", maskedUserId)
    .log();

// 在 logback-spring.xml 中可单独控制此 Marker
// <logger name="com.example" level="INFO">
//   <markerFilter marker="SENSITIVE" onMatch="DENY" onMismatch="NEUTRAL"/>
// </logger>

4. 日志采样策略

生产环境不应追求"所有日志全部保留"。采样的核心目标是:100% 记录错误,防止排障时缺少关键信息;对成功或重复的日志进行抽样,避免管道过载。

必须 100% 保留的内容:

  • 所有 ERROR 及以上级别的日志
  • 关键业务状态变化(如订单从 PAID 进入 SHIPPED
  • 需要人工介入的异常和安全事件

可以采样的内容:

  • INFO 级别的成功请求日志
  • 高频但需要保留少量样本用于验证的日志
  • 重复出现的相同模式日志

Spring Boot 中实现采样推荐用 TurboFilter,它在日志事件创建前就执行,开销低于 Appender 级别的 Filter。通过 MDC 标记实现动态采样:

按请求特征动态采样:

// 特定用户或环境的日志全量保留
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DynamicSamplingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String userId = request.getHeader("X-User-Id");
        boolean isDebugUser = "debug-user-123".equals(userId);

        if (isDebugUser) {
            // 特定用户全量保留,允许输出 DEBUG
            MDC.put("logLevel", "DEBUG");
        }

        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("logLevel");
        }
    }
}

logback-spring.xml 中使用 DynamicMDCFilter(TurboFilter,在配置级别声明):

<configuration>

  <!-- TurboFilter 在日志事件创建前执行,开销极低 -->
  <turboFilter class="ch.qos.logback.classic.turbo.DynamicMDCFilter">
    <defaultThreshold>INFO</defaultThreshold>
    <!-- MDC 中 logLevel=DEBUG 的请求允许输出 DEBUG -->
    <mdcValueThresholdPairs>
      <mdcValueThresholdPair>
        <value>DEBUG</value>
        <threshold>DEBUG</threshold>
      </mdcValueThresholdPair>
    </mdcValueThresholdPairs>
  </turboFilter>

  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{ISO8601} %-5level [%X{traceId},%X{requestId}] %logger{36} - %message %kvp%n</pattern>
    </encoder>
  </appender>

  <root level="TRACE">
    <appender-ref ref="CONSOLE"/>
  </root>

</configuration>

注意:DynamicMDCFilter 的根 level 需要设为最细级别(如 TRACE),因为实际的级别控制由 TurboFilter 的 defaultThreshold 和 MDC 配对决定。

采样的关键原则:

  • 级联故障时避免日志管道崩溃:采样成功日志,但错误全记
  • 采样率应可调且无需重启:走 Actuator /actuator/loggers 或动态配置
  • 采样后的日志仍能代表系统行为:基于请求特征采样优于固定截取

容器化日志实践#

在容器化环境(Kubernetes、Docker Compose 等)中,日志的处理方式与传统虚拟机或物理机有本质区别。核心原则是:应用只输出到标准输出(stdout)和标准错误(stderr),不写入本地文件

为什么容器不应写本地文件#

  • 容器文件系统是临时的,Pod 重启或重新调度后日志丢失
  • 写入容器内文件会占用容器层存储,影响性能和镜像大小
  • 容器编排平台(如 Kubernetes)的日志采集器默认从 stdout/stderr 采集

容器环境推荐配置#

Spring Boot 3.4+ 优先使用 YAML 原生配置(见"Spring Boot 项目的推荐落地方式"章节),只需一行 console: ecs 即可。当需要精细控制 Appender 行为(如 stderr 分流、自定义 Filter、多输出目标)时才需要 logback-spring.xml

Kubernetes 的日志采集架构会自动接管:

┌─────────────────────────────────────┐
│  Pod (Spring Boot App)              │
│  ┌───────────────────────────────┐  │
│  │  stdout → ECS JSON logs       │  │
│  │  stderr → Error logs          │  │
│  └───────────────────────────────┘  │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│  Node: Fluentd / Filebeat DaemonSet │
│  采集 stdout/stderr → 发送到         │
│  OpenSearch / Elasticsearch         │
└─────────────────────────────────────┘

如果使用 Helm 部署,确保日志格式与采集器期望的格式一致。例如:

  • 采集器期望 JSON:设置 console: ecs
  • 采集器期望纯文本:不设置 console,保留 Logback 默认彩色输出

注意:容器环境的日志路由通常由平台团队配置,应用开发者的责任是确保输出格式正确、结构化、包含必要上下文。

异常日志的 stderr 路由#

部分日志平台会把 ERROR 级别路由到 stderr,其他级别走 stdout,便于在采集管道中做不同处理。Spring Boot 3.4+ 原生 YAML 配置默认只走 stdout,需要自行分流时必须用 logback-spring.xml

<!-- logback-spring.xml -->

<!-- stdout:INFO、WARN -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
  <target>System.out</target>
  <encoder class="co.elastic.logging.logback.EcsEncoder"/>
</appender>

<!-- stderr:只接收 ERROR 及以上 -->
<appender name="ERROR_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
  <target>System.err</target>
  <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
    <level>ERROR</level>
  </filter>
  <encoder class="co.elastic.logging.logback.EcsEncoder"/>
</appender>

<root level="INFO">
  <appender-ref ref="CONSOLE"/>
  <appender-ref ref="ERROR_CONSOLE"/>
</root>

注意:这种配置会让 ERROR 同时出现在 stdout 和 stderr(因为 CONSOLE 没有拒绝 ERROR)。要实现严格的"二选一",需要在 CONSOLE 的 ThresholdFilter 前加一个自定义 Filter 拒绝 ERROR 级别。大多数场景下,ERROR 同时出现在两个输出流不影响实际使用——采集平台通常去重或只订阅其中一个流。

日志反模式与常见错误#

反模式(Anti-patterns)是团队在日志实践中反复出现的错误做法。识别并避免这些模式比"知道该怎么做"更重要。

1. 静默吞掉异常#

// 禁止:catch 后什么都不做
try {
    paymentService.charge(request);
} catch (PaymentException e) {
    // 异常被静默吞掉,排障无从下手
}

// 禁止:只打印到标准输出,不进入日志系统
try {
    paymentService.charge(request);
} catch (PaymentException e) {
    e.printStackTrace(); // 不进入集中式日志平台
}

2. 记录后立即重新抛出,未增加诊断价值#

// 禁止:记录的内容和抛出的异常完全一样,没有增加上下文
try {
    paymentService.charge(request);
} catch (PaymentException e) {
    log.error("Payment failed: {}", e.getMessage()); // 无新增信息
    throw e;
}

// 正确:增加业务上下文后重新抛出,或在顶层统一记录
try {
    paymentService.charge(request);
} catch (PaymentException e) {
    log.atError()
        .setMessage("payment processing failed")
        .addKeyValue("orderId", orderId)
        .addKeyValue("paymentMethod", request.getMethod())
        .setCause(e)
        .log();
    throw new OrderProcessingException("Failed to process payment for order " + orderId, e);
}

3. 模糊的日志消息#

// 禁止:无法知道哪个服务、哪个请求、什么状态码
log.error("Communication error");

// 禁止:信息量不足,无法定位问题
log.warn("Something went wrong");

// 正确:明确动作、目标、结果
log.atError()
    .setMessage("inventory service call failed")
    .addKeyValue("service", "inventory-service")
    .addKeyValue("statusCode", 503)
    .addKeyValue("retryCount", 3)
    .addKeyValue("orderId", orderId)
    .setCause(e)
    .log();

4. 极端冗余或信息匮乏#

两个极端都不可取:

  • 过度日志:每秒数万条、重复、无差别输出,导致"信息疲劳"
  • 日志饥饿:关键路径无日志,异常无堆栈,排障靠猜
// 禁止:过度日志(每个方法 entry/exit + 每个变量变化)
log.debug("Entering processOrder()");
log.debug("orderId = {}", orderId);
log.debug("Calling validateOrder()");
log.debug("Exiting validateOrder(), result = {}", valid);
log.debug("Calling calculatePrice()");
// ... 数十行类似日志

// 禁止:日志饥饿(整个关键流程只有一行,无异常堆栈)
log.info("Order processed"); // 成功失败?耗时?哪些步骤?无从得知

// 正确:关键边界 + 结构化上下文
log.atInfo()
    .setMessage("order processing completed")
    .addKeyValue("orderId", orderId)
    .addKeyValue("durationMs", elapsed)
    .addKeyValue("stepsCompleted", List.of("validation", "pricing", "inventory-reserve", "payment"))
    .log();

5. 在容器中写入临时存储#

// 禁止:容器内写文件,Pod 重启后丢失
File logFile = new File("/var/log/app/debug.log");
Files.writeString(logFile.toPath(), logLine); // 容器文件系统不是持久化存储

// 正确:输出到 stdout/stderr,由平台采集
log.info("Debug information: {}", details); // 配置 ConsoleAppender 即可

Spring Boot 项目的推荐落地方式#

Spring Boot 3.4+ 已内建 structured logging 支持(ECS、GELF、Logstash 等格式),优先使用 YAML 原生配置,无需额外依赖 co.elastic.logging 或编写 logback-spring.xml。参考 Spring Boot LoggingStructured logging in Spring Boot 3.4 了解其引入背景。

如果你在使用 Spring Boot 3.4+,推荐阅读配套文章 Spring Boot 3.4+ 结构化日志实践:用 ECS + Fluent API 告别 logback-spring.xml,其中包含从零配置 ECS 输出、Fluent API 与 OpenSearch KQL 查询联动、以及无需 XML 的精细化定制方案。

容器环境推荐 console: ecs 输出 ECS JSON 到 stdout;本地开发不设置 console,保留 Logback 默认彩色文本;仅在合规要求需要日志存档时才额外启用 file: ecs。YAML 完整配置、Fluent API 写法与 OpenSearch KQL 查询联动,见配套文章 Spring Boot 3.4+ 结构化日志实践:用 ECS + Fluent API 告别 logback-spring.xml

Logback 与 Spring 环境属性集成#

日志配置不应硬编码。Spring Boot 提供了 <springProperty> 扩展,让 logback-spring.xml 直接读取 application.yml 中的属性值,避免在两份配置文件中重复维护相同的信息。

<!-- logback-spring.xml -->
<configuration>

  <!-- 从 application.yml 读取 myapp.log-service.host,默认 localhost -->
  <springProperty scope="context" name="logHost"
                  source="myapp.log-service.host"
                  defaultValue="localhost"/>

  <!-- 从 application.yml 读取 myapp.log-service.port,默认 5044 -->
  <springProperty scope="context" name="logPort"
                  source="myapp.log-service.port"
                  defaultValue="5044"/>

  <appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
    <destination>${logHost}:${logPort}</destination>
    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
  </appender>

  <root level="INFO">
    <appender-ref ref="LOGSTASH"/>
  </root>

</configuration>

对应的 application.yml

myapp:
  log-service:
    host: log-collector.internal
    port: 5044

关键约束:

  • <springProperty>source 属性必须使用 kebab-case,支持 Spring Boot 的宽松绑定
  • 只能用于 <springProperty scope="context"> 声明上下文属性,不能直接引用到 pattern 表达式中
  • 注意:<springProperty><springProfile><configuration scan="true"> 不兼容,需要二选一
  • 日志系统在 ApplicationContext 之前初始化,所以 @Configuration 类上的 @PropertySource 无法影响日志配置

Profile-specific 日志配置#

Spring Boot 支持在 logback-spring.xml 中通过 <springProfile> 标签为不同环境设置不同的日志策略,无需维护多套配置文件:

<configuration>

  <!-- 开发环境:彩色控制台,DEBUG 级别 -->
  <springProfile name="dev">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%cyan(%d{HH:mm:ss.SSS}) %magenta(%thread) %-5level %logger{36} - %msg%n</pattern>
      </encoder>
    </appender>
    <root level="DEBUG">
      <appender-ref ref="CONSOLE"/>
    </root>
  </springProfile>

  <!-- 生产环境:JSON ECS 输出,INFO 级别 -->
  <springProfile name="prod">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="co.elastic.logging.logback.EcsEncoder"/>
    </appender>
    <root level="INFO">
      <appender-ref ref="CONSOLE"/>
    </root>
  </springProfile>

  <!-- 多环境组合:staging 或非 production 环境都生效 -->
  <springProfile name="staging | !production">
    <logger name="com.example.payment" level="DEBUG"/>
  </springProfile>

</configuration>

支持的布尔表达式:name="dev"name="dev \| test"name="staging & !production"

--debug--trace 的真实行为#

启动参数 --debug--trace(或 application.yml 中的 debug=true / trace=true不会将全局日志级别设为 DEBUG/TRACE。它们只开启 Spring Boot 核心包、Tomcat、Hibernate 等关键组件的 DEBUG 级别,全局 root 级别仍保持默认值(通常是 INFO)。

如果需要在开发环境全局调到 DEBUG,应显式设置:

# application-dev.yml
logging:
  level:
    root: DEBUG

或启动参数:--logging.level.root=DEBUG

Code Review 检查清单#

  • 是否记录了关键边界事件
  • 是否泄漏敏感信息
  • 是否使用参数化日志或 fluent API
  • 是否补充了必要的结构化字段(orderIdmaskedUserIdexternalServicedurationMsstatusCode
  • 是否错误使用了日志级别
  • 是否在异常日志中丢失堆栈
  • 是否在异步场景中遗漏上下文传播
  • 在 K8s 环境中是否仅输出 stdout/stderr(无不必要的文件 Appender)
  • 异常是否在日志记录后正确重抛或交由统一异常处理(配合熔断器使用)

参考资料#

日志集中管理与监控#

日志的价值不在于"写入磁盘",而在于能否被集中管理、关联分析和告警。没有集中式日志平台时,再好的日志规范也只能停留在"知道怎么查,但查不到"的状态。

集中式日志架构#

典型的集中式日志架构包括以下层次:

┌──────────────────────────────────────────────────────────┐
│  应用层 (Spring Boot)                                     │
│  - 结构化日志输出 (ECS JSON)                              │
│  - TraceId/RequestId 自动注入                             │
│  - 异常堆栈完整记录                                        │
└──────────────────────┬───────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────┐
│  采集层 (Filebeat / Fluentd / Fluent Bit)                 │
│  - 从 stdout/stderr 或文件采集                             │
│  - 解析 JSON(如未原生输出)                               │
│  - 添加元数据:Pod 名、Namespace、Node IP                  │
└──────────────────────┬───────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────┐
│  缓冲层 (Kafka / Redis) [可选]                            │
│  - 削峰填谷,避免采集器直连存储                            │
│  - 支持多消费者(搜索、告警、分析)                         │
└──────────────────────┬───────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────┐
│  存储层 (OpenSearch / Elasticsearch / Loki)               │
│  - 索引映射定义字段类型                                    │
│  - 生命周期管理(ILM):热/温/冷/删除                      │
│  - 全文检索 + KQL 查询                                    │
└──────────────────────┬───────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────┐
│  展示层 (Grafana / Kibana / OpenSearch Dashboards)        │
│  - 仪表盘:错误率、延迟分布、流量趋势                       │
│  - 告警规则:基于日志指标触发通知                          │
│  - 关联分析:与 Metrics、Traces 联动                      │
└──────────────────────────────────────────────────────────┘

JVM 与 GC 日志#

除了应用日志,JVM 级别的日志也应纳入集中管理,尤其是性能调优和 OOM 排查时:

# JVM 启动参数推荐
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m
-Xlog:jni+resolve=off

Spring Boot 容器环境推荐把 GC 日志也输出到 stdout:

-Xlog:gc*:stdout:time,uptime,level,tags

GC 日志可由 GCLogViewer、GCEasy 或集中平台的专用解析器处理,用于:

  • 识别内存泄漏和频繁 Full GC
  • 调整堆大小和 GC 算法
  • 关联应用延迟尖刺与 GC Stop-The-World

日志指标与告警#

日志不仅是"用来搜索的",也可以作为告警的数据源。常见基于日志的告警场景:

  • 单位时间内 ERROR 级别日志超过阈值
  • 特定关键词(如 OutOfMemoryErrorConnection refused)出现
  • 关键业务事件(如 order payment failed)频率异常
  • 外部调用失败率持续升高

告警规则应在日志平台侧配置,而不是由应用代码直接触发。这样的好处是:

  • 告警逻辑与业务代码解耦
  • 支持跨服务、跨实例的聚合告警
  • 告警规则可独立于应用发布周期调整

日志生命周期管理#

日志数据应设置合理的保留策略:

  • 热存储(7-30 天):SSD,支持快速检索和即时排障
  • 温存储(30-90 天):HDD/对象存储,检索较慢但成本更低
  • 冷存储(90 天-数年):归档存储(如 S3 Glacier),满足合规审计要求
  • 删除:超过保留期的日志自动清理,控制存储成本

OpenSearch/Elasticsearch 的 ILM(Index Lifecycle Management)可自动管理这一流程,无需手动干预。