ARTICLE DETAIL

资讯详情

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

Redis内存只增不减?从原理到实战搞定内存泄漏排查

Redis内存只增不减?从原理到实战搞定内存泄漏排查 上个月半夜两点同事打电话说线上Redis内存又被打满了。监控图从3GB一路爬到12GB重启之后两小时又开始涨业务量并没有明显变化键的数量也不像激增的样子可内存就是回不去。这种“内存只增不减”的情况我碰过不下十次归根结底不是Redis有什么神秘的内存黑洞而是我们把Redis当成一个无限大的袋子什么东西都往里塞塞完就不管了。这就是常说的Redis内存泄漏——听起来像个C野指针实际上大多数发生在数据设计和使用习惯上。我打算把这次排查过程完整拆开讲。会先解释清楚“Redis内存泄漏”到底指什么再看监控上怎么快速发现它然后给出一条可以直接照抄的排查路线最后把线上最常见的原因和处理方案列出来。如果你用过Redis做缓存、分布式锁或者正在为云上Redis内存持续上涨发愁这篇应该能帮上忙。1. 先给“内存泄漏”定义边界Redis的哪种内存异常最值得警惕1.1 数据层面的泄漏该淘汰的键没有淘汰很多人听到“泄漏”就会想到进程内部有一块内存再也无法释放比如C语言里malloc之后忘了free。但Redis是内存数据库它的内存由自己管理绝大多数所谓的“内存泄漏”其实是数据层的滞留键还在、Value还在、引用关系还在只是业务上再也用不到它了。最常见的是写缓存时不设置过期时间或者设置了过期时间但键本身在持续累计。比如登录验证码、接口防抖标记、用户临时状态这些数据本质上是短生命周期的但代码里写成了set key value忘了加EX 60。业务低峰期数据量看着不大高峰期过后这些键全都留在内存里。此时Redis的used_memory会稳步上升键数量也随之变多但它并不是一条“内存块丢失”式的泄漏而是“垃圾数据被当成正式数据保存”的逻辑泄漏。另一个常见形式是同一个业务键不断更新但版本永远不会删除。例如用户昵称、商品信息每次变动都用一个新的key保存老key留着做历史记录。历史记录如果无限增长最终Redis内存就会被拉爆。这种场景下从INFO命令里看键数量增长明显数据也确实有用但“有用”的比例极低实际就是泄漏。1.2 运行机制层面的消耗碎片与内部缓冲除了数据本身Redis进程还要维护很多辅助结构。每个键除了Value以外还有key本身RedisObject头、SDS头、字典表项、过期索引等。100万个很小的键光元数据就能吃掉几百MB到1GB。比这个更隐蔽的是内存碎片。Redis默认用的是jemalloc分配器它为了分配效率会把内存切成不同大小的bin。当键反复被创建删除、Value的大小又不固定时分配器里会出现大量“看起来空着、但没法被新键使用”的碎片。这时候used_memory可能只有5GB但used_memory_rss显示进程占用了8GB这就是碎片率高的表现。虽然每个碎片很小累积起来非常可观并且常规手段回收不了只能靠碎片整理或重启。还有一些运行机制也会占用内存客户端的输出缓冲区、AOF重写期间的copy-on-write、主从全量同步时的积压缓冲区。这些东西都可能让内存短期暴涨如果配置不合理涨上去之后也不容易降下来。它们同样会被当成“内存泄漏”来查。1.3 哪些情况不叫泄漏别混淆排查前先排除正常的容量增长。业务日活在涨、缓存命中率没变、数据量每天增加used_memory同步上涨是非常正常的这叫容量规划问题不是故障。还有一些情况是Redis重启后内存马上就降下来了但业务量本身也恢复了这种往往是瞬时高峰导致的临时内存膨胀也不该归为泄漏。如果不区分这些很容易在错误方向上浪费半天时间。我的习惯是看到内存上涨第一件事不钻代码先坐下来把时间线捋清楚内存是什么时候开始涨的、当时业务有没有发布、数据增长曲线是否和业务量同步。这一步可以过滤掉至少一半的“假报警”。2. 第一步永远是看监控趋势而不是直接翻键2.1 用INFO memory把三个关键数字先对齐排查内存问题我最先执行的命令永远是这一条redis-cli info memory输出里面重点看五个字段字段含义重点关注used_memoryRedis分配器实际分配的内存总量含数据与内部结构判断数据占用是否增长的依据used_memory_rss操作系统视角下Redis占用的物理内存和容器内存监控对比used_memory_peak历史最高占用判断当前是否处于回落期maxmemory配置的最大内存上限为0代表未限制危险mem_fragmentation_ratioRSS和分配内存的比值碎片率后面细说我见过很多人只看DBA平台或云监控上的内存曲线内存图发红就开始各种查。但云监控的“内存使用率”通常来自used_memory_rss它受碎片和操作系统页缓存影响不一定能真实反映数据量。所以先看这三个数字如果used_memory_rss高但used_memory不高优先查碎片如果used_memory本身就高再继续往下查键。2.2 内存碎片率这条线怎么读mem_fragmentation_ratio used_memory_rss / used_memory它的合理区间是1到1.5之间。小于1说明使用了swap进程被操作系统换到了磁盘内存可能不够了1.5到2之间说明碎片偏多可以接受但需要关注大于2就是很明显的碎片率高rss比实际分配多了一倍内存告警大概率是这个引起的。要特别注意碎片率不是一个平滑的曲线。Redis空闲时内部管理结构会自动整理一部分碎片所以碎片率会上下跳动。清理碎片可以开启主动碎片整理但不要在生产环境直接贸然开启要在低峰期逐步调整参数config set activedefrag yes config set active-defrag-ignore-bytes 100mb config set active-defrag-threshold-lower 10 config set active-defrag-cycle-min 5 config set active-defrag-cycle-max 75这几个参数的意思是当碎片超过100MB且碎片率超过10%时开始整理CPU占用控制在5%到75%之间。调整完需要观察至少一晚上如果CPU升高明显把cycle-max调低。2.3 从时间轴找到内存“胀点”只看当前数值容易误判一定要结合时间线。内存曲线通常有两种形状一种是持续爬坡像楼梯一样一格格往上走这种多半是键只增不减另一种是平时平缓某个时间点突然跳高之后一直不降这种大概率是某个大操作导致的比如全量同步、AOF重写、加载大量数据。我在定位的时候会把监控图切成小时粒度和天粒度各看一遍。小时粒度用来找“胀点”的具体时刻天粒度用来确认是不是周期性。比如某个业务每天凌晨跑批任务写一批临时键跑完不删那曲线就会每天往上涨一格。看到这种规律基本可以断定是业务代码的问题直接去查对应时段的写入命令就行。提示内存曲线如果只在重启后短暂下降、然后又回到原来的高度说明数据本身已经膨胀到了那个量级单纯重启解决不了问题。这时就算物理机内存从16GB升到32GB也只是把爆发点往后推。3. 一套可以照抄的排查路线按顺序排除四大类根因3.1 第一轮盘键——总量、过期键和类型分布确定需要查数据问题以后先看键的整体情况。redis-cli dbsize redis-cli info keyspacedbsize返回当前库的键总数info keyspace能看到每个库的键数量。如果键总数和内存增长不成比例比如内存涨了3倍但键数只涨了20%说明问题不在键数量上而是某些键的体积变大了或者是非数据内存膨胀。接下来要搞清楚键的类型分布。不要用KEYS *线上大keyspace会被阻塞。正确姿势是用SCAN迭代配合TYPE、STRLEN、LLEN等命令统计。Redis也提供了一个快捷命令redis-cli --bigkeys它会扫描整个keyspace输出每个类型最大的几个键。这个命令只读安全系数较高但在超大实例上仍会产生较大开销建议低峰期执行。如果线上Redis是云厂商托管实例通常云控制台也自带“大Key分析”效果类似。3.2 第二轮捞大键和无TTL键直接定位重量级对象--bigkeys能查出最大的键但漏掉了“很多不算大、但总量极大的键”。所以要再跑一遍“无TTL键”排查重点筛出所有没有设置过期时间的键。我常用的方式是用SCAN配合TTL命令写个小脚本按1000个一批来扫避免阻塞实例。伪逻辑如下redis-cli --scan --pattern * | while read key; do ttl$(redis-cli ttl $key) if [ $ttl -eq -1 ]; then echo no ttl: $key fi done生产环境不建议直接把这个脚本跑在集群所有分片上可以按db分片跑或者抽样一部分。脚本目的不是精确统计每一个键而是找出“无TTL键大概占多少、集中在哪个业务前缀”。找到可疑键以后用Redis 4.0以上提供的MEMORY USAGE命令精确估算它占用的内存redis-cli memory usage sp:user_detail:12345比如一个看似普通的Hash键嵌套了10万个小字段memory usage可能直接给你返回10MB。这种键就是隐藏的重量级对象。大键的排查还要注意嵌套结构和序列化方式。很多团队习惯把Java对象用JSON序列化之后存成string一个value轻松几百KB压测一跑内存直接失控。要在代码层扫一遍凡是单个key超过1MB的都是重点治理对象。3.3 第三轮查客户端连接与输入输出缓冲区如果键的数量和大小都没有明显异常下一个疑点就是客户端连接。redis-cli client list每条连接记录里有两个字段非常关键omem表示输出缓冲区已使用字节数tot-net表示累计网络传输字节数last-cmd表示最近执行的命令。如果发现某个连接的omem长期很大说明这条连接上的慢客户端没有及时读走响应数据全部积压在Redis进程内。造成这个现象的典型路径是客户端执行了LRANGE key 0 -1取一个超大list或者订阅频道后消费速率跟不上又或者通过MONITOR命令长时间监听所有命令都被复制到输出缓冲区。缓冲区积压最严重的时候一条连接就能吃掉几个GB内存。这时候还要看服务端的缓冲区限制配置redis-cli config get client-output-buffer-limit默认配置通常是normal 0 0 0、replica 256mb 64mb 60、pubsub 32mb 8mb 60。如果有某个连接类型长期顶到limitRedis会直接断开客户端但断开前那段内存压力仍然会造成明显尖峰。另一个容易被忽略的“客户端”是Redis自身的复制客户端。主节点发送RDB全量或者大量增量命令给从节点时会为复制连接分配输出缓冲区。从节点处理跟不上主节点的内存就会飙升。这种情况看client list里面flags为S的连接omem会异常高。3.4 第四轮看持久化与主从复制带来的内存放大持久化和复制经常是内存“假泄漏”的元凶。先看INFO persistenceredis-cli info persistence如果rdb_bgsave_in_progress为1说明正在执行RDB后台保存。RDB持久化用到copy-on-write机制也就是说保存开始的那一刻Redis会fork一个子进程父进程所有内存页被标记为可写。如果保存期间有大量写入父进程会复制被修改的内存页内存占用可能瞬间变成原来的1.5到2倍。这个现象是临时的保存结束后会回落但如果你配置了很高的rdb-save-incremental-fsync频率每次全量都能卡在峰值上。AOF重写也一样aof_rewrite_in_progress为1的时候内存会被放大。这里有个经验值在开启了RDB/AOF持久化的Redis上maxmemory最好不要超过物理内存的50%到60%否则fork和COW很容易把机器打满。主从复制方面INFO replication里的repl_backlog_size值得关注。复制积压缓冲区默认1MB如果主节点写入量极大或者从节点断连时间较长积压缓冲区会被撑大。另外从节点全量同步期间主节点需要把一份完整RDB传给从节点期间所有写命令都要缓冲主节点内存会额外多出一份。同样这也是一过性的但高峰叠加时足以引发OOM。3.5 第五轮用慢日志和MONITOR抓“看不见的写入”前面的排查能覆盖80%的场景剩下20%是那种“内存一直涨但查不到固定的大键”的情况。这时候需要从命令层面入手。先看慢日志redis-cli slowlog get 30慢日志能告诉你哪些命令耗时最长。如果有很多SET操作占用时间长大概率是因为Value特别大如果有很多DEL操作大概率是因为删除了大集合导致卡顿和内存抖动。想看清写入源可以用MONITOR观察几十秒redis-cli monitor /tmp/monitor.log但注意MONITOR本身就是高开销命令会大幅降低Redis性能生产环境务必在低峰期执行且不要超过30秒。抓到日志后按命令前缀统计一下grep -E \SET\|\HSET\ /tmp/monitor.log | awk {print $3} | sort | uniq -c | sort -nr | head -30这样能看到是哪个业务前缀在疯狂写。再配合应用发布记录基本能锁定是哪段代码在搞事情。比如某个定时任务每5分钟往Redis里写10万个键跑一个晚上就是近6000万键不泄漏才怪。4. 线上最常见的六类内存泄漏处理方案直接抄4.1 无TTL缓存键最经典的持久性垃圾我在不少团队里看到过同一个问题缓存写入不设过期时间。开发同学觉得“缓存嘛本来就该一直放着”结果用户量一涨那些临时验证码、防抖标记、首页聚合数据全部变成永久键。这些键其实绝大多数只在一小时内有效剩下的时间纯粹占着内存。处理方案分两步一是立刻清理存量数据。先用之前说的SCAN脚本找出无TTL键过滤出业务前缀然后分批删除或设置过期时间。二是修补代码所有写缓存的地方强制加上过期时间。想在Redis端兜底的话可以用rename-command把SET改成只允许带过期参数的脚本但从工程角度最实际的还是加代码规范和CR审查。4.2 分布式锁的key异常残留分布式锁是“逻辑泄漏”的重灾区。很多团队早期用SETNX实现锁锁key只有业务处理完才删除。一旦业务异常退出、方法抛错、进程被kill锁key就永远留在Redis里。更危险的是用Redis客户端自带的分布式锁时如果没设置合理的leaseTime或者看门狗逻辑有问题锁key过期时间远远大于实际持有时间也会出现大量无效key。处理这种问题不是只靠“解锁时删除”就够必须在加锁时设置较短的过期时间并且解锁时用Lua脚本保证原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end使用Lua脚本是为了防止“判断是自己的锁、准备删除时锁刚好过期被别的线程拿到”这种极端竞态。真正的生产实践里用Redisson这类带看门狗自动续期的库会比自己造轮子稳得多。4.3 大Key频繁更新旧值迟迟不释放当一个string类型的key反复被覆盖旧值分配的旧空间会被分配器释放然后新值重新申请空间。听起来没问题但如果新值比旧值大很多而旧值所在的内存bin无法被其他数据复用空出来的空间就成了碎片。比如某个key之前是2KB的JSON某天突然写入20MB的数据这个跳跃很容易产生大量碎片。更典型的场景是开发者用一个Hash做大对象里面塞了上百万字段。每次只有一个字段变更但整个Hash的底层结构会不断扩容过程中会产生大量临时内存。清掉这类大key也很讲究直接用DEL会造成长时间阻塞要用UNLINK异步删除redis-cli unlink sp:user_detail:12345UNLINK在Redis 4.0之后可用它会主线程只做指针摘除实际内存释放放到后台线程完成不会阻塞线上请求。4.4 过期键清理机制导致回收延迟Redis清理过期键不是实时的而是靠两条腿走路惰性删除也就是访问时发现过期才删除定期删除每100ms随机抽样一些键如果过期键占比高就继续扫。如果过期键数量特别庞大比如一大批发往同一个时间点写入并设置了同一秒过期的键定期删除来不及处理内存就会在过期后的一段时间内继续维持高位。从监控指标里能看到证据INFO stats里的expired_keys增速突然增加但used_memory下降缓慢。这种情况可以把过期时间加一点随机扰动比如expire 300 random(0, 60)秒避免“雪崩式过期”和“雪崩式堆积”。同时在Redis 4.0以上开启lazy freeconfig set lazyfree-lazy-expire yes config set lazyfree-lazy-eviction yes过期键删除就能从主线程挪到异步线程删除大对象时的阻塞和内存波动会明显减轻。4.5 AOF重写和主从全量同步时的临时双倍内存如果内存增长总发生在某个固定时间段比如凌晨3点到4点而业务此时写入量又不大那就要重点看定时任务有没有触发BGREWRITEAOF有没有新建从节点。AOF重写期间Redis会把当前内存中的数据快照重写为新的AOF文件过程中父进程要持续分配内存给页面复制和新缓冲区。主从全量同步也一样。如果你的主节点内存本来就用了70%以上再来一次fork和COW内存直接翻倍最终OOM被系统Kill。处理这类问题的核心是错峰和限流场景建议AOF重写设置自动触发阈值避免频繁重写低峰期手动执行主从同步新从节点先做RDB备份恢复再挂到主节点全量RDB生成保证maxmemory不超过物理内存50%~60%复制积压缓冲区按写入量调整repl-backlog-size不要盲目加大4.6 分配器碎片长期膨胀RSS居高不下前几种处理完还会遇到一种“明明键已经删了但used_memory_rss就是降不下来”的情况。这就是jemalloc碎片。删掉的键释放了很多内存但这些内存位于不同的arena和bin里无法被新的写入复用Redis整体占用的物理内存就下不去。碎片整理可以参考前面提到的activedefrag参数也可以直接用命令行手动触发。还有一种实用技巧是在业务低峰期通过主从切换来释放碎片比如给从节点升主、原主节点重新作为从节点挂载重启后分配器重新初始化RSS会回到一个干净的水平。当然这是运维动作必须有完善的切换预案再操作。5. 修复效果怎么验证以及后续怎么防5.1 修复后盯着四个指标每次处理完内存问题我都要求团队跟踪至少一周核心看四个指标指标变化方向判断标准used_memory应该停止爬坡缓慢下降或稳定一周内的日均增长率不超过5%keyspace中的键总数不再持续上涨排除正常业务增长后保持平稳expired_keys/evicted_keys有节奏增长说明过期和淘汰机制在正常工作mem_fragmentation_ratio稳定在1.5以下长期高于1.5继续观察高于2继续处理修完不是立刻判死刑至少观察72小时。很多问题代码是周期性任务触发的比如周末跑批、月底结算不多看几天容易误判。5.2 把maxmemory和淘汰策略调好无论怎么防总会有漏网之鱼所以必须给Redis加最后一道保险maxmemory和淘汰策略。不要把maxmemory设成0那是无限内存。建议在容器内存的50%到70%之间取值。以物理机32GB为例如果只有Redis一个主力进程maxmemory可以设24GB如果同一台机器还跑其他应用建议16GB到20GB给fork和COW留出空间。淘汰策略根据业务特性选择策略适用场景缺点allkeys-lru通用缓存所有key都可被淘汰可能淘汰到非缓存业务键volatile-lru只有设置了过期时间的键才参与淘汰无TTL键无法淘汰allkeys-random数据访问无明显热点淘汰效率低volatile-ttl优先淘汰剩余TTL短的键依赖过期时间设置合理性noeviction不可丢弃数据的场景内存满时写命令直接报错我的建议是绝大多数缓存场景选allkeys-lru因为它的淘汰粒度最粗但效果最直接。如果Redis里同时存在不能丢的核心数据和可丢的缓存数据那就用volatile-lru并且强制所有缓存key都要带过期时间。5.3 缓存治理的三个底层习惯想长期不踩坑光靠排查不够要从代码习惯上卡住第一所有缓存代码必须讲清楚“失效策略”。接口入参、过期时间、大key上限要写在设计文档里不要“先写上去后面再优化”。第二给key建立前缀规范。比如业务模块:业务类型:ID这样排查时只用一条命令就能筛出某个模块的所有键。第三建立大小Key治理清单。定期用--bigkeys分析实例超过预设阈值的大key要自动告警到钉钉/企业微信。这三个习惯看起来简单但能做到的团队真的不多。我见过太多公司在内存爆掉之后才开始补规范之前每天都在疯狂挖坑。5.4 监控告警和应急兜底最后是监控。我不推荐只买云厂商的默认监控就完事有条件上redis_exporter Prometheus Grafana这套组合自己定义告警项。除了基础的内存使用率额外建议盯这几个used_memory日环比涨幅超过20%时告警mem_fragmentation_ratio大于2持续30分钟时告警keyspace键数量日环比增长超过40%时告警expired_keys突然降到0说明过期键清理机制可能失活。应急兜底也要提前准备。内存打满时不要慌着重启先检查maxmemory和淘汰策略。如果是大key导致的用UNLINK异步删除如果是临时流量高峰直接扩容。重启必须放在最后因为重启会清掉所有缓存数据很可能把压力直接打到数据库上造成二次故障。我在实际排查里最深的体会是Redis内存问题大多不是Redis本身的问题而是从代码到运维一整条链路的问题。泄漏点永远藏在那些“先临时用一下”、“以后会清理”、“数据量应该不大”的乐观假设里。把边界定义清楚把监控和排查流程固化下来内存问题就会从“午夜惊魂”变成一个按流程处理的普通故障。
返回列表