监控指标、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 倍,是高频采集必选;5 倍;隐式创建序列会带来首次写入的额外开销,应避免。TS.INCRBY 与 ADD 相当;Pipeline 叠加 MADD 可再降 2
四、压缩编码与内存优化
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 的能力圈,用保留策略、压缩编码、降采样规则三件套解决了时序数据的核心矛盾:数据无限增长,而内存有限。核心要点回顾:
- RETENTION 是生命线:没有保留期的序列就是内存泄漏,上线前必须设置
- 降采样是唯一解药:原始数据短保留 + 聚合数据长保留,内存可省数十倍
- 压缩对平稳数据效果好:Gorilla 编码对监控指标可达 1/10 压缩率,抖动剧烈的数据效果打折
- 批量写入是性能前提:
TS.MADD而非逐点TS.ADD,RTT 开销相差两个数量级 - 标签是查询入口:设计低基数标签,警惕标签爆炸导致的序列数失控
- 查询要对准粒度:查 30 天用 1 小时序列,不要扫秒级原始点
- LATEST 不可少:实时看板不加
LATEST会看不到最新数据
时序数据最容易被低估的是它的增长惯性:上线时每秒 100 个点不觉得多,一年后就是 31 亿个点。RedisTimeSeries 的设计哲学正是用声明式的
RETENTION与CREATERULE把这个惯性提前锁死——你不需要写任何清理任务,数据自己会老去、会浓缩、会留下精华。这才是时序存储该有的样子。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。