监控与可观测性
本教程共 76 篇 · 第 71 篇 · 更新于 2026-07-25 · 约 10 分钟阅读
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 自带 eventLoopLag、process.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() 会自动给 http、express、pg(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 直接被撑爆。标签只用「低基数、有聚合意义」的(如route、status)。 - 追踪只接了一半:自动插桩只覆盖了部分服务,链路断在中间,等于没接。要么全接,要么先集中接核心路径。
- 告警阈值拍脑袋:设了「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]))。哪条路由慢、慢到什么程度,全在图上。
WarningHistogram 的
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 这边只要保证指标暴露得准、标签贴得对,下游告警自然好配。