首页 / Node.js 教程 / 监控与可观测性

Node.js 教程

监控与可观测性

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

Node.js监控可观测性OpenTelemetry健康检查

71. 监控与可观测性

本节目标:- 给服务提供健康检查接口,让编排系统知道它活着 - 用 Prometheus 风格暴露运行指标(请求数、耗时、内存) - 用 OpenTelemetry 做跨服务的链路追踪,看清一次请求到底卡在哪 一个直观的比喻:健康检查是「你心跳还在不在」,指标是「你的体温、血压长期趋势」,链路追踪是「这次感冒是从哪传染来的」。三者合起来才算真正「看得见」。

服务上线了,但你对它「现在过得好不好」一无所知——这就像把车开上高速却关了仪表盘。可观测性(Observability)就是给系统装仪表盘:出了问题你能从数据里反推出原因,而不是靠猜。

可观测性常拆成三块:日志(Log,第 65 章讲过)、指标(Metrics)、链路追踪(Trace)。这一章重点讲后两者,外加最基础也最容易被忽略的健康检查。

健康检查

最简单的可观测性动作。搞一个 /health 路由,返回 200 代表活着:

// health.js (ESM)
export function registerHealth(app) {
  app.get('/health', (_req, res) => {
    res.status(200).json({ status: 'ok', uptime: process.uptime() });
  });

  //  readiness:能不能正常接客(比如数据库连上了吗)
  app.get('/ready', async (_req, res) => {
    try {
      // 这里查一下数据库连通性
      await checkDatabase();
      res.status(200).json({ status: 'ready' });
    } catch (err) {
      res.status(503).json({ status: 'not ready', error: err.message });
    }
  });
}

注意 liveness(活着)和 readiness(能干活)是两回事:进程在跑但数据库挂了,liveness 该返回 200(别让编排系统误杀重启),readiness 该返回 503(别把流量打过来)。Kubernetes、Docker 的 healthcheck 都用这套语义。

Note

健康检查接口千万别去查「重活」(比如拉一大张表),否则健康检查本身变成性能负担。它只验证「关键依赖是否可达」即可。

如果你用 Docker 跑,可以加个 healthcheck

services:
  app:
    build: .
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

指标:Prometheus 风格

指标是「随时间变化的数值」,比如「过去一分钟处理了多少请求」「P95 响应时间是多少」。业界常用 Prometheus 拉取(pull)模式:你的服务把指标暴露在 /metrics 端口,Prometheus 定时来抓。

自己实现最简单版:

// metrics.js (ESM)
const counters = {
  httpRequests: 0,
};
const histograms = {
  httpDuration: [], // 简单起见用数组存样本(生产请用专业库)
};

export function recordRequest(durationMs) {
  counters.httpRequests++;
  histograms.httpDuration.push(durationMs);
}

export function metricsHandler(_req, res) {
  const lines = [];
  lines.push(`# HELP http_requests_total 总请求数`);
  lines.push(`# TYPE http_requests_total counter`);
  lines.push(`http_requests_total ${counters.httpRequests}`);

  if (histograms.httpDuration.length) {
    const avg =
      histograms.httpDuration.reduce((a, b) => a + b, 0) /
      histograms.httpDuration.length;
    lines.push(`# HELP http_request_duration_avg_ms 平均耗时`);
    lines.push(`# TYPE http_request_duration_avg_ms gauge`);
    lines.push(`http_request_duration_avg_ms ${avg.toFixed(2)}`);
  }

  res.setHeader('Content-Type', 'text/plain; version=0.0.4');
  res.end(lines.join('\n'));
}

真实项目别手写,直接用 prom-client

import client from 'prom-client';

const httpRequests = new client.Counter({
  name: 'http_requests_total',
  help: '总请求数',
  labelNames: ['method', 'status'],
});

// 在中间件里
httpRequests.inc({ method: req.method, status: res.statusCode });

// 暴露端点
app.get('/metrics', async (_req, res) => {
  res.set('Content-Type', client.register.contentType);
  res.end(await client.register.metrics());
});

prom-client 自带 eventLoopLagprocess.memory 等默认指标,连内存泄漏都能从图表上看见苗头。Prometheus 抓到后配合 Grafana 画仪表盘,你就能盯着曲线喝咖啡了。

Tip

别一上来就埋一百个指标。先盯「黄金三指标」:请求量(QPS)、错误率、延迟(P95/P99)。这三个能回答 90% 的「服务还健不健康」。

OpenTelemetry:链路追踪

当你的系统从「一个服务」变成「一堆服务」(下一章讲微服务),一个问题就来了:一次用户请求穿过了订单、用户、库存三个服务,到底卡在哪个?这就是链路追踪(Distributed Tracing)要解决的。

OpenTelemetry(简称 OTel)是现在的事实标准,它定义了一套采集、导出遥测数据的协议,跟具体后端(Jaeger、Tempo、商业 APM)解耦。

基本接入

安装:

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http

建一个 instrumentation.js,在你的应用代码之前先加载:

// instrumentation.js (ESM)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const traceExporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
        'http://localhost:4318/v1/traces',
});

const sdk = new NodeSDK({
  traceExporter,
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

然后改一下启动方式,让它在最前面运行:

node --import ./instrumentation.js app.js

--import 是 v24 推荐的预加载方式(替代已弃用的 -r)。getNodeAutoInstrumentations() 会自动给 httpexpresspg(PostgreSQL 驱动)等库「插桩」,你不用改业务代码就能拿到跨服务的调用链。

Note

自动插桩靠的是「在库的函数外面包一层」。对 Express、http、常用数据库驱动都支持得很好。如果你用了很冷门的库,可能需要手写 Span(跨度,一次操作的时间片段)来补全链路。

看清一次请求

配置好之后,一次从浏览器到后端的请求,在 Jaeger 里会显示成一棵「调用树」:外层是 HTTP 请求,里面是数据库查询、下游服务调用,每个节点都带着耗时。哪个环节慢,一眼就看出来——不用再靠 console.time 到处打点了。

手动加 Span

自动插桩覆盖不到的自定义逻辑,自己补一个 Span:

import { trace } from '@opentelemetry/api';

export async function heavyCalculation(input) {
  const tracer = trace.getTracer('my-api');
  return tracer.startActiveSpan('heavyCalculation', async (span) => {
    span.setAttribute('input.size', input.length);
    try {
      const result = await doWork(input);
      span.setStatus({ code: 0 }); // OK
      return result;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: 2, message: err.message }); // ERROR
      throw err;
    } finally {
      span.end();
    }
  });
}

setAttribute 往 Span 上贴业务标签(比如 user.id),排查时能在追踪系统里按标签过滤,非常实用。

Warning

别在 Span 上塞敏感信息(密码、token、身份证号)。遥测数据通常会被收集到集中存储,敏感标签等于明文泄漏。只放排查必需的、非敏感的维度。

几个常见反模式

踩过的坑总结几条,帮你少走弯路:

  • 只接日志不接指标:日志能看「发生了什么」,但看不出「整体趋势」。没有指标,你发现不了「错误率正在缓慢爬升」。
  • 指标标签维度爆炸:给 Histogram 的 label 加了 user_id 这种高基数(cardinality)维度,Prometheus 直接被撑爆。标签只用「低基数、有聚合意义」的(如 routestatus)。
  • 追踪只接了一半:自动插桩只覆盖了部分服务,链路断在中间,等于没接。要么全接,要么先集中接核心路径。
  • 告警阈值拍脑袋:设了「CPU 超 90% 告警」,结果常态就 85%,天天误报,最后大家把告警当杂音。阈值要基于真实基线和业务容忍度。
  • 可观测性上线了没人看:面板和告警配完就扔那,真出事没人盯。可观测性的价值在于「有人基于它决策」,不是装了就完事。

三者怎么落地

  • 健康检查:让编排系统(Docker/K8s)知道该重启还是该摘流量,几乎零成本先加上。
  • 指标:用一个 prom-client + /metrics 暴露,配 Prometheus + Grafana,长期看趋势、设告警。
  • 链路追踪:等服务多了、排查跨服务问题变痛苦时,再上 OpenTelemetry,性价比最高。
Tip

可观测性不是「上线前最后补一下」的事,而是从写第一行代码就该留好口子。日志、指标、追踪的接入点写得顺手,后面排查问题会省下无数个焦头烂额的夜晚。

三类信号怎么串起来

单独看日志、指标、追踪都有用,但把它们关联起来才真正好用。关键纽带是 trace_id:一次请求从入口到末端都带着同一个 trace_id,你就能「先在追踪系统里看到某次慢请求,再拿它的 trace_id 去日志里捞完整上下文,同时对照当时的指标曲线」。

Node 里把当前 trace 的上下文带进日志,OpenTelemetry 提供了现成 API:

import { trace } from '@opentelemetry/api';

function logWithTrace(level, msg) {
  const span = trace.getActiveSpan();
  const ctx = span ? span.spanContext() : null;
  console.log(JSON.stringify({
    level,
    msg,
    traceId: ctx ? ctx.traceId : undefined,
    ts: new Date().toISOString(),
  }));
}

这样你的结构化日志(第 65 章讲过)里每一行都带着 traceId,排查时一搜全出来。注意:这里用的是上一章提到的 prom-client 之外的另一套逻辑,两者不冲突,一个管指标、一个管链路。

Note

console.log(JSON.stringify(...)) 是为了直观展示。生产请用 pino/winston 这类日志库,把 traceId 作为固定字段输出,性能更好、格式更稳。

指标再深入一点

prom-client 不止 Counter(计数器),常用的还有几种:

  • Gauge(仪表盘):可增可减的瞬时值,比如「当前在线连接数」「内存占用」。
  • Histogram(直方图):把耗时按桶(bucket)统计,能直接算出 P95/P99 延迟。
  • Summary(摘要):类似 Histogram,但直接在客户端算好分位数。

一个记录接口耗时的例子:

import client from 'prom-client';

const httpDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP 请求耗时分布',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.05, 0.1, 0.3, 0.5, 1, 3, 5], // 秒
});

// 中间件里
const end = httpDuration.startTimer();
res.on('finish', () => {
  end({ method: req.method, route: req.route?.path, status: res.statusCode });
});

配好之后,Grafana 里写一句 PromQL 就能拿到 P95:histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))。哪条路由慢、慢到什么程度,全在图上。

Warning

Histogram 的 buckets 要按你业务的真实耗时分布来设。设得太粗(比如只有 1s、10s 两档),算出来的 P95 精度很差;设得太细又增加存储成本。上线后根据实际数据调几轮才合理。

链路追踪的采样

全量记录每一次请求的链路,数据量和成本会爆炸——特别是高 QPS 服务。所以实际都会「采样(Sampling)」:只记录一部分请求的完整链路。OTel 支持多种采样策略:

  • 恒定采样:比如「永远记录 10% 的请求」。简单,但可能漏掉出问题的那次。
  • 基于错误采样:出错的请求 100% 记录,正常的按低比例采。生产最常用,性价比最高。
  • 限速采样:每秒最多记 N 条,突发流量下保护后端不被冲垮。

在 Node SDK 里配置恒定采样:

import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node';

const sampler = new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(0.1), // 根 Span 采 10%
});

ParentBasedSampler 的意思是「如果请求是从别处链路延续来的,就沿用父级的采样决定」,避免一条链路前半段记了、后半段没记,拆得七零八落。

告警:让指标替你盯梢

指标收集上来,下一步是「超标就通知人」。这通常不在 Node 里做,而是 Prometheus 的 alerting rules 或 Grafana 告警去比对阈值,比如「错误率 5 分钟超 1% 就发钉钉」。Node 这边只要保证指标暴露得准、标签贴得对,下游告警自然好配。