Spring Boot 3.4+ 结构化日志实践记录:用 ECS + Fluent API
目录
为什么我开始关注结构化输出#
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.id 和 span.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 内建支持
- 极致性能调优:需要精确控制
AsyncAppender的queueSize、discardingThreshold、neverBlock等参数,或者使用 LMAX Disruptor 替代标准队列 - 多 profile 下完全不同的 Appender 组合:
logback-spring.xml的<springProfile>标签在复杂的多环境差异场景下比 YAML 多文件更直观
上述场景在多数微服务项目里通常属于少数。我现在更倾向于先依赖 YAML 配置,真正遇到这些需求时再引入 XML,而不是提前引入复杂度。
Demo 项目#
本文所有配置和代码示例均来自配套 Demo 项目,与《日志开发实践记录》共用同一个仓库。
仓库结构:
modules/structured-logging/— 本文的 ECS 配置和 Fluent API 示例modules/logging-guidelines/— 日志实践文章的代码参考实现