排查线上 Bug 的时候,最让人崩溃的莫过于在 Kibana 里搜到一堆没有上下文的 console.log("data is null") 或者 logger.info("user login success")。你根本不知道这个 null 是哪个用户的,这行日志是哪个请求触发的,当时的调用链条长什么样。这种纯文本的、散落四处的日志,不仅无法建立追踪,反而增加了日志存储的成本,简直是在制造噪声。
在微服务或者分布式架构下,日志如果做不到结构化(Structured Logging)和调用链协同(Trace Context),那基本等于瞎子摸象。出了问题,你得在几个微服务的日志里来回肉眼对比时间戳去“猜”关联性,效率低到让人想辞职。
其实解决思路很简单:所有日志输出一律使用标准的 JSON 格式,且必须包含全局唯一的 traceId 和 spanId,再配合统一的日志格式器(Formatter)。当请求进入系统时,由网关或者入口中间件生成 Trace 标识,并存入上下文(在 Node.js 中常用 AsyncLocalStorage,在 Go 中直接塞进 context.Context),后续所有的日志库调用都会自动提取并附带这些标识。
以下是我们在 Node.js 核心服务中基于 Winston 配合 AsyncLocalStorage 封装的生产级结构化日志方案,不仅统一了 JSON 字段,还实现了调用链的自动透传:
import winston from 'winston';
import { AsyncLocalStorage } from 'async_hooks';
// 定义调用链上下文存储器
interface LogContext {
traceId: string;
spanId: string;
userId?: string;
}
export const logStorage = new AsyncLocalStorage<LogContext>();
// 封装 Winston 的格式化器,注入 Trace 字段
const traceFormat = winston.format((info) => {
const store = logStorage.getStore();
if (store) {
info.traceId = store.traceId;
info.spanId = store.spanId;
if (store.userId) {
info.userId = store.userId;
}
}
// 注入系统元数据
info.service = process.env.SERVICE_NAME || 'user-service';
info.environment = process.env.NODE_ENV || 'production';
return info;
});
// 初始化全局 Logger
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
traceFormat(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.json() // 必须输出为 JSON,方便 ELK/Loki 解析
),
transports: [
new winston.transports.Console()
]
});
// 模拟 Express 调用链追踪中间件
export function traceMiddleware(req: any, res: any, next: () => void) {
// 优先从 Header 中提取网关传过来的 TraceID,没有则自己生成
const traceId = req.headers['x-trace-id'] || `trace_${Math.random().toString(36).substring(2, 15)}`;
const spanId = req.headers['x-span-id'] || `span_${Math.random().toString(36).substring(2, 10)}`;
const context: LogContext = {
traceId,
spanId,
userId: req.query?.userId // 仅做演示,真实环境可从 Token 解析
};
// 在异步上下文中运行后续逻辑
logStorage.run(context, () => {
logger.info('Received incoming HTTP request', {
method: req.method,
path: req.path,
ip: req.ip
});
// 监听响应结束,记录请求耗时
const start = Date.now();
res.on('finish', () => {
logger.info('Request processed completed', {
statusCode: res.statusCode,
durationMs: Date.now() - start
});
});
next();
});
}
部署这套方案后,输出的日志全都是规范的单行 JSON 串:
{"message":"Received incoming HTTP request","method":"GET","path":"/api/users","ip":"::1","level":"info","traceId":"trace_abc123xyz","spanId":"span_u8s83","service":"user-service","environment":"production","timestamp":"2026-07-13 15:10:30.123"}
这样一来,不管是 Kibana 还是 Grafana Loki,都能完美解析 these key-value。开发人员只需要搜一下对应的 traceId,就能像看剧一样,把这次请求在用户服务、订单服务、支付服务里的所有行为轨迹串联得清清楚楚。
别再在项目里写 console.log 祸害队友了。既然上了微服务,就把基础设施做规范,这才是能让你提早下班的正确姿势。