日志规范与 TraceID 串联
一句话速记
日志的三条铁律:① 每条日志必须有 TraceID——用 MDC(Mapped Diagnostic Context)自动注入,不用手动拼;② 关键节点必须打点——入口(请求参数)、出口(响应)、调下游前后(耗时)、异常(完整堆栈 + 上下文);③ 日志不要打太多也不要太少——一个请求正常路径 5-8 条日志,异常路径补 2-3 条。TraceID 串联 = 网关生成 TraceID → 全链路透传 → 每个服务日志自动带 → grep <traceId> 就能串出完整请求生命周期。
通俗解释(5 分钟版)
没有 TraceID:
用户投诉:"我刚才下不了单"
你:grep 用户 ID → 5000 条日志
grep 订单号 → 100 条日志
手动对时间戳猜测哪条日志属于哪次请求
多个服务日志时间戳对不上(NTP 不同步)
→ 10 分钟过去了,还没串起来
有了 TraceID:
用户投诉:"下不了单,订单号 12345"
你:grep 订单号 → 拿到 TraceID "abc-123-def"
grep "abc-123-def" 所有服务日志 → 100 条,按时间排序
一眼看到:OrderService → CouponService → CouponService 返回了 500
→ 30 秒定位到是优惠券服务挂了
关键细节
1)TraceID 的生成和传递
生成规则:
TraceID 生成位置:网关层(或第一个接收到请求的服务)
格式建议:32 位 hex 或 UUID(不带横线)
生成示例:
UUID.randomUUID().toString().replace("-", "")
→ "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
注意:
- 不要用自增 ID(分布式冲突)
- 不要用时间戳(同一 ms 多个请求)
- 可以考虑前 8 位放机器标识 + 后 24 位放随机数(方便反查)
透传方式:
// 方式 1:HTTP Header(推荐)
// 网关生成:
String traceId = IdGenerator.generateTraceId();
request.setHeader("X-Trace-Id", traceId);
// 每个服务接收:
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null) {
traceId = IdGenerator.generateTraceId(); // 兜底:自己生成
log.warn("Missing X-Trace-Id header, generated: {}", traceId);
}
MDC.put("traceId", traceId);
// 调下游时透传:
httpRequest.setHeader("X-Trace-Id", MDC.get("traceId"));
// 方式 2:MQ 消息头
message.setProperty("X-Trace-Id", MDC.get("traceId"));
// 方式 3:gRPC metadata
metadata.put(Metadata.Key.of("X-Trace-Id", Metadata.ASCII_STRING_MARSHALLER), traceId);Spring Boot Filter 实现:
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
MDC.put("userId", request.getHeader("X-User-Id")); // 同时注入用户 ID
MDC.put("requestPath", request.getRequestURI());
response.setHeader(TRACE_ID_HEADER, traceId); // 返回给调用方
try {
chain.doFilter(request, response);
} finally {
MDC.clear(); // 必须清理!线程池复用场景尤其重要
}
}
}2)日志格式规范
Logback 配置:
<!-- logback-spring.xml -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-no-traceId}] %-5level %logger{36} - %msg%n%ex{full}
</pattern>
</encoder>
</appender>输出示例:
2024-01-15 14:30:25.123 [http-nio-8080-exec-3] [a1b2c3d4e5f6] INFO OrderService - 收到创建订单请求, orderId=12345, userId=67890
2024-01-15 14:30:25.456 [http-nio-8080-exec-3] [a1b2c3d4e5f6] INFO OrderService - 调用优惠券服务, couponCode=SAVE10
2024-01-15 14:30:27.789 [http-nio-8080-exec-3] [a1b2c3d4e5f6] ERROR OrderService - 调用优惠券服务失败, cost=2333ms
java.net.SocketTimeoutException: Read timed out
at java.net.SocketInputStream.socketRead0(Native Method)
...
3)打点规范:在哪打、打什么
每个请求必须打的 5 个点:
位置 日志级别 内容 目的
──────────────────────────────────────────────────────────────────────
请求入口 INFO 方法名 + 关键参数 + TraceID 接到什么请求
调下游前 DEBUG 下游名 + 方法 + 入参 + TraceID 谁调了谁
调下游后(成功) DEBUG 下游名 + 耗时 + 返回值摘要 + TraceID 下游耗时多少
调下游后(失败) ERROR 下游名 + 耗时 + 异常堆栈 + TraceID 为什么失败
响应出口 INFO 方法名 + 耗时 + 返回值 + TraceID 处理结果
一个完整请求的日志轨迹:
[2024-01-15 14:30:25.100] [traceId=abc123] INFO Gateway → 收到请求 /api/order/create
[2024-01-15 14:30:25.101] [traceId=abc123] INFO Gateway → 转发到 OrderService
[2024-01-15 14:30:25.102] [traceId=abc123] INFO OrderService.createOrder() 入参 userId=67890, items=[...]
[2024-01-15 14:30:25.103] [traceId=abc123] DEBUG OrderService → 调 UserService.getUser(67890)
[2024-01-15 14:30:25.150] [traceId=abc123] DEBUG OrderService ← UserService.getUser() 耗时 47ms, 返回 {...}
[2024-01-15 14:30:25.151] [traceId=abc123] DEBUG OrderService → 调 CouponService.validateCoupon(SAVE10)
[2024-01-15 14:30:29.456] [traceId=abc123] ERROR OrderService ← CouponService 超时, 耗时 4305ms
[2024-01-15 14:30:29.457] [traceId=abc123] WARN OrderService 优惠券校验降级: 跳过优惠券
[2024-01-15 14:30:29.460] [traceId=abc123] INFO OrderService.createOrder() 耗时 4358ms, 返回 orderId=ORD-2024-0567
[2024-01-15 14:30:29.461] [traceId=abc123] INFO Gateway ← 响应 200, 耗时 4361ms
grep "abc123" *.log → 完整的请求轨迹,一步到位
4)不该打日志的情况
❌ 不要在循环里打 INFO/DEBUG 日志
for (Item item : items) {
log.info("processing item: {}", item); // 10000 条日志!
}
❌ 不要打印大对象
log.info("request body: {}", requestBody); // 2MB JSON
❌ 不要打印敏感信息
log.info("user password: {}", password); // 密码、Token、身份证号
❌ 不要在 FINEST/TRACE 级别打高频日志但仍然在生产开启
即使是 DEBUG 级别,高频路径每秒几千条也会打满磁盘
✅ 日志中掩码敏感字段
log.info("user mobile: {}", maskMobile(mobile)); // 138****1234
5)日志聚合和查询
# 单机排查(紧急,没有日志平台时)
grep "abc123" /var/log/app.log /var/log/app.log.1
# 多机排查:如果日志分散在多台机器
# 方案 A:用 Ansible 批量 grep
ansible all -m shell -a "grep 'abc123' /var/log/app.log" | grep -v "SUCCESS"
# 方案 B:ELK/Loki/SLS 查询(推荐)
# Kibana / Grafana Loki:
{service="order-service"} |= "abc123" | sort @timestamp
# 方案 C:阿里云 SLS
traceId: abc123 | SELECT * FROM log ORDER BY __time__延伸追问
- Q:TraceID 和 SkyWalking 的 TraceID 是什么关系?
→ 可以统一。SkyWalking agent 会自动生成 TraceID 并注入到 MDC 的
traceIdkey。如果你自己又在 Filter 里生成了一个,可能冲突。建议:如果接入了 SkyWalking,直接用它的 TraceID(MDC.get("traceId")就能拿到),不用自己生成。如果没有全链路追踪,就自己生成。 - Q:异步线程/MQ 消费端怎么拿到 TraceID?
→ 从主线程切换到异步线程时,需要手动传递 MDC 上下文。可以用
MDC.getCopyOfContextMap()拿到当前上下文,在异步线程里MDC.setContextMap(context)恢复。MQ 场景:生产者把 TraceID 放进消息 properties,消费者从 properties 取出放入 MDC。 - Q:日志花了太多磁盘空间,怎么控制? → ① 调整日志级别(生产用 INFO,调下游用 DEBUG 但在 logback 里单独控制);② 缩短保留时间(本地 3 天,ELK 30 天);③ 开启压缩轮转;④ 别把大响应体打到日志里。
我的记法
- 三条铁律:每条日志带 TraceID、关键节点必打点、不多不少刚好够
- TraceID 一条龙:网关生成 → Header 透传 → MDC 注入 → 日志自动带 → grep 串联
- 5 个必打点:入口、调下游前、调下游后、异常、出口
- 一句话:「没有 TraceID 的日志就是废纸——对不上请求,找不到上游,没法串链路」
状态
- 已背速记
- 能讲通俗版
- 能答追问