为什么需要结构化日志
传统文本日志:
2026-11-05 06:32:00 INFO [http-nio-8080-exec-1] OrderService - Created order #123 for user alice
问题:
- 需要正则提取字段
- 多行异常堆栈难以解析
- 无标准字段名(有的用
"user",有的用"userId") - 无法被日志系统(ELK、Loki)高效索引
结构化日志输出 JSON,每个字段明确:
{
"@timestamp": "2026-11-05T06:32:00.123Z",
"level": "INFO",
"logger_name": "com.plume.eventd.OrderService",
"message": "Created order",
"order_id": 123,
"user_id": "alice",
"request_id": "req-456",
"trace_id": "abc-123",
"duration_ms": 45
}
Logstash Encoder 配置
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>9.0</version>
</dependency>
<!-- logback-spring.xml -->
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<includeMdcKeyName>traceId</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</configuration>
MDC 在响应式中的问题
MDC(Mapped Diagnostic Context)基于 ThreadLocal:
MDC.put("requestId", "req-456"); // 绑定到当前线程
log.info("Processing order"); // 日志自动包含 requestId
MDC.clear();
在响应式链中,操作可能在不同线程执行:
Mono.just(order)
.map(this::validate) // 线程 A
.publishOn(Schedulers.boundedElastic())
.flatMap(this::save) // 线程 B —— MDC 丢失了!
.subscribe();
contextWrite 替代方案
用 Reactor Context 传递日志上下文:
import reactor.util.context.Context;
public Mono<Order> processOrder(String requestId, Order order) {
return Mono.just(order)
.flatMap(this::validate)
.flatMap(this::save)
.doOnNext(saved -> {
// 从 Context 读取并记录
log.info("Order saved: id={}", saved.id());
})
// contextWrite 必须放在链尾(离 subscribe 最近),它写入的 Context
// 才能被上面的 flatMap、doOnNext 等所有操作符看到
.contextWrite(Context.of("requestId", requestId, "userId", order.userId()));
}
Context 的传播方向是从订阅端往上游走,所以 contextWrite 放在链中间只会影响它上游的操作符;放在 flatMap 之前,flatMap 自己反而读不到 requestId。日志框架没有「自动从 Reactor Context 取字段」的能力,MDC 也不会因为 contextWrite 就自动填充。可以把日志放进链内,用信号携带的 ContextView 读上下文:
public <T> Mono<T> logOnNext(Mono<T> mono, Function<T, String> message) {
return mono.doOnEach(signal -> {
if (!signal.isOnNext()) {
return;
}
Map<String, String> mdc = new HashMap<>();
ContextView ctx = signal.getContextView();
ctx.getOrEmpty("requestId").ifPresent(v -> mdc.put("requestId", v.toString()));
ctx.getOrEmpty("userId").ifPresent(v -> mdc.put("userId", v.toString()));
MDC.setContextMap(mdc);
try {
log.info(message.apply(signal.get()));
} finally {
MDC.clear();
}
});
}
关键是使用 doOnEach 的 signal.getContextView():信号本身带着订阅时的 Context。不要在普通方法里写 Mono.deferContextual(...).subscribe()——那是一次新的订阅,Context 是空的,读不到调用方的 requestId。
WebFlux 自动 MDC 传播
Spring Boot 3.2+ 会通过 Micrometer Tracing 把 traceId / spanId 放进 MDC,但不会自动把任意的 requestId 从 ServerWebExchange 提取进去:
logging:
pattern:
level: "%5p [traceId=%X{traceId:-} spanId=%X{spanId:-}]"
要记录 requestId,可以在过滤器里写 Reactor Context,再由日志调用点显式读取;也可以在同步边界内临时写 MDC。contextWrite 本身只写 Reactor Context,不会触发 MDC:
@Component
public class MdcFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String requestId = (String) exchange.getAttribute("requestId");
if (requestId == null) {
return chain.filter(exchange);
}
// Context.of 不接受 null 值,必须先判空
return chain.filter(exchange)
.contextWrite(Context.of("requestId", requestId));
}
}
如果希望 MDC 在跨线程后自动恢复,需要引入 Micrometer Context Propagation 并调用 Hooks.enableAutomaticContextPropagation(),同时为自定义键注册 ThreadLocalAccessor;只靠 contextWrite 做不到。下面的写法仅作上下文传递示意:
@Component
public class MdcFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String requestId = (String) exchange.getAttribute("requestId");
if (requestId == null) {
return chain.filter(exchange);
}
return chain.filter(exchange)
.contextWrite(Context.of("requestId", requestId))
.doOnEach(signal -> {
if (signal.isOnError()) {
log.error("request failed requestId={}", requestId, signal.getThrowable());
}
});
}
}
日志采样
高流量服务不需要记录每条请求。按条件采样:
@Component
public class SamplingLogger {
private final Logger log = LoggerFactory.getLogger(getClass());
public void debugSampled(String message, double rate) {
// ThreadLocalRandom 每次在当前线程取,不能存成字段
if (ThreadLocalRandom.current().nextDouble() < rate) {
log.debug(message);
}
}
}
// 使用:只记录 1% 的 DEBUG 日志
samplingLogger.debugSampled("Detailed processing info", 0.01);
敏感字段脱敏
日志中不能输出密码、token、信用卡号:
public record User(
Long id,
String name,
@JsonIgnore String password, // JSON 序列化忽略
@JsonIgnore String token
) {}
// 或使用 toString 过滤
@Override
public String toString() {
return "User{id=" + id + ", name=" + name + "}";
}
对于通用脱敏,用 Logstash Encoder 的 MaskingJsonGeneratorDecorator 按字段路径遮蔽。LogstashEncoder 没有 setValueMasker 方法,配置写在 logback XML 里:
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
<defaultMask>****</defaultMask>
<path>password</path>
<path>token</path>
<path>user.creditCard</path>
</jsonGeneratorDecorator>
</encoder>
LogstashEncoder 没有 setValueMasker 这类 API;遮蔽能力来自底层 formatter 的 JsonGeneratorDecorator 链。XML 里按上面的写法配置 <jsonGeneratorDecorator> 即可;手工组装 formatter 时,则调用 AbstractCompositeJsonFormatter.addJsonGeneratorDecorator(...)。
eventd v2 日志规范
eventd v2 的日志格式:
{
"@timestamp": "2026-11-05T06:32:00.123Z",
"level": "INFO",
"service": "eventd-api",
"logger_name": "com.plume.eventd.OrderService",
"message": "Order created",
"order_id": 123,
"user_id": "alice",
"request_id": "req-456",
"trace_id": "trace-abc",
"duration_ms": 45
}
MDC 键默认按原样作为顶层字段:上面配置的是 includeMdcKeyName 的 requestId,输出字段就是 requestId。要和 request_id 这类下划线命名对齐,用 LogstashEncoder.addMdcKeyFieldName("requestId=request_id") 做映射(9.x 的格式是 mdcKeyName=fieldName);不需要再包一层 fields(除非你在 encoder 里配置了嵌套)。
所有服务统一字段名,便于 ElasticSearch/Loki 聚合查询。
日志聚合查询
Kibana/Loki 查询示例:
# 查找特定用户的错误
{service="eventd-api"} | json | user_id="alice" | level="ERROR"
# 统计每分钟订单创建量(count_over_time 的输入必须是日志范围选择器)
count_over_time({service="eventd-api"} | json | message="Order created" [1m])
# 查找延迟 > 100ms 的请求
{service="eventd-api"} | json | duration_ms > 100
小结
| 概念 | 方案 | 注意 |
|---|---|---|
| 结构化格式 | LogstashEncoder | JSON 输出 |
| 上下文传递 | Reactor Context | MDC ThreadLocal 在响应式中失效 |
| 上下文传播 | doOnEach + signal.getContextView() | contextWrite 只写 Reactor Context,不写 MDC |
| 采样 | 随机数采样 | 生产降低 DEBUG 量 |
| 脱敏 | @JsonIgnore / MaskingJsonGeneratorDecorator | 绝不记录密码/token |
| 聚合 | Kibana/Loki + JSON 解析 | 统一字段命名 |
第 10 章结束。下一节 11.1 客户端负载均衡 :自定义 LoadBalancer、加权与响应式集成。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。