一、超表压缩:直接在chunk上压,还是单独存储?

答案是:压缩数据存储在单独的"压缩chunk"表中。 这是一个关键设计:

原始超表 (hypertable)
    └── chunk_1 (未压缩数据, 如 _timescaledb_internal._hyper_1_1_chunk)
    │       ↓ compress_chunk()
    │   chunk_1 的行被清空 (truncate 或 delete),变成空表
    │
    └── 对应的压缩chunk (压缩数据, 如 _timescaledb_internal._hyper_2_2_chunk)
            └── 每个压缩行 = 一个 batch (最多1000行原始数据)

核心代码在 api.c:387-566compress_chunk_impl():

  1. 创建独立的压缩chunk — create_compress_chunk() 在压缩 hypertable 内创建一个新表
  2. 将原始数据压缩写入压缩chunk — compress_chunk(in_table, out_table) 读取原始chunk,排序后压缩写入压缩chunk
  3. 清空原始chunk — truncate_relation(in_table) 或 delete_relation_rows(in_table) 清空原始chunk的数据(保留表结构)

压缩chunk表结构与原始chunk完全不同:create.h:14-17

压缩chunk的列结构

以这样一个配置为例:segmentby(device_id)orderby(time),还有一列 value

原始表的列 在压缩chunk中的存储方式
device_id (segmentby) 直接存原值 — 每个batch一行,该batch内所有行共享同一个 device_id
time (orderby) 存为 CompressedDataHeader 变长类型(如 DELTADELTA 压缩)+ 元数据列 _ts_meta_min_1_ts_meta_max_1
value (普通列) 存为 CompressedDataHeader 变长类型(如 GORILLA 压缩)
(额外的) _ts_meta_count — 此batch包含多少原始行
(额外的) _ts_meta_sequence_num — 序列号(保证orderby顺序)

二、压缩代码流程分析

2.1 整体调用链

SQL: compress_chunk(chunk_name)
  ↓
tsl_compress_chunk                      [api.c:903]
  ↓
tsl_compress_chunk_wrapper              [api.c:927]
  ↓ (chunk未压缩)
compress_chunk_impl                     [api.c:387]
  ├── create_compress_chunk()           — 创建压缩chunk表
  ├── compress_chunk(in, out)           — 核心压缩 [compression.c:254]
  └── truncate_relation(in)             — 清空原始chunk

2.2 核心压缩函数 compress_chunk() — compression.c:254-531

步骤1: 尝试找到匹配的索引 (避免全排序)
  ├── 遍历原始chunk的所有索引
  ├── 检查索引列是否匹配 segmentby列 + orderby列
  ├── 检查排序方向(ASC/DESC)和NULL顺序是否一致
  └── 如果找到匹配索引 → 直接使用 IndexScan (按索引序读取)

步骤2: 如果没有匹配索引 → Tuplesort 全排序
  ├── 按 segmentby列(升序) + orderby列(指定方向) 排序
  └── 确保所有行按 group 组织好后传递给 RowCompressor

步骤3: RowCompressor 打包压缩
  ├── row_compressor_init()              — 初始化压缩器,映射列关系
  ├── row_compressor_append_sorted_rows()— 逐行消费排好序的行
  │   └── row_compressor_process_ordered_slot()
  │       ├── 检测 segmentby 值是否变化 (变了=新batch)
  │       ├── 检测当前batch是否满了 (1000行)
  │       ├── 如果换group或满了 → row_compressor_flush()
  │       │   ├── 对每个压缩列调用 compressor->finish()
  │       │   │   生成 CompressedDataHeader (算法标记+压缩数据)
  │       │   ├── 写入元数据: _ts_meta_count, _ts_meta_min_N, _ts_meta_max_N
  │       │   ├── heap_insert() 插入到压缩chunk表
  │       │   └── 更新索引
  │       └── row_compressor_append_row()
  │           ├── 对每个压缩列调用 compressor->append_val()
  │           └── 更新 min/max 元数据
  └── 最后一个batch → flush

步骤4: 清空原始chunk
  ├── truncate_relation() — 默认方式,重建relfilenode
  └── 或 delete_relation_rows() — 逐行删除

2.3 RowCompressor 的具体工作方式 — compression.c:799-1115

RowCompressor 结构:
  per_column[]:  每列的压缩器或segment信息
    ├── segmentby列 → PerColumn.segment_info (存当前group的值,用于比较)
    └── 普通列     → PerColumn.compressor (具体算法压缩器)
                      └── PerColumn.metadata_builder (min/max跟踪器,仅orderby列)

  compressed_values[]:   即将写入压缩chunk的一行数据
  rows_compressed_into_current_value: 当前batch已累积行数

压缩一行时 (append_row):
  for each column:
    如果是压缩列:
      if NULL → compressor->append_null()
      else    → compressor->append_val(val)
      如果 orderby 列 → metadata_builder->update_val/min(val)
  rows_compressed_into_current_value++

刷新batch时 (flush):
  for each 压缩列:
    compressed_data = compressor->finish()  ← 生成压缩后的Datum
  → 写入 _ts_meta_count = rows_compressed_into_current_value
  → 写入 min/max 元数据
  → heap_insert() 将此压缩行插入压缩chunk表

2.4 压缩算法选择 — compression.c:1916-1952

数据类型 默认算法 说明
int2/int4/int8/date/timestamp/timestamptz DELTADELTA delta-of-delta + zigzag + simple8b_rle
float4/float8 GORILLA XOR-based (Facebook Gorilla)
numeric ARRAY 无特殊压缩,依赖TOAST
bool BOOL bitmap + simple8b_rle
text及其他有hash+eq的类型 DICTIONARY 字典编码
其他类型 ARRAY fallback

2.5 压缩数据格式 — compression.h:32-41

// 每个压缩列的数据结构:
typedef struct CompressedDataHeader {
    char    vl_len_[4];            // PostgreSQL变长类型头部 (4字节)
    uint8   compression_algorithm; // 算法标记 (1字节)
    // 后面跟着算法特定的压缩数据...
} CompressedDataHeader;
  • 总大小由 PostgreSQL 的变长类型机制管理
  • 压缩后的数据可能被 TOAST (超长字段外存) 进一步压缩或外存
  • 如果整列全是NULL → 使用 COMPRESSION_ALGORITHM_NULL (标记为NULL块)

三、读取时的解压缩流程

TimescaleDB 在 SELECT 查询时采用 透明解压缩:用户查询原始超表,优化器自动将查询重定向到压缩chunk并在执行时实时解压。

3.1 整体架构

查询: SELECT * FROM hypertable WHERE device_id = 5 AND time > '2024-01-01';

优化器 (planner.c):
  原始chunk的RelOptInfo → 检测是压缩chunk
    ↓
  创建 DecompressChunkPath (CustomPath)
  ├── 将 WHERE 条件分离为两部分:
  │   ├── 可下推的条件 → 应用到压缩chunk的扫描
  │   │   ├── segmentby列条件 → 直接过滤压缩行
  │   │   └── orderby列的min/max条件 → 用元数据列过滤batch
  │   └── 不可下推的条件 → 在解压后检查
  ↓
执行器 (exec.c):
  DecompressChunkState
  ├── 子计划: 扫描压缩chunk (使用下推的过滤条件)
  │   → 只返回符合条件的压缩行(batch)
  ├── 对每个返回的压缩行:
  │   └── decompress_batch() → 解压出一个batch内所有行
  └── 对解压出的每行检查剩余条件

3.2 谓词下推 (Predicate Pushdown) — qual_pushdown.c

这是压缩读取最关键的性能优化:

原始 WHERE: device_id = 5 AND time > '2024-01-01'

转换后:
  压缩chunk扫描条件:
    device_id = 5                          ← segmentby列直接过滤
    AND _ts_meta_max_1 > '2024-01-01'     ← orderby列 max元数据检查
                                            (如果batch的max值都 <= 条件值,
                                             整个batch可以跳过!)
  解压后检查条件:
    time > '2024-01-01'                   ← 对batch内每行精确检查

结果: 大量不满足条件的batch被提前过滤,避免了解压开销

下推规则:

  • segmentby 列 — = 条件可完全下推到压缩chunk;IS NULL / IS NOT NULL 也可下推
  • orderby 列 — >>=<<= 条件可转换为对 min/max 元数据列的检查
  • volatile 函数 (如 random()) — 不能下推,必须在解压后评估

3.3 Batch 解压缩 — compression.c:1399-1545

decompress_batch(RowDecompressor *decompressor)
{
    // 1. 读取 count 元数据 → n_batch_rows (本batch包含多少原始行)
    n_batch_rows = DatumGetInt32(
        compressed_datums[count_compressed_attindex]);

    // 2. 对每个压缩列创建迭代器
    for each column:
        if segmentby列 → 直接复制值 (整个batch共享)
        if 压缩列:
            CompressedDataHeader *header = get_compressed_data_header(datum);
            iterator = definitions[header->algorithm].iterator_init_forward(datum);
        if 压缩列为NULL → 使用默认值 (或标记为NULL)

    // 3. 逐行解压
    for row in 0..n_batch_rows:
        for each 压缩列:
            DecompressResult val = iterator->try_next(iterator);
            写出到 decompressed_slots[row]
        → heap_form_tuple() 形成解压后的HeapTuple

    // 4. 验证所有列都已消费完毕 (防止数据损坏)
    for each 压缩列:
        assert iterator->try_next() == is_done
}

3.4 SELECT 查询的两种 Batch 输出模式 — exec.c:155-172

decompress_chunk_exec_fifo (默认—无序)
  → 压缩chunk扫描返回什么顺序就按什么顺序输出解压行
  → 适用场景: 无 ORDER BY,或 ORDER BY 可由上层节点处理

decompress_chunk_exec_heap (batch sorted merge)
  → 使用二叉堆对多个压缩batch进行归并排序
  → 适用场景: ORDER BY 与压缩的 orderby 列兼容时
  → 利用压缩batch已在内部排序的特性,进行高效的批量归并

3.5 DML 时的解压缩 (UPDATE/DELETE) — compression_dml.c

UPDATE/DELETE 走不同的路径:

decompress_target_segments() → 遍历计划树找到压缩chunk的扫描节点
  ↓
decompress_batches_for_update_delete()
  ├── 构建三层过滤:
  │   1. Index Scan Keys — segmentby列条件,用压缩chunk索引过滤
  │   2. Heap Scan Keys  — orderby列的min/max元数据过滤
  │   3. Memory Scan Keys — 解压batch后逐行精确检查
  ├── 解压匹配的batch → 插入回原始chunk
  └── 从压缩chunk删除已解压的batch

总结

┌─────────────────────────────────────────────────────────────────┐
│                      压缩存储架构                                 │
├─────────────────────────────────────────────────────────────────┤
│  原始Chunk                       压缩Chunk (独立表!)              │
│  ┌──────────────┐               ┌──────────────────────────┐    │
│  │ device|time|val│              │device|_ts_meta_min_1|    │    │
│  │  (空表)       │              │       _ts_meta_max_1|    │    │
│  │               │              │   time(压缩)|val(压缩)|   │    │
│  │  压缩后truncate│              │  _ts_meta_count|         │    │
│  └──────────────┘               │  _ts_meta_seq_num        │    │
│                                  └──────────────────────────┘    │
│                                  每个HeapTuple = 一个batch(≤1000行)│
│                                  压缩列 = CompressedDataHeader    │
│                                    [4B len][1B algo][压缩数据]    │
├─────────────────────────────────────────────────────────────────┤
│  SELECT读取: 透明解压 (plan node DecompressChunk)                 │
│    1. 谓词下推到压缩chunk (segmentby列 + min/max元数据)           │
│    2. 扫描压缩chunk → 逐batch解压 → 检查剩余条件 → 返回行          │
│                                                                  │
│  UPDATE/DELETE: decompress-target-modify 模式                    │
│    1. 找到匹配的batch → 解压 → 写回原始chunk                      │
│    2. 从压缩chunk删除已解压的batch                                │
└────────────────────────────────────────────────

SELECT 查询效率很高,INSERT 凑合,UPDATE/DELETE 有明显的性能代价。 设计上就是「写一次、读多次」的优化方向。

SELECT 性能:相当好

关键在于 压缩粒度 = batch(≤1000行),以及三层过滤机制:

查询: SELECT * FROM sensor WHERE device_id = 5 AND time > '2024-06-01'

┌──────────────────────────────────────────────────────────────┐
│ 第1层: Index过滤 (压缩chunk的BTREE索引)                       │
│   条件: device_id = 5                                        │
│   效果: 直接定位到特定segmentby值的压缩行                      │
│   跳过: 所有其他 device_id 的batch → 不解压                   │
├──────────────────────────────────────────────────────────────┤
│ 第2层: Heap/MinMax过滤 (元数据列)                             │
│   条件: _ts_meta_max_1 > '2024-06-01'                        │
│   效果: 在内存中检查batch的min/max值                          │
│   跳过: batch内max值都小于条件的 → 不解压                      │
├──────────────────────────────────────────────────────────────┤
│ 第3层: Memory过滤 (解压后逐行检查)                            │
│   条件: time > '2024-06-01'                                  │
│   效果: 只对通过前两层的batch解压,逐行精确检查                 │
│   这才是最终精确过滤                                          │
└──────────────────────────────────────────────────────────────┘

实际的性能放大效应:

假设一个chunk有1000万行,压缩后约1万个batch(每batch 1000行):

  查询命中率 10%(按segmentby精确命中):
    → 第1层过滤: 1万batch → 只扫描1000个batch
    → 第2层过滤: 1000个batch → 去掉50%不合时间范围的 → 500个batch
    → 第3层: 解压500个batch = 50万行 → 精确过滤 → 10万行结果
    
  实际解压行数 / 原始总行数 = 50万 / 1000万 = 5%
  只解压了5%的数据!其余都在batch级别被过滤掉了。

代码中 table_multi_insert 在解压时批量写入(compression.c:1555),一次插入一个batch的所有行,避免了逐行插入的开销。

INSERT 性能:有条件地差

INSERT 只在一种情况触发解压:压缩chunk上有唯一约束compression_dml.c:75-81)。

没有唯一约束:
  INSERT → 直接写入原始chunk(压缩chunk不动)
  → 性能:和普通PostgreSQL INSERT一样 ✅

有唯一约束:
  INSERT → 检查唯一约束是否与压缩数据冲突
    → 构建index scan key → 查找可能冲突的batch
    → 解压相关batch → 逐行检查冲突
    → 无冲突 → 写入未压缩部分
  → 性能:取决于需要解压多少batch ⚠️

UPDATE/DELETE 性能:最大的弱点

这是代价最大的操作。看 compression_dml.c:384-393 的注释:

/*
 * This method will:
 *  1. Scan the index created with SEGMENT BY columns or the entire compressed chunk
 *  2. Fetch matching rows and decompress the row
 *  3. Delete this row from compressed chunk
 *  4. Insert decompressed rows to uncompressed chunk
 */

UPDATE/DELETE 执行的是「全batch解压 → 部分行删除/修改 → 重新插入」:

原始状态:
  压缩chunk: [batch_1: 1000行] [batch_2: 1000行] ... [batch_N: 1000行]

DELETE 1行 where device_id=5 AND time='2024-06-15':

  1. 定位到包含这行的batch(假设 batch_42)
  2. 解压 batch_42 全部1000行           ← 成本:解压1000行
  3. 找到那1行,标记删除
  4. 把剩余999行插入回原始chunk           ← 成本:插入999行
  5. 从压缩chunk删除 batch_42            ← batch被打散
  6. 标记该chunk为"partial"(部分压缩)

  → 删1行 = 解压1000行 + 插入999行 = 1999行I/O

❌ 最差情况: 每行都分布在不同的batch里
  → 删1000行,每行在不同batch = 解压100万行 + 插入99.9万行

这也是为什么 enable_dml_decompression 这个 GUC 默认对 UPDATE/DELETE 是关闭的!

有一个优化例外 — can_delete_without_decompressioncompression_dml.c:242):如果 WHERE 条件覆盖了一个batch中所有 segmentby 列的值,可以直接删整个压缩行(batch),完全不解压。

性能总结

┌───────────────────────────────────────────────────────────────┐
│  场景                       性能          原因                 │
├───────────────────────────────────────────────────────────────┤
│  SELECT (有segmentby+时间过滤)  ⭐⭐⭐⭐⭐   batch级过滤跳过大量数据 │
│  SELECT (无过滤,全表扫)       ⭐⭐⭐     仍需解压,但有批量加速  │
│  INSERT (无唯一约束)           ⭐⭐⭐⭐⭐  直接写未压缩部分        │
│  INSERT (有唯一约束)           ⭐⭐⭐     需解压部分batch检查冲突  │
│  UPDATE/DELETE (少量行)        ⭐⭐       触发batch拆散重写       │
│  UPDATE/DELETE (大量行)        ⭐        巨大的写放大            │
│  直接删整个batch (segmentby全匹配) ⭐⭐⭐⭐ 不解压,直接删压缩行   │
└───────────────────────────────────────────────────────────────┘

设计哲学

TimescaleDB 的压缩就是为追加写 + 范围查询优化的,和 ClickHouse 类似:

  • 适合: 「传感器每分钟上报一次,按月压缩,只做追加写入和范围查询」
  • 不适合: 「频繁 UPDATE/DELETE,需要实时修改历史数据」

如果要频繁修改已压缩数据,应该先 decompress_chunk() 解压回来,改完再 compress_chunk() 压回去 — 这比逐行触发 DML 解压高效得多。这也是他们提供了 segmentwise recompression(recompress.c)的原因:只重压有变更的 segment,而不是整个 chunk。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐