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 年):查询按时间范围选粒度,既控存储又保留趋势。关键是 “降采样任务要幂等 + 可重算”,否则补数据后会与原始数据对不上。