本节目标:区分 Counter、Gauge、Histogram 三种仪器类型并正确选用;用 OpenTelemetry SDK 在 Node.js 服务里注册指标并暴露
/metrics;掌握命名规范与标签基数控制;用 RED 方法与黄金信号设计仪表盘;写出低噪音、可执行的告警规则,并理解 SLO 与燃烧率告警的推导方式。
17.2 指标与告警
上一节的追踪解决的是「查个案」:某一次请求慢了,顺着 trace 找到那一段。但运维的日常问题往往是「整体怎么样」——现在 QPS 多少、错误率有没有涨、P99 延迟是不是在恶化。这些问题不该靠翻 trace 回答,那是指标的工作。
追踪与指标的分工可以这样记:trace 是抽样的事后证据,metrics 是全量的实时统计。前者精度高、成本高、覆盖低;后者成本极低、覆盖全量、但会丢失个体细节。
17.2.1 三种仪器类型
指标系统的核心概念是「仪器」(instrument),只有三种,选错类型会让数据彻底失去意义:
| 类型 | 语义 | 能否减少 | 典型用途 |
|---|---|---|---|
| Counter | 只增不减的累计值 | 否 | 请求总数、错误总数、已处理字节数 |
| Gauge | 可增可减的瞬时值 | 是 | 当前连接数、队列长度、内存占用 |
| Histogram | 按桶统计的分布 | 否 | 请求耗时、响应体大小 |
选错的典型表现是用 Counter 记「当前连接数」:连接断开时数值不降,看起来像是泄漏了。反过来,用 Gauge 记「请求总数」则会在采集间隙丢失峰值。判断标准很简单:这个量会不会下降?会就是 Gauge,不会就是 Counter。
Histogram 值得多说一句。它并不存储每一次观测,而是把观测值落进预设的桶里:
bucket(le) count
0.005 1203
0.01 5891
0.025 9820
0.05 10233
+Inf 10245
有了桶计数,就能用 histogram_quantile 估算分位数。桶的边界必须按业务的实际延迟量级来设,默认桶(0.005 到 10 秒)对大多数 API 都太粗。一个常见做法是按「目标 SLO 的 1/10、1/4、1/2、1、2、4 倍」布桶。
17.2.2 用 OTel SDK 注册指标
Node.js 侧推荐用 OpenTelemetry 的 Metrics API,这样追踪与指标共用同一套 resource 与导出通道:
// src/metrics.ts
import { metrics } from '@opentelemetry/api'
const meter = metrics.getMeter('order-service', '1.0.0')
// 耗时用 Histogram,桶按业务量级定制
export const httpDuration = meter.createHistogram('http.server.request.duration', {
description: 'HTTP 请求耗时',
unit: 'ms',
advice: {
explicitBucketBoundaries: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000],
},
})
// 计数用 Counter,语义上只增不减
export const ordersCreated = meter.createCounter('orders.created.total', {
description: '成功创建的订单数',
unit: '{order}',
})
// 瞬时值用 ObservableGauge,由回调按需拉取
export const queueDepth = meter.createObservableGauge('queue.depth', {
description: '待处理任务数',
})
queueDepth.addCallback(async (result) => {
// 回调可以是 async,SDK 在采集时等待它返回
result.observe(await bullmqQueue.getWaitingCount())
})
注意 ObservableGauge 用的是回调式而不是 set 式:SDK 在每次采集时调用你的回调,你返回当前值。这与 Prometheus 的拉取模型天然契合,也避免了业务代码到处埋「更新指标」的语句。
记录观测值时的写法:
import { httpDuration, ordersCreated } from './metrics'
async function handleCreateOrder(req: Request, reply: Reply) {
const start = performance.now()
try {
const order = await createOrder(req.body)
ordersCreated.add(1, { channel: req.body.channel ?? 'web' })
return reply.code(201).send(order)
} finally {
// 单位要与声明一致,这里声明的是 ms
httpDuration.record(performance.now() - start, {
method: req.method,
route: req.routeOptions.url ?? 'unknown',
status: String(reply.statusCode),
})
}
}
record 的第二个参数是标签(attributes)。标签的取值集合必须是有限的,这是指标系统最重要的一条纪律,下一节展开。
17.2.3 暴露 /metrics 端点
OTel SDK 只负责收集,还要把它暴露成 Prometheus 能拉取的格式。最简做法是用 @opentelemetry/exporter-prometheus,它自带一个 HTTP 服务:
// src/telemetry.ts 中增加
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'
const prometheus = new PrometheusExporter({ port: 9464, endpoint: '/metrics' })
const sdk = new NodeSDK({
metricReader: prometheus,
// ...其余配置同 17.1
})
也可以把指标挂到业务服务的路由上,避免多开端口(在 K8s 里更容易配 NetworkPolicy):
import { PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
export function registerMetricsRoute(app: FastifyInstance, reader: PrometheusExporter) {
const serializer = new PrometheusSerializer()
app.get('/metrics', async (_req, reply) => {
const { resourceMetrics } = await reader.collect()
reply.header('content-type', 'text/plain; version=0.0.4')
return serializer.serialize(resourceMetrics)
})
}
拉取到的文本格式长这样,每一行是「指标名{标签} 值 时间戳」:
# HELP http_server_request_duration HTTP 请求耗时
# TYPE http_server_request_duration histogram
http_server_request_duration_bucket{method="GET",route="/orders",status="200",le="50"} 8921
http_server_request_duration_bucket{method="GET",route="/orders",status="200",le="100"} 10142
http_server_request_duration_sum{method="GET",route="/orders",status="200"} 486213.5
http_server_request_duration_count{method="GET",route="/orders",status="200"} 10245
这个端点必须排除在鉴权与限流之外,否则 Prometheus 拉取会收到 401,指标直接断流。同时它不应对外网暴露——指标里往往包含业务量级信息。
17.2.4 命名规范与标签基数
命名遵循「单位后缀 + 语义前缀」的约定:
| 规则 | 正例 | 反例 |
|---|---|---|
累计量用 _total 后缀 | orders_created_total | ordersCreated |
| 单位进名字或 unit 字段 | request_duration_ms | latency |
| 用点分域前缀分组 | http.server.request.duration | duration |
| 标签名不带单位 | {status="500"} | {status_code_num="500"} |
比命名更重要的是标签基数控制。基数 = 标签取值的笛卡尔积数量,它决定了时间序列的条数。一条序列在 Prometheus 里大约占几 KB 内存,基数失控会直接拖垮监控系统。
规则是:标签取值必须是「枚举」而不是「自由文本」。对照检查:
✅ 可以当标签:method、route(模板化后的路由名)、status、channel、region
❌ 不能当标签:user_id、order_id、url(含 query)、trace_id、error_message
route 是最容易踩的坑。如果直接记 req.url,那么 /orders/123、/orders/124 会各成一条序列,基数和用户数一样大。必须记模板化后的路由(/orders/:id),这也是前面代码里用 req.routeOptions.url 而不是 req.url 的原因。错误信息同理:error_message 里带订单号,基数立刻爆炸,正确做法是记 error_type 这种有限枚举。
高基数问题的排查与治理可以延伸阅读 Prometheus 高基数优化 。
17.2.5 用什么指标衡量服务
指标不缺数量,缺的是选择。业界有两套成熟的框架:
- RED:Rate(请求速率)、Errors(错误数)、Duration(耗时分布),适合请求驱动的服务;
- USE:Utilization(使用率)、Saturation(饱和度)、Errors(错误数),适合资源视角(CPU、内存、连接池)。
对大多数 TypeScript 后端,RED 是主力,USE 用于排查资源瓶颈。四项黄金信号则是更高层的归纳:延迟、流量、错误、饱和度。
落到仪表盘上,最小可用的一组查询长这样:
# 每秒请求数(按路由)
sum by (route) (rate(http_server_request_duration_count[5m]))
# 5xx 错误率
sum(rate(http_server_request_duration_count{status=~"5.."}[5m]))
/ sum(rate(http_server_request_duration_count[5m]))
# P95 延迟
histogram_quantile(0.95,
sum by (le, route) (rate(http_server_request_duration_bucket[5m])))
三条曲线放在同一屏上,基本就能判断服务健康度。注意 rate 的时间窗口至少要是采集间隔的 4 倍,采集间隔 15 秒就用 [1m] 起步;用 [5m] 是为了让曲线更平滑。
17.2.6 告警规则:从阈值到症状
写告警的第一原则是:告警必须对应一个用户能感知的症状,而不是一个原因。CPU 高本身不是问题,请求变慢才是。
对照检查一组规则的优劣:
| 规则 | 评价 |
|---|---|
| CPU 使用率 > 80% 持续 5 分钟 | 差:原因型告警,CPU 高但服务正常时纯属噪音 |
| 单条 5xx 日志出现 | 差:任何一次抖动都会响,必然被静音 |
| 5xx 错误率 > 5% 持续 5 分钟 | 好:直接对应「用户在报错」 |
| P95 延迟 > 1 秒持续 10 分钟 | 好:直接对应「用户觉得慢」 |
一条可用的 Prometheus 告警规则:
groups:
- name: order-service
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_server_request_duration_count{status=~"5.."}[5m]))
/ sum(rate(http_server_request_duration_count[5m])) > 0.05
for: 5m
labels:
severity: critical
team: backend
annotations:
summary: "订单服务 5xx 错误率超过 5%"
description: "当前错误率 {{ $value | humanizePercentage }},持续 5 分钟"
runbook_url: "https://wiki.internal/runbooks/order-5xx"
- alert: HighLatency
expr: |
histogram_quantile(0.95,
sum by (le) (rate(http_server_request_duration_bucket[5m]))) > 1000
for: 10m
labels:
severity: warning
annotations:
summary: "订单服务 P95 延迟超过 1 秒"
三个参数决定了告警的质量:
for:持续时间。它把「瞬时抖动」过滤掉,是降低噪音最有效的一招。错误率类告警用 5 分钟,延迟类用 10 分钟,因为延迟的自然波动更大。severity:分级决定通知渠道。critical走电话,warning走群消息,info只进面板。不要所有告警都发电话。runbook_url:没有处置手册的告警等于没写。收到告警的人第一件事应该是打开手册而不是开始猜。
告警发出后如何组织响应流程,可以延伸阅读 告警设计与事件响应 与 DevOps 监控与告警 。
17.2.7 SLO 与燃烧率告警
阈值告警的问题是「阈值从哪来」。5% 是不是合理的错误率?如果业务能接受 1% 呢?这套问题由 SLO(服务等级目标)回答。
SLO 的思路是:先定义 SLI(指标)与目标值,再算出错误预算。例如「99.9% 的请求成功」意味着每月允许 43 分钟不可用——这个额度就是错误预算。
有了预算,告警不再是「错误率超过 X」,而是「错误预算消耗过快」:
- alert: ErrorBudgetBurnFast
# 1 小时内消耗掉 30 天预算的 14.4 倍 → 约 2 天耗尽
expr: |
sum(rate(http_server_request_duration_count{status=~"5.."}[1h]))
/ sum(rate(http_server_request_duration_count[1h])) > 0.0144
for: 2m
labels:
severity: critical
annotations:
summary: "错误预算燃烧过快,按当前速度 2 天内耗尽月度预算"
这里的 0.0144 不是拍脑袋:30 天预算 × (1 - 0.999) / (1 小时 / 30 天) × 14.4 倍系数推出来的。多窗口多燃烧率是 SRE 的推荐做法——短窗口抓突发(1h + 14.4x),长窗口抓慢性劣化(6h + 6x),两者同时满足才触发,可以同时兼顾灵敏与稳定。
燃烧率告警的额外好处是它自带优先级:预算还剩很多时可以容忍,快耗尽时才值得半夜叫人。
17.2.8 常见坑
第一个坑是用 Gauge 记累计量。上一节讲过语义问题,这里补一个后果:Prometheus 对 Gauge 做 rate() 会得到无意义的波动曲线,而重启后 Gauge 归零会让曲线出现假尖峰。
第二个坑是标签里塞了会变的值。除了 user_id,还有一个隐蔽的:把 hostname 当标签。容器每次重建主机名都不同,几周后序列数会缓慢膨胀到失控。正确做法是用 pod 这类会被回收的标签,并配置保留策略。
第三个坑是**rate 窗口小于采集间隔**。采集 30 秒一次却写 rate(x[15s]),会频繁出现「无数据」或尖刺,因为窗口里根本没有两个样本点。
第四个坑是直方图分位数跨标签聚合。histogram_quantile 必须带 sum by (le),否则分位数计算会跨序列乱聚合,结果偏小。这也是 histogram_quantile 最常见的误用。
第五个坑是告警没有恢复通知。只配了 firing 没配 resolved,团队收到告警后不知道什么时候恢复,只能手动去查。Alertmanager 默认会发 resolved,但很多自建通知渠道把它丢了。
第六个坑是指标与追踪的标签不一致。追踪里用 order.id、指标里用 orderId,排障时无法互相跳转。建议在项目里维护一份「语义属性清单」,两边共用。
把本节与上一节串起来:trace 提供个案证据,metrics 提供整体视图,告警则是在整体视图上画的一条「需要人介入」的线。下一节我们把视角从「运行时可见性」转到「交付安全」,讨论依赖供应链与应用的加固——毕竟一个被投毒的依赖,可以让前面所有可观测性都变成「看着服务正常地泄露数据」。
小结
本节的核心是:指标要少而准,告警要指向症状而非原因。
- 仪器类型只有三种:只增不减用 Counter、可增可减用 Gauge、看分布用 Histogram,选错会让数据失去意义;
- Histogram 的桶边界必须按业务延迟量级定制,默认桶对多数 API 太粗;
- 标签取值必须是有限枚举,
route要用模板化路由名而非req.url,高基数字段一律不进标签; /metrics必须排除鉴权与限流,否则 Prometheus 拉取会断流;- 衡量服务用 RED(速率、错误、耗时)为主,USE 为辅,四项黄金信号是更高层归纳;
- 告警对应「用户能感知的症状」,
for持续时长是降噪第一手段,每条告警都要配 runbook; - SLO 与燃烧率告警把「阈值从哪来」变成「错误预算消耗多快」,多窗口多燃烧率兼顾灵敏与稳定;
- 直方图分位数必须
sum by (le),rate窗口至少是采集间隔的 4 倍。
阅读导航:上一节:17.1 OpenTelemetry 追踪 · 下一节:17.3 依赖供应链与应用安全加固 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。