TimescaleDB 压缩:PostgreSQL 高达 98% 压缩率,显著降低存储成本、提升查询速度

跳转到主要内容

RoszigIT

____

TimescaleDB 压缩:PostgreSQL 中高达 98% 比率的 Hypercore 和列存储

1. 主页

2. 博客

3. TimescaleDB 压缩:PostgreSQL 中高达 98% 比率的 Hypercore 和列存储

作者:Aleksander Roszig 2026 年 5 月 29 日 | 阅读时长:12 分钟

TimescaleDB 对于典型的时间序列数据可实现高达 98% 的压缩率。压缩时间序列数据需要一种与 OLTP 数据库中通用算法截然不同的方法。在 TimescaleDB 中,这由 Hypercore 引擎处理,它是一种混合行 - 列引擎,使用专门的算法,如增量编码、增量的增量、Gorilla XOR 和游程编码。本文将解释其工作原理以及如何配置压缩以实现该压缩率。

TimescaleDB 压缩与 PostgreSQL TOAST 的区别

PostgreSQL 有一个内置机制,称为 TOAST(超大属性存储技术),但 TimescaleDB 压缩解决的是一个根本不同的问题。TOAST 处理单个大值(长字符串、jsonb、bytea),而 TimescaleDB 压缩则优化时间序列数据中的 跨行模式。这两种机制是互补的,而非竞争关系,TimescaleDB 甚至在内部将 TOAST 用作某些数据类型的备用方法。PostgreSQL 使用固定的“页面大小”,通常为 8 kB,并且不允许元组跨多个页面。因此,当字段值非常大时,数据必须进行压缩和/或分割到多个物理行中。

特性TOAST(原生 PostgreSQL)TimescaleDB Hypercore
设计目标单个值 > 2 KB时间序列中的跨行模式
触发条件行超过 `TOAST_TUPLE_THRESHOLD`(约 2 KB)按块策略(例如,超过 7 天)
支持的类型仅可变长度类型(`text`、`jsonb`、`bytea`、`numeric`)所有数据类型
算法`pglz`(默认),`lz4`(从 PG14 开始,可选)组合:增量编码、增量的增量、simple-8b、游程编码、基于 XOR 的编码、字典压缩
压缩粒度每个值(1 个值 = 1 个字节流)每批(约 1000 行一起)
利用数据结构否 - 将值视为不透明字节是 - 利用数值结构、单调性、重复性
传感器浮点数的典型压缩率~1.0×(无压缩)10 - 20×
时间戳的典型压缩率~1.0×(无压缩 - 固定长度类型)50 - 100×(规则间隔的增量的增量)
文本的典型压缩率2 - 3×(通用 LZ)5 - 10×(如果有重复性,使用字典 + RLE)

该表显示了两者的差异程度。对于具有浮点数和时间戳的典型物联网工作负载(即 TOAST 根本无法压缩的列),TimescaleDB 可达到 10 - 100× 的压缩率,因为它是为这类数据而构建的。

Hypercore 引擎与列存储压缩

在 TimescaleDB 中,压缩由名为 Hypercore 的引擎处理,这是一种混合行 - 列引擎。新数据首先存储在基于 Postgres 行的块中(便于快速插入和更新),而较旧的块会自动转换为列存储的压缩格式。读取这种压缩数据的分析查询所需读取的字节更少,运行速度更快。这种转换可实现高达 98% 的压缩率,显著降低了具有长期数据保留需求的项目的存储成本。与传统的基于行的存储不同,列存储按列组织和压缩数据,而不是按行顺序存储。因此,查询可以批量获取所需字段,而无需扫描整行。

行的转换

将一个块进行转换时,会将行分组为最多 1000 行的批次,每个批次在压缩表中成为 一行,其中列是数组。

每个压缩批次:

  • 将列数据封装在每列最多 1000 个值的压缩数组中,作为压缩表中的单个条目存储。
  • 在批次内使用列优先格式,通过将同一列的值放在一起实现高效扫描,并允许你选择单个列而无需读取整个批次。
  • 应用高级列级压缩技术,如游程编码、增量编码、Gorilla 压缩,减少存储并提高 I/O 性能。

来源:https://www.tigerdata.com/docs/learn/deep-dive/whitepaper#data-model

以下是使用增量编码进行压缩的示例:

时间机器 ID传感器类型
12:00:00MACHINE_001温度72.5
12:00:00MACHINE_001速度2.0
12:00:05MACHINE_001温度72.7
12:00:05MACHINE_001速度2.1
12:00:10MACHINE_001温度72.4
12:00:10MACHINE_001速度2.4

使用增量编码时,你只需存储每个值相对于前一个数据点的变化量,这意味着存储的值更小。在第一行之后,你可以用更少的信息表示后续行,例如:

时间机器 ID传感器类型
12:00:00MACHINE_001温度72.5
0 秒MACHINE_001速度2.0
5 秒MACHINE_001温度+0.2
0 秒MACHINE_001速度+0.1
5 秒MACHINE_001温度-0.3
0 秒MACHINE_001速度+0.3

在时间序列数据中,某些值通常会在一段时间内重复。例如,如果你有一个温度传感器,在 10 分钟内读数为 72.5 度,然后突然升至 73.0 度并保持 10 分钟,你可以使用增量的增量编码。如果时间间隔是恒定的(例如,始终为 5 秒),增量的增量为 0,可以用很少的位数存储。

时间机器 ID传感器类型
12:00:00MACHINE_001温度72.5
+5 秒MACHINE_001温度+0.2
0 秒MACHINE_001温度-0.3
0 秒MACHINE_001温度+0.3
0 秒MACHINE_001温度-0.1

增量编码对于变化较小的数值非常有效,但时间序列数据中也经常包含同一值在许多连续行中重复的列,例如 `machine_id`、`sensor_type` 或设备状态。在这种情况下,会使用游程编码(RLE),它不是重复存储相同的值,而是存储一次该值并记录重复次数。

压缩前的数据:

时间机器 ID传感器类型
12:00:00MACHINE_001温度72.5
12:00:05MACHINE_001温度72.7
12:00:10MACHINE_001温度72.4
12:00:15MACHINE_001温度72.6
12:00:20MACHINE_001温度72.5

对 `machine_id` 和 `sensor_type` 列应用 RLE 后:

机器 ID传感器类型
MACHINE_001 × 5温度 × 5

我们不是存储 5 份字符串 `MACHINE_001`(约 55 字节),而是存储单个值和一个计数器(约 15 字节)。对于共享相同 `machine_id` 值的数百万行,节省的空间非常可观。

最终结果如下:

技术压缩后的表示
`时间`增量的增量`12:00:00`,`+5s`,`0`,`0`,`0`
`机器 ID`游程编码`MACHINE_001 × 5`
`传感器类型`游程编码`温度 × 5`
`值`增量编码`72.5`,`+0.2`,`-0.3`,`+0.2`,`-0.1`

TimescaleDB 还使用了许多其他方法,你可以在 官方文档 中了解更多。

压缩并非“一刀切”,TimescaleDB 会根据列类型选择算法,这是理解不同模式下压缩率差异如此之大的关键:

  • 整数、时间戳、布尔值和类似整数的类型:结合使用增量编码、增量的增量、simple-8b 和游程编码。增量的增量会产生小数字(对于规则间隔,全为零),然后 simple-8b 将这些小数字物理打包成每个值几个比特。Facebook 的 Gorilla 算法也采用了类似的方法(对时间戳使用增量的增量)。
  • 没有太多重复的列(例如,温度和振动测量的浮点数):基于 XOR 的压缩(基于 Gorilla),并结合一点字典压缩。当相邻浮点数相似时,对它们进行 XOR 运算会得到一个有很多前导和尾随零的结果,这样你只需存储中间的“有效”比特,而不是完整的 64 位。
  • JSONB:有两层压缩,首先是字典压缩(当值重复时),如果没有重复,则回退到 PostgreSQL TOAST(默认使用 `pglz`,如果配置则使用 `lz4`)。
  • 其他类型(字符串、更不常见的类型):使用字典压缩。字典索引也会经过 simple-8b + RLE 处理,因此压缩是分两步进行的。

这就是为什么 `sensor_type` 以 `'TEMPERATURE'/'SPEED'/'PRESSURE'` 形式存储时压缩效果很好(一个 3 元素的字典加上索引的 RLE),单调递增的 `time` 每个值几乎可以压缩到零字节,而高熵列(如每行的 UUID)压缩效果则差很多,因为每个值都是唯一的,字典和原始数据一样大,TimescaleDB 会检测到这种情况并直接不使用字典。

`segmentby` 和 `orderby` —— 最重要的参数

这两个参数需要你仔细选择,因为它们决定了 压缩前如何将行分组为批次

  • `segmentby`:其值在整个批次中共享的列(例如 `machine_id` 或 `sensor_id`)。该值在每个批次中只存储一次,而不是作为数组存储。此外,查询规划器使用 `segmentby` 元数据跳过与 `WHERE` 子句不匹配的整个批次。
  • `orderby`:批次内的排序顺序(通常是 `time DESC`)。按时间排序能让增量编码和增量的增量发挥最大优势,因为相邻的值彼此接近,所以差异很小,可以用几个比特存储。

ALTER TABLE iot_sensor_data SET (timescaledb.orderby = 'time DESC', timescaledb.segmentby = 'machine_id');

在这样配置的表上执行带有 `WHERE machine_id = '...' AND time BETWEEN ...` 过滤条件的查询,比没有 `segmentby` 时快一个数量级,因为查询规划器根据元数据跳过其他机器的批次,而无需实际访问数据。

TimescaleDB 将行打包成约 1000 行的批次,并分别压缩每个批次。如果 `segmentby` 的基数太高(例如,在物联网中有数千个传感器,`segmentby = sensor_id`,每个传感器每个块只有几行),那么块中的每个“段”行数太少,批次填充不足,压缩效果不佳,因为增量/XOR 编码器需要一系列相似的值才能进行压缩。

官方文档中的规则是:每个段在一个块中应至少包含 100 行,并且每个块中最佳的 `segmentby` 唯一值数量为 100 - 10,000 个。

压缩对查询性能有何影响?

常见问题:压缩会减慢查询速度吗?

简短回答:对于典型的时间序列查询,压缩会加快查询速度。

加快的查询(大多数工作负载)

  • 按时间范围扫描并进行聚合(`SUM`、`AVG`、`MAX` 按时间桶计算)
  • 在 `segmentby` 列上有过滤条件的查询
  • 大范围的顺序扫描

列存储压缩可将 I/O 减少 10 - 20 倍。读取 1 GB 未压缩数据与读取 100 MB 压缩数据相比,意味着更少的磁盘读取、更少的内存使用和更少的反序列化 CPU 开销。

变慢的查询(在时间序列中很少见)

  • 单个行的点查找(`WHERE time = '...' AND id = X`)
  • 对压缩块进行 UPDATE/DELETE 操作(解压缩 → 修改 → 重新压缩循环)
  • 当 `segmentby` 列基数较高时,没有对该列进行过滤的查询
如何实现

-- IoT 传感器监控的列存储配置
ALTER TABLE iot_sensor_data SET (timescaledb.compress, timescaledb.segmentby = 'machine_id', timescaledb.orderby = 'time DESC');

-- 自动转换超过 7 天的块的策略
SELECT add_columnstore_policy('iot_sensor_data', after => INTERVAL '7 days');

-- 验证
SELECT * FROM chunks_detailed_size('iot_sensor_data');

-- 查看哪些块已压缩
SELECT chunk_name, is_compressed, range_start, pg_size_pretty(total_bytes) AS size
FROM timescaledb_information.chunks c
JOIN chunks_detailed_size('iot_sensor_data') cds USING (chunk_schema, chunk_name)
WHERE hypertable_name = 'iot_sensor_data'
AND is_compressed = true
ORDER BY range_start;

真实数据库示例

在我的 `mqtt_data` 表中,有大约 180 个唯一的 `id` 值,每个 `id` 有 4000 - 113000 行,具体取决于块。配置如下:

ALTER TABLE mqtt_data SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = 'id', timescaledb.orderby = 'time DESC');

效果 —— 行存储与列存储块上的相同查询

一个生产类型的查询,“按 `id` 和窄时间范围进行点读取”:

SELECT *
FROM mqtt_data
WHERE time >= '...'::timestamptz
AND time < '...'::timestamptz + interval '5 minutes'
AND id = 'Site1.Machine1.SPEED'
ORDER BY time DESC
LIMIT 10;

指标行存储(块 47,2.3 GB)列存储(块 46,7.2 MB)
执行时间10.2 ms0.36 ms
规划时间19.0 ms1.9 ms
总计29.2 ms2.3 ms
加速比总计约 12.7× / 执行约 28×
数据压缩率42.8×(308 MB → 7.2 MB)

压缩后的块在磁盘上小了约 42 倍(数据相同;列存储中每个块的 B 树索引消失,所以实际节省的空间更大),同时 执行速度快了 28 倍。这并非偶然,而是三个因素共同作用的结果。

查询计划 —— 使用 `EXPLAIN ANALYZE` 展示

列存储块(压缩后)

Limit (actual time=0.058..0.259 rows=10 loops=1)
-> Custom Scan (ChunkAppend) on mqtt_data
Order: mqtt_data.time DESC
-> Index Scan using _hyper_1_47_chunk_mqtt_data_time_idx
on _hyper_1_47_chunk (rowstore, latest)
-> Custom Scan (DecompressChunk) on _hyper_1_46_chunk (never executed)
Vectorized Filter: ((time >= '...') AND (time < '...'))
-> Index Scan using compress_hyper_28_823_chunk_id__ts_meta_min_1__ts_meta_max__idx
Index Cond: ((id = 'Site1.Machine1.ERROR')
AND (_ts_meta_min_1 < '...')
AND (_ts_meta_max_1 >= '...'))
Planning Time: 1.912 ms
Execution Time: 0.363 ms

TimescaleDB 为列存储构建的索引是 `(id, _ts_meta_min_1, _ts_meta_max_1)`,它是自动创建的,并非手动定义。这仅仅是因为 `id` 是 `segmentby`,`time` 是 `orderby`。

行存储块(压缩前)

Limit (actual time=3.562..10.076 rows=10 loops=1)
-> Custom Scan (ChunkAppend) on mqtt_data
Order: mqtt_data.time DESC
-> Index Scan using _hyper_1_47_chunk_mqtt_data_id_time_idx
on _hyper_1_47_chunk
Index Cond: ((id = 'Site1.Machine1.ERROR')
AND (time >= '...')
AND (time < '...'))
Planning Time: 19.014 ms
Execution Time: 10.217 ms

这是一个对 `mqtt_data_id_time_idx` 的经典索引扫描(该块的 B 树索引约 750 MB)。它可以工作,但速度较慢,原因如下:

  • 索引无法全部放入缓存
  • 查询规划器需要读取更大的统计信息
  • Postgres 逐行迭代

为什么列存储在速度上也更胜一筹?

1. 元列上的稀疏最小 - 最大索引

TimescaleDB 会在 `(segmentby_col, _ts_meta_min_1, _ts_meta_max_1)` 上构建一个索引,其中最小/最大是每 1000 行批次的 `orderby` 极值。这使得它可以在不读取整个批次的情况下,仅通过检查元数据就排除整个批次。

2. `segmentby` 作为原生过滤器

具有相同 `id` 的行在物理上是分组在一起的。索引可以立即定位到正确的段,无需在 `(id, time)` 上单独创建 B 树索引。`segmentby` 作为数据布局的副产品,“免费”实现了这种过滤。

3. 向量化执行

对 `time` 范围的操作以批次(每次 1000 行)的方式运行,而不是像经典索引扫描那样逐行运行。

重要注意事项

  1. 这些数据适用于特定用例 —— “按 `id` 和窄时间范围进行点读取”。对于聚合整月数据的查询,差异会不同。对于没有 `id` 过滤条件的扫描查询,列存储可能比索引良好的行存储慢。
  2. 42× 是我的数据集情况。MQTT 传感器数据具有极高的冗余性,值变化平滑(Gorilla 算法效果很好),主题/单位在 `id` 内重复(字典编码发挥到极致)。对于典型的时间序列数据,现实的期望压缩率为 8 - 20×。
  3. 新块保持行存储 —— 策略只转换超过 `after =>` 间隔的块。对当前数据(例如,最后 5 分钟)的查询不受 Hypercore 影响。

如何检查 `segmentby` 是否适用于你的数据?

-- 特定块内的分布情况
WITH per_id AS (
SELECT id, count(*) AS n
FROM _timescaledb_internal._hyper_X_Y_chunk
GROUP BY id
)
SELECT
count(*) FILTER (WHERE n < 100) AS ids_under_100_rows,
count(*) FILTER (WHERE n < 1000) AS ids_under_1000_rows,
count(*) AS total_ids
FROM per_id;

如果 `ids_under_100_rows = 0` 且 `total_ids` 在 100 - 10000 之间,则 `segmentby` 配置良好。

如果大多数 `id` 值的行数少于 100,则需要改变策略。

总结

如果你正在计划部署 PostgreSQL TimescaleDB,特别是用于物联网应用、生产监控或金融系统中的时间序列数据,并且希望确保压缩率达到 15× 而不是 2×,RoszigIT 为行业设计和部署 Grafana + TimescaleDB + AWS 堆栈。如果你需要架构方面的建议或直接支持,请 联系我们

RoszigIT

AWS 和 Kubernetes 专家。

contact@roszigit.com

税号:PL7422280103

领英

公司

服务

支持

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值