MDC 加 ELK 全链路日志系统建设

1. 对应简历段落

对应简历中可以这样描述:

在微服务稳定性治理中,负责建设基于 MDC、Logback、Filebeat、Logstash、Elasticsearch、Kibana 的全链路日志系统。通过 traceId、spanId、userId、bizId、eventId 等上下文字段贯穿 HTTP 请求、Feign 调用、MQ 生产消费和异步线程,统一 JSON 日志格式、索引模板、脱敏规则和查询看板,显著提升商机流转、活动报名、出单回写等跨服务问题的定位效率。

这段经历强调的是可观测性。资深 Java 面试中,日志系统不是“把日志打到 ES”,而是如何让一次业务请求在多个线程、多个服务、多个中间件之间被串起来,并且能在故障时快速缩小范围。

2. 业务背景

商机和营销系统往往是典型微服务链路:入口网关接收请求,活动服务处理报名,客户中心建档,商机中心生成商机,销售工作台生成待办,RocketMQ 发送事件,报表消费者异步落库。任何一个环节失败,业务方看到的可能只是“报名后没有待办”或“出单后商机没更新”。

如果每个服务只打普通文本日志,排查就会非常痛苦。你需要按时间猜测请求,登录多台机器 grep,查完 HTTP 日志再查 MQ 日志,再查消费者日志。遇到高峰期日志量大,几乎无法定位。

全链路日志系统的目标是:拿到一个 traceIdbizIdeventId,就能查到这次请求从入口到下游、从同步到异步的完整路径。它不替代链路追踪系统,但能作为最基础、最可控、最贴近业务的排查工具。

3. 核心原理

MDC 是 SLF4J 提供的线程上下文机制,本质上是基于 ThreadLocal 存储一组 key-value。日志框架输出日志时,可以把 MDC 中的字段写入日志。常见字段包括:

  1. traceId:一次入口请求或业务链路的全局追踪 ID。
  2. spanId:链路中的局部调用片段。
  3. userId:当前登录用户。
  4. bizId:业务主键,如商机 ID、订单 ID、保单 ID。
  5. eventId:MQ 事件 ID。
  6. source:来源系统或渠道。

ELK 负责采集、解析、存储和查询。应用输出 JSON 日志到文件,Filebeat 采集并发送到 Logstash 或 Elasticsearch,Logstash 做解析、过滤、脱敏和路由,Elasticsearch 建索引,Kibana 用于检索和看板。

关键点是上下文传递。MDC 默认只在当前线程有效,跨线程池、异步任务、Feign 调用、MQ 消息都需要显式传递。否则入口日志有 traceId,异步消费者又变成另一条孤立日志。

4. 项目落地

落地可以分四层。

第一层是入口生成 traceId。在网关或服务 Filter 中读取请求头 X-Trace-Id,如果没有则生成一个新的 traceId,写入 MDC,并在响应头返回。这样前端、网关和后端可以共享同一个 ID。

第二层是服务间传递。Feign 或 RestTemplate 拦截器从 MDC 读取 traceId、userId、bizId,写入 HTTP Header。下游 Filter 再从 Header 恢复 MDC。

第三层是 MQ 传递。生产者发送消息时,把 traceId、eventId 放入消息 Header 和消息体;消费者收到消息后,先把这些字段放入 MDC,再执行业务逻辑。消费结束必须清理 MDC,避免线程复用导致串日志。

第四层是日志采集与索引。应用统一输出 JSON 格式,每条日志包含 timestamplevelservicehosttraceIdbizIdeventIdmessageexception 等字段。ES 索引按环境、服务、日期拆分,例如 app-prod-opportunity-2026.05.01

业务上可以建几个常用查询:按 traceId 查全链路,按 opportunityId 查商机流转,按 eventId 查 MQ 生产消费,按 error level 查异常,按接口统计错误率和耗时。

5. 关键配置或伪代码

入口 Filter:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
    HttpServletRequest request = (HttpServletRequest) req;
    String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
            .filter(StringUtils::hasText)
            .orElse(IdGenerator.traceId());
    try {
        MDC.put("traceId", traceId);
        MDC.put("path", request.getRequestURI());
        chain.doFilter(req, res);
    } finally {
        MDC.clear();
    }
}

Feign 拦截器:

public void apply(RequestTemplate template) {
    copyHeader(template, "X-Trace-Id", MDC.get("traceId"));
    copyHeader(template, "X-User-Id", MDC.get("userId"));
    copyHeader(template, "X-Biz-Id", MDC.get("bizId"));
}

线程池装饰器:

public Runnable decorate(Runnable task) {
    Map<String, String> context = MDC.getCopyOfContextMap();
    return () -> {
        Map<String, String> previous = MDC.getCopyOfContextMap();
        try {
            if (context != null) {
                MDC.setContextMap(context);
            }
            task.run();
        } finally {
            if (previous != null) {
                MDC.setContextMap(previous);
            } else {
                MDC.clear();
            }
        }
    };
}

Logback JSON 字段示例:

{
  "time": "%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}",
  "level": "%level",
  "service": "${spring.application.name}",
  "thread": "%thread",
  "traceId": "%X{traceId}",
  "bizId": "%X{bizId}",
  "eventId": "%X{eventId}",
  "logger": "%logger{36}",
  "message": "%message",
  "exception": "%exception"
}

6. 常见坑

第一个坑是忘记清理 MDC。线程池线程会复用,如果请求结束不 clear,下一个请求可能带上前一个请求的 traceId,排查会被误导。

第二个坑是异步线程丢上下文。@Async、线程池、CompletableFuture、定时任务都不会自动继承 MDC,需要包装任务或使用支持上下文传递的组件。

第三个坑是 MQ 链路断掉。生产者只在日志里有 traceId,但消息体和 Header 没带,消费者就无法恢复上下文。

第四个坑是日志格式不统一。有的服务文本日志,有的服务 JSON,有的字段叫 traceId,有的叫 trace_id,最终 Kibana 很难统一查询。

第五个坑是 ES 索引无节制。日志量大时如果不按日期、服务分索引,不设置生命周期和冷热策略,ES 很快会磁盘告警。

第六个坑是敏感信息泄露。客户手机号、身份证、保单号、银行卡、第三方 token 不能明文输出,要在日志框架或 Logstash 层脱敏。

7. 面试追问

常见追问包括:

  1. MDC 的底层原理是什么?
  2. 为什么异步线程会丢 traceId?
  3. 如何让 Feign 和 MQ 传递 traceId?
  4. 日志系统和 SkyWalking、Zipkin 有什么区别?
  5. ES 日志索引如何设计?
  6. 日志量太大怎么办?
  7. 如何做敏感字段脱敏?
  8. 如何通过日志排查一次 MQ 消费失败?

这些问题都要求你讲出工程细节。只说“我们用 ELK 查日志”是不够的。

8. 推荐回答

可以这样回答:

“我们建设全链路日志时,核心是统一上下文字段。入口 Filter 生成或透传 traceId,放到 MDC;日志输出 JSON 时把 MDC 字段一起写出。服务间调用通过 Feign 拦截器把 traceId 放到 Header,下游再恢复到 MDC。MQ 生产时把 traceId 和 eventId 放到消息 Header,消费者收到后先写 MDC 再处理业务。所有 finally 里都会清理 MDC,避免线程复用串上下文。”

“ELK 侧我们统一日志格式和索引规范,按环境、服务、日期建索引。Kibana 可以按 traceId 查一次请求全链路,也可以按 bizId 查商机或订单的完整流转,按 eventId 查 MQ 从生产到消费的日志。对于异常日志,会保留 exception 栈,但手机号、证件号、token 等字段会在应用或 Logstash 层脱敏。”

“我也会说明它和 APM 的关系。SkyWalking 这类链路追踪更适合看拓扑、调用耗时和依赖关系;ELK 日志更适合看业务字段、异常细节和人工排查。两者不是替代关系。很多生产问题最终还是要靠 traceId 把链路追踪、业务日志、MQ 事件和数据库记录串起来。”

9. 延伸学习路线

第一步,理解 SLF4J、Logback、MDC、Appender、Encoder、异步日志的基本机制。

第二步,掌握 traceId 在 HTTP、RPC、MQ、线程池、定时任务中的传递方式,特别是 ThreadLocal 在线程复用下的风险。

第三步,学习 ELK 或 EFK 架构,包括 Filebeat、Logstash、Elasticsearch、Kibana、索引模板和生命周期管理。

第四步,补充可观测性体系,把日志、指标、链路追踪、告警和业务审计放在一起看。

第五步,做几次故障排查演练:接口超时、MQ 消费失败、状态不一致、线程池耗尽。每次都用 traceId 或 bizId 从入口查到根因,形成面试中能讲的真实闭环。