这一篇讲什么
「Redis 内存满了」网上的答案大多是一张淘汰策略表,外加一句「设置 maxmemory 和淘汰策略即可」。真碰上的时候,想知道的往往是:
- 满了以后应用那边报什么错?是所有命令都不能用,还是只有写不了?
- 8 种淘汰策略,到底删掉的是哪些 key?热数据会不会被删?
- 不设
maxmemory会怎样? - 为什么删了一半 key,内存监控上一点没降?
- key 早就过期了,为什么内存还占着?
- 没往 Redis 里多写东西,数据怎么就被淘汰了?
这一篇每个问题都在真机上把内存写满,贴出命令输出、错误原文和 INFO 里的数字。
实测环境:
- CentOS 7.9,Redis 6.2.4 源码编译(gcc 7.3.1,内存分配器
jemalloc-5.1.0)。 - 实验用单独的实例(端口 6380),不开持久化(
save ""、appendonly no),maxmemory按每个实验调。 - Java 端:Spring Boot 4.1.1 + Lettuce 7.5.2。
- 写入的数据都是
SET k:<序号> <100 个 x>,用redis-cli --pipe批量灌。
1. 默认策略下写满:直接拒写
1.1 报什么错
maxmemory 50mb、maxmemory-policy noeviction,灌 100 万条:
yaml
errors: 730087, replies: 1000000
只写进去 269,913 条,其余 730,087 条每一条的回复都是:
bash
OOM command not allowed when used memory > 'maxmemory'.
INFO stats 里 evicted_keys:0 ------ noeviction 不删任何东西,满了就拒。
noeviction 就是默认值 。redis.conf 原文里这两行都是注释掉的:
arduino
# maxmemory <bytes>
# maxmemory-policy noeviction
我这台机器是照一份常见的安装说明装的,配置里只加了 maxmemory 1024000000,没配策略,CONFIG GET maxmemory-policy 读出来就是 noeviction。只配上限、不配策略,满了的表现就是这一节:拒写。
1.2 满了以后哪些命令还能用
为了让结果干净,把上限调到当前用量以下(CONFIG SET maxmemory 40mb,此时 used_memory 是 48M),再逐条试:
| 命令 | 结果 |
|---|---|
GET k:5 |
正常返回 |
SET k:6 y(把 100 字节的值改短成 1 字节) |
(error) OOM command not allowed when used memory > 'maxmemory'. |
SET brandnew 1 |
OOM |
INCR / APPEND / GETSET / SETEX |
OOM |
HSET / LPUSH / SADD |
OOM |
EXPIRE k:9 100 / PERSIST k:9 |
(integer) 1,正常 |
DEL k:10 / UNLINK k:11 |
(integer) 1,正常 |
FLUSHALL |
OK |
注意第二行:把值改短也被拒 。Redis 不判断这条命令会不会让内存变大,它看的是两件事:执行前 used_memory 是否已经超过 maxmemory,以及这条命令有没有 denyoom 标志。用 COMMAND INFO 可以直接查:
bash
127.0.0.1:6380> command info set
1) 1) "set"
2) (integer) -3
3) 1) write
2) denyoom
...
127.0.0.1:6380> command info expire
1) 1) "expire"
2) (integer) 3
3) 1) write
2) fast
...
被拒的 set、getset、append、incr、setex 都带 denyoom;能执行的 expire、persist、del、unlink、get、flushall 都没有。所以满了以后,删除和设过期时间还能做 ------ 这是能自救的那条路。
我第一次是写满后马上 逐条试的,结果有的
SET成功、有的失败:内存正好卡在 50M 的边上,一条改短值、一条DEL腾出一点点空间,后面的写入又能进去。线上也一样:贴着上限的时候,同样的命令时好时坏,看起来像随机报错。
1.3 Java 端看到什么
Spring Boot 4.1.1(Lettuce)写一个已经超限的实例:
kotlin
org.springframework.data.redis.RedisSystemException: Error in execution
io.lettuce.core.RedisCommandExecutionException: OOM command not allowed when used memory > 'maxmemory'.
set 和 increment 都是这个;同一时刻 get 正常返回、delete 正常执行。异常最外层只写着 Error in execution,要往下看 cause 才知道是内存满了。
2. 8 种淘汰策略,各删掉了谁
2.1 实验设计
每种策略都做同样的事:
FLUSHALL,写 2 万个不带过期时间 的perm:*,和 2 万个带过期时间 的vol:*(EX 100000+i,序号越小剩余 TTL 越短);- 等 2 秒,然后把两组的前 2000 个各读 5 遍,当作热数据;
CONFIG SET maxmemory <当前 used_memory>,让内存刚好满;- 再写 2 万个新 key
new:*,数每组还剩多少。
2.2 结果
yaml
policy perm perm热 vol vol热 new 被拒 evicted
noeviction 20000 2000 20000 2000 0 20000 0
allkeys-lru 10494 2000 10403 2000 20000 0 19103
volatile-lru 20000 2000 2085 1776 20000 0 17915
allkeys-lfu 13172 2000 13110 2000 14226 0 19492
volatile-lfu 20000 2000 2085 1754 20000 0 17915
allkeys-random 12435 1211 12445 1207 15721 0 19399
volatile-random 20000 2000 2081 194 20000 0 17919
volatile-ttl 20000 2000 2081 0 20000 0 17919
(perm热 / vol热 = 前 2000 个热 key 还剩几个;被拒 = 写 new:* 时报 OOM 的条数。)
LRU / LFU 在 Redis 里是采样近似 的(默认 maxmemory-samples 5),所以我跑了两次。两次的定性结论完全一样,数字差几十到几百,比如 allkeys-lru 第二次是 10480 / 2000 / 10415 / 2000 / 20000。
2.3 怎么读这张表
- noeviction:新数据一条都没写进去。
- allkeys-lru:新数据全进去了,两组热 key 一个没丢,删掉的是两组里的冷数据。最「符合直觉」的一个。
- volatile-* (lru / lfu / random / ttl):
perm:*一个都没动,只在带过期时间的 key 里挑。 - volatile-ttl :热的那 2000 个
vol恰好是 TTL 最短的,结果全部被删(剩 0)。它只看谁快过期,不看谁常用。 - allkeys-lfu :热 key 都保住了,但刚写进来的新 key 也被删了,2 万条只剩 14,226 ------ 新 key 访问次数少,在 LFU 眼里就是冷数据。
- random:热 key 也只剩六成左右(1,211 / 2,000)。
2.4 坑:设了 volatile 策略,数据却都没有 TTL
很多人把策略设成 volatile-lru,但业务里大部分 key 是不设过期时间的。只写 2 万个不带 TTL 的 key,写满后再写 2 万个新的:
| 策略 | 结果 |
|---|---|
volatile-lru |
20000 条全部 OOM command not allowed ...,evicted_keys:0 |
volatile-ttl |
同上 |
volatile 策略找不到可淘汰的 key 时,表现和 noeviction 一模一样 ------ 设了淘汰策略,却照样拒写。
3. 不设 maxmemory:被系统杀掉,Redis 日志里什么都没有
maxmemory 默认是 0,即不限制。这时一直写下去,Redis 会一直向系统要内存,直到系统不给。
这台机器上还跑着别的服务,为了不把整机写爆,我把实验实例放进一个 cgroup ,限制它最多用 200M 内存(memory.limit_in_bytes = 200M,memory.swappiness = 0),相当于「这台机器只给 Redis 200M」。确认 CONFIG GET maxmemory 是 0,然后灌 200 万条(约 337M):
- 大约 2 秒后,Redis 进程没了。客户端
--pipe什么都没输出,退出码 1;再连:Could not connect to Redis at 127.0.0.1:6381: Connection refused。 - Redis 自己的日志一行相关记录都没有 ,最后一行还是启动时的
Ready to accept connections。 - 原因在内核日志里,
dmesg:
arduino
redis-server invoked oom-killer: gfp_mask=0xd0, order=0, oom_score_adj=0
Task in /redislab killed as a result of limit of /redislab
Memory cgroup out of memory: Kill process 17417 (jemalloc_bg_thd) score 981 or sacrifice child
Killed process 17412 (redis-server), UID 0, total-vm:409364kB, anon-rss:204732kB, file-rss:1896kB, shmem-rss:0kB
同样的步骤跑了两次,现象一致。
所以「Redis 突然没了、日志里什么都没有」,第一件事是看 dmesg 里有没有 Killed process。 这里因为用的是 cgroup,被杀的只会是组里的 Redis;如果是整台机器内存耗尽,内核会按 oom_score 挑进程杀,被杀的不一定是 Redis ------ 也可能是同机的数据库或应用。
4. 删了一半 key,内存为什么不降
4.1 现象
maxmemory 0,写 100 万个 key,然后隔一个删一个(删 50 万):
| 时点 | used_memory | RSS | mem_fragmentation_ratio |
|---|---|---|---|
| 写入后 | 168.97M | 175.05M | 1.07 |
| 删 50 万后 | 88.90M | 182.97M | 2.00 |
| 等 15 秒 | 88.90M | 178.97M | 2.01 |
MEMORY PURGE 后 |
88.90M | 178.97M | 2.01 |
FLUSHALL 后 |
851.91K | 17.68M | 21.25 |
used_memory 是 Redis 自己记的「数据占了多少」,删完立刻降了一半;RSS 是操作系统看到的进程占用,几乎没动。监控上看的通常是后者,所以「删了 key 内存没降」。
两者的比值就是 mem_fragmentation_ratio,从 1.07 变成了 2.00 ------ 删掉的 key 散落在各处,空出来的是一个个小洞,分配器没法把整页还给系统。MEMORY PURGE(让 jemalloc 归还空闲页)在这里没有任何效果 。FLUSHALL 全清以后 RSS 才回到十几 M。
空库时
mem_fragmentation_ratio是 15~21:分母used_memory只有 800K,这时候这个比值没有参考意义,别被它吓到。
4.2 activedefrag:要过门槛才会动
Redis 有主动碎片整理 activedefrag(默认关闭)。在上面那个状态下 CONFIG SET activedefrag yes,等了 60 秒:
makefile
active_defrag_hits:0 active_defrag_misses:0
一次都没整理。 查参数:
lua
active-defrag-ignore-bytes 104857600
active-defrag-threshold-lower 10
redis.conf 里对它的注释是 Minimum amount of fragmentation waste to start active defrag,默认 100MB:碎片少于 100MB 不启动整理。此时碎片(allocator_frag_bytes)约 84MB,没到门槛。
数据量翻倍再来一遍(200 万个,删 100 万,碎片约 168MB),默认参数:
| 时点 | used_memory | RSS | mem_fragmentation_ratio |
|---|---|---|---|
| 删 100 万后等 15 秒 | 177.01M | 347.47M | 1.96 |
MEMORY PURGE 后 |
177.01M | 347.47M | 1.96 |
activedefrag yes 后 30 秒 |
177.01M | 270.18M | 1.53 |
| 60 / 120 秒 | 177.01M | 268.6M | 1.52 |
这次动了:active_defrag_hits:2069542,RSS 从 347M 降到 269M。之后停住不再降,这时 allocator_frag_bytes 是 85,529,848(约 85MB),刚好又低于 100MB 的门槛。
结论 :删 key 不降 RSS 是正常的;想让它降,开 activedefrag,但要知道它有门槛,碎片不到 active-defrag-ignore-bytes(默认 100MB)时开了也不动。
5. key 过期了,内存却还占着
5.1 全部过期:几秒就清完
100 万个 key 全部 EX 5,写完不再访问:
ini
t= 0s dbsize= 1000000 expired_keys:0 used_memory_human:199.86M
t= 4s dbsize= 955032 expired_keys:46055 used_memory_human:191.24M
t= 6s dbsize= 384537 expired_keys:616790 used_memory_human:86.74M
t= 8s dbsize= 0 expired_keys:1000000 used_memory_human:852.12K
5.2 少量过期混在大量未过期里:清得极慢
更常见的情况:库里大部分 key 的过期时间很长,只有一小部分到期了。100 万个 EX 86400 + 10 万个 EX 5,写完不再访问:
ini
t= 0s dbsize= 1100000 expired_keys:0 used_memory_human:250.25M
t= 30s dbsize= 1099377 expired_keys:623 used_memory_human:234.13M
t= 60s dbsize= 1098649 expired_keys:1351
t=120s dbsize= 1097079 expired_keys:2921
t=180s dbsize= 1095639 expired_keys:4361 used_memory_human:233.45M
10 万个早就过期的 key,3 分钟只清掉了 4,361 个 ,dbsize 里仍然算着它们,内存也还占着。此时 INFO stats 里:
makefile
expired_stale_perc:7.92
redis.conf 里对后台清理的说明是:默认力度下,它尽量让残留的过期 key 不超过一成 (try to avoid having more than ten percent of expired keys still in memory)。这里 10 万 / 110 万不到一成(expired_stale_perc 7.92%),所以它清得很省力。这时候直接访问某个过期 key,会被当场判为不存在(EXISTS short:1 → 0)------ 所以业务上看不出问题,只有内存和 dbsize 不对。
5.3 两个处理办法(都实测过)
办法一:用 SCAN 扫一遍。 SCAN 遍历到过期 key 时会顺手删掉:
css
$ redis-cli -p 6380 --scan --pattern "short:*" | wc -l
0 ← 耗时 2,228ms
扫完以后 dbsize 回到 1,000,000,expired_keys:100000,used_memory 从 233M 降到 216M。
办法二:调高 active-expire-effort (默认 1,最大 10;设 11 会报 argument must be between 1 and 10 inclusive)。改成 10,同样的场景再来一遍:
ini
t= 10s dbsize= 1077284 expired_keys:22716
t= 20s dbsize= 1033504 expired_keys:66496
t= 30s dbsize= 1000000 expired_keys:100000
expired_stale_perc:0.00
约 25 秒清完;默认值时 3 分钟只清了 4,361 个。代价配置文件里写了:At its maximum value the system will use more CPU, longer cycles (and technically may introduce more latency)。
6. 没多写数据,key 却被淘汰了:慢消费者
最后一个场景解释「内存突然飙升」:客户端的输出缓冲区也算在 used_memory 里。
maxmemory 50mb、allkeys-lru,先放 20 万个 key(used 34.80M)。起一个客户端 SUBSCRIBE ch 之后一直不读(模拟消费者卡住),然后往频道里发消息,每批 60 条、每条 100KB:
markdown
dbsize used_memory evicted_keys 订阅端 omem
放好数据 200000 34.80M 0 0
第 1 批后 200000 37.76M 0 2,867,872
第 2 批后 200000 43.33M 0 8,919,104
第 3 批后 200000 49.19M 0 15,064,544
第 4 批后 167885 49.91M 32115 21,209,984
第 5 批后 131220 49.93M 68780 27,371,808
第 6 批后 94470 49.92M 105530 33,517,248
一个 key 都没多写,数据被淘汰了 10.5 万个,超过一半 。原因只是那个订阅端不读消息,Redis 替它攒着,攒的这部分算进了 used_memory(INFO memory 里记在 mem_clients_normal)。
pubsub 的输出缓冲默认上限是 client-output-buffer-limit pubsub 33554432 8388608 60(硬上限 32MB)。实验里 omem 到了 33,517,248,还没碰到硬上限,所以这个订阅端没被断开,日志里也没有任何记录。
排查时用 CLIENT LIST 看每个连接的 omem(输出缓冲占的字节)和 oll(积压的回复条数),谁在攒一眼就能看到:
ini
id=598 ... sub=1 ... oll=328 omem=33517248 ... cmd=subscribe
7. 速查
| 现象 | 原因 | 看哪里 / 怎么办 |
|---|---|---|
OOM command not allowed when used memory > 'maxmemory' |
到上限了,且策略是 noeviction(默认) | 设淘汰策略;紧急时 DEL / UNLINK / EXPIRE 还能执行 |
设了 volatile-lru 还是报 OOM |
数据没有 TTL,volatile 策略没东西可删 | 改 allkeys-lru,或给数据加 TTL |
| 热点 key 被删了 | volatile-ttl / random 不看冷热 |
换 allkeys-lru / allkeys-lfu |
| 新写进去的 key 很快没了 | allkeys-lfu 下新 key 访问次数少 |
了解 LFU 行为,或用 LRU |
| Redis 突然没了,日志里什么都没有 | 没设 maxmemory,被系统 OOM 杀了 |
dmesg 找 Killed process;设 maxmemory |
| 删了大量 key,监控内存不降 | 碎片,RSS 不还给系统 | 看 mem_fragmentation_ratio;activedefrag yes(碎片要过 100MB) |
dbsize 远大于实际有效的 key |
过期 key 没被后台清掉 | INFO stats 看 expired_stale_perc;SCAN 扫一遍;调 active-expire-effort |
没多写数据,却有大量 evicted_keys |
客户端输出缓冲涨了(慢消费者等) | CLIENT LIST 看 omem / oll |
下一篇:Redis 宕机会丢多少数据 ------ kill -9 和断电下,RDB、AOF 各丢多少,以及「明明有 dump.rdb,重启后数据却没了」的几种原因。