背景
服务一上量,日志就变"乱"。你盯着某次下单失败,屏幕上却是 A 请求的入参、B 请求的 SQL、C 请求的异常堆栈搅在一起——按时间顺序往下翻,几乎无法还原"这一次请求"到底经历了什么。
这不是日志框架坏了,而是共享输出流 + 并发执行下的必然现象。真正要解决的,不是让日志"按请求顺序打印",而是让每条日志都能被准确归属到某一次请求。
为什么会乱序
多数服务日志最终写到同一条 stdout / 文件 / 采集管道。多个线程或协程同时 log.info(...),输出端只能按到达顺序落盘,于是不同请求的行自然穿插。
异步更糟:业务线程打完"开始处理",回调线程稍后才打"调用第三方成功",中间早已塞进几十条别人的日志。若没有关联字段,人眼几乎无法重组因果链。
所以目标可以收敛成一句话:
给每次请求一个稳定 ID,并让整条调用链上的每一条日志都带上它。
核心实践:请求级关联 ID
业界常见叫法是 requestId、traceId、correlationId。入口(网关、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 的最小示例。
@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:
%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
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 一行一条:
{
"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 负责串请求;userId、orderId 负责从业务侧反查。两者都要,不要只靠其中一个。
再进一步:分布式追踪
单机 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 透传(W3Ctraceparent或 B3) - 每个服务再记自己的
spanId,组成调用树 - 用 OpenTelemetry + Jaeger / Tempo / SkyWalking 看链路,用日志平台按同一
traceId看细节
日志回答"发生了什么",链路回答"经过了谁、卡在哪"。两者用同一个 traceId 打通,排障效率会高一个数量级。
落地清单
按优先级推进即可,不必一次上全套可观测平台:
- 入口统一生成 / 透传 TraceId,响应头回写
- 日志自动注入 TraceId(MDC / ALS / ctx),禁止靠人肉拼接
- 异步与线程池做上下文传播,并在
finally清理 - 日志改为结构化输出,关键业务主键一并落字段
- 多服务时上 OpenTelemetry,日志与 Trace 共用 ID
- 规范日志内容:少打大对象全文,多打状态变更与失败原因;敏感信息脱敏
小结
高并发下日志穿插是物理事实,不是配置没调好。最佳实践不是强行"按请求顺序写盘",而是:
用 TraceId 给每次请求立户口,用上下文机制自动盖章,用结构化字段让检索变成点选。
做到这三步,乱序日志从"无法阅读"变成"可以精确过滤"——排查问题,靠的是关联,而不是滚动条。