时序数据库:InfluxDB 存储引擎设计
监控指标、传感器数据、行情报价这类时序负载有非常鲜明的个性:写入几乎是纯追加且按时间有序、写多读少、查询几乎都带时间范围、旧数据几乎不更新但会整批过期。通用 B+Tree 引擎在这种负载下既浪费随机写性能,又浪费存储空间。InfluxDB 的 TSM(Time-Structured Merge Tree)引擎为此而生:用 LSM 架构承接高吞吐追加写,再靠时序专用压缩算法把数据压到原体积的几十分之一。本文拆解 TSM 的整体架构与两大压缩算法,并用一段零依赖 Python 代码真实跑出压缩率。
一、TSM 引擎的整体架构
TSM 本质是 LSM-Tree 的时序特化版,写路径分三段:
- WAL:写先进预写日志,保证崩溃可恢复;WAL 段文件按大小切分,缓存刷盘后对应段即可删除。
- Cache:内存中的排序结构(按 series key + 时间戳组织),承接实时写入并直接参与查询,保证新数据立即可读;达到阈值(默认约 25MB)后快照刷盘。
- 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 列式 + 对象存储,与上一篇《列式存储与向量化执行引擎》讲的列存思想直接汇合。
八、常见误区
- 把无界维度放进 tag:用户 ID、请求 ID、时间戳字符串做 tag 会让 series 基数指数爆炸,索引内存被打爆是 InfluxDB 生产事故的头号原因。
- 以为压缩率恒定:如本文实测,时间戳能压 62x 而抖动浮点只有 1.31x,评估存储成本必须基于真实数据分布做抽样测算。
- 忽略 retention policy:不设置过期策略,磁盘迟早被打满;时序库的默认假设就是『数据有寿命』。
- 用时序库做随机更新:TSM 的不可变文件 + 归并模型对单点更新极不友好,需要频繁改值的业务应回到关系库。
- 误解降采样是可选优化:对长期保留的场景,先聚合再落盘是成本模型的一部分,不是事后补救。
九、参考文献
- 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.