10.3 结构化日志

响应式系统结构化日志完整方案:Logstash Logback Encoder JSON 格式输出、MDC ThreadLocal 在 Reactor 中的局限、Reactor Context 替代方案与 contextWrite 传播、WebFilter 自动 MDC 注入、日志采样与敏感字段脱敏。

为什么需要结构化日志

传统文本日志:

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

小结

概念方案注意
结构化格式LogstashEncoderJSON 输出
上下文传递Reactor ContextMDC ThreadLocal 在响应式中失效
上下文传播doOnEach + signal.getContextView()contextWrite 只写 Reactor Context,不写 MDC
采样随机数采样生产降低 DEBUG 量
脱敏@JsonIgnore / MaskingJsonGeneratorDecorator绝不记录密码/token
聚合Kibana/Loki + JSON 解析统一字段命名

第 10 章结束。下一节 11.1 客户端负载均衡 :自定义 LoadBalancer、加权与响应式集成。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南