专栏文章
专栏文章
存储算法系列
1. 存储算法 #01:LSM Tree 2. 存储算法 #02:Bloom Filter 3. 存储算法 #03:B+ Tree 4. 存储算法 #04:SSTable 5. 存储算法 #05:Copy-on-Write(COW) 6. 存储算法 #06:Roaring Bitmap 7. 存储算法 #07:WAL(Write-Ahead Log) 8. 存储算法 #08:Bitcask 9. 存储算法 #09:Redo 与 Undo Log 10. 存储算法 #10:Varint 与 ZigZag 编码 11. 存储算法 #11:Delta 编码 12. 存储算法 #12:LZ4 与 Snappy 与 Zstd

存储算法 #11:Delta 编码

发布于 2026-06-04 04:28 👁 15 次阅读

Delta 编码将有序递增序列转为差值序列存储,结合 Varint 可将时间戳等单调 ID 压缩 50%~80%;ClickHouse、Parquet、Gorilla 时序数据库均内置此方案。

相关文章Varint 与 ZigZag 编码 · LZ4 与 Snappy 与 Zstd

delta encoding

目录

章节 说明
问题背景 为何有序序列直接存储冗余高
基础 Delta 编码 encode/decode 原理与伪代码
Delta-of-Delta(二阶差分) 适合近似等差序列的进一步压缩
FOR(Frame of Reference) 基准值 + bit-packing 小差值
执行追踪 时间戳序列完整演示
工程实现 ClickHouse Delta codec、Parquet Delta encoding
异常与边界场景 非单调、方差过大、溢出等
参考资料 论文与官方文档

问题背景

有序递增序列(时间戳、单调递增 ID、日志序号)直接存储时,每个元素都是一个大数值,例如 Unix 时间戳约 1.6×10⁹,需要 4~8 字节。

关键观察:相邻元素的差值通常远小于原值。

收益公式(粗估):

原始大小 = n × 8 字节(64-bit 整数)
压缩后   = 8 字节(首值) + (n-1) × avg_delta_varint_bytes

avg_delta < 128 时,差值 Varint 仅需 1 字节,整体压缩率约 87.5%。


基础 Delta 编码

2.1 核心数据结构

DeltaEncoded {
    first_value  : int64   // 原始序列第一个元素,完整存储
    deltas[]     : int64[] // 后续元素与前一元素的差值,可为负数
}

2.2 编码伪代码

encode_delta(arr: int64[]) -> DeltaEncoded:
    n = arr.length
    if n == 0: return {}

    output.first_value = arr[0]
    output.deltas = new int64[n - 1]

    prev = arr[0]
    for i = 1 to n - 1:
        delta = arr[i] - prev      // 差值,可为负
        output.deltas[i - 1] = delta
        prev = arr[i]              // 滑动窗口:上一个原始值

    return output

2.3 解码伪代码

decode_delta(encoded: DeltaEncoded) -> int64[]:
    n = encoded.deltas.length + 1
    result = new int64[n]

    result[0] = encoded.first_value
    for i = 1 to n - 1:
        result[i] = result[i - 1] + encoded.deltas[i - 1]
        // 累加还原:当前值 = 前一值 + 差值

    return result

2.4 正确性保证

encode 后再 decode 必须还原原始序列:

∀ i ≥ 1: result[i] = result[i-1] + (arr[i] - arr[i-1])
        = arr[i-1] + arr[i] - arr[i-1]
        = arr[i]   ✓

Delta-of-Delta(二阶差分)

3.1 适用场景

当差值序列本身也是近似等差的(例如每秒采集一次,时间戳差值总是约 1000ms),可以对差值再求差,使大多数值趋近于 0。

Gorilla 时序数据库(Facebook,2015)的 timestamp 压缩就采用此方案。

3.2 数据结构

DeltaOfDeltaEncoded {
    first_value  : int64   // 原始序列首值
    first_delta  : int64   // 第一个差值(d1 = arr[1] - arr[0])
    dod[]        : int64[] // 差值的差值:dod[i] = delta[i] - delta[i-1]
}

3.3 编码伪代码

encode_dod(arr: int64[]) -> DeltaOfDeltaEncoded:
    n = arr.length
    if n <= 1: return {first_value=arr[0]}

    out.first_value = arr[0]
    out.first_delta = arr[1] - arr[0]        // 第一个差值完整存储
    out.dod = new int64[n - 2]

    prev_delta = out.first_delta
    for i = 2 to n - 1:
        curr_delta = arr[i] - arr[i - 1]     // 当前差值
        dod = curr_delta - prev_delta         // 差值的差值
        out.dod[i - 2] = dod
        prev_delta = curr_delta              // 滑动:更新上一差值

    return out

3.4 解码伪代码

decode_dod(encoded: DeltaOfDeltaEncoded) -> int64[]:
    n = encoded.dod.length + 2
    result = new int64[n]
    result[0] = encoded.first_value
    result[1] = result[0] + encoded.first_delta

    prev_delta = encoded.first_delta
    for i = 2 to n - 1:
        curr_delta = prev_delta + encoded.dod[i - 2]  // 还原差值
        result[i]  = result[i - 1] + curr_delta        // 还原原值
        prev_delta = curr_delta

    return result

3.5 Gorilla 的位级优化

Gorilla 对 DoD 值用变长位存储:

DoD 值范围 存储格式
= 0 1 bit: 0
[-63, 64] 2+7 bits: 10 + 7-bit signed
[-255, 256] 3+9 bits: 110 + 9-bit signed
[-2047, 2048] 4+12 bits: 1110 + 12-bit signed
其他 4+32 bits: 1111 + 32-bit

规则等差序列(DoD=0)每个时间戳仅需 1 bit


FOR(Frame of Reference)编码

4.1 思路

在一个数据块(block,如 128 个元素)内,选一个基准值 base(通常为最小值),存储:

base + [val[0]-base, val[1]-base, ..., val[n-1]-base]

所有差值均非负,且范围缩小,可用固定位宽 bit-packing 打包。

4.2 数据结构

FORBlock {
    base       : int64   // 块内最小值(基准)
    bit_width  : uint8   // 差值所需最小位宽,range = max_delta.bit_length()
    packed[]   : byte[]  // 所有差值按 bit_width 位宽连续打包
}

4.3 编码伪代码

encode_for(arr: int64[], block_size: int = 128) -> FORBlock[]:
    blocks = []
    for chunk in split(arr, block_size):
        base = min(chunk)
        deltas = [v - base for v in chunk]
        max_delta = max(deltas)
        bit_width = ceil(log2(max_delta + 1))   // 至少 1 bit

        packed = bit_pack(deltas, bit_width)     // 将每个差值压进 bit_width 位
        blocks.append({base, bit_width, packed})

    return blocks

4.4 解码伪代码

decode_for(blocks: FORBlock[]) -> int64[]:
    result = []
    for block in blocks:
        deltas = bit_unpack(block.packed, block.bit_width)
        for delta in deltas:
            result.append(block.base + delta)   // 还原 = base + 差值
    return result

4.5 位宽影响

块内差值范围 bit_width 每元素字节 压缩率(原 8B)
[0, 1] 1 0.125 B 98.4%
[0, 255] 8 1 B 87.5%
[0, 65535] 16 2 B 75%
[0, 16M] 24 3 B 62.5%

执行追踪

以时间戳序列为例(单位:秒):

原始序列: [1620000000, 1620000001, 1620000003, 1620000006]

5.1 基础 Delta 编码追踪

步骤 1: first_value = 1620000000
步骤 2: i=1, delta = 1620000001 - 1620000000 = 1,   prev = 1620000001
步骤 3: i=2, delta = 1620000003 - 1620000001 = 2,   prev = 1620000003
步骤 4: i=3, delta = 1620000006 - 1620000003 = 3,   prev = 1620000006

DeltaEncoded = {first_value: 1620000000, deltas: [1, 2, 3]}

存储大小对比(结合 Varint):

原始 Varint
1620000000 8 B 5 B(需要 5 字节)
1 8 B 1 B
2 8 B 1 B
3 8 B 1 B
合计 32 B 8 B(压缩率 75%)

5.2 Delta-of-Delta 追踪

差值序列:      [1, 2, 3]
first_delta =  1
dod[0] = 2 - 1 = 1
dod[1] = 3 - 2 = 1

DoD编码 = {first_value: 1620000000, first_delta: 1, dod: [1, 1]}

若差值恒为 1(等差序列),则 DoD 全为 0,每个后续时间戳仅需 1 bit

5.3 FOR 编码追踪

arr   = [1620000000, 1620000001, 1620000003, 1620000006]
base  = min = 1620000000
deltas = [0, 1, 3, 6]
max_delta = 6 → bit_width = ceil(log2(7)) = 3

packed = 000 001 011 110  (12 bits = 1.5 bytes)
FORBlock = {base: 1620000000, bit_width: 3, packed: 0x01_6x...}
总大小 = 8(base) + 1(bit_width) + 2(packed) ≈ 11 B

工程实现

6.1 ClickHouse Delta Codec

ClickHouse 的列式存储对整型列支持 Delta codec,通常与 LZ4 组合:

CREATE TABLE metrics (
    ts       DateTime   CODEC(Delta, LZ4),
    value    Float64    CODEC(Delta, LZ4),
    user_id  UInt64     CODEC(Delta, LZ4)
) ENGINE = MergeTree ORDER BY ts;

工作机制

  1. Delta 阶段:列内相邻行求差,得到差值列
  2. LZ4 阶段:对差值列做通用压缩(小数值重复多,LZ4 效果更好)

适用列类型:时间列、单调 ID 列、传感器采样值;
不适用:随机 UUID、无规律字符串。

6.2 Parquet Delta Encoding

Parquet 定义了两种 Delta encoding:

DELTA_BINARY_PACKED(整型):

Block结构:
  [block_size: varint][mini_block_count: varint]
  per mini_block:
    [min_delta: zigzag_varint][bit_widths: byte[8]][packed_deltas: ...]

DELTA_BYTE_ARRAY(字节数组/字符串):

编码结构:
  [prefix_lengths: DELTA_BINARY_PACKED]
  [suffixes: DELTA_LENGTH_BYTE_ARRAY]

异常与边界场景

场景 1:非单调序列(差值为负)

情况:arr = [100, 50, 80],差值 = [-50, 30]

处理方式

ZigZag 映射zigzag(n) = (n << 1) XOR (n >> 63),确保 -1→1, 1→2, -2→3,小绝对值始终短编码

场景 2:差值方差过大(压缩率退化)

情况:arr = [1, 1000000, 2, 999999],差值 = [999999, -999998, 999997]

处理方式

场景 3:解码时累加溢出(int64 边界)

情况:首值接近 Int64.MAX_VALUE,差值为正,累加后溢出

处理方式

场景 4:单元素或空序列

encode_delta([])        → {first_value: 无, deltas: []}
encode_delta([42])      → {first_value: 42, deltas: []}
decode_delta({42, []})  → [42]

空序列返回空结果,单元素只存首值,逻辑最简,需在边界判断中覆盖。

场景 5:FOR 块内全相同值

情况:chunk = [100, 100, 100, 100],deltas = [0, 0, 0, 0]

处理方式


参考资料

  1. Gorilla 论文:Pelkonen et al. "Gorilla: A Fast, Scalable, In-Memory Time Series Database." VLDB 2015. PDF
  2. Parquet 格式规范:Apache Parquet Encoding Definitions. 官方文档
  3. ClickHouse Codecs:ClickHouse Column Compression Codecs. 官方文档
  4. Daniel Lemire 博客:"SIMD Compression and the Intersection of Sorted Integers." SIAM Journal on Scientific Computing, 2016.
  5. Lemire & Boytsov:"Decoding billions of integers per second through vectorization." Software: Practice and Experience, 2015.
  6. 《数据密集型应用系统设计》(DDIA)第 3 章:存储与检索,列式存储压缩一节。
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论