下午两点多,群里突然有人喊“线上Redis报错了”,紧接着运维截图贴上来一串醒目的英文:OOM command not allowed when used memory > 'maxmemory'。这种报错我见过太多次了,原因其实就一句话:Redis的内存占用量达到了maxmemory配置的上限,而当时设置的淘汰策略又没法把内存降下来,于是所有写命令直接被拒绝执行。
这篇文章就把这个经典问题完整梳理一遍:maxmemory到底怎么算、内存被谁吃掉了、有哪些治本和治标的手段、以及我在实际救火过程中的完整排查路径。不管是刚接触Redis的新手,还是已经被线上告警折磨过的老运维,都能从里面找到能直接上手的方案。
1. 先搞懂 maxmemory 到底是什么
1.1 maxmemory 和“Redis 能用多少内存”不是一回事
maxmemory是Redis配置里的一个硬性上限参数,单位可以是gb、mb、kb,也可以直接写字节数。当used_memory达到这个值之后,Redis会根据maxmemory-policy配置的策略来决定下一步动作:要么从现有key中挑选一些淘汰掉,要么直接拒绝写入命令并返回OOM错误。
很多人的第一个误区就在这里:以为设置了maxmemory,Redis就只会占用这么多内存。实际上不是。maxmemory只是Redis内部数据内存的上限,它管不到内存碎片、主从复制积压缓冲区、客户端输出缓冲区、AOF重写缓冲区这些额外开销。换句话说,你给Redis设了8GB的maxmemory,但它在操作系统里看到的常驻内存(RSS)可能已经到了10GB甚至更多,这两者的差距就是上面说的那些额外部分在“吃”内存。
用生活里的例子来理解:maxmemory相当于宿舍的限电功率,你设了1000W,但不代表整个宿舍就只有1000W在跑。空调、饮水机这些“额外负载”并不计入限电额度内,可一旦总功率过载,照样会跳闸。
1.2 内存为什么必须设上限
有些刚接触Redis的同学会问:既然maxmemory会导致写入失败,那我把它设成0或者干脆不设,行不行?
先澄清一个概念:maxmemory 0在64位系统上表示不限制,这也是默认状态。不限制意味着Redis可以一直吃内存,直到操作系统的物理内存被耗尽。这时候问题就不只是Redis自己报错了,而是操作系统OOM Killer会跳出来随机挑进程杀掉。更可怕的是,如果内存耗尽导致系统疯狂使用swap,整个服务器的响应速度都会断崖式下跌,其他业务全部被拖下水。
所以设maxmemory本质上是在“主动控制失效方式”:与其被操作系统强行杀掉,不如让Redis自己定义一个优雅的失败边界——要么淘汰不重要的数据,要么明确告诉业务方“我装不下了”。这一点对生产环境来说非常关键。
另外还要知道Redis的检查时机:它不是在后台线程自动检查的,而是在每次执行写命令之前同步检查。所以内存并不是“瞬间打满”的时候才报错,而是写入方每次尝试写入时发现“已经满了”,就会拒绝。这也解释了为什么OOM报错往往在业务高峰时段集中爆发——因为那个时间段写入请求最密集。
1.3 怎么正确估算 maxmemory 的值
估算实际上分两个层面:一个是Redis内部数据的真实占用,另一个是整个系统层面的内存预算。
先说Redis内部。执行info memory会看到一个关键字段used_memory,它表示Redis分配器实际分配出去的内存总量,包括数据本身、key、过期字典、命令缓冲等。这个值才是和maxmemory比较的那个指标。
再看系统层面。假设一台服务器有32GB物理内存,上面只跑一个Redis,第一版配置往往有人直接写maxmemory 30gb,实际上这非常危险。操作系统本身、后台进程、页面缓存都需要内存,再加上Redis还有RSS和used_memory之间的碎片差距,设置成30GB很可能导致整机内存紧张,反而触发swap。
我一般建议的做法是:
- 先预留操作系统和基础运维组件的内存,通常至少4GB,如果还跑监控Agent、日志采集,再往上加;
- 观察线上稳定时的
mem_fragmentation_ratio,如果长期在1.3以上,说明碎片开销偏高,要给maxmemory留出更多缓冲; - 最终maxmemory建议控制在物理内存的70%到80%之间,比如32GB内存的机器,Redis的maxmemory先设20GB比较稳妥,而不是贴着物理内存上限去卡。
配置修改有两种方式:写入redis.conf里的maxmemory 20gb,或者在Redis命令行执行CONFIG SET maxmemory 20gb。前者重启后生效,后者即时生效、但重启后会丢失。稳妥做法是两者都做:先用CONFIG SET动态调整,再把配置写回文件。
提示:设置maxmemory之前,一定要先想清楚maxmemory-policy选什么。只设上限不选策略,等于默认noeviction,内存满了不仅不淘汰任何数据,还会直接拒写。
2. 排查:内存到底被谁吃掉了
2.1 用 info memory 一眼看穿内存构成
遇到maxmemory打满,第一步永远不是急着删除数据,而是先搞清楚内存结构。redis-cli info memory会输出一串指标,我重点看这几个:
| 字段 | 含义 | 我关注的原因 |
|---|---|---|
| used_memory | Redis分配器实际分配的内存总量 | 和maxmemory直接对比的对象 |
| used_memory_rss | 向操作系统申请的内存 | 比used_memory更能反映真实占用量 |
| used_memory_dataset | 数据实际占用的内存 | 判断纯业务数据占了多少 |
| used_memory_overhead | 数据之外的开销 | 过大说明连接数、meta信息异常 |
| mem_fragmentation_ratio | used_memory_rss / used_memory | 大于1.5说明碎片严重 |
| maxmemory | 当前配置的最高上限 | 确认是不是真的到了阈值 |
| maxmemory_policy | 当前淘汰策略 | 确认报错原因的关键字段 |
举个例子:某个实例used_memory显示8GB,used_memory_rss显示11GB,说明有3GB的内存碎片开销。这时候就算你把maxmemory从8GB调到9GB,表面上看有的缓存空间更多了,但实际数据还是只能放8GB左右,碎片会继续蚕食多余的内存。
碎片过高的处理办法有两个方向:Redis 4.0以上可以开启activedefrag yes让后台线程主动整理碎片,或者干脆在主从切换、业务低峰期重启一次Redis,让内存重新规整。前者适合长期运行、不想中断业务的场景,后者适合碎片率实在高得离谱的极端情况。
2.2 大 key 是头号嫌疑人
在所有导致used_memory暴涨的因素里,大key是出现频率最高的一个。所谓大key,通常指单个key对应的value特别大,比如一个list里塞了几百万个元素、一个hash里挂了上百万个字段、一个string直接存了几十MB。
定位大key可以用两条命令。第一条是redis-cli --bigkeys,它会遍历整个实例并按照string、list、set、zset、hash分类统计出最大的几个key。第二条是MEMORY USAGE key,可以精确查看某个key及它的整个value占用了多少字节。
这里要特别提醒一个坑:redis-cli --bigkeys是全库扫描,本质上就是遍历所有key,线上实例如果数据量大、流量高,执行期间可能引起阻塞。更好的习惯是在业务低峰期跑,或者用--i 0.1参数控制每扫描100个key之后的休眠时间,比如redis-cli --bigkeys --i 0.1,能明显降低对线上服务的影响。
另外,不能只看key本身的大小,还要看并发访问频率。一个value只有几KB、但每秒被访问几万次的key,虽然不太吃内存,却可能拖垮CPU。所以定位问题时要同时看“大”和“热”,Redis 4.0之后有redis-cli --hotkeys命令,但它要求淘汰策略必须是LFU,不然跑不起来。
2.3 容易被忽略的内存吞噬者
内存打满后,很多人把所有注意力都放在业务key上,却忘了一些非常容易忽略的内存开销。
第一个是客户端连接。每一条客户端连接在Redis服务端都有对应的输入缓冲区、输出缓冲区。比如某次线上事故排查时,我通过CLIENT LIST发现实例上有几千个连接,其中不少连接从建立开始就一直挂在那里,输出缓冲区积压了大量数据。Redis默认的client-output-buffer-limit normal 0 0 0表示普通客户端不限制,但如果连接异常,缓冲区越积越大,内存就是这么悄悄吃掉的。
第二个是复制积压缓冲区。如果实例配置了主从复制,那么repl-backlog-size这块内存是必须留出来的,默认1MB,主从网络不稳定的时候会临时增大。这个值虽然不大,但在内存精确卡的场景下也要算进去。
第三个是发布订阅和慢日志相关的临时内存。PUBSUB模式下的订阅者如果消费速度跟不上消息产生速度,Redis就需要暂存消息,积压到一定程度也会推高内存。
排查时我习惯先看一眼info clients里的connected_clients和blocked_clients,再结合info stats里的total_net_input_bytes和total_net_output_bytes判断是否有异常连接在大量拉取数据。很多时候把一堆僵死连接清掉,内存立刻就能降下来一截。
3. 解决:四个方向把内存压回安全线
3.1 方法一:设置匹配业务的淘汰策略 maxmemory-policy
maxmemory-policy是Redis提供的淘汰机制,策略选对了,OOM报错可以在秒级内消除;选错了,可能变成大面积误删数据。我先把八种策略列全,再说怎么选。
| 策略 | 含义 | 适用场景 |
|---|---|---|
| noeviction | 不淘汰任何key,写命令直接报OOM | 存储重要数据,不允许数据丢失 |
| allkeys-lru | 从所有key中按最近最少使用淘汰 | 纯缓存场景,数据可重建 |
| volatile-lru | 只从设置了过期时间的key中按LRU淘汰 | 缓存为主但有少量永久key |
| allkeys-lfu | 从所有key中按访问频率最低淘汰 | 访问频率差异极大的场景 |
| volatile-lfu | 只从设置了过期时间的key中按LFU淘汰 | 带过期时间且频率差异大的数据 |
| allkeys-random | 从所有key中随机淘汰 | 数据访问无规律,淘汰谁影响都不大 |
| volatile-random | 只从设置了过期时间的key中随机淘汰 | 带过期时间且可随机淘汰 |
| volatile-ttl | 优先淘汰剩余存活时间短的key | 时间敏感型缓存数据 |
实际业务中,90%以上的缓存场景选allkeys-lru就够了。它简单、直观、维护成本低,Redis会用一个近似LRU算法淘汰最久没有被访问过的key。如果你对“最近少用”这个定义拿不准,就用LFU的变体,它在Redis 4.0之后才引入,对访问频率的刻画更准确。
最怕的是volatile-lru搭配“所有key都没设置过期时间”。这种情况下,Redis在内存满了以后发现没有任何key是“可淘汰”的,于是默默退化成noeviction,继续报OOM。这个问题我见过太多次了,很多团队以为“设了volatile-lru就高枕无忧”,结果忘了给key加TTL,内存照样被打满,写操作照样被拒绝。
修改策略的命令是:
redis-cli CONFIG SET maxmemory-policy allkeys-lru另外还有两个相关参数可以顺手调一下:
maxmemory-samples:默认5,表示LRU算法采样几个key做比较。采样数越大,淘汰结果越接近精确LRU,但CPU开销也越高。一般不建议超过10。maxmemory-clients:Redis 7.0新增的按客户端限制内存的参数,如果线上客户端连接非常杂,可以按连接维度设置内存上限。
注意:动态修改策略后,要记得
CONFIG REWRITE把配置写回redis.conf,不然下次重启策略就回滚了。
3.2 方法二:给无过期时间的 key 补票
淘汰策略只是“灭火”,真正治本要给数据补上生命周期。我处理过不少实例,里面的key有一大半是没设过期时间的缓存数据,它们永远躺在内存里,直到把maxmemory耗尽。
检查方式很简单:
redis-cli --scan --pattern '*' | xargs -L 100 redis-cli ttl | grep -v '^-1$' | wc -l上面这条命令统计的是有TTL的key数量,反过来就能算出没设TTL的比例。如果比例偏高,就要跟业务方确认:这些key真的需要永久保存,还是当初写代码时忘记加EXPIRE了?
大部分情况都是“忘记加”。比如一些缓存工具类库默认不设置过期时间,业务方调用时也没细看,数据就这么源源不断地堆积了下来。针对这种情况、如果数据语义上允许过期,最简单粗暴的方法是先给它们补一个合理的TTL,让Redis自己慢慢淘汰。
还有一种情况是key确实不能设置过期时间,比如存储了用户的登录态或者订单状态,过期会导致业务逻辑出错。这类永久key要单独拎出来,放到固定的key前缀空间里,然后选择volatile-lru或volatile-ttl策略,确保淘汰只落在“可过期”的数据上,永久数据不会被动到。
3.3 方法三:宁可扩容也别硬扛
当maxmemory打满的根因是“数据量真的增长太快,现有实例已经装不下”时,调整淘汰策略只是缓兵之计,治本方案是扩容。
扩容有两个方向。一个是纵向扩容,也就是调大maxmemory。但这有个前提:服务器物理内存必须跟得上。我见过有人把maxmemory从8GB直接调到12GB,结果实例所在机器总内存只有16GB,系统其他进程直接OOM。扩容之前先看free -h,确认机器剩余内存够不够。
一个是横向扩容,也就是上Redis Cluster集群。Redis Cluster通过分片把数据分散到多个节点上,每个节点承载一部分数据,理论上可以线性扩展容量。注意迁移过程中涉及reshard,对主从复制的带宽和节点CPU都有影响,最好在业务低峰期做。
另外还有一个很多人忽略的方向:调整持久化策略来降低内存峰值。比如RDB快照在BGSAVE时需要fork子进程,而fork瞬间会用掉额外内存(写时复制机制),如果实例的数据量已经很大,fork一次可能导致内存瞬间翻倍。这时候如果业务能接受,可以调低RDB的自动触发频率,或者干脆关闭RDB、只保留AOF。
3.4 方法四:数据结构层面省内存
这是容易被忽视但效果极好的一种方式。Redis在不同数据量下会采用不同的内部编码,合理配置这些编码参数,能省下大量内存。
几个典型参数:
hash-max-ziplist-entries:默认128,表示hash里字段数小于等于128时用ziplist编码hash-max-ziplist-value:默认64,表示hash里单个字段值小于等于64字节时用ziplistlist-max-ziplist-size:quicklist的压缩节点大小set-max-intset-entries:set全是整数且数量不超过512时用intset
这些编码的前提是数据量小、字段数少,当超过阈值时会自动转换为hashtable编码,内存占用会显著升高。如果业务能接受微小的CPU开销去换取内存节省,可以适当调高这些参数。
还有一个常见方案:大量字符串key有相同前缀或相同结构时,考虑改用hash存储。比如原来存user:10001:name、user:10001:age、user:10001:email三个独立string,可以改成user:10001这个hash里的三个字段。这样不仅节省了key本身的元数据开销,还能利用hash的底层编码优势。
大key的拆分也一样。一个包含百万元素的list或set,建议按固定数量拆成多个小key,这样既能避免单key过大导致淘汰和迁移困难,也能降低单次删除时的阻塞风险。
4. 实战:一次线上 maxmemory 满的完整救火过程
4.1 事故现场复盘
之前接手过一套业务系统,Redis用的是单机实例,maxmemory设置为16GB,数据主要是用户维度的缓存和一批消息队列结构。某个星期三下午,业务方反馈首页接口突然大量超时,紧接着监控告警弹出“Redis写命令失败”。
登录Redis执行info memory,关键结果如下:
used_memory: 17179869184 maxmemory: 17179869184 maxmemory_policy: noeviction mem_fragmentation_ratio: 1.12used_memory和maxmemory已经完全持平,说明内存确实打满了。而maxmemory_policy是noeviction,意思是Redis拒绝了所有写命令,只保留读命令能力。这解释了为什么接口会大量超时——写缓存、更新队列的操作全部失败,业务链路上抛出了一堆异常。
4.2 按顺序排查和止血
第一步是止血,因为noeviction策略下业务已经严重受损。我直接动态修改了淘汰策略:
redis-cli CONFIG SET maxmemory-policy allkeys-lru这个操作即时生效,Redis开始按照LRU算法淘汰那些长期没有被访问的key,写命令立刻恢复。这里要说明一下,我选择allkeys-lru而不是volatile-lru,原因是这台实例上的缓存数据以未设置TTL的为主,用volatile策略等于没用。
第二步是找到内存大头。在确认写命令恢复后,我趁业务请求量稍微回落的间隙执行了大key扫描:
redis-cli --bigkeys --i 0.1扫描结果里出现了一个非常扎眼的数据:某个业务名下的list,元素数量超过800万,整体占用内存超过2GB。顺着key前缀找到对应的业务方,确认这是一个历史遗留的消息队列,消息生产方一直往里面写,消费方逻辑出了bug,导致消费速度跟不上,队列无限积压。
第三步是安全删除这个队列。这里强调一下,删除这么大的key,绝对不能直接用DEL,Redis的单线程模型会被阻塞好几秒。正确做法是用异步删除:
redis-cli UNLINK <大key名>UNLINK会在后台释放内存,主线程不会被阻塞。命令执行后used_memory没有立刻下降,这是因为内存释放是异步完成的,过了几十秒再看才发现降了好几个GB。
第四步是恢复策略。内存降下来之后,我从业务角度重新评估:这类缓存数据虽然可以接受淘汰,但淘汰范围最好不要扩大到永久数据。于是确认了所有重要数据都设置了足够的TTL之后,把策略从临时的allkeys-lru调整回更精准的形态,配置成适合业务的组合。
4.3 事后复盘
整个事故从爆发到恢复大约用了四十分钟,其中大部分时间花在定位大key和确认业务归属上。事后我做的第一件事是给这台实例增加了内存监控告警,阈值设在maxmemory的75%和90%两个档位。75%告警意味着“需要关注”,90%告警意味着“必须马上处理”,而不是等到100%才发现问题。
第二件事是跟业务方确认了所有还在增长的队列类key,给它们设置了最大长度限制,并且在消费逻辑里做了流控。第三件事是建立了一个每周执行一次的大key巡检任务,用脚本在低峰期自动跑--bigkeys并输出报告,避免同类问题再次出现。
5. 常见问题与避坑技巧
5.1 高频问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 设置了allkeys-lru还是报OOM | 数据增长太快,Redis淘汰速度跟不上写入速度 | 扩容、减少写入频率、给数据加TTL |
| 设置了volatile-lru仍报OOM | 很多key没有设置过期时间,无可淘汰对象 | 检查TTL覆盖情况,改为allkeys-lru或补TTL |
| 删除大key后内存没立刻降下来 | DEL是异步后台释放 | 等待一段时间再观察,不要重复删除 |
| 动态修改配置后重启失效 | 没有执行CONFIG REWRITE | 手工重写redis.conf或执行CONFIG REWRITE |
| 内存没到maxmemory但写命令报OOM | 开启了maxmemory-clients限制 | 调整maxmemory-clients或客户端连接数 |
| 碎片率长期偏高 | 大量小key频繁增删、过期 | 开启activedefrag,或低峰期重启 |
5.2 避坑清单(真·经验)
先说几个我踩过或者见别人踩过的坑,每一个都值得单独记住。
第一条,不要在线上随手执行FLUSHALL或FLUSHDB来释放内存。这两个命令会清空所有数据,而且老版本在清空过程中会阻塞实例。即使真的要清,也要先确认数据可重建,再考虑用FLUSHDB ASYNC这种异步版本。
第二条,KEYS *命令在生产环境千万不要碰。它会遍历所有key并返回全部结果,实例数据量一大,瞬间阻塞几十秒甚至几分钟一点不夸张。定位问题要用SCAN或者redis-cli --scan,它们可以分批迭代,不会阻塞主线程。
第三条,设置maxmemory时,一定要连带设置maxmemory-policy。默认的noeviction会在内存打满后直接拒写,对缓存型业务来说这几乎等于线上事故。正确的做法是先在测试环境验证策略效果,再到生产环境动态调整。
第四条,删除大key优先用UNLINK,不要用DEL。Redis 4.0以后提供的异步删除接口能避免主线程长时间阻塞,尤其对于list、set、zset这类可能包含百万个元素的key,差异是几十毫秒和几十秒的区别。
第五条,调整maxmemory后要同步检查实例所在机器的物理内存。之前见过一个案例,maxmemory调到20GB,但机器物理内存只有24GB,系统其他进程不到0.5GB可用,操作系统开始频繁swap,Redis响应时间直接从微秒级变成了秒级。maxmemory不是越高越好,它只是数据内存的上限,不代表整机上限。
5.3 有没有办法预测 maxmemory 什么时候会打满
内存打满不是突然发生的,通常有一个增长过程。如果监控里记录了过去几周used_memory的变化曲线,可以做一个简单的线性外推,估算出预计打满的时间点。比如当前used_memory是12GB,maxmemory是16GB,最近一个月平均每天增长150MB,那么大约一个月后就会触顶。
这类估算不需要复杂的算法,在监控系统里配置一个“增长速度”指标就行。当连续几天的内存增量超过预期,就说明业务数据量在变多,要么是自然增长,要么是出现了类似消息积压的异常。提前发现异常增长,能在大故障发生前就介入处理。
另外,info memory里有一个mem_fragmentation_ratio指标,如果它持续上升,说明小key在大量创建和过期,内存碎片逐渐累积。这种情况下即使used_memory没有接近maxmemory,RSS也已经很高了,同样需要关注。最直接的做法是开启activedefrag,让Redis在后台自动整理碎片。
6. 写到最后的一点个人经验
处理过几次maxmemory打满的现场之后,我最大的感悟是:maxmemory并不是一个“错误配置”,而是Redis给业务划定的一条安全底线。它逼迫你在上线前就要想清楚一件事——如果内存满了,到底应该牺牲谁。
很多团队是在内存打满之后才临时把策略调成allkeys-lru,这确实能让服务立刻恢复,但代价可能是某些不该被淘汰的数据被悄无声息地清除掉了,等业务方发现数据缺失时已经晚了一整天。这个风险远比OOM报错本身严重得多,因为OOM是显式的、能被监控发现,而误淘汰是隐形的,事后极难排查。
所以我现在接手任何一个Redis实例,第一件事就是打开redis.conf看maxmemory和maxmemory-policy这两项配置,再执行一次info memory确认碎片率,最后跟业务方确认数据生命周期。这套动作看似简单,却能提前排除掉绝大多数内存相关隐患。希望这篇文章里的排查思路、命令技巧和避坑经验,也能帮你少踩几个坑。