当一个大型互联网公司的监控系统每秒需要写入 500 万个数据点、每天产生超过 4000 亿条记录时,传统的关系型数据库在几分钟内就会不堪重负。时序数据库(Time-Series Database,TSDB)正是为解决这类问题而生的。从 Facebook 的 Gorilla 内存时序存储,到 Prometheus 驱动的云原生监控生态,再到 InfluxDB 的专用 TSM 引擎,时序数据架构在过去十年间经历了快速演进。本文将从时序数据的本质特征出发,深入分析编码压缩原理、存储引擎设计、降采样策略,并对三大主流 TSDB 进行架构级对比,为监控与 IoT 场景的存储选型提供工程参考。
一、时序数据的特征分析
1.1 什么是时序数据
时序数据(Time-Series Data)是按时间顺序记录的一系列数据点的集合。每条记录通常包含三个核心要素:
- 时间戳(Timestamp):数据产生的精确时刻,通常精确到毫秒或纳秒级别。
- 标签(Tags / Labels):用于标识数据来源的元数据,例如主机名、地域、服务名称。
- 值(Value / Field):实际的度量值,如 CPU 使用率、温度、请求延迟。
一条典型的时序数据记录如下:
cpu_usage,host=server01,region=us-east value=73.2 1681000000000000000
这里 cpu_usage 是度量名称(Metric
Name),host 和 region
是标签,value=73.2
是字段值,最后的长整型数字是纳秒级时间戳。
1.2 写多读少的访问模式
时序数据最显著的特征是写入密集、读取相对稀少。以一个中等规模的 Kubernetes 集群监控为例:
- 集群包含 200 个节点,每个节点运行 50 个 Pod。
- 每个 Pod 暴露 100 个监控指标。
- 采集间隔为 15 秒。
则每秒写入的数据点数量为:
200 × 50 × 100 / 15 ≈ 66,667 数据点/秒
而查询通常只发生在以下场景:运维人员查看仪表盘、告警规则定期评估、事后回溯排查。查询量与写入量的比值通常在 1:100 到 1:1000 之间。
这一特征直接决定了存储引擎的设计方向——必须在写入路径上做到极致优化,即使以牺牲部分读取性能为代价。
1.3 按时间有序与追加写入
时序数据天然按时间顺序产生,几乎所有写入操作都是追加(Append-Only)模式。历史数据一旦写入,极少被修改或删除。这意味着:
- 不需要复杂的事务支持和行级锁机制。
- 可以利用顺序写入优化磁盘 I/O。
- 数据天然具备分区键——时间本身就是最佳的分区维度。
传统 B-Tree 索引在随机写入场景下性能优异,但面对持续的追加写入,日志结构合并树(LSM-Tree)和类似的追加友好数据结构表现更好。
1.4 高基数问题
高基数(High Cardinality)是时序数据库面临的核心挑战之一。基数指的是标签组合的唯一数量。考虑以下场景:
度量名称: http_request_duration
标签: method={GET,POST,PUT,DELETE} × path={/api/v1/users, /api/v1/orders, ...共500个} × status={200,201,400,404,500} × instance={共100个实例}
标签组合的总数为:
4 × 500 × 5 × 100 = 1,000,000 个唯一时间序列
每个唯一的标签组合构成一条独立的时间序列。当基数达到百万甚至千万级别时,倒排索引的内存占用和查询开销都会急剧膨胀。不同的 TSDB 对高基数问题采取了截然不同的应对策略,这也是选型时的关键考量因素。
1.5 查询模式的固定性
时序数据的查询模式相对固定,主要包括以下几类:
| 查询类型 | 说明 | 示例 |
|---|---|---|
| 时间范围扫描 | 查询某个时间窗口内的所有数据点 | 最近 1 小时的 CPU 使用率 |
| 聚合计算 | 对时间窗口内的数据做统计运算 | 过去 24 小时的平均响应时间 |
| 分组聚合 | 按标签维度分组后聚合 | 各机房的 P99 延迟 |
| Top-N 查询 | 找出值最大/最小的 N 个序列 | CPU 使用率最高的 10 台主机 |
| 降采样查询 | 以更粗粒度查看历史数据 | 按小时查看过去 30 天的流量趋势 |
这种可预测的查询模式使得存储引擎可以针对性优化——例如预计算聚合结果、按时间分块组织数据、在存储层面直接支持降采样。
二、Gorilla 编码:时序压缩的里程碑
2.1 Facebook 的 Gorilla 论文
2015 年,Facebook 发表了论文《Gorilla: A Fast, Scalable, In-Memory Time Series Database》,提出了一套针对时序数据的高效压缩编码方案。Gorilla 的核心目标是将时序数据压缩到足够小,使其能够完全驻留在内存中,从而实现亚毫秒级的查询延迟。
Gorilla 编码的两个核心技术是:
- 时间戳的 Delta-of-Delta 编码:利用相邻时间戳之间差值的稳定性。
- 浮点数值的 XOR 压缩:利用相邻数值的相似性。
在 Facebook 的生产环境中,Gorilla 编码实现了平均 12 倍的压缩比,将每个数据点的平均存储开销从 16 字节压缩到约 1.37 字节。
2.2 时间戳的 Delta-of-Delta 编码
时序数据的采集通常是定时的——每 15 秒、每 1 分钟采集一次。这意味着相邻时间戳的差值(Delta)高度一致。Gorilla 更进一步,对差值再做一次差分(Delta-of-Delta),得到的值在大多数情况下为零。
编码过程如下:
原始时间戳序列(秒): T0=1000, T1=1015, T2=1030, T3=1045, T4=1061
第一步:计算 Delta(与前一个时间戳的差)
D1 = T1 - T0 = 15
D2 = T2 - T1 = 15
D3 = T3 - T2 = 15
D4 = T4 - T3 = 16
第二步:计算 Delta-of-Delta(与前一个 Delta 的差)
DD2 = D2 - D1 = 0
DD3 = D3 - D2 = 0
DD4 = D4 - D3 = 1
然后使用变长编码存储 Delta-of-Delta 值:
| Delta-of-Delta 值 | 编码方式 | 占用位数 |
|---|---|---|
| 0 | 单个 0 位 | 1 位 |
| [-63, 64] | 10 + 7 位值 | 9 位 |
| [-255, 256] | 110 + 9 位值 | 12 位 |
| [-2047, 2048] | 1110 + 12 位值 | 16 位 |
| 其他 | 1111 + 32 位值 | 36 位 |
在理想情况下(采集间隔完全固定),每个时间戳只需要 1 位即可编码。即使偶尔出现抖动,大多数情况下也只需要 9 位。
以下是 Delta-of-Delta 编码核心逻辑的 Go 语言实现:
package gorilla
func (e *TimestampEncoder) Encode(t int64) error {
if e.first {
delta := t - e.t0
e.deltaPrev = delta
e.tPrev = t
e.first = false
return e.w.WriteBits(uint64(delta), 14) // 14 位存储首个 Delta
}
delta := t - e.tPrev
dod := delta - e.deltaPrev // Delta-of-Delta
e.deltaPrev = delta
e.tPrev = t
switch {
case dod == 0:
return e.w.WriteBit(false) // 1 位
case -63 <= dod && dod <= 64:
e.w.WriteBits(0x02, 2) // 前缀 10 + 7 位值
return e.w.WriteBits(uint64(dod+63), 7)
case -255 <= dod && dod <= 256:
e.w.WriteBits(0x06, 3) // 前缀 110 + 9 位值
return e.w.WriteBits(uint64(dod+255), 9)
case -2047 <= dod && dod <= 2048:
e.w.WriteBits(0x0E, 4) // 前缀 1110 + 12 位值
return e.w.WriteBits(uint64(dod+2047), 12)
default:
e.w.WriteBits(0x0F, 4) // 前缀 1111 + 32 位值
return e.w.WriteBits(uint64(dod), 32)
}
}2.3 浮点数值的 XOR 压缩
对于浮点数值,Gorilla 利用了一个观察:相邻数据点的值通常非常接近。当两个 IEEE 754 双精度浮点数相近时,它们的二进制表示有大量相同的前导位和尾部位。
XOR 压缩的原理:
- 将当前值与前一个值做 XOR 运算。
- 如果 XOR 结果为 0(值完全相同),只需写入 1 位标记。
- 如果 XOR 结果非零,记录有效位的起始位置(Leading Zeros)和长度(Meaningful Bits)。
编码规则:
情况 A:XOR 结果为 0
编码: 写入单个 0 位
情况 B:XOR 非零,且有效位区间落在前一次的有效位区间内
编码: 10 + 仅有效位部分
情况 C:XOR 非零,有效位区间发生变化
编码: 11 + 5 位前导零数量 + 6 位有效位长度 + 有效位部分
以一个实际例子说明:
V1 = 73.2 -> IEEE 754: 0100000001010010010011001100110011001100110011001100110011001101
V2 = 73.5 -> IEEE 754: 0100000001010010011000000000000000000000000000000000000000000000
XOR(V1,V2)= 0000000000000000001011001100110011001100110011001100110011001101
前导零: 18 位
尾部零: 0 位
有效位: 46 位
XOR 压缩核心逻辑的 Go 实现:
package gorilla
import "math"
func (e *ValueEncoder) Encode(v float64) error {
vBits := math.Float64bits(v)
if e.first {
e.first = false
e.vPrev = vBits
return e.w.WriteBits(vBits, 64) // 首个值完整写入
}
xor := vBits ^ e.vPrev
e.vPrev = vBits
if xor == 0 {
return e.w.WriteBit(false) // 情况 A:值相同,1 位
}
leading := uint8(countLeadingZeros(xor))
trailing := uint8(countTrailingZeros(xor))
e.w.WriteBit(true)
if leading >= e.leadingPrev && trailing >= e.trailingPrev {
e.w.WriteBit(false) // 情况 B:有效位区间在前一次范围内
meaningful := 64 - e.leadingPrev - e.trailingPrev
return e.w.WriteBits(xor>>e.trailingPrev, int(meaningful))
}
e.w.WriteBit(true) // 情况 C:更新有效位区间
e.w.WriteBits(uint64(leading), 5)
meaningful := 64 - leading - trailing
e.w.WriteBits(uint64(meaningful), 6)
e.leadingPrev = leading
e.trailingPrev = trailing
return e.w.WriteBits(xor>>trailing, int(meaningful))
}2.4 Gorilla 编码的压缩效果
Facebook 在论文中给出了生产环境的压缩统计数据:
| 数据类型 | 原始大小(每点) | 压缩后大小(每点) | 压缩比 |
|---|---|---|---|
| 时间戳 | 8 字节 | ~0.056 字节 | 143:1 |
| 浮点值 | 8 字节 | ~1.31 字节 | 6.1:1 |
| 合计 | 16 字节 | ~1.37 字节 | 11.7:1 |
96% 的时间戳 Delta-of-Delta 为零,只需 1 位编码。约 51% 的浮点值与前一个值完全相同(XOR 为零),也只需 1 位。
以下 Mermaid 图展示了 Gorilla 编码的整体数据流:
flowchart TD
A[原始时序数据点] --> B{是否为首个数据点?}
B -- 是 --> C[完整存储时间戳和值]
B -- 否 --> D[计算时间戳 Delta]
D --> E[计算 Delta-of-Delta]
E --> F{DoD 是否为 0?}
F -- 是 --> G[写入 1 位: 0]
F -- 否 --> H[变长编码 DoD]
H --> I[选择最短前缀编码]
A --> J[提取浮点值]
J --> K[与前一个值 XOR]
K --> L{XOR 是否为 0?}
L -- 是 --> M[写入 1 位: 0]
L -- 否 --> N{有效位区间是否在前一次范围内?}
N -- 是 --> O[前缀 10 + 有效位]
N -- 否 --> P[前缀 11 + 前导零 + 长度 + 有效位]
G --> Q[压缩后的比特流]
I --> Q
M --> Q
O --> Q
P --> Q
C --> Q
Q --> R[内存块存储 / 持久化]
2.5 Gorilla 编码的局限性
Gorilla 编码并非万能方案,它存在以下局限:
- 强依赖数据有序性:编码基于前后数据点的差值,乱序到达的数据会严重影响压缩效率。
- 不适合字符串/日志数据:XOR 压缩只对数值型数据有效。
- 解码需要顺序扫描:由于每个数据点依赖前一个点的值,无法直接跳转到任意位置,必须从块起始位置顺序解码。
- 对采集间隔抖动敏感:如果采集间隔极不规律,Delta-of-Delta 的值分布范围会很大,压缩效率下降。
为解决第 3 个问题,实际系统通常将数据按固定时间窗口(如 2 小时)分块,每个块独立编码,从而限制顺序扫描的范围。
三、LSM-Tree 在时序场景的适配
3.1 为什么选择 LSM-Tree
日志结构合并树(Log-Structured Merge-Tree,LSM-Tree)是一种针对写入密集场景优化的数据结构。它的核心思想是将随机写入转化为顺序写入:
- 所有写入操作先进入内存中的 MemTable(通常是一个有序数据结构,如跳表或红黑树)。
- 当 MemTable 达到阈值后,以排序顺序刷写到磁盘上的 SSTable(Sorted String Table)文件。
- 后台定期执行合并(Compaction),将多个 SSTable 合并成更大的文件,清理重复和过期数据。
对于时序场景,LSM-Tree 的优势在于:
- 写入吞吐量极高:所有写入都是顺序追加,磁盘利用率接近理论峰值。
- 天然适配追加模式:时序数据几乎不更新历史记录。
- 灵活的合并策略:可以在合并时执行降采样、数据过期清理等操作。
3.2 时序数据的 LSM-Tree 优化
标准的 LSM-Tree(如 LevelDB、RocksDB)面向通用的键值存储场景。在时序场景下,需要做以下适配:
键设计优化:
通用 KV 的键: 任意字节序列
时序场景的键: SeriesID + Timestamp
SeriesID = Hash(metric_name + sorted_tags)
将序列 ID 和时间戳组合为键,相同序列的数据在 SSTable 中物理相邻,利于范围扫描。
分层存储(Tiered Storage):
层级 0 (热数据): 最近 2 小时 → 内存 + SSD
层级 1 (温数据): 2 小时 ~ 7 天 → SSD
层级 2 (冷数据): 7 天 ~ 90 天 → HDD
层级 3 (归档): 90 天以上 → 对象存储 (S3/OSS)
Compaction
策略定制:时序场景通常按时间窗口分组
SSTable,配置项包括窗口大小(TimeWindowSize)、每窗口
SSTable
数量阈值(MaxSSTPerWindow)、合并时降采样开关(DownsampleOnCompact)以及数据过期时间(RetentionPeriod)。当窗口内
SSTable 数量超过阈值,或窗口关闭后仍存在多个 SSTable
时,触发合并。
3.3 时间窗口合并(Time-Window Compaction)
传统的分层合并(Leveled Compaction)或大小分层合并(Size-Tiered Compaction)在时序场景下存在问题:它们可能将不同时间范围的数据合并在一起,导致查询时需要扫描不必要的文件。
时间窗口合并策略的核心思想是:只合并属于同一时间窗口的 SSTable。
时间窗口: [00:00-02:00] [02:00-04:00] [04:00-06:00] ...
窗口 [00:00-02:00] 的 SSTable 集合:
sst_001.db (00:00 - 00:30)
sst_002.db (00:15 - 01:00)
sst_003.db (00:45 - 02:00)
→ 合并为 sst_merged_001.db (00:00 - 02:00)
窗口 [02:00-04:00] 的 SSTable 集合:
sst_004.db (02:00 - 02:45)
sst_005.db (02:30 - 04:00)
→ 合并为 sst_merged_002.db (02:00 - 04:00)
Apache Cassandra 的
TimeWindowCompactionStrategy(TWCS)就是这一思想的实现。InfluxDB
的 TSM 引擎也采用了类似的时间分片策略。
四、降采样策略与数据保留
4.1 为什么需要降采样
假设一个监控系统以 10 秒间隔采集数据,那么:
- 1 天产生 8,640 个数据点/序列
- 1 个月产生 259,200 个数据点/序列
- 1 年产生 3,153,600 个数据点/序列
如果有 100 万个活跃序列,1 年的数据量将超过 3 万亿个数据点。即使使用 Gorilla 编码压缩到每点 1.37 字节,也需要约 4 TB 存储空间。
更重要的是,当用户查看 “过去 1 年的 CPU 使用率趋势” 时,在仪表盘上显示 315 万个数据点既没有必要也没有意义——屏幕像素数量远不足以展示如此密集的数据。
降采样(Downsampling)通过降低时间分辨率来减少数据量,同时保留趋势信息:
原始数据 (10 秒间隔):
10:00:00 → 72.3
10:00:10 → 73.1
10:00:20 → 71.8
10:00:30 → 74.5
10:00:40 → 73.9
10:00:50 → 72.7
降采样到 1 分钟 (聚合函数: avg, min, max):
10:00:00 → avg=73.05, min=71.8, max=74.5
4.2 常见降采样策略
降采样策略通常结合数据保留策略一起使用:
# 典型的多级保留策略配置
retention_policies:
- name: raw
resolution: 10s
retention: 7d
description: "原始精度数据保留 7 天"
- name: hourly
resolution: 1h
retention: 90d
description: "小时级聚合数据保留 90 天"
- name: daily
resolution: 1d
retention: 2y
description: "天级聚合数据保留 2 年"
- name: monthly
resolution: 30d
retention: forever
description: "月级聚合数据永久保留"降采样时需要保留的聚合维度通常包括:
| 聚合函数 | 用途 | 说明 |
|---|---|---|
| avg | 趋势分析 | 平均值反映总体趋势 |
| min | 下限检测 | 最小值可能触发告警 |
| max | 峰值分析 | 最大值关乎容量规划 |
| sum | 累计统计 | 适合请求计数、流量统计 |
| count | 数据密度 | 了解原始数据点数量 |
| p50/p95/p99 | 分位数 | 延迟分析的关键指标 |
4.3 降采样的实现方式
降采样可以在不同层面实现:
写入时降采样(流式降采样):
from collections import defaultdict
class StreamDownsampler:
"""流式降采样器:按固定窗口实时聚合"""
def __init__(self, window_seconds: int):
self.window_seconds = window_seconds
self.buffers: dict[str, list[float]] = defaultdict(list)
self.window_start: dict[str, int] = {}
def add_point(self, series_id: str, timestamp: int, value: float):
window = timestamp // self.window_seconds * self.window_seconds
if series_id not in self.window_start:
self.window_start[series_id] = window
if window != self.window_start[series_id]:
result = self._flush(series_id)
self.window_start[series_id] = window
self.buffers[series_id] = [value]
return result
self.buffers[series_id].append(value)
return None
def _flush(self, series_id: str) -> dict | None:
values = self.buffers[series_id]
if not values:
return None
return {
"series_id": series_id, "timestamp": self.window_start[series_id],
"avg": sum(values) / len(values),
"min": min(values), "max": max(values),
"sum": sum(values), "count": len(values),
}后台批量降采样(Compaction 时降采样):另一种方式是在 SSTable Compaction 过程中同步执行降采样。引擎遍历输入数据点,按目标分辨率的时间窗口分组,对每组计算 avg、min、max、sum、count、p99 等聚合值,输出为新的聚合数据点。这种方式不占用额外的写入带宽,但增加了 Compaction 的处理时间。
4.4 降采样的工程挑战
降采样看似简单,但在工程实践中存在若干陷阱:
分位数不可再聚合:P99 的 P99 不等于原始数据的 P99。解决方案是存储分位数摘要(如 T-Digest 或 DDSketch)而非单一的分位数值。
稀疏序列的处理:如果某个序列在降采样窗口内没有数据点,是填充零值、填充 NULL,还是直接跳过?不同选择影响后续的聚合计算。
对齐问题:降采样窗口的起止时间应当对齐到整点(如每小时的 00 分 00 秒),而非基于第一个数据点的到达时间。
回填场景:当历史数据被回填时,已经生成的降采样结果可能需要重新计算。
五、InfluxDB 架构分析
5.1 整体架构
InfluxDB 是目前最流行的开源时序数据库之一,由 InfluxData 公司开发。其架构经历了多次演进,从 v1 的单节点存储到 v3 的基于 Apache Arrow/DataFusion 的全新架构。本节以 InfluxDB 2.x 的 TSM 引擎为核心进行分析。
InfluxDB 的数据模型使用以下术语:
- Bucket:数据库的逻辑容器,包含保留策略配置。
- Measurement:度量名称,类似关系数据库中的表。
- Tag:索引字段,用于快速过滤。标签值为字符串类型。
- Field:数据字段,不创建索引。支持浮点、整数、字符串、布尔类型。
- Point:一条时序数据记录。
数据写入使用 Line Protocol 格式:
weather,location=shanghai,station=pudong temperature=25.3,humidity=62i 1681000000000000000
5.2 TSM 引擎
TSM(Time-Structured Merge Tree)是 InfluxDB 自研的存储引擎,它借鉴了 LSM-Tree 的思想但做了大量时序特化优化。
TSM 引擎的核心组件:
WAL(Write-Ahead Log):所有写入操作先追加到 WAL 文件,确保数据持久性。WAL 文件使用 Snappy 压缩,按固定大小(默认 10 MB)分割。
Cache(内存缓存):写入数据同时缓存在内存中的有序数据结构(按序列 ID + 时间戳排序)。当缓存大小达到阈值(默认 1 GB)或定时触发时,将缓存数据刷写为 TSM 文件。
TSM 文件:只读的、按列组织的数据文件。每个 TSM 文件的内部结构如下:
+-------------------+
| Header | 4 字节: 魔数 + 版本
+-------------------+
| Data Block 1 | 压缩后的时间戳 + 值
+-------------------+
| Data Block 2 |
+-------------------+
| ... |
+-------------------+
| Data Block N |
+-------------------+
| Index Block | 序列键 → 数据块偏移的映射
+-------------------+
| Index Offset | 索引起始位置
+-------------------+
| Footer | 校验和
+-------------------+
每个数据块按列存储,同一序列的时间戳和值分别压缩:
Data Block 结构:
+----------+---------+----------+---------+
| TS 类型 | TS 数据 | 值类型 | 值数据 |
| (1 byte) | (变长) | (1 byte) | (变长) |
+----------+---------+----------+---------+
时间戳压缩: 使用类 Gorilla 的 Delta-of-Delta + Simple8B 编码
浮点值压缩: 使用 Gorilla XOR 编码
整数值压缩: 使用 ZigZag + Simple8B 编码
5.3 倒排索引(TSI)
InfluxDB 使用时序索引(Time Series Index,TSI)来解决高基数场景下的索引问题。TSI 本质上是一个针对标签的倒排索引。
正排索引: SeriesID → {measurement, tags, fields}
倒排索引: tag_key=tag_value → {SeriesID1, SeriesID2, ...}
示例:
host=server01 → {series_1, series_5, series_12}
host=server02 → {series_2, series_7, series_15}
region=us-east → {series_1, series_2, series_5}
查询 host=server01 AND region=us-east
时,TSI 将两个倒排列表做交集运算:
{series_1, series_5, series_12} ∩ {series_1, series_2, series_5}
= {series_1, series_5}
TSI 使用基于磁盘的索引文件,采用类 LSM-Tree 的分层合并策略管理索引更新,避免了早期版本将所有索引加载到内存的内存瓶颈。
5.4 Flux 查询语言
Flux 是 InfluxDB 2.x 引入的函数式查询语言,替代了 1.x 的 InfluxQL(类 SQL 语法)。Flux 的设计理念是将查询表达为数据管道(Pipeline):
// Flux 查询示例:查询过去 1 小时各主机的平均 CPU 使用率
from(bucket: "monitoring")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_idle")
|> group(columns: ["host"])
|> aggregateWindow(every: 5m, fn: mean)
|> yield(name: "cpu_avg")// Flux 查询示例:检测异常——CPU 使用率超过 90% 持续 5 分钟
from(bucket: "monitoring")
|> range(start: -30m)
|> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_system")
|> aggregateWindow(every: 1m, fn: mean)
|> filter(fn: (r) => r._value > 90.0)
|> stateCount(fn: (r) => true, column: "consecutive")
|> filter(fn: (r) => r.consecutive >= 5)5.5 InfluxDB 的局限性
InfluxDB 2.x 开源版(OSS)存在以下架构限制:
- 单节点架构:开源版不支持集群部署,水平扩展能力有限。商业版 InfluxDB Enterprise 提供集群支持。
- 删除操作昂贵:TSM 引擎不支持原地删除,需要通过标记删除 + Compaction 回收空间。
- 高基数性能退化:当唯一时间序列数量超过千万级别时,TSI 索引的性能和内存占用会显著增长。
- Flux 语言学习曲线:Flux 的函数式范式与传统 SQL 差异较大,需要额外的学习成本。
六、Prometheus 架构分析
6.1 整体架构与拉取模型
Prometheus 是云原生监控领域的事实标准,由 SoundCloud 开发并于 2016 年成为 CNCF 的第二个毕业项目。与大多数 TSDB 的推送(Push)模型不同,Prometheus 采用拉取(Pull)模型主动从目标服务获取监控数据。
拉取模型的工作流程:
1. 服务暴露 /metrics HTTP 端点
2. Prometheus Server 按配置的间隔定期抓取该端点
3. 抓取到的数据写入本地存储
4. 告警规则引擎定期评估,触发告警发送到 Alertmanager
5. Grafana 等仪表盘工具通过 PromQL 查询数据
一个典型的 Prometheus 目标暴露的指标格式:
# HELP http_requests_total The total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/api/users",status="200"} 12457
http_requests_total{method="POST",path="/api/users",status="201"} 342
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",le="0.05"} 11204
http_request_duration_seconds_bucket{method="GET",le="+Inf"} 12457
http_request_duration_seconds_sum{method="GET"} 534.21
http_request_duration_seconds_count{method="GET"} 12457
6.2 本地存储引擎
Prometheus 2.x 使用自研的本地存储引擎,它的设计深受 Gorilla 论文影响,但针对磁盘持久化做了重要改进。
数据块(Block)组织结构:
data/
├── 01BKGV7JBM69T2G1BGBGM6KB12/ # Block 1 (时间范围: T0 ~ T0+2h)
│ ├── chunks/
│ │ └── 000001 # 压缩后的数据块
│ ├── index # 索引文件
│ ├── meta.json # 元数据
│ └── tombstones # 删除标记
├── 01BKGTZQ1SYQJTR4PB43C8PD98/ # Block 2 (时间范围: T0+2h ~ T0+4h)
│ ├── chunks/
│ │ └── 000001
│ ├── index
│ ├── meta.json
│ └── tombstones
├── chunks_head/ # Head Block (当前写入的活跃块)
│ └── 000001
└── wal/ # Write-Ahead Log
├── 00000001
├── 00000002
└── checkpoint.00000001
核心设计要点:
- Head Block:最近 2 小时的数据保存在内存中(Head Block),使用 Gorilla 编码。这是唯一可写入的区域。
- 持久化 Block:当 Head Block 达到 2 小时后,被压缩写入磁盘成为只读 Block。
- Compaction:后台线程将相邻的小 Block 合并成更大的 Block,默认最大 Block 覆盖时间范围不超过保留期的 10%。
- 索引结构:每个 Block 内部维护独立的倒排索引,按标签名/标签值到序列 ID 的映射。
6.3 PromQL 查询语言
PromQL(Prometheus Query Language)是 Prometheus 的核心查询语言,专门为时序数据设计:
# 瞬时向量查询:获取所有 HTTP GET 请求的当前请求率
rate(http_requests_total{method="GET"}[5m])
# 范围聚合:过去 1 小时每 5 分钟的平均请求延迟
avg_over_time(http_request_duration_seconds_sum[1h]) /
avg_over_time(http_request_duration_seconds_count[1h])
# Top 5 内存使用率最高的 Pod
topk(5,
container_memory_usage_bytes{namespace="production"}
/ on(pod) container_spec_memory_limit_bytes{namespace="production"}
* 100
)
# 预测:基于过去 24 小时趋势,预测磁盘将在多少秒后写满
predict_linear(
node_filesystem_avail_bytes{mountpoint="/data"}[24h],
3600 * 24 * 7 # 预测未来 7 天
)
6.4 水平扩展:Thanos 与 Cortex
Prometheus 的核心局限是单节点架构——它不原生支持集群部署和长期存储。社区发展出两个主要的扩展方案:
Thanos 架构:
Thanos 通过 Sidecar 模式将 Prometheus 的本地数据上传到对象存储(如 S3),并提供全局查询视图。
Thanos 核心组件:
Sidecar — 部署在每个 Prometheus 旁,上传 Block 到对象存储
Store — 从对象存储读取历史数据
Query — 统一查询入口,扇出查询到 Sidecar 和 Store
Compactor — 对对象存储中的 Block 执行合并和降采样
Ruler — 分布式告警规则评估
Thanos 的降采样策略:
原始数据 → 保留 N 天(由 Prometheus 本地保留策略决定)
5 分钟降采样 → 长期保留
1 小时降采样 → 长期保留
Cortex / Mimir 架构:
Cortex(及其后继者 Grafana Mimir)采用完全不同的思路——作为 Prometheus 的远程写入后端,接收所有数据并分布式存储。
Cortex 核心组件:
Distributor — 接收远程写入请求,按一致性哈希分发
Ingester — 内存中构建数据块,定期刷写到长期存储
Querier — 查询 Ingester(近期数据)和长期存储(历史数据)
Compactor — 合并和降采样
Store Gateway — 从对象存储加载 Block 索引和数据
6.5 Prometheus 配置示例
一个生产级 Prometheus 的核心配置:
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
cluster: production-east
scrape_configs:
- job_name: "kubernetes-pods"
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
remote_write:
- url: "http://thanos-receive:19291/api/v1/receive"
queue_config:
max_samples_per_send: 5000
batch_send_deadline: 5s七、TimescaleDB 架构分析
7.1 基于 PostgreSQL 的时序扩展
TimescaleDB 走了一条与 InfluxDB 和 Prometheus 截然不同的路线——它不是从零构建新的存储引擎,而是作为 PostgreSQL 的扩展(Extension)在现有关系数据库基础上添加时序优化。
这种设计的核心优势:
- 完整的 SQL 支持,包括 JOIN、子查询、窗口函数、CTE 等。
- 可以将时序数据与业务数据存储在同一个数据库实例中。
- 继承 PostgreSQL 成熟的生态系统——备份工具、连接池、ORM 支持。
- 开发人员可以使用已有的 SQL 技能,无需学习新的查询语言。
7.2 Hypertable 与自动分块
TimescaleDB 的核心抽象是超表(Hypertable),它在用户视角上表现为一张普通的 PostgreSQL 表,但底层被自动划分为多个块(Chunk)。
-- 创建普通 PostgreSQL 表
CREATE TABLE sensor_data (
time TIMESTAMPTZ NOT NULL,
sensor_id INTEGER NOT NULL,
location TEXT NOT NULL,
temperature DOUBLE PRECISION,
humidity DOUBLE PRECISION,
pressure DOUBLE PRECISION
);
-- 将其转换为 TimescaleDB 的 Hypertable
-- 按 time 列自动分块,每个块覆盖 7 天的数据
SELECT create_hypertable(
'sensor_data',
by_range('time', INTERVAL '7 days')
);
-- 可选:添加空间维度的分区(按 sensor_id 哈希分区)
SELECT add_dimension(
'sensor_data',
by_hash('sensor_id', 4)
);自动分块的工作方式:
Hypertable: sensor_data (用户可见的虚拟表)
│
├── Chunk 1: _hyper_1_1_chunk (2026-01-01 ~ 2026-01-08, sensor_id hash 0)
├── Chunk 2: _hyper_1_2_chunk (2026-01-01 ~ 2026-01-08, sensor_id hash 1)
├── Chunk 3: _hyper_1_3_chunk (2026-01-01 ~ 2026-01-08, sensor_id hash 2)
├── Chunk 4: _hyper_1_4_chunk (2026-01-01 ~ 2026-01-08, sensor_id hash 3)
├── Chunk 5: _hyper_1_5_chunk (2026-01-08 ~ 2026-01-15, sensor_id hash 0)
├── ...
分块带来的性能优势:
- 查询剪枝(Chunk Exclusion):按时间范围查询时,优化器自动跳过不相关的块。
- 并行扫描:不同块可以分配给不同的 Worker 进程并行扫描。
- 高效的数据过期:删除过期数据只需
DROP CHUNK,无需逐行删除。 - 压缩粒度可控:旧块可以单独启用列式压缩,新块保持行式存储以支持高效写入。
7.3 列式压缩
TimescaleDB 提供原生的列式压缩(Native Compression)功能,将行式存储转换为列式压缩存储,通常可实现 90-95% 的压缩比。
-- 对 Hypertable 启用压缩
ALTER TABLE sensor_data SET (
timescaledb.compress,
-- 按 sensor_id 和 location 分段:相同标签的数据存储在一起
timescaledb.compress_segmentby = 'sensor_id, location',
-- 在每个分段内按 time 排序
timescaledb.compress_orderby = 'time DESC'
);
-- 添加自动压缩策略:超过 7 天的块自动压缩
SELECT add_compression_policy('sensor_data', INTERVAL '7 days');
-- 手动压缩特定块
SELECT compress_chunk('_hyper_1_1_chunk');
-- 查看压缩状态
SELECT
chunk_name,
before_compression_total_bytes,
after_compression_total_bytes,
round(
(1 - after_compression_total_bytes::numeric /
before_compression_total_bytes::numeric) * 100, 1
) AS compression_ratio_pct
FROM chunk_compression_stats('sensor_data');压缩后的数据在查询时透明解压,用户无需关心底层存储格式。
7.4 连续聚合
连续聚合(Continuous Aggregates)是 TimescaleDB 提供的物化视图增量更新机制,用于高效实现降采样:
-- 创建连续聚合:每小时的传感器数据摘要
CREATE MATERIALIZED VIEW sensor_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS bucket,
sensor_id,
location,
avg(temperature) AS avg_temp,
min(temperature) AS min_temp,
max(temperature) AS max_temp,
avg(humidity) AS avg_humidity,
count(*) AS sample_count
FROM sensor_data
GROUP BY bucket, sensor_id, location
WITH NO DATA;
-- 添加自动刷新策略
SELECT add_continuous_aggregate_policy('sensor_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour'
);
-- 在连续聚合之上再建连续聚合(层级聚合),实现天级粒度的数据摘要
CREATE MATERIALIZED VIEW sensor_daily
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 day', bucket) AS bucket, sensor_id, location,
avg(avg_temp) AS avg_temp, min(min_temp) AS min_temp,
max(max_temp) AS max_temp, sum(sample_count) AS sample_count
FROM sensor_hourly
GROUP BY time_bucket('1 day', bucket), sensor_id, location
WITH NO DATA;连续聚合的核心优势是增量计算——只处理自上次刷新以来新到达的数据,而非重新扫描全表。
7.5 数据保留策略
-- 自动删除超过 90 天的原始数据
SELECT add_retention_policy('sensor_data', INTERVAL '90 days');
-- 小时级聚合保留 2 年
SELECT add_retention_policy('sensor_hourly', INTERVAL '2 years');
-- 天级聚合永久保留(不设置保留策略)
-- 查看所有保留策略
SELECT * FROM timescaledb_information.jobs
WHERE proc_name = 'policy_retention';7.6 实用查询示例
TimescaleDB 的 SQL 兼容性使其能够执行复杂的分析查询:
-- 最近 24 小时内温度异常的传感器(超过 3 个标准差)
WITH stats AS (
SELECT sensor_id, avg(temperature) AS mean_temp,
stddev(temperature) AS stddev_temp
FROM sensor_data
WHERE time > now() - INTERVAL '24 hours'
GROUP BY sensor_id
)
SELECT s.sensor_id, s.time, s.temperature, st.mean_temp, st.stddev_temp
FROM sensor_data s
JOIN stats st ON s.sensor_id = st.sensor_id
WHERE s.time > now() - INTERVAL '24 hours'
AND abs(s.temperature - st.mean_temp) > 3 * st.stddev_temp
ORDER BY s.time DESC;
-- 使用 time_bucket_gapfill 进行缺失值插值
SELECT
time_bucket_gapfill('5 minutes', time) AS bucket,
sensor_id,
locf(avg(temperature)) AS temperature_filled
FROM sensor_data
WHERE time > now() - INTERVAL '1 hour' AND sensor_id = 42
GROUP BY bucket, sensor_id
ORDER BY bucket;八、三大 TSDB 架构对比与选型
8.1 架构对比总表
| 对比维度 | InfluxDB 2.x | Prometheus 2.x | TimescaleDB |
|---|---|---|---|
| 存储引擎 | TSM(自研) | 自研(类 Gorilla) | PostgreSQL + 分块扩展 |
| 数据模型 | Measurement + Tag + Field | Metric + Label | 关系表 + Hypertable |
| 查询语言 | Flux / InfluxQL | PromQL | 标准 SQL |
| 写入协议 | Line Protocol / HTTP API | Pull (HTTP Scrape) / Remote Write | SQL INSERT / COPY |
| 压缩方式 | Gorilla + Simple8B + Snappy | Gorilla + 变长编码 | PostgreSQL TOAST + 列式压缩 |
| 高基数支持 | 中等(TSI 倒排索引) | 弱(倒排索引内存受限) | 强(PostgreSQL B-Tree/GiST) |
| 集群/扩展 | 商业版支持 | Thanos / Cortex / Mimir | 多节点版(商业) / Patroni |
| 降采样 | 任务机制 / 连续查询 | Recording Rules + Thanos Compactor | 连续聚合(Continuous Aggregates) |
| SQL 支持 | 不支持标准 SQL | 不支持 | 完整 SQL 支持 |
| JOIN 能力 | 不支持 | 有限(向量匹配) | 完整 JOIN 支持 |
| 生态系统 | Telegraf + Grafana + Kapacitor | Exporter 生态 + Grafana | PostgreSQL 完整生态 |
| 开源协议 | MIT(2.x) | Apache 2.0 | Apache 2.0(社区版) |
| 典型压缩比 | 10-15x | 10-12x | 10-20x(列式压缩后) |
| 适合的写入吞吐量 | 百万点/秒级 | 十万点/秒级 | 十万~百万点/秒级 |
8.2 选型决策树
根据实际需求,可以按以下决策路径选择:
选择 Prometheus 的场景: - 云原生 Kubernetes 环境的基础设施监控。 - 已有 Grafana + Alertmanager 告警链路。 - 数据保留期较短(15 天 ~ 90 天),或通过 Thanos/Mimir 扩展长期存储。 - 不需要复杂的 SQL 查询和 JOIN 操作。
选择 InfluxDB 的场景: - IoT 设备数据采集(设备主动推送数据)。 - 需要灵活的数据保留策略和降采样。 - 写入吞吐量要求极高,达到百万级数据点/秒。 - 开发团队愿意学习 Flux 查询语言。
选择 TimescaleDB 的场景: - 需要将时序数据与业务数据关联查询(JOIN)。 - 团队熟悉 SQL,不希望引入新的查询语言。 - 高基数场景(千万级唯一序列)。 - 需要事务支持或复杂的数据约束。 - 已有 PostgreSQL 运维经验和基础设施。
8.3 性能基准参考
以下是一组参考性的性能数据(具体数值因硬件配置和数据特征而异):
测试环境: 8 核 CPU, 32 GB 内存, NVMe SSD
数据模式: 10 个标签, 5 个字段, 15 秒间隔
写入吞吐量(数据点/秒):
InfluxDB 2.7: ~800,000
Prometheus 2.45: ~250,000 (含抓取开销)
TimescaleDB 2.11: ~400,000 (COPY 批量写入)
简单范围查询延迟(最近 1 小时, 单序列):
InfluxDB: ~5 ms
Prometheus: ~3 ms (Head Block 命中)
TimescaleDB: ~8 ms
复杂聚合查询(过去 24 小时, 1000 个序列, 5 分钟聚合):
InfluxDB: ~120 ms
Prometheus: ~200 ms
TimescaleDB: ~80 ms (使用连续聚合物化视图时 ~5 ms)
磁盘空间占用(1 亿数据点):
InfluxDB: ~150 MB (TSM 压缩)
Prometheus: ~180 MB (Block 压缩)
TimescaleDB: ~130 MB (列式压缩) / ~1.2 GB (未压缩)
九、工程案例:大规模 IoT 监控平台
9.1 业务背景
某智能制造企业运营着 50 个工厂,每个工厂部署了约 2000 个传感器,用于监测生产设备的振动频率、温度、电流、转速等参数。系统需要实时检测设备异常,提前预警潜在故障。
关键技术指标:
传感器总数: 50 × 2000 = 100,000 个
采集频率: 每个传感器 1 秒 1 个数据点
每个数据点: 5 个度量字段(振动、温度、电流、转速、压力)
写入吞吐量: 100,000 × 5 = 500,000 数据点/秒
日增数据量: 500,000 × 86,400 ≈ 432 亿数据点/天
数据保留: 原始数据 30 天,聚合数据 3 年
查询延迟要求: 实时告警 < 1 秒,历史趋势 < 3 秒
9.2 架构设计
经过技术评估,团队选择了 TimescaleDB 作为核心存储,原因包括:
- 需要将传感器数据与设备台账、维保记录等业务数据关联查询。
- 设备异常检测算法需要复杂的 SQL 窗口函数和统计计算。
- 运维团队对 PostgreSQL 有丰富经验。
整体架构如下:
传感器设备 → MQTT Broker → Kafka → 流处理层 → TimescaleDB
│
├→ 实时告警引擎
└→ Grafana 仪表盘
数据表设计:
-- 传感器原始数据表
CREATE TABLE device_metrics (
time TIMESTAMPTZ NOT NULL,
factory_id SMALLINT NOT NULL,
device_id INTEGER NOT NULL,
vibration REAL,
temperature REAL,
current_amp REAL,
rpm REAL,
pressure REAL
);
SELECT create_hypertable(
'device_metrics',
by_range('time', INTERVAL '1 day')
);
SELECT add_dimension(
'device_metrics',
by_hash('factory_id', 8)
);
-- 创建索引
CREATE INDEX idx_device_metrics_device
ON device_metrics (device_id, time DESC);
-- 启用压缩
ALTER TABLE device_metrics SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'factory_id, device_id',
timescaledb.compress_orderby = 'time DESC'
);
SELECT add_compression_policy('device_metrics', INTERVAL '2 days');
-- 设置数据保留策略:原始数据保留 30 天
SELECT add_retention_policy('device_metrics', INTERVAL '30 days');
-- 创建小时级连续聚合
CREATE MATERIALIZED VIEW device_metrics_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS bucket,
factory_id,
device_id,
avg(vibration) AS avg_vibration,
max(vibration) AS max_vibration,
avg(temperature) AS avg_temperature,
max(temperature) AS max_temperature,
avg(current_amp) AS avg_current,
avg(rpm) AS avg_rpm,
count(*) AS sample_count
FROM device_metrics
GROUP BY bucket, factory_id, device_id
WITH NO DATA;
SELECT add_continuous_aggregate_policy('device_metrics_hourly',
start_offset => INTERVAL '4 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour'
);9.3 数据写入优化
为了达到 50 万数据点/秒的写入吞吐量,采用了以下优化策略:
import psycopg2
from io import StringIO
import csv
class TSDBBatchWriter:
"""高性能批量写入器,使用 PostgreSQL COPY 协议"""
def __init__(self, dsn: str, batch_size: int = 10000):
self.dsn = dsn
self.batch_size = batch_size
self.buffer: list[tuple] = []
self.conn = psycopg2.connect(dsn)
self.conn.autocommit = True
def add_point(self, time, factory_id, device_id,
vibration, temperature, current_amp, rpm, pressure):
self.buffer.append((time, factory_id, device_id,
vibration, temperature, current_amp, rpm, pressure))
if len(self.buffer) >= self.batch_size:
self.flush()
def flush(self):
if not self.buffer:
return
sio = StringIO()
writer = csv.writer(sio, delimiter='\t')
for row in self.buffer:
writer.writerow(row)
sio.seek(0)
with self.conn.cursor() as cur:
cur.copy_from(sio, 'device_metrics',
columns=('time', 'factory_id', 'device_id',
'vibration', 'temperature', 'current_amp',
'rpm', 'pressure'), sep='\t')
self.buffer.clear()关键优化点:
- 使用 PostgreSQL 的
COPY协议而非逐条INSERT,写入性能提升约 10 倍。 - 客户端本地缓冲批量提交,减少网络往返。
- 开启
autocommit避免长事务导致的 WAL 膨胀。 - Kafka 消费者按
factory_id分区,写入时天然按分区对齐。
9.4 异常检测查询
设备异常检测使用滑动窗口统计和阈值比较:
-- 检测振动异常:过去 10 分钟的振动值超过历史基线的 2 倍标准差
WITH baseline AS (
SELECT device_id, avg(avg_vibration) AS hist_mean,
stddev(avg_vibration) AS hist_stddev
FROM device_metrics_hourly
WHERE bucket > now() - INTERVAL '30 days'
AND bucket < now() - INTERVAL '1 day'
GROUP BY device_id
),
recent AS (
SELECT device_id, avg(vibration) AS recent_avg,
max(vibration) AS recent_max
FROM device_metrics
WHERE time > now() - INTERVAL '10 minutes'
GROUP BY device_id
)
SELECT r.device_id, r.recent_avg, r.recent_max,
b.hist_mean, b.hist_stddev,
(r.recent_avg - b.hist_mean) / NULLIF(b.hist_stddev, 0) AS z_score
FROM recent r JOIN baseline b ON r.device_id = b.device_id
WHERE (r.recent_avg - b.hist_mean) > 2 * b.hist_stddev
ORDER BY z_score DESC;9.5 实施效果
经过 6 个月的生产运行,该平台的关键指标如下:
| 指标 | 数值 |
|---|---|
| 写入吞吐量 | 稳定在 48 万点/秒 |
| 原始数据压缩比 | 17:1(列式压缩后) |
| 磁盘占用(30 天原始数据) | 约 2.8 TB |
| 磁盘占用(3 年聚合数据) | 约 120 GB |
| 实时告警延迟 | P99 < 800 ms |
| 历史趋势查询延迟 | P99 < 2 秒 |
| 设备故障预测准确率 | 87%(结合 ML 模型) |
十、最佳实践与常见陷阱
10.1 数据建模最佳实践
- 控制标签基数:避免将高变异度的值(如用户 ID、请求 ID、IP 地址)作为标签。这些值会导致时间序列数量爆炸。
错误示范:
http_request_duration{user_id="u12345", request_id="req-abc-123"} 0.42
正确做法:
http_request_duration{method="GET", path="/api/users", status="200"} 0.42
# user_id 和 request_id 应存储在日志系统而非时序数据库
合理选择采集间隔:不是越频繁越好。对于变化缓慢的指标(如磁盘容量),60 秒甚至 5 分钟的间隔就足够了。
使用枚举标签而非字符串字段:标签用于索引和分组查询,应当是有限的枚举值集合。
10.2 写入优化
通用写入优化清单:
[1] 使用批量写入而非逐条插入
[2] 按时间排序写入,减少乱序写入比例
[3] 尽可能使用二进制协议而非文本协议
[4] 启用客户端本地缓冲和压缩
[5] 避免在写入路径上执行同步索引更新
[6] 合理设置 WAL 刷盘策略(fsync 频率)
10.3 查询优化
查询优化清单:
[1] 始终在查询中指定时间范围,避免全表扫描
[2] 利用降采样/物化视图加速长时间跨度查询
[3] 限制返回的序列数量(使用 LIMIT 或 topk)
[4] 避免在标签上使用正则匹配(性能差)
[5] 使用预聚合代替实时聚合
[6] 监控慢查询日志,定期优化
10.4 常见陷阱
陷阱一:忽视时序数据的冷热分层
新手常犯的错误是将所有数据以相同精度存储在相同的存储介质上。正确做法是实施分层存储:
热数据(最近数小时): 内存 / SSD,原始精度,支持快速查询
温数据(最近数天): SSD,原始精度,支持常规查询
冷数据(数周到数月): HDD / 对象存储,降采样后的数据
归档数据(更久远): 对象存储,高度压缩和降采样
陷阱二:在 Prometheus 中存储高基数数据
Prometheus 的倒排索引存储在内存中,高基数标签会导致内存使用量线性增长甚至 OOM。常见的踩坑标签包括:
高基数标签(避免):
pod_name — Kubernetes Pod 重启后名称变化
container_id — 每次重启都变化
ip_address — 动态 IP 地址
trace_id — 链路追踪 ID
安全标签(推荐):
service — 服务名称
namespace — 命名空间
method — HTTP 方法
status_code — 状态码
陷阱三:不设数据保留策略
不设置保留策略会导致存储空间无限增长。应当为每种精度的数据设置明确的保留期限和自动清理机制。
陷阱四:查询不带时间范围
不带时间范围的查询会扫描全部历史数据,可能导致查询超时甚至拖垮整个集群。所有查询语言都提供了时间范围限制:
Prometheus: rate(metric[5m]) 中的 [5m] 就是时间范围
InfluxDB: |> range(start: -1h) 限定查询范围
SQL: WHERE time > now() - INTERVAL '1 hour'
十一、总结
时序数据架构的核心在于理解并利用时序数据的固有特征:时间有序、写入密集、查询模式可预测。Gorilla 编码以极低的空间开销将数据保持在内存中;LSM-Tree 将随机写入转化为顺序写入以最大化磁盘吞吐;降采样策略在精度和存储成本之间找到平衡点。
InfluxDB 以自研引擎追求极致的写入性能,Prometheus 以拉取模型定义了云原生监控的标准范式,TimescaleDB 以 SQL 兼容性降低了时序数据分析的门槛。三者并非简单的替代关系,而是在不同维度上各有侧重。选型时应当从数据模型复杂度、查询需求、团队技术栈和运维能力等多个角度综合评估。
时序数据的价值不仅在于存储,更在于分析——从海量数据中提取趋势、发现异常、预测未来。一个好的时序数据架构,应当让数据的写入像水流一样自然畅通,让数据的查询像索引一样精准高效。
上一篇:搜索引擎架构
下一篇:数据迁移与版本化
参考资料
- Pelkonen, T., et al. “Gorilla: A Fast, Scalable, In-Memory Time Series Database.” Proceedings of the VLDB Endowment, 2015.
- O’Neil, P., et al. “The Log-Structured Merge-Tree (LSM-Tree).” Acta Informatica, 1996.
- InfluxData. “InfluxDB TSM Storage Engine.” InfluxDB Documentation.
- Prometheus Authors. “Prometheus TSDB Design.” Prometheus Documentation.
- Freedman, M., et al. “TimescaleDB: SQL Made Scalable for Time-Series Data.” SIGMOD, 2020.
- Borthakur, D. “Under the Hood: Building and Open-Sourcing RocksDB.” Facebook Engineering Blog, 2013.
- Thanos Project. “Thanos: Highly Available Prometheus Setup with Long-Term Storage.” GitHub.
- Grafana Labs. “Cortex: Horizontally Scalable, Highly Available, Multi-Tenant, Long-Term Prometheus.” GitHub.
- Dunning, T. and Ertl, O. “Computing Extremely Accurate Quantiles Using t-Digests.” arXiv, 2019.
- Timescale, Inc. “Continuous Aggregates in TimescaleDB.” Timescale Documentation.
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】指标与监控架构:维度数据模型与基数爆炸
Prometheus 的 pull 模型在小规模集群中简洁高效,但当目标实例超过十万、指标基数突破千万时,单实例架构迅速遇到瓶颈。本文从时序数据库的存储原理出发,拆解 Prometheus、VictoriaMetrics、Thanos 的联邦与长期存储架构,分析基数爆炸的成因与治理手段,结合 USE、RED、Golden Signals 三种方法论,给出大规模指标监控体系的工程设计路径。
时序数据压缩:Gorilla 编码与 Delta-of-Delta
Facebook 的 Gorilla 论文改变了时序数据库的压缩格局。
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。