有些日子没在博客上写东西了周末整理排障笔记翻到去年处理的一次生产事故表象是接口超时、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但对于一个包含几十万元素的HashDEL需要逐个释放内部节点这个过程是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(host127.0.0.1, port6379, passwordyourpassword, decode_responsesTrue) # 使用 SCAN 获取所有 key避免 KEYS 阻塞 cursor 0 threshold 1024 * 1024 # 1MB 以上视为大 Key 候选 big_keys [] while True: cursor, keys r.scan(cursorcursor, count500) 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(keylambda x: x[1], reverseTrue) 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(host127.0.0.1, port6379, passwordyourpassword, decode_responsesTrue) 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(fkey: {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(fset 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_size500在内存压力偏高的实例上仍然会让主线程偶尔出现毫秒级抖动后来调整为200才彻底稳定。这种经验光靠看文档是看不出门道的。我也遇到过一种很常见的错误心态觉得大Key治理是运维的事或者是大数据量公司才需要关心的事。实际上只要你的Redis实例在持续运行业务大Key就可能在任何规模下出现。哪怕QPS不高一个特别大的Key在定期删除或全量扫描时都有可能造成间歇性的延迟抖动影响到的可能是一个最核心的接口。与其等到线上告警不如现在就花一两个晚上把集群扫一遍。这个工作量不大收益却很直接。写在最后。回到“性能刺客”这个比喻上Redis大Key的问题不在于它本身有多神秘而在于它足够隐蔽平时不显山不露水一旦爆发就是全局性影响。我的切身体会是但凡涉及到缓存层的治理最有效的思路永远是“源头约束 主动发现 安全清理 业务侧改造”四管齐下。只要你能让这四个环节形成闭环大Key问题就很难再反反复复地咬你一口。如果有正在准备做Redis治理的朋友希望这篇整理可以帮你少走一些弯路。这一版内容也是按我实际排障的思考脉络写的有什么别的做法欢迎留言交流。
阅读完成 · 觉得有帮助?