Skip to main content

日志规范与链路追踪实践

在生产环境中,排查问题最直接的手段就是查日志。但如果日志打得杂乱无章,不仅无法帮助排查,还会成为性能瓶颈。本文将结合 Node.js 代码示例,带你构建一套生产级的日志体系。

一、为什么生产环境要告别 console.log

为什么抛弃 console.log

很多新手喜欢用 console.log 打天下,但这在生产环境中存在致命缺陷:

  1. 性能极差console.log 在 Node.js 中通常是同步阻塞的(尤其是当目标是文件且磁盘 I/O 慢时),高并发下会严重拖慢事件循环。
  2. 缺乏结构化:输出的是纯文本,无法被 ELK (Elasticsearch, Logstash, Kibana) 等日志系统高效索引和查询。
  3. 无法动态调级:没法区分 INFOWARNERRORDEBUG。线上无法做到动态调整日志级别。

现代日志框架推荐

在 Node.js 中,最主流的两个日志库是 WinstonPino

  • Winston:老牌经典,生态极好。支持多 Transport(比如同时输出到控制台、写入文件、发送到日志服务器)。
  • Pino:主打极速 (Super fast)。通过异步日志和极其优化的序列化机制,性能远超 Winston,是现代高并发框架(如 Fastify)的标配。

最佳实践:使用专为 Node.js 优化的结构化日志库 Pino,配合大厂常见的日志落地方案。

二、基于 Pino 的结构化日志实战

结构化日志(JSON 格式)能让机器和人类都更容易阅读。

1. 初始化 Pino 实例与大厂规范

大厂日志规范通常包含:

  • 结构化:方便 Logstash / Filebeat 采集,提取 leveluserId 供 Kibana 检索。
  • 日志滚动切分 (Log Rotation):不能把所有日志写在一个 app.log 里,否则会撑爆磁盘,需要按天切分并定期清理。
// logger.js
const pino = require('pino');

const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// 生产环境输出纯 JSON,开发环境可以使用 pino-pretty 格式化输出
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined, // 生产环境还可以配置 file transport 结合 log rotation
// 增加基础信息,如机器名、进程 ID 等
base: {
pid: process.pid,
env: process.env.NODE_ENV
},
// 时间格式化
timestamp: pino.stdTimeFunctions.isoTime,
});

module.exports = logger;

2. 正确打印日志的姿势

❌ 错误姿势(字符串拼接)

// 这样会导致丢失结构化信息,且消耗 CPU 拼接字符串
logger.error(`Failed to fetch user ${userId}: ${err.message}`);

✅ 正确姿势(对象传参)

// 错误对象放第一个参数,附加信息放第二个参数
logger.error({ err, userId }, 'Failed to fetch user profile');

这样输出的 JSON 日志中,err 会被自动展开包含 stack trace,userId 也会作为一个独立字段被 ElasticSearch 索引,方便后续直接搜索 userId: 12345

三、进阶:使用 AsyncLocalStorage 实现 Trace ID 追踪

在微服务或高并发单体中,同一个用户的请求可能会触发成百上千条日志。如果不把它们串起来,排查问题无异于大海捞针。

Node.js 提供了 async_hooks 模块下的 AsyncLocalStorage,完美解决了跨异步回调传递上下文的问题。

1. 封装上下文管理器

// context.js
const { AsyncLocalStorage } = require('async_hooks');
const { randomUUID } = require('crypto');

const asyncLocalStorage = new AsyncLocalStorage();

// 获取当前上下文的 Trace ID
function getTraceId() {
const store = asyncLocalStorage.getStore();
return store ? store.traceId : null;
}

// 中间件:为每个请求初始化上下文
function tracingMiddleware(req, res, next) {
// 优先从请求头获取,方便微服务链路传递;没有则生成新的
const traceId = req.headers['x-request-id'] || randomUUID();

// 在 res 头中也注入 traceId,方便前端排查
res.setHeader('X-Request-Id', traceId);

// 运行上下午,内部的所有异步操作都能获取到该 traceId
asyncLocalStorage.run({ traceId }, () => {
next();
});
}

module.exports = { getTraceId, tracingMiddleware };

2. 改造 Logger 自动注入 Trace ID

我们不需要在每次打日志时手动传入 traceId,可以通过 Pino 的 mixin 功能自动注入:

// logger.js (改造后)
const pino = require('pino');
const { getTraceId } = require('./context');

const logger = pino({
level: 'info',
mixin() {
const traceId = getTraceId();
// 只要处于上下文中,每条日志都会自动带上 traceId 字段
return traceId ? { traceId } : {};
}
});

module.exports = logger;

3. 在业务中使用

// server.js
const express = require('express');
const { tracingMiddleware } = require('./context');
const logger = require('./logger');

const app = express();

app.use(tracingMiddleware);

app.get('/api/data', async (req, res) => {
logger.info('Received data request'); // 自动带上 traceId

try {
const data = await mockDatabaseQuery();
logger.info({ dataLength: data.length }, 'Query successful'); // 自动带上 traceId
res.json(data);
} catch (err) {
logger.error({ err }, 'Query failed'); // 自动带上 traceId
res.status(500).send('Internal Server Error');
}
});

通过这种方式,在 Kibana 等平台排查问题时,只需过滤 traceId: "xxx-yyy-zzz",就能瞬间拉出该请求的完整生命周期日志。

四、安全红线:敏感信息脱敏

在电商、金融等领域,将用户密码、Token、手机号打到日志中是严重的 P0 级事故。日志库需要具备自动脱敏(Redaction)能力。

以 Pino 为例:

const logger = pino({
redact: {
paths: [
'user.password',
'req.headers.authorization',
'token',
'*.phone' // 支持通配符
],
censor: '[REDACTED]' // 替换后的文本,默认是 '[Redacted]'
}
});

logger.info({
user: { name: 'Alice', password: 'secret-password' },
token: 'jwt-token-123'
}, 'User logged in');

// 输出结果:
// {"level":30,"user":{"name":"Alice","password":"[REDACTED]"},"token":"[REDACTED]","msg":"User logged in"}

五、总结

  1. 结构化先行:坚决摒弃 console.log,使用 Pino 输出 JSON 日志。
  2. 错误携带堆栈:打印错误时,一定要把 Error 对象本身传给日志库,保留完整的 Call Stack。
  3. Trace ID 串联:利用 AsyncLocalStorage 实现请求全链路追踪,让排查如同顺藤摸瓜。
  4. 敬畏数据安全:开启 Redact 功能,严防敏感数据泄露。