日志实践
本教程共 76 篇 · 第 65 篇 · 更新于 2026-07-25 · 约 7 分钟阅读
65. 日志实践
本节目标:结构化日志、日志级别和 pino、winston 选型。
console.log 在开发阶段够方便,但生产环境里继续用它,就像用草稿纸记账本——格式混乱、找不到东西、下雨就糊了。一个成熟的 Node.js 服务需要结构化的、可查询的、带级别的日志系统。
为什么 console.log 不够
先看几个 console.log 的硬伤:
- 没有级别。info、warn、error 全混在一起,排查问题时得逐行翻。
- 不是结构化。纯文本日志很难被机器解析,想按用户 ID 或请求路径过滤基本靠 grep。
- 同步写磁盘。输出到文件时,
console.log是同步调用,高并发下会堵住事件循环。 - 没有上下文。一条
console.log('查询失败')告诉你发生了什么,但没告诉你是哪个用户、哪次请求、什么时间。
Tip开发阶段
console.log随便打没关系,但提交代码前养成习惯:能进生产环境的日志,一律走 logger。
日志级别:只说该说的话
常见的级别从低到高:
| 级别 | 用途 | 生产环境是否输出 |
|---|---|---|
trace | 最详细的调试,比如函数进出 | 否 |
debug | 开发调试信息 | 否 |
info | 正常业务流程,如服务启动、请求完成 | 是(少量) |
warn | 非致命异常,如请求重试、降级 | 是 |
error | 需要人处理的错误 | 是 |
fatal | 服务即将崩溃 | 是 |
生产环境的日志级别通常设为 info 或 warn。级别设太低,日志量爆炸,存储和查询成本飙升;设太高,出了问题没有上下文可以追溯。
Pino:高性能之选
Node.js 生态里成熟的日志库不少,Pino 是我目前最推荐的。它设计哲学就一个字:快。基准测试里,Pino 的吞吐量比 Winston 高 5~10 倍,因为内部做了大量优化,比如固定字段预序列化、字符串拼接替代 JSON.stringify 等。
npm install pino pino-pretty
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// 生产环境输出 JSON,开发环境可以配 pino-pretty
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
// 每个日志自动带上 pid 和 hostname
base: { pid: process.pid, hostname: require('os').hostname() },
});
logger.info('服务启动');
logger.warn({ latency: 2300 }, '接口响应过慢');
logger.error({ err: new Error('数据库连接失败') }, '查询异常');
输出示例(生产环境 JSON):
{"level":30,"time":1699123456789,"pid":1234,"hostname":"web-01","msg":"服务启动"}
{"level":40,"time":1699123457890,"pid":1234,"hostname":"web-01","latency":2300,"msg":"接口响应过慢"}
{"level":50,"time":1699123458901,"pid":1234,"hostname":"web-01","err":{"type":"Error","message":"数据库连接失败","stack":"..."},"msg":"查询异常"}
JSON 格式意味着你可以直接把日志丢进 Elasticsearch、Loki 或 Splunk,按字段搜索、聚合、做仪表盘。
Winston:老牌全能
如果你的项目已经在用 Winston,或者需要多 transport(同时写文件、发邮件、推送到云),Winston 依然是稳妥的选择。
npm install winston
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'order-service' },
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
logger.info('订单创建成功', { orderId: 'ORD-2024-001', amount: 199.9 });
Winston 的优点是生态丰富,各种 transport 插件应有尽有。缺点是性能不如 Pino,对超高并发场景可能形成瓶颈。
给日志加上请求上下文
没有上下文的日志,就像没有日期的日记——你知道发生了什么事,但不知道是谁干的。
在 Express 里,可以用中间件给每个请求绑定一个 child logger:
import express from 'express';
import { randomUUID } from 'node:crypto';
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({ level: 'info' });
const app = express();
// pino-http 自动记录每个请求的时间和状态码
app.use(pinoHttp({ logger, genReqId: () => randomUUID() }));
app.get('/api/orders/:id', (req, res) => {
// req.log 是绑定当前请求 ID 的 child logger
req.log.info({ orderId: req.params.id }, '查询订单');
// 模拟业务逻辑...
res.json({ id: req.params.id, status: 'paid' });
});
app.listen(3000);
pino-http 会在请求进入时自动生成一个 req.id,所有通过 req.log 输出的日志都会带上这个 ID。当用户反馈 “某个请求报错了”,你只需要按 req.id 一搜,就能串起这条请求链路里的所有日志。
日志脱敏:别把密码写进日志
日志里泄漏敏感信息,是安全审计里最常见的问题之一。密码、Token、身份证号、银行卡号,这些都不能出现在日志里。
Pino 可以通过 redact 配置自动脱敏:
const logger = pino({
redact: {
paths: [
'password',
'*.password',
'headers.authorization',
'creditCard',
'ssn',
],
censor: '[REDACTED]',
},
});
logger.info({
user: 'alice',
password: 'secret123',
headers: { authorization: 'Bearer abc.def.ghi' },
creditCard: '4111111111111111',
});
// 输出中 password 和 authorization 会被替换为 [REDACTED]
Winston 也可以自定义 format 实现类似功能:
const sanitize = winston.format((info) => {
if (info.password) info.password = '[REDACTED]';
if (info.headers?.authorization) info.headers.authorization = '[REDACTED]';
return info;
});
Warning脱敏规则要定期 review。新业务字段加了敏感信息,记得同步更新 redact paths。最好的做法是:敏感字段统一命名(比如都叫
password、token、secret),脱敏规则用通配符一把梭。
日志轮转:别让磁盘爆掉
日志文件如果不加限制,几天就能吃掉几百 GB 磁盘。生产环境必须配轮转策略。
用 pino 配合 pino-roll(v24 可用):
npm install pino-roll
import pino from 'pino';
import { build } from 'pino-roll';
const transport = build({
file: 'app.log',
frequency: 'daily',
size: '100m',
maxFiles: 14, // 保留 14 天
});
const logger = pino(transport);
Winston 用户可以用 winston-daily-rotate-file:
npm install winston-daily-rotate-file
import DailyRotateFile from 'winston-daily-rotate-file';
const transport = new DailyRotateFile({
filename: 'app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
});
配置要点:
- 按天切分:方便按日期定位问题。
- 单文件上限:避免单个日志文件过大,影响 tail/grep 性能。
- 保留期限:结合业务需求和合规要求,一般保留 7~30 天。
- 压缩旧文件:
zippedArchive: true能省不少磁盘。
集中化日志
当服务部署到多台机器或 Kubernetes 集群里,每台机器的本地日志就变成了孤岛。你需要一个集中化的日志系统:
| 方案 | 特点 | 适合场景 |
|---|---|---|
| ELK (Elasticsearch + Logstash + Kibana) | 功能全面,生态庞大 | 中大型团队,有运维资源 |
| Loki + Grafana | 轻量,和 Prometheus 生态融合好 | K8s 环境,成本敏感 |
| Fluentd / Fluent Bit | 日志收集转发中间件 | 多源聚合,统一出口 |
| 云厂商方案 (CloudWatch/Log Analytics/SLS) | 免运维,按量计费 | 云上部署,快速启动 |
架构上通常是这样的流程:
应用 (JSON 日志) → Fluent Bit (节点采集) → Kafka/直接 → Loki/ES → Grafana/Kibana
应用只负责把日志以结构化 JSON 输出到 stdout,收集和存储交给基础设施。这也是 12-Factor App 推荐的做法。
生产环境日志检查清单
上线前对照这个清单过一遍:
- 使用结构化日志(JSON),不是纯文本
- 设置了合理的日志级别(生产
info/warn起步) - 每条日志都有时间戳、服务名、实例标识
- 请求链路有唯一 ID(correlation ID / req.id)
- 敏感字段已脱敏
- 配置了日志轮转,不会撑爆磁盘
- 日志输出到 stdout/stderr,由外部采集(容器/K8s 环境)
- 错误日志包含完整的 error stack
- 异步 logger,不阻塞事件循环