4.1 写入路径与索引设计


4.1 写入路径与索引设计

本节摘要:现代 TSDB 写入分协议层、内存层、持久层三重解耦:Line Protocol O(n) 解析 → MemTable 按 series 归位 → WAL 追加 → 异步 Flush 落盘。索引侧,标签倒排定位 series,时间 block 内顺序扫描。若每次写入同步 fsync,P99 写入延迟可轻易突破 100ms——异步刷盘是默认工程选择。

读前必看

阅读完本节,你应当能够:

  1. 描述从 ingest 到 block 落盘的三层路径
  2. 区分 HTTP 204 ack 与数据 durability 的关系
  3. 对比 Prometheus、Influx、QuestDB 的写入路径差异
  4. 解释高基数如何撑爆倒排索引

一、问题与直觉

关系型数据库每次 INSERT 可能触发 B+ 树页分裂——在 60M points/s 级 IoT 场景下不可接受。TSDB 把写请求转化为顺序 append:WAL 与 SSD 顺序带宽对齐,MemTable 按时间归位,Compaction 后台合并小 block。2023 年 CNCF《Time-Series Databases Landscape》称:超 50 万点/秒且 P99 查询 <200ms 的生产场景,原生架构 CPU/GB/Query 效率平均高出混合方案 3.7 倍——差距来自是否尊重「写入递增、查询近端」规律。

为什么「顺序写」这么重要?机械盘时代磁盘随机写与顺序写带宽可差百倍;SSD 时代差距缩小,但块擦写与写放大仍在。更重要的是,顺序写天然适合批量合并:MemTable 攒一批同 series 的样本,一次落盘就能生成一个紧凑的列存块,后续查询与压缩都受益。这就是 TSDB 把「INSERT 语义」重构成「append 语义」的根本动机。

二、核心原理

层级 职责 Prometheus InfluxDB QuestDB
协议 解析与批处理 Remote Write protobuf Line Protocol ILP + PG wire
内存 series 归位 head block 哈希表 shard MemTable 列式 mem table
持久 顺序落盘 WAL + snapshot WAL + TSM WAL + mmap 分区

三重解耦

协议层:Influx 一行 cpu,host=web01 usage=0.73 1682753400000000000 仅字符串切分;客户端应 1000 点/批 发送——RTT 成本约为单点的 1/1000。

解析一行 line protocol 的大致过程:

# 以 Influx line protocol 为例的解析示意 line = "cpu,host=web-01,region=cn usage=0.73,idle=0.27 1717023456789000000" measurement, rest = line.split(",", 1) # measurement = "cpu" tag_part, field_part, ts = rest.split(" ", 2) # 拆分三个部分 tags = dict(kv.split("=") for kv in tag_part.split(",")) fields = dict(kv.split("=") for kv in field_part.split(","))

注意这里没有正则、没有数据库查找,全是线性字符串处理,因此单行解析是 O(长度) 的。真正的开销在协议层之后:为每个新 series 在内存里登记 identity、把样本插入到该 series 的样本数组。

内存层:Prometheus 在内存维护 (metric, labels) → []sample;Influx shard 内按 series 排序;QuestDB 列式缓冲后批量 commit。

持久层:用户收到 204/200 仅表示进 WAL 缓冲;Flush Daemon 按内存水位(80%)、WAL 大小(128MB)或时间窗(10s)触发 fsync——durability 与 latency 解绑

一条数据在 WAL 中的生命周期大致四个阶段:追加进 WAL 缓冲(返回 204)→ Flush Daemon 触发 fsync(落盘保证)→ 内存块刷成 TSM/block 文件(数据进主存储)→ 旧 WAL 段回收。其中只有第一步是客户端可感知的,其余都在后台异步完成。

索引:标签倒排 + 时间 block

查询 cpu{host="a"}[1h] 的路径:

  1. 标签倒排host=a → series ID 集合
  2. 时间索引:每个 series 的 block 按时间排序,跳过 1h 外文件

倒排索引的定位过程可以简化为两步:

倒排表(简化示意) host=a → [series_1, series_3, series_5] region=cn → [series_1, series_2, series_5] service=api → [series_3, series_5] 查询 host=a AND service=api → 交集 [series_3, series_5]

高基数:标签组合数 = series 数。倒排 posting list 与内存索引随 series 线性涨——InfluxData 2023 报告:基数超 50 万 时 P99 查询延迟平均升 3.7 倍

04-04-fig01-9

04-04-fig01-9

产品 写入强项 索引特点
Prometheus 本地 head 极低延迟 内存倒排 + posting
InfluxDB shard 水平扩展 TSI 倒排 + TSM 时间文件
TimescaleDB SQL COPY 批量 B-tree/BRIN on chunk
QuestDB 百万行/s ingest symbol 列 + 时间分区

三、工程实践要点

批量写入:Telegraf metric_batch_size=1000;Prometheus Remote Write 默认批处理。

Cardinality 治理metric_relabel_configs drop 高基数 label;勿把 pod_uidtrace_id 作 label。

fsync 策略:金融闭环控制若要求 RPO=0,需显式配置同步刷盘——接受吞吐下降。

写入健康度的自查顺序

当写入开始变慢,按下面顺序排查,比直接调参数更有效:先看 WAL 队列积压(write_queue_length),积压说明下游消费慢;再看 fsync 延迟(wal_fsync_duration_seconds),偏高说明磁盘 IOPS 到顶;然后看是否新 series 剧增(head_series),突增说明基数问题而非容量问题;最后才考虑扩 shard。这个顺序把「容量」与「基数」两类根因区分开,避免为基数问题错误地采购硬件。

补充一个常见的性能误判:很多人把「写入变慢」直接归因于磁盘,实际上在高基数场景下,写入路径里最贵的一步往往是「为新 series 登记 identity 并更新倒排索引」——它在内存里做,不碰磁盘。所以当 series 数翻倍而磁盘 IO 正常时,问题几乎一定在内存索引侧。判断技巧是看两条曲线:head_series 与写入延迟是否同步上涨。同步涨,先治基数;不同步,再查磁盘与 WAL。

⚠️ 常见坑:每次写入 sync WAL——NVMe 上 fsync 仍可能数 ms,P99 写入轻松超 100ms。

💡 关键直觉:索引跳转次数主导 P99——IoT 场景 92.7% 查询延迟在索引定位阶段(TSDB Benchmark Consortium 2024)。

自测题

  1. 客户端收到 204 时,数据到底在哪里?离「落盘」还差几步?
  2. 倒排索引与时间 block 分别在查询中承担什么角色?
  3. 为什么基数翻倍会让内存与查询延迟同时恶化?

本节速览

  • 三重解耦:协议 / 内存 / 持久
  • WAL ack 先于 fsync 落盘
  • 倒排 + 时间 block 定位 series
  • 高基数 是索引与内存第一杀手
  • 原生架构 在高写入场景资源效率显著更高

下一节讲 retention 与冷热分层如何控制长期成本。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U