时序存储:影响成本与性能的设计选择

点击查看原文>

使用任何时序数据库都需要做出一系列的存储设计决策:如何布局行、何时进行压缩、基于什么进行分区。这些决策对成本和查询性能的影响,甚至比数据库本身的选择更为关键。本文将从第一性原理出发,系统地探讨这些基本问题,并借助 PostgreSQL 和 Apache Parquet 等广泛可用的工具来量化评估每一项权衡。

什么是时序数据?

时序数据是一系列随时间推移记录的测量值。标准数据库记录追踪的是当前状态(如“账户余额为 50 美元”),而时序数据则追踪变化历程(如“10:00、10:01、10:02 时的余额……”)。

现代系统会持续发出有关运行状况和活动状态的信号:

  • 生物识别技术(智能手表每五秒记录一次心率,以便追踪运动强度)。

  • 交通运输(拼车应用通过追踪 GPS 坐标来计算车费和预计到达时间)。

  • 金融(市场行情显示系统每秒捕捉数千次价格更新)。

  • 基础设施(监控工具追踪 CPU 趋势,以便预测资源耗尽情况)。

廉价的传感器和存储设备使得几乎所有数据都能被即时记录并保存完整的历史数据,而不仅仅是快照。核心数据点遵循一种重复的结构:时间戳、标识符和数值。这种简单性具有欺骗性,因为在海量数据规模下,这种重复性反而会成为问题。以下是一个数据序列示例:

表 1:维度和度量示例

此处,“序列”被定义为一个唯一的标识属性组合。例如,{device=thermostat, location=living_room}。

图 1:多序列和数据点示例

每个数据点包含三个部分:

  • 时间戳——测量发生的时间。

  • 维度(或标签)——用于识别和分组数据序列的属性。

  • 指标(或字段)——该时间点的测量值。

一个实用的原则是:将稳定的标识符放在维度中,将变化的测量值放在指标中。

这种划分很重要,因为它们在查询中的用途不同:维度用于过滤和分组(WHERE、GROUP BY device_id);指标用于计算(AVG(temperature_c)、MAX(humidity_pct))。

关系型存储:扁平化 vs. 规范化

要在 PostgreSQL 这样的关系型数据库中存储时序数据,我们可以将所有属性存储在一个扁平表中,也可以将标识符规范化到一个单独的注册表中。

选项 A :扁平化(简单)模式

扁平模式将维度和指标存储在同一行中。这种布局虽然易于实现和查询,但会导致高度冗余。

CREATE TABLE readings_flat (    ts timestamptz NOT NULL,    device_id text NOT NULL,    location text NOT NULL,   region text NOT NULL,    metric_name text NOT NULL,    value double precision NOT NULL ); CREATE INDEX idx_flat_device_ts ON readings_flat (device_id, ts); 
复制代码

选项 B:规范化模式

规范化操作会将稳定的标识符移入 series_dim 表中。每个测量值都会引用一个 series_id,而不是重复使用标识符字符串。

CREATE TABLE series_dim (series_id serial PRIMARY KEY,device_id text NOT NULL,location text NOT NULL,region text NOT NULL,UNIQUE (device_id, location, region));CREATE TABLE readings_normalized (series_id integer REFERENCES series_dim(series_id),ts timestamptz NOT NULL,metric_name text NOT NULL,value double precision NOT NULL);CREATE INDEX idx_norm_series_ts ON readings_normalized (series_id, ts);
复制代码

存储开销实验结果 

我们通过一项涉及一千个序列和 280 万行数据的 PostgreSQL 16 实验,测量了重复操作的成本。规范化处理使总存储空间减少了约 42%(节省了 289 MB)。

表 2:扁平化模式和规范化模式的存储开销对比

成本模型

效率差距取决于维度字节数与什么相乘。对于扁平化数据,TotalBytes ≈ N_rows * (timestamp + metric + dimensions)。对于规范化数据,TotalBytes ≈ N_rows * (timestamp + metric + series_id) + N_series * dimensions。

这两种模式的总存储量均为 O(N_rows)。区别在于每行的开销:在扁平化模式中,每行都携带完整的维度字符串。而在规范化模式中,每行仅携带一个空间占用较小的序列 ID,而维度字符串则每个序列存储一次。由于序列 ID 远小于完整的维度数据,而且在高频监控中 N_series ≪ N_rows,所以在行数相同的情况下,规范化模式存储的数据量要少得多。

查询性能(缓存预热)

通过对比“扁平化”和“规范化”这两种方式,我们可以获得一些有趣的发现。在范围查询中,两者的性能表现相同:0.74 毫秒(扁平化)vs. 0.74 毫秒(规范化)。而在小时平均值的查询中,规范化方式更快:215.51 毫秒(扁平化)vs. 164.41 毫秒(规范化)。通过仅存储一次维度并使用 ID 进行引用,可以在不增加查询开销的情况下减少存储占用。

高基数时规范化失效

当许多行具有相同的标识时,规范化会有所帮助。但当标识字段具有高基数且每行几乎唯一时,规范化的优势就会减弱。

情况 A:可重复维度(规范化有帮助)

表 3:维度可重复示例

这里,N_rows = 3 且 N_series = 1。规范化布局将维度信息仅在 series_dim 中存储一次,并在每个数据点中复用 series_id。

 情况 B:事件唯一标识符(规范化效果减弱)

表 4:高基数维度示例

如果 request_id 是序列标识的一部分,N_series 将趋近于 N_rows。此时,规范化存储的去重效果将大大降低。从实际应用的角度来看,应将稳定的维度保留在序列标识中。此外,除非查询模式有此要求,否则应将 request_id 等事件级 ID 排除在序列标识之外。

外部的一些指南也体现了同样的基数行为。AWS CloudWatch 将每个唯一的维度组合定义为一个独立的指标流。高基数维度会直接增加指标体量。CloudWatch Logs 的指标过滤器则更进一步:它们明确警告用户不要使用 requestID 和 IPAddress 之类的维度,并且尽可能禁用会产生过多独立流的过滤器。InfluxDB 的基数指南也指出了同样的模式:标签中的唯一 ID、哈希值和随机值会导致序列数量膨胀,并同时降低写入和查询性能。

模式演进设计

固定列的模式在标签演进之前都能正常工作。新标签的引入会导致表结构发生变化,需要进行数据回填以及索引更新。对于时序系统,一种实用的模式是维护一个以 series_id 为键的 series_dim 注册表,并将序列属性存储在 jsonb 类型的 dimensions 字段中,如下所示:

CREATE TABLE series_dim (    series_id bigserial PRIMARY KEY,    dimensions jsonb NOT NULL ); 
复制代码

这种方法在标签发生变化时仍然能保持数据摄入的稳定性。这里的权衡在于,索引构建变成了策略决策,而非一次性 DDL 任务。但索引设计仍然需要考虑。针对维度的通用倒排索引(GIN)对于一般的包含关系和键的存在性过滤非常有用。InfluxDB 采用了类似的倒排索引理念,最初出现在 InfluxDB v1 的元数据索引中,随后在时序索引(TSI)中得到进一步发展。Prometheus 采用了类似的 TSDB 索引模式,在分块读取前将标签匹配器映射到序列 ID(参见 Prometheus 概念Prometheus 存储以及 GitHub 上的 Prometheus 项目)。此外,对于频繁查询的一小部分热门属性,B-树表达式索引非常有用(如 dimensions->>‘region’)。

即使采用这种布局,随着时间的推移,仍然会出现一些反复出现的问题:

  • 标签的增长会导致新键的出现,但其中只有部分键对过滤操作有意义。

  • 索引膨胀会导致索引的 JSON 属性过多,增加写入开销和存储空间。

  • 类型漂移会导致同一个键以不同的类型出现(如“42”与 42),除非进行验证。

  • 基数错误会导致易变键被误认为标识键,从而虚增序列数量。

这种设计可以避免频繁的模式迁移,并保持常见查询路径的高速运行。

列式存储:下一代引擎

在序列标识、基数和模式演进得到控制之后,下一个关键点就是存储布局。列式存储将每一列集中存放。对于时序工作负载,即使采用比较扁平的逻辑模型,这种方法也能获得规范化带来的部分存储优势,因为重复维度的压缩效果良好,而且查询扫描的字节数更少。请看以下 Parquet 模式示例:

message readings {     required int64 ts_utc_ms (TIMESTAMP(MILLIS,true));     required binary series_id (UTF8);    required binary metric_name (UTF8);     required double value;     optional group dimensions (MAP) {         repeated group key_value {             required binary key (UTF8);             optional binary value (UTF8);         }    }} 
复制代码

列式存储选项和查询引擎

上文所述的 Parquet 模式定义了数据在磁盘上的布局方式,但 Parquet 是一种文件格式,而非数据库。选择列式存储也意味着需要决定这些文件的存储位置以及由什么来读取它们。

对象存储上的 Parquet

最简单的部署方式是将 Parquet 文件存储在 Amazon Simple Storage Service (S3) 或与 S3 兼容的存储中,如 Amazon S3 Tables、MinIO 或 GCS。这种方法实现了存储成本与计算成本的分离;你只需按通用价格支付数据保留费用,并在进行查询时支付计算费用。

有多种引擎可以直接从 S3 查询 Parquet 数据。DuckDB 是一款嵌入式分析型数据库,非常适合单节点分析和 CLI 探索。AWS Athena 提供直接从 S3 执行的按查询付费的无服务器 SQL 服务。Trino 和 Presto 是专为大规模交互式查询而设计的联合 SQL 引擎,而 Apache Spark 则是处理大型 Parquet 数据集的标准分布式批处理选项。ClickHouse 可以通过外部表函数读取 Parquet,而 Polars 和 Pandas(通过 PyArrow)等 DataFrame 库原生支持 Parquet,可以很方便地在笔记本中进行即席分析。

请注意,本文中用于说明关系型数据库权衡的 PostgreSQL 数据未提供 Parquet 原生查询功能。一个典型的架构是将数据分层:PostgreSQL 处理“热数据”(低延迟写入和索引查找),而存储在 S3 上的 Parquet 则负责处理“冷数据”(以扫描为主的分析查询)。

使用开放式表格式 Apache Iceberg

虽然将原始 Parquet 文件存储在 S3 上行之有效,但在大规模场景下,你无法获得模式演进、ACID(原子性、一致性、隔离性和持久性)事务以及自动分区管理等功能。Apache Iceberg 在 Parquet 文件之上添加了一层元数据,在将 Parquet 作为物理存储的情况下解决了这些问题。对于时序数据,Iceberg 是一个极佳的默认选项,因为它支持不需要重写数据的模式演进、基于时间的隐式分区、快照隔离以及内置的压缩跟踪。实际上,它最大的优势在于广泛的引擎支持。同一张 Iceberg 表既可由 Spark 或 Flink 写入,也可由 Athena、Trino、DuckDB、ClickHouse、Snowflake、BigQuery、Dremio、StarRocks 或 Polars 查询,而且无需复制或转换数据。S3 Tables 提供了一个托管的 Iceberg 兼容存储,可以自动处理压缩、快照过期和目录集成。

大规模生成 Parquet 数据

对于中等规模的数据量,定期导出(例如一个每晚运行的任务,通过 PyArrow 或 DuckDB 查询 PostgreSQL 并写入 Parquet 格式)已经足够。当数据摄入速率较高时,标准的做法是直接将数据流式传输到 Iceberg;Spark Structured Streaming 和 Apache Flink 都原生支持 Iceberg 接收端,能够处理分区、验证模式以及自动调整文件大小。

Parquet 的压缩优势基于文件大小适中这个前提。实际上,在 S3 上采用列式存储后,文件大小问题是最常见的运维难题之一。如果数据管道每隔几秒钟就刷新一个新的 Parquet 文件,结果就会产生成千上万个小微文件。每个文件都携带自己的尾部元数据,而每次 S3 GET 请求都会产生相应的成本。与打开十个各为 1 MB 的文件相比,打开一万个 1 KB 文件查询速度要慢得多,成本也高得多。针对基于 S3 的分析,一个常见的建议是将每个文件的大小限制在 128 MB 到 1 GB 之间,这样可以在并行处理能力与元数据开销及请求成本之间取得平衡。

对比:规范化 Postgres 与列式 Parquet 

为了测算我们这个 280 万行数据集的存储需求,我们使用最高效的规范化 PostgreSQL 布局,与扁平化的 Parquet 文件进行了对比。

表 5:规范化 PostgreSQL 与 列式 Parquet 存储

基准差距几乎完全取决于压缩效果。Parquet 对包括时间戳在内的每一列都应用字典编码,因此,跨序列重复出现的维度字符串、指标名称,甚至时间戳,都会被压缩为带有整数引用的小型查找表。在本次实验中,1000 个序列和 280 万行数据意味着每一列上都存在大量的重复,这就是为什么它仅靠字典编码就能将文件大小压缩至 1 MB 以下。随着时间序列数量增加、时间分辨率提高以及时间范围延长,时间戳的基数不断增大,字典编码在该列上的效果逐渐减弱,压缩比随之缩小。当添加 UUID 列后,由于每个值都是唯一的,字典编码便完全无法发挥作用。此时文件大小跃升至 100 MB,压缩比从约 434 倍降至约 3.7 倍,但这仍然是一个很有意义的压缩效果。

除了压缩比之外,列式布局在处理时间序列工作负载方面还具有更广泛的优势:

优化存储空间占用

由于列式存储将多行数据的值集中存储在一起,所以系统能够根据各列的数据类型和模式,单独针对每一列应用最高效的压缩算法,例如增量(Delta)、RLE 或字典编码。这种专门的压缩技术可以合并连续的时间戳并去除稳定维度的重复数据,与未压缩的行式存储相比,数据量可减少数个数量级。

高效分析读取

查询仅扫描所需的列段,可以避免 I/O 放大效应。这种选择性投影有助于针对特定的指标快速进行聚合,而无需承担读取整行数据的开销。

存储更便宜

将 Parquet 文件存储在通用对象存储(例如 S3)中,既能实现经济高效的长期保留,又能将归档历史数据与活跃数据库所需的高性能 SSD 分离。

如需进一步阅读,请参阅:列式存储与行式存储:它们究竟有多大区别?(SIGMOD 2008)

多指标行的宽模式与窄模式

当多个指标在同一时间戳发布时,行结构会同时影响存储开销和查询复杂度。窄(长格式)模式为每个指标存储一行:

表 6:窄模式:对于每个时间戳,一个指标一行,序列标识与时间戳在每行中重复出现

宽模式一行存储多个指标列:

表 7:宽模式:每行对应一个时间戳,所有指标均作为独立的列存储

权衡:

  • 窄模式:每行指标数据都包含 (series_id, ts)。

  • 宽模式: (series_id, ts) 仅针对该时间戳存储一次。

  • 如果需要查询对应同一时间戳的多个指标,窄模式通常需要在 (series_id, ts) 上进行转置或自连接。

  • 宽模式通过读取单行即可返回这些指标,从而使查询逻辑更简单。

随着每个时间戳对应的指标数增加,窄模式会将更多的存储空间用于存储重复的键值,而非新信息。这种开销既体现在数据中,也体现在索引中。只要指标保持稳定且同时发布,宽模式就能避免大部分重复。

窄模式自连接示例:

SELECT t.series_id, t.ts, t.value AS temperature_c, h.value AS humidity_pct FROM readings_narrow t JOIN readings_narrow h    ON h.series_id = t.series_id    AND h.ts = t.ts WHERE t.metric_name = 'temperature_c'    AND h.metric_name = 'humidity_pct'    AND t.series_id = 'A1'    AND t.ts >= now() - interval '1 hour';
复制代码

宽模式查询:

SELECT series_id, ts, temperature_c, humidity_pct FROM readings_wide WHERE series_id = 'A1' AND ts >= now() - interval '1 hour';
复制代码

实用规则:

  • 当指标集规模较小、稳定且同时生成时,应使用宽模式。

  • 当常见查询需要在同一结果行中包含同一时间戳的多个指标时,应优先选择宽模式。

  • 当指标稀疏、动态变化或独立采样时,应使用窄模式。

  • 当指标集快速演进时,宽模式可能会积累大量可为空的列。

分区:先按时间,再按空间

到目前为止,我们一直假设数据存储在单个表中。随着数据量的增长,单表结构越来越难以高效地查询,维护成本也随之增加。分区技术将数据集拆分为多个相对较小且可独立管理的块。在单数据库实例上,这种方法可以优化查询剪枝、简化数据保留策略,并降低维护开销。如果单个实例不再满足需求,这些分区边界随后将成为将数据分布到不同节点的自然单元。

时间分区

时间是天然的分区轴。时序查询几乎总是有时间范围限制(例如“过去一小时”或“过去 7 天”),而且数据本身的内置排序与数据到达顺序一致。

将数据按时间范围拆分成块可带来以下三点好处:

分区修剪

针对过去一小时的查询仅涉及当前分区。规划器仅通过元数据即可排除不匹配的分区(无需进行索引扫描,也无需进行 I/O 操作)。

数据过期清理成本低

删除旧分区是一项 O(1) 级的元数据操作。而从单个表中删除行则需要进行扫描、标记和 vacuum 操作。对于大型表而言,这些操作可能需要好几分钟的时间,并且会导致 I/O 突发。

独立维护

每个分区都可以独立地进行 vacuum、重新索引或分析。活跃(当前)分区会频繁进行维护;冷分区则保持不变。每个分区的索引也更小、更浅,因此查找速度更快,而且内存使用情况可预测。

在 PostgreSQL 中,这种方法是声明式的:

CREATE TABLE readings (    series_id integer NOT NULL,    ts timestamptz NOT NULL,    metric_name text NOT NULL,    value double precision NOT NULL ) PARTITION BY RANGE (ts); CREATE TABLE readings_2026_02_01    PARTITION OF readings    FOR VALUES FROM ('2026-02-01') TO ('2026-02-02'); CREATE TABLE readings_2026_02_02    PARTITION OF readings    FOR VALUES FROM ('2026-02-02') TO ('2026-02-03');
复制代码

包含 WHERE ts BETWEEN ... 子句的查询会自动仅匹配相关分区。可以通过执行 DROP TABLE readings_2026_01_15 来删除旧数据,而无需进行行级清理。

为什么单用时间分区会导致热点 

时序写入具有一个特性:它们集中在“当下”这一时间点。每个活跃的时序都会将其最新数据点写入同一个当前时间窗口内。如果仅按时间进行分区,当前分区将吸收所有写入流量,而历史分区则处于闲置状态。

对于一个每秒处理 10 万个数据点、涵盖 1 万个时间序列的系统,当前每日分区每秒需要处理全部 10 万次写入操作。前一天的分区则完全没有写入。这种设计本身就形成了写入热点。读取操作也会出现同样的问题。比如,“给我 device-42 过去一小时的数据”这样的查询会命中当前分区,但该分区包含这一小时内所有序列的数据。如果没有进一步的数据组织机制,该查询将扫描所有 1 万个序列的数据来查找所需的那个序列。

二维分区:时间和空间

解决方法是按两个维度进行分区:时间和序列标识。生成的的每个分区都覆盖一个时间范围和一个序列子集。采用这种方法,即使在当前时间窗口内,也能将写入操作分散到多个分区。

通常,“空间”维度是由序列标识衍生而来的:例如序列标识(series_id)的哈希值,或是基于区域或设备类型等的自然分组方式。

图 2:二维时序分区可视化

现在,当日每秒 10 万次的写入操作分布在三个分区上,而不是一个分区。针对特定序列的查询只会访问包含其哈希桶且时间范围相符的分区。如果想深入了解这种二维分区在大型托管系统中的运作原理,请参阅 Werner Vogels 关于 Amazon Timestream 设计的文章

降采样与保留:以分辨率换成本

全分辨率原始时序数据的有效期很短。在事件发生时,每五秒一次的 CPU 读数都至关重要。一周后,分钟级数据变化趋势就已经足够。一年后,人们查询的只有每小时的平均值。

降采样是随着时间的推移降低数据分辨率,方法是预先计算更粗时间间隔的聚合数据。保留策略则规定了何时彻底丢弃数据。二者共同控制着存储成本的增长曲线。

分辨率阶梯

下图是一种典型的策略:

图 3:分辨率阶梯

每一步都会按时间间隔的比例减少行数。从 5 秒分辨率降至 1 分钟分辨率,行数减少 12 倍;从 1 分钟分辨率降至 1 小时分辨率,行数再减少 60 倍。一个每天产生 17280 个原始数据点的序列,在 1 分钟分辨率下变为 1440 个,在 1 小时分辨率下变为 24 个,减少了 720 倍。

仪表板刷新的成本挑战

在存储和保留成本得到控制的情况下,剩下的成本驱动因素就是读取放大效应,而仪表盘是其最常见的来源。

单个仪表盘的查询成本可能很低。但在大规模环境下,刷新流量会迅速推高成本。如果:

U = 并发用户数 Q = 每个仪表板试图的查询数R = 刷新间隔(秒)
复制代码

那么:

QPS ~= (U * Q) / R 
复制代码

数据摄入速率可能很平稳,但读取成本仍然可能激增,因为系统会反复重新计算相同时段的聚合结果。

实际中可考虑的缓解措施:

  • 使用短时效(TTL)缓存结果。

  • 通过预先聚合的汇总数据而非原始行数据来响应仪表盘查询。

  • 在条件允许的情况下,利用仪表盘/查询层中的查询缓存功能(如 Grafana 搭配 Amazon Timestream 使用)。

结论

时序数据库底层的存储决策(包括如何对标识符进行建模、基于何种维度进行分区、何时进行降采样以及如何布局列)对成本和性能的影响远大于数据库选择本身。无论你运行的是 PostgreSQL、S3 上的 Parquet,还是托管的时序服务,这些基本原则都适用。只要能正确地处理这些方面,无论选择哪种数据库,你都能避免那些表现为意外账单和仪表盘运行缓慢的问题。

原文链接:https://www.infoq.com/articles/time-series-storage-design/


本文来源:InfoQ