深入理解 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 与应用的交互由以下三个核心机制协同完成:
- 服务发现(Service Discovery, SD):
在云原生或微服务集群中, 业务机器的 IP 和端口是动态扩缩容的. Prometheus 内部集成了 Consul、Kubernetes API、DNS 以及基于文件的服务发现机制, 实时监听集群变化, 动态维护一份当前在线的目标实例列表(Targets). - 独立协程定时拉取(Scrape Loop):
Prometheus 使用 Go 语言编写, 它会为每一个被监控的目标实例(Target)启动一个独立的 Goroutine(scrapeLoop). 按照配置文件中设定的scrape_interval(例如15s), 该协程会定期向目标的http://ip:port/metrics发起 HTTP GET 请求(默认开启 Gzip 压缩以节省内网带宽). 为了防止成千上万个 Target 在同一毫秒发起请求打满网卡(惊群效应), Prometheus 会对每个 Target 的标识做哈希计算, 将拉取时间均匀打散在整个 15 秒周期内. - 服务端打标与时间戳对齐:
拿到应用返回的文本流后, 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)”唯一确定:
实际上, 在 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 维度取值的笛卡尔积:
假设你有 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 | |
| +-----------------------+ +-----------------------+ |
+-----------------------------------------------------------------------+
- 内存态(Head Block)与 WAL:
所有最新抓取进来的样本, 永远只写入内存中的 Head Block(因此写入速度极快). 为了防止机器断电丢失内存中最近 2 小时的数据, 在写入 Head Block 的同时, 数据会以二进制追加写的方式落入磁盘的 WAL(Write-Ahead Log, 预写日志) 目录中(每个段文件 128MB). - 磁盘态(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 秒).
- 先算一阶差分(相邻时间差 \(\Delta_t = T_t - T_{t-1}\)):
- 再算二阶差分(Delta-of-Delta, \(D_t = \Delta_t - \Delta_{t-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):
- 如果 \(V_t \oplus V_{t-1} = 0\)(说明数值和上一次一模一样): 直接在二进制流中只写入
1 个 bit的0! - 如果异或结果不为
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.128Instance 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):
- 瞬时向量(Instant Vector): 写法如
http_requests_total{status="200"}. 表示在某一个特定评估时间点 \(T_{eval}\), 每条匹配的时间序列对应的最新 1 个样本值(由于抓取间隔的存在, 引擎会自动往回找最多 5 分钟内离 \(T_{eval}\) 最近的那个点, 这叫Lookback Delta时间对齐). - 区间向量(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):
接着, 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 重启归零等计算难题 |

浙公网安备 33010602011771号