做Redis排查这么多年,能让半夜从床上爬起来的告警不多,Big Key算一个。网上一搜“Redis卡顿”,十有八九最后都会指向同一个原因:某个key太大,一条命令下去,整个实例的请求全堵在那儿。前阵子我们线上就出过一次凌晨毛刺,所有接口RT从2毫秒飙到2秒,翻慢日志,第一条就是针对一个Hash的HGETALL,里面存了几百万个字段。
说白了,Big Key就是Redis里体积超出常规规模的单个key。它和数据量大有本质区别:数据量大可以靠扩容、分片解决,但一个大key一旦出现,它是单体问题,会直接拖垮整个实例。这篇文章我不打算只讲定义,而是把我从定位、删除到预防的完整排查链路写出来,希望你遇到的时候不用再重复踩坑。
1. Big Key不是“看着很大”这么简单
很多人以为Big Key就是value很大的字符串,其实不全面。要解决问题,得先把什么叫“大”这个标准对齐,不然排查时容易误判。
1.1 什么样的key才算Big Key:量化标准先对齐
Big Key没有一个官方的绝对阈值,但业界普遍有一套经验参考值。我自己的判断标准是分数据类型来看的,因为不同数据结构的“大”体现形式不一样。
- String类型:单个value超过10KB就要注意,超过100KB基本算问题。别觉得100KB不大,想想看一个实例里如果有几千个这样的key,内存和网络的压力已经不小了。
- Hash类型:某个key的field数量超过5000个,或者整个Hash的总大小超过10MB,基本可以认定为Big Key。
- List类型:某个key的节点数量超过10000个,或者总大小超过10MB。
- Set类型:成员数量超过10000个,或者总大小超过10MB。
- Zset类型:成员数量超过10000个,或者总大小超过10MB。
需要说明的是,这套标准不是死的。如果你们公司的Redis实例是64GB内存的高配机器,10MB的key可能不痛不痒;但如果实例内存只有4GB,一个10MB的key占的比例就很高了。所以更合理的判断维度是看它占实例内存的比例,一般超过实例内存的5%就要警惕。
拿一张表来整理可能更直观:
| 数据类型 | 建议关注阈值 | 建议处理阈值 |
|---|---|---|
| String | value > 10KB | value > 100KB |
| Hash | field > 1000 | field > 5000 或整体 > 10MB |
| List | 元素 > 5000 | 元素 > 10000 或整体 > 10MB |
| Set | 元素 > 5000 | 元素 > 10000 或整体 > 10MB |
| Zset | 元素 > 5000 | 元素 > 10000 或整体 > 10MB |
1.2 两种常见的Big Key形态和它们的区别
我在实际排查中发现,Big Key往往以两种形态出现,处理方式完全不同。
第一种是“大而少”的string类型。比如一个key下面挂了2MB的JSON字符串,这类key的危害主要在网络传输和序列化反序列化上,每次读写都要搬运这么大一个包。
第二种是“大而多”的集合类型。比如一个Hash里塞了上百万个field,一个List里有上千万条记录。这类key的危害更隐蔽,因为它单个元素不大,但元素总数巨大,做全量操作时复杂度是O(N),瞬间阻塞主线程。
这里要特别提醒一点:别把Big Key和热Key(Hot Key)混为一谈。热Key是访问频率高,Big Key是自身体积大,两者不是一回事。但一个Big Key如果同时被高频访问,破坏力是叠加的,既阻塞命令又打满网卡,这种情况下得先解决体积问题,再考虑热点拆分。
2. 为什么一个“大”key能让整个Redis卡住
刚接触Redis的人常有个疑惑:Redis单核处理能力很强,一个key大点而已,怎么会影响别人?要理解这一点,得先理解Redis的工作模型。
2.1 单线程模型下的一次O(N)命令
Redis的核心处理是单线程的,所有命令在主线程里排队执行。单线程意味着同一时刻只能有一个命令在执行,其他所有请求都在等待。单个命令执行时间越长,后续堆积的命令就越多。
举个例子:一个Hash里有100万个field,执行一次HGETALL,Redis需要遍历全部100万个字段并拼接返回结果。这个操作在普通机器上可能要几百毫秒甚至更久。这几百毫秒里,Redis主线程被占住,所有QPS再高的请求也只能排队。排队的请求又会占用内存、文件描述符,触发更多连带问题。
关键点在于:这个O(N)的N是元素数量,不是value的字节数。所以一个List哪怕每个节点只有几十字节,只要节点数量达到百万级,操作它同样慢。Big Key的“大”不只看字节,还要看结构复杂度。
2.2 内存与持久化层面的连锁反应
Big Key不只在命令执行时阻塞,在持久化环节同样会制造灾难。
Redis做RDB快照时,会fork一个子进程。fork之后利用写时复制(Copy On Write)技术,只有在父进程内存页被修改时才会复制页面。如果某个Big Key所在的页面恰好被频繁修改,每次修改都会触发一次页面复制,导致父进程内存瞬时飙高。
AOF重写也是同理。AOF重写时需要遍历所有key生成新的追加文件,一个超大的key会显著延长重写时间,重写期间的内存和磁盘IO都会上涨。
还有主从同步。从库第一次全量同步时,主库要生成RDB文件传给从库,如果RDB里有一个几十MB甚至更大的大key,同步包变大,传输时间边长,主从复制延迟随之增加。
2.3 集群环境下的大key带来的数据倾斜
如果使用的是Redis Cluster,Big Key问题会更突出。Redis Cluster按照key的哈希槽(hash slot)把数据分布到不同节点,节点之间无法将单个key拆分到多个节点。
想象一个1GB的Zset,哈希槽确定后,这个key必然落在某一个节点上。后果是:这个节点的内存使用率比其他节点高出好几个等级,它的CPU负载、磁盘IO、网络流量也都跟着升高。其他节点空闲,这个节点繁忙,整个集群的容量和性能都被这一个key锁死了。
更麻烦的是,集群在做slot迁移时如果遇到大key,迁移过程会非常慢,甚至触发迁移超时。这也是为什么很多公司在生产环境禁用某些大命令,本质上不是命令的问题,是命令作用于big key后会放大成节点级故障。
2.4 过期删除和内存淘汰又会怎么被放大
Big Key还有一个容易被忽视的杀伤力:过期删除和内存淘汰。
Redis删除一个集合类型的key时,需要逐个释放所有节点。如果这个key有100万个元素,删除操作本身就要在主线程里执行1百万次节点释放。所以一个Big Key到达过期时间的那一刻,主线程会直接被卡住几百毫秒。
内存淘汰也一样。当实例内存达到maxmemory上限时,Redis会根据淘汰策略挑选key进行删除。如果挑中了一个Big Key,同步删除过程会阻塞主线程。很多情况下,实例明明内存没满却出现抖动,查半天可能就是一个大key在被淘汰时拖慢了主线程。
这里有个重点,Redis从4.0提供了异步删除机制(lazy free),但默认情况下,过期删除和内存淘汰的异步开关是关闭的。也就是说,哪怕你用了unlink命令,如果不把对应的lazyfree配置打开,大key过期时还是会同步删除,该卡照样卡。
3. 定位Big Key:手头有哪几把趁手的工具
知道Big Key的危害之后,重点是怎么把它找出来。这个过程我踩过不少坑,按工具的实用性和踩坑程度排序说。
3.1 redis-cli --bigkeys:最快但别迷信
Redis自带的--bigkeys参数是最常用的快速扫描工具,用法很简单:
redis-cli --bigkeys它会用SCAN命令遍历全部key,对每种数据类型统计出最大的几个key,输出类似这样:
-------- summary ------- Sampled 100000 keys in the keyspace! Total key length in bytes is 3021041 (avg len 30.21) Biggest string found 'user:profile:10086' has 13242 bytes Biggest list found 'recent_news:2024' has 990001 items Biggest hash found 'user_tags:all' has 1200000 fields这个工具最大的坑在于:它是抽样统计,用的是SCAN游标采样,不是全量精确统计。如果你的实例key很多,采样分布可能漏掉真正的大key。
另一个坑更隐蔽:对String类型,--bigkeys统计的只是字符串长度,不是真实占用内存。比如一个经过压缩的字符串可能长度不大,但实际在Redis内部因为编码、碎片等原因占用的内存远高于长度。对集合类型,它统计的是元素个数,一个元素里塞大JSON的Hash,虽然元素数不多但实际内存巨大,--bigkeys就“看不见”它。
我在Redis 7.x的redis-cli里发现多了--memkeys参数,专门按内存占用做采样,比--bigkeys准确不少:
redis-cli --memkeys3.2 memory usage与debug object:精确测量与注意点
如果要精确知道某个key占多少内存,用MEMORY USAGE命令:
MEMORY USAGE user:profile:10086 (integer) 153680返回的是字节数,unit是byte。在Redis 4.0及以上版本可用。
这个命令的坑在于:它本身也会遍历整个key的结构来估算内存,复杂度同样与元素数量相关。也就是说,对一个100万字段的Hash执行MEMORY USAGE,它也是O(N)的遍历,执行期间主线程一样会被占用。所以线上如果已经锁定了几个疑似大key,谨慎使用,尽量放在低峰期执行。
还有一个命令DEBUG OBJECT可以查看key的内部编码和序列化长度:
DEBUG OBJECT user:profile:10086它会返回Value at: ... serializedlength:153680 ... 等信息。这里要特别注意,serializedlength只是序列化之后的长度,不等于实际内存占用。内存碎片、内部数据结构开销都不算在里面,所以拿它去评估大key会低估不少。我在早期排查时用这个字段做判断,结果把一个大key漏掉了,后来用MEMORY USAGE才发现实际占用比serializedlength高了几倍。
3.3 生产环境更推荐的离线RDB分析
如果线上实例不方便随时执行扫描命令,最安全的方案是在不影响线上的前提下,从RDB文件里离线分析。
用redis-rdb-tools可以解析RDB文件,生成内存报告:
rdb -c memory /path/to/dump.rdb --bytes 10240 > memory.csv它会列出每个key的类型、编码、内存占用、元素个数等信息,还能按内存大小排序。因为是离线分析,不会对线上产生任何压力,适合在做大版本变动前、定期巡检时使用。
这条路径唯一的门槛是:需要拿到RDB文件。如果是云厂商的Redis服务,一般需要在控制台手动触发备份,然后下载备份文件到本地或专用分析机器。但为了线上稳定,这个成本是值得的。
3.4 日常监控:慢查询和内存趋势也可以帮忙
工具扫描是一时的,日常监控才是持续防线。
- 慢查询日志:
SLOWLOG GET可以查看最近执行时间过长的命令。Big Key相关命令通常会出现在慢日志里,比如HGETALL、LRANGE 0 -1、SMEMMBERS这种O(N)命令。 - INFO命令统计:
INFO COMMANDSTATS能看每个命令的调用次数和耗时,如果某个命令的耗时占比异常高,顺着命令去查对应的key类型,很可能就是Big Key。 - 内存趋势:监控实例的used_memory增长曲线,如果某个节点内存增长速度异常,往往说明有key在持续膨胀。
我在生产环境里的做法是:写一个定时脚本,每天凌晨低峰期用SCAN配合MEMORY USAGE采样扫描实例中内存占用top N的key,并记录到监控系统。这样Big Key不是等出故障了才被发现,而是能在膨胀初期就发出预警。
4. 删除Big Key:这步才是真正的技术活
找出Big Key之后,很多人第一反应是直接DEL,这就是翻车的开始。
4.1 为什么不能直接del
DEL命令删除集合类型时,需要在主线程里逐个释放所有元素。一个100万字段的Hash,DEL它的耗时可能超过几百毫秒,主线程被占住的时间比执行HGETALL还长。
而且DEL的耗时和元素数量强相关,不是常数时间。想象一下,你发现了一个大key,想把它删掉,结果删除操作本身又把服务卡了一次,这是最讽刺的翻车姿势。
如果这个key正在被业务频繁访问,DEL的一瞬间还可能造成缓存击穿,大量请求直接打到数据库。所以删除前,必须先确保业务侧已经停止访问该key,或者做好降级方案。
4.2 unlink的适用场景与局限性
Redis 4.0提供了一个相对安全的删除命令:
UNLINK user:profile:10086UNLINK会让主线程先把key从全局字典中摘除,然后交给后台线程异步释放内存。从命令执行角度看,主线程只做了O(1)的摘除操作,很快就能返回。
但UNLINK不是万能的,我实测下来有几个坑值得注意:
- 如果key很小,UNLINK和DEL差别不大,没必要为了“更安全”而故意用UNLINK。
- UNLINK只是把内存释放放到后台线程,但后台线程如果积压了大量待释放对象,内存不会立刻下降,会有延迟。
- 最关键的是配置联动。Redis有四个lazy free相关的配置项:
lazyfree-lazy-eviction no lazyfree-lazy-expire no lazyfree-lazy-server-del no lazyfree-lazy-user-del no这四个开关默认都是no。也就是说,UNLINK命令本身是异步的,但通过过期机制触发的删除、内存淘汰触发的删除,默认仍然是同步操作。如果一个大key设置了TTL,到达过期时间时,Redis会在主线程同步释放它,该卡照样卡。
所以如果你确认线上存在大key且设置了TTL,建议把这几个开关打开:
lazyfree-lazy-expire yes lazyfree-lazy-eviction yes然后用INFO STATS里的lazyfree_pending_objects观察后台待释放对象数量,如果这个值长期很高,说明后台线程压力大,需要关注。
4.3 老版本渐进删除的通用套路(附脚本)
如果线上Redis版本低于4.0,用不了UNLINK,或者工程上想完全避免一次性释放带来的风险,渐进式删除是更稳妥的方案。核心思路是:分批删除,每批删除固定数量的元素,中间sleep一下,把压力分摊到多个时间片。
以Hash为例,用HSCAN配合HDEL分批删除:
import redis import time r = redis.Redis(host='127.0.0.1', port=6379, db=0) key = 'user:tags:all' batch_size = 200 cursor = 0 while True: cursor, fields = r.hscan(key, cursor=cursor, count=batch_size) if fields: r.hdel(key, *fields.keys()) if cursor == 0: break time.sleep(0.1) # 最后再删除已经清空的key r.delete(key)这个脚本的关键点在于:
count参数控制每次扫描的field数量,建议100-500,值太小删除慢,值太大会单次阻塞。time.sleep(0.1)是给主线程喘息的机会,避免删除操作连续触发阻塞。- 分批删除完成后,key里还剩一个空的结构,最后再DEL(此时key已经很小,DEL不怕)。
其他数据类型对应的渐进删除方式:
| 数据类型 | 分批删除方法 |
|---|---|
| Hash | HSCAN + HDEL |
| Set | SSCAN + SREM |
| Zset | ZSCAN + ZREM,或 ZREMRANGEBYRANK |
| List | LRANGE + LTRIM,或反复 RPOP 固定数量 |
List删除有个更简单的方式,利用LTRIM key 0 (len-batch-1),每次保留头部的一部分,把尾部的一批删掉:
while r.llen(key) > 0: length = r.llen(key) batch = 1000 if length <= batch: r.delete(key) break r.ltrim(key, 0, length - batch - 1) time.sleep(0.1)渐进删除的好处是可控性强,坏处是耗时长。一个几百万元素的集合,按每批500的速度删,可能要跑很久。实际操作中我一般会把它放到后台脚本里执行,同时做好进度日志,删除期间持续观察Redis的响应延迟和内存变化。
5. 治理Big Key:从数据设计层面让它“大不了”
找到Big Key、删掉Big Key只是救火,真正要解决的是以后别再产生新的Big Key。这部分靠的是数据设计功夫。
5.1 拆Key的几种常见方式
最常用也最有效的治理手段是拆分,把一个“巨无霸”拆成多个“小个子”。
第一种是按时间维度拆。如果你的数据是流水、日志、Feed流这种有时序特性的,可以按天或按小时拆key,比如feed:20250612、feed:20250613。这样即使单个key今天变大了,明天也会过期清理掉,不会无限累积。
第二种是按业务维度拆,或者叫分桶。举个例子,一个Hash存了所有用户的标签,field是user_id,value是标签JSON。这个Hash会随着用户量增长变成大key,正确的做法是按用户ID取模分桶:
user:tags:{user_id % 100}这样每个Hash最多只会有全量用户的1%,单key大小和读写压力都降下来了。读取时也只需要根据用户ID定位到对应的桶,逻辑不复杂,效果立竿见影。
第三种是List和Zset场景的“只留热的”策略。很多List膨胀的根源是没有限制长度,解决方案是每次写入后执行一次裁剪:
LPUSH recent:news:20240601 news_id_123 LTRIM recent:news:20240601 0 999这样List最多只保留最近的1000条,从源头杜绝无限增长。
5.2 压缩与序列化的收益
对于String类型的Big Key,换个序列化方式经常能省一半内存。
比如一个大的JSON字符串,如果字段冗余多,可以考虑:
- 去掉无用的字段,只存必要数据。
- 用更紧凑的序列化格式,比如MessagePack或Protobuf,替换JSON。
- 对内容做压缩,比如gzip、Snappy、LZ4。几百KB以上的数据,压缩率通常很可观,虽然压缩会消耗一点CPU,但换来的是内存和网络带宽的大幅下降,整体收益是正的。
我自己实测过,一个1.2MB的JSON字符串,用gzip压缩后不到200KB,内存占用降低接近80%,接口延迟反而因为传输数据量减少而下降了。
要注意的是,压缩适合value大到一定程度才划算。如果你只有一个几百字节的小字符串,压缩反而浪费CPU。一般超过1KB再考虑压缩比较合理。
5.3 定期淘汰和TTL策略,别让数据无限膨胀
很多Big Key不是一天变成大key的,是长年累月只写不删造成的。所以治理上一定要有“定期清理”的机制。
- 能设置TTL的数据,务必设置TTL。特别是一些临时数据、会话数据、验证码,给一个合理的过期时间,让Redis自动清理。
- 对不能设置TTL的长期数据,写一个定时任务,定期扫描并清理过期或无效的数据。
- 在架构层面,把冷数据从Redis迁移到更便宜的存储,Redis只保留热数据。
我见过一个印象很深的翻车案例:业务方把用户画像的完整JSON塞进Redis,每天更新一次,但从来不设置TTL,也不清理已注销用户的数据。一年下来,这个key膨胀到了2GB,查询一次要几百毫秒,直接拖垮了整条链路的接口。后面加了TTL和冷热分层,问题才彻底解决。
6. 踩坑记录:这些年我在Big Key上见过的翻车案例
前面讲的都是方法论,最后分享几个我自己和团队实际遇到的翻车现场,这些场景比教科书案例生动得多,也更容易让你有代入感。
6.1 案例一:定时任务把全量数据写入一个Hash
某个业务每天早上有个定时任务,会把全量用户的标签数据写入一个Hash,field是user_id,value是标签数组的JSON。用户量从100万涨到500万后,这个Hash变成了一个接近1GB的大key。
最开始的表现是每天早上定时任务跑完后,应用接口就开始变慢。排查时我们用--bigkeys发现了这个Hash,但它显示的元素数是500万,却看不出实际内存占用。后来用MEMORY USAGE才发现真实占用是980MB,远超预期。
修复方案很简单:拆成用户ID取模分桶,每个桶200个用户,单key大小控制住了,读写也分散到了多个key上。这个案例最有价值的教训是:--bigkeys只能作为初步筛查工具,精确评估还要依赖MEMORY USAGE或离线RDB分析。
6.2 案例二:扫码列表List无限增长
另一个案例是扫码记录列表。业务方在用户扫码后往一个List里push一条记录,逻辑上很简单,但没人限制List的长度。半年后这个List累积了几千万条记录。某天业务要做一次全量数据导出,直接执行了LRANGE 0 -1,结果这条命令在Redis主线程跑了20多秒,期间所有读写全部超时。
这个场景的修复分为两步:先停机让业务停止写入,用渐进删除把List裁剪到只保留最近1000条;后续代码里每次push都跟着LTRIM限制长度。同时导出的逻辑改成基于增量游标分批读取,而不是一次性全量拉取。
6.3 案例三:一个Hash存了几百万个领域的会话
这个案例来自一个常见的架构设计失误:为了管理方便,把一类会话数据全部放在一个Hash里,每个field对应一个会话ID。随着用户量增长,这个Hash越来越庞大。这还不算完,这个Hash还设置了TTL,于是每次TTL到期的瞬间,Redis主线程同步删除这个Big Key,直接导致整个实例周期性卡顿。
这类问题的本质是“用错数据结构”——这类数据天然适合每个会话一个独立key,而不是塞进一个共享的Hash里。改成独立key后,TTL到期也是小key的释放,不再阻塞主线程。同时我记得当时把lazyfree-lazy-expire也打开了作为第二道保险。
6.4 最后分享一个排查习惯
多踩几次坑之后,我给自己定了个习惯:
- 每次写Redis相关代码前,先问自己:这个key会不会无限增长?如果会,必须加长度限制或TTL。
- 每个季度跑一次离线RDB分析,输出内存top 100 key清单,交给业务方确认是否有清理和优化的空间。
- 监控平台对慢查询命令做告警,比如HGETALL、LRANGE、SMEMBERS这种O(N)命令,如果单次执行时间超过50ms就告警。
把重心从“事后救火”挪到“事前预防”,Big Key这个问题的频次会下降很多。我的体会是,Big Key问题本质上不是Redis能力不行,而是很多团队没有把Redis的数据结构当作数据库schema来设计。每个key对应什么数据、多大容量、怎么分桶、怎么过期,这些问题在设计阶段不搞清楚,迟早会在生产环境以故障的形式向你讨债。