时序数据库:InfluxDB 存储引擎设计

数据库内核 2026-07-08 · 阅读约 23 分钟

监控指标、传感器数据、行情报价这类时序负载有非常鲜明的个性:写入几乎是纯追加且按时间有序、写多读少、查询几乎都带时间范围、旧数据几乎不更新但会整批过期。通用 B+Tree 引擎在这种负载下既浪费随机写性能,又浪费存储空间。InfluxDB 的 TSM(Time-Structured Merge Tree)引擎为此而生:用 LSM 架构承接高吞吐追加写,再靠时序专用压缩算法把数据压到原体积的几十分之一。本文拆解 TSM 的整体架构与两大压缩算法,并用一段零依赖 Python 代码真实跑出压缩率。

一、TSM 引擎的整体架构

TSM 本质是 LSM-Tree 的时序特化版,写路径分三段:

  1. WAL:写先进预写日志,保证崩溃可恢复;WAL 段文件按大小切分,缓存刷盘后对应段即可删除。
  2. Cache:内存中的排序结构(按 series key + 时间戳组织),承接实时写入并直接参与查询,保证新数据立即可读;达到阈值(默认约 25MB)后快照刷盘。
  3. TSM 文件:不可变的磁盘文件,内部是按时间窗口切分的 block:每个 block 存一个 series 在某时间范围内的压缩时间戳 + 压缩值,文件尾部带索引(series key -> block 位置)与布隆过滤器。

后台压缩(compaction)分多级:Level 1 把多个小 TSM 文件合并;更高层级把同一 series 的多个 block 合并成更大的时间窗口块,并处理删除标记与过期数据整批回收。查询则同时读 Cache 与命中的 TSM block,在内存里做归并——这与 LSM 的多路归并一脉相承。

二、压缩算法一:时间戳的 delta-of-delta

时序数据的时间戳有两个可利用的规律:总量大(每点 8 字节)、间隔高度规律(监控多为固定周期采集)。delta-of-delta 编码分两步:

  • 一阶差分(delta):d[i] = ts[i] - ts[i-1],把递增大整数变成小间隔;
  • 二阶差分(delta-of-delta):dod[i] = d[i] - d[i-1]。对固定周期采样,所有 dod 恒为 0,可用接近 1 bit 存储。

Facebook Gorilla 论文的做法是:dod=0 用 1 bit;落在小范围用 2 控制位 + 7 bit;更大范围逐级加宽。下面的实验用简化口径统计位数。

三、压缩算法二:浮点值的 XOR 编码(Gorilla)

时序指标的相邻值往往高度相似,IEEE 754 双精度浮点的高位符号位与指数完全相同。Gorilla 的做法是对相邻值做 XOR:

  • XOR 结果为 0(值完全没变):只存 1 个控制位 0;
  • 否则存控制位 + 前导零压缩后的有效位段:前导零和尾随零被跳过,只记录中间真正变化的位。

因此压缩率强依赖相邻值的相似度:缓慢变化或长时间恒定的指标(如服务在线状态、温度)能压到每点 1~2 bit;而每点都剧烈抖动的噪声数据几乎压不动。下面的实验会把这个差异直接量化给您看。

四、动手实验:真实压缩率测算

代码生成 12 小时、10 秒间隔共 4320 个点的模拟 CPU 使用率,实现 dod 编码/解码、XOR 位数统计、按 1 小时切块刷盘、降采样为 1 小时均值,并做无损校验。零依赖,Python 3 直接运行:

# -*- coding: utf-8 -*-
# 简化模拟 InfluxDB TSM 存储引擎核心机制
import math, random, struct

random.seed(7)
N = 4320   # 12 小时, 10s 间隔
base_ts = 1759200000
timestamps = [base_ts + 10 * i for i in range(N)]
values = [round(35 + 20 * math.sin(i / 200.0)
          + random.uniform(-3, 3), 2) for i in range(N)]

# 1. delta-of-delta 时间戳压缩
def dod_encode(ts):
    out = [ts[0]]
    prev_d = ts[1] - ts[0]
    out.append(prev_d)
    for i in range(2, len(ts)):
        d = ts[i] - ts[i-1]
        out.append(d - prev_d)
        prev_d = d
    return out

def dod_decode(dods):
    ts = [dods[0], dods[0] + dods[1]]
    for i in range(2, len(dods)):
        ts.append(ts[-1] + (dods[i] + (ts[-1] - ts[-2])))
    return ts

dods = dod_encode(timestamps)
assert dod_decode(dods) == timestamps
ts_raw_bits = 64 * N
ts_cmp_bits = 128 + sum(1 if d == 0 else 8 for d in dods[2:])

# 2. Gorilla XOR 浮点压缩
def f2b(f): return struct.unpack('>Q', struct.pack('>d', f))[0]

def xor_encode(vals):
    bits = 64
    prev = f2b(vals[0])
    same = diff = 0
    for v in vals[1:]:
        x = f2b(v) ^ prev
        if x == 0:
            same += 1; bits += 1
        else:
            lz = 64 - x.bit_length()
            tz = (x & -x).bit_length() - 1
            bits += 2 + (64 - lz - tz)
            diff += 1
        prev = f2b(v)
    return bits, same, diff

val_raw_bits = 64 * N
val_cmp_bits, same, diff = xor_encode(values)

# 3. cache -> 刷盘 block (1h 切块)
BLOCK = 360
blocks = [(timestamps[i], timestamps[min(i+BLOCK, N)-1])
          for i in range(0, N, BLOCK)]

# 4. 降采样: 10s -> 1h 均值
hourly = [round(sum(values[i:i+BLOCK]) / len(values[i:i+BLOCK]), 2)
          for i in range(0, N, BLOCK)]

print(f'时间戳: {ts_raw_bits/8/1024:.1f} KiB -> '
      f'{ts_cmp_bits/8/1024:.1f} KiB, 压缩率 {ts_raw_bits/ts_cmp_bits:.1f}x')
print(f'dod 为 0 的占比: '
      f'{sum(1 for d in dods[2:] if d==0)}/{N-2}')
print(f'值: {val_raw_bits/8/1024:.1f} KiB -> '
      f'{val_cmp_bits/8/1024:.1f} KiB, 压缩率 {val_raw_bits/val_cmp_bits:.2f}x')
print(f'相邻完全相同: {same}, 有差异: {diff}')
print(f'block 数: {len(blocks)}, 降采样点数: {len(hourly)}')

在沙箱(Python 3,无第三方库)上的真实运行输出:

数据规模: 4320 个点 (12 小时, 10s 间隔)

== 时间戳压缩 (delta-of-delta) ==
  原始: 33.8 KiB -> 压缩后约: 0.5 KiB  压缩率: 62.2x
  周期性采样的 dod 全为 0 的占比: 4318/4318

== 值压缩 (Gorilla XOR) ==
  原始: 33.8 KiB -> 压缩后约: 25.9 KiB  压缩率: 1.31x
  与上一点完全相同: 7 个, 有差异: 4312 个

== block 切分 (1h/块) ==
  block-0: [1759200000, 1759203590]
  block-1: [1759203600, 1759207190]
  ...(共 12 块, 覆盖 12 小时)

== 降采样 (10s -> 1h 均值, 前 5 个) ==
  48.54%, 42.34%, 17.91%, 35.2%, 51.99%

校验通过: 解码无损, 块数与降采样点数一致

五、结果解读:两种压缩的天壤之别

  • 时间戳 62.2x:固定 10s 周期使全部 4318 个二阶差分恒为 0,每个点只占约 1 bit。这解释了为什么真实监控场景下时间戳几乎『零成本』。即便采集抖动,dod 也多为小整数,用变长编码仍然极省。
  • 值只有 1.31x:实验数据每秒都在以 ±3 的幅度抖动且保留两位小数,XOR 后几乎每一位都在变,前导/尾随零极少,压缩自然失效。这并非算法不行,而是数据分布决定压缩上限:真实场景里变化缓慢或长时间恒定的指标(设备状态、电压、布尔量)XOR 压缩轻松到 10x 以上。
  • 工程推论:高基数 + 高精度 + 高频抖动的指标,除了压缩还应考虑有损降精度(如保留 1 位小数)或先聚合再落盘(降采样),这正是 TSM 把降采样做成一等公民功能的原因。

六、时序特有问题的设计取舍

  • 高基数(cardinality):series 数 = 指标名 × 标签组合。TSM 的索引按 series key 组织,把用户 ID 之类无界维度放进 tag 会让索引爆炸——InfluxDB 2.x 的 TSI 索引、3.x 改用 Parquet + 对象存储都是围绕这个问题演进。
  • 乱序与迟到数据:TSM 文件不可变,迟到数据写到 Cache 后随下一轮 compaction 归并到正确的时间块,逻辑上按 (series, ts) 排序保证查询正确。
  • 过期删除:按 block 的时间窗口整批淘汰(Retention Policy),删除老数据只是丢文件,代价 O(1),这是行式/B+Tree 做不到的。
  • 快照与连续查询:把高频原始数据定时聚合成低频结果写入新 measurement,长期保留趋势、丢弃细节,存储成本再降数量级。

七、工程应用

  • 监控体系:Telegraf 采集 -> InfluxDB 存储 -> Grafana 可视化,是中小型监控最常见的组合;大规模场景常替换为 VictoriaMetrics / Mimir(Prometheus 兼容)。
  • IoT 平台:设备影子数据按 retention policy 自动分层保留,热数据 7 天高精度、冷数据 1 年降采样。
  • 行情系统:tick 数据用 TSM 类结构按品种 + 日期切块,回测按时间窗整块顺序读。
  • InfluxDB 3.x 的转向:核心存储改为 Apache Arrow/Parquet 列式 + 对象存储,与上一篇《列式存储与向量化执行引擎》讲的列存思想直接汇合。

八、常见误区

  1. 把无界维度放进 tag:用户 ID、请求 ID、时间戳字符串做 tag 会让 series 基数指数爆炸,索引内存被打爆是 InfluxDB 生产事故的头号原因。
  2. 以为压缩率恒定:如本文实测,时间戳能压 62x 而抖动浮点只有 1.31x,评估存储成本必须基于真实数据分布做抽样测算。
  3. 忽略 retention policy:不设置过期策略,磁盘迟早被打满;时序库的默认假设就是『数据有寿命』。
  4. 用时序库做随机更新:TSM 的不可变文件 + 归并模型对单点更新极不友好,需要频繁改值的业务应回到关系库。
  5. 误解降采样是可选优化:对长期保留的场景,先聚合再落盘是成本模型的一部分,不是事后补救。

九、参考文献

  • Pelkonen, T. et al. Gorilla: A Fast, Scalable, In-Memory Time Series Database. VLDB 2015.(delta-of-delta 与 XOR 压缩的出处)
  • InfluxData 官方文档: InfluxDB TSM Storage Engine. https://docs.influxdata.com/influxdb/v2/reference/internals/storage-engine/
  • O'Neil, P. et al. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica, 1996.
  • Rabbach, M. et al. The Design of Time Series Data Management at Facebook. ICDE 2017(Gorilla 的工程演进).
  • Bader, A. et al. Survey and Comparison of Open Source Time Series Databases. BTW 2017.