监控系统每秒采集几十万个指标点,把这些点塞进关系库里,一个月后表会撑到无法维护。时序数据有自己的形状:写多、几乎不更新、按时间追加、查询几乎总是时间范围加几个维度过滤。针对这种形状专门设计的存储引擎,与通用行存的取舍差异很大。
时序数据的四个特征
写多且几乎不更新。数据点是设备或探针在同一时刻的状态快照,写入之后不会再被修改。偶发的更正以新数据点的形式出现。
按时间追加。新数据的时间戳总是接近当前时刻,写入集中在”最近”,形成强局部性。
查询是时间范围加维度过滤。”过去 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 抖动,换取极高的写入吞吐和极强的压缩率。业务里的数据是否长这幅样子,决定了这套取舍是否划算。
评论(0)
还没有评论,来抢沙发。
请 登录 后参与评论。