我在线上遇到过一次特别有代表性的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,不是10GB,Redis配置解析器对单位大小写不敏感,但用全小写最保险。运行时改的话用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到显式修改淘汰策略之前,只要内存达到maxmemory,Redis就会拒绝所有新增数据的写命令。
这种默认设计其实很“安全”,它不会偷偷删你的数据。但对于绝大多数缓存场景,这就是灾难:你存进去的旧数据明明可以淘汰,Redis却选择把新写入全部挡住,业务一时半会还找不出原因。
所以我的建议是:只要Redis被当作缓存用,就一定要显式设置maxmemory-policy,不要用默认值。具体选哪个,看下面几种策略的对比。
2.2 8种淘汰策略对比,选型不再纠结
从Redis 4.0开始,官方支持的淘汰策略一共有8种:
| 策略 | 淘汰范围 | 算法 | 适用场景 |
|---|---|---|---|
noeviction | 不淘汰 | 无 | 严格不能丢数据的场景,内存满直接报错 |
allkeys-lru | 所有key | 近似LRU | 缓存数据冷热分明,能接受淘汰任意key |
volatile-lru | 设置了TTL的key | 近似LRU | 只希望淘汰可过期的缓存,持久化key不被动 |
allkeys-random | 所有key | 随机 | 访问非常均匀,无热点,淘汰谁都无所谓 |
volatile-random | 设置了TTL的key | 随机 | 全部key都有过期时间,随机淘汰可接受 |
volatile-ttl | 设置了TTL的key | 剩余存活时间最短优先 | 优先淘汰快要过期的key |
allkeys-lfu | 所有key | LFU | 热点差异明显,需要识别真正高频访问的数据 |
volatile-lfu | 设置了TTL的key | LFU | 只想在可过期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_memory | Redis分配器实际分配的内存 | 和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 USAGE:
redis-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-lru:
redis-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扛住了,操作系统可能先挂。这里给一套比较实用的估算方法:
假设业务预期缓存数据量为D,Redis自身管理内存会有哈希表开销、过期字典、客户端输出缓冲、复制积压缓冲等额外占用,大概占数据量的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.8KB | 480MB |
| Jackson JSON | 2.1KB | 210MB |
| JSON + Snappy压缩 | 780B | 78MB |
同样的数据量,光是换序列化方案,内存就能降低一半以上。如果是新系统,更推荐Protobuf这类二进制序列化方案,体积小、解析快,但改造成本也会高一些。
还有一个容易踩的坑是String类型存大对象。Redis的String是二进制安全,但它底层是SDS,存储大JSON时除了数据结构本身没有太多额外功能。如果你要存一个业务对象并且会频繁改字段,建议用Hash分散存储,至少不会因为一次GET大value把带宽和线程拖死。
5. 容器化部署:Docker里Redis内存限制要特别小心
5.1 Docker容器的内存限额和maxmemory是两回事
现在很多团队用Docker部署Redis,docker-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设成4GB,maxmemory也设成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_exporter+Prometheus+Grafana。redis_exporter会采集INFO memory里的指标,Prometheus负责存储和计算告警规则,Grafana做展示。我自己的经验是,至少配置三个告警阈值:
used_memory / maxmemory大于80%,警告,通知值班同学关注趋势;used_memory / maxmemory大于90%,紧急,说明随时可能OOM;evicted_keys增长速率异常,说明淘汰策略开始生效,可能有人在大量淘汰数据,需要排查原因。
日志方面,容器部署的直接docker logs redis-cache,物理机部署一般看/var/log/redis/redis-server.log。Redis的OOM错误本身不会直接写进日志,它是以客户端报错形式返回的,所以监控客户端异常率也很重要。
6. 典型故障复盘:三次不同原因的OOM排查记录
6.1 案例一:缓存全没TTL,volatile策略形同虚设
有一次线上告警,Redis实例写入大面积失败,报错正是OOM command not allowed。配置文件里明明写着maxmemory-policy volatile-lru,按道理内存满应该触发淘汰,怎么还会OOM?
排查过程走了不少弯路。先看INFO memory,used_memory已经和maxmemory齐平,但expires字段统计的“带过期时间的key数量”只有几百个,而dbsize显示的key总数是十几万。
问题一下就清楚了:业务代码里绝大部分缓存key都没设置TTL,volatile-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%,收到告警之后慢慢排查,远好过被用户报障逼着救火。