日志与监控

What — 是什么

日志与监控是 Node.js 生产环境的可观测性基石,通过结构化日志记录运行状态,通过健康检查和 APM 监控发现异常,确保服务稳定运行。

核心概念:

  • Winston:功能丰富的日志库,支持多传输(Console/File/HTTP),日志级别,格式化
  • Pino:极致性能的 JSON 日志库,Fastify 默认集成,子进程写文件避免阻塞主线程
  • 结构化日志:每条日志是 JSON 对象,包含时间/级别/请求ID/上下文等字段,便于机器检索
  • APM:应用性能监控,追踪请求链路、数据库查询、HTTP 调用的耗时
  • 健康检查/health 端点检查服务依赖(数据库/Redis/外部 API)是否正常
  • PM2 Metrics:PM2 内置的进程级监控(CPU/内存/事件循环延迟/请求数)

关键特性:

  • Pino 比 Winston 快 5-10 倍,是 Node.js 最快的日志库
  • 结构化日志配合 ELK/Loki 等日志系统实现集中检索
  • 健康检查是 K8s liveness/readiness 探针的基础
  • APM 可追踪分布式请求的完整链路

Why — 为什么

适用场景:

  • 故障排查:通过日志定位错误原因
  • 性能监控:追踪慢请求和瓶颈
  • 审计合规:记录关键操作和访问
  • 告警:异常指标触发通知

对比日志库:

维度PinoWinstonconsole.log
性能极快(~10M ops/s)中(~1M ops/s)
格式JSON可配置文本
传输子进程写文件多传输终端
子日志child() 绑定上下文手动
适用生产高性能灵活配置开发调试

优缺点:

  • ✅ 优点:
    • Pino 极致性能不阻塞事件循环
    • 结构化日志便于机器检索分析
    • 健康检查实现自动化故障检测
    • APM 全链路追踪定位分布式问题
  • ❌ 缺点:
    • 日志量大增加存储成本
    • APM 引入额外性能开销
    • 结构化日志不如文本日志直观

How — 怎么用

安装配置

npm install pino pino-pretty
const pino = require('pino');

const logger = pino({
    level: process.env.LOG_LEVEL || 'info',
    formatters: { level: (label) => ({ level: label }) },
    timestamp: pino.stdTimeFunctions.isoTime
}, pino.destination({ dest: 'logs/app.log', mkdir: true, sync: false }));

代码示例

Pino 结构化日志 + 请求上下文:

const pino = require('pino');

const logger = pino({
    level: 'info',
    redact: ['req.headers.authorization', 'req.body.password'], // 脱敏
    serializers: {
        req(req) {
            return { method: req.method, url: req.url, id: req.id };
        },
        err: pino.stdSerializers.err
    }
});

// 子日志绑定上下文
function requestLogger(req, res, next) {
    req.log = logger.child({ reqId: req.id, userId: req.user?.id });
    req.log.info({ req }, 'Request started');
    const start = Date.now();
    res.on('finish', () => {
        req.log.info({ res: { statusCode: res.statusCode }, duration: Date.now() - start }, 'Request completed');
    });
    next();
}

// 使用
app.use(requestLogger);
app.get('/users/:id', async (req, res) => {
    req.log.info({ userId: req.params.id }, 'Fetching user');
    const user = await getUser(req.params.id);
    req.log.debug({ user }, 'User fetched');
    res.json(user);
});

健康检查端点:

app.get('/health', async (req, res) => {
    const checks = {
        database: await checkDatabase(),
        redis: await checkRedis(),
        externalApi: await checkExternalApi()
    };

    const isHealthy = Object.values(checks).every(c => c.status === 'ok');
    res.status(isHealthy ? 200 : 503).json({
        status: isHealthy ? 'healthy' : 'degraded',
        uptime: process.uptime(),
        timestamp: new Date().toISOString(),
        checks
    });
});

async function checkDatabase() {
    try {
        await db.query('SELECT 1');
        return { status: 'ok', latency: 1 };
    } catch (err) {
        return { status: 'error', message: err.message };
    }
}

async function checkRedis() {
    try {
        const start = Date.now();
        await redis.ping();
        return { status: 'ok', latency: Date.now() - start };
    } catch (err) {
        return { status: 'error', message: err.message };
    }
}

性能调优

参数默认值调优建议说明
synctrue生产用 false异步写文件不阻塞主线程
levelinfo生产 info,开发 debug低级别日志量增大
redact敏感字段列表防止密码/Token 入日志
日志轮转pino-roll/pmv-logrotate防止日志文件无限增长

常见问题与踩坑

问题原因解决方案
日志丢失异步写入时进程崩溃关键日志用 logger.flush() 确保写入
敏感信息泄露未脱敏配置 redact 字段列表
日志文件过大未轮转pino-roll 或外部 logrotate
日志影响性能日志级别太低或同步写生产用 info+async
健康检查假阳性检查逻辑太敏感设置合理超时和重试

最佳实践

  • 生产环境使用 Pino + 异步写入
  • 每个请求绑定请求 ID 和用户 ID(logger.child()
  • 敏感字段脱敏(redact
  • 实现健康检查端点供 K8s/负载均衡探针
  • 日志级别:开发 debug,生产 info
  • 关键操作记录审计日志(创建/删除/权限变更)

面试题

Q1: Pino 为什么比 Winston 快?

三个原因:① Pino 最小化主线程工作——只做 JSON 序列化,文件写入交由子进程(pino.destinationsync: false),不阻塞事件循环;② 无格式化开销——Winston 的格式化链(timestamp/printf/colorize)在主线程执行,Pino 直接输出 JSON;③ 无动态特性——Winston 支持多传输和动态配置,Pino 设计极简,牺牲灵活性换性能。

Q2: 结构化日志的好处?

结构化日志每条记录是 JSON 对象,好处:① 机器可解析——ELK/Loki/Datadog 直接索引字段,全文检索和聚合分析;② 上下文丰富——每条日志携带请求 ID/用户 ID/trace ID,串联完整请求链路;③ 脱敏方便——redact 规则自动过滤敏感字段;④ 告警精准——按字段值设告警规则(如 level=error AND statusCode>=500);⑤ 兼容微服务——各服务日志格式统一,集中检索。

Q3: 健康检查的 liveness 和 readiness 区别?

liveness(存活探针):进程是否存活,失败则重启容器。检查进程本身(死锁/无限循环),简单返回 200 即可。readiness(就绪探针):服务是否准备好接收流量,失败则从负载均衡移除。检查依赖服务(数据库/Redis/外部 API),所有依赖正常才返回 200。区别:liveness 失败=重启进程,readiness 失败=暂停流量。启动时 readiness 先失败(加载配置/连接数据库),就绪后通过;liveness 一直正常。


相关链接: