
先别急着重启重启只能把问题往后拖。我说的是Redis内存无缘无故往上涨这件事。项目群里半夜有人发了一句“Redis内存又爆了”然后一顿操作重启、恢复、上线第二天同样的剧情重演。这场景不少运维和开发都经历过。大家习惯把这种内存越涨越多、怎么都降不下来的现象统称为“Redis内存泄漏”。但如果你真的按“内存泄漏”去排查很容易走偏——因为在绝大多数情况下Redis并没有真的“泄漏”内存而是业务层或部署层面的某些设计让内存被不合理地占用又没法通过正常机制释放。这篇文章就围绕“Redis内存泄漏到底指什么、怎么排查”展开把常见根因、排查命令、几个真实案例和预防手段讲透。1. 先搞清楚Redis内存泄漏到底指什么1.1 严格意义的内存泄漏在Redis里其实很罕见传统软件开发里说“内存泄漏”指的是程序申请了内存但忘记释放比如C语言里malloc了没freeJava里对象被无意引用导致GC回收不掉。这类泄漏的特征是内存占用只增不减而且没有任何正常手段能把它“找回来”只能靠重启进程。Redis自身是C语言写的理论上也可能有这种bug但实际上你去找Redis官网、GitHub issue真正能被确认为“Redis自身内存泄漏”的案例极少。我在生产环境跑了这么多年Redis几乎没有遇到过因为Redis本身的Bug导致内存泄漏的情况。所以当你看到内存异常上涨第一反应不要是“Redis有漏洞”而是要怀疑“我的使用方式有不合理的地方”。那大家口中的“Redis内存泄漏”到底是什么绝大多数是逻辑层面的内存异常占用可以叫“内存只进不出”或者“内存无法回收”。说白了就是数据写进去了但因为过期策略、数据结构设计、配置问题等原因这些数据占用的内存没法在预期时间内被清理掉或者清理成本太高导致内存水位持续走高。我用一个生活化类比真正泄漏是水池漏水而Redis里常见的情况更像是水池只会进水、排水口却堵住了。1.2 内存异常增长的真实表现与判断标准一个运行健康的Redis实例内存曲线应该相对平稳即使有业务高峰波峰过后也会回落到正常水位。当你遇到以下几种表现就需要高度警惕了used_memory持续走高used_memory_peak逼近甚至达到maxmemory设置的上限。内存涨上去之后不管业务流量怎么降它都不回落或者只有小幅波动。mem_fragmentation_ratio内存碎片率长期大于1.5甚至到2以上。系统开始使用swapINFO memory里能看到used_memory不高但RSS高得离谱。达到maxmemory后写入命令开始报OOM command not allowed when used memory maxmemory或者触发淘汰把有用的key干掉了。为什么要强调这些表现因为不同的表现指向的根因可能完全不同。比如used_memory一直涨但RSS正常多半是数据量确实在增长如果used_memory不高但RSS很高优先怀疑碎片化或者客户端输出缓冲区如果内存涨到某个点突然就跌下去一截然后又慢慢涨起来大概率是淘汰策略在生效或者过期键在批量清理。1.3 影响范围不只是崩溃那么简单内存异常增长的破坏力远不止“服务重启”这么简单。内存被打满之后Redis开始走淘汰策略如果是allkeys-lru可能把还没到期的热点数据淘汰掉造成缓存击穿直接把流量打到数据库如果设置了noeviction所有写命令直接失败上层业务报错一片。更麻烦的是内存高水位会导致持久化RDB快照、AOF重写期间的内存翻倍风险加大最终触发系统OOM killer把Redis进程直接杀掉。还有一个容易被忽略的连锁反应主从架构下主库内存异常膨胀fork子进程做持久化时内存翻倍可能导致全量同步失败、从库落后、主从切换时新主库根本没有完整数据。这一串下来就不是“重启一下”能解决的了。所以内存排查不是等到OOM了才做而是看到苗头就要动手。2. Redis内存异常增长的七类根因画像2.1 过期键堆积TTL设了但清理机制没那么快Redis清理过期键靠两招惰性删除和定期删除。惰性删除是访问到那个key才检查它是否过期定期删除是每100ms随机抽一批设置了过期时间的key如果过期比例超过25%就继续抽。这套机制的设计初衷是为了避免一次性删除大量key阻塞主线程但副作用就是如果一个实例里堆积了海量过期key清理速度可能跟不上写入速度内存自然就“只进不出”。更隐蔽的情况是大量key根本没有设置TTL。比如业务方用Redis做临时存储习惯性SET进去就再也不管了key一辈子不过期。时间一长这些“永远活着”的数据越积越多。我见过一个实际案例一个内部系统每天往Redis写一千万条短时效记录所有key都没设置TTL三个月后内存从8G涨到40G查下来全是这种“僵尸key”。2.2 大Key与无界集合典型的“内存黑洞”大Key是指单个key内存储的数据体量过大比如一个String存了几MB或者一个Hash里塞了几百万个field。大Key本身不会直接导致“泄漏”但它会让内存清理变得异常困难。举个例子一个1000万字段的Hash如果业务不再需要它了你DEL它的时候Redis主线程会同步删除卡住几十秒甚至几分钟线上服务直接雪崩导致运维根本不敢删内存就只能一直占着。还有一种更典型的场景用List当消息队列RPUSH生产消息消费者消费后LPOP。如果消费者服务挂了没人来取消息List就无限增长。这非常像“内存泄漏”但本质上是生产端没有限流、消费端没有容灾。这类无界集合还包括Set、ZSet、Stream只要往里写而取出/清理路径不通畅内存就会一路涨。2.3 缓存值设计不合理单个value被无限撑大有些团队习惯把一个大对象整体序列化后塞进Redis当缓存比如把一个用户的完整订单列表、一个页面的完整HTML片段都放进一个key。如果这个对象不断增长每次写入都会生成一个更大的value而旧版本如果没被覆盖或删除内存里可能同时存在多个版本。更糟的是String类型反复APPENDRedis底层会重新分配内存分配器一旦找不到连续内存就会产生大量碎片内存占用看起来就“只涨不跌”。这类问题隐蔽性很强因为从命令看都是正常的SET、GET、APPEND没有大集合那么扎眼但如果value从最初的几十KB慢慢涨到几十MB每个key都这么搞整体内存压力是非常明显的。2.4 持久化与写时复制瞬时内存翻倍的“假泄漏”RDB执行BGSAVE或者AOF触发自动重写时Redis会fork一个子进程来干活。fork之后父子进程共享同一份内存理论上子进程只是为了写快照不需要额外拷贝内存但父进程在fork期间如果还在继续接收写入内核会触发写时复制Copy On Write在父进程里复制被修改的内存页。写入越频繁需要复制的内存页就越多父进程RSS可能短时间内大幅上涨。这个现象从used_memory上未必看得出来因为used_memory统计的是逻辑数据占用而RSS才反映真实物理内存占用。如果你只看used_memory会觉得内存没怎么涨但系统可能已经在崩溃边缘因为物理内存被fork过程吃掉了。很多人把这个当成“内存泄漏”其实只要BGSAVE结束这部分内存就会释放。但如果BGSAVE期间写入量极大内存翻倍到系统扛不住就会被OOM杀掉看起来就像内存莫名其妙爆掉。2.5 客户端输出缓冲区看不见的“内存吸尘器”Redis为每个连接维护输出缓冲区。如果某个客户端通过SUBSCRIBE订阅了大量频道或者执行了SYNC/PSYNC拉取全量数据又或者开了MONITOR监听所有命令而客户端读取速度跟不上Redis产生数据的速度数据就会堆积在输出缓冲区里。尤其要注意MONITOR命令。很多人排查问题时会开一个MONITOR挂几分钟但忘了关它会把所有命令实时推给客户端如果客户端处理不过来缓冲就越堆越大。我之前排查一个线上实例内存一直涨查不到原因最后发现有人开着一个redis-cli monitor窗口没关输出缓冲区已经吃掉了好几个GB。这类“泄漏”只要断开那个客户端连接内存立刻回落。2.6 复制积压与全量同步主从断线重连的代价Redis主从架构里主库维护一个复制积压缓冲区repl-backlog-size默认1MB。如果从库断线时间较长断线期间的增量数据超过了积压缓冲区的容量从库就无法通过PSYNC继续增量同步只能触发全量同步。全量同步时主库要fork子进程生成RDB再把整个RDB文件通过输出缓冲发给从库。如果主库数据量大全量同步期间内存消耗会非常明显一方面fork带来的COW内存增长另一方面发送RDB时输出缓冲区也可能膨胀。如果client-output-buffer-limit replica配置不当或者从库网络不好主库的输出缓冲区会持续堆积内存上涨就是必然的。我见过一个从库因为网络抖动反复触发全量同步主库内存从10G飙到30G的情况。2.7 内存碎片化内存“房间”被拆得七零八落Redis默认使用jemalloc内存分配器频繁的增删改会导致内存块大小分布不均匀产生大量碎片。表现为used_memory_rss远大于used_memorymem_fragmentation_ratio长期偏高。这些碎片没法被其他key复用内存从“总量上有富余”变成“实际可用很少”。碎片化在高并发、高变更的场景下尤其严重。比如一个业务不停地写入大量带TTL的短数据过期后内存释放了但这些释放出来的空间因为不连续新数据又用不上RSS自然居高不下。碎片率超过1.5就说明内存里有很多“拆不进去”的小隔间超过2基本只能靠重启释放。3. 四步排查法从现象到定位3.1 第一步用info memory锁定内存画像排查内存问题第一件事永远是先看内存的整体画像而不是直接去猜大Key。redis-cli -a 你的密码 info memory重点看这几个指标指标含义判断要点used_memory逻辑数据占用的字节数是否持续上升、是否接近maxmemoryused_memory_rss实际物理内存占用RSS与used_memory的差值是否过大used_memory_peak历史峰值内存如果峰值远超当前说明之前有过异常波峰mem_fragmentation_ratioRSS除以used_memory的比值大于1.5警惕碎片化小于1可能已使用swapmaxmemory内存上限配置检查是否真的设置过maxmemory_policy达到上限后的淘汰策略是否与数据容忍度匹配判断逻辑可以这样走如果used_memory接近maxmemory先看expired_keys和evicted_keys两个累计指标判断是过期堆积还是淘汰频繁。如果mem_fragmentation_ratio大于1.5且used_memory不算太高优先考虑碎片化问题。如果used_memory_rss比used_memory大很多同时系统出现过swap需要考虑fork或输出缓冲区的问题。这一步不需要太多时间但能帮你把排查方向收窄到一到两个。3.2 第二步扫描大Key与过期Key分布定位到可能是数据层面的问题后就该看看实例里到底有什么“大家伙”。最简单的方式是用自带的--bigkeys扫描redis-cli -a 你的密码 --bigkeys --i 0.1它会按类型统计最大key。注意两点第一--bigkeys是近似统计它按采样来估算最大key大小不是精确值第二全量扫描对线上实例有一定压力建议在业务低峰期跑--i 0.1表示每扫描100个key后sleep 0.1秒用来限速。对于已经定位到的可疑key用MEMORY USAGE查精确值redis-cli -a 你的密码 memory usage 具体的key名它能给出这个key实际占用的字节数比--bigkeys准确得多。大Key查完之后要查过期Key的分布情况。这一步很重要因为很多“内存泄漏”其实就是一堆没有TTL的key在堆积。可以参考下面这个Python脚本用SCAN遍历所有key并统计TTL分布import redis r redis.Redis(host127.0.0.1, port6379, password你的密码, decode_responsesTrue) cursor 0 buckets {} no_ttl 0 total 0 pipe r.pipeline(transactionFalse) while True: cursor, keys r.scan(cursorcursor, count1000) for key in keys: pipe.ttl(key) pipe.memory_usage(key) results pipe.execute() for i in range(len(keys)): ttl results[i * 2] mem results[i * 2 1] total 1 if ttl -1: bucket 无TTL no_ttl 1 elif ttl -2: bucket 已过期待清 elif ttl 60: bucket 小于1分钟 elif ttl 3600: bucket 1分钟-1小时 elif ttl 86400: bucket 1小时-1天 else: bucket 1天以上 buckets[bucket] buckets.get(bucket, 0) 1 if cursor 0: break print(f总key数: {total}) print(f无TTL的key数: {no_ttl}) for k, v in sorted(buckets.items(), keylambda x: -x[1]): print(f{k}: {v})这个脚本会告诉你有多少key根本没设置过期时间有多少key过期后还没被清理掉。如果“无TTL”的占比很高那根因基本就找到了。3.3 第三步RDB离线分析不占线上资源线上的扫描始终有压力更稳妥的办法是拿一份RDB文件下来离线分析。RDB是Redis的内存快照分析它能拿到非常精确的数据分布。先找到RDB文件位置CONFIG GET dir和CONFIG GET dbfilename能看到路径。然后拷贝一份到分析机器用rdbtools工具分析pip install rdbtools python-lzf rdb -c memory /path/to/dump.rdb -f memory.csv生成的memory.csv会按内存占用列出所有key。接着可以用命令行工具做排序head -20 memory.csv或者用awk按内存大小排序。这样你就能看到实例里内存占用排前20的key分别是谁、什么类型、占了多少内存。这个方法最大的好处是零侵入不影响线上Redis的性能适合在内存问题很严重、线上扫描风险过高的时候使用。有个细节要提醒RDB文件默认是压缩的分析出来的内存占比是逻辑数据占比不是物理内存占比碎片问题从这里面看不出来。所以离线分析适合定位“数据量层面的异常”不适合定位“碎片化”。3.4 第四步动态观测抓MONITOR与客户端连接如果数据和过期Key都查不出问题那就要怀疑是动态层面的异常了比如输出缓冲区、复制积压、客户端连接数异常。用CLIENT LIST查看所有客户端连接及其缓冲区状态redis-cli -a 你的密码 client list重点关注omem字段输出缓冲区内存占用。如果某个客户端连接的omem特别大就说明这个客户端消费速度跟不上数据在Redis侧堆积了。这种情况直接断开这个客户端连接内存就能释放redis-cli -a 你的密码 client kill 客户端ID如果问题还是找不到可以在业务低峰期用MONITOR做短时间抓包timeout 30 redis-cli -a 你的密码 monitor /tmp/monitor.log然后分析日志里是否有大量的APPEND、RPUSH、SET等写操作这些可能是内存增长的直接来源。但切记MONITOR本身会持续吃掉输出缓冲而且会拖慢Redis整体QPS绝对不能长时间运行更不能在高流量时段开。我发现过有团队在排查问题时忘记关掉它反而进一步搞爆了内存。SLOWLOG也值得看一眼redis-cli -a 你的密码 slowlog get 20如果发现有大Key的DEL、BGSAVE之类的慢命令说明内存问题可能伴随阻塞风险需要特别小心。4. 典型问题速查表与三个真实案例复盘4.1 速查表症状到处理方案的快速对照整理了一份速查表排查的时候可以直接对着看现象可能原因核心排查命令处理手段used_memory持续涨TTL-1的key多无TTL的key堆积scanttl脚本业务评估后清理后续开发规范强制加TTL有个key特别大删除会阻塞大Keybigkeys、memory usage异步删除unlink数据结构分片List/Stream不断增长消费端故障或太慢llen、xlen、monitor修复消费端限流截断RSS远大于used_memory碎片率高内存碎片化info memory开启activedefrag规划低峰重启某客户端omem非常大输出缓冲区堆积client list断开该连接排查客户端读取逻辑BGSAVE期间内存暴涨fork写时复制info stats 时间对照降低maxmemory余量错峰持久化从库断线后主库内存暴涨全量同步info replication、client list调大repl-backlog-size减少全量同步4.2 案例一大促后的“降不下来”的内存一次大促活动结束后Redis实例的used_memory从20G涨到50G三天了都没降下来。活动流量早没了这显然不正常。通过扫描发现热点是几个存活动热榜的ZSet每个ZSet里有几百万到上千万个memberTTL设置了3天但Redis的定期删除根本来不及在到期瞬间清干净。真正麻烦的不是过期键没删干净而是这几个大ZSet在删除时会造成主线程阻塞运维根本不敢直接DEL。最后方案是先用ZREMRANGEBYRANK按批次小步慢走地删除元素再在配置里给这些大Key做了分片把一个大ZSet拆成多个按时间段命名的ZSet这样单个key体量可控过期清理压力也分散了。处理完之后内存回到18G并且再次大促时没有再出现这个现象。4.3 案例二消费者挂了List无限增长某个团队把订单事件写进Redis List做轻量队列生产者写入很快消费者服务因为代码bug宕机了三个小时。等服务恢复之前队列里已经堆积了大约几十GB的数据。从Redis侧看内存就是从List疯狂增长导致的。排查时直接LLEN一看队列长度多到离谱。处理方式分两步先把消费者服务修复好让它开始消费同时对队列做了一次LTRIM截断保留最近一小时的数据快速把内存压下去。这里有个矛盾是直接DEL队列会丢失未处理的数据但业务确认这些事件可以容忍丢失所以选择了截断。如果业务数据不能丢就得先扩容消费者能力或者临时阻塞生产者再让队列慢慢消化。4.4 案例三BGSAVE触发的OOM“假泄漏”一个实例配置了maxmemory 60G物理内存96G。平时内存占用在40G左右看起来还有很大余量。但每次BGSAVE执行到一半系统就报OOMRedis直接挂了。查INFO memory发现used_memory不高但used_memory_rss在BGSAVE期间飙到100G以上。原因就是fork之后写时复制高并发写入导致父进程内存页大量被复制再加上子进程写RDB也要消耗内存最终超过了物理内存上限。这个问题的根源是给Redis留的物理内存余量太少了。调整方案把maxmemory降到45G留出更多物理内存给fork期间的内存膨胀同时把自动BGSAVE关闭改成每天业务低峰期定时执行BGSAVEAOF重写也错峰安排。之后再没有发生OOM。5. 内存治理与长效预防5.1 配置层整改maxmemory、淘汰策略与碎片整理内存治理不是等出了事再处理而是平时就要把配置立好规矩。maxmemory一定要设置。不设置上限Redis就会无限吃内存把整台机器拖垮。设置上限时要考虑到fork、输出缓冲区、碎片化这些因素建议给系统预留20%-30%的物理内存。如果机器有32Gmaxmemory可以设20G左右留出空间给BGSAVE等瞬时内存消耗。淘汰策略要根据业务场景选。缓存类数据用allkeys-lru是合理的因为即使把最老的key淘汰掉也只是少了一次缓存命中但如果你把Redis当数据库用数据不能丢就不能用allkeys-lru否则未过期的业务数据也会被误删。这时候应该选noeviction配合业务系统自己处理写入失败或者选volatile-lru只淘汰设置了过期时间的key。没有一种策略是万能的选之前一定要明确Redis在你系统里承担的角色。碎片整理方面Redis 4.4及以上版本支持在线碎片整理相关配置可以参考activedefrag yes active-defrag-ignore-bytes 100mb active-defrag-threshold-lower 10 active-defrag-threshold-upper 100 active-defrag-cycle-min 5 active-defrag-cycle-max 75这些参数的意思是当碎片率超过10%、需要整理的碎片量超过100MB时以不超过75%的CPU时间进行整理。开启active defrag对CPU有一定消耗建议测试环境下验证过对业务无影响后再打开。如果版本较老最简单的释放碎片方案是维护窗口期做主从切换让新主库重启后重新加载数据碎片自然清零。5.2 监控告警别等内存顶到天花板排查再牛不如监控前置。很多内存问题实际上是有前兆的只是没人看。推荐用redis_exporter对接Prometheus配上Grafana看板。至少盯这么几个指标redis_memory_used_bytes逻辑内存使用量。redis_memory_used_rss_bytes物理内存占用。redis_memory_fragmentation_ratio碎片率。redis_memory_max_bytes设置的内存上限接近时要告警。redis_keyspace_avg_ttl和带_expires的key数观察过期key堆积情况。告警阈值要根据实例规格定。比如内存水位超过maxmemory的70%告警超过85%就要紧急处理。另外建议做“增长率告警”——如果used_memory在一个小时内增长了超过2G即使绝对值不高也要提前排查因为这种增长趋势往往预示着业务异常。5.3 业务层规范数据结构选择与TTL治理内存问题的根子往往在业务设计。实践下来有两个铁律值得写进开发规范第一除了极少数明确允许长期存在的key所有写入Redis的key都必须设置TTL而且TTL时长要跟数据的真实生命周期对齐。不要“图省事不设过期”你省下的那一行代码将来要用几天的通宵来还。第二避免大Key和无界集合。设计阶段就要规定单个String大小不超过100KB单个Hash、Set、ZSet的元素数量不超过一万如果存在超过这个量级的场景必须做分片。比如用户维度的数据可以用user:{userId}:orders的方式拆分而不是把所有订单都塞进一个Hash里。消息队列该用Stream就用Stream用List做队列时一定要配合LTRIM或者消费者组的ack机制避免无限堆积。上线前review数据结构设计其实比出问题再排查要省力得多。团队里如果有人提出“临时放一下不会太多”之类的说法基本可以判定未来会有内存隐患。最后分享一点个人体会排查到现在我的经验是遇到Redis内存异常先别慌着重启重启只是刷新了问题现场什么都没留下。正确做法是先抓住INFO memory的完整输出再看CLIENT LIST再考虑要不要扫描大Key最后才是动作。很多所谓的“内存泄漏”查到最后都是某个没设置TTL的key、一个忘了关的MONITOR、或者消费者挂了没人管。把设计规范立好比会各种排查命令重要得多。顺便说一句定期给Redis做一次“内存体检”很值——每个月挑个低峰期跑一遍扫描脚本看看有没有异常增长的大Key和大量无TTL数据十分钟的事能省掉未来好几天的排障时间。