7AA7 博客

时序数据库入门:写入模型与降采样

逻辑熵 Logic UID:93
2026-10-12 16:44
39 0 0

监控系统每秒采集几十万个指标点,把这些点塞进关系库里,一个月后表会撑到无法维护。时序数据有自己的形状:写多、几乎不更新、按时间追加、查询几乎总是时间范围加几个维度过滤。针对这种形状专门设计的存储引擎,与通用行存的取舍差异很大。

时序数据的四个特征

写多且几乎不更新。数据点是设备或探针在同一时刻的状态快照,写入之后不会再被修改。偶发的更正以新数据点的形式出现。

按时间追加。新数据的时间戳总是接近当前时刻,写入集中在”最近”,形成强局部性。

查询是时间范围加维度过滤。”过去 24 小时,某机房、某服务的 CPU 使用率”,几乎不会有人查询”某个具体数值出现在什么时候”。点查询非常罕见。

数据随时间衰减。三个月前的数据很少被访问,一年前的基本只在年度报表里出现。这一特征决定了保留策略和分层存储是可行的。

为什么顺序写重要

传统 B+Tree 的写入必须落到叶子节点的正确位置。当写入 key 随机时,每次写入都可能命中不同的页面,产生随机 IO;页面写满后还要分裂,进一步放大写入量。

时序数据的写入天然近似单调(时间戳递增),但这不意味着可以简单地用 B+Tree:多个设备并发上报时,同一个文件里的位置会被不同设备交错,实际写入模式仍然是按设备而非按时间聚合的。

LSM-Tree(Log-Structured Merge Tree)的做法是把随机写转换成顺序写:所有写入先追加到一个有序的内存结构里,攒够批次后整体顺序落盘。

写入 -> WAL(顺序追加,用于崩溃恢复)
     -> MemTable(内存中的有序结构,通常是跳表或平衡树)
           │  达到阈值后冻结,异步刷写
           v
     SSTable / Segment(磁盘上不可变的有序文件)
           │  后台周期性合并
           v
     更大层的 SSTable

因为 SSTable 是不可变的,写路径上不存在”定位-修改-写回”的过程,只有一个追加动作。这对机械盘尤其重要——顺序写的 IOPS 可以比随机写高一到两个数量级;即使是 SSD,顺序写也能显著减少写放大与垃圾回收压力。

代价出现在读路径上:

  • 读一个 key 需要检查 MemTable,再逐级检查多个 SSTable。用 bloom filter 快速排除不包含该 key 的文件可以把这个过程压到接近常数次 IO。
  • 同一个 key 的多个版本分布在不同层级,需要合并取最新。
  • 后台合并(compaction)消耗 IO 与 CPU。这是 LSM 引擎最常被诟病的地方,compaction 抢占前台资源会造成写入抖动。按层合并(leveled)与按大小分层(tiered)两类策略的差异主要体现在这里:前者读放大更小,后者写放大更小。

数据模型:标签与字段

时序数据普遍采用”measurement + tags + fields + timestamp”的模型:

cpu_usage,host=web01,region=cn-north,dc=bj  usage_user=23.4,usage_sys=5.1  1719849600000000000
│         │                                 │                              │
│         └─ tag set(索引、维度、可被过滤分组)  └─ field set(指标值)        └─ 时间戳(纳秒)
└─ measurement name

tag 是被索引的元数据,通常是低基数枚举值:主机名、机房、实例 ID、服务名。它们参与构建倒排索引,可以被 WHERE、GROUP BY 高效使用。

field 是不被索引的数值:CPU 使用率、内存占用、请求数。它们就是被聚合的对象。

写入示例:

-- InfluxDB 风格的行协议
INSERT cpu,host=web01,region=cn-north usage=23.4,load=1.2 1719849600000000
-- TimescaleDB 的 hypertable,表结构是普通 SQL 表
CREATE TABLE metrics (
  ts        TIMESTAMPTZ NOT NULL,
  host      TEXT        NOT NULL,
  cpu       DOUBLE PRECISION,
  memory    DOUBLE PRECISION
);
SELECT create_hypertable('metrics', 'ts', chunk_time_interval => INTERVAL '1 day');

两者的区别在于:前者是专为时序设计的存储格式,后者是在通用行存之上加了按时间自动分区的 hypertable 抽象。后者保留了 SQL 的完整能力,前者在写入吞吐和存储压缩率上通常更有优势。

高基数问题

假设一个集群有两万台主机,每台主机上有 200 个容器,每个容器上报 30 个指标。如果错误地把 container_id 和指标的详细 variants 都作为 tag:

series 数量 ≈ 20000 × 200 × 30 = 1.2 亿条时间序列

每条 series 都要在倒排索引里维护一份条目,索引会膨胀到远超数据本身的规模。写入时每个数据点都要更新索引,查询时跨高基数维度的过滤会命中海量 series 条目。

判断标准:tag 应该是”用来过滤和分组的维度”,值域应该有限且相对稳定。以下这些都属高基数:请求 ID、用户 ID、Trace ID、带随机后缀的容器名、精确到毫秒的时间戳(不应额外再做 tag)。这些应该放到日志或链路追踪系统里,而不是时序库的 tag。

排查高基数:

-- InfluxDB 风格的 schema 探查
SHOW TAG VALUES FROM cpu WITH KEY = "host";
SHOW SERIES CARDINALITY ON mydb;

-- TimescaleDB 里可以直接用 SQL 数
SELECT COUNT(DISTINCT host) FROM metrics;

时间分区与保留策略

时序库按时间把数据切成一块一块(chunk / shard),每块覆盖固定的时间跨度。这带来几个直接好处:

  • 过期删除变成删除整个块,而不是逐行 DELETE。释放一连串连续的空间,几乎瞬间完成。
  • 查询裁剪:查询带了时间范围时,引擎可以先算出涉及哪几个块,直接跳过其他块,可能只读到很小的比例。
  • 冷热分层:老块可以移到廉价存储上,或直接压缩归档。
-- InfluxDB 保留策略
CREATE RETENTION POLICY "30d" ON "monitoring" DURATION 30d REPLICATION 1 DEFAULT;
ALTER RETENTION POLICY "30d" ON "monitoring" DURATION 90d;
-- TimescaleDB 自动删除老 chunk
SELECT add_retention_policy('metrics', INTERVAL '90 days');

-- 压缩老 chunk(列存 + 字典编码)
ALTER TABLE metrics SET (timescaledb.compress, timescaledb.compress_segmentby = 'host');
SELECT add_compression_policy('metrics', INTERVAL '7 days');

压缩对时序数据的效果通常很显著。同一 series 上相邻时间戳的差值很小,可以用 delta 编码压到几 bit;相邻数值变化不大时可以用 XOR 编码;重复的 tag 值用字典。这些都是无损压缩,只是换了编码方式。

降采样与预计算

原始精度数据往往远超实际需要。采集间隔 1 秒的数据,看一个月的趋势时,没有必要保留每一天每一秒的点。降采样就是把高精度数据聚合成低精度并延长保留期:

原始数据:间隔 10s,保留 7 天
降采样层:5 分钟均值/最大值/最小值/计数,保留 90 天
再降采样:1 小时聚合,保留 2 年

每种聚合留有不同用途:mean 用于趋势、max 用于峰值、count 用于请求量。p99 这类分位数需要可合并的 sketch 结构来存(t-digest 或 DDSketch),简单存数值再平均会得到错误结果。

InfluxDB 用连续查询(continuous query)表达这件事:

CREATE CONTINUOUS QUERY cq_5m ON monitoring BEGIN
  SELECT mean(cpu) AS cpu_mean, max(cpu) AS cpu_max
  INTO monitoring."30d".cpu_5m
  FROM cpu
  GROUP BY time(5m), host, region
END

TimescaleDB 用连续聚合视图:

CREATE MATERIALIZED VIEW metrics_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
       host,
       avg(cpu) AS cpu_avg, max(cpu) AS cpu_max, count(*) AS cnt
FROM metrics
GROUP BY bucket, host;

SELECT add_continuous_aggregate_policy('metrics_5m',
  start_offset => INTERVAL '3 hours',
  end_offset   => INTERVAL '10 minutes',
  schedule_interval => INTERVAL '10 minutes');

end_offset 的存在是为了处理迟到数据——延迟到达的数据点如果立刻被聚合,后来的迟到点就丢失了。把聚合窗口的终点推迟一段时间,让迟到数据有机会进入正确的桶。代价是这个时间窗内的新数据查询需要额外合并实时部分。

查询侧:函数下推

写了正确的降采样之后,查询仍然要走很久,通常是因为没有让引擎去做裁剪和下推。

-- 不友好的写法:先拉全量再在应用层算聚合
SELECT ts, cpu FROM metrics
WHERE ts > now() - INTERVAL '7 days';
-- 应用层再 group by 计算平均值
-- 把时间过滤、维度过滤、聚合全部推到数据库
SELECT time_bucket(INTERVAL '1 hour', ts) AS bucket,
       host, avg(cpu), max(memory)
FROM metrics
WHERE ts > now() - INTERVAL '7 days'
  AND host IN ('web01','web02','web03')
GROUP BY bucket, host
ORDER BY bucket DESC;

需要留意的是 now() 这类非确定性函数:在使用了缓存或预处理时可能会限制某些优化。用明确的时间常量有时候反而让计划更稳定。

几条通用原则:

  • 时间范围必须是查询的第一个条件,让分区裁剪能尽早生效。
  • 用 hour/天的粗粒度替代分钟级:分钟级数据量大一个数量级。
  • 避免在 field 上做正则匹配或者数值运算后再过滤,这会让索引失效。
  • 不要 SELECT *:时序表的列可能很多,且 fileds 是宽表,只取需要的列能显著减少 IO。

时序库的取舍很清晰:用放弃随机更新、放弃单行事务、容忍 compaction 抖动,换取极高的写入吞吐和极强的压缩率。业务里的数据是否长这幅样子,决定了这套取舍是否划算。

1.3k浏览量
39赞
0评论
0参与用户
13 分钟阅读时间

评论(0)

还没有评论,来抢沙发。