指标:时间序列的量化哲学
上一篇里的监控,给出的是一个布尔判断——健康,还是不健康。但真实世界很少是非黑即白的。磁盘不是”满”或”不满”,它是”还剩 23%,而且过去一小时掉了 5%“;服务不是”快”或”慢”,它是”P99 是 340 毫秒,比昨天高了 80 毫秒”。
指标(metrics)就是把这种程度量化下来的范式。它没有推翻监控的世界观——你还是要预先决定测什么、还是要设阈值告警——但它把那个粗糙的布尔状态,升级成了连续的、可查询的数字。这一升级换来了趋势、容量规划和精细告警的能力,也埋下了一道它永远翻不过去的墙。理解这道墙在哪,是理解为什么会有 可观测性的关键。
一、从布尔到数字:指标在做什么
监控说”坏了没”,指标说”坏到什么程度”
监控的检查是一道判断题:check_disk 返回 OK / Warning / Critical。它在阈值的某一侧给你一个离散的答案。
指标问的是一道计算题:磁盘的可用百分比此刻是多少?它一分钟前是多少?一小时前呢?指标不告诉你”坏了没”,它给你一条曲线,让你自己看出趋势——磁盘正在以每小时 5% 的速度下降,于是你能在它真正满之前就知道”还有四个小时”。
这是一次认知层级的跃迁:从”系统现在是否越线”,到”系统正在往哪个方向走、走多快”。监控回答现状,指标回答趋势。
指标的定义:一个数随时间的变化
把这件事收紧成定义:一个指标,就是一个数值随时间的变化——一条时间序列(time series)。
它的最小单元是一个二元组:(时间戳, 数值)。每隔一段固定的间隔(比如 15 秒)采样一次,把这些点连起来,就是一条序列。cpu_usage 是一条,http_requests_per_second 是一条,memory_bytes 是一条。整个指标系统,就是在高效地存储、查询、聚合海量的这种”数随时间走”的曲线。
力量与盲区同源
和监控一样,指标的力量和盲区也是同一件事的两面,根源在于那个”数”:
指标能精确回答”多少”,但答不出”哪一个”。
它能告诉你”过去一小时有 1% 的请求超过了 1 秒”——这是力量。但它答不出”超时的是哪 1% 的请求、属于哪些用户、走的哪条代码路径”——这是盲区。原因在第二章会变得很清楚:当一个请求被并进”每秒请求数”这个数字里时,它作为”一个具体请求”的身份,就已经永久地消失了。
二、数据模型:时间序列的解剖
指标的全部哲学,都藏在它的数据模型里。把这个模型解剖清楚,后面的力量和边界就都是它的推论。
一条时间序列长什么样
在现代指标系统(以 Prometheus 为范本)里,一条时间序列由三部分构成:
http_requests_total{method="POST", status="200", handler="/api/checkout"} → [(t1, 1532), (t2, 1547), ...]
└── 指标名 ──┘ └────────────── 标签(labels)──────────────────┘ └──── 样本点序列 ────┘- 指标名(metric name):测的是什么——
http_requests_total。 - 标签(labels):一组键值对,给这个测量加上维度——这是 POST 请求、状态码 200、打到了
/api/checkout。 - 样本点(samples):
(时间戳, 值)的序列。
关键在于:指标名 + 一组具体的标签值,唯一确定一条时间序列。 {method="POST"} 和 {method="GET"} 是两条不同的序列。这一点看似平淡,却是后面”基数爆炸”的全部根源——记住它。
四种类型:counter / gauge / histogram / summary
不是所有的”数”都以同样的方式变化。指标系统区分四种类型,每一种对应一类被测量的物理量:
- Counter(计数器):只增不减的累积值——请求总数、错误总数、发送的字节数。它的值本身没意义(
http_requests_total = 1532告诉不了你什么),有意义的是它的变化率——所以你几乎总是对 counter 用rate()。进程重启会让它归零,查询时要能识别这种 reset。 - Gauge(仪表盘):可增可减的瞬时值——当前内存占用、队列长度、温度、在线连接数。它的值本身就有意义,你直接看它、对它求平均或最大。
- Histogram(直方图):把观测值分到预设的”桶”里计数——比如把请求延迟分到
≤0.1s、≤0.5s、≤1s、≤+∞几个桶。它让你能在查询时估算任意分位数(P50、P99),而且多个实例的直方图可以相加后再算分位数。代价是分位数是估算的,精度取决于桶的划分。 - Summary(摘要):和直方图类似,但分位数在客户端就算好了。好处是精确,坏处是算好的分位数不能跨实例相加(你没法把两台机器各自的 P99 合成一个总 P99)。
这四种类型的区分不是吹毛求疵——选错了,你的查询要么算不出、要么算错。histogram 和 summary 在 P99 上的这个差异(能不能跨实例聚合),是实践中最常踩的坑。
预聚合的本质:落盘前丢掉了什么
现在触及指标最核心、也最容易被忽视的一个性质:预聚合(pre-aggregation)。
当你的服务每秒处理一万个请求,指标系统不会存一万条记录。它只会让 http_requests_total{status="200"} 这条序列的值,每秒加上对应的数量。一万个各不相同的请求——不同的用户、不同的参数、不同的耗时——在被计入这个数字的瞬间,坍缩成了一次 +N。它们作为独立个体的全部信息,在数据落盘之前就被丢弃了。
这就是”预聚合”:在存储之前,就把原始事件按维度聚合成数字。 这笔交易的账要算清楚:
- 收益:存储成本不和请求量挂钩,而和序列数挂钩。无论这条序列每秒被加 10 次还是 10 万次,它占的存储几乎一样——一串
(时间戳, 值)。于是成本可预期、查询极快。 - 代价:你永远拿不回那些被坍缩掉的细节。而且,序列数本身成了新的成本变量——这就引出了**基数(cardinality)**这个贯穿全篇的物理量:一个指标的成本,正比于它能产生的不同序列的数量,也就是各个标签取值的笛卡尔积。 第五章我们会看到,这个笛卡尔积是如何失控的。
三、经典实践:Graphite / StatsD 的分层世界
理论必须落到工具。在 Prometheus 一统江湖之前,指标的世界由 Graphite、StatsD、RRDtool 统治。看懂它们怎么做,你才会明白 Prometheus 的”多维”到底革了谁的命。
push 模型:应用主动吐数据
经典指标栈是 push 模型:应用自己在代码里,把测量主动”推”出去。最典型的搭配是 StatsD——应用发一个 UDP 包给本地的 StatsD daemon:
# 应用代码里,每处理一个结账请求就发一行
checkout.requests:1|c # counter,+1
checkout.latency:340|ms # timing,本次耗时 340msStatsD 在内存里按一个时间窗口(比如 10 秒)聚合这些事件——算出这段时间的请求数、平均/分位延迟——再把聚合结果转发给后端 Graphite 存储。注意:聚合发生在 StatsD 这一步,又是一次预聚合。UDP 是故意的——发完就不管,宁可丢几个包,也不让监控拖慢业务。
分层命名:维度藏在名字里
Graphite 存储和组织指标的方式,是分层的点分命名——像文件路径一样:
servers.web01.cpu.load
servers.web01.disk.root.free
servers.web02.cpu.load
checkout.requests.count
checkout.latency.p99要”看所有 Web 服务器的 CPU”,你用通配符:servers.*.cpu.load。这套体系直观、好上手,在”一台机器一个名字”的年代非常顺手。
RRDtool:固定空间换可预期的磁盘
更早的 RRDtool(Round-Robin Database,Cacti/Munin 的底座)把”预聚合”推到了极致。它的数据库大小固定:创建时就规定好——最近 1 天存 1 分钟精度,最近 1 周存 5 分钟精度,最近 1 年存 1 小时精度。数据超期,就被自动降采样(rollup)成更粗的精度,覆盖掉老数据。
这是一个极聪明的资源取舍:用”越老的数据越模糊”换”磁盘占用永远恒定”。 你提前一次性付清了存储成本,代价是历史精度的逐级流失。这在磁盘金贵的年代是理性的——它把 存储这根标尺拨到了极致的资源端。
死穴:维度被焊死在名字里
但分层命名有一个致命的结构性缺陷:维度被编码进了名字本身,再也切不开。
servers.web01.cpu.load 这个名字里,“哪台机器”(web01)和”测什么”(cpu.load)被焊死在了一起。现在你想问一个稍微复杂点的问题——“按机房分组,看所有生产环境 Web 服务器的 CPU”——你做不到,因为”机房”和”环境”这两个维度,当初根本没进名字。要支持它,你得重新设计命名:servers.prod.us-east.web01.cpu.load。可一旦维度的顺序或集合变了,所有历史查询和告警全部失效。
命名即 schema,而这个 schema 是僵硬的。 你能切的维度,必须在埋点时就想好、并固化进名字的层级里。这恰恰是监控范式”预判”的味道又回来了——只不过这次要预判的,是”我将来想按哪些维度切分”。这个死穴,就是下一章 Prometheus 要解决的核心问题。
四、云原生实践:Prometheus 的多维革命
Prometheus 在 2012 年从 Graphite 的世界里杀出来,靠的不是更快或更省,而是一个数据模型上的根本变革——多维标签。今天它是云原生指标的事实标准。
pull 模型与服务发现
Prometheus 反转了数据的流向:不是应用 push,而是 Prometheus 主动 pull(scrape)。每个被监控的目标暴露一个 /metrics HTTP 端点,Prometheus 按配置周期性地去抓:
scrape_configs:
- job_name: 'checkout-service'
scrape_interval: 15s
kubernetes_sd_configs: # 服务发现:自动找到所有 pod
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: checkout
action: keeppull 模型在云原生里有个决定性的好处:目标是动态的。 pod 起起停停、扩容缩容,靠 kubernetes_sd_configs 服务发现,Prometheus 自动发现新目标、丢弃消失的目标——你不用维护一份机器清单。(代价是短命任务,比如一个批处理 job,可能还没被 scrape 到就结束了,这类要靠 Pushgateway 补救。pull/push 各有适用场景,不是谁绝对更优。)
多维标签:把维度从名字里解放出来
这是革命的核心。同一个指标 http_requests_total,Prometheus 用标签携带任意多个维度:
http_requests_total{method="POST", status="200", handler="/checkout", env="prod", dc="us-east"}把它和前面 Graphite 的 servers.web01.cpu.load 对比:经典模型里”哪台机器”焊死在名字里,Prometheus 里它只是一个标签 instance="web01",和其他维度平权。维度从名字里被解放了出来,变成了可以在查询时任意组合、聚合、切分的自由变量。 前面那个”按机房分组看生产 Web 的 CPU”的问题,在这里只是一次普通查询——只要 dc 和 env 当初作为标签存在。
代价你已经预感到了:每一个标签组合都是一条独立的序列(回到前面那条序列的定义)。标签给了你自由,也给了你前面那个笛卡尔积——这把双刃剑,第五章会见血。
PromQL:在维度上聚合与切片
有了多维数据,就需要一种能在维度上运算的查询语言。PromQL 就是干这个的。三个最常用的例子,覆盖了日常 90% 的查询:
# 1. 每秒请求速率——counter 必须配 rate()
rate(http_requests_total[5m])
# 2. 按 handler 聚合后的错误率
sum by (handler) (rate(http_requests_total{status=~"5.."}[5m]))
/ sum by (handler) (rate(http_requests_total[5m]))
# 3. 从直方图估算 P99 延迟
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))读这三行,前面讲的类型知识全用上了:rate() 对付 counter,by (handler) 在维度上做聚合切片,histogram_quantile + by (le) 把多个实例的直方图先相加再算分位数——这正是前面说的 histogram 能跨实例聚合、而 summary 不能的那个性质在发挥作用。
RED 与 USE:到底该测什么
多维标签解决了”怎么存、怎么查”,但还有一个更前置的问题:该测什么? 漫无目的地埋指标,只会得到一堆没人看的曲线。业界沉淀出两套互补的方法论:
- RED(面向请求的服务):Rate(每秒请求数)、Errors(错误率)、Duration(延迟分布)。任何一个对外提供服务的组件,盯住这三个就抓住了用户体验的命脉。
- USE(面向资源):Utilization(利用率)、Saturation(饱和度,比如队列等待)、Errors(错误)。任何一种资源(CPU、内存、磁盘、网络),盯住这三个就能判断它是不是瓶颈。
RED 看的是”服务对用户怎么样”,USE 看的是”资源对服务怎么样”。它们本质上是在替你做 监控范式里那张"故障清单"——只不过把”列举具体故障”升级成了”覆盖几个普适维度”。预判还在,只是更系统了。
用指标告警:recording rule + alert rule
指标的终点之一,是回到告警——但这次是建立在连续量之上的精细告警。Prometheus 用两类规则:
groups:
- name: checkout-slo
rules:
# recording rule:把贵的查询预先算好、存成新序列
- record: job:checkout_error_ratio:rate5m
expr: |
sum(rate(http_requests_total{job="checkout", status=~"5.."}[5m]))
/ sum(rate(http_requests_total{job="checkout"}[5m]))
# alert rule:基于上面的比率告警
- alert: CheckoutHighErrorRate
expr: job:checkout_error_ratio:rate5m > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "结账错误率超过 5%,已持续 5 分钟"把它和 02 的 Nagios 阈值对照:内核一模一样——判据(expr)、去抖(for)、分级(severity)。指标没有改变”基于原因设阈值”这个告警哲学,它只是让判据可以是任意复杂的 PromQL 表达式,而不再是一个简单的数。注意这里的 > 0.05 仍然是一个拍脑袋的阈值——这个”5% 凭什么”的问题,要到 SLO才被真正回答。
五、边界:高基数的墙与”哪一个”的失明
指标如此强大,以至于人们总想让它回答更多问题。而它每一次说”不”,都指向同一道墙——预聚合。这一章,我们亲手撞墙。
★ 故障剧场:给指标加一个 user_id 标签
你的结账服务出了问题,你想知道”是不是某些特定用户受影响”。一个看似自然的念头冒出来:给指标加个用户维度。
# 改之前:少数几条序列
http_requests_total{handler="/checkout", status="200"}
# 改之后:每个用户一条独立序列
http_requests_total{handler="/checkout", status="200", user_id="8f3a..."}部署上线。几个小时后,Prometheus 的内存开始疯涨,scrape 变慢,查询超时,最后 OOM 崩溃。
发生了什么?你的服务有一百万个用户。加上 user_id 之前,http_requests_total 也许只有几十条序列(handler × status 的组合);加上之后,它变成了 几十 × 一百万 = 数千万条序列。每一条序列都要在内存里维护状态、在磁盘上占空间。这就是基数爆炸——而它是 user_id 这一个标签亲手引爆的。
基数爆炸:为什么维度不能随便加
回到前面那个物理量:指标的成本正比于序列数,而序列数是所有标签取值的笛卡尔积。method(5 种)× status(10 种)× handler(20 种)= 1000 条,完全可控。可一旦混进一个高基数标签——user_id(百万级)、request_id(无限)、完整 URL(无限)、IP 地址(海量)——笛卡尔积瞬间失控。
所以指标系统有一条铁律:标签只能用低基数的维度。 能枚举的、取值有限的(方法、状态码、机房、环境)可以做标签;唯一标识一个个体的(用户、请求、会话),绝不能。这条铁律不是 Prometheus 的实现缺陷,是预聚合模型的数学本性——它逼着你在埋点时,就把”个体”挡在门外。
“哪一个”的失明
前面故障剧场里那个需求——“是不是某些特定用户受影响”——指标在原理上就回答不了。这正是开篇埋下的盲区现在结的果:
指标能告诉你”结账错误率涨到了 8%“。但是哪些用户?哪条代码路径?带了什么参数?和正常请求有什么共同点?——这些问题的答案,藏在那些早已被预聚合坍缩掉的原始细节里。指标手上根本没有这些数据,它只有数字。P99 升高了,它能精确地告诉你”升高了多少”,却对”是哪 1% 的请求”彻底失明。
你不可能从聚合后的数字,反推出聚合前的个体。 这是信息论意义上的不可逆——这道墙,指标永远翻不过去。
它仍在经典范式里
退一步看清指标的身份:尽管它比监控强大得多,它的世界观和监控是同一个。你依然要预先决定测什么(埋哪些指标、用 RED/USE 选维度)、预先决定什么算坏(设阈值告警)。它没有解决”未知的未知”——一个你没埋指标的故障维度,指标和监控一样对它沉默。
指标是监控的量化升级,不是它的替代。 它把布尔变成了连续量,把单点检查变成了趋势曲线,但它没有跨过那条线——预判依然是前提,个体依然被丢弃。真正跨过这条线,要等到 可观测性。
六、小结:坐标与去向
指标在五维标尺上的坐标
| 维度 | 监控 | 指标 |
|---|---|---|
| ① 故障预判权 | 人预判 | 人预判(没变,RED/USE 让预判更系统) |
| ② 存储汇率 | 只存状态 | 极致预聚合——按序列存,丢弃个体 |
| ③ 信号分工 | 单一布尔 | 量化的单一信号——更丰富,但仍是聚合数字 |
| ④ 注意力阀门 | 基于原因 | 基于原因——阈值更精细,但仍对着原因 |
| ⑤ 仪器化负担 | 手写检查 | 手写埋点——仍要人显式 instrument |
五根标尺整体仍在资源端,只是②被打磨得更精致。指标把人与资源的天平,依然压在省存储、靠人预判的一边——它是经典范式更锋利的那把刀,不是另一把刀。
去向
指标的边界,恰好定义了后面三篇要去的地方:
- 当你需要的不是”多少”,而是某个事件到底发生了什么——完整的上下文、参数、结果——你需要 日志。
- 当你不满足于”5% 凭什么”,想把可靠性从拍脑袋的阈值变成可计算、可协商的工程目标——指标之上会长出 SLO。
- 当你撞上前面那道”哪一个”的墙,决心保留个体、支持事后任意提问——你就要跨进 可观测性,付出高基数的存储代价,换回指标永远给不了的东西。
那个贯穿全篇的问题,最后一次:指标把天平推向了哪边?省资源、靠人预判——和监控同一边,只是站得更稳、看得更细。下一篇日志,会第一次把天平往”留下个体”的方向,挪动一格。
返回 可观测性与运维工程 MOC | 上一篇 02-监控-预知故障的经典范式 | 下一篇 04-日志-结构化事件的叙事力量