
一、超表压缩直接在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-566 的compress_chunk_impl():创建独立的压缩chunk—create_compress_chunk()在压缩 hypertable 内创建一个新表将原始数据压缩写入压缩chunk—compress_chunk(in_table, out_table)读取原始chunk排序后压缩写入压缩chunk清空原始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_idtime(orderby)存为CompressedDataHeader变长类型如 DELTADELTA 压缩元数据列_ts_meta_min_1,_ts_meta_max_1value(普通列)存为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) — 清空原始chunk2.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-1115RowCompressor 结构: 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/timestamptzDELTADELTAdelta-of-delta zigzag simple8b_rlefloat4/float8GORILLAXOR-based (Facebook Gorilla)numericARRAY无特殊压缩依赖TOASTboolBOOLbitmap simple8b_rletext及其他有hasheq的类型DICTIONARY字典编码其他类型ARRAYfallback2.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 列—条件可完全下推到压缩chunkIS NULL/IS NOT NULL也可下推orderby 列—,,,条件可转换为对 min/max 元数据列的检查volatile 函数(如random()) — 不能下推必须在解压后评估3.3 Batch 解压缩 — compression.c:1399-1545decompress_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-172decompress_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.cUPDATE/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_id5 AND time2024-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 recompressionrecompress.c的原因只重压有变更的 segment而不是整个 chunk。