首页 文章 项目 标签 关于我 友链

时序数据库

> 介绍时序数据库的定义、诞生背景以及它与传统关系型数据库的核心区别和典型应用场景。 **时序数据库** (Time-Series Database, 简称 ==TSDB==) 是一种专门为处理时间序列数据而优化的数据库。时间序列数据是由*...

· 13 分钟阅读

时序数据库

时序数据库 (TSDB) 基础入门

本文将介绍时序数据库的定义、诞生背景,并对比其与传统关系型数据库的核心区别及典型应用场景。

核心概念

时序数据库 (Time-Series Database, 简称 TSDB) 是一种专门为处理时间序列数据而优化的数据库。时间序列数据是由带有时间戳的客观数据点组成的序列,通常按时间顺序持续产生。

为什么需要专门的时序数据库?

传统关系型数据库(如 MySQL、PostgreSQL)虽然也能存储带时间戳的数据,但在面对时序数据特有的场景时,往往会暴露出性能瓶颈:

  • 极高的写入吞吐量:物联网传感器或服务器集群每秒可能产生数百万个数据点。
  • 追加写入为主:时序数据记录的是已发生的历史事实,通常是 Append-only(仅追加) 的,极少涉及更新或删除。
  • 时间范围查询需求:最常见的查询是“获取过去一小时的平均 CPU 使用率”,而非查找某条特定记录。

典型应用场景

  1. IT 基础设施监控:监控服务器集群的 CPU、内存、网络流量(如 Prometheus)。
  2. 物联网 (IoT):收集传感器设备采集的温度、湿度、车辆轨迹等数据。
  3. 金融量化分析:记录股票市场的逐笔交易数据、K 线数据等。

主流时序数据库代表

  • InfluxDB:最流行的开源 TSDB,生态系统完善。
  • Prometheus:云原生监控的标准,内置 TSDB 功能。
  • TimescaleDB:基于 PostgreSQL 构建,完美支持 SQL。
graph TD
  A[数据源] --> B(高并发追加写入)
  A1[IoT 传感器] --> A
  A2[应用监控] --> A
  A3[金融交易] --> A
  B --> C{时序数据库 TSDB}
  C --> D(时间范围聚合查询)
  D --> E[数据可视化/Grafana]
  D --> F[告警系统]

参考链接:


时序数据模型与多维查询

深入解析时序数据的内部结构,包括时间戳、指标、标签和数据字段,帮助你理解其多维数据模型。

时序数据的基本结构

尽管不同数据库的术语有所差异,但一条典型的时序数据记录(Data Point)通常包含以下四个核心维度:

  1. 时间戳 (Timestamp):数据的绝对时间,是时序数据的主键维度,通常精确到毫秒甚至纳秒。
  2. 指标名/表名 (Metric/Measurement):描述数据类别,例如 cpu_usagetemperature
  3. 标签/维度 (Tags/Labels):键值对格式,用于描述数据源的元数据。标签通常会被建立索引,以便快速过滤和分组计算,例如 host=server01, region=cn-hangzhou
  4. 数据值/字段 (Fields/Values):实际测量的值。字段通常不建索引,可以是数值型(整数、浮点数)、布尔型或字符串,例如 value=85.5

多维数据模型 (Multi-Dimensional Model)

传统数据库多采用扁平的表格结构,而现代时序数据库(如 Prometheus、InfluxDB)采用的是多维数据模型。这意味着同一个指标(Metric)加上不同的标签(Tags)组合,就构成了一个唯一的时间序列(Time Series)。

示例数据结构:

# InfluxDB 的 Line Protocol 格式
measurement,tag_set field_set timestamp
cpu_load,host=server01,region=cn value=0.64 1629811200000000000
cpu_load,host=server02,region=cn value=0.55 1629811200000000000

在这个例子中:

  • cpu_load 是指标 (Metric)
  • host=server01, region=cn 是标签 (Tags),会被索引
  • value=0.64 是字段 (Field),即实际数值
  • 1629811200... 是时间戳 (Timestamp)

为什么要区分 Tags 和 Fields?

区分两者的核心在于索引成本。数据库会为 Tags 构建倒排索引,使得 WHERE host='server01' 这样的查询极快;但如果将不断变化的随机数值(如 CPU 负载具体值)当作 Tag,会导致索引数量爆炸,这就是著名的 高基数 (High Cardinality) 问题。

graph TD
  A[Data Point 数据点] --> B(Timestamp 时间戳)
  A --> C(Metric/Measurement 指标名)
  A --> D(Tags/Labels 标签-建索引)
  A --> E(Fields/Values 字段-不建索引)
  D --> D1[host=web-server-1]
  D --> D2[env=production]
  E --> E1[cpu_usage=85.2]
  E --> E2[memory_usage=1024]

参考链接:


时序查询与聚合函数分析

探讨时序数据库特有的查询语言和操作模式,重点理解基于时间窗口的聚合分析。

时序查询的核心特征

时序数据库的查询模式与关系型数据库有本质区别,绝大部分查询属于基于时间范围的聚合分析。用户通常不关心某一个特定毫秒的数据点,而是关注“过去一段时间的整体趋势”。

时间窗口 (Time Windows)

在分析连续的时间线时,我们通常会将数据划分到不同的时间窗口中进行聚合:

  1. 滚动窗口 (Tumbling Window):固定大小、不重叠的窗口。例如,计算每分钟的平均 CPU 使用率(00:00-00:01, 00:01-00:02)。
  2. 滑动窗口 (Sliding Window):固定大小、有重叠的窗口。例如,每 10 秒钟计算一次过去一分钟的平均值。

常用聚合函数

除了常规的 SUMAVGMAXMIN 外,时序分析中还经常使用特殊的数学函数:

  • 百分位数 (Percentiles, 如 P99, P95):在监控系统中至关重要。P99 延迟为 100ms 表示 99% 的请求在 100ms 内完成,相比平均值,它能更好地反映长尾效应。
  • 变化率 (Rate/Derivative):计算指标随时间的增长速率。例如,通过对累计总流量(Counter 类型)求导,得出当前的实时带宽。

查询语言示例

PromQL (Prometheus Query Language) 示例: 获取过去 5 分钟内,所有 web 服务器 HTTP 500 错误率的平均值:

rate(http_requests_total{job="web", status="500"}[5m])

SQL 扩展示例 (TimescaleDB): 将数据按 1 小时对齐(滚动窗口),并计算最高温度:

SELECT 
  time_bucket('1 hour', time) AS bucket,
  MAX(temperature) 
FROM sensor_data 
GROUP BY bucket 
ORDER BY bucket DESC;
flowchart LR
  A[原始数据点] -->|每秒一个点| B(时间窗口划分)
  B -->|Tumbling Window 1m| C[聚合计算]
  C --> D1[AVG/MIN/MAX]
  C --> D2[P99 / P95]
  C --> D3[Rate变化率]
  D1 --> E[平滑趋势图]
  D2 --> E
  D3 --> E

参考链接:


底层存储引擎与高压缩率原理

剖析时序数据库为了应对海量写入和降低存储成本,在底层存储架构(如 LSM Tree)及数据压缩算法上的核心设计。

为什么不用 B+ 树?

传统关系型数据库多采用 B+ Tree 作为存储引擎。B+ 树适合随机读写,但在时序场景下,源源不断的数据涌入会导致 B+ 树频繁进行节点分裂,产生严重的 写放大 (Write Amplification) 和随机磁盘 I/O,使写入性能急剧下降。

LSM Tree 与 TSM Tree

现代时序数据库普遍采用基于 LSM Tree (Log-Structured Merge-Tree) 的变种架构:

  1. WAL (Write Ahead Log):写入数据首先追加到日志中,确保宕机不丢数据。
  2. MemTable / 内存缓存:数据随后写入内存,当积攒到阈值时,直接批量 Dump 到磁盘,将随机写转化为极速的顺序写
  3. SSTable / TSM 文件:落盘后的只读数据文件。后台会定期执行 Compaction (合并操作),将小文件合并并清理过期数据。

(注:InfluxDB 针对时序场景定制了 LSM Tree,演进出了专门的 TSM (Time-Structured Merge Tree) 架构。)

列式存储与高压缩率

时序数据非常适合列式存储。因为同一列的数据类型一致,且随时间变化幅度小,这为高效压缩提供了基础:

  • Delta-of-Delta 压缩 (针对时间戳):计算相邻时间戳的差值(Delta),再计算差值的差值(Delta-of-Delta),结果往往是 0 或极小的整数,极大地减少了存储空间。
  • Gorilla 算法 (针对浮点数):Facebook 开源的算法,通过异或 (XOR) 运算比较相邻的浮点数值,大幅降低存储开销。
graph TD
  A[客户端写入请求] --> B[WAL 预写日志]
  A --> C[内存: MemTable]
  C -->|达到阈值 Flush| D[磁盘: SSTable / TSM File 层级1]
  D -->|后台 Compaction| E[磁盘: TSM File 层级2]
  E -->|后台 Compaction| F[磁盘: TSM File 层级3]
  style C fill:#f9f,stroke:#333,stroke-width:2px
  style B fill:#ffd,stroke:#333

参考链接:


生命周期管理:降采样与数据保留

探讨如何通过降采样 (Downsampling) 和保留策略 (Retention Policy) 在长期存储成本与查询精度之间取得平衡。

无限数据增长的困境

时序数据具有持续且无限产生的特征。如果以 1 秒的精度存储所有监控数据,几个月内就会消耗掉数百 TB 空间。然而,数据的价值会随时间急剧衰减

  • 最近 1 小时:需要秒级精度,用于故障排查。
  • 1 个月前:仅需小时级精度,用于查看趋势。
  • 1 年前:仅需天级精度,用于长期容量规划。

降采样 (Downsampling / Continuous Queries)

降采样是一种自动化机制,定期读取高精度数据,利用聚合函数转换为低精度数据,并写入新表。

典型的降采样流程:

  1. 收集 1秒 精度的原始数据,存入 raw_data 表。
  2. 配置后台任务:每 5 分钟计算一次平均值,将 1分钟 精度的数据写入 data_1m 表。
  3. 每 1 小时,基于 data_1m 计算聚合值,写入 data_1h 表。

数据保留策略 (Retention Policy, RP)

结合降采样,TSDB 提供了自动删除过期数据的机制。由于时序数据按时间分片(Time Sharding)存储,过期删除不需要执行低效的 DELETE 语句,而是直接删除底层过期的时间片文件,开销极低。

生命周期组合拳示例:

  • 策略 A:raw_data (秒级) 保留 7 天。
  • 策略 B:data_1m (分钟级) 保留 30 天。
  • 策略 C:data_1h (小时级) 保留 1 年。 通过此机制,数据库可节省 90% 以上的存储空间,且不影响宏观趋势分析。

参考链接:


分布式架构与高基数问题挑战

深入探讨分布式时序数据库的集群架构、分片策略,以及如何应对“高基数 (High Cardinality)”这一终极挑战。

分布式分片策略 (Sharding Strategy)

当单机容量无法支撑海量数据时,时序数据库需扩展为集群。其分片通常采用双维度策略:

  1. 按时间分片 (Time Sharding):每个分片存储特定时间跨度(如一天)的数据,便于生命周期管理和缩小扫描范围。
  2. 按哈希/标签分片 (Hash/Tag Sharding):将同一时间段的数据根据 Tags(如设备 ID)哈希到不同节点,实现负载均衡。

核心挑战:高基数问题 (High Cardinality)

基数 (Cardinality) 指集合中不同元素的数量。在时序数据中,总时间序列数量 = 所有索引标签组合的总数。

高基数的灾难场景: 如果在 Tags 中引入了 IP 地址、随机 UUID 或容器 ID 等无界值,时间序列总数会呈指数级爆炸(数亿甚至数十亿级别)。

高基数的危害:

  1. 倒排索引膨胀:内存无法装下庞大的索引字典,导致严重的内存溢出 (OOM)。
  2. 查询极慢:底层需要扫描成千上万的零碎序列来合并数据。

优化方案

  1. 严格的数据建模:禁止将无界的随机值放入 Tags(应放入不建索引的 Fields)。
  2. 倒排索引优化:如 Prometheus 借鉴了全文检索的倒排结构,InfluxDB 引入了 TSI (Time Series Index) 架构,将庞大的内存索引落盘。
  3. 分离计算与存储:新一代 TSDB(如 VictoriaMetrics, InfluxDB IOx)将存储卸载到 S3 等对象存储,计算节点无状态化,通过 Parquet 列存格式应对高基数分析。
graph TD
  A[路由/代理层] --> B(Hash: device_id=A)
  A --> C(Hash: device_id=B)
  B --> D[节点1: 昨天数据切片]
  B --> E[节点1: 今天数据切片]
  C --> F[节点2: 昨天数据切片]
  C --> G[节点2: 今天数据切片]
  style D fill:#f9f,stroke:#333
  style F fill:#f9f,stroke:#333

参考链接:

所有文章