2015年,Facebook的监控团队面临一个看似无解的问题:每秒产生700万个数据点,如果按照传统方式存储,仅内存就需要16TB。这个数字意味着任何规模的集群都无法在经济上承受。但他们最终找到了答案——通过一种称为Gorilla的压缩算法,将每个数据点从16字节压缩到平均1.37字节,压缩比达到12倍。
这不是魔法,而是对时间序列数据本质特性的深刻洞察。
时间序列数据的特殊性
时序数据与传统业务数据有着根本性的不同。一条订单记录可能包含几十个字段,每个字段都可能在任意时间被更新;而一个CPU使用率指标,只有时间戳和数值两个维度,且一旦写入就几乎不会被修改。
这种看似简单的数据模型,在规模面前却变得极其复杂。假设你有200台服务器,每台服务器每10秒采集100个指标,一天就会产生1.728亿个数据点。如果是物联网场景,设备数量可能是百万级别。
更关键的是,时序数据的写入模式非常特殊:新数据持续追加,历史数据很少被修改;查询通常集中在最近的时间窗口;数据往往按照固定的时间间隔采集。这些特性为专门的存储优化提供了空间。
从16字节到1.37字节:Gorilla压缩算法
Facebook在VLDB 2015发表的Gorilla论文,至今仍是时序数据库压缩技术的基石。其核心思想非常简单:利用时序数据的局部性,只存储变化量而非完整值。
时间戳的增量之增量编码
假设有一个每60秒采集一次的指标,其时间戳序列可能是:1609459200, 1609459260, 1609459320, 1609459380…
直接存储每个时间戳需要8字节(64位)。但如果只存储相邻时间戳的差值(delta),结果变成:60, 60, 60, 60…这已经大大简化了。
Gorilla更进一步,计算"差值的差值"(delta-of-delta):第一个差值是60,第二个差值与第一个相同,所以delta-of-delta是0。对于稳定间隔采集的数据,这个值几乎总是0。
原始时间戳: 1609459200, 1609459260, 1609459320, 1609459380
Delta: -, 60, 60, 60
Delta-of-delta: -, -, 0, 0
编码规则非常精巧:
- 如果delta-of-delta为0,只存储1位二进制"0"
- 如果在[-63, 64]范围内,存储"10"前缀 + 7位值(共9位)
- 如果在[-255, 256]范围内,存储"110"前缀 + 9位值(共12位)
- 依此类推,最大使用36位
Facebook的统计数据表明,约96%的时间戳可以用单比特存储。原本64位的时间戳,压缩后平均只需1-2位。
浮点数的XOR压缩
浮点数的压缩更具挑战性。传统方法如差分编码对浮点数效果不佳,因为0.82和0.98之间的差值0.16在IEEE 754表示下仍然是完整的浮点数。
Gorilla采用的是XOR(异或)方法。将相邻的两个浮点数进行按位异或,如果两个数值接近,结果中连续的0会很多:
值1: 0x4028000000000000 (12.0)
值2: 0x4038000000000000 (24.0)
XOR: 0x0010000000000000
这个XOR结果只有一位有效位(第12位的1),其余全是0。Gorilla的编码方案会记录前导零数量、有效位长度和有效位本身。
更进一步,如果当前XOR结果与前一个XOR结果有相同的前导零和后导零数量,则只需存储有效位本身,省去重复的长度信息。
实测数据显示:
- 约51%的浮点值压缩到1位(与前值完全相同)
- 约30%压缩到26.6位
- 其余约19%压缩到36.9位
综合时间戳和值的压缩,最终达到平均1.37字节/数据点的效果。
存储引擎的抉择:LSM-Tree还是B-Tree?
压缩解决了存储空间问题,但如何高效地写入和查询海量数据?这涉及存储引擎的核心设计。
B-Tree:读优化的经典方案
B-Tree是传统关系数据库的标准选择。它的特点是:
- 数据在磁盘上按顺序组织,支持高效的点查询和范围扫描
- 每次写入需要找到对应位置并原地更新
- 对于随机写入,会产生大量磁盘寻道
时序数据的写入模式看似是追加写入,实际上是"分散追加"——同一时刻可能有成千上万个不同的时间序列同时写入。对B-Tree来说,这相当于大量的随机写入。
InfluxDB早期版本(0.9.0-0.9.2)曾使用基于B+Tree的BoltDB,结果发现当数据量超过几GB后,写入延迟会急剧上升,IOPS成为瓶颈。
LSM-Tree:为写入而生
LSM-Tree(Log-Structured Merge Tree)的设计哲学完全不同:将随机写入转化为顺序写入。
其核心结构包括:
- MemTable:内存中的有序结构,接收所有写入
- WAL(Write-Ahead Log):保障数据持久性的预写日志
- SSTable:磁盘上的有序只读文件,由MemTable刷盘产生
- Compaction:后台合并过程,将多个小SSTable合并为大文件
写入时,数据先写入WAL(顺序写),然后写入MemTable。当MemTable达到阈值后,刷盘形成SSTable。这个过程完全避免了随机写入,使得LSM-Tree在写密集型场景下表现出色。
但LSM-Tree也有代价:
- 读放大:查询可能需要检查多个层级的SSTable
- 写放大:Compaction过程会重复写入数据
- 空间放大:合并前可能存在多份数据副本
InfluxDB的TSM:面向时序的LSM变体
InfluxDB最终选择了自研的TSM(Time-Structured Merge Tree),它在LSM-Tree基础上针对时序场景做了优化:
按时间分片:数据按时间范围分成多个Shard,每个Shard是一个独立的存储引擎实例。这样可以按时间整块删除旧数据,避免了传统LSM-Tree中删除操作需要写入墓碑标记的开销。
压缩感知的存储格式:TSM文件内部使用Gorilla算法压缩数据,并将时间戳和值分开存储。时间戳使用delta-of-delta + Simple8b + RLE的组合编码,值使用XOR压缩。
多级Compaction策略:
- 快照压缩:将内存Cache刷入TSM文件
- 层级压缩:Level 1到Level 4的渐进合并
- 索引优化:将同一时间序列的数据集中到一个文件
- 全量压缩:对冷数据执行最终优化
根据InfluxDB官方数据,相比之前的BoltDB方案,TSM实现了45倍的磁盘空间节省,同时保持了更高的写入吞吐量。
Prometheus的TSDB:简洁之美
Prometheus的存储设计走了另一条路——它没有追求通用性,而是专注于监控场景的特定需求。
两小时数据块
Prometheus将数据按2小时窗口组织成独立的数据块(Block)。每个块包含:
chunks/:压缩后的原始数据index:标签到时间序列的倒排索引meta.json:元数据
这种设计有几个优势:
- 旧数据块只读,不会被修改,便于备份和传输
- 查询时可以跳过不相关的数据块
- 按时间删除数据只需移除对应块
写入路径
新数据首先写入内存中的Head块,同时写入WAL保障持久性。当Head块满(约2小时数据)后,刷盘成为只读块。WAL会定期截断,只保留最近的写入记录。
Prometheus声称每个数据点平均仅需1-2字节存储,这与Gorilla论文的数据一致——Prometheus确实采用了相同的压缩算法。
倒排索引:标签查询的加速器
监控数据的查询通常涉及标签过滤,如http_requests_total{status="500", method="GET"}。Prometheus使用倒排索引来加速这类查询:
标签 status="500" → [series_id_1, series_id_5, series_id_8, ...]
标签 method="GET" → [series_id_1, series_id_2, series_id_5, ...]
查询时,对多个标签的posting list取交集即可快速定位目标时间序列。这种设计在标签基数适中的情况下非常高效。
高基数问题:时序数据库的阿喀琉斯之踵
当标签的可能取值数量庞大时(如用户ID、请求ID),会出现"高基数"问题。这是时序数据库最头疼的挑战之一。
为什么高基数会导致性能崩溃?
每个唯一的时间序列都需要独立的索引条目。如果有一个标签包含100万个不同值,而系统有10个这样的标签,理论上可能产生10^6个时间序列。
内存中的索引结构会急剧膨胀。以InfluxDB为例,当时间序列数量从1万增长到1亿时:
- TSM索引从400MB增长到80GB+
- Series元数据从5MB增长到50GB
- 查询缓存基本失效
更严重的是,每个数据点写入时都需要查找或创建对应的时间序列,这会使写入吞吐量从每秒50万点暴跌到1万点以下。
不同数据库的应对策略
InfluxDB TSI:将索引从内存移到磁盘,使用多级索引结构,可支持10-100倍的时间序列数量,但代价是查询需要更多磁盘IO。
VictoriaMetrics:采用改进的倒排索引,专门优化高基数场景。测试显示在千万级时间序列下,查询性能仍能保持稳定。
TimescaleDB:利用PostgreSQL成熟的索引技术,通过分区裁剪减少需要扫描的数据量。对于超高基数,建议在应用层进行数据建模优化。
设计层面的解决方案
最有效的方法是从数据建模阶段就控制基数:
-- 不推荐:直接存储用户ID
metrics{user_id="user_12345"} 100
-- 推荐:存储用户分桶或群体
metrics{user_cohort="premium", region="us-west"} 100
分层保留策略也很常见:最近7天保留完整基数,之后自动降采样并减少维度。这样既保证了实时分析能力,又控制了历史数据的复杂度。
压缩算法的演进:从Gorilla到Chimp
Gorilla算法在2015年是一个突破,但学术界和工业界并未停止探索。2022年发表的Chimp算法在Gorilla基础上做了进一步优化。
Chimp的核心改进是更智能的XOR值表示。它观察到在真实数据集中,XOR结果的前导零和有效位分布存在明显的模式。通过更精细的编码策略,Chimp在保持压缩/解压速度的同时,实现了比Gorilla更好的压缩比。
数据集 Gorilla (bits/value) Chimp (bits/value)
DevOps 32.51 28.85
IoT 31.54 27.61
Financial 35.62 32.18
不过,对于大多数工程场景,Gorilla仍然是一个足够好的选择——它足够简单,实现成本低,且性能已经能满足绝大多数监控和分析需求。
查询优化:从原始数据到聚合结果
时序数据库的查询模式通常涉及大范围时间窗口上的聚合计算。直接在压缩数据上执行聚合是一种重要的优化手段。
向量化执行
列式存储天然支持向量化执行。当查询需要计算过去24小时的平均CPU使用率时,可以逐块解码数据并累加,而无需将所有数据加载到内存。
InfluxDB 3.0基于Apache Arrow和DataFusion构建,充分利用了向量化执行的优势。对于典型的聚合查询,性能提升可达10倍以上。
预聚合与连续查询
对于固定的聚合需求,预计算是最高效的方式。TimescaleDB的Continuous Aggregates允许用户定义聚合规则,系统会在后台自动维护聚合结果:
CREATE MATERIALIZED VIEW cpu_hourly
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', timestamp) AS bucket,
host_id,
avg(cpu_usage) AS avg_cpu,
max(cpu_usage) AS max_cpu
FROM cpu_metrics
GROUP BY bucket, host_id;
查询时可以直接读取预聚合结果,响应时间从秒级降到毫秒级。
数据生命周期管理
时序数据的一个显著特点是"价值随时间衰减"。最近的数据需要高精度保留,历史数据则可以降采样后长期保存。
分层存储
典型的时间分层策略:
- 热数据层(0-7天):原始精度,SSD存储
- 温数据层(7-30天):1分钟聚合,普通磁盘
- 冷数据层(30天以上):1小时聚合,对象存储
Prometheus本身不支持长期存储,但通过Remote Write接口可以将数据写入Thanos、Cortex或VictoriaMetrics等长期存储方案。
TimescaleDB通过自动压缩策略实现类似效果:旧数据块自动切换到列式存储格式,压缩比可达90%以上,同时查询性能保持稳定。
数据删除的特殊挑战
时序数据通常有明确的保留期限。传统数据库中,DELETE操作需要定位记录并标记删除,这对海量数据来说极其低效。
按时间分片的设计使得删除变得简单:整个Shard或Block可以直接删除,无需逐条处理。这是时序数据库设计中的关键洞察之一。
不同选型的权衡
选择时序数据库时,需要考虑几个关键因素:
写入量级:如果每秒写入超过100万数据点,需要考虑InfluxDB或VictoriaMetrics这样专门优化的方案。
查询模式:如果查询涉及复杂的标签过滤和高基数维度,VictoriaMetrics的索引设计更有优势。
运维复杂度:如果团队已有PostgreSQL经验,TimescaleDB的学习成本最低。
生态系统:Prometheus与Kubernetes生态深度集成,是云原生监控的事实标准。
没有完美的解决方案,只有最适合特定场景的权衡。理解这些技术背后的原理,才能在架构决策中做出正确选择。
时序数据库的十五年演进,核心始终围绕着同一个问题:如何在资源有限的情况下,高效地存储、查询和理解随时间变化的海量数据。从Gorilla的压缩突破,到LSM-Tree的写入优化,再到高基数问题的各种应对策略,每一步都在解决一个具体的工程挑战。
这个领域仍在快速演进。列式存储、向量化执行、近似查询处理等新技术正在被引入。硬件层面的持久化内存和GPU加速也预示着更多可能性。但无论如何发展,对数据本质特性的深刻理解,始终是优化设计的起点。
参考文献
- Pelkonen T, et al. “Gorilla: A Fast, Scalable, In-Memory Time Series Database.” VLDB Endowment, 2015.
- Liakos P, et al. “Chimp: Efficient Lossless Floating Point Compression for Time Series Databases.” VLDB Endowment, 2022.
- InfluxData. “InfluxDB Storage Engine Documentation.” docs.influxdata.com
- Prometheus Authors. “Prometheus Storage Documentation.” prometheus.io
- Timescale. “Time-Series Compression Algorithms, Explained.” tigerdata.com/blog
- VictoriaMetrics. “The Rise of Open Source Time Series Databases.” victoriametrics.com/blog
- Last9. “Performance Impact of High Cardinality in Time-Series DBs.” last9.io/blog
- O’Neil P, et al. “The Log-Structured Merge-Tree (LSM-Tree).” Acta Informatica, 1996.