TimescaleDB 如何压缩时间序列数据
摘要
本文解释了 TimescaleDB 的 hypercore 引擎如何通过列式存储以及 delta 编码和 Gorilla XOR 等专门算法,为时间序列数据实现高达 98% 的压缩率,并将其与 PostgreSQL 的 TOAST 进行了对比。
暂无内容
查看缓存全文
缓存时间: 2026/06/15 17:59
# TimescaleDB 压缩:Hypercore 与列式存储,PostgreSQL 中压缩率高达 98%
来源:https://roszigit.com/en/blog/timescaledb-compression-hypercore
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) |
上表展示了差异的规模。对于典型的 IoT 工作负载,包含浮点数和时间戳——也就是 TOAST 完全不压缩的列——TimescaleDB 能达到 10–100× 的压缩比,因为它正是为此类数据而构建的。
## Hypercore 引擎与列式压缩
在 TimescaleDB 中,压缩由一个名为 **hypercore** 的引擎处理——这是一个混合行-列式引擎,新数据落在基于 PostgreSQL 行的块中(快速 INSERT 和 UPDATE),而较旧的块自动转换为列式压缩格式。读取这些压缩数据的分析查询会读取更少的字节,运行得更快。这种转换可实现高达 98% 的压缩率,从而显著降低数据保留期长的项目的存储成本。与传统的按行顺序存储数据的行式存储不同,列式存储按列组织并压缩数据。因此,查询可以批量获取所需字段,而无需扫描整行。
### 行发生了什么
转换数据块会将行分组为最多 1000 行的批次,每个批次成为**压缩表中的一个单行**,其中列是数组。
每个压缩批次:
- 将列数据封装在每列最多 1000 个值的压缩数组中,作为压缩表中的一个条目存储。
- 在批次内部使用列优先格式,通过将同一列的值集中在一起,实现高效扫描,并且允许选择单个列而无需读取整个批次。
- 应用高级列级压缩技术——游程编码、增量编码、Gorilla 压缩——减少存储并改善 I/O。
来源:https://www.tigerdata.com/docs/learn/deep-dive/whitepaper#data-model
使用增量编码的压缩示例:
| time | machine_id | sensor_type | value |
|------|------------|-------------|-------|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| 12:00:00 | MACHINE_001 | speed | 2.0 |
| 12:00:05 | MACHINE_001 | temp | 72.7 |
| 12:00:05 | MACHINE_001 | speed | 2.1 |
| 12:00:10 | MACHINE_001 | temp | 72.4 |
| 12:00:10 | MACHINE_001 | speed | 2.4 |
使用增量编码,只需存储每个数据点相对于前一个数据点的变化量,这意味着存储的值更小。在第一行之后,可以用更少的信息表示后续行,例如:
| time | machine_id | sensor_type | value |
|------|------------|-------------|-------|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| 0 秒 | MACHINE_001 | speed | 2.0 |
| 5 秒 | MACHINE_001 | temp | +0.2 |
| 0 秒 | MACHINE_001 | speed | +0.1 |
| 5 秒 | MACHINE_001 | temp | -0.3 |
| 0 秒 | MACHINE_001 | speed | +0.3 |
在时序数据中,某些值往往会在一段时间内重复。例如,如果你有一个温度传感器,在 10 分钟内读数为 72.5 度,然后突然升至 73.0 度并保持另一个 10 分钟,你可以使用二阶增量编码。如果间隔恒定(例如始终为 5 秒),则二阶增量为 0,可以用非常少的位数存储。
| time | machine_id | sensor_type | value |
|------|------------|-------------|-------|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| +5 秒 | MACHINE_001 | temp | +0.2 |
| 0 秒 | MACHINE_001 | temp | -0.3 |
| 0 秒 | MACHINE_001 | temp | +0.3 |
| 0 秒 | MACHINE_001 | temp | -0.1 |
增量编码对于变化幅度小的数值效果很好,但时序数据也经常包含在连续多行中重复相同值的列——例如 `machine_id`、`sensor_type` 或设备状态。在这种情况下,使用游程编码(RLE),它不是重复存储相同的值,而是将值存储一次并附带重复次数。
压缩前的数据:
| time | machine_id | sensor_type | value |
|------|------------|-------------|-------|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| 12:00:05 | MACHINE_001 | temp | 72.7 |
| 12:00:10 | MACHINE_001 | temp | 72.4 |
| 12:00:15 | MACHINE_001 | temp | 72.6 |
| 12:00:20 | MACHINE_001 | temp | 72.5 |
对 `machine_id` 和 `sensor_type` 列应用 RLE 后:
| machine_id | sensor_type |
|------------|-------------|
| MACHINE_001 × 5 | temp × 5 |
不再存储五个字符串 `MACHINE_001` 的副本(约 55 字节),而是存储一个值加上一个计数器(约 15 字节)。对于共享相同 `machine_id` 值的数百万行,节省的空间是巨大的。
最终将如下所示:
| 列 | 技术 | 压缩后的表示 |
|----|------|--------------|
| `time` | 二阶增量 | `12:00:00`、`+5s`、`0`、`0`、`0` |
| `machine_id` | 游程编码 | `MACHINE_001 × 5` |
| `sensor_type` | 游程编码 | `temp × 5` |
| `value` | 增量编码 | `72.5`、`+0.2`、`-0.3`、`+0.2`、`-0.1` |
TimescaleDB 中还有许多其他方法,你可以在[官方文档](https://www.tigerdata.com/docs/learn/columnar-storage/compression-methods)中阅读。
压缩并非“一刀切”——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`)。按时间排序能够最大化增量编码和二阶增量的优势——相邻值彼此接近,因此差异很小,可以打包到几位中。
``sql
ALTER TABLE iot_sensor_data SET (
timescaledb.orderby = 'time DESC',
timescaledb.segmentby = 'machine_id'
);
``
对于在此配置的表上执行的查询,如果带有 `WHERE machine_id = '...' AND time BETWEEN ...` 过滤条件,其速度可能比没有 `segmentby` 时快一个数量级,因为规划器基于元数据跳过其他机器的批次——而无需触及数据本身。
TimescaleDB 将行打包成约 1000 行的批次,并分别压缩每个批次。如果 `segmentby` 的基数过高(例如在 IoT 中,有成千上万个传感器,`segmentby = sensor_id`,每个传感器在每个数据块中只有几行),那么数据块中的每个“段”只有太少的行,批次填不满,压缩效果不佳——增量/XOR 编码器需要一系列相似的值才能进行压缩。
文档中的官方规则:**每个段在一个数据块中至少应包含 100 行**,最佳情况下每个数据块包含 100–10,000 个唯一的 segmentby 值。
## 压缩对查询性能有何影响?
一个常见问题:压缩是否会拖慢查询?
**简短回答:**对于典型的时序查询——它会加速。
**加速**的查询(大多数工作负载):
- 带聚合的时间范围扫描(`SUM`、`AVG`、`MAX` 按时间桶)
- 对 `segmentby` 列有过滤条件的查询
- 大范围顺序扫描
列式压缩将 I/O 减少 10–20 倍。读取 1 GB 未压缩数据与 100 MB 压缩数据对比 = 更少的磁盘读取、更少的内存、更少的反序列化 CPU。
**拖慢**的查询(时序中罕见):
- 单行点查询(`WHERE time = '...' AND id = X`)
- 对已压缩数据块的 UPDATE/DELETE(解压→修改→重新压缩循环)
- 当 `segmentby` 列具有高基数时,没有对该列的过滤条件的查询
### 如何实现
``sql
-- 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` 值,每个值根据数据块不同有 4,000 到 113,000 行。配置如下:
``sql
ALTER TABLE mqtt_data SET (
timescaledb.enable_columnstore = true,
timescaledb.segmentby = 'id',
timescaledb.orderby = 'time DESC'
);
``
### 效果——在行存储与列存储数据块上执行相同查询
生产类型查询,“按 `id` 和窄时间范围进行点查询”:
``sql
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-tree 索引在列存储中消失,因此实际节省更大),同时**执行速度快了 28 倍**。这不是错误——这是三个因素共同作用的结果。
## 查询计划——用 `EXPLAIN ANALYZE` 展示
### 列存储数据块(压缩后)
``sql
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。
### 行存储数据块(压缩前)
``sql
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-tree 约 750 MB)。它能工作,但更慢,因为:
- 索引无法完全缓存
- 规划器需要读取更大的统计信息
- PostgreSQL 逐行迭代
## 列存储为什么在速度上也胜出?
### 1. 元列上的稀疏最小最大索引
TimescaleDB 自己在 `(segmentby_col, _ts_meta_min_1, _ts_meta_max_1)` 上构建索引——其中 min/max 是每个 1000 行批次中 `orderby` 值的极值。这使其能够在不读取数据的情况下消除整个批次,只需检查元数据。
### 2. Segmentby 作为原生过滤器
具有相同 `id` 的行在物理上被分组在一起。索引立即命中正确的段——无需单独的 `(id, time)` B-tree。Segmentby 作为数据布局的副产品“免费”处理了这个需求。
### 3. 向量化执行
对 `time` 范围的操作以批处理方式(一次 1000 行)而不是逐行方式进行,就像经典的索引扫描那样。
## 重要注意事项
1. **这些数字适用于特定用例**——“按 `id` 和窄时间范围进行点查询”。对于聚合整月数据的查询,差异可能不同。
相似文章
Gorilla:一种快速、可扩展的内存时间序列数据库(2016)
本文介绍了Gorilla,一个由Facebook开发的内存时间序列数据库,通过一种新颖的压缩算法实现了高性能,能够存储数十亿时间序列并支持生产监控的快速查询。
pg_deltax: Apache许可的PostgreSQL时序扩展
DeltaX 是一个基于Apache许可的PostgreSQL扩展,为时序数据提供压缩和列式存储,是TimescaleDB或ClickHouse的快速替代方案,同时将数据保留在PostgreSQL中。
@_avichawla: Cloudflare如何在不离开Postgres的情况下将查询时间缩短35倍:他们的Postgres表达到数十亿行,而每次时间范围查询开始变慢。
Cloudflare使用TimescaleDB(Tiger Cloud)在数十亿行的Postgres表上将查询性能提升了35倍,并借助Claude Code和Tiger CLI构建了一个实时地震仪表板。
Postgres 19 压缩:从 pglz 到 LZ4
PostgreSQL 19 计划将默认的 TOAST 压缩算法从 pglz 改为 LZ4,提供更好的性能和效率。文章介绍了 Postgres 压缩的历史以及切换的原因。
Toto 2.0:时间序列预测进入规模化时代(13分钟阅读)
DataDog 发布了 Toto 2.0,这是一系列参数量从 4M 到 2.5B 的开源时间序列基础模型,展现出持续的规模化改进,并在包括 BOOM、GIFT-Eval 和 TIME 等多个基准测试中取得了领先成果。