深入理解 Prometheus: 从 SDK 埋点、TSDB 底层存储到 PromQL 计算引擎全解析

深入理解 Prometheus: 从 SDK 埋点、TSDB 底层存储到 PromQL 计算引擎全解析

对于习惯了关系型数据库(MySQL)和常规 RPC/HTTP 服务端开发的后端工程师来说, 初次接触 Prometheus 往往会有一种“割裂感”: 为什么业务代码里调了埋点 API 却抓不到外发的网络包? 为什么 Prometheus 不连数据库就能存下数亿条监控数据? 为什么查监控不用 SQL 而要用一套长相奇怪的 PromQL?

本文将顺着一条监控数据从产生、传输、落盘到最终在 Grafana 上渲染出图表的完整生命周期, 从底层数据结构与系统设计的视角, 彻底拆解 Prometheus 的核心原理.


一、 应用侧埋点与传输: SDK 到底在业务进程里干了什么?

很多刚接触监控的后端同学会有一个直觉误区: 以为在 Java 或 C/C++ 代码里调用 counter.inc() 时, SDK 会通过网络(如 TCP/UDP)异步把这条数据发给 Prometheus.

事实上, 在业务代码执行埋点操作的那一刻, 没有任何网络 I/O 发生.

1. 埋点的本质: 纯内存的并发安全状态聚合

无论你使用的是 Java 的 client_java / Micrometer, 还是 C++ 的 prometheus-cpp, Prometheus SDK 在你的业务进程内部扮演的角色, 仅仅是一个内存状态注册表(In-Memory Registry).

当你在代码中初始化一个指标并调用埋点方法时, SDK 只是在本地进程堆内存中维护了一组无锁(Lock-free)或细粒度锁的并发数据结构:

// 以 C++ SDK 的 Counter 核心内存模型为例(简化伪代码)
class Counter {
private:
    // 底层就是一个支持 CAS 原子更新的 64 位双精度浮点数
    std::atomic<double> value_{0.0}; 

public:
    // 业务线程调用 inc() 时, 仅仅执行一次 CPU 原子指令, 耗时在纳秒级
    void Increment(double val = 1.0) {
        double current = value_.load(std::memory_order_relaxed);
        while (!value_.compare_exchange_weak(current, current + val));
    }
};

Prometheus 定义的四种核心指标类型, 在应用内存中的真实形态如下:

指标类型 应用进程内存中的数据结构 业务调用埋点时的真实动作 典型工程场景
Counter(计数器) 单个原子浮点数(atomic double 或 Java 的 DoubleAdder) 在本地内存对变量做原子累加(只增不减, 进程重启归零) 请求总数、异常总数、任务完成数
Gauge(仪表盘) 单个原子浮点数 在本地内存对变量做原子加、减或直接赋值(可增可减) 当前内存占用、活跃连接数、队列长度
Histogram(直方图) 一个固定长度的原子计数器数组(代表各个 le 桶边界, 如 10ms、50ms、100ms)+ _sum 累加器 + _count 计数器 拿到本次请求耗时后, 在内存数组中通过二分查找命中对应的 Bucket, 并将对应的数组元素原子 +1 接口 RT 延迟分布(由服务端聚合计算 P99/P95)
Summary(摘要) 内存中的滑动时间窗口采样缓冲区(基于 CKMS 等流式分位数算法) 将样本塞入本地滑动窗口, 在客户端本地实时近似计算出 P99/P95 值 极少使用(客户端 CPU 开销大, 且分位数无法跨实例二次聚合)

2. 数据是如何传输给 Prometheus 的?

既然埋点只是改内存, 那数据怎么出去?

SDK 除了提供埋点 API 外, 还会在你的业务进程内挂载一个极轻量的 HTTP Handler(默认路由通常为 /metrics). 当且仅当收到外部发来的 HTTP GET 请求时, SDK 才会遍历内存中的 Registry, 将当前这一瞬间所有指标的内存快照, 序列化为一段纯文本格式(Text-based Exposition Format)(新版本也支持 Protobuf 二进制格式), 作为 HTTP Response Body 返回:

# HELP http_requests_total The total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="post",code="200"} 1027
http_requests_total{method="post",code="400"} 3

# HELP http_request_duration_seconds A histogram of the request duration.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.05"} 24054
http_request_duration_seconds_bucket{le="0.1"} 33444
http_request_duration_seconds_bucket{le="+Inf"} 144320
http_request_duration_seconds_sum 53423
http_request_duration_seconds_count 144320

注意观察上面的文本: 业务应用暴露出来的文本里通常是不带时间戳的, 它只负责汇报“此时此刻我内存里的数值是多少”.


二、 部署了 Prometheus 的机器如何与应用交互?

在网络拓扑上, Prometheus 采用的是典型的 HTTP Pull(拉取)模型.也就是说, 部署了 Prometheus 的机器是 HTTP Client, 而你的业务服务是 HTTP Server.

+-----------------------------------+                  +----------------------------------+
|         业务应用进程 (Target)       |                  |         Prometheus Server        |
|                                   |                  |                                  |
|  [业务线程] ---> 原子更新内存变量    |                  |  [Service Discovery] 动态感知实例 |
|                     |             |                  |                 |                |
|                     v             |   HTTP GET       |                 v                |
|            [In-Memory Registry]   | <--------------- |  [Scrape Loop 协程池] (每 15s)    |
|                     |             |   /metrics       |                 |                |
|                     v             |                  |                 v                |
|            [HTTP /metrics 接口] ---|----------------> |  解析文本 -> 注入时间戳 -> 写 TSDB |
+-----------------------------------+   纯文本/Gzip流    +----------------------------------+

1. 完整交互与抓取(Scrape)链路

Prometheus Server 与应用的交互由以下三个核心机制协同完成:

  1. 服务发现(Service Discovery, SD):
    在云原生或微服务集群中, 业务机器的 IP 和端口是动态扩缩容的. Prometheus 内部集成了 Consul、Kubernetes API、DNS 以及基于文件的服务发现机制, 实时监听集群变化, 动态维护一份当前在线的目标实例列表(Targets).
  2. 独立协程定时拉取(Scrape Loop):
    Prometheus 使用 Go 语言编写, 它会为每一个被监控的目标实例(Target)启动一个独立的 Goroutine(scrapeLoop). 按照配置文件中设定的 scrape_interval(例如 15s), 该协程会定期向目标的 http://ip:port/metrics 发起 HTTP GET 请求(默认开启 Gzip 压缩以节省内网带宽). 为了防止成千上万个 Target 在同一毫秒发起请求打满网卡(惊群效应), Prometheus 会对每个 Target 的标识做哈希计算, 将拉取时间均匀打散在整个 15 秒周期内.
  3. 服务端打标与时间戳对齐:
    拿到应用返回的文本流后, Prometheus 的解析器会在内存中逐行解析, 由 Prometheus Server 统一打上当前抓取时刻的 Unix 毫秒时间戳(int64), 并自动为这批数据注入代表来源的元标签(如 instance="10.0.1.5:8080"、job="order-service"), 随后送入底层的存储引擎.

2. 架构思考: 为什么坚持用 Pull(拉)而不是 Push(推)?

作为后端开发者, 你可能会问: 让应用直接通过长连接把监控数据 Push 到监控中心不是更实时吗? Prometheus 选择 Pull 模型有三个极其务实的工程考量:

  • 天然的过载保护与反压(Backpressure): 在流量洪峰或雪崩发生时, 如果是 Push 模型, 成千上万个业务节点会疯狂向监控中心发包, 不仅容易把监控系统打挂, 一旦监控中心响应变慢, 客户端侧的网络缓冲区积压甚至会反向拖垮业务进程. 而在 Pull 模型下, 主动权完全在 Prometheus 手里, 业务侧只管改自己的内存, 哪怕 Prometheus 彻底宕机, 对业务系统也是零影响.
  • 自带存活探针(Health Check): 因为是主动拉取, Prometheus 每次抓取完一个 Target, 都会自动在内部生成一条名为 up{job="...", instance="..."} 的指标. 拉取成功记为 1, 连接拒绝或超时记为 0. 无需额外部署心跳组件, 就能直接监控全网实例的死活.
  • 短生命周期任务的补充(Pushgateway): 唯一不适合 Pull 的场景是执行时间只有几秒的定时脚本或离线批处理任务(还没等 Prometheus 15 秒一次轮询过来, 进程就已经退出了). 针对这种场景, Prometheus 提供了官方中转组件 Pushgateway——短任务在退出前将数据主动 Push 给 Pushgateway 暂存, Prometheus 依然保持统一的 Pull 逻辑去拉取 Pushgateway.

三、 Prometheus 存储数据的维度是什么?

在深入底层磁盘格式之前, 必须先厘清 Prometheus 的数据模型(Data Model).

传统关系型数据库按“表(Table)→ 行(Row)→ 列(Column)”组织数据; 而 Prometheus 是一个纯粹的多维时间序列数据库(Multi-dimensional TSDB), 它的数据模型只有两个概念: 时间序列(Time Series) 与 样本(Sample).

1. 维度的数学表达: Metric Name + Labels

在 Prometheus 中, 每一条唯一的时间序列, 由一个“指标名称(Metric Name)”加上一组无序的“键值对标签(Labels)”唯一确定:

\[\text{Time Series Identifier} = \text{Metric Name} + \{ \text{Label}_1=\text{"Val}_1\text{"}, \text{Label}_2=\text{"Val}_2\text{"}, \dots \} \]

实际上, 在 Prometheus 的底层源码实现中, 连 Metric Name 本身也只是一个特殊的内置 Label, 它的 Key 固定叫做 __name__.也就是说, 下面这两种写法在底层内存中是完全等价的:

# 表面语法
http_requests_total{job="payment", instance="10.0.1.5:8080", method="POST", status="200"}

# 底层真实存储的维度结构(按 Key 字典序排列的键值对集合)
{__name__="http_requests_total", instance="10.0.1.5:8080", job="payment", method="POST", status="200"}

什么是“维度”? 每一个 Label 的 Key 就是一个正交的维度!

  • 一旦上述集合中的任何一个键或值发生变化(比如 status="200" 变成了 status="500", 或者 instance 换了一个 IP), 在 Prometheus 眼里, 这就不再是原来那条数据, 而是开辟了一条全新的、完全独立的时间序列(Time Series).

2. 时间序列上的数据点: Sample(样本)

当一组 Label 集合确定了一条唯一的“时间序列”后, 每次 Prometheus 抓取到的数据, 就是往这条时间序列的末尾追加一个 Sample(样本点). 一个样本点的结构极其精简, 固定占 16 Bytes:

  • Timestamp(时间戳): int64 类型, 精确到毫秒的 Unix 时间戳(8 Bytes).
  • Value(数值): float64 类型, 遵循 IEEE 754 标准的双精度浮点数(8 Bytes). (这也是为什么 Prometheus 里没有整型、字符串型的指标值, 所有数值在底层一律是 float64).

如果我们把 Prometheus 的存储维度画在二维坐标系上, 它的物理意义一目了然:

维度标识 (Series / 纵轴)                                  时间轴 (Timestamp -> Sample Value / 横轴)
---------------------------------------------------------------------------------------------------------
Series 1: {__name__="http_req", method="GET",  code="200"}  --> (t1, 10.0) -> (t2, 15.0) -> (t3, 22.0)
Series 2: {__name__="http_req", method="POST", code="200"}  --> (t1,  4.0) -> (t2,  5.0) -> (t3,  5.0)
Series 3: {__name__="http_req", method="POST", code="500"}  --> (t1,  0.0) -> (t2,  1.0) -> (t3,  1.0)

3. 后端埋点第一铁律: 警惕“高基数(High Cardinality)”爆炸

理解了“维度”的本质, 你就明白为什么资深架构师严禁在 Prometheus 的 Label 里塞入 user_id、order_id、trace_id 或未归一化的完整 URL 路径.

因为总时间序列的数量(即 基数 Cardinality)等于所有 Label 维度取值的笛卡尔积:

\[\text{Total Series} = \vert{}\text{Instances}\vert{} \times \vert{}\text{Methods}\vert{} \times \vert{}\text{Status Codes}\vert{} \times \vert{}\text{User IDs}\vert{} \]

假设你有 50 个实例、4 种 HTTP 方法、10 种状态码, 这只有 \(50 \times 4 \times 10 = 2000\) 条时间序列, 毫无压力; 但如果你把拥有 100 万用户的 user_id 作为一个 Label 放进去, 时间序列数量瞬间膨胀到 20 亿条!每一条时间序列在内存中都需要维护独立的结构体和索引项, 这会直接把 Prometheus 节点的内存撑爆(OOM).


四、 Prometheus 的底层存储引擎: TSDB 二进制格式与压缩原理

拿到抓取的数据后, Prometheus 是怎么存的?
Prometheus 没有使用 MySQL、RocksDB 或任何外部数据库, 而是自己在内部从零实现了一套专门针对时序数据定制的存储引擎——TSDB(Time Series Database), 直接以自定义的二进制格式操作磁盘文件.

为什么要自研? 因为监控数据的读写特征极其极端: 写多读少、严格按时间顺序追加(Append-only)、几乎没有随机修改、且同一条序列相邻两个点的数据高度相似. 通用的 B+ 树或普通 KV 引擎根本无法达到所需的压缩率和吞吐量.

1. 宏观存储架构: LSM-Tree 变体(Head Block + 不可变磁盘块)

Prometheus 借鉴了 LSM-Tree(Log-Structured Merge-Tree)的思想, 将数据按时间窗口(默认每 2 小时为一个 Block)在水平方向上进行切分:

                        [ Prometheus TSDB 存储引擎架构 ]

   新抓取的数据流
        |
        +-----------------------+
        |                       | (1. 先顺序写 WAL 磁盘日志防掉电)
        v                       v
+---------------+       +---------------+
|  Head Block   |       |   WAL (磁盘)   |
|  (纯内存读写)   |       | 128MB 分段文件 |
| 最近 2 小时数据 |       +---------------+
+---------------+
        |
        | (2. 每满 2 小时, 将内存数据刷盘切分为不可变 Block)
        v
+-----------------------------------------------------------------------+
| 磁盘目录 (data/)                                                        |
|                                                                       |
|  +-----------------------+  +-----------------------+                 |
|  | Block: 01BKGT... (2h) |  | Block: 01BKGV... (2h) |  ... (后台定期合并)|
|  |  ├── meta.json        |  |  ├── meta.json        |                 |
|  |  ├── chunks/ (样本数据) |  |  ├── chunks/          |                 |
|  |  ├── index   (倒排索引) |  |  ├── index            |                 |
|  |  └── tombstones       |  |  └── tombstones       |                 |
|  +-----------------------+  +-----------------------+                 |
+-----------------------------------------------------------------------+

  1. 内存态(Head Block)与 WAL:
    所有最新抓取进来的样本, 永远只写入内存中的 Head Block(因此写入速度极快). 为了防止机器断电丢失内存中最近 2 小时的数据, 在写入 Head Block 的同时, 数据会以二进制追加写的方式落入磁盘的 WAL(Write-Ahead Log, 预写日志) 目录中(每个段文件 128MB).
  2. 磁盘态(Immutable Blocks):
    每过 2 小时, Head Block 中积攒的数据会被冻结, 经过压缩后刷入磁盘, 形成一个以全局唯一 ULID 命名的独立目录(例如 01BKGTZQ1SYQJTR4PB43C8PD98/). 一旦落盘, 这个 Block 就是绝对不可变的(Immutable), 查询时直接通过 Linux 系统调用 mmap() 将磁盘文件映射到虚拟内存地址空间, 实现零拷贝读取. 后台的 Compactor 协程会定期将多个 2 小时的小 Block 合并成 6 小时、18 小时的大 Block, 并直接按目录删除超过保留期(默认 15 天)的老旧 Block, 完全避免了传统数据库 DELETE 带来的磁盘碎片问题.

2. 二进制文件拆解: index 倒排索引与 chunks 数据块

打开任意一个 2 小时的 Block 目录, 最核心的就是两个高度定制的二进制文件: index(索引文件) 和 chunks/000001(原始样本块文件).

(1)如何解决长字符串冗余与快速检索? —— Series ID 与倒排索引(Inverted Index)

如果每个样本点都把 {__name__="http_requests_total", instance="10.0.1.5:8080", ...} 这串长字符串往磁盘里写一遍, 磁盘空间瞬间就会被撑爆.

Prometheus 的解法非常巧妙:

  • 分配整数 ID(Series ID): 每一条独特的 Label 组合, 在当前 Block 内都会被分配一个唯一的 64 位无符号整数 Series ID(比如 Series 1、Series 2). 在真正存数据的 chunks 文件里, 根本不存任何标签字符串, 只存 Series ID 对应的纯数字样本流!
  • 构建倒排索引(index 文件): 为了能根据查询条件找到对应的 Series ID, Prometheus 将搜索引擎(如 Lucene/Elasticsearch)中的倒排索引(Inverted Index)直接搬进了 TSDB. 在二进制 index 文件中, 维护了每一个 Label Key=Value 到包含该标签的所有 Series ID 有序列表(称为 Posting List)的映射:
[ Symbol Table & Label Index (倒排索引表) ]
  __name__="http_requests_total"  ---> Posting List: [1, 2, 3, 4, 5, 8]
  job="payment"                   ---> Posting List: [1, 3, 5, 9]
  status="500"                    ---> Posting List: [2, 3, 7]

[ Series Table (正向映射与 Chunk 偏移量) ]
  Series ID 3  ---> 完整标签集 + 指向 chunks/000001 文件的字节偏移量 (Offset: 0x004F20)

当你查询 http_requests_total{job="payment", status="500"} 时, Prometheus 根本不需要扫描数据文件, 它只需从 index 文件中取出这三个标签对应的有序整数数组 [1,2,3,4,5,8]、[1,3,5,9] 和 [2,3,7], 在内存中做一次极速的有序数组求交集(Intersection), 瞬间就能算出唯一匹配的序列是 Series ID = 3, 然后直接根据偏移量 0x004F20 去 chunks 文件里把数据抠出来!

(2)如何把 16 Bytes 的样本压缩到 1.37 Bytes? —— Facebook Gorilla 压缩算法

在 chunks/ 目录下, 每一条时间序列(Series)的样本点会按最多 120 个点切分成一个紧密排列的二进制数据块(Chunk).

前面提到, 1 个原始样本点 = int64 时间戳(8 Bytes) + float64 浮点数(8 Bytes) = 16 Bytes. 但在 Prometheus 生产环境中, 落盘后平均每个样本只占 1.37 Bytes(压缩率高达 90% 以上)!这归功于它采用的 Gorilla 位级流式压缩算法:

  • 针对时间戳(Timestamp): Delta-of-Delta(二阶差分编码)
    因为 Prometheus 是固定间隔(如 15 秒)抓取一次, 同一条时间序列上的时间戳是高度等距的.
    假设连续 4 次抓取的时间戳(秒)为: \(T_1 = 1000, \quad T_2 = 1015, \quad T_3 = 1030, \quad T_4 = 1046\)(第 4 次因网络抖动慢了 1 秒).
  1. 先算一阶差分(相邻时间差 \(\Delta_t = T_t - T_{t-1}\)):

\[\Delta_2 = 15, \quad \Delta_3 = 15, \quad \Delta_4 = 16 \]

  1. 再算二阶差分(Delta-of-Delta, \(D_t = \Delta_t - \Delta_{t-1}\)):

\[D_3 = 15 - 15 = 0, \quad D_4 = 16 - 15 = +1 \]

  1. **变长 Bit 编码落盘: **
  • 如果算出来的二阶差分 \(D = 0\)(代表完全准时): 直接在二进制流中只写入 1 个 bit 的 0!(原本 64 bits 的时间戳瞬间变成了 1 bit!)

  • 如果 \(D\) 在 \([-63, 64]\) 之间(轻微网络抖动): 写入控制前缀 10(2 bits)加上用 7 bits 表示的 \(D\) 值, 总共也才占 9 bits.

  • 针对数值(float64 Value): XOR(按位异或)压缩
    同一条指标相邻两次采集的 float64 数值往往完全不变(例如没有新报错时, 错误计数一直保持不变), 或者只有微小的尾数波动.
    算法将当前值 \(V_t\) 与前一个值 \(V_{t-1}\) 的 64 位 IEEE 754 二进制表示直接做 按位异或(XOR):

  1. 如果 \(V_t \oplus V_{t-1} = 0\)(说明数值和上一次一模一样): 直接在二进制流中只写入 1 个 bit 的 0!
  2. 如果异或结果不为 0: 因为两个相近浮点数的符号位、指数位和有效位高位几乎相同, 异或后的 64 位二进制数必定包含大量的前导零(Leading Zeros)和尾随零(Trailing Zeros).Prometheus 只需要写入控制位 1, 并记录前导零和尾随零的长度, 只把中间真正发生变化的几个有效 bit 截取出来写进磁盘.

五、 数据源接口、Grafana 交互与 PromQL 计算引擎的本质

当数据被压缩存储在 TSDB 后, 我们是如何使用 Grafana 和 PromQL 将它们变成可视化监控大盘的?

1. “暴露的数据源接口”是什么意思?

Prometheus 本身只内置了一个极简的调试网页(Expression Browser), 它遵循 UNIX 设计哲学——把存储和计算做到极致, 把炫酷的图表渲染交给专业的外部组件(Grafana).

为此, Prometheus Server 对外暴露了一套标准的 HTTP RESTful API, 这就是所谓的数据源接口(Data Source API)./ 其中最核心的两个接口是:

  • GET /api/v1/query: 瞬时查询接口(Instant Query), 计算某一个特定时间点(time)上 PromQL 表达式的值.
  • GET /api/v1/query_range: 区间查询接口(Range Query), 计算从起始时间(start)到结束时间(end)、按固定步长(step)采样的整个时间段内的趋势数据.

当你在 Grafana 后台添加 Prometheus Data Source 并填入 http://prometheus-ip:9090 时, Grafana 和 Prometheus 的关系其實非常纯粹: Grafana 就是一个拼装 HTTP 请求并负责前端画图的客户端.

+--------------------+   1. HTTP GET /api/v1/query_range    +-----------------------+
|                    |      ?query=sum(rate(http_req[5m]))  |                       |
|      Grafana       |      &start=1700000000               |   Prometheus Server   |
|  (可视化渲染前端)    |      &end=1700003600&step=15s        |   (PromQL 计算引擎)     |
|                    | -----------------------------------> |                       |
|  拿到 JSON 矩阵后   |                                      | 1. 倒排索引定位 Series  |
|  在 Canvas 画折线图 | <----------------------------------- | 2. 解压 Chunk 原始样本 |
+--------------------+   2. 返回计算对齐后的 JSON Matrix      | 3. 向量对齐、求导、聚合 |
                                                            +-----------------------+

2. 为什么不能用 SQL? PromQL 到底在干什么?

很多后端工程师初学 PromQL 时都很痛苦: 为什么不直接支持 SELECT sum(value) FROM http_requests_total GROUP BY job?

答案在于时序数据的离散性与时间戳错位问题.
假设你有两台机器 Instance A 和 Instance B, Prometheus 独立协程去拉取它们:

  • Instance A 的样本落盘时间戳是: 10:00:01.124、10:00:16.128
  • Instance B 的样本落盘时间戳是: 10:00:04.892、10:00:19.901

如果你用传统 SQL 按时间戳做 JOIN 或 GROUP BY timestamp, 因为这两台机器的时间戳在毫秒级永远不可能对齐, 求和出来的结果完全是碎裂且错误的!而且对于 Counter 这种只增不减的累计值(比如从昨天开机到现在一共处理了 800 万次请求), 我们关心的根本不是“800 万”这个绝对值, 而是“此时此刻每秒钟增加了多少(QPS, 即对时间求一阶导数)”.

因此, PromQL 的本质不是关系代数查询语言, 而是一个自带“时间网格对齐(Time Alignment)”与“微积分插值运算”的向量计算引擎(Vector Evaluation Engine).

3. 深度拆解: 一条经典 PromQL 在引擎内部的完整执行过程

我们以后端最常用的黄金监控查询语句——按服务统计接口成功请求的 QPS 为例, 看看当 Grafana 发起一次 query_range 请求时, PromQL 引擎在底层到底干了什么:

sum by (job) (
  rate(http_requests_total{status="200"}[5m])
)

第一步: 掌握 PromQL 的两个核心数据类型

在看执行流程前, 必须分清 PromQL 内存里的两种向量(Vector):

  1. 瞬时向量(Instant Vector): 写法如 http_requests_total{status="200"}. 表示在某一个特定评估时间点 \(T_{eval}\), 每条匹配的时间序列对应的最新 1 个样本值(由于抓取间隔的存在, 引擎会自动往回找最多 5 分钟内离 \(T_{eval}\) 最近的那个点, 这叫 Lookback Delta 时间对齐).
  2. 区间向量(Range Vector): 写法如 http_requests_total{status="200"}[5m](后面带了时间窗口 [5m]). 表示从评估时间点 \(T_{eval}\) 往回推 5 分钟的窗口内, 每条匹配的时间序列包含的 一组原始样本点数组 []Sample.

第二步: 引擎四阶段求值流水线(在单个评估时间点 \(T_{eval}\) 上)

[阶段 1: 倒排索引检索] 
  选择器 http_requests_total{status="200"} 
  ---> 查 index 文件求交集 ---> 命中 Series 1 (实例A) 和 Series 2 (实例B)

[阶段 2: 解压拉取区间向量 (Range Vector)]
  读取过去 [5m] 窗口内的原始 Chunk 并执行 Gorilla 逆向解压: 
  Series 1 (实例A): [(t1, 100), (t2, 160), (t3, 220), ..., (t20, 1300)]
  Series 2 (实例B): [(t1', 50), (t2', 80), (t3', 110), ..., (t20', 650)]

[阶段 3: rate() 函数求导与边界外推 -> 坍缩为瞬时向量 (Instant Vector)]
  计算 (末尾值 - 起始值) / 窗口时间跨度(同时自动处理进程重启导致的 Counter 归零重置): 
  Series 1 (实例A) 在 T_eval 时刻的每秒增长率 ---> 4.0 req/s
  Series 2 (实例B) 在 T_eval 时刻的每秒增长率 ---> 2.0 req/s

[阶段 4: sum by (job) 跨维度向量聚合]
  因为 Series 1 和 Series 2 的时间已经被对齐到了同一个 T_eval 时刻, 现在可以直接安全相加!
  抹去 instance 等标签, 只保留 job 标签 ---> {job="payment"} 在 T_eval 时刻的值 = 6.0 req/s

这里有两个极其精妙的底层细节:

  • rate() 的 Counter Reset 自动补偿: 如果在过去 5 分钟内, 实例 A 重启过一次, 那么它在内存里的 Counter 会从 0 重新开始记, 样本序列看起来会像: [100, 150, 20, 50]. 如果你自己写 SQL 用 末尾值(50) - 起始值(100), 算出来的 QPS 就是负数!而 PromQL 的 rate() 在遍历区间向量时, 一旦发现后一个点小于前一个点(20 小于 150), 就会判定发生了进程重启重置, 自动将跌落前的峰值补偿上去, 算出真实增量 \((150 - 100) + 50 = 100\).
  • 线性外推(Extrapolation): 因为 5 分钟窗口内的第一个点和最后一个点不可能正好卡在 5 分钟的起止毫秒边界上, rate() 会根据首尾样本点的斜率向窗口边界做线性外推插值, 从而算出极其平滑的每秒速率.

第三步: 区间查询(Range Query)的网格步进真相

当你在 Grafana 上查看 “过去 1 小时(3600 秒)的 QPS 曲线”, 且 Grafana 设置采样步长 step = 15s 时:

Prometheus 引擎会在过去 1 小时的时间轴上, 严格按照 15 秒的间隔, 划分出 \(3600 \div 15 = 240\) 个绝对对齐的虚拟时间网格点(Evaluation Timestamps):

\[T_1, \quad T_2 (= T_1 + 15\text{s}), \quad T_3 (= T_2 + 15\text{s}), \quad \dots, \quad T_{240} \]

接着, PromQL 引擎会在这 240 个时间网格点上, 分别把每个网格点当作当前时刻 \(T_{eval}\), 循环执行 240 次上面讲到的“四阶段求值流水线”!
不管你的业务机器底层是在第几秒、第几毫秒被抓取的, 经过 rate() 窗口外推和 Lookback 机制, 所有机器的数据都会被强行收敛对齐到这 240 个标准时间刻度上, 最终拼装成一个规整的 JSON 二维数组返回给 Grafana:

{
  "status": "success",
  "data": {
    "resultType": "matrix",
    "result": [
      {
        "metric": { "job": "payment" },
        "values": [
          [ 1700000000, "6.0" ],
          [ 1700000015, "6.4" ],
          [ 1700000030, "5.8" ]
        ]
      }
    ]
  }
}

Grafana 收到这个 JSON 后, 只需要把 metric 作为图例名称, 把 values 里的 [时间戳, 数值] 作为 \((X, Y)\) 坐标点连成线, 一张丝滑的监控大盘图表就呈现在你眼前了.


六、 全链路架构全景回顾

把上述五个问题串联起来, Prometheus 的整套设计哲学可以用下面这张全链路对照表来贯通:

链路阶段 核心组件 / 机制 底层数据结构与核心动作 解决的工程痛点
1. 应用内埋点 语言 SDK (Registry) 进程内原子变量(atomic double)、分桶数组 纳秒级内存操作, 零网络 I/O, 不阻塞业务主线程
2. 指标暴露与采集 HTTP /metrics + Scrape Loop 纯文本/Protobuf 流、Goroutine 定时哈希打散拉取 Pull 模型掌握主动权, 天然具备反压保护与 up 探活能力
3. 数据维度建模 Metric Name + Labels {__name__="...", k1="v1"} 唯一确定一条 Series 正交多维标签支持任意维度切片聚合(需严防高基数笛卡尔积爆炸)
4. 内存与磁盘存储 自研 TSDB 引擎 Head Block + WAL + 2小时不可变磁盘块(mmap) 顺序追加写最大化吞吐; 按目录直接清理过期数据, 零磁盘碎片
5. 索引与压缩算法 index 与 chunks 二进制文件 倒排索引(Posting List 交集)+ Gorilla(二阶差分 & XOR) 消除长字符串重复存储; 将 16 Bytes 样本压缩至 1.37 Bytes
6. 查询计算与展示 HTTP API + PromQL + Grafana 区间向量拉取 \(\rightarrow\) 函数插值求导 \(\rightarrow\) 时间网格对齐聚合 彻底解决分布式机器采集时间戳错位、Counter 重启归零等计算难题
posted @ 2026-11-02 11:18  虾野百鹤  阅读(6)  评论(0)    收藏  举报