十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis Big Key排查与治理:从阻塞原理到渐进删除的完整方案

Redis Big Key排查与治理:从阻塞原理到渐进删除的完整方案 做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%就要警惕。拿一张表来整理可能更直观数据类型建议关注阈值建议处理阈值Stringvalue 10KBvalue 100KBHashfield 1000field 5000 或整体 10MBList元素 5000元素 10000 或整体 10MBSet元素 5000元素 10000 或整体 10MBZset元素 5000元素 10000 或整体 10MB1.2 两种常见的Big Key形态和它们的区别我在实际排查中发现Big Key往往以两种形态出现处理方式完全不同。第一种是“大而少”的string类型。比如一个key下面挂了2MB的JSON字符串这类key的危害主要在网络传输和序列化反序列化上每次读写都要搬运这么大一个包。第二种是“大而多”的集合类型。比如一个Hash里塞了上百万个field一个List里有上千万条记录。这类key的危害更隐蔽因为它单个元素不大但元素总数巨大做全量操作时复杂度是O(N)瞬间阻塞主线程。这里要特别提醒一点别把Big Key和热KeyHot Key混为一谈。热Key是访问频率高Big Key是自身体积大两者不是一回事。但一个Big Key如果同时被高频访问破坏力是叠加的既阻塞命令又打满网卡这种情况下得先解决体积问题再考虑热点拆分。2. 为什么一个“大”key能让整个Redis卡住刚接触Redis的人常有个疑惑Redis单核处理能力很强一个key大点而已怎么会影响别人要理解这一点得先理解Redis的工作模型。2.1 单线程模型下的一次O(N)命令Redis的核心处理是单线程的所有命令在主线程里排队执行。单线程意味着同一时刻只能有一个命令在执行其他所有请求都在等待。单个命令执行时间越长后续堆积的命令就越多。举个例子一个Hash里有100万个field执行一次HGETALLRedis需要遍历全部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 ClusterBig 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 为什么不能直接delDEL命令删除集合类型时需要在主线程里逐个释放所有元素。一个100万字段的HashDEL它的耗时可能超过几百毫秒主线程被占住的时间比执行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(host127.0.0.1, port6379, db0) key user:tags:all batch_size 200 cursor 0 while True: cursor, fields r.hscan(key, cursorcursor, countbatch_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不怕。其他数据类型对应的渐进删除方式数据类型分批删除方法HashHSCAN HDELSetSSCAN SREMZsetZSCAN ZREM或 ZREMRANGEBYRANKListLRANGE 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_idvalue是标签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某个业务每天早上有个定时任务会把全量用户的标签数据写入一个Hashfield是user_idvalue是标签数组的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对应什么数据、多大容量、怎么分桶、怎么过期这些问题在设计阶段不搞清楚迟早会在生产环境以故障的形式向你讨债。
返回列表