
我在线上遇到过一次特别有代表性的Redis故障业务量一上来客户端就开始成片地抛异常核心报错就一句话——OOM command not allowed when used memory maxmemory。新同学第一次看到这行英文基本都是懵的Redis不是号称高性能缓存吗怎么还会OOM实际上这不是操作系统把Redis进程杀了而是Redis自己触发了内存保护当已用内存超过配置的maxmemory上限同时内存淘汰策略又不允许自动清理旧数据时新的写命令就会被直接拒绝。换句话说Redis还活着但已经“写不进去了”。这篇文章就从这个报错出发把maxmemory、内存淘汰策略、大Key排查、业务侧治理、容器化部署这些内容完整串一遍。适合正在排查线上Redis内存告警的后端和运维同学也适合刚接手一个“缓存总是被塞满”的旧系统、想彻底搞清楚内存管理机制的开发者。我会尽量按排查现场来还原思路把能直接抄作业的命令和参数都放出来。1. 先搞懂这条报错Redis到底在拒绝什么1.1 OOM不等于进程崩溃首先要区分两个OOM。操作系统的OOM Killer是物理内存彻底耗尽后由内核挑进程杀掉而Redis的OOM是逻辑层面的“软限制”。Redis在启动时会读取maxmemory配置之后每个命令进来主线程都会先算一笔账当前used_memory有没有超过maxmemory。如果超了再检查当前maxmemory-policy是什么策略。如果策略是noeviction也就是“一个key都不淘汰”那Redis就只能对写命令说“不”。所以你会看到典型现象服务不宕机、INFO还能查、读请求也正常但只要涉及写入比如SET、LPUSH、HSET、SADD客户端就收到这段OOM错误。这是Redis牺牲写入能力、保住核心进程存活的一种自我保护设计。1.2 maxmemory一个经常被忽略的全局阀门maxmemory默认值是多少在64位系统上默认是0意思是不限制。很多团队一开始部署Redis时根本没管这个参数数据量小还好一旦业务增长、key没有过期时间内存就会一路涨到系统物理内存耗尽最后被操作系统OOM Killer直接干掉。生产环境一般都会显式设置maxmemory。设置成多大不能拍脑袋需要结合机器物理内存、同机有没有部署其他进程、是否开启了RDB或AOF持久化来综合判断。举个例子一台16GB内存的机器假设Redis独占且开了AOF我一般会把maxmemory设置在10GB到12GB之间至少留出25%~30%的内存给操作系统page cache、fork子进程和连接缓冲。不建议把maxmemory直接设成物理内存大小比如16GB因为一旦AOF重写或RDB快照fork子进程内存瞬时翻倍系统直接卡死。maxmemory的配置格式也容易写错# 可以直接写字节数 maxmemory 10737418240 # 也可以用这种更可读的格式 maxmemory 10gb注意是10gb不是10GBRedis配置解析器对单位大小写不敏感但用全小写最保险。运行时改的话用CONFIG SET maxmemory 10gb不过只对当前实例生效想持久化还要CONFIG REWRITE。1.3 为什么只有写入命令被拒很多人会问内存满了为什么GET还能用因为读命令不会让内存继续增长放行读请求是合理的。而SET这类命令必然带来新数据在noeviction策略下没有任何key可以被清理Redis只能拒绝执行防止内存彻底失控。这里有个容易被忽略的细节想快速“救急”的时候删除类操作是可以执行的。比如用DEL或UNLINK删掉一些大Key内存被释放OOM状态会立刻解除。所以遇到全站写失败不要傻等先用删除操作腾出内存空间再考虑后续策略调整。2. maxmemory-policy为什么你遇到的偏偏是这条错误2.1 默认的noeviction就是OOM的直接来源maxmemory-policy的默认值就是noeviction。也就是说从你第一次启动Redis到显式修改淘汰策略之前只要内存达到maxmemoryRedis就会拒绝所有新增数据的写命令。这种默认设计其实很“安全”它不会偷偷删你的数据。但对于绝大多数缓存场景这就是灾难你存进去的旧数据明明可以淘汰Redis却选择把新写入全部挡住业务一时半会还找不出原因。所以我的建议是只要Redis被当作缓存用就一定要显式设置maxmemory-policy不要用默认值。具体选哪个看下面几种策略的对比。2.2 8种淘汰策略对比选型不再纠结从Redis 4.0开始官方支持的淘汰策略一共有8种策略淘汰范围算法适用场景noeviction不淘汰无严格不能丢数据的场景内存满直接报错allkeys-lru所有key近似LRU缓存数据冷热分明能接受淘汰任意keyvolatile-lru设置了TTL的key近似LRU只希望淘汰可过期的缓存持久化key不被动allkeys-random所有key随机访问非常均匀无热点淘汰谁都无所谓volatile-random设置了TTL的key随机全部key都有过期时间随机淘汰可接受volatile-ttl设置了TTL的key剩余存活时间最短优先优先淘汰快要过期的keyallkeys-lfu所有keyLFU热点差异明显需要识别真正高频访问的数据volatile-lfu设置了TTL的keyLFU只想在可过期key里做LFU淘汰这里我最想强调的是volatile-*系列的一个大坑它只会淘汰那些设置了过期时间的key。假如你的业务代码里大量key都没有设置TTL那volatile-lru实际上就退化成noeviction内存满了照样报OOM。我见过太多线上案例配置文件里明确写着maxmemory-policy volatile-lru结果内存还在持续上涨最后排查发现业务代码里90%的缓存key都没设过期时间。策略没生效相当于形同虚设。所以选volatile-*策略之前先想清楚你的key是否都有TTL。2.3 从LRU到LFU淘汰算法背后的细节决策Redis的LRU并不是严格意义上的LRU而是“近似LRU”。它不会给每个key维护精确的访问时间而是用采样方式去猜测哪些key最久没被访问。核心参数是maxmemory-samples默认值是5。如果把maxmemory-samples调大到10淘汰时挑选出的key就更接近真实LRU结果淘汰精度更高但CPU消耗会略微增加。反过来调小比如3能省一点CPU但淘汰结果会更“随机”可能误伤热数据。生产环境我一般保持10这个性价比最平衡。Redis 4.0引入了LFU全称Least Frequently Used关注的是访问频率不是访问时间。LFU适合那种数据访问频率差异特别明显的业务比如某个爆款资源被大量读取其他资源无人问津。LRU在这种情况下可能因为“偶尔扫一次”而保留冷数据LFU可以更精准地保留高频热数据。LFU有两个参数lfu-log-factor控制频率增长的速率lfu-decay-time控制访问频率的衰减时间。lfu-decay-time默认是1意思是每分钟衰减一次。如果业务是周期性爆发比如每天某个时段才有大流量要适当调大这个值否则热点数据可能在低峰期被淘汰。在选择到底是LRU还是LFU时我的经验是大部分业务用allkeys-lru就够用了除非你有比较明确的热点访问模型否则不要为了“更高级”去盲目上LFU。还有一个跟淘汰直接相关的参数lazyfree-lazy-eviction yes。默认情况下Redis淘汰key是在主线程里同步执行的。如果你淘汰的是一个几百MB的大Key主线程会被卡住业务上表现就是Redis的耗时突然从1ms变成几秒。开启这个参数后淘汰操作会放到后台异步线程执行主线程不会被阻塞。Redis 4.0之后支持UNLINK命令也是异步删除操作大Key时优先用它。3. 快速定位内存杀手一条命令把老底翻出来3.1 INFO memory每个字段都是破案线索遇到OOM告警第一步永远是看内存的整体构成。执行redis-cli -p 6379 INFO memory重点看这几个字段字段含义排查价值used_memoryRedis分配器实际分配的内存和maxmemory直接对比判断是否触顶used_memory_rss进程在操作系统里占用的物理内存比used_memory大很多说明碎片多used_memory_peak历史最高内存占用判断当前是不是“历史新高”used_memory_overhead数据以外额外占用的内存包含缓冲区、字典、复制积压等mem_fragmentation_ratio碎片率 RSS / used_memory大于1.5说明碎片严重可能需要清理碎片maxmemory配置的内存上限确认配置是否生效maxmemory_policy当前淘汰策略确认策略是否符合预期我一般先看used_memory是不是已经顶到maxmemory再看mem_fragmentation_ratio是否异常。碎片率长期高于1.5可以考虑开启activedefrag yes让Redis在后台整理内存碎片。不过这个功能本身也消耗CPU不是所有版本默认开启需要观察一段时间。3.2 MEMORY DOCTOR和MEMORY USAGE单点体检工具INFO memory看完整体下一步是定位具体对象。MEMORY DOCTOR可以理解成Redis自带的“体检报告”它会基于当前内存情况给出一段分析建议。虽然建议比较泛但作为最开始的问题诊断入口很方便redis-cli -p 6379 MEMORY DOCTOR想知道某个具体key占了多少内存用MEMORY USAGEredis-cli -p 6379 MEMORY USAGE user:profile:10001它会返回这个key占用的大致字节数。排查OOM时可以用它去验证怀疑对象比如觉得某个缓存value很大一查可能发现一个key就占了5MB那问题就清晰了。MEMORY STATS还能看更细的分配情况包括overhead.hashtable.main、overhead.hashtable.expires这类明细有兴趣可以深入看日常排查主用前三个命令就够了。3.3 大Key扫描别用KEYS用官方工具线上定位大Key强烈不建议用KEYS *它会在主线程遍历全量key直接卡住Redis。正确做法是用SCAN更简单的是直接用Redis自带的--bigkeys参数redis-cli -p 6379 --bigkeys--bigkeys内部用的是SCAN游标遍历不会一次性阻塞那么久但它会统计每种数据类型里最大的key最后汇总成一个报告。执行时CPU会有一些波动建议在业务低峰期跑。命令执行后会看到类似这样的输出-------- largest 5 strings found -------- 1) 100 KB user:profile:102938 2) 90 KB user:profile:102934 ... -------- largest 5 lists found -------- 1) 1200000 items msg:queue:order看到几百万条元素的List或者超大String基本就能锁定内存增长源了。除了--bigkeys如果你用的Redis版本支持也可以试试--memkeys不过主流版本里--bigkeys已经足够。4. 现场止血与长期治理maxmemory调教与业务侧改造4.1 临时止血三步恢复写入线上已经出现OOM报错了最要紧的不是马上改造业务代码而是先恢复可用性。第一步快速判断当前数据的可丢性。如果这个Redis实例存的是纯缓存可以从别处重建那直接把淘汰策略改成allkeys-lruredis-cli -p 6379 CONFIG SET maxmemory-policy allkeys-lruCONFIG SET是即时生效的不需要重启内存不够时Redis会立刻按LRU淘汰一部分key写命令马上恢复正常。注意这只是一个应急操作。如果业务数据不能丢千万不要随意开启淘汰策略否则你会“成功”地丢失一批重要数据。第二步如果不想开启淘汰那就手动清空间。先用--bigkeys找出大Key再用UNLINK删除redis-cli -p 6379 UNLINK msg:queue:orderUNLINK是异步删除不会阻塞主线程。老版本Redis没有UNLINK只能用DEL但删大Key会卡所以生产环境尽量把Redis升级到4.0以上。第三步所有CONFIG SET的修改都要记得执行CONFIG REWRITE把配置持久化到配置文件里。不然下一次重启配置又回到老样子问题复现。4.2 容量规划maxmemory设多大才合理止血之后要思考容量规划。如果maxmemory设置得太小数据稍微一涨又OOM设置得太大Redis扛住了操作系统可能先挂。这里给一套比较实用的估算方法假设业务预期缓存数据量为DRedis自身管理内存会有哈希表开销、过期字典、客户端输出缓冲、复制积压缓冲等额外占用大概占数据量的10%~30%。如果开启了AOF重写或RDB快照fork子进程还会因为copy-on-write机制额外占用内存这部分可能达到数据量的20%~30%甚至更高。那么一个相对安全的公式是maxmemory ≈ 预期数据量D / (1 - 30%)也就是预留30%的内存给额外开销。举例你预估缓存最多放5GB数据那maxmemory不要设置成5GB建议设置成7GB左右物理机内存至少要12GB以上才能给系统留出充足余量。如果物理机只有8GB那maxmemory最多设置5GB~6GB同时要严格控制业务数据量。used_memory_peak也是一个很好的参考指标。如果历史峰值是6GB而当前maxmemory只有4GB说明容量明显不够要么扩容要么优化数据。4.3 业务侧改造TTL和序列化是两座大山内存问题只靠调Redis参数永远治标不治本。真正决定内存增长上限的是业务代码怎么设置key的生命周期、怎么序列化数据。先说TTL。很多缓存的key根本没有设置过期时间相当于永久驻留。这在一开始没什么感觉但日积月累数据量会持续增长。业务上应该明确缓存类key必须设置TTL具体时长看数据容忍度比如用户会话可以15分钟商品详情可以30分钟活动配置可以1小时。代码里常见的问题是// 错误示范set之后忘了expire redisTemplate.opsForValue().set(user: userId, userJson);// 正确示范带过期时间 redisTemplate.opsForValue().set(user: userId, userJson, 30, TimeUnit.MINUTES);再说序列化。JDK原生的Java序列化会把类全限定名、对象头、字段元信息全部写进去膨胀率特别高。一个原本2KB的Java对象JDK序列化后可能变成5KB甚至更大。同一份数据改用JSON序列化能降到1.5KB再用压缩能压到500字节。我之前测过一个用户画像缓存三种序列化方案的内存占用差异非常明显序列化方式单个value大小10万个key占用JDK原生序列化4.8KB480MBJackson JSON2.1KB210MBJSON Snappy压缩780B78MB同样的数据量光是换序列化方案内存就能降低一半以上。如果是新系统更推荐Protobuf这类二进制序列化方案体积小、解析快但改造成本也会高一些。还有一个容易踩的坑是String类型存大对象。Redis的String是二进制安全但它底层是SDS存储大JSON时除了数据结构本身没有太多额外功能。如果你要存一个业务对象并且会频繁改字段建议用Hash分散存储至少不会因为一次GET大value把带宽和线程拖死。5. 容器化部署Docker里Redis内存限制要特别小心5.1 Docker容器的内存限额和maxmemory是两回事现在很多团队用Docker部署Redisdocker-compose里经常看到这样的配置services: redis: image: redis:7 container_name: redis-cache command: redis-server /usr/local/etc/redis/redis.conf mem_limit: 2g ports: - 6379:6379 volumes: - ./redis.conf:/usr/local/etc/redis/redis.conf restart: unless-stopped这里有个关键点mem_limit: 2g是容器能使用的物理内存上限maxmemory是Redis自身能使用多少内存来存数据。两者必须留出差值绝对不能相等。我见过有人把mem_limit设成4GBmaxmemory也设成4GB运行一段时间后Redis触发AOF重写子进程fork时复制页表内存瞬间超过容器限制整个容器被OOM Killer杀掉。Docker不会因为你配了maxmemory就保护你不被cgroup杀掉它只认实际物理内存消耗。所以容器环境下maxmemory一定要小于容器的mem_limit。如果容器限制2GB我建议maxmemory设置在1.5GB左右给fork子进程和缓冲区留出500MB。对应的redis.conf可以这样写maxmemory 1.5gb maxmemory-policy allkeys-lru appendonly yes lazyfree-lazy-eviction yes另外容器里通常看不到物理机的完整内存free -h显示的可能是宿主机内存。所以排查时不要只看容器内的数值要结合docker stats观察真实占用。5.2 主从复制场景下从节点的内存陷阱热词里出现了“docker安装redis主从”“redis主从”说明很多人都在容器里搭主从。主从复制有个默认参数replica-ignore-maxmemory yes意思是当从节点内存超过maxmemory时它不会主动淘汰数据因为数据应该由主节点通过DEL命令同步过来。这本来是合理的但会带来一个隐患从节点不会主动淘汰只被动接受主节点的写命令。如果主从节点的物理内存规格不一致比如主节点是8GB内存、maxmemory设6GB从节点只有4GB内存那主节点写到6GB时从节点可能已经物理内存耗尽了容器被OOM Killer干掉。所以在主从架构下主从节点的内存规格、maxmemory配置必须保持一致。尤其是用Docker部署时每个容器的mem_limit都要对齐。还有一个主从场景容易被忽略主节点因为网络分区短暂断开复制积压缓冲区repl-backlog-size默认1MB会累积数据。如果大量写命令堆积积压缓冲区也可能占内存。连接断开时间越长积压越多内存上涨越明显。遇到这种问题可以适当调小repl-backlog-size并且尽快恢复主从网络。5.3 监控告警比调参更重要无论是容器还是物理机Redis内存问题最有效的防治手段是监控告警。等到业务报障才发现OOM说明监控链路是缺失的。比较常规的一套方案是redis_exporterPrometheusGrafana。redis_exporter会采集INFO memory里的指标Prometheus负责存储和计算告警规则Grafana做展示。我自己的经验是至少配置三个告警阈值used_memory / maxmemory大于80%警告通知值班同学关注趋势used_memory / maxmemory大于90%紧急说明随时可能OOMevicted_keys增长速率异常说明淘汰策略开始生效可能有人在大量淘汰数据需要排查原因。日志方面容器部署的直接docker logs redis-cache物理机部署一般看/var/log/redis/redis-server.log。Redis的OOM错误本身不会直接写进日志它是以客户端报错形式返回的所以监控客户端异常率也很重要。6. 典型故障复盘三次不同原因的OOM排查记录6.1 案例一缓存全没TTLvolatile策略形同虚设有一次线上告警Redis实例写入大面积失败报错正是OOM command not allowed。配置文件里明明写着maxmemory-policy volatile-lru按道理内存满应该触发淘汰怎么还会OOM排查过程走了不少弯路。先看INFO memoryused_memory已经和maxmemory齐平但expires字段统计的“带过期时间的key数量”只有几百个而dbsize显示的key总数是十几万。问题一下就清楚了业务代码里绝大部分缓存key都没设置TTLvolatile-lru只能淘汰那几百个带TTL的key淘汰完之后没有新的key可淘汰Redis就按noeviction的逻辑拒绝写入。这个过程验证了前面提到的坑volatile-*策略的生效前提是key有TTL。最后的修复方案是先临时把策略改成allkeys-lru恢复写入然后用脚本把所有没有TTL且允许丢弃的缓存key批量设置过期时间。后续把maxmemory-policy改回volatile-lru并推动业务侧统一封装缓存工具强制要求传入过期时间。6.2 案例二分布式锁key堆积锁不释放另一个案例更隐蔽。某订单系统的Redis内存持续上涨但--bigkeys扫描出来的大Key并不明显总体积也不大说明是海量小key堆积。看了INFO keyspace发现有个固定前缀的key数量异常多lock:order:*。再结合代码排查发现老项目用的是SETNX加锁拿到锁之后再EXPIRE设过期时间Boolean locked redisTemplate.opsForValue().setIfAbsent(lock:order: orderId, 1); if (locked) { redisTemplate.expire(lock:order: orderId, 30, TimeUnit.SECONDS); // 业务逻辑 }这个写法看起来没问题但如果业务逻辑执行过程中抛了异常或者进程在SETNX成功之后、EXPIRE执行之前突然宕机锁key就永远没有过期时间一直残留在Redis里。订单量一大这类残留key累积起来内存自然被吃光。这种问题最好的解法是放弃这种组合命令直接用一条带过期时间的原子命令Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:order: orderId, 1, 30, TimeUnit.SECONDS);或者直接用Redisson它的看门狗机制会自动续期也能避免key永久残留。清理已经产生的垃圾锁key则可以用SCAN匹配lock:order:*前缀逐个删除。6.3 案例三List bigkey无脑增长消费者跟不上第三个案例是典型的List大Key增长。业务把Redis List当消息队列用生产者持续LPUSH消费者用RPOP消费。原本一切正常但消费者服务发版后一直报错重启消费速度骤降List里的消息只进不出。等到发现的时候List已经有几百万条元素key占用了几百MB内存。因为在noeviction策略下内存撞到上限连LPUSH都被拒绝了生产者开始大量报错。这种问题的处理思路是先停掉部分生产者流量然后用UNLINK把整个List key删掉或者用LTRIM只保留最近一段数据# 只保留最近10万条消息 LTRIM msg:queue:order 0 99999业务侧还要增加消费堆积告警监控LLEN的长度超过阈值就要人工介入。更长远来看Redis List做消息队列不如专业的Stream类型它支持消费者组和消息确认对消费堆积情况也更好控制。6.4 OOM现场排查速查表最后整理一个可以直接抄的排查顺序碰到OOM command not allowed的时候按这个来执行INFO memory确认used_memory和maxmemory是否已经齐平执行CONFIG GET maxmemory-policy看当前淘汰策略如果策略是noeviction考虑是否允许临时切换成allkeys-lru恢复写入执行redis-cli --bigkeys找大Key和异常增长的key集合用MEMORY USAGE验证怀疑目标用UNLINK删除大Key或垃圾key释放内存检查业务代码里的TTL、序列化、锁key、队列消费逻辑CONFIG REWRITE持久化修改回顾监控告警阈值确保下次在OOM之前就有通知。这套流程跑下来大部分Redis内存问题都能定位到具体原因。我个人在实际操作中的体会是allkeys-lru确实能快速恢复写入但它本质上是拿业务数据换可用性。如果你没有告警、没有TTL治理、没有容量评估OOM只是被延迟了不是被解决了。Redis内存管理真正要打磨的地方是把“key生命周期”这件事管起来让数据在写进去的那一刻就已经想好什么时候淘汰、什么时候清理。最后再分享一个小技巧日常就把used_memory / maxmemory的告警阈值配到80%收到告警之后慢慢排查远好过被用户报障逼着救火。