← BACK TO BLOG

高并发服务日志乱序?用请求关联把排查理顺

高并发下多请求日志互相穿插是常态。本文从根因出发,介绍用 TraceId / MDC / 结构化日志把一次请求串起来的最佳实践。

次阅读

背景

服务一上量,日志就变"乱"。你盯着某次下单失败,屏幕上却是 A 请求的入参、B 请求的 SQL、C 请求的异常堆栈搅在一起——按时间顺序往下翻,几乎无法还原"这一次请求"到底经历了什么。

这不是日志框架坏了,而是共享输出流 + 并发执行下的必然现象。真正要解决的,不是让日志"按请求顺序打印",而是让每条日志都能被准确归属到某一次请求

为什么会乱序

多数服务日志最终写到同一条 stdout / 文件 / 采集管道。多个线程或协程同时 log.info(...),输出端只能按到达顺序落盘,于是不同请求的行自然穿插。

异步更糟:业务线程打完"开始处理",回调线程稍后才打"调用第三方成功",中间早已塞进几十条别人的日志。若没有关联字段,人眼几乎无法重组因果链。

所以目标可以收敛成一句话:

给每次请求一个稳定 ID,并让整条调用链上的每一条日志都带上它。

核心实践:请求级关联 ID

业界常见叫法是 requestIdtraceIdcorrelationId。入口(网关、Filter、Middleware)生成或透传一个 ID,后续所有日志、下游 RPC、异步任务都携带它。

一个最小可用约定:

  • 入口优先读上游传入的 X-Request-Id / traceparent;没有则自行生成 UUID
  • 响应头回写同一 ID,方便前端、客服、联调方反馈
  • 业务日志、错误上报、审计记录统一带这个字段

有了 ID,乱序不再可怕:在 ELK、Loki、云日志里按 traceId=xxx 一过滤,就是一次完整请求的故事线。

把 ID"粘"在上下文上:MDC / 等价物

如果每行日志都手写 log.info("[{}] xxx", requestId, ...),漏带、异步丢上下文几乎必然。更好的做法是把 ID 放进请求上下文,由日志框架自动注入。

Java:MDC + Filter

Java 里这套机制叫 MDC(Mapped Diagnostic Context)。原理、API、异步传播与生产踩坑见专文:Java MDC 详解:Mapped Diagnostic Context。下面给一个入口 Filter 的最小示例。

java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {

  public static final String TRACE_ID = "traceId";
  public static final String HEADER = "X-Request-Id";

  @Override
  protected void doFilterInternal(
      HttpServletRequest request,
      HttpServletResponse response,
      FilterChain chain) throws ServletException, IOException {

    String traceId = request.getHeader(HEADER);
    if (traceId == null || traceId.isBlank()) {
      traceId = UUID.randomUUID().toString().replace("-", "");
    }

    MDC.put(TRACE_ID, traceId);
    response.setHeader(HEADER, traceId);
    try {
      chain.doFilter(request, response);
    } finally {
      MDC.clear(); // 线程池复用时必须清理,否则会串请求
    }
  }
}

日志 pattern 里带上 MDC:

code
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [traceId=%X{traceId}] - %msg%n

业务代码继续写普通日志即可,无需到处传 traceId

注意线程池与异步@Async、线程池、Reactor、WebFlux 不会自动继承 MDC。需要:

  • 包装 Executor,提交任务前拷贝 MDC.getCopyOfContextMap(),执行时 MDC.setContextMap(...),结束后 clear
  • 或改用支持上下文传播的观测库(如 OpenTelemetry)

完整的异步传播写法与坑点见 Java MDC 详解

Node.js:AsyncLocalStorage

js
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'

export const requestContext = new AsyncLocalStorage()

export function withRequestContext(req, res, next) {
  const traceId = req.headers['x-request-id'] || randomUUID()
  res.setHeader('X-Request-Id', traceId)
  requestContext.run({ traceId }, next)
}

export function log(level, message, extra = {}) {
  const { traceId } = requestContext.getStore() || {}
  console.log(JSON.stringify({ level, message, traceId, ...extra, ts: Date.now() }))
}

只要请求进入 run 作用域,后续 await、Promise 回调里都能读到同一 traceId

结构化日志:让过滤成为一等能力

纯文本日志里用正则抠 traceId 脆弱且慢。高并发服务更推荐 JSON 一行一条:

json
{
  "ts": "2026-08-13T15:42:01.123+08:00",
  "level": "ERROR",
  "msg": "pay failed",
  "traceId": "a1b2c3d4",
  "userId": 10086,
  "orderId": "O20260813001",
  "costMs": 312
}

采集到 Loki / ELK / ClickHouse 后,可以稳定按字段查询、聚合、告警。traceId 负责串请求;userIdorderId 负责从业务侧反查。两者都要,不要只靠其中一个。

再进一步:分布式追踪

单机 MDC 解决"一个进程内日志穿插"。服务一拆成多个,还需要跨服务透传同一 traceId,并同时落链路与日志:

flowchart TB
  Client[客户端 / 前端] -->|X-Request-Id / traceparent| GW[网关 / Filter<br/>生成或透传 TraceId]

  subgraph Services["业务服务 — 共享同一 TraceId"]
    direction LR
    A["订单服务<br/>spanId=A"]
    B["支付服务<br/>spanId=B"]
    C["库存服务<br/>spanId=C"]
  end

  GW --> A
  A -->|HTTP / gRPC 透传| B
  A -->|HTTP / gRPC 透传| C

  subgraph Observability["可观测平台"]
    OTel[OpenTelemetry Collector]
    Trace[Jaeger / Tempo / SkyWalking]
    Log[Loki / ELK / ClickHouse]
  end

  A & B & C -->|Span / OTLP| OTel
  A & B & C -->|结构化日志 + TraceId| Log
  OTel --> Trace
  Trace -.->|同一 TraceId 关联| Log

要点:

  • 入口生成 traceId,下游 HTTP/gRPC 透传(W3C traceparent 或 B3)
  • 每个服务再记自己的 spanId,组成调用树
  • 用 OpenTelemetry + Jaeger / Tempo / SkyWalking 看链路,用日志平台按同一 traceId 看细节

日志回答"发生了什么",链路回答"经过了谁、卡在哪"。两者用同一个 traceId 打通,排障效率会高一个数量级。

落地清单

按优先级推进即可,不必一次上全套可观测平台:

  1. 入口统一生成 / 透传 TraceId,响应头回写
  2. 日志自动注入 TraceId(MDC / ALS / ctx),禁止靠人肉拼接
  3. 异步与线程池做上下文传播,并在 finally 清理
  4. 日志改为结构化输出,关键业务主键一并落字段
  5. 多服务时上 OpenTelemetry,日志与 Trace 共用 ID
  6. 规范日志内容:少打大对象全文,多打状态变更与失败原因;敏感信息脱敏

小结

高并发下日志穿插是物理事实,不是配置没调好。最佳实践不是强行"按请求顺序写盘",而是:

用 TraceId 给每次请求立户口,用上下文机制自动盖章,用结构化字段让检索变成点选。

做到这三步,乱序日志从"无法阅读"变成"可以精确过滤"——排查问题,靠的是关联,而不是滚动条。