
跳转到主要内容
RoszigIT
____
- 主页
- 审计与优化
- 监控与数据
- 关于我们

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:00 | MACHINE_001 | 温度 | 72.5 |
| 12:00:00 | MACHINE_001 | 速度 | 2.0 |
| 12:00:05 | MACHINE_001 | 温度 | 72.7 |
| 12:00:05 | MACHINE_001 | 速度 | 2.1 |
| 12:00:10 | MACHINE_001 | 温度 | 72.4 |
| 12:00:10 | MACHINE_001 | 速度 | 2.4 |
使用增量编码时,你只需存储每个值相对于前一个数据点的变化量,这意味着存储的值更小。在第一行之后,你可以用更少的信息表示后续行,例如:
| 时间 | 机器 ID | 传感器类型 | 值 |
|---|---|---|---|
| 12:00:00 | MACHINE_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:00 | MACHINE_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:00 | MACHINE_001 | 温度 | 72.5 |
| 12:00:05 | MACHINE_001 | 温度 | 72.7 |
| 12:00:10 | MACHINE_001 | 温度 | 72.4 |
| 12:00:15 | MACHINE_001 | 温度 | 72.6 |
| 12:00:20 | MACHINE_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 ms | 0.36 ms |
| 规划时间 | 19.0 ms | 1.9 ms |
| 总计 | 29.2 ms | 2.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 行)的方式运行,而不是像经典索引扫描那样逐行运行。
重要注意事项
- 这些数据适用于特定用例 —— “按 `id` 和窄时间范围进行点读取”。对于聚合整月数据的查询,差异会不同。对于没有 `id` 过滤条件的扫描查询,列存储可能比索引良好的行存储慢。
- 42× 是我的数据集情况。MQTT 传感器数据具有极高的冗余性,值变化平滑(Gorilla 算法效果很好),主题/单位在 `id` 内重复(字典编码发挥到极致)。对于典型的时间序列数据,现实的期望压缩率为 8 - 20×。
- 新块保持行存储 —— 策略只转换超过 `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 堆栈。如果你需要架构方面的建议或直接支持,请 联系我们。
AWS 和 Kubernetes 专家。
税号:PL7422280103

被折叠的 条评论
为什么被折叠?



