列存引擎的编码压缩算法选择:Delta、RLE、Dictionary与BitPacking,到底谁的压缩比最高?
2026/7/17 16:53:42 网站建设 项目流程

列存引擎的编码压缩算法选择:Delta、RLE、Dictionary与BitPacking,到底谁的压缩比最高?

一、列存的秘密武器,选错编码等于白费存储

列式存储相比行式存储的最大优势之一就是压缩比。同行数据因为来自不同的列,数据类型各异、值域分散,压缩算法很难找到重复模式。而同一列的数据类型一致、值域集中,天然适合各种轻量级编码。但这并不意味着随便套一个压缩算法就能拿到理想的压缩比。在生产环境里经常看到这样的事故:运维在ClickHouse表上用了默认的LZ4压缩,结果时间戳列从Delta编码下的3%压缩到LZ4下的15%,存储膨胀了5倍;反过来,在高基数列(如UUID)上强行使用Dictionary编码,字典本身的大小就接近甚至超过了原始数据。

压缩算法的选择本质上是一个"读懂数据特征"的过程。同一张表的两个列,一个适合Delta,另一个可能最适合RLE,选错算法不仅浪费存储,而且在查询时需要额外的解压开销——压缩比的提升往往以解压CPU为代价。本文从四种核心轻量级编码出发,剖析各自的最佳场景和性能特征。

二、四种核心编码的算法本质与适用场景

Delta编码存储相邻值的差值而非原值。假设一列时间戳为[1700000000, 1700000001, 1700000002, 1700000100],Delta后变为[1700000000, 1, 1, 98]。差值通常远小于原值,能用更少的bit表示。Delta编码对单调递增或变化缓慢的数值列极为有效——时间戳、自增ID、日志序号是天然场景。Delta的第二个优势是可以叠加二次Delta甚至Gorilla-like XOR Delta(针对浮点数),进一步压缩。

RLE编码将连续相同值替换为(值,重复次数)对。列[A, A, A, B, B, A, A]变为[(A,3), (B,2), (A,2)]。当列经过排序或者数据本身就存在大量连续重复时,RLE压缩比惊人。在Parquet格式中,RLE还和BitPacking结合使用——先用RLE压缩重复部分,再用BitPacking压缩非重复部分的位宽。

Dictionary编码构建一个值到整数ID的映射字典,用紧凑的整数ID替代原始值。对于低基数列(状态码、省份、设备类型),字典大小远小于数据量,是压缩效率最高的方式。但字典需要随数据一起存储,查询时需要通过字典反查——对全表扫描友好,对点查不友好。

BitPacking分析列中所有值的范围,用刚好够的最小比特数存储每个值。如果一列INT32实际只用到0-1023,BitPacking只需10bit/值,压缩比约3.2倍。BitPacking通常与前三种编码级联使用,对已经Delta/Dictionary化后的数据进行二次压缩。

三、编码选择器的实现:基于列统计特征的决策树

import numpy as np from dataclasses import dataclass from typing import List, Optional, Tuple from enum import Enum import logging logger = logging.getLogger(__name__) class EncodingType(Enum): DELTA = "delta" RLE = "rle" DICTIONARY = "dictionary" BITPACKING = "bitpacking" PLAIN = "plain" CASCADE = "cascade" # 级联编码 @dataclass class ColumnStats: """轻量级列统计信息,用于编码选择决策""" name: str dtype: str distinct_count: int total_count: int null_count: int is_sorted: bool max_run_length: int # 最大连续重复长度 min_val: Optional[float] = None max_val: Optional[float] = None @property def cardinality_ratio(self) -> float: return self.distinct_count / max(self.total_count, 1) @property def null_ratio(self) -> float: return self.null_count / max(self.total_count, 1) @property def avg_run_length(self) -> float: return self.total_count / max(self.distinct_count, 1) class EncodingSelector: """基于列统计信息的编码选择器""" # 各编码的阈值参数 DICT_MAX_CARDINALITY = 0.01 # Dictionary适用:基数<1% DICT_ABS_MAX_DISTINCT = 100000 # Dictionary绝对上限:10万不同值 RLE_MIN_RUN_RATIO = 0.3 # RLE适用:平均游程长度覆盖30% DELTA_SORTED_RATIO = 0.8 # Delta适用:80%以上有序 BITPACKING_MAX_BITWIDTH = 24 # BitPacking上限24bit def select_encoding(self, stats: ColumnStats) -> List[EncodingType]: """返回推荐的编码链(可能级联多个编码)""" encodings = [] # Step 1: 空值处理 if stats.null_ratio > 0.5: logger.warning(f"Column {stats.name}: >50% nulls, using PLAIN") return [EncodingType.PLAIN] # Step 2: Dictionary编码检查 if (stats.cardinality_ratio < self.DICT_MAX_CARDINALITY and stats.distinct_count < self.DICT_ABS_MAX_DISTINCT): encodings.append(EncodingType.DICTIONARY) logger.info( f"Column {stats.name}: Dictionary ({stats.distinct_count} " f"distinct / {stats.total_count} total)" ) # Dictionary后可叠加BitPacking encodings.append(EncodingType.BITPACKING) return encodings # Step 3: RLE编码检查 if stats.avg_run_length > self.RLE_MIN_RUN_RATIO * stats.total_count: encodings.append(EncodingType.RLE) logger.info( f"Column {stats.name}: RLE " f"(max_run={stats.max_run_length}, " f"avg_run={stats.avg_run_length:.1f})" ) encodings.append(EncodingType.BITPACKING) return encodings # Step 4: Delta编码检查(仅数值类型) if stats.dtype in ('int', 'bigint', 'float', 'double', 'timestamp', 'date'): if stats.is_sorted and stats.cardinality_ratio > self.DELTA_SORTED_RATIO: encodings.append(EncodingType.DELTA) logger.info(f"Column {stats.name}: Delta (sorted, {stats.distinct_count} distinct)") encodings.append(EncodingType.BITPACKING) return encodings # Step 5: BitPacking单独使用 if stats.min_val is not None and stats.max_val is not None: value_range = abs(stats.max_val - stats.min_val) if value_range > 0: bits_needed = int(np.ceil(np.log2(value_range + 1))) if bits_needed < self.BITPACKING_MAX_BITWIDTH: encodings.append(EncodingType.BITPACKING) logger.info( f"Column {stats.name}: BitPacking {bits_needed}bit " f"(range=[{stats.min_val}, {stats.max_val}])" ) return encodings # 兜底:Plain或通用压缩 logger.info(f"Column {stats.name}: PLAIN (no suitable encoding)") return [EncodingType.PLAIN] def estimate_compression_ratio( self, stats: ColumnStats, encodings: List[EncodingType] ) -> float: """粗略估计压缩比""" if EncodingType.PLAIN in encodings: return 1.0 ratio = 1.0 for enc in encodings: if enc == EncodingType.DICTIONARY: bits_per_value = max(1, int(np.ceil(np.log2(stats.distinct_count + 1)))) ratio *= 32.0 / bits_per_value # 假设原始32bit elif enc == EncodingType.RLE: ratio *= max(1.0, stats.avg_run_length * 0.5) elif enc == EncodingType.DELTA: ratio *= 4.0 # 经验值 elif enc == EncodingType.BITPACKING: if stats.min_val is not None and stats.max_val is not None: bits_needed = int(np.ceil(np.log2(abs(stats.max_val - stats.min_val) + 1))) ratio *= 32.0 / max(bits_needed, 1) return ratio

生产环境的编码选择器必须轻量。上述实现对每列只需统计基数、排序性、值域和游程长度,计算复杂度O(n),在百GB级别的列上单次ANALYZE能在秒级完成。

四、编码级联的代价:解压延迟与查询性能的博弈

压缩比的提升不是免费午餐,编码级联的直接代价是解压开销的叠加。

解压延迟的尺度差异非常显著。BitPacking解压最快——本质就是bit位移和掩码操作,在SIMD加速下接近零开销。Delta解码需要一次扫描做前缀求和,O(n)但简单。RLE解码也在O(n)。Dictionary解码需要额外的字典查找,相当于每个值的读取多了一次间接寻址。当这些编码级联时,解压开销线性叠加。一个极端案例:对高基数列使用"Delta → RLE → BitPacking"三级级联,压缩比达到惊人的50:1,但单次全表扫描的解压时间从10ms暴涨到180ms。

谓词下推与解压的交互决定了查询性能。像Parquet和ClickHouse这类支持谓词下推的引擎,在扫描前先用列的统计信息(Min/Max、Bloom Filter)判断是否需要读取该列块。如果统计信息显示谓词不匹配,该列块的压缩数据直接跳过,解压根本不会发生——这时编码级联的代价为零。因此,对于高频过滤列,统计信息的质量比压缩比更重要。

压缩比不存在绝对最优。以下是在生产环境测过的一组典型数据:

  • 服务器状态码列(5个不同值,99%为"200"):Dictionary + BitPacking,压缩比95:1,解压开销<1ms
  • 用户ID列(高基数,有序):Delta + BitPacking,压缩比6:1,解压开销3ms
  • UUID列(高基数,随机):任何编码都无法有效压缩,直接通用压缩(ZSTD),压缩比3:1
  • 事件时间戳(微秒级,有序):Delta + BitPacking,压缩比40:1,解压开销2ms

根本原则是"先懂数据,再选编码"——在没有统计信息的情况下,ZSTD(1)作为默认是一个不差的起步选择。

五、总结

列存编码的选择本质上是在压缩比和解压延迟之间做多目标优化。Dictionary是低基数列的王者,Delta是时序/有序列的最佳拍档,RLE在排序后的重复列上表现惊艳,BitPacking是其他编码的完美搭档。工程实践中建议从默认ZSTD起步,在采集到足够列统计信息后逐步切换到级联编码,以压缩比和查询延迟的加权得分为优化目标。一个经常被低估的要点是:压缩比的ROI与你是否做谓词下推强相关——如果你的查询引擎不支持统计信息过滤,那么追求极致压缩比反而会损害查询性能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询