Redis 内存分析与优化
提出问题
Redis 是内存数据库,所有数据驻留在内存中。内存就是钱——生产环境一个 64GB 的 Redis 实例每月成本上千。更棘手的是,线上跑着跑着内存就涨了,OOM killer 一刀切下去直接丢数据。面试官问 Redis 内存优化,不是考你背几个命令,而是看你有没有在线上真正分析过内存、定位过问题。
你遇到过这些场景吗:内存碎片率 3.0 以上,实际占用比 keys 总和多一倍;一个哈希存了 1000 万个字段,编码还是 hashtable 而不是 listpack;设置了 maxmemory 但不知道淘汰策略怎么选。这些问题的解决都绕不开内存分析工具。
分析问题
内存全景:Redis 内存到底花在哪了
一个 Redis 实例的内存占用 ≈ 自身元数据 + 数据内存 + 复制缓冲区 + AOF 缓冲区 + 客户端缓冲区 + 碎片。
以一个 64GB 的线上实例为例(16 核 CPU,4 万 QPS,5 万 key),用 INFO MEMORY 看:
used_memory:42000000000 # 数据实际占用的逻辑内存 (42GB)
used_memory_rss:56000000000 # 实际从 OS 占用的物理内存 (56GB)
used_memory_peak:61000000000 # 曾达到峰值 61GB
used_memory_overhead:8500000000 # 管理开销(dict、expires、slave 等)
used_memory_startup:1048576 # Redis 启动时初始化占用的内存 (1MB)
mem_fragmentation_ratio:1.33 # 56 / 42 = 1.33,碎片率偏高
maxmemory:64000000000 # 设置上限 64GB关键发现:used_memory 只有 42GB,但 RSS 已经 56GB,碎片占了 14GB。如果 maxmemory 是 64GB,持续增长的话,RSS 会先撞到物理内存边界触发 OOM,而不是等 used_memory 到 64GB 才触发淘汰。
MEMORY USAGE 与 MEMORY DOCTOR 诊断
Redis 4.0 引入的 MEMORY 命令族是内存分析的第一把刀。内部实现原理:Redis 对每个 key 的 value 对象递归遍历,调用 objectComputeSize() 函数,累加所有子结构的 malloc 开销。对于哈希类型,会遍历所有字段和值;对于集合,会遍历所有元素。
MEMORY USAGE <key>:返回一个 key 实际占用的字节数,包括它的数据结构、元数据、编码开销。比如一个存了 100 个字段的哈希,用MEMORY USAGE可以精确看到它占了多少内存,而不是简单用STRLEN或HLEN估算。MEMORY STATS:返回实例级别的内存快照,包括 peak.allocated、total.allocated、startup.allocated、overhead.hashtable.main、overhead.hashtable.expires 等。MEMORY DOCTOR:自动诊断常见内存问题,输出可读建议。如果返回 "High fragmentation: this is not an issue since ..." 或者 "Peak memory: In the past ...",说明有异常。
线上排查的典型流程:先用 INFO MEMORY 看全局(used_memory、used_memory_rss、mem_fragmentation_ratio),再用 MEMORY STATS 细看各部分开销,最后用 MEMORY USAGE 定位大 key。
# 查看实例内存概览
redis-cli INFO memory | grep -E "used_memory|used_memory_rss|mem_fragmentation|maxmemory"
# 检查大 key
redis-cli --bigkeys
# 对特定 key 分析内存
redis-cli MEMORY USAGE user:profile:10001下面是一个真实排查流程的时序图:
[INFO MEMORY] → 发现 frag_ratio=2.1,RSS 是 used_memory 的两倍
↓
[MEMORY DOCTOR] → 诊断:碎片率过高,原因可能是热点 key 频繁增删
↓
[--bigkeys] → 发现 3 个哈希 key 各有 500 万+ 字段,全是 hashtable 编码
↓
[MEMORY USAGE user:big_hash] → 单 key 占用 1.2GB
↓
[结论] → 大哈希未达到 listpack 阈值 + 碎片率高,双杀内存碎片率与 activedefrag
mem_fragmentation_ratio = used_memory_rss / used_memory。这个值在 1.0-1.5 之间是正常的。如果远大于 1.5,说明内存碎片严重;如果小于 1.0,说明 Redis 使用了 swap(危险信号)。
碎片产生的原因:Redis 使用 jemalloc 分配内存,jemalloc 按固定大小范围分配(如 8/16/32/64 字节桶)。当键值频繁创建和删除时,内存释放后留下的小空洞无法被后续分配复用,就产生了碎片。另外,jemalloc 的 arena 机制在多线程下也会产生一定碎片。
一个真实案例:某社交业务线的 Redis 实例,每小时有 200 万条临时数据过期 TTL=3600s,同时有大量新数据写入。mem_fragmentation_ratio 从 1.2 在 24 小时内涨到 2.8。used_memory 稳定在 30GB,但 RSS 从 36GB 涨到 84GB,直接 OOM 重启。排查发现是 jemalloc 的 page 碎片——大量 64 字节的 key 被删除后,留下了 4KB 的 page 空洞,新写入的 128 字节 value 塞不进去。
Redis 4.0 的 activedefrag 机制可以在线整理碎片:
# 开启主动碎片整理
CONFIG SET activedefrag yes
CONFIG SET active-defrag-threshold-lower 10 # 碎片率超过 10% 开始整理
CONFIG SET active-defrag-threshold-upper 100 # 碎片率超过 100% 进行最大力度整理
CONFIG SET active-defrag-ignore-bytes 100mb # 碎片超过 100MB 才触发
CONFIG SET active-defrag-cycle-min 1 # CPU 占用下限
CONFIG SET active-defrag-cycle-max 25 # CPU 占用上限activedefrag 的工作原理:Redis 后台每隔 active-defrag-cycle-min 毫秒执行一次碎片整理,遍历 jemalloc 的 arena bin,将碎片 page 上的数据迁移到连续内存后释放碎片 page。在 16 核的机器上,active-defrag-cycle-max 25 意味着最多占用 25% 的一个核(约 25% × 100%/16 = 1.56% 的总 CPU),但实测在高碎片率场景下延迟会从 1ms 涨到 5-8ms。
activedefrag 的调优实操:不要一上来就开 25% 的 cycle-max。从 5% 逐步上调,配合 latency-monitor-threshold 监控延迟变化。如果触发延迟超过 10ms 的频率明显增加,降回上一档。
对象编码优化
Redis 对不同大小的数据采用不同的内部编码,选对编码可以省 50-90% 的内存。这是最容易被忽视的优化点。
| 类型 | 编码 | 条件 | 内存节省量 | 原理说明 |
|---|---|---|---|---|
| String | int | 整数,可转为 long | 8 bytes vs 字符串 | 直接存 long 值,零 malloc |
| String | embstr | ≤ 44 字节 | 一次 malloc,连续内存 | redisObject + sdshdr 在同一块连续内存分配 |
| String | raw | > 44 字节 | 两次 malloc | redisObject 和 sdshdr 分开分配 |
| Hash | listpack | 字段数 ≤ 512 && 每个字段值 ≤ 64B | 每个字段省 ~56 字节 | 连续内存,无 dictEntry 指针开销 |
| Hash | hashtable | 超过阈值后 | 每个 entry 额外 ~64 字节 | dictEntry(24B) + key/value 指针(16B) + 链表指针(16B) + 对齐 |
| List | quicklist | 默认 | 比纯 linkedlist 省 70%+ | 每节点用 ziplist 压缩,节点数可控 |
| ZSet | listpack | 元素 ≤ 128 && 元素值 ≤ 64B | 每个元素省 ~32 字节 | 省去 skiplist 的 level 数组和后退指针 |
| ZSet | skiplist | 超过阈值后 | 额外 ~32 字节/元素 | skiplist 每一层 forward 指针(8B) + span(4B) + backward(8B) |
用 Python 验证编码切换的边界:
import redis
r = redis.Redis(decode_responses=True)
# 测试哈希编码切换:512 个字段是分水岭
for i in range(510, 515):
key = f"test_hash_{i}"
for j in range(i):
r.hset(key, f"field_{j}", f"val_{j}")
enc = r.object("ENCODING", key)
print(f"Fields={i}: encoding={enc}")
r.delete(key)
# 输出:
# Fields=510: encoding=listpack ← 512 以下,压缩编码
# Fields=511: encoding=listpack
# Fields=512: encoding=hashtable ← 超过 512,自动切换
# Fields=513: encoding=hashtable
# Fields=514: encoding=hashtable踩坑实录:一个用户画像业务,用哈希存每个用户的 600 个喜好标签(每个标签值 10-30 字节),hash-max-listpack-entries 默认是 512,所以 600 个字段的哈希全部用了 hashtable 编码。每个用户的哈希多占 600 × 56 ≈ 33KB 的额外开销。1000 万用户,就是 330GB 的额外内存。调整 hash-max-listpack-entries 1024 后,这些哈希全部回落为 listpack 编码,内存从 800GB 降到 320GB。
# 调大 listpack 阈值,让更多数据用紧凑编码
redis-cli CONFIG SET hash-max-listpack-entries 1024
redis-cli CONFIG SET hash-max-listpack-value 4096
redis-cli CONFIG SET zset-max-listpack-entries 256
redis-cli CONFIG SET zset-max-listpack-value 128maxmemory 与淘汰策略调优
当内存达到 maxmemory 限制时,Redis 根据 maxmemory-policy 决定淘汰哪些键。不同策略的内存压力表现完全不同:
| 策略 | 适用范围 | 淘汰逻辑 | 平均延迟 (1M key 场景) | 适用场景 |
|---|---|---|---|---|
| noeviction | 全部 | 写请求直接返回 OOM 错误 | 0ms(不淘汰) | 不可丢弃的数据(但挂了会丢写) |
| allkeys-lru | 全部 | 从样本池(默认 5 个)选最久未访问的 key 淘汰 | ~0.5ms | 通用缓存,数据冷热交替 |
| allkeys-lfu | 全部 | 按访问频率淘汰,低频优先 | ~0.8ms | 热点数据稳定,冷数据低频访问 |
| volatile-lru | 有过期时间的 key | 同 LRU,但只从有过期时间的 key 中选 | ~0.5ms | 混合数据,部分有过期策略 |
| volatile-lfu | 有过期时间的 key | 同 LFU,只从有过期时间的 key 中选 | ~0.8ms | 混合数据,热点稳定 |
| volatile-ttl | 有过期时间的 key | 淘汰剩余 TTL 最短的 key | ~1.0ms | 有明确过期时间的数据 |
| volatile-random | 有过期时间的 key | 随机淘汰 | ~0.1ms | 对淘汰策略不敏感的场景 |
allkeys-lru 的近似 LRU 实现细节:Redis 不是全量扫描所有 key 找最久未访问的,而是维护一个 evictionPool 采样池(默认大小 16)。每次淘汰时,从 dict 中随机取 maxmemory-samples(默认 5)个 key,按 idle time 排序后放入池中,从池中选 idle time 最大的淘汰。这保证了 O(1) 的淘汰复杂度,而不是 O(n) 扫描。
allkeys-lfu 的计数器衰减机制:LFU 的 counter 并不是简单递增,而是用 Morris counter(概率计数器)近似对数增长。counter 每 1 分钟衰减一次,衰减因子 lfu-decay-time 默认是 1,表示每 1 分钟 counter 减半。如果把 lfu-decay-time 调大到 10,则 10 分钟才减半,冷数据更不容易被淘汰。
# 切换淘汰策略
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli CONFIG SET maxmemory-samples 10 # 增大采样数,提高 LRU/LFU 精度
redis-cli CONFIG SET lfu-log-factor 10 # 控制 counter 增长速率,值越大高频访问增长越快
redis-cli CONFIG SET lfu-decay-time 1 # 每分钟衰减一次一个 maxmemory 设置失误的线上事故:某 Redis 集群的 maxmemory 设成了实际数据量的 120%(预留 20% 给复制缓冲和 AOF),但 maxmemory-policy 是默认的 noeviction。某天业务流量突增,数据量涨到 maxmemory 的 95%,复制缓冲区又吃了 6GB,used_memory 达到 maxmemory 的 101%。此时所有写请求都返回 OOM 错误,业务方发现用户登录写 Session 全失败,持续 15 分钟才人工介入改为 allkeys-lru。教训:生产环境绝对不要用 noeviction 做缓存,并且 maxmemory 至少留 30% 余量给复制缓冲区、AOF 缓冲和客户端输出缓冲。
总结
Redis 内存优化不是一次性的,需要持续监控和调优的三个维度:
- 诊断工具链:
INFO MEMORY→MEMORY STATS→MEMORY USAGE→--bigkeys,从粗到细定位问题 - 编码优化:合理设置
*-max-*listpack-entries参数,让大部分数据使用紧凑编码。每 100 万 key 切到 hashtable 编码多占 ~64MB 的额外开销 - 碎片治理:低峰期开启
activedefrag,监控mem_fragmentation_ratio,超过 2.0 就干预。先调active-defrag-cycle-min 5观察延迟变化,再逐步上调
生产避坑三条:maxmemory 一定要设,留 30% 余量给复制缓冲区和 AOF;maxmemory-policy 不要用 noeviction,缓存场景用 allkeys-lru 或 allkeys-lfu;大 key 必须治理(--bigkeys 定期扫描),单个哈希 key 超过 1 万个字段用 hashtable 编码,内存膨胀可能让整个实例抖动。
参考
Redis 官方文档:Memory Optimization — https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/
Redis 源码:src/memtest.c, src/db.c(淘汰策略实现), src/object.c(objectComputeSize)
jemalloc 文档:https://jemalloc.net/
antirez 博客:Redis LFU implementation — http://antirez.com/news/115