首页 / 资讯中心 / 文章详情

Redis 时间序列数据存储实战:ZSET 分桶、TTL 与降采样

Redis 时间序列数据存储实战:ZSET 分桶、TTL 与降采样 ★ FEATURED ARTICLE
在监控采集、设备上报、业务埋点、行情快照这些场景里时间序列数据几乎是绕不开的一类东西它按时间顺序源源不断地产生每条记录都挂着一个时间戳写入量远大于查询量而且天然带“热冷分层”——最近一小时的点被反复读三个月前的点基本没人看。很多人第一反应是上专门的时序数据库但真到了工程落地往往因为链路太长、运维成本太高、或者只是想先把一个功能跑通最后还是把目光转回了手边现成的 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 只能存一个指标多指标要拆 keyZSET 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、网络进出流量同一时刻一起上报。这时候如果每个字段建一个 ZSETkey 的数量会翻好几倍管理起来很乱。我的做法是时间戳做 ZSET 的 scoremember 用一个短标识比如自增序号或者时间戳字符串明细字段塞进一个 Hashfield 用同样的标识。查询的时候先用 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(host127.0.0.1, port6379, decode_responsesTrue) def batch_write(metric, host, points): points: [(ts_ms, value_dict), ...] pipe r.pipeline(transactionFalse) for ts_ms, val in points: bucket time.strftime(%Y%m%d, time.localtime(ts_ms / 1000)) key fts: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()这里transactionFalse是有意的。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 1Lua 脚本在 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 MB1000 个指标单日约 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[i1]) / 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 metriccpu 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 就值得警惕了。处置方式按优先级排提前分桶从设计上就不让单个 key 长到这么大这是最优解已经长了的话用UNLINK替代DEL把内存回收放到后台线程如果需要迁移用SCAN加ZREMRANGEBYSCORE分批把数据搬到新 key每次搬一小批中间留出间隔。6.3 跨桶查询的时间边界按天分桶的时候桶的边界是自然日的 0 点。但用户查询的时间范围往往不按自然日来比如“昨晚 22 点到今天 6 点”跨了两个桶。这时候如果只查一个桶结果就会缺一半。我的处理方式是根据查询的起止时间戳先算出涉及哪些桶然后用 Pipeline 并发查出来再在客户端按时间戳归并。归并的时候要注意去重因为如果同一个时间点被写进了两个桶极少见通常是时钟回拨导致的会出现重复数据。时钟回拨这个问题值得单独提一句。如果采集端的时间被 NTP 往回调了可能出现新写入的点时间戳比已有的还小落在“过去”的桶里。如果那个桶已经过期被删了数据就会丢失。防御办法是在服务端做一次时间戳校正发现明显偏离当前时间的就按服务端时间兜底同时打日志告警。7. 几条踩出来的硬经验最后分享几条我在实际项目里反复验证过的判断都是文档里不会写、但能帮你省下大量返工时间的。第一能提前算的内存一定要提前算。我见过太多项目在设计阶段拍脑袋说“Redis 快先放 Redis”上线两周内存打满然后紧急加机器、改架构。前面那套 150 字节一个点的估算方法花十分钟算一遍能避免后面两周的救火。第二TTL 要和分桶一起设计不能事后补。分桶决定了数据天然的边界桶的边界就是最自然的过期单位。反过来如果一个 key 里混了不同时间的数据你就没法给它设一个合理的过期时间——设短了会误删新数据设长了旧数据一直占着。这是设计顺序的问题不是技术问题。第三查询接口的默认时间范围要收窄。前端一上来就问“给我最近一个月的数据”后端老老实实全查出来几百万个点推过去浏览器直接卡死。我的做法是在服务端强制限制单次查询的最大时间跨度和最大返回点数超了就自动降采样或者直接报错。这个限制看着不友好实际上保护了整个链路。第四不要迷信“一个指标一个 key”。指标维度多了之后key 的数量会爆炸式增长管理成本比想象的高。如果维度组合是有限且固定的可以考虑把多个维度编码进一个 key用 Hash 的 field 区分。当然这会牺牲一部分查询灵活性得权衡。第五压测要按真实的数据分布来。用随机时间戳压测和用真实业务的时间分布压测结果差别很大。真实场景里往往有明显的波峰比如整点集中上报这种突发写入对 Redis 的冲击比均匀写入大得多。我在一个项目里就吃过这个亏均匀压测下很稳的配置一到整点就被打出一堆慢查询后来加了写入侧的随机抖动才平滑下来。这套东西没有银弹每个项目的采样频率、指标规模、查询模式都不一样得对着自己的场景调参数。但思路是通用的先想清楚数据形态再选结构算好内存设计好 key 的边界和生命周期最后才是写代码。顺序反了后面全是补丁。
阅读完成 · 觉得有帮助?
咨询建站