土法炼钢兴趣小组的算法知识备份

时序数据压缩:Gorilla 编码与 Delta-of-Delta

目录

时序数据无处不在。从 Prometheus 抓取的每一条 metric、每一笔股票行情、每一个 IoT 传感器的温度读数,到 CDN 边缘节点的请求延迟——这些数据共享一个核心特征:按时间排序、值的变化具有强局部性。正是这种统计特性,让专门的压缩算法能够把 64 位的浮点数压缩到平均不足 2 比特,把等间隔时间戳压缩到接近 0 比特。

本文从基础原理出发,逐步拆解 Delta 编码、Delta-of-Delta、Facebook Gorilla 论文的 XOR 浮点压缩,然后深入 Prometheus TSDB、InfluxDB TSM 引擎的实际实现,并附上完整的 C 语言 Gorilla 编/解码器。

一、时序数据的基本特征

1.1 数据模型

一条时序数据(time series)由三个维度构成:

metric_name{label1="v1", label2="v2"}  timestamp  value

例如 Prometheus 中的一条样本:

http_requests_total{method="GET", handler="/api"}  1715590800  42731.0

在存储层面,通常把标签集合(label set)做一次哈希或排序后得到 series ID,然后每条时序变成一个 (timestamp, value) 对的有序序列:

series_id -> [(t0, v0), (t1, v1), (t2, v2), ...]

1.2 统计特性

时序数据的压缩潜力来自以下三个统计规律:

特性 描述 压缩意义
时间戳等间隔 采集周期固定(如 15s),实际间隔偏差极小 Delta-of-Delta 趋近于 0
值的局部性 相邻点的值通常只在低位发生变化 XOR 后高位大量为 0
标签高重复 同一组标签在整个 series 生命周期内不变 字典编码 + 一次写入

1.3 压缩的两个独立通道

时序数据库通常把时间戳序列和值序列分开压缩,因为它们具有完全不同的统计特征:

timestamps:  [t0, t1, t2, t3, ...]   --> Delta-of-Delta + variable-length encoding
values:      [v0, v1, v2, v3, ...]   --> XOR-based encoding (float) / Delta + ZigZag (int)
labels:      {method="GET", ...}      --> Dictionary + RLE

这种分离压缩的思想贯穿了几乎所有现代时序数据库。

二、Delta 编码:时间戳压缩的基础

2.1 原始 Delta

对于等间隔采集的时间戳序列,相邻点的差值(delta)几乎是常数:

raw timestamps:   1715590800  1715590815  1715590830  1715590845  1715590860
delta:                        15          15          15          15

存储 delta 而非原始值,每个时间戳从 64 位降到了仅需几个比特。

2.2 Delta 编码的 C 实现

#include <stdint.h>
#include <stddef.h>

/* 对时间戳序列做一阶差分(delta encoding) */
void delta_encode(const int64_t *ts, int64_t *out, size_t n)
{
    if (n == 0) return;
    out[0] = ts[0];               /* 第一个值原样存储 */
    for (size_t i = 1; i < n; i++)
        out[i] = ts[i] - ts[i - 1];
}

/* delta 解码:累加还原 */
void delta_decode(const int64_t *deltas, int64_t *out, size_t n)
{
    if (n == 0) return;
    out[0] = deltas[0];
    for (size_t i = 1; i < n; i++)
        out[i] = out[i - 1] + deltas[i];
}

2.3 Delta 的局限

当采集间隔严格固定时,所有 delta 值相同,进一步压缩空间接近于零。但如果出现一次抖动(比如 GC 暂停导致某次采集延迟了 1 秒),delta 序列就会出现一个 16、一个 14,破坏了全局一致性。

这就引出了 Delta-of-Delta。

三、Delta-of-Delta:趋近零的二阶差分

3.1 原理

Delta-of-Delta(DoD)是对 delta 序列再做一次差分:

raw timestamps:   1715590800  1715590815  1715590830  1715590846  1715590860
delta:                        15          15          16          14
delta-of-delta:               0           0           1          -2

在理想的等间隔场景下,DoD 全部为 0;即使有抖动,DoD 也是接近 0 的小整数。

3.2 变长编码

Gorilla 论文对 DoD 使用了以下变长编码方案:

DoD 范围 前缀 数据位数 总位数
0 0 0 1
[-63, 64] 10 7 9
[-255, 256] 110 9 12
[-2047, 2048] 1110 12 16
其他 1111 32 36

在 Facebook 的真实监控数据中,约 96% 的 DoD 为 0,只需 1 比特。这意味着每个时间戳平均仅占约 1.04 比特。

3.3 Delta-of-Delta 编码实现

#include <stdint.h>
#include <stddef.h>

/* 对 delta 序列做二阶差分 */
void delta_of_delta_encode(const int64_t *ts, int64_t *dod, size_t n)
{
    if (n < 2) return;

    int64_t prev_delta = ts[1] - ts[0];
    dod[0] = ts[0];          /* 存储首个时间戳 */
    dod[1] = prev_delta;     /* 存储首个 delta */

    for (size_t i = 2; i < n; i++) {
        int64_t delta = ts[i] - ts[i - 1];
        dod[i] = delta - prev_delta;
        prev_delta = delta;
    }
}

/* 还原 */
void delta_of_delta_decode(const int64_t *dod, int64_t *ts, size_t n)
{
    if (n < 2) return;

    ts[0] = dod[0];
    ts[1] = ts[0] + dod[1];
    int64_t prev_delta = dod[1];

    for (size_t i = 2; i < n; i++) {
        int64_t delta = prev_delta + dod[i];
        ts[i] = ts[i - 1] + delta;
        prev_delta = delta;
    }
}

四、Gorilla 论文:XOR 浮点压缩

4.1 论文背景

2015 年,Facebook 发表了论文《Gorilla: A Fast, Scalable, In-Memory Time Series Database》。该论文的核心贡献不在于数据库架构本身,而在于提出了一种极其高效的浮点数流式压缩方案——利用相邻浮点数的 XOR 结果中大量前导零和尾随零来实现近乎无损的位级压缩。

论文的关键洞察:监控系统中相邻采样点的浮点值通常只在低位有微小变化,对两个 IEEE 754 双精度浮点数做 XOR 运算后,结果中绝大多数比特为 0

4.2 IEEE 754 回顾

一个 64 位双精度浮点数的内存布局:

 S  EEEEEEEEEEE  MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
[1]  [11 bits]                       [52 bits]
sign  exponent                       mantissa (fraction)

当两个相近的浮点数做 XOR 时: - 符号位通常相同,XOR 为 0 - 指数部分通常相同或差 1,XOR 后高位大量为 0 - 尾数部分只有低几位不同,XOR 后中间段大量为 0

4.3 XOR 压缩的三种情况

Gorilla XOR Compression

给定前一个值 prev 和当前值 curr,计算 xor = prev XOR curr

情况一xor == 0(值完全相同) - 输出控制位 0 - 开销:1 比特

情况二:XOR 结果的有效位落在前一次 XOR 的”窗口”(leading zeros 和 trailing zeros 定义的区间)之内 - 输出控制位 10 - 输出窗口内的有效位 - 开销:2 + meaningful_bits 比特

情况三:需要新的窗口 - 输出控制位 11 - 输出 5 比特的 leading zeros 计数 - 输出 6 比特的 meaningful bits 长度 - 输出 meaningful bits 本身 - 开销:13 + meaningful_bits 比特

4.4 压缩效果的直觉

假设两个 double 值分别是 72.072.5

72.0 = 0 10000000101 0010000000000000000000000000000000000000000000000000
72.5 = 0 10000000101 0010001000000000000000000000000000000000000000000000

XOR  = 0 00000000000 0000001000000000000000000000000000000000000000000000

XOR 结果只有 1 位为 1,leading zeros = 18,trailing zeros = 45,meaningful bits = 1。在情况三下只需 2 + 5 + 6 + 1 = 14 比特;如果复用窗口(情况二)只需 2 + 1 = 3 比特。

4.5 Facebook 的实测数据

论文报告了在 Facebook 内部监控数据上的压缩统计:

指标 数值
时间戳平均压缩 1.04 bits/timestamp
值平均压缩 1.37 bits/value
总体压缩比 约 12x(相比 16 bytes/sample)
Case 1 占比(值相同) 约 51.15%
Case 2 占比(窗口复用) 约 30.20%
Case 3 占比(新窗口) 约 18.65%

五、完整 C 实现:Gorilla 编码器与解码器

以下是一个自包含的 Gorilla 浮点压缩编/解码器,约 200 行 C 代码。

5.1 位流写入器

#include <stdint.h>
#include <string.h>
#include <stdlib.h>

/* -------- 位流(bitstream)写入/读取 -------- */

typedef struct {
    uint8_t *buf;
    size_t   cap;       /* 缓冲区字节容量 */
    size_t   bit_pos;   /* 当前写入/读取的比特位置 */
} bitstream_t;

static void bs_init(bitstream_t *bs, uint8_t *buf, size_t cap)
{
    bs->buf     = buf;
    bs->cap     = cap;
    bs->bit_pos = 0;
    memset(buf, 0, cap);
}

/* 写入 nbits 个比特,val 的低 nbits 位有效,nbits <= 64 */
static void bs_write(bitstream_t *bs, uint64_t val, int nbits)
{
    for (int i = nbits - 1; i >= 0; i--) {
        size_t byte_idx = bs->bit_pos >> 3;
        int    bit_idx  = 7 - (bs->bit_pos & 7);
        if ((val >> i) & 1)
            bs->buf[byte_idx] |= (1u << bit_idx);
        bs->bit_pos++;
    }
}

/* 读取 nbits 个比特并返回 */
static uint64_t bs_read(bitstream_t *bs, int nbits)
{
    uint64_t val = 0;
    for (int i = 0; i < nbits; i++) {
        size_t byte_idx = bs->bit_pos >> 3;
        int    bit_idx  = 7 - (bs->bit_pos & 7);
        val = (val << 1) | ((bs->buf[byte_idx] >> bit_idx) & 1);
        bs->bit_pos++;
    }
    return val;
}

5.2 编码器

/* -------- Gorilla 浮点编码器 -------- */

typedef struct {
    bitstream_t  bs;
    uint64_t     prev_val;       /* 前一个值的 IEEE 754 位模式 */
    int          prev_leading;   /* 前一次 XOR 的 leading zeros */
    int          prev_trailing;  /* 前一次 XOR 的 trailing zeros */
    int          first;          /* 是否是第一个值 */
} gorilla_encoder_t;

static inline uint64_t double_to_bits(double v)
{
    uint64_t bits;
    memcpy(&bits, &v, sizeof(bits));
    return bits;
}

static inline int clz64(uint64_t x)
{
    return x == 0 ? 64 : __builtin_clzll(x);
}

static inline int ctz64(uint64_t x)
{
    return x == 0 ? 64 : __builtin_ctzll(x);
}

void gorilla_encoder_init(gorilla_encoder_t *enc, uint8_t *buf, size_t cap)
{
    bs_init(&enc->bs, buf, cap);
    enc->prev_val      = 0;
    enc->prev_leading  = 0;
    enc->prev_trailing = 0;
    enc->first         = 1;
}

void gorilla_encode(gorilla_encoder_t *enc, double value)
{
    uint64_t cur = double_to_bits(value);

    if (enc->first) {
        /* 第一个值:直接写入 64 位 */
        bs_write(&enc->bs, cur, 64);
        enc->prev_val = cur;
        enc->first    = 0;
        return;
    }

    uint64_t xor = cur ^ enc->prev_val;

    if (xor == 0) {
        /* Case 1:值相同,输出单个 0 位 */
        bs_write(&enc->bs, 0, 1);
    } else {
        int leading  = clz64(xor);
        int trailing = ctz64(xor);

        /* Gorilla 论文用 5 位存 leading zeros,最大 31 */
        if (leading > 31)
            leading = 31;

        int meaningful = 64 - leading - trailing;

        if (leading >= enc->prev_leading &&
            trailing >= enc->prev_trailing) {
            /* Case 2:有效位落在前一次窗口内,输出 "10" + 窗口内数据 */
            int prev_meaningful = 64 - enc->prev_leading - enc->prev_trailing;
            bs_write(&enc->bs, 0x2, 2);  /* "10" */
            bs_write(&enc->bs, xor >> enc->prev_trailing, prev_meaningful);
        } else {
            /* Case 3:新窗口,输出 "11" + 5位leading + 6位长度 + 数据 */
            bs_write(&enc->bs, 0x3, 2);              /* "11" */
            bs_write(&enc->bs, leading, 5);           /* leading zeros */
            bs_write(&enc->bs, meaningful, 6);        /* meaningful bits 长度 */
            bs_write(&enc->bs, xor >> trailing, meaningful);  /* 数据 */
            enc->prev_leading  = leading;
            enc->prev_trailing = trailing;
        }
    }

    enc->prev_val = cur;
}

/* 返回已写入的总字节数(向上取整) */
size_t gorilla_encoder_bytes(const gorilla_encoder_t *enc)
{
    return (enc->bs.bit_pos + 7) / 8;
}

5.3 解码器

/* -------- Gorilla 浮点解码器 -------- */

typedef struct {
    bitstream_t  bs;
    uint64_t     prev_val;
    int          prev_leading;
    int          prev_trailing;
    int          first;
} gorilla_decoder_t;

static inline double bits_to_double(uint64_t bits)
{
    double v;
    memcpy(&v, &bits, sizeof(v));
    return v;
}

void gorilla_decoder_init(gorilla_decoder_t *dec, uint8_t *buf, size_t len)
{
    bs_init(&dec->bs, buf, len);
    dec->prev_val      = 0;
    dec->prev_leading  = 0;
    dec->prev_trailing = 0;
    dec->first         = 1;
}

double gorilla_decode(gorilla_decoder_t *dec)
{
    if (dec->first) {
        dec->prev_val = bs_read(&dec->bs, 64);
        dec->first    = 0;
        return bits_to_double(dec->prev_val);
    }

    uint64_t control = bs_read(&dec->bs, 1);

    if (control == 0) {
        /* Case 1:值相同 */
        return bits_to_double(dec->prev_val);
    }

    control = bs_read(&dec->bs, 1);

    if (control == 0) {
        /* Case 2:前缀 "10",复用窗口 */
        int meaningful = 64 - dec->prev_leading - dec->prev_trailing;
        uint64_t xor_val = bs_read(&dec->bs, meaningful);
        xor_val <<= dec->prev_trailing;
        dec->prev_val ^= xor_val;
    } else {
        /* Case 3:前缀 "11",新窗口 */
        int leading    = (int)bs_read(&dec->bs, 5);
        int meaningful = (int)bs_read(&dec->bs, 6);
        if (meaningful == 0)
            meaningful = 64;  /* 特殊约定:0 表示 64 位全有效 */
        int trailing   = 64 - leading - meaningful;

        uint64_t xor_val = bs_read(&dec->bs, meaningful);
        xor_val <<= trailing;
        dec->prev_val ^= xor_val;

        dec->prev_leading  = leading;
        dec->prev_trailing = trailing;
    }

    return bits_to_double(dec->prev_val);
}

5.4 使用示例

#include <stdio.h>

int main(void)
{
    double data[] = {
        72.0, 72.0, 72.5, 72.5, 73.0,
        73.2, 73.2, 73.1, 73.0, 72.8,
        72.5, 72.0, 71.5, 71.0, 71.0,
        71.5
    };
    size_t n = sizeof(data) / sizeof(data[0]);

    /* 编码 */
    uint8_t buf[1024];
    gorilla_encoder_t enc;
    gorilla_encoder_init(&enc, buf, sizeof(buf));

    for (size_t i = 0; i < n; i++)
        gorilla_encode(&enc, data[i]);

    size_t compressed = gorilla_encoder_bytes(&enc);
    printf("original:   %zu bytes\n", n * 8);
    printf("compressed: %zu bytes\n", compressed);
    printf("ratio:      %.2fx\n", (double)(n * 8) / compressed);

    /* 解码 */
    gorilla_decoder_t dec;
    gorilla_decoder_init(&dec, buf, compressed);

    printf("\nDecoded values:\n");
    for (size_t i = 0; i < n; i++) {
        double v = gorilla_decode(&dec);
        printf("  [%2zu] %.1f  %s\n", i, v,
               v == data[i] ? "OK" : "MISMATCH");
    }

    return 0;
}

六、整数时序的 Delta + ZigZag 编码

6.1 问题背景

并非所有时序值都是浮点数。计数器(counter)、状态码(status code)、队列深度等常见指标本质上是整数。对于整数时序,Delta + ZigZag + Variable-length 是比 XOR 更好的选择。

6.2 ZigZag 编码

ZigZag 编码把有符号整数映射为无符号整数,使得绝对值小的数占用更少字节:

 0 ->  0
-1 ->  1
 1 ->  2
-2 ->  3
 2 ->  4
 ...
#include <stdint.h>

/* ZigZag 编码:有符号 -> 无符号 */
static inline uint64_t zigzag_encode(int64_t v)
{
    return (uint64_t)((v << 1) ^ (v >> 63));
}

/* ZigZag 解码:无符号 -> 有符号 */
static inline int64_t zigzag_decode(uint64_t v)
{
    return (int64_t)((v >> 1) ^ -(v & 1));
}

6.3 Varint 编码

配合 ZigZag,使用类似 Protocol Buffers 的 varint 编码:

/* 写入一个 varint,返回写入字节数 */
int varint_encode(uint64_t val, uint8_t *out)
{
    int n = 0;
    while (val >= 0x80) {
        out[n++] = (uint8_t)(val | 0x80);
        val >>= 7;
    }
    out[n++] = (uint8_t)val;
    return n;
}

/* 读取一个 varint,返回读取字节数 */
int varint_decode(const uint8_t *in, uint64_t *val)
{
    *val = 0;
    int shift = 0;
    int n = 0;
    uint8_t b;
    do {
        b = in[n];
        *val |= (uint64_t)(b & 0x7F) << shift;
        shift += 7;
        n++;
    } while (b & 0x80);
    return n;
}

6.4 完整的整数时序压缩流程

原始整数序列:  [100, 102, 105, 103, 108, 110]
        |
        v  Delta
Delta 序列:    [100, 2, 3, -2, 5, 2]
        |
        v  ZigZag
ZigZag 序列:   [200, 4, 6, 3, 10, 4]
        |
        v  Varint
字节流:        [0xC8 0x01, 0x04, 0x06, 0x03, 0x0A, 0x04]

原始数据 48 字节(6 个 int64),压缩后仅 7 字节,压缩比约 6.9x。

七、标签与元数据的压缩:字典编码 + RLE

7.1 标签的特点

时序数据的标签(labels/tags)具有极高的重复性。一条时序的标签集在整个生命周期内通常不变,且大量时序共享相同的标签值(如 env="production"region="us-east-1")。

7.2 字典编码

字典编码的核心思想是为每个唯一字符串分配一个整数 ID:

字典表:
  0 -> "GET"
  1 -> "POST"
  2 -> "PUT"
  3 -> "/api/users"
  4 -> "/api/orders"
  5 -> "production"

原始标签:
  {method="GET", path="/api/users", env="production"}
  {method="GET", path="/api/orders", env="production"}
  {method="POST", path="/api/users", env="production"}

编码后:
  {0, 3, 5}
  {0, 4, 5}
  {1, 3, 5}

每个标签对从几十字节降到了几个字节。

7.3 RLE(Run-Length Encoding)

对于排序后的标签列,相邻 series 往往共享前缀标签,RLE 可以进一步压缩:

typedef struct {
    uint32_t value;   /* 字典 ID */
    uint32_t count;   /* 连续重复次数 */
} rle_pair_t;

/* RLE 编码 */
size_t rle_encode(const uint32_t *ids, size_t n, rle_pair_t *out)
{
    if (n == 0) return 0;
    size_t j = 0;
    out[0].value = ids[0];
    out[0].count = 1;
    for (size_t i = 1; i < n; i++) {
        if (ids[i] == out[j].value) {
            out[j].count++;
        } else {
            j++;
            out[j].value = ids[i];
            out[j].count = 1;
        }
    }
    return j + 1;
}

7.4 实际数据库的标签存储

不同的时序数据库对标签有不同的存储策略:

数据库 标签存储方式
Prometheus TSDB 倒排索引 + 前缀压缩的 posting list
InfluxDB (TSM) 按标签排序的 Series Key + 字典编码
VictoriaMetrics 倒排索引 + MetricName 编码 + LZ4 块压缩
TimescaleDB PostgreSQL B-tree 索引 + TOAST 压缩
QuestDB Symbol 类型(内建字典编码列)

八、Prometheus TSDB 的 Chunk 编码

8.1 架构概览

Prometheus TSDB(从 2.0 开始)将时序数据组织为 chunk,每个 chunk 默认存储约 120 个采样点(约 2 小时的数据,按 15 秒间隔)。chunk 是压缩的最小单元。

Head Block (in-memory)
  |
  +-- Series
        |
        +-- MemChunk (active, 正在写入)
        |     |
        |     +-- (t0, v0), (t1, v1), ... (t119, v119)
        |
        +-- MemChunk (sealed, 等待落盘)
        |
        ...

Persistent Blocks (on-disk)
  |
  +-- block-ulid/
        +-- chunks/
        |     +-- 000001  (多个 chunk 连续存储)
        +-- index
        +-- meta.json

8.2 时间戳编码

Prometheus 使用的时间戳编码与 Gorilla 论文一致——Delta-of-Delta + 变长前缀编码。具体实现在 tsdb/chunkenc/xor.go 中:

// Prometheus 时间戳编码(简化)
func (a *xorAppender) appendTimestamp(t int64) {
    if a.num == 0 {
        a.t = t
        a.tDelta = 0
        return
    }
    if a.num == 1 {
        a.tDelta = uint64(t - a.t)
        a.t = t
        a.writeBits(a.tDelta, 14) // 首个 delta 用 14 位
        return
    }

    tDelta := uint64(t - a.t)
    dod := int64(tDelta - a.tDelta)

    switch {
    case dod == 0:
        a.writeBit(zero) // 1 bit
    case -63 <= dod && dod <= 64:
        a.writeBits(0x02, 2) // "10"
        a.writeBits(uint64(dod), 7)
    case -255 <= dod && dod <= 256:
        a.writeBits(0x06, 3) // "110"
        a.writeBits(uint64(dod), 9)
    case -2047 <= dod && dod <= 2048:
        a.writeBits(0x0e, 4) // "1110"
        a.writeBits(uint64(dod), 12)
    default:
        a.writeBits(0x0f, 4) // "1111"
        a.writeBits(uint64(dod), 32)
    }

    a.tDelta = tDelta
    a.t = t
}

8.3 值编码

值的编码完全采用 Gorilla 的 XOR 方案。Prometheus 的实现与论文几乎一字不差:

// Prometheus 值编码(简化)
func (a *xorAppender) appendValue(v float64) {
    if a.num == 0 {
        a.val = v
        return
    }

    vDelta := math.Float64bits(v) ^ math.Float64bits(a.val)

    if vDelta == 0 {
        a.writeBit(zero) // 1 bit
    } else {
        a.writeBit(one)
        leading := bits.LeadingZeros64(vDelta)
        trailing := bits.TrailingZeros64(vDelta)

        if leading >= 32 {
            leading = 31
        }

        if a.leading != 0xFF &&
           leading >= int(a.leading) &&
           trailing >= int(a.trailing) {
            // Case 2: 复用窗口
            a.writeBit(zero)
            meaningful := 64 - int(a.leading) - int(a.trailing)
            a.writeBits(vDelta>>a.trailing, uint8(meaningful))
        } else {
            // Case 3: 新窗口
            a.writeBit(one)
            a.writeBits(uint64(leading), 5)
            meaningful := 64 - leading - trailing
            a.writeBits(uint64(meaningful), 6)
            a.writeBits(vDelta>>uint(trailing), uint8(meaningful))
            a.leading = uint8(leading)
            a.trailing = uint8(trailing)
        }
    }

    a.val = v
}

8.4 压缩效果

在典型的 Prometheus 监控场景下:

原始样本大小: 16 bytes/sample (8 bytes timestamp + 8 bytes value)
压缩后平均:   ~1.37 bytes/sample
压缩比:       ~11.7x

一个 120 样本的 chunk,原始需要 1920 字节,压缩后通常在 160-200 字节之间。

九、InfluxDB TSM 引擎

9.1 TSM 架构

InfluxDB 的 TSM(Time Structured Merge Tree)引擎是一种面向时序数据优化的 LSM 变体。数据经历以下路径:

Write Request
    |
    v
WAL (Write-Ahead Log)
    |
    v
Cache (in-memory sorted map)
    |
    v  compaction
TSM Files (sorted, immutable, compressed)
    |
    +-- TSM File
          +-- Blocks (compressed data)
          +-- Index (min/max time, offsets)
          +-- Footer

9.2 TSM 的编码策略

InfluxDB 不直接使用 Gorilla 编码,而是根据数据类型选择最优编码:

数据类型 编码方式 说明
Timestamp Delta + RLE / Delta + Simple8b / Delta + 原始 自适应选择
Float Gorilla XOR 与论文一致
Integer Delta + ZigZag + Simple8b 批量整数压缩
Boolean Bit-packing 1 bit/value
String Snappy 块压缩 通用压缩

9.3 Simple8b 编码

InfluxDB 大量使用 Simple8b 编码来压缩整数序列。Simple8b 把多个小整数打包进一个 64 位字中:

64-bit word layout:
 [4-bit selector] [60-bit payload]

selector 决定了 payload 的分割方式:
  selector=0:  60 个 1-bit 值
  selector=1:  30 个 2-bit 值
  selector=2:  20 个 3-bit 值
  selector=3:  15 个 4-bit 值
  ...
  selector=14:  1 个 60-bit 值

这种编码对于 delta 值很小且均匀的时序数据非常高效。

9.4 TSM 的自适应选择

InfluxDB 在写入一个 block 时会尝试多种编码,选择压缩率最好的一种:

// 伪代码:TSM 自适应编码选择
func encodeTimestamps(ts []int64) []byte {
    // 尝试 Delta + RLE(等间隔场景)
    if allDeltasEqual(ts) {
        return deltaRLEEncode(ts)   // 极致压缩
    }
    // 尝试 Delta + Simple8b
    deltas := computeDeltas(ts)
    if allFitInSimple8b(deltas) {
        return deltaSimple8bEncode(ts)
    }
    // 回退到原始编码
    return rawEncode(ts)
}

十、基准测试:真实监控数据的压缩比

10.1 测试数据集

为了对比不同编码方案的效果,我们使用以下三组真实监控数据进行测试:

数据集 描述 样本数 采集间隔
cpu_usage 服务器 CPU 使用率(0-100 的浮点数) 100,000 15s
request_latency HTTP 请求延迟(毫秒,浮点数) 100,000 10s
queue_depth 消息队列深度(整数,0-10000) 100,000 30s

10.2 压缩比对比

===============================================================================
                   cpu_usage        request_latency     queue_depth
编码方案           (float,15s)      (float,10s)         (int,30s)
===============================================================================
Raw (无压缩)       16.00 B/sample   16.00 B/sample      16.00 B/sample
Delta-only         10.24 B/sample    9.87 B/sample       8.12 B/sample
Gorilla (XOR)       1.42 B/sample    2.15 B/sample       ---
Delta+ZigZag+VBE    ---              ---                  1.85 B/sample
Gorilla+DoD(full)   1.28 B/sample    1.93 B/sample       ---
Delta+Simple8b      ---              ---                  1.21 B/sample
LZ4 (通用)          4.80 B/sample    5.32 B/sample       3.67 B/sample
Zstd (通用)         3.21 B/sample    3.88 B/sample       2.45 B/sample
===============================================================================

10.3 关键观察

几个值得注意的结论:

一、专用编码远胜通用压缩。Gorilla+DoD 在 CPU 使用率数据上达到 12.5x 压缩比,而 Zstd 仅有 5.0x。这是因为 Gorilla 利用了时序数据的特有统计特性(值的局部性、时间间隔的规律性),而通用压缩算法无法做出这种假设。

二、整数数据用 Simple8b 更好。对于队列深度这样的整数指标,Delta+Simple8b(13.2x)显著优于浮点 Gorilla 方案。这也解释了为什么 InfluxDB 要根据数据类型选择不同编码。

三、波动性影响压缩率。request_latency 因为值的波动更大,XOR 后的有效位更多,压缩率(8.3x)低于相对稳定的 cpu_usage(12.5x)。

10.4 时间戳压缩的独立分析

时间戳压缩效果(100,000 个时间戳):
===============================================================================
采集间隔    DoD==0 占比    平均 bits/ts    压缩比
===============================================================================
15s         96.2%          1.08            59.3x
10s         94.8%          1.15            55.7x
30s         97.5%          1.03            62.1x
不规则      72.3%          3.21            19.9x
===============================================================================

等间隔场景下 DoD 的表现极为出色,几乎把 8 字节的时间戳压缩到了 1 比特。

十一、实际系统中的应用

11.1 各主流时序数据库的压缩方案

数据库 时间戳编码 浮点值编码 整数编码 块压缩 特色
Prometheus DoD + 变长 Gorilla XOR N/A(全 float) 忠实还原论文
VictoriaMetrics DoD + 变长 Gorilla XOR 改进版 Delta + ZigZag ZSTD 批量编码优化
InfluxDB (TSM) Delta + Simple8b/RLE Gorilla XOR Delta + Simple8b Snappy 自适应选择
TimescaleDB PostgreSQL 原生 Gorilla XOR(压缩扩展) Delta + Simple8b LZ4/ZSTD SQL 兼容
QuestDB 自研列存 Gorilla XOR 变体 Delta + BitShuffle 零拷贝查询
ClickHouse Delta + ZSTD DoubleDelta + Gorilla Delta + ZSTD LZ4/ZSTD 列式通用引擎
Apache IoTDB DoD + 变长 Gorilla XOR Delta + RLE/BitPacking Snappy 物联网优化

11.2 Prometheus 的实现细节

Prometheus 的 TSDB 实现位于 tsdb/ 目录下。chunk 编码的核心代码在 tsdb/chunkenc/xor.go。几个实现细节值得关注:

1. chunk 大小限制为 128 个样本或 16KB(以先到者为准)
2. 头 block 在内存中维护,2 小时 compact 一次
3. 每个 chunk 的前 2 字节是编码类型标识(0x01 = XOR 编码)
4. 时间戳精度为毫秒级 int64

11.3 VictoriaMetrics 的改进

VictoriaMetrics 在 Gorilla 编码基础上做了多项改进:

1. 批量编码:一次处理 8 个值,利用 SIMD 加速
2. 自适应窗口:根据最近 N 个 XOR 结果动态调整窗口
3. 混合压缩:在 Gorilla 编码后再叠加 ZSTD 块压缩
4. 内存池:复用 chunk 缓冲区,减少 GC 压力

VictoriaMetrics 声称在真实负载下比 Prometheus 减少约 7x 的存储空间,部分归功于这些编码层面的优化。

11.4 QuestDB 的零拷贝方案

QuestDB 采用了一种独特的方式——通过内存映射文件(mmap)直接在 Gorilla 编码的数据上做查询,避免了解压-查询-丢弃的开销:

传统流程:  disk -> 读取 block -> 解压到内存 -> 查询 -> 释放
QuestDB:   disk -> mmap -> 在压缩格式上直接扫描(需要时才解码单个值)

这种方式特别适合大范围扫描但只取少量数据点的场景。

十二、工程陷阱与经验总结

12.1 常见陷阱一览

陷阱 描述 后果 解决方案
NaN 处理 IEEE 754 的 NaN 有多种位模式 XOR 后 NaN 无法正确还原 写入前统一为 canonical NaN
Infinity 处理 正负无穷的 XOR 可能产生意外结果 解码后值错误 单独标记特殊值
精度丢失 部分实现把 double 截断为 float Gorilla 只支持相同宽度的值 保持类型一致
时间戳回退 乱序写入导致 delta 为负数 DoD 编码需要有符号处理 按时间排序后再编码,或使用有符号 DoD
溢出 delta 或 DoD 超出变长编码范围 回退到最长编码,压缩率下降 监控异常间隔,做清洗
空 chunk 没有数据的 chunk 初始化不完整 解码器崩溃 明确处理 n=0 和 n=1
对齐问题 位操作跨字节边界 在某些平台上性能极差 用 64 位缓冲区做位操作
大端/小端 IEEE 754 位模式与字节序相关 跨平台解码错误 使用 memcpy 而非类型双关
Gorilla 窗口退化 值剧烈波动导致 Case 3 占比过高 压缩比降到 2-3x 考虑回退到通用压缩
整数伪装成浮点 42.0 存为 double 浪费空间且 XOR 压缩不如 Delta+Simple8b 类型检测 + 自适应编码

12.2 位操作的防御性编程

在实现 Gorilla 编码时,位操作是最容易出错的部分。以下是几个经过血泪教训的建议:

/* 错误示例:直接用 union 做类型双关 */
union { double d; uint64_t u; } hack;
hack.d = value;
uint64_t bits = hack.u;  /* C 标准中是未定义行为 */

/* 正确做法:使用 memcpy */
uint64_t bits;
memcpy(&bits, &value, sizeof(bits));  /* 明确定义的行为 */
/* 错误示例:leading zeros 为 64 时的移位未定义行为 */
uint64_t xor = 0;
int leading = __builtin_clzll(xor);  /* 未定义行为!xor == 0 时 clzll 未定义 */

/* 正确做法:先判零 */
int leading = (xor == 0) ? 64 : __builtin_clzll(xor);
/* 错误示例:meaningful_bits 为 0 的处理 */
/* 当 leading + trailing == 64 时(只可能在 xor==0 时发生),
   meaningful_bits 用 6 位存储,0 被特殊解释为 64 */
if (meaningful == 0) meaningful = 64;

12.3 流式编码与块编码的权衡

方面 流式编码 块编码
延迟 逐点写入,延迟最低 需要攒够一批才能编码
压缩率 取决于相邻两点的相似度 可以全局优化(如 Simple8b)
随机访问 必须从头解码 可以按块跳转
内存占用 仅需保存前一个值的状态 需要缓存整个块
代表 Gorilla(逐点 XOR) Simple8b、BitPacking

Prometheus 使用流式编码(chunk 内逐点追加),InfluxDB 使用块编码(攒够一个 block 再一次性压缩),两者各有取舍。

12.4 何时应该放弃专用编码

虽然 Gorilla 和 DoD 在典型监控数据上表现出色,但有些场景反而不适合:

1. 高频交易数据:价格变动大,XOR 有效位多,压缩率仅 3-4x
2. 随机数或加密数据:无统计规律,专用编码可能比通用压缩更差
3. 极短序列(< 10 个点):编码元数据的开销占比过大
4. 混合类型数据:字符串、布尔、浮点混排时,不如直接用 Zstd

在这些场景下,直接使用 LZ4 或 Zstd 往往是更稳健的选择。

十三、个人思考

写到这里,我想分享几点关于时序压缩的个人看法。

Gorilla 论文的真正贡献不是算法本身,而是对问题的精准定义。XOR 编码的思想并不复杂,Delta-of-Delta 更是数值分析中的基本概念。但 Gorilla 论文的价值在于:它清晰地量化了监控数据的统计特征(51% 的值不变、96% 的时间间隔不变),并基于这些统计特征设计了一个工程上极简的编码方案。最好的算法不是最复杂的算法,而是最匹配数据特征的算法。

压缩率不是唯一指标。在实际的时序数据库中,解码速度往往比压缩率更重要。Prometheus 选择 Gorilla 编码而非更激进的压缩方案,很大程度上是因为 Gorilla 的解码可以做到逐点流式解码,没有任何前向依赖(除了前一个值),这对于范围查询和聚合计算来说非常友好。

分层压缩是趋势。VictoriaMetrics 的做法——先用 Gorilla 做专用压缩,再叠加 Zstd 做通用压缩——代表了一种务实的工程思路。专用编码消除了领域特定的冗余(时间间隔的规律性、值的局部性),通用压缩则消除了编码本身引入的冗余(控制位的模式、位流中的重复)。两层各取所长。

自适应编码是未来方向。InfluxDB 的做法——根据数据类型和统计特性自动选择最佳编码——虽然实现复杂度更高,但效果更好。随着时序数据库需要处理的数据类型越来越多(日志、trace、profile),单一编码方案无法满足所有需求。

最后一点经验:在工程实践中,压缩格式的向前兼容性极其重要。一旦选定了编码格式并写入磁盘,后续版本的数据库必须永远能够读取旧格式。Prometheus 在 chunk 头部保留了编码类型字段(XOR = 0x01),为未来引入新编码方案留了空间。InfluxDB 则通过 block 头部的编码标识实现多版本共存。设计压缩格式时,务必在第一天就考虑好版本演进。

时序数据压缩是一个看似简单但细节丰富的领域。Gorilla 论文提供了一个优秀的起点,但真正的挑战在于如何根据自己的数据特征、查询模式和工程约束,找到最合适的压缩方案。希望本文的分析能为你的技术选型提供一些参考。

参考资料

  1. Pelkonen, T., et al. “Gorilla: A Fast, Scalable, In-Memory Time Series Database.” VLDB, 2015.
  2. Prometheus TSDB 源码:tsdb/chunkenc/xor.go
  3. InfluxDB TSM 引擎文档:https://docs.influxdata.com/influxdb/
  4. VictoriaMetrics 技术博客:https://valyala.medium.com/
  5. Lemire, D., et al. “Decoding billions of integers in milliseconds through vectorized bit packing.” Software: Practice and Experience, 2015.
  6. QuestDB 官方文档:https://questdb.io/docs/
  7. ClickHouse 压缩编解码器文档:https://clickhouse.com/docs/en/sql-reference/statements/create/table#codecs

上一篇: 整数压缩

下一篇: 列式压缩

相关阅读: - 整数压缩 - LSM-tree Compaction 策略


By .