为什么我开始关注结构化输出#

Spring Boot 默认的 Logback 输出是人类可读的文本行,形如:

2026-04-07 10:00:00.123  INFO 12345 --- [main] c.e.OrderService : order created orderId=ORD-001

这在本地开发时没有问题,但一旦日志进入 OpenSearch,文本行只能靠全文检索(message: *ORD-001*)来定位,无法按字段过滤、聚合统计或设置告警规则。当并发量上来,同一秒内几百条日志里找一个 orderId 靠的是运气而不是索引。

解决这个问题的方式是让每条日志以结构化 JSON 格式输出,每个上下文值都成为独立字段,可以被 OpenSearch 精确索引和查询。Spring Boot 3.4 正式将这个能力内建进框架,开发者不再需要引入 logstash-logback-encoder 或手写复杂的 XML encoder 配置。

5 行配置启用 ECS 结构化输出#

ECS(Elastic Common Schema)是 Elastic 定义的日志字段规范,OpenSearch 原生兼容。Spring Boot 3.4+ 内建了 ECS 格式支持,至少在这个场景里,只需要在 application.yml 里声明:

spring:
  application:
    name: order-service

logging:
  structured:
    format:
      # console 不设置:本地开发保留 Logback 默认彩色文本(不设置即为默认行为)
      # console: ecs  # 容器环境如需从 stdout 采集,将此行取消注释
      file: ecs          # 生产输出:ECS JSON
  file:
    name: logs/app.json

两个格式可以共存:console 保留彩色文本方便本地联调,file 输出 ECS JSON 供采集器(Fluent Bit、Filebeat)读取后推送到 OpenSearch。生产环境如果控制台也需要结构化输出(容器日志直接采集 stdout),把 console 也改成 ecs 即可。

启动后 logs/app.json 里的每条记录是标准 ECS JSON:

{
  "@timestamp": "2026-04-07T10:00:00.123Z",
  "log.level": "INFO",
  "message": "order created",
  "service.name": "order-service",
  "process.pid": 12345,
  "log.logger": "com.example.order.OrderService",
  "log.origin": {
    "function": "createOrder",
    "file.line": 42
  }
}

字段名遵循 ECS 规范,OpenSearch 的 ECS 索引模板通常可以直接识别,省掉不少手动 mapping 工作。

用 Fluent API 写出 OpenSearch 可查询的字段#

ECS 提供了字段容器,Fluent API 负责把业务上下文填进去。SLF4J 2.0 引入的 log.atLevel().addKeyValue() 写法,在 Spring Boot 的 ECS encoder 下会把每个 addKeyValue() 变成 JSON 里的独立字段,可以被 OpenSearch 独立索引。

场景一:外部调用完成#

long start = System.currentTimeMillis();
try {
    PaymentResponse response = paymentGateway.charge(request);
    long elapsed = System.currentTimeMillis() - start;

    log.atInfo()
        .setMessage("payment gateway call completed")
        .addKeyValue("orderId", orderId)
        .addKeyValue("externalService", "payment-gateway")
        .addKeyValue("statusCode", response.getStatusCode())
        .addKeyValue("durationMs", elapsed)
        .log();

} catch (GatewayException e) {
    long elapsed = System.currentTimeMillis() - start;

    log.atError()
        .setMessage("payment gateway call failed")
        .addKeyValue("orderId", orderId)
        .addKeyValue("externalService", "payment-gateway")
        .addKeyValue("statusCode", e.getStatusCode())
        .addKeyValue("durationMs", elapsed)
        .setCause(e)
        .log();
}

输出的 ECS JSON(成功路径):

{
  "@timestamp": "2026-04-07T10:00:00.456Z",
  "log.level": "INFO",
  "message": "payment gateway call completed",
  "orderId": "ORD-2026-001",
  "externalService": "payment-gateway",
  "statusCode": 200,
  "durationMs": 134,
  "service.name": "order-service"
}

在 OpenSearch Discover 里,这条日志可以用 KQL 精确查询:

# 查某笔订单的所有支付日志
orderId: "ORD-2026-001" AND externalService: "payment-gateway"

# 查支付网关的慢请求(超过 500ms)
externalService: "payment-gateway" AND durationMs >= 500

# 查支付网关的所有失败
externalService: "payment-gateway" AND log.level: "ERROR"

# 聚合:过去 1 小时支付网关的错误率
# 在 Discover 的 Lens 或 Aggregation 面板里选 statusCode 字段做 Terms 聚合

场景二:关键状态变化#

orderRepository.updateStatus(orderId, OrderStatus.PAID);

log.atInfo()
    .setMessage("order status changed")
    .addKeyValue("orderId", orderId)
    .addKeyValue("fromStatus", "PENDING")
    .addKeyValue("toStatus", "PAID")
    .addKeyValue("userId", maskUserId(userId))
    .log();

对应的 KQL 查询:

# 查某订单的完整状态流转历史
orderId: "ORD-2026-001" AND message: "order status changed"

# 查所有进入 FAILED 状态的订单(排查批量失败)
toStatus: "FAILED"

# 查某用户今日的订单状态变化
userId: "usr_**01" AND toStatus: *

场景三:异常记录#

try {
    inventoryService.deduct(skuId, quantity);
} catch (InsufficientStockException e) {
    log.atWarn()
        .setMessage("order rejected: insufficient stock")
        .addKeyValue("orderId", orderId)
        .addKeyValue("skuId", skuId)
        .addKeyValue("requested", quantity)
        .addKeyValue("available", e.getAvailable())
        .setCause(e)   // 保留完整堆栈,避免只留下简化消息
        .log();
    return OrderResult.rejected("INSUFFICIENT_STOCK");
}

setCause(e) 让 ECS encoder 把完整堆栈序列化为 error.stack_trace 字段,不会污染 message,也不会截断。OpenSearch 里可以单独过滤:

# 查所有库存不足的拒单
message: "order rejected: insufficient stock"

# 查某 SKU 的缺货频率
skuId: "SKU-8888" AND message: "order rejected: insufficient stock"

# 查含异常堆栈的 WARN 日志
log.level: "WARN" AND error.stack_trace: *

addKeyValue() 的边界说明#

addKeyValue() 把字段写入当前日志事件,不修改 MDC。字段是否出现在 ECS JSON 里,由当前激活的 encoder 决定——Spring Boot 3.4+ 的内建 ECS encoder 自动支持这些字段;如果项目中还残留旧的自定义 PatternLayout,字段不会出现,需要在 pattern 里显式加 %kvp,或者切换到内建结构化输出后才生效。

精细化定制:不写 XML,只改 YAML#

Spring Boot 3.4+ 提供了 logging.structured.json.*logging.structured.ecs.* 两组属性,覆盖大多数生产定制需求。

注入固定字段#

logging:
  structured:
    json:
      add:
        environment: production
        team: platform-engineering
        region: ap-southeast-1

每条日志都会带上这三个字段,OpenSearch 里可以直接按环境或团队过滤,不需要在每次 addKeyValue() 里手动加。

重命名字段适配索引模板#

如果 OpenSearch 的索引模板已经定义了自定义字段名(例如把 @timestamp 映射为 time),可以通过 rename 对齐:

logging:
  structured:
    json:
      rename:
        "@timestamp": "time"
        "log.level": "level"

ECS 服务元数据#

logging:
  structured:
    ecs:
      service:
        name: ${spring.application.name}
        version: ${project.version:unknown}
        environment: ${spring.profiles.active:prod}
        node-name: ${HOSTNAME:unknown}

node-name 在排查特定 Pod 或实例问题时非常有用,尤其是水平扩展后需要定位到某个节点的日志。

控制异常堆栈#

默认的堆栈输出可能很长,进入 OpenSearch 后占用大量存储。logging.structured.json.stacktrace.* 可以精确控制:

logging:
  structured:
    json:
      stacktrace:
        max-length: 3000             # 单条堆栈最大字符数
        max-throwable-depth: 20      # cause chain 最大深度
        include-common-frames: false # 排除 JDK/Spring 框架层的重复帧
        include-hashes: true         # 为堆栈生成哈希,便于聚合同类异常

include-hashes: true 是排查高频异常时特别有用的选项:同一行代码抛出的异常会有相同的 error.stack_trace.hash,OpenSearch 里可以直接按哈希聚合,看哪类异常最多,而不是逐条看日志文本。

完整生产配置参考#

spring:
  application:
    name: order-service

logging:
  level:
    root: INFO
    com.example: INFO
  structured:
    format:
      # console 不设置:本地开发保留默认彩色文本(CI/容器需从 stdout 采集时改为 ecs)
      # console: ecs
      file: ecs
    ecs:
      service:
        name: ${spring.application.name}
        version: ${project.version:unknown}
        environment: ${spring.profiles.active:prod}
        node-name: ${HOSTNAME:unknown}
    json:
      add:
        team: platform-engineering
      stacktrace:
        max-length: 3000
        max-throwable-depth: 20
        include-common-frames: false
        include-hashes: true
  file:
    name: logs/app.json

management:
  tracing:
    sampling:
      probability: 0.1   # 生产采样率,按流量调整

traceId 自动注入#

添加 Micrometer Tracing 依赖后,trace.idspan.id 会自动出现在每条 ECS JSON 里,无需任何额外配置:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

输出示例:

{
  "@timestamp": "2026-04-07T10:00:01.789Z",
  "log.level": "INFO",
  "message": "order created",
  "orderId": "ORD-2026-001",
  "trace.id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span.id": "00f067aa0ba902b7",
  "service.name": "order-service"
}

在 OpenSearch 里就可以用 trace.id 串联同一请求的所有服务日志,跨服务排障时会比单纯对时间戳轻松很多。完整的 Micrometer Tracing + OpenTelemetry 配置见 Spring Boot 3.5 Tracing 实践记录

什么时候仍然需要 logback-spring.xml#

以下场景超出了 YAML 属性的覆盖范围,需要保留或编写 XML:

  • 按日志类型分流输出:例如审计日志写到单独文件,业务日志写到另一个文件,需要用 SiftingAppender 或条件路由配置
  • 接入自定义第三方 Appender:例如直接推送到 Kafka、Redis 或自研日志平台的 Appender,没有 Spring Boot 内建支持
  • 极致性能调优:需要精确控制 AsyncAppenderqueueSizediscardingThresholdneverBlock 等参数,或者使用 LMAX Disruptor 替代标准队列
  • 多 profile 下完全不同的 Appender 组合logback-spring.xml<springProfile> 标签在复杂的多环境差异场景下比 YAML 多文件更直观

上述场景在多数微服务项目里通常属于少数。我现在更倾向于先依赖 YAML 配置,真正遇到这些需求时再引入 XML,而不是提前引入复杂度。

Demo 项目#

本文所有配置和代码示例均来自配套 Demo 项目,与《日志开发实践记录》共用同一个仓库。

仓库结构:

  • modules/structured-logging/ — 本文的 ECS 配置和 Fluent API 示例
  • modules/logging-guidelines/ — 日志实践文章的代码参考实现

参考资料#