Redis内存占用过高怎么办?大键排查、淘汰策略与容量规划

凌晨三点,监控系统报 Redis 实例内存超过 95%,紧接着业务开始收到 OOM command not allowed when used memory > 'maxmemory' 的错误——写入全部失败,读正常。这种"只能读不能写"的半瘫状态最难排查,因为它不像宕机那么明显。Redis 内存问题的特殊性在于:它的 maxmemory 默认值为 0(不限制),也就是说如果你什么都不配,它会一路吃到被操作系统 OOM Kill 或者触发 swap,而不是自己拒绝写入。本文讲清楚三件事:内存到底被谁占了、八种淘汰策略怎么选、以及单机容量该留多少安全边界。

一、先看清楚:INFO memory 每个字段意味着什么

redis-cli -h 127.0.0.1 -p 6379 INFO memory

# 也可以用 Redis 4.0 起自带的医生,它会给出诊断建议
redis-cli MEMORY DOCTOR
redis-cli MEMORY STATS

重点看这几个字段:

字段含义判断标准
used_memoryRedis 分配器实际使用的内存总量(字节)接近 maxmemory 时需处理
used_memory_rss操作系统看到的进程常驻内存远大于 used_memory 说明碎片多
used_memory_peak历史峰值用于评估容量是否足够
mem_fragmentation_ratioused_memory_rss / used_memory1.0-1.5 健康;大于 1.5 碎片明显;小于 1 说明已在 swap,性能危险
mem_allocator内存分配器Linux 下通常为 jemalloc
maxmemory内存上限配置值0 表示不限制,生产必须显式设置
maxmemory_policy淘汰策略默认 noeviction,写入会报错
evicted_keys因淘汰而删除的 key 累计数持续增长说明缓存命中率会受影响

mem_fragmentation_ratio 小于 1 是最危险的信号:意味着部分内存页被换出到磁盘,而 Redis 是纯内存服务,一旦某个热点 key 落在 swap 上,单次访问可能从 0.1 毫秒变成几毫秒,整个 QPS 会被拖垮。此时应立即检查 swap 使用情况,并考虑扩容或迁移。

# 碎片整理(Redis 4.0+,需要 activedefrag 参数开启)
redis-cli CONFIG GET activedefrag
redis-cli CONFIG SET activedefrag yes
redis-cli MEMORY PURGE      # 请求 jemalloc 归还空闲内存,效果有限但无害

# 查看是否有 swap 使用
free -h
grep VmSwap /proc/$(redis-cli INFO server | grep process_id | tr -d '\r' | cut -d: -f2)/status

二、大键排查:MEMORY USAGE 与几把现成的刀

内存问题的第一嫌疑人永远是大 key(big key)。一个含 500 万个元素的 hash,本身占几个 GB,更麻烦的是:删除它要阻塞主线程几百毫秒到数秒,且 expire 到期删除同样会卡;在集群模式下它还会导致 slot 数据倾斜。判断标准可以这样定:

  • String 类型:value 大于 10KB 就算大 key(超过 1MB 严重)。
  • Hash / List / Set / ZSet:元素数量大于 5000,或整体占用大于 100KB,建议拆分。
  • 以上并非绝对值,更实用的阈值是"单 key 操作耗时是否超过 1 毫秒":Redis 官方 benchmark 里单次 GET/SET 在 微秒级,如果一个 key 的操作到了毫秒级,它就是热点大 key。

2.1 用 redis-cli 自带的扫描

# Redis 3.x/4.x/5.x:按元素个数找大 key(基于 SCAN,不阻塞)
redis-cli -h 127.0.0.1 -p 6379 --bigkeys

# Redis 6+ 推荐:按实际内存占用找大 key(更准确,建议配合采样数)
redis-cli --memkeys
redis-cli --memkeys --memkeys-samples 1000

# Redis 7 还内置了 key 类型分布的直方图统计
redis-cli --keystats

# 对指定 key 精确查询占用
redis-cli MEMORY USAGE mykey
redis-cli MEMORY USAGE myhash SAMPLES 1000    # 对聚合类型提高采样数更准
redis-cli OBJECT ENCODING myhash
redis-cli DEBUG OBJECT myhash 2>/dev/null     # 输出 serializedlength(压缩后的字节数)

注意 --bigkeys 是按"元素个数"排序的,对 String 类型的 key 完全无效(String 永远只有 1 个元素),所以 Redis 6 之后的版本推荐直接用 --memkeys。SCAN 类操作虽然不会像 KEYS 那样长时间阻塞,但会让 CPU 有一定上升,建议在业务低峰执行。

2.2 分类型统计与 TOP key 定位

# 各 db 的 key 数量
redis-cli INFO keyspace

# 用 SCAN 遍历并按编码统计(示例:找出占用最大的 String)
redis-cli --scan --pattern 'cache:*' | head -100000 | while read k; do
  echo "$(redis-cli MEMORY USAGE "$k") $k"
done | sort -nr | head -20

这个 shell 片段在 key 数量很大时执行较慢(每条一次 RTT),生产环境可以用 --pipe 或者 Lua 脚本批量处理。对海量 key 的实例,更实际的做法是接入 redis-rdb-tools 离线分析 RDB 快照:

# 安装后有命令行:
rdb -c memory /var/lib/redis/dump.rdb --bytes 10240 -f /tmp/big.csv
rdb -c memory /var/lib/redis/dump.rdb | sort -k4 -nr | head -30

三、maxmemory-policy:八种策略怎么选

当 used_memory 达到 maxmemory 时,Redis 按 maxmemory-policy 决定怎么办。默认策略是 noeviction——它不淘汰任何东西,直接让写命令返回错误,这就是开头那个 OOM 报错的来源。

策略淘汰范围算法适用场景
noeviction不淘汰写请求返回错误默认值;要求数据绝不丢失、由业务自己控制容量的场景
allkeys-lru全部 key近似 LRU纯缓存,允许丢任何数据,最常用
volatile-lru仅设了过期时间的 key近似 LRU缓存与持久数据混布,只想淘汰可过期部分
allkeys-lfu全部 key近似 LFU(4.0+)有长期热点、且希望按访问频次而非时间淘汰
volatile-lfu仅设了过期时间的 key近似 LFU同上,但只淘汰可过期部分
allkeys-random全部 key随机访问分布均匀、无需区分冷热
volatile-random仅设了过期时间的 key随机较少使用
volatile-ttl仅设了过期时间的 key优先淘汰剩余 TTL 最短的明确的短期缓存,希望快过期的先走

两个必须知道的实现细节:

  • 它们是近似算法。Redis 不会给所有 key 维护严格链表(那样内存和计算开销太大),而是随机抽样 maxmemory-samples(默认 5)个候选再挑最优。把该值调到 10-20 会更接近真实 LRU,代价是每次淘汰多花一点 CPU。
  • LFU 需要预热期。volatile-lfu / allkeys-lfu 依赖对象头里的 8 位访问计数器(带衰减,lfu-decay-time 默认 1 分钟),所以刚切换策略时计数从零开始积累,需要一段时间才能反映真实热度。
# 查看与动态修改(CONFIG REWRITE 持久化到 redis.conf)
redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy
redis-cli CONFIG SET maxmemory 8gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG SET maxmemory-samples 10
redis-cli CONFIG REWRITE

四、降低内存占用:编码、拆分与 lazyfree

4.1 利用紧凑编码

小规模的 hash、list、zset 会被编码成 listpack(Redis 7 起取代了 ziplist),它们用连续内存代替指针结构,能省一半以上内存。触达阈值后会转成 hashtable / skiplist 等结构,一旦扩容就不会自动降级回来。相关参数:

# redis.conf(Redis 7 命名;Redis 6 及以前为 hash-max-ziplist-entries / -value)
hash-max-listpack-entries   128
hash-max-listpack-value     64
zset-max-listpack-entries   128
zset-max-listpack-value     64
list-max-listpack-size      -2      # -2 表示单个节点不超过 8KB
set-max-intset-entries      512     # 纯整数集合用 intset 编码

举例:一个存用户信息的小 hash,field 数控制在 128 以内、每个 field 值不超过 64 字节,就能一直享受 listpack 的紧凑存储;超过阈值后每个 field 会变成独立对象,内存占用可能翻两三倍。这也是"用 hash 替代大量独立 String key"能省内存的原因,但同时要注意单 hash 过大又会退化成 hashtable,所以要分桶。

4.2 大 key 拆分

# 错误:一个 hash 存 300 万用户昵称
HSET user:names uid1 name1 uid2 name2 ...

# 正确:按 uid 取模拆成 1000 个桶,每个桶约 3000 个 field(仍需小于 listpack 阈值或接近)
bucket = crc32(uid) % 1000
HSET user:names:{bucket} uid nickname

List 按序号区间拆多段、ZSet 按分数区间拆分也是同样的思路。拆分后单个 key 的操作耗时降到毫秒以下,删除也变得安全。

4.3 删除大 key 不要卡主线程

DEL 是同步阻塞的,删除一个千万元素的集合可能卡住 Redis 数百毫秒。Redis 4.0 起应改用 UNLINK(异步回收到后台线程),并开启 lazy 相关的配置:

# redis.conf
lazyfree-lazy-eviction  yes
lazyfree-lazy-expire    yes
lazyfree-lazy-server-del yes
replica-lazy-flush      yes

# 命令行使用
redis-cli UNLINK huge:set:key
redis-cli FLUSHALL ASYNC

五、容量规划:给 fork 与缓冲区留一半

Redis 需要的内存不只是 used_memory。在做容量规划时至少要考虑这些额外占用:

单机所需内存 ≈ 数据本身 × (1 + 碎片冗余系数 0.3)
             + 复制 backlog:repl-backlog-size(默认 1MB,建议 64-256MB)
             + 客户端输出缓冲区上限之和(replica 默认 256MB 硬限制、slave client 可达 64MB 软限制 60 秒)
             + fork 期间的写时复制(COW)峰值增量

经验值:Redis 实例占用的峰值不应超过物理内存的 45%-65%

最后那一条最重要也最容易被忽略:当 Redis 执行 BGSAVE 或 AOF 重写时,会 fork 子进程。fork 之后父子进程共享内存页,但只要父进程有写入,被修改的页就会被复制(Copy-On-Write)。在写密集的实例里,fork 期间的额外内存可能接近"写速率 × fork 期间产生的写入量"。如果此时物理内存不足会直接触发 OOM Kill——连 maxmemory 都救不了你,因为 OOM 是内核层面的事。所以:

  • 64GB 内存的机器给一个 Redis 实例,maxmemory 建议设为 28-40GB。
  • 16GB 内存的机器,maxmemory 建议设为 6-10GB。
  • 写密集且开启了 AOF 的实例要再保守一点;纯读缓存可以相对激进。
  • 把 vm.overcommit_memory = 1 加入 /etc/sysctl.conf,避免 fork 因为地址空间检查失败而报错。
  • 将 vm.swappiness 调低(例如 1),降低被换出的概率。
# /etc/sysctl.conf
vm.overcommit_memory = 1
vm.swappiness = 1
# sysctl -p 使其生效

# 观察 fork 耗时(latest_fork_usec 单位微秒,超过 100000 即 100ms 就需关注)
redis-cli INFO stats | grep -E 'latest_fork_usec|total_forks'

六、内存问题的处理步骤

  1. 先确认是真高还是假高:看 INFO memory 的 used_memory 与 mem_fragmentation_ratio,小于 1 优先处理 swap。
  2. 确认 maxmemory 是否已设置;若为 0 应立刻按机器规格设置一个安全值。
  3. 用 --memkeys 或 rdb 离线分析找出 TOP 20 大 key,优先处理 size 最大的几个。
  4. 检查业务是否在往 Redis 里写"永不过期"的 key,这是最常见的失控原因。
  5. 按业务是否接受丢数据选择策略:纯缓存选 allkeys-lru;有持久数据要保留则选 volatile-lru;热点稳定选 allkeys-lfu。
  6. 用 UNLINK 分批清理无用过期数据,避免 DEL 阻塞。
  7. 优化数据类型:小对象用 hash/listpack 编码,大集合分桶。
  8. 核对 compact 编码阈值参数是否被误改小。
  9. 按第 5 节公式评估是否需要扩容,或者直接把大实例拆成多个小实例。
  10. 接入监控并设阈值:内存使用率 80% 预警、90% 告警,并对 evicted_keys、latest_fork_usec 单独配监控。

小结

Redis 内存治理的核心是把"未知"变成"可预算":用 INFO memory 看清楚实际占用与碎片率,用 --memkeys 和 MEMORY USAGE 定位大 key,用合适的 maxmemory-policy 让超限行为可控,最后按"给 fork 留一半"的原则倒推 maxmemory。避坑清单:maxmemory 绝不要留 0;纯缓存场景也不要默认用 noeviction,否则超限就是写入全线失败;不要用 KEYS、不要用同步 DEL 干掉大 key;不要在同一实例里混布持久数据和临时缓存还期望淘汰策略能区分它们;不要把实例开到物理内存的 80% 以上。