首页 / Node.js 教程 / 日志实践

Node.js 教程

日志实践

本教程共 76 篇 · 第 65 篇 · 更新于 2026-07-25 · 约 7 分钟阅读

Node.js日志pinowinston结构化

65. 日志实践

本节目标:结构化日志、日志级别和 pino、winston 选型。

console.log 在开发阶段够方便,但生产环境里继续用它,就像用草稿纸记账本——格式混乱、找不到东西、下雨就糊了。一个成熟的 Node.js 服务需要结构化的、可查询的、带级别的日志系统。

为什么 console.log 不够

先看几个 console.log 的硬伤:

  1. 没有级别。info、warn、error 全混在一起,排查问题时得逐行翻。
  2. 不是结构化。纯文本日志很难被机器解析,想按用户 ID 或请求路径过滤基本靠 grep。
  3. 同步写磁盘。输出到文件时,console.log 是同步调用,高并发下会堵住事件循环。
  4. 没有上下文。一条 console.log('查询失败') 告诉你发生了什么,但没告诉你是哪个用户、哪次请求、什么时间。
Tip

开发阶段 console.log 随便打没关系,但提交代码前养成习惯:能进生产环境的日志,一律走 logger。

日志级别:只说该说的话

常见的级别从低到高:

级别用途生产环境是否输出
trace最详细的调试,比如函数进出
debug开发调试信息
info正常业务流程,如服务启动、请求完成是(少量)
warn非致命异常,如请求重试、降级
error需要人处理的错误
fatal服务即将崩溃

生产环境的日志级别通常设为 infowarn。级别设太低,日志量爆炸,存储和查询成本飙升;设太高,出了问题没有上下文可以追溯。

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。最好的做法是:敏感字段统一命名(比如都叫 passwordtokensecret),脱敏规则用通配符一把梭。

日志轮转:别让磁盘爆掉

日志文件如果不加限制,几天就能吃掉几百 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,不阻塞事件循环