RedisTimeSeries 时序数据实战:降采样、压缩与监控告警

RedisTimeSeries 时序数据实战:TS.CREATE 建序列与 RETENTION/ENCODING/CHUNK_SIZE 参数、TS.ADD/TS.MADD 写入与自动建键、TS.INCRBY/DECRBY 计数器、标签 LABELS 与过滤查询、TS.CREATERULE 降采样规则与聚合器(avg/sum/min/max/count/first/last/std.p/twa)、保留策略与压缩编码(UNCOMPRESSED/COMPRESSED/GORILLA/DOUBLE_DELTA)、TS.RANGE/TS.MRANGE/TS.MGET/TS.MREVRANGE 查询与 BUCKET 聚合、TS.QUERYINDEX 索引发现、与 Prometheus/Grafana 监控结合、容量规划与生产实践

监控指标、IoT 传感器读数、业务埋点、金融行情——这些数据的共同特征是:按时间顺序追加、几乎不改不删、总量持续增长、查询以时间区间聚合为主。用普通 Redis 结构存它们会立刻碰壁:ZSet 成员膨胀、Hash 无法区间聚合、数据永不淘汰。

RedisTimeSeries 是 Redis Stack 的时序模块,专为这类负载设计:自动保留策略控制内存上限、Gorilla 压缩把内存降到原始数据的几分之一、降采样规则在写入时自动生成多粒度聚合、标签查询支持跨序列的多维度检索。

本文从建序列讲到压缩编码、降采样、聚合查询与监控集成。


一、时序数据的挑战与方案对比

1.1 时序负载的四个特征

写多读少(每秒写入上万点,读取集中在聚合与降采样)、时间有序(时间戳单调递增)、永不更新(历史点几乎不改)、需要淘汰(只关心最近 N 天,老数据应自动清理)。

1.2 用普通结构存的困境

ZSet 用时间戳作 score,ZADD 与 ZRANGEBYSCORE 可行,但聚合需客户端计算、内存要手动 ZREMRANGEBYSCORE;Hash 以时间戳为 field,支持 HSET 但不支持有序范围查询与聚合,需手动清理;List 的 LPUSH 很快,但区间查询、聚合都不支持,只能 LTRIM;String 拼接每次都要读出改写。只有 RedisTimeSeries 原生支持区间查询与服务端聚合,并用 RETENTION 自动控制内存。

用 ZSet 存指标,1000 个序列 × 每序列 100 万个点 = 10 亿成员,内存轻松突破 100GB。RedisTimeSeries 的 Gorilla 压缩能把同样的数据压到几 GB。

1.3 与其他时序方案的定位

RedisTimeSeries 延迟低、内存内、易部署,但受单机内存上限约束且无 SQL;Prometheus 生态成熟、PromQL 强大,但采用拉模式且长期存储弱;InfluxDB 专为时序设计、查询语言完善,但运维较重;TimescaleDB 有 SQL 与 PostGIS 生态,但依赖 PostgreSQL。常见组合是热冷分层:最近 24 小时的高频数据放 RedisTimeSeries 做实时告警与看板,历史数据落 Prometheus/InfluxDB 做长期分析。


二、创建序列:TS.CREATE

2.1 基本形式

签名形如 TS.CREATE key [RETENTION retentionPeriod] [ENCODING UNCOMPRESSED|COMPRESSED] [CHUNK_SIZE size] [DUPLICATE_POLICY policy] [LABELS label value ...] [IGNORE maxTimeDiff maxValDiff]。

# 创建 CPU 使用率序列:保留 7 天,带标签
redis-cli TS.CREATE cpu:usage:host1 \
  RETENTION 604800000 \
  LABELS host host1 metric cpu_usage region cn-east

redis-cli TS.CREATE mem:used:host1 \
  RETENTION 604800000 \
  LABELS host host1 metric mem_used region cn-east

RETENTION 单位是毫秒。604800000 = 7 天。保留策略是 RedisTimeSeries 最重要的参数:超过保留期的数据点会被自动删除,从而给内存用量设下硬上限。

2.2 参数详解

RETENTION 是数据保留时长(毫秒,默认 0 即永久),应按业务设置以避免无限增长;ENCODING 默认 COMPRESSED,除非需要随机访问否则保持压缩;CHUNK_SIZE 默认 4096 字节,大序列可加大到 16384;DUPLICATE_POLICY 默认 BLOCK,见 2.3;LABELS 是标签键值对,用于 TS.MRANGE 过滤;IGNORE 用于忽略微小偏差以减少噪声点写入。

2.3 DUPLICATE_POLICY 重复写入策略

BLOCK 报错拒绝(默认);FIRST 保留最早写入的值;LAST 覆盖为最新值;MIN 保留较小值;MAX 保留较大值;SUM 累加两个值。

# 允许重复写入时覆盖为最新值(适合采集乱序或重试场景)
redis-cli TS.CREATE sensor:temp RETENTION 86400000 \
  DUPLICATE_POLICY LAST \
  LABELS device d1 unit celsius

采集端重试很常见。若用默认 BLOCK,重试会报 TSDB: Error at upsert, update is not supported。幂等采集建议用 LAST。

2.4 自动创建与查看修改

直接 TS.ADD 不存在的键会自动创建,但使用默认参数(无保留期、无标签),在生产中通常是隐患,建议显式 TS.CREATE 后再写。

redis-cli TS.INFO cpu:usage:host1
# totalSamples 1440 / memoryUsage 12345 / retentionTime 604800000 / chunkCount 2

# 修改保留期(只能改大,不能改小)
redis-cli TS.ALTER cpu:usage:host1 RETENTION 2592000000

# 添加标签
redis-cli TS.ALTER cpu:usage:host1 LABELS host host1 env prod

TS.ALTER 的 RETENTION 只能增大。想缩短保留期只能重建序列并迁移数据。


三、写入:TS.ADD、TS.MADD 与计数器

3.1 TS.ADD 单点写入

TS.ADD cpu:usage:host1 1696123456789 42.5 用显式毫秒时间戳写入;时间戳写 * 表示使用服务器当前时间,返回实际写入的时间戳(如 1696123457890);TS.ADD cpu:usage:host1 '*' 45.0 RETENTION 604800000 可在写入的同时设置该点之后自动应用的保留期。

3.2 TS.MADD 批量写入

TS.MADD 一次原子写入多个序列的多个点,参数按「键 时间戳 值」三元组重复:TS.MADD cpu:usage:host1 '*' 42.5 cpu:usage:host2 '*' 55.1 mem:used:host1 '*' 8192,返回各点实际写入的时间戳。

采集端应聚合成批再写:每 100ms 或每 1000 个点调用一次 TS.MADD,而不是每个点一次 TS.ADD。在 10 万点/秒的场景下,批量写入能把 RTT 开销降低两个数量级。

3.3 TS.INCRBY / TS.DECRBY:计数器

redis-cli TS.CREATE api:requests:host1 RETENTION 2592000000 \
  LABELS host host1 metric api_requests

# 每次请求 +1
redis-cli TS.INCRBY api:requests:host1 1
# 1696123459000

TS.INCRBY 与 TS.ADD 的语义不同:前者是累加,后者是设值。计数器(请求数、错误数、字节数)用 TS.INCRBY;仪表(CPU、温度、内存)用 TS.ADD。同一毫秒内多次自增会合并为一个点(值累加)。

3.4 写入性能参考

TS.ADD 单点作为基准;TS.MADD 批量每点降低 1050 倍,是高频采集必选;TS.INCRBY 与 ADD 相当;Pipeline 叠加 MADD 可再降 25 倍;隐式创建序列会带来首次写入的额外开销,应避免。


四、压缩编码与内存优化

4.1 两种编码

UNCOMPRESSED 每个点存 16 字节(8 时间戳 + 8 值),内存高但可随机访问;COMPRESSED(默认)使用 Gorilla 压缩,内存降到 1/5~1/20,代价是读取时需解压数据块。

高精度金融数据可用 TS.CREATE tick:000001 ENCODING UNCOMPRESSED RETENTION 86400000 换取读取速度;监控指标则用 TS.CREATE cpu:usage:host3 ENCODING COMPRESSED RETENTION 604800000 压缩存储。

4.2 Gorilla 压缩原理

Gorilla 是 Facebook 提出的时序压缩算法,核心是两条:时间戳的 Delta-of-Delta——监控数据采集间隔固定(如每 15 秒),相邻时间戳差值恒定,二阶差分后大部分为 0,只需 1 bit 表示;值的 XOR 编码——相邻点的浮点值通常变化很小,XOR 后前导零与后置零很多,只存中间有效位。

压缩对变化平缓的指标效果最好(CPU、温度、内存)。对剧烈抖动的数据(股票逐笔成交)压缩率会明显下降,此时可评估是否改用 UNCOMPRESSED 换取读取速度。

4.3 CHUNK_SIZE 调优

每个序列由多个 chunk 组成,每个 chunk 存储一段时间范围的点,默认 4096 字节。调小(如 1024)内存利用率高、适合稀疏序列,但 chunk 数量多、元数据开销大;调大(如 16384)可减少元数据、适合高密度序列,但单序列内存粒度更粗。

# 高密度序列(每秒 1 点,保留 30 天)
redis-cli TS.CREATE high:rate:host1 CHUNK_SIZE 16384 RETENTION 2592000000

4.4 内存估算

单点压缩后约 2~16 字节,取决于数据特征。粗算公式为「内存 ≈ 序列数 × 点数 × 平均每点字节 + 序列数 × 固定开销」,其中点数 = RETENTION / 采集间隔。

按此估算,100 个序列、15 秒间隔、保留 7 天约 40MB;1000 个序列、10 秒间隔、保留 30 天约 2.6GB;10000 个序列、1 秒间隔、保留 7 天约 60GB;若降采样到 1 分钟粒度并保留 1 年,则约 10GB(粒度降 60 倍)。

关键结论:保留期 × 采集频率 决定内存,降采样是唯一的解药。原始数据只留 7 天,1 分钟粒度的聚合数据留 1 年,内存可以省下 60 倍。


五、降采样:TS.CREATERULE

5.1 为什么需要降采样

原始数据是秒级的,但看板只需要分钟级、小时级趋势,告警只需要 5 分钟均值。若查询时实时聚合 7 天的秒级数据,需要扫描 60 万个点——延迟高、CPU 高。降采样的做法是:写入原始点时,由服务端自动把点聚合进更高粒度的序列,查询长周期时直接读聚合序列。

5.2 创建降采样规则

# 1. 建源序列(秒级)
redis-cli TS.CREATE cpu:raw:host1 RETENTION 604800000 \
  LABELS host host1 level raw

# 2. 建目标序列(分钟级,保留更久)
redis-cli TS.CREATE cpu:1m:host1 RETENTION 31536000000 \
  LABELS host host1 level 1m

# 3. 建立降采样规则:每 60000ms 聚合一次,用 avg
redis-cli TS.CREATERULE cpu:raw:host1 cpu:1m:host1 \
  AGGREGATION avg 60000

# 4. 继续做二级降采样:分钟 -> 小时
redis-cli TS.CREATE cpu:1h:host1 RETENTION 63072000000 LABELS host host1 level 1h
redis-cli TS.CREATERULE cpu:1m:host1 cpu:1h:host1 AGGREGATION avg 3600000
写入点 -> 源序列(1s) --规则(avg 60s)--> 聚合序列(1m) --规则(avg 3600s)--> 聚合序列(1h)
         保留 7 天                        保留 1 年                        保留 2 年

5.3 聚合器类型

avg 平均值(CPU、温度、延迟);sum 求和(请求数、字节数);min/max 极值(峰值、谷值);range 极差(抖动分析);count 点数(采样率验证);first/last 首末值(状态类指标);std.p/std.s 总体与样本标准差;var.p/var.s 方差;twa 时间加权平均(不规则采样)。

延迟指标要同时保留均值与最大值,需建多个目标序列:TS.CREATE latency:1m:avg ... 与 TS.CREATE latency:1m:max ...,再分别用 TS.CREATERULE latency:raw latency:1m:avg AGGREGATION avg 60000 与 ... AGGREGATION max 60000 挂上规则。

RedisTimeSeries 的降采样只支持一种聚合器。要同时看 avg 与 max,必须建多个目标序列。这是它与 Prometheus Recording Rules 的差异。

5.4 规则的限制

一个源序列可有多个规则,但每个规则只对应一个目标;不能形成环;聚合窗口按 timestamp / bucketDuration 对齐;删除规则用 TS.DELETERULE src dst。

降采样是写入时触发的:只有当新的原始点写入时,聚合窗口才可能被填充。因此当前未完成的窗口在查询聚合序列时可能是不完整的——最后一个桶的值会随新数据持续变化。


六、标签查询与序列发现

6.1 TS.QUERYINDEX:按标签找序列

redis-cli TS.QUERYINDEX host=host1
# 1) "cpu:raw:host1"
# 2) "cpu:1m:host1"

redis-cli TS.QUERYINDEX metric=cpu_usage region=cn-east
redis-cli TS.QUERYINDEX host=host1 level!=raw

运算符包括等值 label=value、不等 label!=value、存在 label=(空值),多条件用空格分隔表示逻辑与。

RedisTimeSeries 不支持正则或范围匹配标签(这点不如 Prometheus)。若需要 host=host* 这样的前缀匹配,只能取出全部序列后在客户端过滤。

6.2 TS.MGET:取多序列的最新值

TS.MGET FILTER host=host1 返回各序列的最新值与标签;加 WITHLABELS 返回全部标签,用 SELECTED_LABELS host 只返回指定标签,例如 TS.MGET SELECTED_LABELS host FILTER metric=cpu_usage。

6.3 TS.MRANGE 与 TS.MREVRANGE

redis-cli TS.MRANGE - + FILTER host=host1          # - 最早,+ 最新
redis-cli TS.MRANGE 1696120000000 1696123600000 FILTER level=1m
redis-cli TS.MRANGE - + FILTER metric=cpu_usage GROUPBY host REDUCE avg
redis-cli TS.MREVRANGE + - FILTER host=host1 COUNT 10   # 反向查询

七、TS.RANGE 聚合查询

7.1 基本范围查询

redis-cli TS.RANGE cpu:raw:host1 - +                   # 全部点
redis-cli TS.RANGE cpu:raw:host1 - + COUNT 10          # 最早 10 个
redis-cli TS.RANGE cpu:raw:host1 1696120000000 1696120600000
redis-cli TS.RANGE cpu:raw:host1 - + FILTER_BY_VALUE 80 100   # 只看 > 80
redis-cli TS.RANGE cpu:raw:host1 - + ALIGN start       # 对齐时间边界

7.2 BUCKET 聚合:服务端分桶

redis-cli TS.RANGE cpu:raw:host1 - + AGGREGATION avg 300000
redis-cli TS.RANGE cpu:raw:host1 - + AGGREGATION max 300000
redis-cli TS.RANGE cpu:raw:host1 - + AGGREGATION avg 300000 EMPTY 0

7.3 查询参数组合

AGGREGATION type bucket 分桶聚合;COUNT n 限制返回点数;FILTER_BY_TS ts... 只返回指定时间戳;FILTER_BY_VALUE min max 只返回值在范围内的点;ALIGN start/-/+ 控制桶对齐方式;EMPTY value 填充空桶;LATEST 包含未完成的聚合桶。

# 复杂查询:最近 1 小时,每 5 分钟 max,只保留 > 70 的桶
redis-cli TS.RANGE cpu:raw:host1 - + \
  AGGREGATION max 300000 \
  FILTER_BY_VALUE 70 100 \
  LATEST

LATEST 会包含降采样序列中尚未完成的最后一个桶,实时看板必加。不加则最新数据要等一个完整窗口才会出现。

7.4 删除数据点与查询性能

# 删除某时间范围内的点
redis-cli TS.DEL cpu:raw:host1 1696120000000 1696123600000
# (integer) 3600    -- 删除的点数

查询压缩序列的原始点需解压整个 chunk;查询降采样序列则点数少、极快;跨大量序列的 MRANGE 开销线性于序列数;大范围加细粒度查询应避免,改用降采样序列;FILTER_BY_VALUE 是后置过滤,不会减少扫描量。

查询降采样序列是性能关键。看板查 30 天趋势时读 1 小时粒度的序列(720 个点),而不是读 260 万个秒级点。设计时应明确「哪个时间范围查哪个粒度的序列」。


八、与监控体系结合

8.1 典型架构

采集端 Agent 用 TS.MADD 批量写入 RedisTimeSeries,服务端通过 TS.CREATERULE 自动生成 1 分钟与 1 小时粒度的降采样序列;告警引擎轮询 TS.RANGE 做判断后发出通知,Grafana 则读同一批序列渲染看板。

8.2 Grafana 集成

Grafana 提供 RedisTimeSeries 数据源插件,支持直接查询 TS.RANGE / TS.MRANGE:配置 Address 为 redis-stack:6379、Type 选 RedisTimeSeries、Filter 填 metric=cpu_usage,Series 会按 label 自动展开为多条线。看板常查的语句形如 TS.MRANGE - + FILTER metric=cpu_usage GROUPBY host REDUCE avg AGGREGATION avg 300000 ALIGN start。

8.3 告警引擎实现

import time
from redis import Redis

r = Redis(decode_responses=True)

def check_alert(metric: str, threshold: float, window_ms: int = 300000):
    now = int(time.time() * 1000)
    res = r.execute_command(
        'TS.MRANGE', now - window_ms, now,
        'FILTER', f'metric={metric}',
        'AGGREGATION', 'avg', window_ms,
    )
    alerts = []
    for item in res:
        labels = dict(zip(item[1][::2], item[1][1::2]))
        points = item[2]
        if points and float(points[-1][1]) > threshold:
            alerts.append({'key': item[0], 'labels': labels, 'value': points[-1][1]})
    return alerts

while True:
    for a in check_alert('cpu_usage', 85.0):
        print(f"告警: {a['key']} CPU={a['value']}%")
    time.sleep(30)

告警轮询应查降采样序列(如 1 分钟粒度)而非原始序列。查 5 分钟窗口的 1 分钟序列只需扫描 5 个点,查原始秒级序列要扫 300 个点。

8.4 与 Prometheus 的配合

两者可以双写(采集端同时写 RedisTimeSeries 与 Prometheus)、用自研 exporter 把 TS 数据暴露为 Prometheus 指标、热冷分层(Redis 存热数据做实时告警,Prometheus 存长期),或定期把降采样数据导出到长期存储。最常见的生产组合是双写:采集 Agent 一次采集,同时投递给 RedisTimeSeries(实时告警,低延迟)与 Prometheus(长期存储,强大查询),二者互补而非替代。

8.5 监控 RedisTimeSeries 自身

redis-cli INFO modules
# redis_timeseries_metrics_total_series:1000
# redis_timeseries_metrics_total_samples:259200000
# redis_timeseries_metrics_total_memory:2764800000

redis-cli TS.INFO cpu:raw:host1 | grep -E 'chunkCount|memoryUsage|totalSamples'

total_series 异常增长说明标签爆炸;total_samples 需结合内存判断;total_memory 接近 maxmemory 时需扩容;单序列 chunkCount 过多说明 CHUNK_SIZE 偏小。


九、生产实践与容量规划

9.1 标签设计原则

标签是查询的入口,设计好坏直接决定能否高效检索。应使用低基数标签(host、region、metric 是好的,request_id、user_id 是灾难);语义清晰(用 level=raw/1m/1h 区分粒度);数量可控(标签组合数等于序列数,爆炸式增长会耗尽内存);命名一致(统一 key=value 风格,避免 host 与 hostname 混用)。

标签爆炸是 RedisTimeSeries 最常见的生产事故。一个 user_id 标签能让序列数从 100 涨到 100 万,内存瞬间打满。上线前务必估算 序列数 = 各标签基数的乘积。

9.2 内存规划流程

确定指标数以得到序列基数;确定采集间隔与保留期以得到每序列点数;选压缩编码得到每点字节数(216);加上降采样序列(点数按粒度与间隔的倍数缩减);最后留 30% 余量加固定开销(每序列约 100200 字节元数据)。

9.3 maxmemory 与淘汰策略

maxmemory 8gb
# RedisTimeSeries 的键不参与 LRU 淘汰(无过期时间),
# 但 maxmemory 打满后写入会失败
maxmemory-policy noeviction

时序数据不应依赖 LRU 淘汰(它们没有 TTL 且访问模式特殊)。正确做法是用 RETENTION 控制数据量,用监控告警提前扩容。若 maxmemory 打满且策略为 noeviction,TS.ADD 会返回 OOM 错误——采集端必须处理该错误并降级(如丢弃或落本地磁盘)。

9.4 高可用与集群

RDB/AOF 均支持持久化,恢复后序列完整;写命令复制到副本,副本自动构建;Cluster 下序列按 Key 分片,但 TS.MRANGE FILTER 只在当前节点的序列中筛选,不会跨分片——客户端需向所有节点发起查询再合并,或使用支持该语义的代理。主从与集群节点的模块版本必须一致。

9.5 上线检查清单

  • 每个序列都设了 RETENTION(禁止无限制增长)
  • 已建立降采样规则,长周期查询走聚合序列
  • 标签基数的乘积已估算,无高基数标签
  • 采集端使用 TS.MADD 批量写入,非逐点 TS.ADD
  • 重复写入场景已设 DUPLICATE_POLICY LAST
  • 查询已使用 LATEST 以包含未完成桶
  • 告警轮询查降采样序列而非原始序列
  • 已监控 total_series / total_memory 并设阈值
  • 写入失败(OOM)的降级策略已在采集端实现
  • Cluster 下的跨分片查询语义已验证

结语

RedisTimeSeries 把「时间序列」这一最普遍的数据形态纳入了 Redis 的能力圈,用保留策略、压缩编码、降采样规则三件套解决了时序数据的核心矛盾:数据无限增长,而内存有限。核心要点回顾:

  1. RETENTION 是生命线:没有保留期的序列就是内存泄漏,上线前必须设置
  2. 降采样是唯一解药:原始数据短保留 + 聚合数据长保留,内存可省数十倍
  3. 压缩对平稳数据效果好:Gorilla 编码对监控指标可达 1/10 压缩率,抖动剧烈的数据效果打折
  4. 批量写入是性能前提:TS.MADD 而非逐点 TS.ADD,RTT 开销相差两个数量级
  5. 标签是查询入口:设计低基数标签,警惕标签爆炸导致的序列数失控
  6. 查询要对准粒度:查 30 天用 1 小时序列,不要扫秒级原始点
  7. LATEST 不可少:实时看板不加 LATEST 会看不到最新数据

时序数据最容易被低估的是它的增长惯性:上线时每秒 100 个点不觉得多,一年后就是 31 亿个点。RedisTimeSeries 的设计哲学正是用声明式的 RETENTION 与 CREATERULE 把这个惯性提前锁死——你不需要写任何清理任务,数据自己会老去、会浓缩、会留下精华。这才是时序存储该有的样子。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. Redis 线上排障与延迟诊断:SLOWLOG、LATENCY 与阻塞命令全流程
  2. 云托管 Redis 选型与运维:ElastiCache、MemoryDB、Redis Cloud 与 Upstash 对比
  3. RedisJSON 文档模型:JSONPath、路径更新与二级索引实战