时序数据这块,我在几个项目里都踩过坑。刚接触「如何在 Redis 中保存时间序列数据」这个问题时,我第一反应也是“不就是拿个 key 存一串点吗”,真上手才发现,同样是往 Redis 里塞监控指标、传感器读数、接口耗时这些时间序列数据,选错结构会在三个月后让你付出十倍代价。这篇文章不谈空泛的概念,只讲我自己实际跑过的几种存法:从最朴素的 String 键,到 Sorted Set、Hash 分桶、Stream,再到专门的时序模块,每一种我都会说清楚它的数据结构长什么样、命令怎么写、内存怎么算、查询怎么做,以及在什么场景下该用它、什么场景下千万别用。不管你是刚接手一个指标采集需求、手头只有一个 Redis 实例的新同学,还是已经在为内存暴涨和慢查询焦头烂额的老手,下面这些内容应该都能直接拿去对照着用。
1. 先想清楚:时间序列数据到底难在哪
1.1 时序数据的四个典型特征
在决定存储方案之前,得先把数据本身的样子描清楚。我经手过的时序场景,不管是机房温度采集、接口 P99 耗时、还是 App 日活曲线,本质上都有四个共同特征,这四个特征直接决定了存储结构的选型方向。
第一是写入量大且持续。一台设备一秒一个点,一千台设备就是每秒一千次写;如果换成高频交易类的计数器,每秒十万次写也不稀奇。第二是写入几乎全是追加,时间戳单调递增,几乎不会去改历史点,也不会随机插入。第三是查询几乎都是按时间范围,“给我看最近一小时”“给我看昨天 0 点到 6 点”,范围扫描是绝对主流,单点精确查询反而很少。第四是新旧数据的价值差异极大,最近一小时的原始点可能被反复查询,三个月前的原始点基本只会被降采样后的聚合值替代。
把这四条摆在一起,你就能看出对存储结构的要求:追加写要便宜、时间范围扫描要高效、老数据要能自动衰减。很多人一上来就用SET ts:device:1:1700000000 25.3这种“时间戳拼进 key 名”的写法,写完跑了两个月,发现KEYS ts:device:1:*一次要扫几百万个 key,直接把实例拖垮。问题不在于 String 不能存,而在于它没能满足上面第三条——范围扫描。
1.2 为什么不一定要上专用时序数据库
这里得先泼一盆冷水。看到“时间序列”四个字,很多人的第一反应是上一套专门的时序数据库。如果你的数据量是每天几十亿点、需要长期保留几年、还需要做复杂的多维度下钻分析,那确实应该走专用方案。但我在实际项目里见到的情况,八成都是“每天几百万到几千万点、只查最近七天”,这种量级上专用库属于杀鸡用牛刀,运维成本、学习成本、以及和现有业务系统的集成成本,往往超过了它带来的收益。
Redis 在这个位置上的优势很明确:你大概率已经有一套在跑的 Redis 了,监控、告警、备份、连接池、客户端库全是现成的,加一张时序表不需要引入新的组件和新的运维面。加上 Redis 本身单机几十万 QPS 的写入能力、内存级的读取延迟,对“写多读少、查最近数据”的时序场景非常契合。真正的难点不在“能不能存”,而在“怎么存才不把内存吃穿、怎么查才不把实例拖慢”。
所以这一节的核心结论是:先按数据量和查询模式算账,再决定架构。下面所有的方案对比,都建立在这个前提上。我在做方案评审时习惯先问三个问题——每天写入多少点、要保留多久、查询最远回溯到什么时候。这三个数一出来,方案基本就定了一半。
2. 方案选型:五种存法摆在一起比一比
2.1 五种存法的结构设计与核心命令
我实际用过、也在生产上跑过的存法主要有五种,每一种的 key 组织方式和命令都不一样,先把它们的骨架摆出来。
第一种是 String 拼时间戳 key,最直观但最不推荐。key 形如ts:sensor:1:1700000000,value 存数值。写入用SET,查询得靠SCAN或维护一个额外的时间戳索引。它的唯一优点是实现简单,缺点是范围查询基本残废,key 数量爆炸,内存碎片严重。
SET ts:sensor:1:1700000000 "25.3" GET ts:sensor:1:1700000000第二种是 Sorted Set,把时间戳当 score,数值当 member。这是最经典也最常用的做法,因为 ZSET 底层是跳表加哈希表,天然按 score 有序,范围查询是 O(log N + M)。写入用ZADD,范围查询用ZRANGEBYSCORE。
ZADD ts:sensor:1 1700000000123 "25.3" ZRANGEBYSCORE ts:sensor:1 1700000000000 1700003600000 WITHSCORES第三种是 Hash 分桶,把一段时间(比如一小时或一天)压成一个 Hash,field 是桶内偏移量,value 是数值。写入用HSET,查询用HGETALL或HMGET。这种写法的内存效率明显高于 ZSET,但范围查询需要客户端自己算桶、自己拼 field,逻辑重一些。
HSET ts:sensor:1:17000000 12 "25.3" 13 "25.5" HGETALL ts:sensor:1:17000000第四种是 Stream,Redis 5.0 引入的结构,专为消息流设计,但拿来做时序日志也很顺手。每条消息有自增 ID 和字段对,支持MAXLEN裁剪,写进去就带“只保留最近 N 条”的能力。
XADD ts:sensor:1 MAXLEN ~ 100000 * temp 25.3 XRANGE ts:sensor:1 - + COUNT 100第五种是 RedisTimeSeries 模块,官方生态里的专门时序结构,自带保留期、降采样规则、标签索引和多序列聚合查询。能力最强,但需要你的实例加载了这个模块,云上托管实例是否开放、自建实例怎么加,都要提前确认。
TS.CREATE ts:sensor:1 RETENTION 604800000 LABELS sensorId 1 TS.ADD ts:sensor:1 * 25.3 TS.RANGE ts:sensor:1 - + AGGREGATION avg 600002.2 选型对照表与决策路径
把这五种放在一起对比,差异就一目了然了。下表是我在自己项目里用的对照表,可以直接拿去改。
| 维度 | String 拼 key | Sorted Set | Hash 分桶 | Stream | 时序模块 |
|---|---|---|---|---|---|
| 写入性能 | 高 | 高 | 最高 | 中高 | 高 |
| 范围查询 | 差 | 好 | 中 | 好 | 最好 |
| 单点内存开销 | 最高 | 中高 | 最低 | 中 | 中低 |
| 自动过期 | 按 key | 按 key | 按 key | 按 key / 裁剪 | 按保留期 |
| 服务端聚合 | 无 | 需 Lua | 需 Lua | 无 | 原生 |
| 部署门槛 | 无 | 无 | 无 | 5.0+ | 需加载模块 |
决策路径我一般这么走:数据量小(每天百万级以下)、保留时间短、查询简单,Hash 分桶够用,内存最省;需要灵活的时间范围查询、又不想引入额外组件,Sorted Set 是首选;数据带“事件”语义、需要多消费者同时消费,用 Stream;有降采样、多序列聚合、长期保留的硬需求,再考虑时序模块。
这里有个容易被忽略的点:这五种方案并不互斥。我在一个设备监控项目里就是热数据放 ZSET(最近 24 小时,保证任意范围查询快),冷数据按天落 Hash 桶再批量转存到对象存储,Stream 单独用来做告警事件的传播。分层设计比死磕一种结构要务实得多。
3. 逐个拆解:每种方案的实操细节与坑
3.1 Sorted Set:最常用的“中庸之选”
先说 ZSET,因为它是绝大多数人第一版会选、也最容易出问题的方案。核心逻辑很简单:score 存时间戳,member 存数值。但这里有两个细节必须提前定清楚,否则后面改起来很痛苦。
第一个是时间戳的精度。score 是 double 类型,双精度浮点数的尾数只有 53 位,能精确表示的最大整数是 2^53,约等于 9.007×10^15。毫秒时间戳现在是 1.7×10^12 量级,离天花板还有很远的距离,完全安全。但如果你图省事直接塞纳秒时间戳(1.7×10^18),尾数就不够用了,相邻的时间戳会被四舍五入成同一个 score,数据直接错乱。我在一个项目里就碰到过这个坑,对接方给的是纳秒精度,写进去之后发现某个毫秒内的几个点全挤成了一个。所以要么统一用毫秒,要么用微秒,绝对不要用纳秒直接当 score。
第二个是同一毫秒多点的冲突。ZSET 的 member 是唯一的,如果两台设备在同一毫秒上报了相同数值,第二次ZADD会覆盖第一次,点数直接少了。解决办法是把 member 拼成时间戳:数值:序列号这种复合串,或者干脆接受“同一毫秒只保留一个点”的业务语义——大多数监控场景其实能接受,但传感器高频采集场景就不行,得提前确认。
范围查询的写法也有讲究。最基础的是这样:
# 查最近一小时,按时间正序,限制返回条数 ZRANGEBYSCORE ts:sensor:1 1700000000000 1700003600000 WITHSCORES LIMIT 0 1000注意LIMIT的 offset 大了会很慢,因为 Redis 要跳过前面所有元素。做深度分页时,正确姿势是游标式分页——记住上一页最后一个 score,下一页从它开始:
# 第一页 ZRANGEBYSCORE ts:sensor:1 -inf +inf WITHSCORES LIMIT 0 100 # 第二页:用上一页最后一条的 score 加 1 作为新的下界 ZRANGEBYSCORE ts:sensor:1 (1700000000123 +inf WITHSCORES LIMIT 0 100左括号表示开区间,这样就不会重复返回边界那条。这个小技巧在数据量大时能把查询从几百毫秒压到几毫秒。
清理老数据是 ZSET 方案必须自己做的事。ZSET 不支持给单个元素设 TTL,只有整个 key 能过期。所以要么按天切 key(ts:sensor:1:20240101),给每个 key 设EXPIRE;要么定期跑ZREMRANGEBYSCORE删掉窗口外的数据:
# 删掉 7 天前的数据 ZREMRANGEBYSCORE ts:sensor:1 -inf 1699395200000我推荐两个都做:切 key 保证兜底,定时裁剪保证单个 key 不会无限膨胀。裁剪任务建议放在业务低峰期,用SCAN遍历而不是KEYS,每次删一批,别一次删几十万条把主线程卡住。
3.2 Hash 分桶:把一秒内的点压进一个 key
如果你对内存特别敏感,Hash 分桶是我实测下来最省的一种。思路是把时间轴切成固定大小的桶,一个桶一个 Hash,桶内的时间偏移当 field。
比如按小时分桶,key 是ts:sensor:1:17000000(这里 17000000 是“小时序号”),field 是这一小时内的秒偏移(0 到 3599),value 是数值。写入就一句:
HSET ts:sensor:1:17000000 12 "25.3"这种写法的内存效率高得离谱,原因在于当 Hash 的元素个数和单个 value 长度都比较小时,Redis 会用紧凑编码(新版叫 listpack,老版叫 ziplist)来存,每个 field-value 对的开销只有十几到二十几字节,而 ZSET 里每个元素加上跳表节点和哈希表 entry,普遍在 80 到 150 字节。同样的数据量,Hash 桶的内存占用常常只有 ZSET 的三分之一到二分之一。
但这里有个必须盯住的临界点:紧凑编码只在元素数小于hash-max-listpack-entries(默认 128)、且每个 value 小于hash-max-listpack-value(默认 64 字节)时才生效。一旦超过,整个 Hash 会转成哈希表编码,内存瞬间翻几倍,而且这个过程不可逆——删掉元素也不会转回去。我见过一个团队按天分桶,一天 86400 个 field,编码早就转成哈希表了,还在疑惑“为什么用了 Hash 反而更费内存”。
所以分桶粒度要按上限倒推。假设你要保证每个桶不超过 128 个元素,那一天分成 86400/128 ≈ 675 个桶,也就是每个桶大约 128 秒。或者干脆一秒一桶、每桶存毫秒偏移,桶大小控制在合理范围。用OBJECT ENCODING随时能确认当前编码:
OBJECT ENCODING ts:sensor:1:17000000 # 返回 listpack 说明还在紧凑编码,返回 hashtable 就要注意了Hash 桶的查询需要客户端做点活:先算出时间范围覆盖哪几个桶,逐个HGETALL或HMGET,再在内存里拼起来按时间排序。这个逻辑不难,但要封装好,否则每个业务方各写一遍,出问题的概率很高。另外跨桶聚合(比如算一天的平均值)在客户端做会拉全量数据,最好用 Lua 在服务端算,或者写个后台任务预聚合。
3.3 Stream:自带消费组,但别当万能表
Stream 是 Redis 5.0 之后的结构,很多人拿它做时序存储,因为它有两个很香的能力:自动 ID 生成和MAXLEN 裁剪。
# 写入,* 表示让 Redis 自动生成 ID(毫秒时间戳-序号) XADD ts:sensor:1 MAXLEN ~ 100000 * temp 25.3 # 读最近 100 条 XRANGE ts:sensor:1 - + COUNT 100 # 读某个时间点之后的 XRANGE ts:sensor:1 1700000000000-0 + COUNT 100MAXLEN ~里的波浪号很关键,它表示“近似裁剪”,Redis 会按内部宏节点(macro node)为单位删,不会每次都精确删除到指定条数。这样做的好处是裁剪开销极低,写入几乎不受影响。如果你写成MAXLEN 100000(不带波浪号),那就是精确裁剪,每次写入都可能触发一次节点拆分合并,性能会明显下降。这是 Stream 使用上最常见的一个性能坑。
Stream 真正的价值在于消费组。如果你的时序数据不只是“存下来查”,还要“被多个下游同时消费”——比如一路写库、一路做实时告警、一路做特征计算——那 Stream 的XREADGROUP加XACK机制能省掉你自己维护偏移量的一大堆代码。
但 Stream 也有明显的短板。它的查询只支持按 ID 范围,不支持“按某个字段过滤”,也没有原生的聚合能力。你想算最近一小时的平均值,还是得把数据拉出来自己算。另外 Stream 的单条消息开销比 Hash 桶大,因为每条消息除了 ID 和字段,还有 radix tree 的节点开销,实测下来每条大约在 60 到 100 字节。所以数据量特别大、又只需要存和查的场景,Stream 不如 ZSET 或 Hash 划算;只有在“需要消费语义”的时候,它才是最优解。
3.4 专用时序模块:能力最强,但要想清楚运维成本
RedisTimeSeries 是官方生态里的时序模块,它把前面所有的手工活都内置了。核心能力有三个:保留期自动淘汰、降采样规则、标签多序列查询。
# 创建一个序列,保留 7 天,带标签 TS.CREATE ts:sensor:1 RETENTION 604800000 LABELS sensorId 1 room A # 写入,* 表示用当前时间 TS.ADD ts:sensor:1 * 25.3 # 按分钟聚合查最近一小时的平均值 TS.RANGE ts:sensor:1 - + AGGREGATION avg 60000降采样规则是它最有价值的部分。你可以建一个原始序列和一个小时级序列,然后挂一条规则,新数据写进原始序列时自动按小时聚合进第二个序列:
TS.CREATE ts:sensor:1:hourly RETENTION 0 TS.CREATERULE ts:sensor:1 ts:sensor:1:hourly AGGREGATION avg 3600000这样查最近一小时用原始序列,查最近一年用小时级序列,查询量和内存都能压下来一大截。多序列聚合用TS.MRANGE,可以按标签筛选出一批序列再统一聚合,做多机房、多设备的对比分析特别方便。
代价是什么?你得先确认实例里有这个模块。自建实例要单独编译加载,云上托管实例得看服务商开没开放,而且模块升级通常不能跟主版本平滑滚动,需要停机或者主从切换。我在一个项目里就因为托管实例不支持某个版本的模块命令,最后把方案退回了 ZSET 加手工降采样。所以选它之前,先去你的环境里跑一条TS.CREATE试试,别等设计文档写完才发现用不了。
4. 内存怎么算:一次真实的容量规划推演
4.1 单点开销拆解与估算公式
时序方案最容易翻车的地方就是内存。我先给一个粗暴但好用的经验值:ZSET 每个元素按 100 字节估,Hash 桶(紧凑编码下)每个点按 20 字节估,Stream 每条按 80 字节估。这三个数不是拍脑袋来的,下面拆一下 ZSET 的构成你就明白了。
一个 ZSET 元素在 skiplist 编码下,至少要占这几块:跳表节点本身(含层高指针数组,平均约 32 字节)、哈希表里的 dictEntry(三个指针,约 24 字节)、member 的 SDS 字符串头加内容(短字符串约 16 到 32 字节)、score 的 double(8 字节,通常内联在节点里)。加总下来 80 到 120 字节是很正常的区间,如果 member 里还拼了设备号之类的长前缀,轻松突破 150 字节。
按这个算法做个推演:1000 台设备,每台每秒一个点,保留 7 天。总点数 = 1000 × 86400 × 7 ≈ 6.05 亿个点。按每个 100 字节算,就是 60.5 GB。这还没算 Redis 自身的开销、主从复制的内存放大、以及碎片率,实际要按 1.3 到 1.5 倍预留,也就是 80 到 90 GB。这个数字一出来,很多方案当场就得推翻重来。
同样的数据换成 Hash 桶,按每点 20 字节算,约 12 GB,加预留 18 GB 左右,一下子就可接受了。再换成“原始数据只留 24 小时,历史数据降采样到分钟级”,点数直接砍到原来的 1/60,内存压力几乎消失。
4.2 用实测数据校准估算
经验值只能用来做初筛,真上线之前必须实测。Redis 提供了MEMORY USAGE命令,能直接告诉你一个 key 占多少字节:
MEMORY USAGE ts:sensor:1 # 返回 (integer) 1248392我的做法是先写一个压测脚本,按真实的数据分布灌 10 万个点,然后用MEMORY USAGE量一下,除以点数得到单点真实开销,再拿这个数去放大推算。这个方法比任何公式都准,因为你的 member 长度、时间戳跨度、编码状态都是真实的。
另外还要盯两个指标:used_memory和mem_fragmentation_ratio。时序场景因为大量小对象、频繁增删,内存碎片率偏高是常态,1.2 到 1.5 都算正常,超过 1.8 就该考虑重启整理或者调整分配器参数了。还有一点容易被忽略——如果开了 RDB 持久化,fork 时会有一份写时复制的内存放大,时序数据写入频繁,这个放大倍数可能不小,规划内存时要把这块算进去。
5. 查询怎么写才快:范围、聚合、降采样
5.1 范围查询与深分页的正确姿势
时序查询的第一原则是永远带着时间边界。我见过太多ZRANGE key 0 -1这种全量拉取的写法,平时数据少看不出问题,数据涨起来之后一次查询就能把带宽打满。正确的写法一定是限定 score 范围,并且带上LIMIT。
前面提过游标式分页,这里再补一个细节:如果业务需要“从最新往前翻”,可以用ZREVRANGEBYSCORE,参数顺序是 max 在前 min 在后,很多人第一次写会搞反:
ZREVRANGEBYSCORE ts:sensor:1 +inf 1700000000000 WITHSCORES LIMIT 0 100还有一个常见需求是“只取最新一条”。这时候别用ZRANGE ... LIMIT 0 1,用ZRANGE key -1 -1或者直接ZREVRANGE key 0 0,后者语义更清楚,性能也更好。
对于“统计某个时间段内有多少个点”这种需求,用ZCOUNT而不是把数据拉出来数:
ZCOUNT ts:sensor:1 1700000000000 1700003600000ZCOUNT在跳表上是 O(log N),非常快。同理,要“删掉某个时间段的数据”用ZREMRANGEBYSCORE,别自己拉出来一条条删。
5.2 聚合与降采样:把计算压力前移
时序数据最典型的查询不是“给我原始点”,而是“给我每分钟的平均值”“给我每小时的峰值”。如果你每次都把原始点拉到客户端再聚合,网络传输和客户端 CPU 都会成为瓶颈。我一般用两种方式解决。
第一种是服务端聚合。用 Lua 脚本把ZRANGEBYSCORE和聚合逻辑打包,一次调用返回结果。好处是数据不出 Redis,坏处是 Lua 里不能做太重的计算,会阻塞主线程。所以脚本里一定要限制处理的数据量,比如超过一万条就直接返回错误,让调用方换更粗的粒度。
-- KEYS[1] = 时序 key, ARGV[1] = 起始 score, ARGV[2] = 结束 score local data = redis.call('ZRANGEBYSCORE', KEYS[1], ARGV[1], ARGV[2], 'WITHSCORES') if #data > 20000 then return redis.error_reply('range too large, use coarser granularity') end local sum, count = 0, 0 for i = 2, #data, 2 do sum = sum + tonumber(data[i - 1]) count = count + 1 end if count == 0 then return 0 end return tostring(sum / count)第二种是预降采样,也就是写入的时候同时写多个粒度。原始点写原始序列,同时用一个后台任务每分钟算一次均值、最大值、最小值,写进分钟级序列;每小时再基于分钟级算一次,写进小时级序列。查询的时候按时间跨度选粒度:查最近一小时用原始点,查最近一天用分钟级,查最近一年用小时级。
这套做法的核心思想是把计算从查询时前移到写入时。代价是写入路径复杂一点、多占一点内存,但查询延迟能降低一个数量级。我在一个项目里做过对比:查一年的分钟级均值,不降采样要扫几千万条,降采样后只扫 52 万条,耗时从秒级降到几十毫秒。
6. 生产环境的三条生命线:过期、裁剪、降级
6.1 过期策略与冷热分层
时序数据天然有生命周期,方案设计的第一天就要把“数据活多久”定下来。ZSET 和 Hash 桶都只能整 key 过期,所以按时间切 key 是标配。我习惯按天切,key 形如ts:sensor:1:20240101,创建时顺手设个EXPIRE:
EXPIRE ts:sensor:1:20240101 604800切 key 还有个额外好处:查询的时候能精准定位到需要扫哪几个 key,不用在一个巨大的 key 上做范围扫描,单次查询的数据量天然被限制住了。
冷热分层的做法是把热数据留在 Redis,冷数据定期搬到别的地方。我的做法是每天凌晨跑一个任务,把 30 天前的整天 key 用SCAN遍历出来,批量导出成文件落到对象存储,然后从 Redis 删掉。查询历史数据的时候走另一条路径读文件。这样 Redis 里永远只保留一个窗口的数据,内存是可预测的。
6.2 裁剪与背压:防止写爆
即使做了过期,也得防着写入突增。设备批量上线、上游重试风暴、采集端 bug 导致高频写入,这些情况我都遇到过。防线有两层。
第一层是写入侧的限流。在采集端或者网关层做令牌桶,按设备维度限制每秒写入点数。超出的直接丢弃并打点告警,别让脏流量进 Redis。
第二层是结构侧的自动裁剪。Stream 用MAXLEN ~;ZSET 用定时ZREMRANGEBYSCORE清理窗口外的数据;Hash 桶靠整 key 过期。这三条都要配上,因为不同结构的能力不一样,混用的时候容易有遗漏。
还有一个容易被忽略的点是大 key 问题。不管是 ZSET 还是 Hash,单个 key 里的元素数一旦过百万,删除和过期都会变成阻塞操作。我一般把单 key 的元素数控制在十万级别以内,超过就继续切分。判断标准可以用MEMORY USAGE或者定期采样ZCARD/HLEN,超过阈值就告警。
7. 踩过的坑与排查速查表
7.1 三个真实的故障复盘
故障一:内存一夜涨了 40G。现象是凌晨内存告警,used_memory曲线陡直上升。排查下来是上游改了采集频率,从 5 秒一次改成 1 秒一次,写入量翻了五倍,但裁剪任务还是按原来的窗口跑,等于每天多存了四倍数据。根因是容量规划时只考虑了正常情况,没做写入量的监控和熔断。后来补了两条:写入速率突增 50% 自动告警,单 key 元素数超过阈值自动触发裁剪。
故障二:查询突然变慢十倍。业务反馈“查最近一小时”从 20ms 变成 200ms。SLOWLOG里看到大量ZRANGEBYSCORE,参数是-inf +inf——某个新接入的调用方没传时间边界,直接全量拉。因为 key 里有几十万个点,一次返回几百 KB,网络和序列化都扛不住。解决办法是在网关层做参数校验,没有时间范围的请求直接拒绝。
故障三:数据出现“时间倒流”。监控图上偶尔出现几个点跑到未来。查了半天发现是某台设备的时钟没同步,上报的时间戳比服务器时间快了几分钟。ZSET 里 score 是乱序进来的,虽然结构本身还能正确排序,但基于“时间戳单调递增”做的裁剪逻辑就出了问题,把正常数据误删了。后来在写入侧加了一道校验:时间戳超出服务器时间 ±5 分钟的直接拒收并记日志。
这三个故障的共同点是:技术方案本身没错,错在对输入数据的假设。做时序存储,永远不要假设上游给的数据是干净的、频率是恒定的、时间是准的。
7.2 常见问题速查表
| 现象 | 可能原因 | 排查命令 | 处理方式 |
|---|---|---|---|
| 内存持续上涨不回落 | 裁剪任务没跑或窗口设太大 | MEMORY USAGE key、ZCARD key | 补裁剪任务,缩短保留窗口 |
| 范围查询变慢 | 查询没带时间边界或 LIMIT | SLOWLOG GET 10 | 网关层强制校验参数 |
| 点数比预期少 | 同毫秒 member 冲突被覆盖 | ZCOUNT对比上游计数 | member 拼序列号,或接受语义 |
| Hash 内存暴涨 | 紧凑编码被撑破转哈希表 | OBJECT ENCODING key | 缩小桶粒度,控制 field 数量 |
| 时间戳精度丢失 | 用了纳秒级时间戳当 score | 抽样对比原始数据 | 降级到毫秒或微秒 |
| Stream 写入变慢 | 用了精确 MAXLEN 而非近似 | LATENCY LATEST | 改成MAXLEN ~ |
| 删除 key 时卡顿 | 大 key 删除阻塞主线程 | SLOWLOG、LATENCY DOCTOR | 用UNLINK异步删除,或分片切 key |
关于最后一条,DEL大 key 是同步的,几十万元素的 ZSET 删起来能卡住几百毫秒。Redis 4.0 之后有UNLINK,把内存回收放到后台线程,建议所有删除时序 key 的地方都换成它。这个改动很小,但能避免很多莫名其妙的超时。
8. 一点个人经验
最后说几个不在文档里、但我觉得最有用的点。
第一,先算账再选型,别先选型再算账。我现在做任何时序方案,第一步永远是拿真实数据压测一个小样本,量出单点开销,然后乘上总量看结果。这个过程大概两小时,能省掉后面两周的重构。
第二,能用简单结构解决的,不要上模块。Hash 桶看起来土,但它在内存上的优势是实打实的,而且不依赖任何额外组件。我见过太多项目为了“架构先进”引入专用组件,结果运维成本比业务代码还高。
第三,降采样要从第一天就做。原始数据只留短期,聚合数据留长期,这个分层越早做越省事。等到数据涨到几百 G 再回头补降采样,迁移成本会高得让你想放弃。
第四,把时间戳精度、member 唯一性、保留窗口这三个决策写进设计文档。这三个东西一旦上线就很难改,而且出了问题往往表现为“数据不对”而不是“报错”,排查起来极其痛苦。
第五,监控要盯着三个数:单 key 元素数、写入速率、内存增长率。这三个数任何一个异常,都意味着方案需要调整,而不是“再观察观察”。我踩过的坑里,绝大多数在事后看都能从这三个指标提前发现苗头。