Redis 内存告警排查:从 maxmemory-policy 到 key 命名空间
## 一、告警响起来时,先别急着扩容
Redis 内存告警是最容易「加内存了事」的一类问题。但加内存通常只买到两三天时间,因为真正常见的三种根因都不在容量上:
1. **淘汰策略配错**,数据根本不淘汰,内存只涨不跌
2. **大 key / 热 key**,少数几个 key 吃掉了大半内存
3. **key 泄漏**,代码里写了没有过期时间的 key,长期堆积
这三种情况下,扩到 32G 也会在某个时点再次打满。所以第一步不是加内存,而是把下面六个数字拿到手。
## 二、INFO memory 里只看六个字段
```
redis-cli INFO memory
```
输出很长,但有六个字段决定了后续方向:
```
used_memory: 8589934592
used_memory_rss: 12884901888
used_memory_peak: 9126805504
mem_fragmentation_ratio: 1.50
maxmemory: 8589934592
maxmemory_policy: noeviction
```
逐个解读:
**used_memory** —— Redis 自己统计的分配量,包含数据、内部结构、复制缓冲区。这是 Redis 认为「我用了多少」。
**used_memory_rss** —— 操作系统看到的进程实际占用(RSS)。包含内存碎片、分配器开销。
**mem_fragmentation_ratio** —— 前两者的比值。`1.0~1.5` 正常,`> 1.5` 说明碎片较多,`< 1` 说明部分内存被换出到 swap(这种情况性能会断崖下跌,必须立刻处理)。
**used_memory_peak** —— 历史峰值。如果 peak 远大于当前值,说明曾经有过一次流量尖峰,之后数据被淘汰掉了;如果 peak ≈ used_memory,说明内存是一路涨上来的,基本可以判定为泄漏。
**maxmemory** —— 上限。**如果这个值是 0,说明没有设置上限**,Redis 会一直吃到系统 OOM 为止。这是最常见的一类配置事故。
**maxmemory_policy** —— 淘汰策略。下一节专门讲。
拿到这六个值,基本就能分出三条路:
| 特征 | 判断 | 方向 |
|---|---|---|
| `maxmemory = 0` | 没设上限 | 立刻设上限 + 策略 |
| `peak ≈ used_memory` 且持续上涨 | 泄漏或没有淘汰 | 查策略与 key 的 TTL |
| `fragmentation_ratio > 1.5` | 碎片 | 开 activedefrag 或迁移重建 |
| `fragmentation_ratio < 1` | 发生 swap | 调 vm.swappiness、加内存 |
| `maxmemory_policy = noeviction` | 不淘汰 | 见下节 |
## 三、maxmemory-policy 的六种策略,选错就是事故
Redis 提供六种策略,本质是两组正交的选择:**淘汰范围**(全部 key / 仅带 TTL 的 key)和 **淘汰依据**(LRU / LFU / TTL / 随机)。
| 策略 | 淘汰范围 | 依据 |
|---|---|---|
| `noeviction` | 不淘汰,写入报 OOM 错误 | — |
| `allkeys-lru` | 所有 key | 最久未使用 |
| `allkeys-lfu` | 所有 key | 访问频率最低 |
| `allkeys-random` | 所有 key | 随机 |
| `volatile-lru` | 仅设置了 TTL 的 key | 最久未使用 |
| `volatile-ttl` | 仅设置了 TTL 的 key | 剩余 TTL 最短 |
三个高频踩坑:
**坑一:`volatile-lru` 遇上「大部分 key 没有 TTL」。**
这是最典型的线上事故。团队为了「只淘汰缓存、别动持久数据」,选了 `volatile-lru`。听起来很合理,但结果是:内存打满时,只有在 TTL 的 key 会被淘汰。如果 80% 的内存被没有 TTL 的 key 占着,那么剩下 20% 的 key 会被反复淘汰、反复回源,缓存命中率掉到个位数,同时内存仍然打满,写入开始报 `OOM command not allowed when used memory > 'maxmemory'`。
排查办法很简单,看有多少 key 带 TTL:
```
redis-cli INFO keyspace
```
输出:
```
db0:keys=1834521,expires=31204,avg_ttl=0
```
`keys=1834521`、`expires=31204`,意味着只有 1.7% 的 key 有过期时间,其余 98.3% 是「永久的」。这种情况下 `volatile-*` 系列策略等于没生效,必须改用 `allkeys-lru` 或 `allkeys-lfu`。
**坑二:`allkeys-lru` 把不该淘汰的数据淘汰了。**
如果这个 Redis 里既有缓存又有业务数据(比如队列、计数器、分布式锁),`allkeys-lru` 会把业务数据也当成缓存淘汰掉,表现为「锁突然失效」「计数器归零」。
正确做法是**实例隔离**:缓存和数据分不同实例,缓存实例用 `allkeys-lfu`,数据实例用 `noeviction` 并把上限设得足够高、配合监控告警。混用是长期隐患。
**坑三:LRU 不是真 LRU。**
Redis 的 LRU 是近似算法:默认采样 5 个 key(`maxmemory-samples`),淘汰其中最久未使用的。采样数越大越接近真 LRU,但 CPU 开销也越大。生产上 `10` 是个不错的折中:
```
maxmemory-samples 10
```
同理,LFU 的计数器有衰减机制(`lfu-decay-time`),默认 1 分钟衰减一次。如果业务有明显的高低峰,`allkeys-lfu` 通常比 `allkeys-lru` 命中率高,因为它保留的是「访问频率高的」而不是「最近访问过的」,后者容易被一次全量扫描污染。
## 四、设了 maxmemory 为什么还会 OOM
这是让人困惑的现象:明明设了 8G 上限,进程却因为 RSS 超过 8G 被系统杀掉。原因是 `maxmemory` 只管**数据集**,下面几块内存不在它的管辖范围内:
**主从复制缓冲区**。每个 replica 有一个 `repl-backlog`,加上 `client-output-buffer-limit replica` 控制的待发送缓冲。全量同步期间,这个缓冲可能涨到几个 G。它不计入 maxmemory,但计入 RSS。
**AOF 重写缓冲区**。`aof_rewrite_buf` 在重写期间把新写入攒起来,重写完成后一次性追加。写入量大时这块能到几百 M 甚至上 G。
**fork 的 Copy-On-Write**。做 RDB 快照或 AOF 重写时 Redis 会 fork 子进程,子进程与父进程共享内存页,但父进程一旦写入就触发 COW,页被复制。最坏情况下 RSS 翻倍。这就是「为什么每天凌晨备份时内存会翻倍」的原因。
检查这几块:
```
redis-cli INFO clients
redis-cli INFO persistence
redis-cli INFO replication
```
`persistence` 里的 `rdb_bgsave_in_progress`、`aof_rewrite_in_progress` 能告诉你当时是否在 fork;`replication` 里的 `slave0:...,offset=...` 和 `client_output_buffer` 相关指标能看出复制缓冲压力。
**实操建议**:容器 limit 或机器内存,按 `maxmemory × 2` 预留。如果内存吃紧,用 `vm.overcommit_memory=1` 并确保有 swap 兜底,但要注意 swap 会让性能崩掉,只适合作为「避免被杀」的最后一道保险。
## 五、大 key 和热 key:内存的真正黑洞
内存 8G 里有 5G 是三个 key 贡献的,这种事故每年都会遇到几次。定位手段有四个,成本递增:
**1. `--bigkeys`(离线、低风险)**
```
redis-cli --bigkeys -i 0.1
```
它用 SCAN 遍历,按类型统计最大的 key。`-i 0.1` 表示每 100 条命令休眠 0.1 秒,避免拖慢实例。缺点是只能看出「最大的单个 key」,看不出总量分布。
**2. `--memkeys`(Redis 6+,直接给内存占用)**
```
redis-cli --memkeys
```
比 `--bigkeys` 更准,因为它按实际内存分配统计,而不是按元素个数。
**3. `--hotkeys`(需要 LFU 策略)**
```
redis-cli --hotkeys
```
只有 `maxmemory-policy` 是 `allkeys-lfu` 或 `volatile-lfu` 时才有效,因为它读的是 LFU 计数器。如果实例是 LRU 策略,这个命令会返回空。
**4. 采样估算某个前缀的总量**
要看「哪类 key 占了最多内存」,得自己聚合:
```bash
redis-cli --scan --pattern '*' | awk -F: '{print $1":"$2}' | sort | uniq -c | sort -rn | head -20
```
如果 key 命名规范(`业务:实体:ID`),这条命令能直接告诉你哪类业务在膨胀。注意 `--scan` 本身是安全的全量遍历,但不能保证强一致。
更进一步,可以采样若干 key 用 `MEMORY USAGE` 估算:
```
redis-cli MEMORY USAGE user:profile:10086 SAMPLES 5
```
然后 `平均单 key 内存 × 该类 key 数量` 得到估算总量。这比全量扫要快得多。
## 六、key 泄漏:最常见的「内存只涨不跌」
代码里写了 `SET key value` 却没设过期时间,是最普遍的泄漏源。三种典型写法:
```php
// 错误:没有 TTL,永久驻留
$redis->set("user:session:" . $uid, $data);
// 正确:明确 TTL
$redis->setex("user:session:" . $uid, 1800, $data);
```
```python
# 错误:set 后再 expire 不是原子的,中间崩溃就永久了
r.set(key, val)
r.expire(key, 3600)
# 正确:一条命令搞定
r.set(key, val, ex=3600)
```
第三种是「每次写入 key 名都不同」,比如拼了时间戳:
```python
key = f"stat:{date}:{hour}:{minute}:{second}:{uid}" # 每秒一个 key,永不重复
```
这类 key 数量随时间线性增长,是隐蔽性最强的泄漏。
排查两步走:
**第一,看 keyspace 的 expires 占比**(前面提过)。如果 `expires/keys < 0.9`,而业务上应该是纯缓存,就已经能说明问题。
**第二,按前缀看新增速度**。隔 10 分钟跑两次统计,对比增量:
```bash
redis-cli --scan --pattern 'stat:*' | wc -l
```
两次差值除以时间,就是新增速率。如果某个前缀在稳定增长且没有对应的清理逻辑,就是它。
定位到之后,清理要小心:**绝不能用 `KEYS pattern` 删**,会阻塞主线程。用 `SCAN` 游标分批删:
```bash
redis-cli --scan --pattern 'stat:2026:*' | head -1000 | xargs -r -n 100 redis-cli UNLINK
```
`UNLINK` 是异步删除,把释放内存的操作放到后台线程,不会卡住主线程。Redis 4.0+ 都应该优先用 `UNLINK` 而不是 `DEL`。
## 七、碎片:ratio 高了怎么办
`mem_fragmentation_ratio` 长期高于 1.5,说明分配器(jemalloc)保留了大量空页。原因是数据大小分布变化剧烈,或者做过大量删除。
两种处理方式:
**开启主动碎片整理(Redis 4.0+)**
```
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
active-defrag-threshold-upper 100
```
`threshold-lower 10` 表示碎片率超过 10% 才开始整理,`upper 100` 表示到 100% 就全力整理。整理过程会消耗 CPU,建议先观察 10 分钟再决定是否常开。
**或者迁移重建**:主从切换一次,新实例的内存是紧凑的。适合能接受秒级中断的场景。
判断是否需要处理的一个实用阈值:**碎片浪费的绝对量**。
```
wasted = used_memory_rss - used_memory
```
如果 `wasted` 只有几百 M,ratio 再高也不值得折腾;如果 `wasted` 超过 2G,就值得处理。
## 八、一份可直接抄的排查清单
```bash
# 1. 六个关键字段
redis-cli INFO memory | grep -E "used_memory:|used_memory_rss:|used_memory_peak:|mem_fragmentation_ratio|maxmemory:|maxmemory_policy:"
# 2. 是否设了上限
redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy
# 3. TTL 覆盖率
redis-cli INFO keyspace
# 4. 大 key
redis-cli --bigkeys -i 0.1
# 5. 按前缀聚合
redis-cli --scan --pattern '*' | awk -F: '{print $1":"$2}' | sort | uniq -c | sort -rn | head -20
# 6. 单 key 内存
redis-cli MEMORY USAGE <key> SAMPLES 5
# 7. 是否在 fork / 复制压力
redis-cli INFO persistence | grep -E "in_progress|rdb_last"
redis-cli INFO replication
# 8. 慢命令(可能是大 key 扫描造成的)
redis-cli SLOWLOG GET 10
```
第 6 步的 `SAMPLES 5` 很关键:默认 `MEMORY USAGE` 会全量采样一个 key,如果 key 有几百万元素,可能阻塞几百毫秒。给个采样数就安全了。
## 九、容量怎么估才靠谱
很多团队按「key 数量 × 平均大小」估容量,结果差三倍。原因是漏了三块开销:
1. **每个 key 的元数据开销**:一个 key 除了值本身,还有 dictEntry、redisObject、SDS 头,小 key 的情况下这部分能占到 50% 以上。比如 `SET k v`,值只有 1 字节,实际占用约 60 字节。
2. **哈希表扩容的冗余**:Redis 的 dict 扩容是翻倍,装载因子在 0.1~1 之间波动,平均有近一倍冗余。
3. **复制缓冲与 fork COW**:前面讲过,最坏情况翻倍。
实操上,用 `INFO memory` 的 `used_memory_dataset` 除以 `keys`,得到真实的「平均单 key 成本」,再乘以预期 key 数,最后乘 1.5 的余量。这个估算比拍脑袋准得多。
```
平均单 key 成本 = used_memory_dataset / db0.keys
```
一半是浪费一半是现实。尤其是小 key 场景,一定要按实际测出来的值算,不要按「值的大小」算。
## 十、决策表
| 现象 | 判断 | 处理 |
|---|---|---|
| `maxmemory = 0` | 无上限 | 立刻设上限与策略 |
| 写入报 `OOM command not allowed` | 已达上限且不淘汰 | 改策略或扩容,先看 keyspace |
| `expires/keys < 0.5` 且策略是 `volatile-*` | 策略失效 | 改 `allkeys-lru/lfu` |
| `peak ≈ used_memory` 持续上涨 | 泄漏 | 按前缀查增速,补 TTL |
| 少数 key 占大半内存 | 大 key | 拆分或改结构(hash 分桶) |
| `fragmentation_ratio > 1.5` 且浪费 > 2G | 碎片 | `activedefrag` 或迁移重建 |
| `fragmentation_ratio < 1` | 用了 swap | 调 `vm.swappiness`,扩内存 |
| 凌晨内存翻倍 | fork COW | 错峰备份或上从库备份 |
| 淘汰频繁但命中率骤降 | 缓存被反复淘汰 | 扩容或改 LFU,检查是否混用实例 |
## 收尾
Redis 内存问题的排查顺序其实很固定:**先看上限有没有设,再看策略对不对,然后看 key 的 TTL 覆盖率,最后才看大 key 和碎片**。
这个顺序的价值在于成本递增:前两步各花十秒,能解决掉一半的问题;大 key 扫描和前缀聚合要花几分钟;扩容最贵,而且往往治标不治本。
下次告警响的时候,先别打开控制台点扩容,跑一遍 `INFO memory | grep` 那六行,答案通常就在里面。
- 点赞
- 收藏
- 关注作者
评论(0)