Redis键数量膨胀治理:Go语言降key实战与监控
这两年我接手过好几个Redis集群最头疼的往往不是QPS打满也不是某个大key阻塞而是键数量在不知不觉中膨胀到几千万上亿。内存看着没涨多少RDB文件越来越大主从同步越来越慢连做一次全量SCAN都要分钟级——这些问题凑到一起基本就是缓存系统“中年危机”的前兆。最近我针对手头一整套Go微服务做了一次缓存降key专项从方案的“为什么”到代码的“怎么写”都走了一遍。这篇文章就想把这段经验原原本本整理出来为什么key数量会拖垮Redis如何用hash、zset、bitmap这些基础结构把散key收拢遇到大hash、field过期、迁移切换这些坑怎么处理以及落地之后怎么让key数量始终处于可控状态。适合正在做缓存治理、打算从零优化Redis成本或者单纯想看看Go语言操作Redis有哪些实用套路的读者。1. 为什么key数量膨胀是“隐形杀手”1.1 先从内存账本说起很多同学觉得Redis是内存数据库value大小才是大头key本身占不了多少空间。这话放在几十万个key时没错一旦上了千万级算法就不一样了。一个最简单的场景假设业务里存了4000万个“user:{id}:session”之类的keyvalue是32字节的sessionId。表面上你只存了约1.3GB的value但实际真实内存占用可能超过3GB。多出来的那部分就是每个key在Redis内部的三层负担。第一层是全局哈希表里的dictEntry64位系统下大约占24到32字节负责保存指针、散列值第二层是key本身的SDS结构有字符串头部的固定开销加上字段长度本身第三层是value对象的robj头和它内部的SDS头。你表面上写了一个“user:10001:session”实际上Redis要额外付出去近100字节的“手续费用”。我按64位Redis 7.x的环境粗算过4000万个独立小key光key结构和对象头的固定开销就有1.4GB到1.8GB。这还只是“存着不动”的状态如果这批key两天后过期了期间产生的碎片还没还给操作系统你就会看到内存只增不减。1.2 key数量膨胀不只是内存问题key多了连锁反应会一波接一波。首先是RDB持久化和AOF重写的成本急剧上升。RDB要把所有key遍历一遍序列化key数量越多备份文件越大主从库之间全量同步的时间越长。遇到过几次主库切换从库因为RDB太大迟迟同步不完整个切换窗口拉长到几十分钟业务侧只能干瞪眼。其次是慢查询。线上排查问题最常用的SCAN命令虽然不会阻塞但遍历几千万个key做一趟也要几十秒甚至分钟级。如果之前有人图省事直接用了KEYS那Redis直接卡死这个不用我多解释。除此之外过期key的清理也要分CPU去轮询key太多会让过期扫描的周期变长内存里堆积的过期key反而更多。再有就是集群模式的槽位迁移。Redis Cluster做reshard时key数量决定槽位之间的迁移量。几千万个key迁起来不光迁移耗时还可能因为单槽slot内的key分布不均导致某些节点先被掏空、另一个节点还在被疯狂写入热点问题雪上加霜。所以降key数量这件事表面是在省内存实际上是在降低持久化成本、缩短异常切换窗口、让日常巡检更轻松。这账算下来谁做谁受益。2. 动手前的关键一步给key做分类体检2.1 先摸清存量用Go写一个扫描分类工具优化任何系统之前我都不建议拍脑袋定方案先把现状摸清楚。Redis提供了一个DBSIZE命令能看总数但我们更想知道的是“这些key到底是什么样的前缀、哪一类占大头”。这时候SCAN要比KEYS安全一万倍。我用Go写了一个简单的key分类扫描器用到的是github.com/redis/go-redis/v9这个库。核心逻辑就是用SCAN游标遍历每10000个key一批把key按“冒号分隔的第一个字段”作为前缀同时用TYPE命令统计类型分布打印出来。package main import ( context fmt strings time github.com/redis/go-redis/v9 ) func main() { ctx : context.Background() rdb : redis.NewClient(redis.Options{ Addr: localhost:6379, }) prefixCount : make(map[string]int64) typeCount : make(map[string]int64) var total int64 iter : rdb.Scan(ctx, 0, *, 10000).Iterator() for iter.Next(ctx) { key : iter.Val() total seg : strings.Split(key, :) if len(seg) 0 { prefixCount[seg[0]] } // 注意每一条都执行TYPE会有额外开销建议抽样统计 if total%100 0 { t, err : rdb.Type(ctx, key).Result() if err nil { typeCount[t] } } } if err : iter.Err(); err ! nil { panic(err) } fmt.Printf(total keys: %d\n, total) fmt.Println(top prefix:) for k, v : range prefixCount { fmt.Printf( %s: %d\n, k, v) } fmt.Println(type sample:) for k, v : range typeCount { fmt.Printf( %s: %d\n, k, v) } }这段代码可以直接在本地跑前提是Redis的maxmemory足够支撑你这么扫描或者放到低峰期执行。扫完一轮基本就能知道到底是session类key多还是登录token类key多或者库存商品类key多。后面所有方案都建立在这份清单上。2.2 三类“可合并key”和两类“绝不能合并”光有清单还得有判断标准。根据我多次做缓存治理的经验可以把业务key粗暴分成三大类“能合并的”和两类“别乱动的”。适合用hash合并的首先是“业务对象 固定字段”这类典型的比如用户资料、商品扩展属性、订单状态。它们的共同点是同一个对象下有多个字段字段数量有限且相对稳定每次查询需要同时读取好几个字段。这种情况下与其建“user:10001:name”、“user:10001:age”、“user:10001:city”三个key不如直接建一个“user:10001”的hash把name、age、city作为field存进去。适合用zset/set/bitmap合并的是“同一前缀下的离散ID”类。比如“order:created:20250601”这种时间分桶key或者“online_user_10086”这种按用户ID拆分的状态key。它们的共性是你在用key名称本身存储“二级数据”而Redis本来就有集合、有序集合这些专门存映射关系的数据结构完全不需要占key位。不适合合并的也讲两类。第一类是对TTL要求很精细的keyA字段要给5分钟过期B字段要保留7天塞进同一个hash后过期粒度就变粗了所以必须拆开。第二类是超高频单独写入的key比如每个请求都更新一次的计数器这种情况下把几百个计数器合到hash里反而会因为每次HSET整个hash交互而导致性能下降和维护复杂。2.3 让DeepSeek参与审计但结论要人工把关上次做降key专项我顺手把扫描工具的样本输出贴给了DeepSeek让它按“独立KV、可聚合到hash、可用bitmap/set替代、可加TTL收敛”几种标签做聚类建议。这个流程确实帮我省了很多肉眼扫key的时间尤其是面对几百个业务前缀时AI的归纳能力比人快得多。不过我必须强调一句AI给的建议可以作为候选但绝不能直接当结论。它不知道你的业务读模型也不知道某个key背后是不是有对TTL精度有依赖的场景。比如它可能建议把“rate:limit:user:123”和“rate:limit:user:456”合并成一个hash但如果这两个key期望的过期时间分别是1分钟和24小时合在一起就完全错误。所以我的做法是先让DeepSeek出候选再让负责对应业务的工程师做二次确认最后才进入开发。工具的价值是把重复劳动减掉把关的责任还是在人。3. 五种经过验证的降key代码模板3.1 模板一hash替代“前缀相同后缀ID”的散key这是最立竿见影的一招。我们线上最常见的坏味道就是“user:10001:profile:name”、“user:10001:profile:age”这种把字段塞进key名的写法。每个字段单独占一个key用户量一大key数量乘以字段数直接爆炸。改造之后同一个用户的资料统一收敛到一个hash里面。用Go操作起来非常直接ctx : context.Background() rdb : redis.NewClient(redis.Options{Addr: localhost:6379}) pipe : rdb.Pipeline() for _, uid : range []string{10001, 10002, 10003} { pipe.HSet(ctx, user:uid, map[string]interface{}{ name: zhangsan, age: 30, city: beijing, }) } _, err : pipe.Exec(ctx) if err ! nil { panic(err) } // 读取时一次把需要的字段全拿出来 vals, err : rdb.HMGet(ctx, user:10001, name, age).Result() if err ! nil { panic(err) } fmt.Println(vals[0], vals[1])用Pipeline是因为HSET多个hash时可以减少RTT。但要注意一个底层限制hash这个结构在field数量少、value长度短时会采用listpack紧凑编码一旦field数量超过hash-max-listpack默认128个Entry或者单个value长度超过64字节就会转成真正的hashtable结构内存优势就会减小。所以这种模板适合“单对象字段小于100个且value短”的场景。比如用户资料、商品扩展字段、配置项都合适。如果一个对象的字段超过几百个对hash做一次GPU式的大拆小就是另一门学问后面我会讲。3.2 模板二zset接管多维排序与带权状态业务里偶尔会遇到一种诡异设计为了给用户打一个带分数的标签单独建100个key“tag:user:10001:labelA”、“tag:user:10001:labelB”……这种方式太浪费key位了。带权重、带排名的数据本质就应该用zset。举一个签到积分的例子。原来一个用户每天签到存一个“sign:20250601:user:10001”用来判断是否签到过同时还要在“rank:202506”里维护该用户累计积分。现在把规则统一用zset存“rank:202506”member就是userIDscore就是累计积分。签到状态如果必须保留让其作为bitmap的位数据下面第三个模板处理而排行榜只需要操作一个zset。// 用户10001签到一次积分10 pipe : rdb.Pipeline() pipe.ZIncrBy(ctx, rank:202506, 10, user:10001) // 同时把签到记录写入bitmap pipe.SetBit(ctx, sign:202506, 10001, 1) _, err : pipe.Exec(ctx) if err ! nil { panic(err) } // 获取排行榜前十名 top, err : rdb.ZRevRangeWithScores(ctx, rank:202506, 0, 9).Result() if err ! nil { panic(err) } for _, z : range top { fmt.Printf(%s score%.0f\n, z.Member, z.Score) }zset本身是跳表实现百万级member的有序集合查询性能依旧很好。相比几千个独立key一个zset的维护成本低得多。要注意的是zset的member要控制长度不建议把一长串JSON塞进去member就是ID最多加个业务前缀。3.3 模板三bitmap把开关型计数变成位运算再介绍一个我特别喜欢的结构bitmap。Redis的string本身就支持位操作底层就是一个二进制数组每个字节有8个bit位。适合bitmap的场景很典型用户签到、登录状态、活动是否参与、灰度开关。一个用户始终对应一个bit位不需要“每天一个key”也不需要“每用户一个key”。一个包含1亿个bit的key内存只有12.5MB却能覆盖1亿用户的签到状态。Go代码操作bitmap很简洁// 用户ID作为offset签到就是置位为1 err : rdb.SetBit(ctx, sign:20250601, 10001, 1).Err() if err ! nil { panic(err) } // 判断是否签到 bit, err : rdb.GetBit(ctx, sign:20250601, 10001).Result() if err ! nil { panic(err) } if bit 1 { fmt.Println(signed) } // 统计当前签到总人数 cnt, err : rdb.BitCount(ctx, sign:20250601, redis.BitCount{Start: 0, End: -1}).Result() if err ! nil { panic(err) } fmt.Println(total signed:, cnt)但bitmap有个明显的缺点无法直接获取“哪些用户签到了”只能用BITFIELD或按位遍历非常慢。另外如果用户ID不是连续数字比如用UUID就没法用这个方案。所以bitmap适合“内部用户ID是连续自增整数”的业务这是一个硬性前提。3.4 模板四HyperLogLog搞定海量去重如果你的业务是统计UV、独立访客、去重数量这类场景用HyperLogLog是最不占地方的。刚才提到的签到、访问量统计如果用set存储所有用户ID10万用户就要存10万个member虽然在一个key里但内存也不小用HyperLogLog无论多少用户误差率在0.81%以内内存固定只有12KB左右。Go代码操作HyperLogLog本质上就三个APIPFAdd、PFCount、PFMerge。// 统计今天页面UV for i : 0; i 100000; i { rdb.PFAdd(ctx, uv:home:20250601, fmt.Sprintf(user:%d, i)) } total, err : rdb.PFCount(ctx, uv:home:20250601).Result() if err ! nil { panic(err) } fmt.Println(uv:, total)这个结构最大的价值在于它直接帮你省掉了原来“每天一个set、每用户一个sadd操作”的散key问题。注意HyperLogLog存在误差对精确去重不能用的场景比如订单号幂等校验就得另想办法。3.5 模板五布隆过滤器挡住空key风暴还有一个很隐蔽的key膨胀源缓存穿透。当请求打到某个不存在的ID时业务方为了防止每次穿透都打DB常常会把“null”作为一个特殊value写进Redis并设置短TTL。这样一来只要有人恶意遍历不存在的IDRedis里就会涌出一大批“key存在但value空”的垃圾key。布隆过滤器是应对这个问题的标准解法。用一个固定大小的bit数组判断“这个ID一定不存在”或“可能存在”从源头拦住那些必然不存在的查询不让他们进入缓存写入逻辑。如果Redis装的是RedisStack可以直接用布隆过滤器模块。Go的go-redis可以用Do方法调用原生命令// 创建一个预计容量10万、误判率1%的布隆过滤器 err : rdb.Do(ctx, BF.RESERVE, bloom:user_id, 0.01, 100000, ).Err() if err ! nil !strings.Contains(err.Error(), EXISTS) { panic(err) } // 写入真实存在的用户ID err rdb.Do(ctx, BF.ADD, bloom:user_id, 10001).Err() if err ! nil { panic(err) } // 查询用户ID是否存在 exist, err : rdb.Do(ctx, BF.EXISTS, bloom:user_id, 10001).Int() if err ! nil { panic(err) } fmt.Println(10001 exists:, exist 1)如果用的是普通Redis也可以自己在应用层实现一个简单布隆过滤器或者用位数组配合多哈希函数实现但那样要自己管理误判率和重置策略。我上面的建议是能上RedisStack模块就上模块不要自己造轮子除非你们对布隆过滤器的参数有很特殊的定制需求。4. 降key过程中绕不开的三个深水区4.1 别指望TTL一劳永逸很多人的第一个反应是“直接给key加TTL不就行了”。NoTTL确实是控制key数量的一种手段但它平滑不了“存量爆炸”的问题还会带来两个特别现实的麻烦。第一Redis对过期的清理不是实时的有两套机制惰性删除和定期删除。定期删除是由主循环每秒执行10次每次随机抽取20个key淘汰其中已过期的key。一旦key数量过大比如达到几个亿每次抽样的命中率就会下降过期key清理的周期会拖得很长内存里堆积的过期key反而更多。你看到DBSIZE一直很高不一定是业务量涨了很可能是过期清理跟不上。第二即使key按时过期Redis所在的操作系统也不会立刻把内存还给你。内碎片和内存分配器的行为会让你看到used_memory已经降了但操作系统层面的RSS还是居高不下。所以降key不能只靠TTL必须把真正的无效key删掉这是两个动作。我在代码里迭代删除过期key或者废弃key时会用SCAN配套UNLINK而不是DEL。UNLINK是异步释放内存不会阻塞主线程。Go代码示例iter : rdb.Scan(ctx, 0, session:*, 1000).Iterator() var keys []string for iter.Next(ctx) { keys append(keys, iter.Val()) if len(keys) 500 { rdb.Unlink(ctx, keys...) keys keys[:0] } } if len(keys) 0 { rdb.Unlink(ctx, keys...) }在现场执行这种脚本我建议先加一个统计模式跑一遍确定匹配数量然后再真正删除避免误删。4.2 大hash、热field和本地缓存hash合并key固然香但一旦合并过头就会出现大key问题。我见过有人把一个几百万字段的hash塞进RedisRDB备份时网卡直接跑满。所以hash的字段数量到底控制在多少合适没有一个绝对标准但我通常遵循两个原则单个hash的field数量尽量不超过1000个单个value大小尽量不超过10KB。如果你确实面临着“一个用户有几千个属性”的场景那就别天真地用一个大hash而是按属性维度拆成几个小的hash例如“user:10001:base”、“user:10001:ext”。大hash之外还有一个问题叫热field。hash里某些field被访问频率极高比如“限量抢购”场景下的库存字段会造成单个hash节点瞬时CPU飙高。解决办法有两个维度一是给热点field加本地缓存比如用Go的singleflight拍平并发二是将热field单独迁移到一个独立key上与原hash双写。我这里提供一种借助singleflight降DB压力的方案var ( g singleflight.Group localCache sync.Map ) func getUserFromCache(ctx context.Context, uid string) (map[string]interface{}, error) { // 先查本地缓存 if v, ok : localCache.Load(uid); ok { return v.(map[string]interface{}), nil } // 用singleflight防止缓存击穿 v, err, _ : g.Do(uid, func() (interface{}, error) { vals, e : hgetAllWithFallback(ctx, uid) if e ! nil { return nil, e } localCache.Store(uid, vals) return vals, nil }) if err ! nil { return nil, err } return v.(map[string]interface{}), nil }本地缓存会带来数据一致性的问题需要设置一个较短的过期时间不能一存就永久有效。4.3 value压缩把每个字节都抠出来降key数量的同时我们也应该顺手看看value的浪费。很多业务在Redis里存的是JSON字符串比如用户资料直接“marshal”成一个struct每个字段名重复出现一遍。这个做法对key数量影响不大但对内存和GC压力不小。建议使用更紧凑的序列化方式例如protobuf或者messagepack。protobuf因为是二进制编码省掉了字段名重复开销value体积通常能比JSON小50%以上。如果value本身很大比如批量结果还可以考虑加一层snappy或者zstd压缩。go-redis底层用的是标准库压缩可以放在业务层import ( github.com/golang/snappy ) func compressValue(data []byte) []byte { return snappy.Encode(nil, data) } func decompressValue(data []byte) ([]byte, error) { return snappy.Decode(nil, data) }这里有一个经验snappy压缩CPU开销低速度极快适合Redis这种低延迟场景zstd压缩率更高但CPU开销稍大适合那些单value超过几千字节的数据。我一般先用snappy压不住再换zstd。5. 把key数量变成可观测、可治理的日常指标5.1 巡检脚本与自动化报警降key专项做完不等于一劳永逸。如果不加监控半年后key数量又会悄悄涨回来。所以我在项目里加了一个定期巡检脚本每天凌晨跑一次核心指标包括DBSIZE、过期key数量增量、内存使用量、按前缀分组后的key排行Top20。Go脚本里最关键的地方是不要在线上高峰期做全量SCAN建议放到凌晨4点。另外TYPE命令有开销抽样就行。巡检结果写入日志或Push到监控系统我用的是Prometheus加Grafana在展示你也可以用公司自研的监控。报警规则我一般设三档key总量每天增长率超过5%时告警提示某个前缀的key数量连续三天增长超过10%时告警单实例used_memory超过maxmemory的80%时升级告警。有了这三条基本能在问题变成事故之前踩一脚刹车。redis-cli info keyspace redis-cli dbsize redis-cli info memory | grep used_memory redis-cli --scan --pattern order:* | wc -l这几条命令适合临时排查不过如果你想系统化我建议先用Go写巡检脚本把结果沉淀成指标。5.2 一个真实案例3000万key缩到800万拿我最近这次专项来说一个核心业务的Redis集群里有3000万key。我用SSR就是扫描工具统计后发现几类大头用户会话session占35%登录token占25%签到记录占20%其余是各类临时标记。最终方案是这样的session类把每个用户的session从“session:{token}”改为hash“session:{uid}”token作为field。原本一个用户登录10次会产生10个key现在一个用户最多对应一个hash。这部分从1000万key降到约100万。token类用一句话说不完但它本质是短期有效期、低频读取所以这个没合并而是靠压缩TTL和UNLINK清理来收敛同时在读取时做好缓存击穿保护。签到类直接从字符串key改为bitmap以“月份签到场景”为粒度一个key覆盖几十万用户的签到记录。从600万key降到几十个key。临时标记类这类“flag:xxx:yyy”大多数可以合并成一个hash或者直接用布隆过滤器判断存在性。整个优化落地后DBSIZE从3000万降到800万不到。与此同时RDB文件大小从12GB降到4.5GB主从全量同步时间从40分钟缩短到11分钟因为全量SCAN巡检时间也从20分钟变成3分钟。这些都是线上的实际收益不是理论推导。5.3 配套治理规范周会看什么数技术方案再漂亮治理跟不上就会反弹。我给团队定了一条规矩每周缓存周会盯三个数——总key数、每业务前缀key数、内存碎片率。如果某条业务链路的key数突然上涨必须当场给出解释和整改排期。另外上线评审时凡是涉及Redis的新功能都要多问一句这个数据结构会新增多少key有没有可能用hash/zset/bitmap降低key量TTL打算设置多久不合理的缓存设计在一开始就打回去比上线后再治理成本低得多。6. 常见问题与排查技巧实录6.1 设置了TTLkey数量为什么没有立刻下降这是治理过程中最高频的疑问。原因有几种。一是过期清理有延迟上面已经解释过惰性删除和定期删除的机制。二是主从架构中从库不能自行过期需要等主库的DEL命令同步所以从库总有几十毫秒的视角延迟。三是内存碎片。比如删除大量key后used_memory确实下降了但RSS可能维持在高位这时你需要用memory purge或重启从库来释放。四是可能你自己误把“过期时间偏移”设成了几天后比如写代码时用了“ 3600 * 24”这种写法实际有效期比你想要的要久。排查技巧小明拿到一个key后先用TTL命令看看剩余时间再用INFO stats里expired_keys字段看是否有清扫发生最后看CONFIG GET hz频率设置。如果系统load高可以把hz适度提高加速过期key清理但代价是CPU占用上升不要盲目调大。6.2 hash里的field也要设置过期时间怎么处理很多人合并hash后才发现原始key各自有TTL现在field没有天然过期机制。这个问题有几种解法。首选方案是Redis 7.4版本新增的HEXPIRE命令可以给hash中指定field设置过期时间。如果你用的版本比较老就需要业务层在field内部包一层时间戳然后在读取时判断是否失效并利用异步任务清理过期field。更简单的一种做法是把“明确只活几分钟”的数据跟“长期保留的数据”区隔开。前者还是用独立key解决别硬塞hash后者用无过期概念的hash处理。别为了降key把过期需求不同的数据强行捏在一起那是饮鸩止渴。6.3 布隆过滤器误判率升高了怎么办布隆过滤器最怕的是初始容量设小了。容量不够时bit数组很快被全部置为1误判率急剧上升最终表现就是“什么key都可能存在”缓存穿透保护失效。解决方案是定期重建。比较稳妥的做法是双缓冲常驻两个布隆过滤器“current”负责读写“next”开始预热。当next填满或者误判率持续走高时把读写切到next再把current清空重建。这样能做到无感切换。在实际项目中我还会给布隆过滤器本身增加一个“计数型key”的清理rollback因为如果一批脏数据已经进入过滤器不会自动清除必须依赖重建。别省这一步。6.4 细说迁key的平滑切换步骤最后说说存量key的平滑迁移。这个步骤如果做得不好容易在切换时造成缓存未命中率飙升和DB被打爆。我通常会把迁移分成四个阶段。第一阶段是双写。业务代码在新旧两套key都写入读取时还是先读旧key读不到再读新key最后回源DB并回填新key。这个阶段跑一天左右让新数据攒出一定命中率。第二阶段是读切换。代码改为先读新key读不到再读旧key旧key命中后回填新key。注意这阶段要观察命中率和DB压力。第三阶段是旧key清理。用前面提到的SCAN加UNLINK分批删除旧key千万别一次性全部删掉否则某个漏网的旧读路径会打爆DB。第四阶段是代码瘦身移除旧key相关的逻辑完成收官。切换期间最好准备一个“回滚开关”比如以配置中心某个布尔值控制读新还是读旧。一旦发现异常能在一分钟内切回旧链路比花二十分钟改代码发布要安全得多。这套降key方案我从最早只把字符串key换成hash到后来做session聚合、签到bitmap、布隆过滤器防御再到整个集群的监控巡检前后迭代了好几轮。个人最大的体会是key数量从来不是孤立的技术指标它背后连着内存成本、运维效率、稳定性甚至团队协作流程。每次规划新功能时多问一句“这会在Redis里形成多少key”长期积累下来的收益比想象中大得多。如果你现在正对着几千万key发愁我建议先从写一个扫描分类的工具开始摸清家底再动手合并、切换、清理一步步来不用急。