可观测性存储引擎深度实战:从 Prometheus TSDB 的 Head Block、XOR 压缩到 Mimir 对象存储与查询分片的工程全解

监控系统真正的技术分水岭,从来不在仪表盘画得多漂亮,而在存储引擎能不能扛住基数爆炸。一个五千节点、每个实例暴露八千条时序的集群,理论活跃时序量是四千万条;按 Prometheus 默认 15 秒抓取间隔,每秒写入约 270 万个样本。任何"先写进时序数据库再说"的朴素想法,在这个量级下都会在几小时内把内存吃光。本文不谈怎么装 Grafana,而是把 Prometheus TSDB 与 Grafana Mimir 的存储层拆开:从一条样本进入进程开始,到它变成对象存储上一个不可变 block,再到它如何被查询路径并行捞出来。

一、数据模型:时序不是行,是一条条独立坐标轴

Prometheus 的数据模型极简:一个 series 由 metric name 加一组 label 唯一确定,series 上挂载一串 (timestamp, value) 样本。

http_requests_total{service="checkout",status="500",instance="10.2.3.4:8080"}
  → 1690000000000 1204.0
    1690000015000 1211.0

关键在于,series 是存储与索引的一等公民,样本不是。TSDB 内部会给每个 series 计算一个 64 位 fingerprint(label 集合排序后做 FNV-1a 哈希),所有内存结构——head 中的 memSeries、倒排索引中的 posting list——都以这个 ID 为键。这意味着成本模型非常清晰:

  • 磁盘成本 ≈ 样本数(样本按 chunk 压缩后每条约 1.5 字节);
  • 内存成本 ≈ 活跃 series 数(每条 head series 常驻约 3 KB 以上的 Go 对象开销,含 stripeSeries 分片锁、label 字符串、pending chunk)。

所以"加一个 label"的代价远大于"多抓几次"。一个取值 1 万种的 user_id 标签,会让 series 数直接乘以 1 万,内存从 3 GB 变成 30 TB。这是所有 Prometheus 生产事故的根源,也是后面所有工程设计的出发点。

二、写入路径:Head Block 与 WAL 的双写

新样本不落盘成文件,而是进入内存中的 Head Block,它同时服务最近约 2 小时的数据:

scrape ──▶ appender.Append(ref, lset, t, v)
             │
             ├──▶ ① 查/建 memSeries(按 fingerprint)
             ├──▶ ② 追加到当前 open chunk(内存中 XOR 编码器)
             └──▶ ③ 追加 WAL 记录(series 记录 + sample 记录)

三个动作的顺序不能反。WAL 是崩溃恢复的唯一依据:进程重启后,TSDB 会重放 WAL 重建 head。Prometheus 2.x 的 WAL 分段为 128 MB,记录格式精简到极致——series 记录带完整的 label 集,sample 记录只带 series_id + timestamp + value,因为 label 已经在前面写过了。

// 简化版:WAL 中一条 sample 记录的编码,8+8=16 字节
func encodeSample(rec []byte, ref uint64, t int64, v float64) []byte {
    rec = append(rec, byte(WALRecordSample))
    rec = appendUvarint(rec, ref)               // series 引用
    rec = appendUvarint(rec, uint64(t))         // 毫秒时间戳
    rec = append(rec, encodeFloat64(v)...)
    return rec
}

注意 WAL 是未压缩的,因此写入放大会非常明显:一个每秒 270 万样本写入的集群,WAL 每秒产生数十 MB。生产上必须把 WAL 目录放在独立的高 IOPS 盘上,否则 head 落盘阻塞会直接拖慢抓取。

三、XOR 压缩:为什么一条样本只占 1.5 字节

TSDB 的 chunk 采用源自 Gorilla(Facebook)的 XOR 编码,思想是:时序数据中相邻样本的时间戳增量稳定、浮点值高位几乎不变。

type XORChunk struct {
    buf    []byte
    t, v   int64          // 上一个样本的 t 与 v 位模式
    tDelta int64          // 上一个 delta,用于 delta-of-delta
    n      int            // 已写入样本数
}

func (c *XORChunk) Append(t int64, v float64) {
    vb := int64(math.Float64bits(v))
    if c.n == 0 {
        c.buf = appendBits(c.buf, uint64(t), 64)
        c.buf = appendBits(c.buf, uint64(vb), 64)
    } else {
        // 1) 时间戳:delta-of-delta
        d := t - c.t
        dod := d - c.tDelta
        switch {
        case dod == 0:
            c.buf = appendBits(c.buf, 0, 1)          // '0'
        case -63 <= dod && dod <= 64:
            c.buf = appendBits(c.buf, 0b10, 2)       // '10' + 7 bit
            c.buf = appendBits(c.buf, uint64(dod), 7)
        case -255 <= dod && dod <= 256:
            c.buf = appendBits(c.buf, 0b110, 3)      // '110' + 9 bit
            c.buf = appendBits(c.buf, uint64(dod), 9)
        default:
            c.buf = appendBits(c.buf, 0b1111, 4)     // '1111' + 64 bit
            c.buf = appendBits(c.buf, uint64(dod), 64)
        }
        // 2) 数值:与前值 XOR
        xor := vb ^ c.v
        if xor == 0 {
            c.buf = appendBits(c.buf, 0, 1)          // 值完全没变,1 bit 搞定
        } else {
            c.buf = appendBits(c.buf, 1, 1)
            leading := bits.LeadingZeros64(uint64(xor))
            trailing := bits.TrailingZeros64(uint64(xor))
            if leading >= 32 { leading = 31 }
            // 复用上一个 block 的窗口则只写 '10' + 有效位
            c.buf = appendBits(c.buf, 0b11, 2)
            c.buf = appendBits(c.buf, uint64(leading), 5)
            c.buf = appendBits(c.buf, uint64(len(trailing)), 6)
            c.buf = appendBits(c.buf, uint64(xor>>trailing), 64-leading-trailing)
        }
    }
    c.t, c.v, c.n = t, vb, c.n+1
}

固定间隔抓取时,delta-of-delta 恒为 0,时间戳退化成 1 个 bit;值变化不大时 XOR 落在低位,整条样本常常压到 1~2 字节。这就是为什么"每 chunk 120 个样本"能压缩到几百字节。代价是随机访问必须顺序解码:读第 100 个样本得先解前 99 个。这也是 TSDB 在查询长区间时要靠 chunk 边界裁剪的原因。

四、从内存到磁盘:Block、mmap 与索引

每 2 小时(可配),head 中被写满的 chunk 会被 Head Compaction 成只读 block 落到 data/01Hxxxx/ 目录:

01HQKZ8M3P9VXYZ/
├── chunks/000001        # 最大 512 MB 的 chunk 文件,mmap 打开
├── index                # series 标签倒排索引 + 符号表
├── meta.json            # ULID、时间范围、compaction 层级、统计
└── tombstones           # 删除标记(软删)

设计要点有三个:

  1. chunks 文件用 mmap 映射,不进 page cache 之外的堆内存。查询时通过 mmap + 偏移量 直接定位 chunk,避免把整个 block 读进用户态。这也带来一条铁律:32 位进程地址空间不够,别用。
  2. index 是倒排索引:对每个 label=value 维护一个 posting list(series ID 的有序列表,delta 编码)。查询 service="checkout" 就是取 posting list,多个 label 做交集。交集算法用 zigzag 归并,遇到最短 list 先剪枝。
  3. block 是不可变的。删除只写 tombstone,修改不存在。不可变性让缓存、复制、对象存储上传、并发查询全部变得简单——这是 Mimir 能横向扩展的基石。

五、Compaction:把 2 小时块滚成 10% 保留期

随着时间推移,block 数量爆炸会导致查询要打开成百上千个文件。TSDB 有一个后台 compactor 做层级归并:

level 0: [2h][2h][2h]...        →  level 1: [8h]
level 1: [8h][8h][8h][8h]       →  level 2: [32h] ...

规则有两条硬约束:只有相邻时间窗且同层级的 block 才能合并,且单个 block 跨度不超过保留期的 10%(默认 15 天保留 → 最大 36 小时)。这条 10% 规则保证了删除过期数据时,只需丢弃整个 block,不需要在 block 内部做删除重写。合并期间如果重叠,Prometheus 会报错拒绝启动(overlapping blocks),这是运维最常见的存储类故障之一。

六、Mimir:把单机的不可变性换成分布式弹性

Prometheus 是单机有状态服务,扩容靠拆(联邦/分片)。Mimir 的思路完全不同:保留 Prometheus 的 TSDB 代码,把它拆成微服务,用对象存储做唯一的持久化真源。

Agent/Prometheus ─(remote_write)─▶ Distributor(一致性哈希 + 复制因子 3)
                                        │
                                        ▼
                                    Ingester ── 内存中持有 2h head
                                        │ 每 2h flush
                                        ▼
                                  Object Store (S3/GCS/OSS)
                                   ├── blocks/  ← Compactor 归并去重
                                   └── bucket-index/  ← Store-Gateway 缓存
                                        ▲
              Query-Frontend ──▶ Querier ──┘
              (拆分/缓存/排队)      ├──▶ Ingester(最近数据)
                                    └──▶ Store-Gateway(历史 block,带 index 缓存)

几个真正关键的工程细节:

(1)Distributor 用带虚拟节点的一致性哈希分发 series。 按 series fingerprint 而非按租户分发,保证同一条 series 永远落到同一组 ingester,避免热点和重复。复制因子默认 3,写入 2 副本成功即返回;查询时 querier 会拿到 3 份数据做去重(按样本时间戳取其一),这正是"副本只提高可用性、不提高容量"的原因。

(2)Ingester 是唯一的有状态组件,且只在 2 小时窗口内。 它跑的就是标准 TSDB head + WAL,只是 WAL 变成多租户分区。超过 2 小时的 block 立刻上传到对象存储,随后被 Compactor 接管。这意味着 ingester 挂掉最多丢 2 小时数据——而这 2 小时由副本补齐。Zone-aware 复制会把 3 副本强制打散到 3 个可用区,单 AZ 全挂也不丢数据。

(3)查询分片(query sharding)是 Mimir 的性能倍增器。 query-frontend 会把一段 PromQL 按时间或按 series 拆成 N 个子查询:

# mimir 配置片段:查询前端分片与结果缓存
query_frontend:
  parallelize_shardable_queries: true
  results_cache:
    backend: memcached
    memcached:
      addresses: "dns+mimir-memcached:11211"
query_engine:
  max_query_into_future: 10m
  max_query_lookback: 30m

例如 sum(rate(http_requests_total[5m])) by (service) 这类可并行聚合的查询,frontend 会按 service 的哈希空间切成 16 份下发,每份由不同 querier 执行,最后在 frontend 归并。不可并行的部分(如 topk(10, ...))则退化为单分片执行——所以 PromQL 写法直接影响能否享受到分片红利。

(4)Store-Gateway 的缓存分层决定了查询延迟。 它维护对象存储 block 的 index 头、posting list、以及 chunk 的本地缓存(LRU + 磁盘)。查询 30 天区间的聚合时,99% 的时间花在"从 S3 拉 index posting list"上。生产上必须给 store-gateway 配大内存 index cache(index-cache 命中率应 > 95%),否则 S3 的 GET 请求会成为瓶颈且成本惊人。

(5)Compactor 承担两件大事:一是把多个 ingester 上传的、时间重叠的 block 做垂直合并 + 水平去重(消除副本带来的重复样本);二是应用租户的保留期与删除请求(写 tombstone)。它依赖对象存储的 bucket-index 做增量发现,避免每次全量 LIST(S3 LIST 在千万级对象下会非常慢)。

七、生产调参与踩坑清单

  • 基数治理是头等大事。 topk(20, count by (__name__)({__name__=~".+"})) 找最大指标;count(count by (label)(metric)) 看标签基数。K8s 场景下最容易失控的是 pod_name(滚动更新即变)与 histogram 的 le 桶,一个 20 桶的 histogram 单指标就能产生 20 倍 series。
  • head 内存估算:活跃 series 数 × 约 3 KB × 2(Go GC + 碎片)。四千万 series 需要 200 GB 以上内存,别指望单机扛,直接上 Mimir 水平拆 ingester。
  • 乱序样本(out-of-order):Prometheus 2.x 默认拒收比最新样本更旧的写入,remote_write 重传会产生大量 out of order sample 报错。Mimir 支持有限窗口的 OOO 处理(ingester 侧开 OOO head),但要付出额外内存;更稳的做法是在 agent 侧做好 WAL 与去重。
  • 查询保护必须配:max_query_lookback、query timeout、以及 frontend 的 max_outstanding_requests_per_tenant 队列。一个 rate(metric[30d]) 就能打垮整个 querier 池。
  • remote_write 批量化:max_samples_per_send=2000、capacity 与 max_shards 调大,把小请求合并,否则对象存储与网络都会浪费在小包上。

八、结语

可观测性存储的本质是一场用不可变性换扩展性的交易:Prometheus 用不可变 block 换来了简单可靠的单机引擎,Mimir 则把这种不可变性推到极致——对象存储成为唯一事实源,所有有状态组件退化成"缓存层",于是扩容变成纯粹的加机器。理解这条主线后,很多运维直觉会改变:你不再纠结"数据库怎么删数据",而是开始关心"block 怎么更小、posting list 怎么命中缓存、PromQL 怎么切分更并行"。这才是大规模监控体系的真正杠杆点。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部