有些日子没在博客上写东西了,周末整理排障笔记,翻到去年处理的一次生产事故,表象是接口超时、CPU毛刺,最后定位到Redis里几个不断膨胀的巨型哈希表。这类问题我给它起了个名字,叫性能刺客——平时安安静静躺在内存里,一旦业务流量上来,或者赶上运维操作窗口,它能在几十毫秒内让你的集群吞吐掉到地板。今天系统地聊一下Redis大Key的前世今生、危害机理和根治手段,算是给自己做个沉淀,也给正在被此类问题折磨的朋友一个可复用的排查思路。
先说清楚大Key是什么。在Redis语境里,大Key不是指Key本身的字符有多长,而是指这个Key对应的Value体量超出了合理阈值。常见判断标准:单个String类型的Value超过10KB,或者Hash、List、Set、Zset这类集合类型中元素数量超过5000个,或者整个Value的总大小超过1MB。注意,这个阈值不是官方硬性规定,而是业界实践里比较公认的“需要开始警惕”的量级。不同业务可以把阈值放宽或收紧,但一旦单个Key的存储规模跨过某个线,它带来的读取延迟、删除阻塞、网络带宽占用都会呈现非线性恶化。
这个问题的“前世”其实很好理解。绝大部分大Key不是一开始就打算造出来的,而是业务代码在演进过程中逐步堆出来的。最常见的剧本是:产品经理要求做一个“用户标签聚合查询”,开发同学图省事,直接把一个用户的全部行为数据塞进一个Hash里,field不断增多;或者做一个Feed流缓存,把某个热点内容的所有评论、点赞、转发ID全部append进同一个List;再或者做排行榜,Zset里无脑zadd几百万个member。代码上线那天一切都很快,因为用户量小、数据量小,Redis毫秒级响应。等运行三个月半年,缓存键还在原样追加,Value膨胀到几十兆甚至上百兆,问题就开始集中爆发了。
我见过最夸张的例子是一个做C端导流活动的项目,他们把一次活动所有用户参与行为的明细都塞进一个Key,那个Key在活动结束时单Value已经超过200MB。当时负责的同学还觉得很“聪明”,以为一次查询能拿到全量数据。直到线上偶发卡顿,排查才发现这个Key已经被业务高频访问,每次都要把200MB的数据序列化传输,IO和CPU全部被打满。这种情况在行业里太典型了,几乎每个规模化的Redis集群都会遇到,区别只在于你能不能在大Key造成事故之前发现它。
从危害层面来看,大Key之所以被称为“刺客”,是因为它不只影响自身,还会拉着整个Redis实例甚至整个分布式集群一起遭殃。首先要意识到Redis的核心模型是单线程事件循环,所有命令串行执行。一次对大集合的遍历、聚合、序列化操作,会占用事件循环很长时间,后续所有的读写请求全部排队等。这意味着一个1GB的List执行LRANGE 0 -1,可能在几十毫秒里冻结这台Redis上所有其他Key的请求。哪怕你的业务做了集群分片,请求被哈希到同一个分片,那你这个分片上的所有业务都会跟着遭殃。
更隐蔽的坑在删除操作上。很多人知道Redis里的DEL命令可以删除任意Key,但对于一个包含几十万元素的Hash,DEL需要逐个释放内部节点,这个过程是O(N)的,N是集合内元素数量。也就是说,删除一个大Key本身就会造成长时间阻塞。线上经常出现这种场景:运维发现某个Key太大,打算趁流量低峰清理掉,敲下DEL的那一刻,实例卡顿数秒,连接全部飙红,这被称为“删除大Key引发的次生事故”。我自己的经验是,宁可慢慢分批Delete,也别在生产环境直接对超大集合执行一个DEL,那是在给自己埋雷。
内存层面也不容忽视。大Key意味着单个分片的内存水位被抬高,这在集群模式下特别棘手。Redis Cluster按Key做哈希分片,Key一旦确定就无法更换分片。如果几个大Key都命中了同一个分片,那么那个节点的内存会明显高于其他节点,触发节点间内存不均匀。内存倾斜带来的直接后果是,倾斜节点的内存优先触顶,触发maxmemory淘汰策略,甚至OOM,而这明明是可以靠合理设计避免的。数据量增长后,大Key带来的内存碎片率也会上升,因为频繁扩容内部编码结构会产生更多内存碎片,最终抬高整个实例的RSS。
网络层面同样会有连锁反应。大Key的读取通常意味着要把大量数据一次性从内存搬出来发到客户端。假设一个Key有50MB的Value,业务每秒读取一次,这就相当于每秒钟产生50MB的出口流量。Redis的网卡带宽是有限的,大Key把带宽吃掉之后,其他所有小型请求的传输时延都会上升。对于使用公网连接或跨地域部署的业务,这种带宽争抢带来的影响会被进一步放大,成为隐形的性能瓶颈。
搞清楚大Key为什么危险,接下来最关键的问题就是如何快速发现它们。严格地说,大Key的排查不应该等到线上出故障才做,而应该是常态化监控的一部分。不过如果你已经有线上问题需要排查,最直接的工具是Redis自带的redis-cli --bigkeys。这条命令会以采样遍历的方式扫描整个实例,统计出每种数据类型中占比最大的Key及其大小。要注意的是,--bigkeys扫描的是“最大”的Key,它采用的是字符串长度或集合元素数量的估算逻辑,对于字符串类型显示字节长度,对于集合类型显示元素个数,并没有精确到每个Key的完整内存占用。
使用redis-cli --bigkeys的典型命令是:
redis-cli -h 127.0.0.1 -p 6379 -a yourpassword --bigkeys加上--verbose可以显示更详细的扫描进度。还有一个参数是-i,用于控制每扫描100个Key的休眠间隔,例如-i 0.1表示每扫描100个Key暂停0.1秒,这在生产实例上很有用,可以避免扫描命令本身对线上造成太大的CPU压力。你可以在低峰期执行这条命令,把结果导到文件里做进一步分析。
但--bigkeys有几个明显的盲区。第一,它只能找出最大的那几个Key,如果你的Redis实例里有很多“中等偏大”的Key,比如每个Hash有1万个左右元素,这类Key可能不会排到整个实例的前几名,但它足以造成单次操作阻塞。第二,它显示的是集合元素数量,而不是精确内存占用。某个Hash虽然有1万个元素,但如果每个field和value都很短,内存占用可能并不高;反过来,一个List虽然只有几百个元素,但每个元素都是比较大的字符串,实际内存可能远超你的预期。所以在精细化治理阶段,我会推荐写一段脚本用MEMORY USAGE命令去精确度量。
MEMORY USAGE是Redis 4.0之后提供的命令,可以精确返回某个Key当前占用的字节数。它的用法是:
redis-cli -h 127.0.0.1 -p 6379 MEMORY USAGE your_key_name这条命令会包含Key本身、Value、以及与之相关的编码结构等内存开销。用Python或者Shell写一个遍历脚本,先用SCAN批量获取所有Key,再逐一对候选Key执行MEMORY USAGE,就可以得到整个实例的精确内存分布。下面是一个简单可用的Python示例:
import redis r = redis.Redis(host='127.0.0.1', port=6379, password='yourpassword', decode_responses=True) # 使用 SCAN 获取所有 key,避免 KEYS 阻塞 cursor = 0 threshold = 1024 * 1024 # 1MB 以上视为大 Key 候选 big_keys = [] while True: cursor, keys = r.scan(cursor=cursor, count=500) for key in keys: try: mem = r.memory_usage(key) if mem and mem > threshold: big_keys.append((key, mem)) except Exception: # 某些 Key 可能在扫描期间被删除,跳过即可 continue if cursor == 0: break # 按内存占用降序排列 big_keys.sort(key=lambda x: x[1], reverse=True) for key, mem in big_keys[:50]: print(f"{key} -> {mem / 1024 / 1024:.2f} MB")注意,在大规模实例上执行MEMORY USAGE会带来一次额外的内存统计开销,频率不能太高,建议用SCAN走完后对阈值以上的候选Key做精确度量。还有个折中的方案,先用DEBUG OBJECT命令观察serializedlength字段,这个字段表示序列化后的字节长度,虽然不是完整内存占用,但它速度更快,适合作为一筛。
很多人在这个环节问过我一个很实在的问题:到底多大才算大Key,我能不能直接抄作业。我的建议是分级设置阈值。String类型的Value如果运行期会超过100KB就要引起注意,因为每次读取都涉及较大的网络包传输和内存拷贝;Hash、List、Set、Zset这类集合,元素数量控制在5000以内比较安全,一旦超过1万就该考虑拆分;如果你评估单Key内存占用超过10MB,那已经属于高危,应该尽快走治理流程。当然阈值要结合你的机器带宽、实例规格和业务容忍度来调整,但在拿不准的情况下,按这个档位去定义大Key不会错。
发现大Key之后,真正考验技术水平的环节是“怎么安全地治理”。这里面我觉得要分两个维度来讨论:已经存在的大Key怎么处理,以及今后的新数据怎么避免再次形成大Key。
先讲已经存在的大Key怎么处理。最核心的原则是:禁止在生产环境直接对大Key执行DEL。前面已经说过DEL大集合的时间复杂度是O(N),N大的时候会造成实例阻塞。更优的删除策略是利用Redis 4.0引入的UNLINK命令。UNLINK与DEL最大的区别在于,它会将Key的释放操作放到后台线程异步执行,主线程只负责将Key从键空间移除并返回,不需要立即遍历和释放整个集合,因此不会阻塞事件循环。大多数场景下,把DEL改成UNLINK就能立刻降低删除操作的风险。
但如果集群还停留在老版本,不支持UNLINK,那就需要用分批删除的方案。对于Hash类型,可以用HSCAN分批获取field,再配合HDEL逐个或小批量删除;对于List类型,用LTRIM每次保留尾部若干元素,一点点截断;对于Set类型,用SSCAN结合SREM分批移除;对于Zset类型,用ZSCAN结合ZREM批量移除。下面是一个针对Hash大Key分批删除的示例脚本:
import redis r = redis.Redis(host='127.0.0.1', port=6379, password='yourpassword', decode_responses=True) big_hash_key = "your_big_hash" batch_size = 200 while True: # 每次只取 batch_size 个 field fields = r.hkeys(big_hash_key, batch_size) if not fields: break r.hdel(big_hash_key, *fields) # 最后确认已删除 exists = r.exists(big_hash_key) print(f"删除完成,Key 是否存在: {exists}")这段脚本的核心逻辑是循环取一小批field并删除,直到Key为空。每批次取200个field删除,对主线程的影响被控制在一个很小的范围内,实测下来基本不会有可感知的阻塞。另一个生产环境常用的技巧是,先重命名Key再异步删除:把大Key改成临时Key名,业务侧不再访问旧Key名,然后对新Key名做低速删除。这个“重命名+惰性删除”的组合非常实用,它可以彻底切断业务对大Key的访问路径,同时用低频率删除操作慢慢释放内存。
再说说从数据结构层面根治大Key。这要回到业务设计阶段。对于Hash类型,最常见的做法是哈希拆分,也叫桶化。譬如某个Key存的是用户维度数据,可以按用户ID取模分桶,拆成多个子Key。假设原结构是user:12345 -> {all data},可以改成user:12345:0、user:12345:1、user:12345:2这样若干个子Key,每个子Key只存一部分字段。这样既保证了逻辑上的数据完整性,又把单个Key的体量降了一个数量级。同理,List类型的Feed流,如果旧逻辑是全部消息都推入同一个List,可以改成按时间区间或者按消息ID段分片存储,并设置每个分片的最大长度,超出后直接丢弃或迁移到冷存储。Zset也可以按分数范围或时间维度分片,比如把排行榜分为“周榜”和“总榜”,避免单个Zset无限膨胀。
我以为最容易被忽略的反而是String类型的大Key。很多String类型的大Key产生于JSON序列化之后的大对象存储,比如缓存了用户全部画像、完整订单详情、商品聚合信息。这一类大Key之所以难治理,是因为它们无法通过集合拆分的方式解决,你需要业务侧把大对象拆成多个小字段存取,或者启用压缩算法处理后再存。如果字符串本身非常大,读取时还会频繁触发内存拷贝和TCP分包传输,这种场景不如直接考虑用更合适的存储系统来承接,比如把文档型数据迁到文档数据库,把大体积的临时内容放到对象存储,Redis只存索引和摘要信息。这个思路其实更符合“Redis是缓存而非主存储”的定位。
拆分Key这个动作听起来不复杂,真正落地时最大的阻力在业务代码改造。旧的调用方可能一次性读写整个大Key,你现在把一个大Key拆成几十个桶,查询逻辑就要改成并行读多个子Key再做聚合,这改变了接口的访问模式。我的建议是改造时保持兼容过渡:先新增分桶写入,同时保留原Key的同步,双写一段时间观察稳定后再切换读取逻辑,最后下线旧Key。不要指望一个晚上把所有代码改完,灰度发布、逐步迁移才是稳妥路线。
治理过程还有一个非常重要的侧翼动作,就是删除Key时的节奏控制。关于这一点我特别想说:问题排查时,不要试图一口气把发现的所有大Key都清掉。即便是使用UNLINK异步删除,后台释放线程的CPU和内存带宽也是有限的,如果你同时发起多个大Key的后台释放,后台线程的负载会被打高,同样可能影响实例稳定性。正确的做法是把大Key按体量分级,每次只处理一两个,观察实例的CPU、内存、网络延迟指标恢复正常后再继续。把“治理”当作一次有计划的变更,而不是一次临时救火。
预防层面,长效机制的建立才是这个大Key问题真正的终点。很多团队在经历一次大Key事故后,当时确实把故障Key处理掉了,但过了半年又冒出新的一个大Key,原因就在于治理没有形成制度化。
我觉得预防体系里第一件要做的事是写入侧限制。最直接的办法是在缓存访问层统一封装,对每个Key的写入操作增加体量检查。比如写入String之前,先判断值的字节数是否超过阈值,超过则拒绝或者告警;对于Hash/List/Set/Zset,监控元素数量增量,超过阈值时触发告警。更严格的做法是在Redis入口配置最大内存策略时,配合maxmemory-policy设置volatile-lru或allkeys-lru,让整个实例在内存接近上限时自动驱逐一些不常用的Key,变相降低大Key的堆积风险。但要注意,淘汰策略并不会阻止单个Key持续增长,它可能先淘汰其他小Key,最后留下的大Key反而更容易集中占用资源,所以它只能作为兜底,不能作为主要防线。
可写一个简单的写入代理层来做体量限制,伪代码如下:
MAX_STRING_BYTES = 100 * 1024 # 100KB MAX_COLLECTION_LEN = 5000 # 集合元素最大数量 def safe_set(key, value): if isinstance(value, str) and len(value.encode('utf-8')) > MAX_STRING_BYTES: raise ValueError(f"key: {key} too large") redis_client.set(key, value) def safe_add_to_set(key, member): if redis_client.scard(key) >= MAX_COLLECTION_LEN: raise ValueError(f"set key: {key} too large") redis_client.sadd(key, member)这样的约束放在所有调用入口,会让开发同学很痛苦,因为阈值打上来,很多原本“能跑”的逻辑就被挡住了。但从维护角度讲,这种强制约束恰恰能让问题在源头暴露,迫使团队去设计更合理的缓存结构。好的基础设施团队就应该承接这种“得罪人”的治理动作,因为生产环境的稳定性比开发便利性重要得多。
第二件要做的事是监控与告警。建议为每个Redis实例配置一款可观测性指标采集工具,以固定频率扫描实例内Key的内存分布。开源世界里比较常见的选择是RedisInsight里的分析工具,另外一些云厂商的Redis控制台也自带大Key分析功能。如果你更喜欢自建,可以考虑用redis-rdb-tools解析RDB文件来离线分析大Key,这种方式对线上实例完全无侵入,适合在每日备份的RDB文件上执行。扫描计划建议至少每日一次,数据量大的集群可以做到每两小时一次,把探测结果落到监控指标里,当发现某个Key超过阈值时自动触发告警,并在告警内容里附上Key名和体量信息,方便值班人员快速定位。
RDB离线分析还有一个额外的好处:它可以精确到每个Key的内存占用,而不是像--bigkeys那样只能看到集合元素数量。使用redis-rdb-tools的命令大致如下:
rdb -c memory /path/to/dump.rdb -f memory.csv输出文件里会列出每个Key的精确内存大小,你可以用SQL或者Excel对CSV做排序,直接拉出Top N大Key清单。这个方案在人多手杂的团队里特别有用,因为RDBA文件是每天都会自动备份的,离线分析不会增加任何线上压力。
最后再讲一个我个人的经验:每年至少做一次大Key压测演练。怎么演练?把当前集群里最庞大的几个Key提取出来,在预发环境构造同规模的测试数据,模拟高并发读取和删除操作,记录Redis实例在压力下的CPU、内存、延迟曲线,以此验证你的拆分方案和删除脚本是否真的扛得住。这个操作看起来消耗时间,但可以帮你提前暴露出很多工具链上的问题,比如分批删除脚本的batch_size设置是否合理,UNLINK后台线程在高负载下是否仍然稳定,业务侧读取分桶Key的并行聚合逻辑有没有性能瓶颈。我团队就是在一次演练中发现,原先设定的batch_size=500在内存压力偏高的实例上,仍然会让主线程偶尔出现毫秒级抖动,后来调整为200才彻底稳定。这种经验,光靠看文档是看不出门道的。
我也遇到过一种很常见的错误心态:觉得大Key治理是运维的事,或者是大数据量公司才需要关心的事。实际上,只要你的Redis实例在持续运行业务,大Key就可能在任何规模下出现。哪怕QPS不高,一个特别大的Key在定期删除或全量扫描时,都有可能造成间歇性的延迟抖动,影响到的可能是一个最核心的接口。与其等到线上告警,不如现在就花一两个晚上把集群扫一遍。这个工作量不大,收益却很直接。
写在最后。回到“性能刺客”这个比喻上,Redis大Key的问题不在于它本身有多神秘,而在于它足够隐蔽,平时不显山不露水,一旦爆发就是全局性影响。我的切身体会是,但凡涉及到缓存层的治理,最有效的思路永远是“源头约束 + 主动发现 + 安全清理 + 业务侧改造”四管齐下。只要你能让这四个环节形成闭环,大Key问题就很难再反反复复地咬你一口。如果有正在准备做Redis治理的朋友,希望这篇整理可以帮你少走一些弯路。这一版内容也是按我实际排障的思考脉络写的,有什么别的做法,欢迎留言交流。