44.17 时序数据库

一句话

时序库的核心不是“写得快”,而是数据天然按时间有序、只追加、按时间范围读——这三条让它能用“差分 + XOR”把 16 字节/点压到 2–5 字节,并用时间分区把“过期删除”变成“整块丢弃”。

时序数据的四个特征

特征 含义 对存储的要求
写多读少、只追加 每秒百万点写入,几乎不更新 顺序写、LSM/列式
按时间范围读 查询永远是 time BETWEEN 按时间分区/排序
聚合为主 avg/max/p99 按时间桶 列存 + 预聚合
有 TTL 90 天前的数据没人看 分区级删除,不是逐行删

压缩:Gorilla 的两招

招 针对 做法 压缩效果
Delta-of-Delta(DoD) 时间戳 时间间隔通常恒定,对“间隔的差”编码,绝大多数是 0 → 1 bit 16 字节 → 1–2 字节
XOR 浮点值 相邻值 XOR,前导零/尾随零很多 → 只存有效位 8 字节 → 1–4 字节
package main

import (
	"fmt"
	"math"
	"math/rand"
)

const n = 1000

func main() {
	r := rand.New(rand.NewSource(42))

	// 模拟指标:15 秒一个点,带少量抖动;值是一条正弦 + 噪声
	ts := make([]int64, n)
	vals := make([]float64, n)
	cur := int64(1_700_000_000)
	for i := 0; i < n; i++ {
		cur += 15000 + int64(r.Intn(3)-1) // 15s ± 1ms
		ts[i] = cur
		vals[i] = 50 + 10*math.Sin(float64(i)/50) + r.NormFloat64()*0.01 // 缓变 + 小噪声
	}

	// ① 时间戳:delta-of-delta,用 zigzag varint 表示
	var prevDelta int64
	tsBytes := 0
	for i := 0; i < n; i++ {
		var delta int64
		if i == 0 {
			delta = ts[i]
		} else {
			delta = ts[i] - ts[i-1]
		}
		dod := delta - prevDelta
		prevDelta = delta
		tsBytes += varintLen(zigzag(dod))
	}

	// ② 值:与上一个值 XOR,按“有效位”估算所需字节
	compressVals := func(vs []float64) int {
		total := 8 // 第一个点原样存
		for i := 1; i < len(vs); i++ {
			x := math.Float64bits(vs[i]) ^ math.Float64bits(vs[i-1])
			if x == 0 {
				total++ // 与上一个值相同:1 字节标记
				continue
			}
			meaningful := 64 - leadingZeros(x) - trailingZeros(x)
			total += 1 + (meaningful+7)/8 // 1 字节头 + 有效位
		}
		return total
	}
	valBytes := compressVals(vals)

	// 对照:整数型计数器(很多指标是计数值,低位全 0),压缩率高得多
	counter := make([]float64, n)
	for i := range counter {
		counter[i] = float64(1000 + i/10) // 每 10 个点加 1,期间完全重复
	}
	counterBytes := compressVals(counter)

	raw := n * 16 // 原始:8 字节时间戳 + 8 字节值
	comp := tsBytes + valBytes
	fmt.Printf("点数 %d,原始 %d 字节(16 字节/点)\n", n, raw)
	fmt.Printf("时间戳压缩后 %d 字节(%.2f 字节/点)\n", tsBytes, float64(tsBytes)/n)
	fmt.Printf("值压缩后     %d 字节(%.2f 字节/点)—— 噪声浮点\n", valBytes, float64(valBytes)/n)
	fmt.Printf("对照:整数计数器 %d 字节(%.2f 字节/点)\n", counterBytes, float64(counterBytes)/n)
	fmt.Printf("合计(噪声浮点)%d 字节(%.2f 字节/点),压缩比 %.1f×\n",
		comp, float64(comp)/n, float64(raw)/float64(comp))
	fmt.Printf("合计(计数器)  %d 字节(%.2f 字节/点),压缩比 %.1f×\n",
		tsBytes+counterBytes, float64(tsBytes+counterBytes)/n,
		float64(raw)/float64(tsBytes+counterBytes))

	// ③ 与“直接存 float64 + 每 15 秒固定时间戳”对比
	fmt.Printf("\n若时间戳完全等间隔(不存时间戳):只剩值部分 %.2f 字节/点(噪声浮点)\n", float64(valBytes)/n)
}

func zigzag(v int64) uint64 { return uint64(v<<1) ^ uint64(v>>63) }

func varintLen(v uint64) int {
	n := 1
	for v >= 0x80 {
		v >>= 7
		n++
	}
	return n
}

func leadingZeros(x uint64) int {
	if x == 0 {
		return 64
	}
	n := 0
	for x&(1<<63) == 0 {
		x <<= 1
		n++
	}
	return n
}

func trailingZeros(x uint64) int {
	if x == 0 {
		return 64
	}
	n := 0
	for x&1 == 0 {
		x >>= 1
		n++
	}
	return n
}

本机实测(macOS,Apple M1,Go 1.27):

点数 1000,原始 16000 字节(16 字节/点)
时间戳压缩后 1008 字节(1.01 字节/点)
值压缩后     6995 字节(7.00 字节/点)—— 噪声浮点
对照:整数计数器 1107 字节(1.11 字节/点)
合计(噪声浮点)8003 字节(8.00 字节/点),压缩比 2.0×
合计(计数器)  2115 字节(2.12 字节/点),压缩比 7.6×

若时间戳完全等间隔(不存时间戳):只剩值部分 7.00 字节/点(噪声浮点)

读数:

项 原始 压缩后 压缩比
时间戳(15s 间隔 + 抖动) 8 字节/点 1.01 字节/点 8×
值:噪声浮点(正弦 + 小噪声) 8 字节/点 7.00 字节/点 1.1×
值:整数计数器(每 10 点 +1) 8 字节/点 1.11 字节/点 7×
合计(噪声浮点) 16 字节/点 8.00 字节/点 2.0×
合计(计数器) 16 字节/点 2.12 字节/点 7.6×

🔑 两个读数要分开看:时间戳几乎必压(间隔稳定 → DoD → 1 字节/点,稳定 8 倍);值能不能压,取决于“低位是否随机”——整数计数器、枚举状态、完全重复的采样点能压到 1–2 字节,而带随机噪声的浮点(低 52 位几乎全随机)XOR 后仍有 50 多位有效,几乎压不动。所以“时序库能压 10 倍”是个平均数,不是保证;真正的红利来自“按时间顺序写 + 值形态稳定”。

存储设计:分区键决定一切

设计 做法 后果
时间分区 按天/小时切块,块内按 series 排序 过期 = 删整块;范围查 = 只读相关块
Series 标识 measurement + tags 组合哈希 标签基数爆炸会拖垮索引(见反模式)
预聚合 写入时同时写 1m/5m/1h 的 rollup 查询快,但存储 ×3
降采样 老数据只留小时级 存储可控,精度下降

⚠️ 时序库最常见的性能事故是高基数标签:给 user_id、request_id 打标签,series 数从几千涨到几千万,索引与内存先崩。标签只放“低基数、常用于过滤”的维度(region、instance、status)。

产品怎么选

产品 定位 适合
Prometheus 拉模式 + 本地 TSDB 指标监控(K8s 生态)
InfluxDB 专用 TSDB,类 SQL 查询 业务指标、IoT
TimescaleDB PostgreSQL 扩展, hypertable 已用 PG、需要 SQL join
ClickHouse 列存 OLAP 超大规模、复杂聚合

反模式

反模式 后果
用 MySQL 存每秒百万点 写入打爆、索引膨胀、查询扫全表
高基数标签(user_id/request_id) series 爆炸,内存与索引崩
逐行删除过期数据 删除比写入还慢;应按分区丢弃
乱序写入 压缩率下降、查询要合并多个块
不预聚合,查询时扫原始点 大时间范围查询超时
把时序库当 KV 用(按随机 key 读) 完全用不上时间有序的优势

自检

Q1 为什么时间戳用“差分再差分”,而不是直接存 int64?

▸ 答案:因为时间戳的一阶差分(间隔)几乎是常数(15 秒一个点),二阶差分(间隔的变化)绝大多数是 0。0 用 1 bit 就能表示,而直接存 int64 是 64 bit。即使有抖动,DoD 后也只是一两个小整数,用 varint 1 字节即可。这就是“利用数据的规律性”而不是“通用压缩算法”的威力。

Q2 为什么时序库的过期删除要按“分区”而不是按“行”?

▸ 答案:因为按行删除要定位、标记、回收,成本与行数成正比,而且会产生大量碎片;按时间分区后,过期就是“删掉整个分区目录/文件”,成本与数据量无关(O(1) 次文件操作)。这也是为什么时序库都要求“按时间写入”,让同一时间段的数据落在同一个分区里。

Q3 一个 IoT 平台给每个设备打 device_id 标签(100 万台设备),会发生什么?

▸ 答案:series 数会达到百万级(甚至乘以指标数),TSDB 要为每个 series 维护索引与内存中的时间窗口,内存先被打满,查询时的 series 匹配也会变慢。正确做法是:device_id 不进标签(或只保留少量维度如 device_type、region),设备维度的明细放到日志/宽表里,指标只做聚合;确需按设备查时,用“设备 ID 作为表名/分区键”的模型,而不是标签。

Q4 为什么 “高基数标签”(如每台设备一个 device_id)会打爆时序库?

▸ 答案:因为标签会被建成索引/维度:基数越高,索引条目越多(百万级 device_id → 百万级序列),内存与元数据开销暴涨,查询规划也变慢。常见对策是 “把高基数字段降为普通列、或做分桶/哈希、或按设备分表”,而不是无脑打标签。

Q5 降采样(rollup)与保留策略怎么配合?

▸ 答案:原始数据短期保留(如 7 天),降采样数据长期保留(如 1 分钟粒度 1 年):查询按时间范围选粒度,既控存储又保留趋势。关键是 “降采样任务要幂等 + 可重算”,否则补数据后会与原始数据对不上。

关联