日志规范与链路追踪实践
在生产环境中,排查问题最直接的手段就是查日志。但如果日志打得杂乱无章,不仅无法帮助排查,还会成为性能瓶颈。本文将结合 Node.js 代码示例,带你构建一套生产级的日志体系。
一、为什么生产环境要告别 console.log?
为什么抛弃 console.log?
很多新手喜欢用 console.log 打天下,但这在生产环境中存在致命缺陷:
- 性能极差:
console.log在 Node.js 中通常是同步阻塞的(尤其是当目标是文件且磁盘 I/O 慢时),高并发下会严重拖慢事件循环。 - 缺乏结构化:输出的是纯文本,无法被 ELK (Elasticsearch, Logstash, Kibana) 等日志系统高效索引和查询。
- 无法动态调级:没法区分
INFO、WARN、ERROR、DEBUG。线上无法做到动态调整日志级别。
现代日志框架推荐
在 Node.js 中,最主流的两个日志库是 Winston 和 Pino。
- Winston:老牌经典,生态极好。支持多 Transport(比如同时输出到控制台、写入文件、发送到日志服务器)。
- Pino:主打极速 (Super fast)。通过异步日志和极其优化的序列化机制,性能远超 Winston,是现代高并发框架(如 Fastify)的标配。
最佳实践:使用专为 Node.js 优化的结构化日志库 Pino,配合大厂常见的日志落地方案。
二、基于 Pino 的结构化日志实战
结构化日志(JSON 格式)能让机器和人类都更容易阅读。
1. 初始化 Pino 实例与大厂规范
大厂日志规范通常包含:
- 结构化:方便 Logstash / Filebeat 采集,提取
level、userId供 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"}
五、总结
- 结构化先行:坚决摒弃
console.log,使用 Pino 输出 JSON 日志。 - 错误携带堆栈:打印错误时,一定要把 Error 对象本身传给日志库,保留完整的 Call Stack。
- Trace ID 串联:利用
AsyncLocalStorage实现请求全链路追踪,让排查如同顺藤摸瓜。 - 敬畏数据安全:开启 Redact 功能,严防敏感数据泄露。