☰
Redis 时间序列数据存储实战:ZSET 分桶、TTL 与降采样
2026/10/27 2:48:06 网站建设 项目流程

在监控采集、设备上报、业务埋点、行情快照这些场景里,时间序列数据几乎是绕不开的一类东西:它按时间顺序源源不断地产生,每条记录都挂着一个时间戳,写入量远大于查询量,而且天然带“热冷分层”——最近一小时的点被反复读,三个月前的点基本没人看。很多人第一反应是上专门的时序数据库,但真到了工程落地,往往因为链路太长、运维成本太高、或者只是想先把一个功能跑通,最后还是把目光转回了手边现成的 Redis。用 Redis 存时间序列数据这件事,能做,而且能做得挺漂亮,但它不像关系型数据库那样有现成的表结构和索引,你得自己设计 key、自己算内存、自己处理聚合和过期。这篇内容就把我这些年在这上面折腾出来的完整思路摊开讲:从数据结构选型、时间戳精度陷阱、分桶设计,到批量写入、范围查询、降采样聚合,再到那些文档里不会写的坑。适合已经会用 Redis 基础命令、准备把指标数据真正落到生产环境的开发者参考,新手也能看懂,因为每一步我都尽量把“为什么要这么做”讲清楚。

1. 先搞清楚要存什么:时间序列数据的形态与Redis的适配边界

动手设计之前,我一般会先在纸上把数据的形态画一遍。时间序列数据看着简单,实际上有几个特征会直接影响存储方案的选择,忽略任何一个,后期都会返工。

1.1 时间序列数据的四个典型特征

第一个特征是写多读少且写入持续。一个采集点可能每 5 秒上报一次,一天就是一万七千多条记录,而查询往往是按时间段翻看,频率远低于写入。这意味着写入路径必须足够轻,任何一次写入带来的额外开销都会被放大成千上万倍。

第二个特征是按时间范围查询为主。业务方要的通常是“昨天 14 点到 15 点的 CPU 使用率曲线”,而不是“第 12345 条记录是什么”。所以数据结构必须能高效支持按时间区间扫描,而不是按主键点查。

第三个特征是数据有生命周期。原始采样点保留 7 天、聚合后的分钟级数据保留 90 天,这种分层保留策略几乎是标配。如果存储层不支持自动过期,你就得自己写定时任务去删,删的时候还得小心别把 Redis 打满。

第四个特征是需要降采样聚合。原始点太密,画图的时候前端根本渲染不过来,也不该把几万个点全推到浏览器。所以系统要能在服务端把 5 秒粒度聚合成 1 分钟粒度,取平均值、最大值、求和这些。

把这四点摆出来,Redis 的适配性就很清楚了:它擅长高频写入,有序集合(ZSET)天然按 score 排序,支持区间扫描,key 级别的 TTL 能自动清理,聚合则可以通过 ZUNIONSTORE 或者专业模块完成。短板在于它不是为时序场景专门设计的,没有内置的降采样规则,内存成本也偏高,需要你在设计上花心思去压。

1.2 Redis 几种数据结构对号入座

很多人一上来就问“时间序列用 Redis 哪个类型存”,其实这个问题没有唯一答案,取决于你要存的是什么粒度、查的是什么维度。我把常用的几种摆在一起,你可以对着自己的场景挑:

数据结构适合存什么优势明显短板
ZSET单指标的 (时间戳 → 值) 序列天然按时间排序、区间扫描快、支持多 key 合并一个 key 只能存一个指标,多指标要拆 key
ZSET + Hash时间戳做 score,明细字段放 Hash一个时间点可挂多个字段,灵活两次写入,非原子时需要 Lua 或事务
Stream事件流、日志、带自增 ID 的消息自带时间戳 ID、支持消费组、MAXLEN 裁剪查询聚合能力弱,不适合复杂时间区间分析
String + INCR计数器类指标(每分钟 PV 之类)极省内存、写入最快只能存数值,无法回溯明细
RedisTimeSeries标准时序场景原生降采样、保留策略、按标签多维查询需要额外加载模块,云环境不一定开放

我个人的默认选择是ZSET 打底,因为它的通用性最好,几乎任何 Redis 版本都能用,运维不用额外申请权限。只有当指标数量特别多、又需要按多个标签维度切片查询时,我才会考虑引入 RedisTimeSeries 模块。Stream 我一般只在“事件本身就是业务主体”的场景下用,比如操作审计流水,而不是纯粹的数字指标。

注意:不要试图用一个大 ZSET 存所有指标的数据,把指标名塞进 member 里做前缀区分。这样做短期看着省事,长期会让这个 key 变成超级大 key,无论是删除、迁移还是主从同步都会非常痛苦。

2. 方案选型:三条路线的取舍逻辑

选型这件事,我见过太多团队在项目初期随手选了一个,等到数据量涨上来再想换,成本高得吓人。下面把三条主要路线的取舍讲透。

2.1 ZSET 打底:为什么它是通用解

ZSET 的核心结构是“member + score”,Redis 内部用跳表加哈希表实现,插入和按 score 范围查找都是 O(log N)。把时间戳放进 score、把数值或者一段序列化后的明细放进 member,就得到了一个按时间排序的序列。

这个方案最大的好处是零依赖。不需要装模块,不需要改配置,任何托管 Redis 都能跑。其次是查询能力够用:ZRANGEBYSCORE 拿区间,ZCOUNT 拿数量,ZREMRANGEBYSCORE 清理旧数据,ZUNIONSTORE 做多 key 合并,这几条命令组合起来能覆盖 90% 的时序查询需求。

它的代价在于每个元素都有额外开销。ZSET 每个成员除了数据本身,还要维护跳表节点和哈希表条目,实际内存占用往往是纯数据的 3 到 5 倍。这一点后面算内存的时候会展开。

2.2 ZSET + Hash:多字段指标的组合拳

真实业务里的一个采样点往往不是一个数字,而是好几个:CPU 使用率、内存使用率、磁盘 IO、网络进出流量,同一时刻一起上报。这时候如果每个字段建一个 ZSET,key 的数量会翻好几倍,管理起来很乱。

我的做法是时间戳做 ZSET 的 score,member 用一个短标识(比如自增序号或者时间戳字符串),明细字段塞进一个 Hash,field 用同样的标识。查询的时候先用 ZRANGEBYSCORE 拿到时间区间内的标识列表,再用 HMGET 或者一次 Pipeline 批量取明细。

这样设计的好处是 key 数量可控,一个指标一个 ZSET 加一个 Hash。代价是写入变成两条命令,读取也可能变成两轮,所以必须用 Pipeline 或者 Lua 保证效率和一致性。这个组合我在好几个项目里用过,稳定性和可维护性都不错。

2.3 Stream 与 RedisTimeSeries:什么时候值得上

Stream 是 Redis 5.0 引入的结构,每条消息有一个毫秒时间戳-序号形式的 ID,天然有序,还支持消费组和 MAXLEN 裁剪。它很适合“事件流”这种场景,比如设备上报的原始报文、用户行为埋点。但拿它做指标分析就有点别扭:它的查询只能按 ID 区间,做不了按值的聚合,也不支持按多个标签过滤。所以我的判断标准是——如果你要的是“回放一批事件”,用 Stream;如果你要的是“画出某个指标的曲线”,别用 Stream。

RedisTimeSeries 是官方模块,提供了 TS.ADD、TS.RANGE、TS.MRANGE 这些命令,原生支持保留策略(retention)和降采样规则(compaction rule),还能给每个序列打标签,按标签做多维过滤。这套能力确实专业,指标量上千、需要按host、region、service多个维度切片查询的时候,它能省掉大量自己造轮子的工作。但它需要额外加载模块,部分云厂商的托管实例不开放模块加载权限,这是选型时必须先确认的前置条件。

3. 核心细节:时间戳、score 精度与 key 设计

这一节是整篇里最容易出错、也最容易被忽略的部分。很多线上问题最后追根溯源,都是这里的设计没想清楚。

3.1 score 是 double,精度只有 53 位

这条必须先讲,因为它是硬约束。ZSET 的 score 类型是 IEEE 754 双精度浮点数,尾数部分 52 位,加上隐含的整数位,能精确表示整数的上限是 2 的 53 次方,约等于 9.007×10^15。超过这个范围,相邻整数就无法区分了。

对照一下常见的时间戳单位:

  • 秒级时间戳,当前约 1.7×10^9,远小于上限,绝对安全;
  • 毫秒级时间戳,当前约 1.7×10^12,安全,还有三个数量级的余量;
  • 微秒级时间戳,当前约 1.7×10^15,已经贴近 9×10^15 的上限,虽然还能用,但余量只剩五倍多,往后推几十年会逼近;
  • 纳秒级时间戳,当前约 1.7×10^18,已经超了,写进去会被静默舍入,同一个纳秒区间内的多个点可能挤到同一个 score 上。

所以我的建议很明确:用毫秒时间戳做 score,别用纳秒。如果你的数据源是纳秒精度,先除以 1000 降到毫秒,或者干脆把纳秒的低位信息编码进 member 里。这个坑我踩过一次,表现是某些点在查询时“消失”了,排查了半天才发现是两个不同的纳秒时间戳被舍入成了同一个 score,后写的覆盖了先写的。

注意:score 相同的时候,ZSET 会按 member 的字典序排列,写入不会互相覆盖(它们是不同的 member),但按 score 范围查询时你拿到的是两个点,看着就像数据重复。真正的覆盖发生在 member 也相同的时候。

3.2 时间戳单位的选择要有全局约定

单位这件事,最怕的是团队里有人用秒、有人用毫秒。表面上查出来的数据都对,一画图就发现曲线错位得离谱。我的做法是在 key 的命名里把单位写进去,比如ts:ms:cpu:host01,任何人看到 key 就知道里面的 score 是什么单位,沟通成本直接降到零。

另外,时间戳的生成也有讲究。如果由客户端生成,务必注意多台机器的时钟偏差,NTP 同步没做好的话,不同机器写入的点在时间轴上会互相穿插,聚合出来的结果很难看。如果由服务端统一生成,那就要接受网络延迟带来的抖动。我的取舍是:采集侧带原始时间戳,服务端只做校正和兜底,因为采集时间才是数据的真实语义。

3.3 key 分桶:把大 key 扼杀在摇篮里

一个指标如果每 5 秒一个点,一年就是 630 万个点。全塞一个 ZSET 里,内存先不说,光是删除和迁移就够运维喝一壶的。所以必须按时间维度分桶。

分桶的粒度怎么定?我的经验公式是:让单个桶内的元素数量控制在 1 万到 10 万之间。太少了 key 数量爆炸,管理麻烦;太多了又变成大 key。按 5 秒采样算,一小时是 720 个点,一天是 17280 个点,所以按天分桶比较合适。如果采样频率是 1 秒,一小时 3600 个点、一天 86400 个点,那就按小时分桶。

key 的命名我一般写成这样:

# 格式:ts:{单位}:{指标}:{维度}:{时间桶} ts:ms:cpu:host01:20240612 ts:ms:memory:host01:20240612

分桶之后的另一个好处是过期策略变得极其简单:直接给桶 key 设 TTL,比如保留 30 天,EXPIRE设 2592000 秒就完事了,不用写任何清理任务。注意 TTL 要在每次写入时续期,或者干脆按桶的结束时间来算,避免正在写入的桶被提前删掉。我一般用“桶的结束时间 + 保留期”作为过期时间点,写入时用EXPIREAT设置绝对时间戳,这样同一个桶的多次写入不会互相覆盖 TTL。

3.4 member 的设计要兼顾幂等和可读

member 存什么,直接影响后续查询的便利性。几种常见做法各有取舍:

  • member 存时间戳字符串:好处是直观,调试的时候ZRANGE一看就知道每个点是什么时间;坏处是重复上报时会互相覆盖,这其实是个优点——天然幂等。
  • member 存自增序号:省空间,但序号生成需要额外的计数器,而且不具备幂等性。
  • member 存序列化后的完整记录:查询一次就拿到全部信息,省掉一次 Hash 查询;但 member 变长,内存开销上升,而且 ZSET 内部要按字典序比较 member,长字符串的比较成本更高。

我通常选第一种,用时间戳字符串做 member。它带来的幂等性在设备重传、消息重复投递的场景下非常有用,写两次和写一次的结果完全一样。如果同一个毫秒内可能有多个点,就在后面加一个短序号后缀,比如1718000000123-01。

4. 实操:从零搭一套可落地的写入链路

讲完设计,该动手了。这一节给出可以直接跑的完整流程,从单条写入到批量写入,再到原子性保证和内存估算。

4.1 单条写入的完整命令序列

假设要记录某台主机的 CPU 使用率,采样周期 5 秒,用 ZSET + Hash 组合方案:

# 1. 写入主序列,score 是毫秒时间戳,member 也是时间戳 ZADD ts:ms:cpu:host01:20240612 1718000000000 "1718000000000" # 2. 写入明细字段 HSET ts:ms:cpu:host01:20240612:detail "1718000000000" '{"cpu":37.2,"mem":61.8,"io":12}' # 3. 给桶设置过期时间(桶结束时间 + 30 天) EXPIREAT ts:ms:cpu:host01:20240612 1718640000 EXPIREAT ts:ms:cpu:host01:20240612:detail 1718640000

这里有个细节值得说一下:ZSET 和 Hash 的 key 用了:detail后缀区分,但过期时间必须同步设置,否则会出现主序列已删、明细还占着内存的情况。我在项目里干脆把这两个 key 的过期时间计算抽成一个公共函数,避免两边算错。

如果明细字段不多、查询又总是同时要时间点和值,其实可以省掉 Hash,直接把值编码进 member,比如1718000000000:37.2。查询时按:切一下就行。这样只用一次 ZADD,写入成本减半。代价是 member 变长,以及后续想加字段要改格式。

4.2 Pipeline 批量写入:吞吐提升的关键

单条写入 1000 次和 Pipeline 批量写入 1000 条,性能差距是数量级的。因为每条命令都有一次网络往返,Pipeline 把这 1000 次往返压成一次。实测下来,同样的硬件,单条写入的 QPS 大概在几千,Pipeline 批量能做到几万甚至更高。

import redis import time import json r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) def batch_write(metric, host, points): """points: [(ts_ms, value_dict), ...]""" pipe = r.pipeline(transaction=False) for ts_ms, val in points: bucket = time.strftime("%Y%m%d", time.localtime(ts_ms / 1000)) key = f"ts:ms:{metric}:{host}:{bucket}" member = str(ts_ms) pipe.zadd(key, {member: ts_ms}) pipe.hset(f"{key}:detail", member, json.dumps(val)) # 过期时间设为桶结束时间 + 30 天 expire_at = int(time.mktime(time.strptime(bucket, "%Y%m%d")) + 86400 + 2592000) pipe.expireat(key, expire_at) pipe.expireat(f"{key}:detail", expire_at) pipe.execute()

这里transaction=False是有意的。Pipeline 本身只保证批量发送,不保证原子性,对时序写入来说完全够用——我们不需要跨命令的强一致,只需要高效。如果开了事务模式,每条命令都要入队再统一执行,客户端内存占用会上去,反而拖慢速度。

4.3 需要原子性的时候上 Lua

批量写入不需要原子性,但“写主序列 + 写明细 + 设过期”这三步如果在某些场景下必须捆绑成功,就得用 Lua 脚本。比如告警系统根据写入结果做判断,写了一半会导致状态不一致。

-- KEYS[1] = 主序列 key, KEYS[2] = 明细 key -- ARGV[1] = timestamp(ms), ARGV[2] = 明细 JSON, ARGV[3] = 过期时间戳 redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1]) redis.call('HSET', KEYS[2], ARGV[1], ARGV[2]) redis.call('EXPIREAT', KEYS[1], ARGV[3]) redis.call('EXPIREAT', KEYS[2], ARGV[3]) return 1

Lua 脚本在 Redis 里是单线程执行的,天然原子。但要注意两点:脚本里的操作要尽量少,因为执行期间会阻塞其他命令;所有 key 必须通过 KEYS 参数传入,不能在脚本里自己拼 key 名,否则在集群模式下会报错。

4.4 内存估算:把账算在前面

这一步很多人跳过,等到内存告警了才回头查。我一般会在设计阶段就把账算清楚。以 ZSET 为例,每个元素的实际开销大致包括:

组成部分估算开销
跳表节点(含多层指针)约 20~40 字节
哈希表条目(dictEntry)约 24~32 字节
member 的 SDS 字符串长度 + 头部约 3~16 字节
score 的 double8 字节

按 member 是 13 位毫秒时间戳来算,一条记录在 ZSET 里的实际占用大概是80 到 100 字节。再算上一份 Hash 明细,每条再加 60 到 80 字节。所以一个采样点的综合成本按150 字节估比较稳妥。

套进去算:5 秒采样、一天 17280 个点、1000 个指标:

  • 单指标单日:17280 × 150B ≈ 2.6 MB
  • 1000 个指标单日:约 2.6 GB
  • 保留 30 天:约 78 GB

这个数字出来,很多人的第一反应是“Redis 扛不住”。确实,纯用 Redis 存 30 天原始数据不现实。所以实际方案一定是热数据放 Redis、冷数据落对象存储或者时序库,Redis 里只保留最近几小时到几天的数据,TTL 设短一点。这个分层设计比在 Redis 里硬扛要理智得多。

注意:上面的估算是按成员数量线性推的,实际会因为跳表层级、内存分配器碎片、key 本身的元数据开销而略高。留 20% 的余量比较安全。

5. 查询与聚合:范围扫描、降采样与多维合并

写入链路搭好之后,查询才是业务方真正天天在用的部分。这一节的几个技巧,能帮你避开响应时间突然飙到几百毫秒的尴尬。

5.1 ZRANGEBYSCORE 的区间和分页陷阱

最基本的区间查询长这样:

# 查 14:00 到 15:00 的数据(毫秒时间戳) ZRANGEBYSCORE ts:ms:cpu:host01:20240612 1718000000000 1718003600000 WITHSCORES

默认是闭区间,两个端点都包含。如果想排除端点,在 score 前面加左括号,比如(1718000000000。这个语法很实用,尤其是在连续分页的时候,用上一页最后一条的 score 加左括号就能避免重复。

分页用LIMIT offset count。这里有个坑:深分页会越来越慢。因为 ZRANGEBYSCORE 是顺着跳表跳过去再跳过 offset 个元素,offset 越大跳得越久。1000 万条数据里翻到第 50 万条,响应时间可能就到几十毫秒了。我的解决办法是用基于时间游标的分页,每次都用上一页最后一条的时间戳作为下一页的起点,而不是用 offset。时间序列数据天然适合这种游标方式,因为时间本身就是有序的。

跨天查询也有讲究。如果按天分桶,查“昨天 23 点到今天 1 点”就得同时查两个 key,然后自己合并。合并的时候要注意顺序,两个 ZRANGEBYSCORE 的结果要按时间戳归并排序,不能简单拼接。如果跨的桶比较多,用 Pipeline 并发查一批 key,然后归并,比串行查快得多。

5.2 ZUNIONSTORE 做降采样聚合

前面说过,前端画图不需要几万个点,1 分钟粒度就够了。这时候可以在写入的时候就顺便把数据聚合到另一个细粒度更粗的 key 里,查询时直接读聚合 key。

ZUNIONSTORE 可以把多个 key 合并成一个:

# 把 60 个秒级桶合并成一个分钟级 key(简化示意) ZUNIONSTORE ts:ms:cpu:host01:20240612:minute 60 ts:sec:cpu:host01:20240612:00 ... AGGREGATE AVG

但更实用的做法是用 Lua 做服务端聚合:读出一个时间区间内的所有原始点,在脚本里按分钟分桶求平均,把结果写到聚合 key 里。这样一次脚本执行就完成了降采样,比客户端拉全量再算要省带宽得多。

-- KEYS[1] = 原始序列, KEYS[2] = 聚合结果 key -- ARGV[1] = 起始 ts, ARGV[2] = 结束 ts, ARGV[3] = 聚合粒度(ms), ARGV[4] = TTL local raw = redis.call('ZRANGEBYSCORE', KEYS[1], ARGV[1], ARGV[2], 'WITHSCORES') local buckets = {} for i = 1, #raw, 2 do local bucket = math.floor(tonumber(raw[i+1]) / tonumber(ARGV[3])) * tonumber(ARGV[3]) buckets[bucket] = (buckets[bucket] or 0) + tonumber(raw[i]) end for b, v in pairs(buckets) do redis.call('ZADD', KEYS[2], b, v) end redis.call('EXPIREAT', KEYS[2], ARGV[4]) return 1

这段脚本的意图很直白:按粒度切桶,把落在同一个桶里的值累加。注意这里假设 member 本身就是数值,才能直接tonumber(raw[i])。如果 member 是纯时间戳、值存在 Hash 里,就得在脚本里再查一次 Hash,脚本会变长、阻塞时间也会增加。所以我更倾向于在聚合场景下把值直接编码进 member,用值:时间戳或者干脆 member 就是值、score 是时间戳。这也说明了为什么前面 member 的设计要提前想清楚——它会影响后面的每一个环节。

5.3 RedisTimeSeries 的降采样规则

如果确定要用模块方案,那降采样这件事基本是白送的。创建序列的时候指定保留期,再挂上聚合规则:

# 创建原始序列,保留 7 天 TS.CREATE cpu:host01 RETENTION 604800000 LABELS host host01 metric cpu # 创建 1 分钟聚合序列,保留 90 天 TS.CREATE cpu:host01:1m RETENTION 7776000000 LABELS host host01 metric cpu # 给原始序列挂规则:每 60 秒聚合一次,取平均值,写到聚合序列 TS.CREATERULE cpu:host01 cpu:host01:1m AGGREGATION avg 60000

这套规则由服务端自动执行,写入原始数据的时候聚合序列自动更新,业务侧完全不用写定时任务。查询的时候按标签过滤:

TS.MRANGE - + FILTER metric=cpu AGGREGATION avg 600000

-和+表示最小和最大时间戳,FILTER按标签筛,最后那个AGGREGATION是查询时再降一次采样。这个能力确实比手写 Lua 干净得多,代价就是前面说的模块依赖。

6. 常见问题与排查实录

实际跑起来之后,问题基本都集中在内存、精度、大 key 和过期这几个方面。下面这张表是我这些年遇到频率最高的一批问题,按出现概率排的:

现象可能原因排查方法处理方式
内存持续上涨,TTL 看着设了但没删只给主 key 设了 TTL,明细 key 漏了TTL逐个检查相关 key补设过期,或改用统一的前缀管理
查询某些时间点数据缺失score 精度溢出被舍入检查时间戳单位是否用了纳秒降级到毫秒,或低位编码进 member
单次查询响应慢到几百毫秒深分页 offset 太大用SLOWLOG看具体命令改用时间游标分页
主从延迟突然变大写入大 key 或批量脚本INFO replication看 offset 差拆分 key,缩小单次脚本规模
删除旧数据时卡顿直接DEL大 keyMEMORY USAGE看 key 大小改用UNLINK异步删除
聚合结果对不上跨桶查询没做归并排序打印两个桶的结果对比按时间戳归并,不是简单拼接

6.1 内存不释放:TTL 的连坐问题

这是最高频的一个。现象是明明设了过期时间,内存监控却一直往上走。原因通常是有个“伴生 key”忘了设 TTL,比如明细 Hash、聚合结果、索引 ZSET。主 key 到期被删了,伴生 key 还在,一天天累积。

排查方法很直接:用SCAN扫一遍,按前缀匹配,逐个TTL检查,值是 -1 的就是没设过期的。更省事的办法是在写入的时候就用一个统一的函数生成所有相关 key 并统一设过期,从流程上杜绝漏设。

还有一个隐藏的坑:Redis 的过期删除是惰性加定期采样,不是精确到点就立刻释放内存。所以 TTL 到期之后内存不会马上降,可能延迟几十秒甚至更久。看到内存曲线滞后于 TTL 曲线,别慌,这是正常的。如果确实急着释放,可以用UNLINK主动删。

6.2 大 key:删除和迁移的双重噩梦

一个桶如果装了上百万个元素,它就是个典型的大 key。危害有两个:一是删除的时候会阻塞主线程(DEL是同步的),二是主从同步和集群迁移的时候会长时间卡住。

判断标准我一般用MEMORY USAGE key,超过 10MB 就值得警惕了。处置方式按优先级排:

  1. 提前分桶,从设计上就不让单个 key 长到这么大,这是最优解;
  2. 已经长了的话,用UNLINK替代DEL,把内存回收放到后台线程;
  3. 如果需要迁移,用SCAN加ZREMRANGEBYSCORE分批把数据搬到新 key,每次搬一小批,中间留出间隔。

6.3 跨桶查询的时间边界

按天分桶的时候,桶的边界是自然日的 0 点。但用户查询的时间范围往往不按自然日来,比如“昨晚 22 点到今天 6 点”,跨了两个桶。这时候如果只查一个桶,结果就会缺一半。

我的处理方式是:根据查询的起止时间戳,先算出涉及哪些桶,然后用 Pipeline 并发查出来,再在客户端按时间戳归并。归并的时候要注意去重,因为如果同一个时间点被写进了两个桶(极少见,通常是时钟回拨导致的),会出现重复数据。

时钟回拨这个问题值得单独提一句。如果采集端的时间被 NTP 往回调了,可能出现新写入的点时间戳比已有的还小,落在“过去”的桶里。如果那个桶已经过期被删了,数据就会丢失。防御办法是在服务端做一次时间戳校正,发现明显偏离当前时间的就按服务端时间兜底,同时打日志告警。

7. 几条踩出来的硬经验

最后分享几条我在实际项目里反复验证过的判断,都是文档里不会写、但能帮你省下大量返工时间的。

第一,能提前算的内存一定要提前算。我见过太多项目在设计阶段拍脑袋说“Redis 快,先放 Redis”,上线两周内存打满,然后紧急加机器、改架构。前面那套 150 字节一个点的估算方法,花十分钟算一遍,能避免后面两周的救火。

第二,TTL 要和分桶一起设计,不能事后补。分桶决定了数据天然的边界,桶的边界就是最自然的过期单位。反过来,如果一个 key 里混了不同时间的数据,你就没法给它设一个合理的过期时间——设短了会误删新数据,设长了旧数据一直占着。这是设计顺序的问题,不是技术问题。

第三,查询接口的默认时间范围要收窄。前端一上来就问“给我最近一个月的数据”,后端老老实实全查出来,几百万个点推过去,浏览器直接卡死。我的做法是在服务端强制限制单次查询的最大时间跨度和最大返回点数,超了就自动降采样或者直接报错。这个限制看着不友好,实际上保护了整个链路。

第四,不要迷信“一个指标一个 key”。指标维度多了之后,key 的数量会爆炸式增长,管理成本比想象的高。如果维度组合是有限且固定的,可以考虑把多个维度编码进一个 key,用 Hash 的 field 区分。当然这会牺牲一部分查询灵活性,得权衡。

第五,压测要按真实的数据分布来。用随机时间戳压测和用真实业务的时间分布压测,结果差别很大。真实场景里往往有明显的波峰,比如整点集中上报,这种突发写入对 Redis 的冲击比均匀写入大得多。我在一个项目里就吃过这个亏,均匀压测下很稳的配置,一到整点就被打出一堆慢查询,后来加了写入侧的随机抖动才平滑下来。

这套东西没有银弹,每个项目的采样频率、指标规模、查询模式都不一样,得对着自己的场景调参数。但思路是通用的:先想清楚数据形态,再选结构,算好内存,设计好 key 的边界和生命周期,最后才是写代码。顺序反了,后面全是补丁。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询