Skip to content

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 可以精确看到它占了多少内存,而不是简单用 STRLENHLEN 估算。
  • 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。

bash
# 查看实例内存概览
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% 的内存。这是最容易被忽视的优化点。

类型编码条件内存节省量原理说明
Stringint整数,可转为 long8 bytes vs 字符串直接存 long 值,零 malloc
Stringembstr≤ 44 字节一次 malloc,连续内存redisObject + sdshdr 在同一块连续内存分配
Stringraw> 44 字节两次 mallocredisObject 和 sdshdr 分开分配
Hashlistpack字段数 ≤ 512 && 每个字段值 ≤ 64B每个字段省 ~56 字节连续内存,无 dictEntry 指针开销
Hashhashtable超过阈值后每个 entry 额外 ~64 字节dictEntry(24B) + key/value 指针(16B) + 链表指针(16B) + 对齐
Listquicklist默认比纯 linkedlist 省 70%+每节点用 ziplist 压缩,节点数可控
ZSetlistpack元素 ≤ 128 && 元素值 ≤ 64B每个元素省 ~32 字节省去 skiplist 的 level 数组和后退指针
ZSetskiplist超过阈值后额外 ~32 字节/元素skiplist 每一层 forward 指针(8B) + span(4B) + backward(8B)

用 Python 验证编码切换的边界:

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。

bash
# 调大 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 128

maxmemory 与淘汰策略调优

当内存达到 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 分钟才减半,冷数据更不容易被淘汰。

bash
# 切换淘汰策略
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 内存优化不是一次性的,需要持续监控和调优的三个维度:

  1. 诊断工具链INFO MEMORYMEMORY STATSMEMORY USAGE--bigkeys,从粗到细定位问题
  2. 编码优化:合理设置 *-max-*listpack-entries 参数,让大部分数据使用紧凑编码。每 100 万 key 切到 hashtable 编码多占 ~64MB 的额外开销
  3. 碎片治理:低峰期开启 activedefrag,监控 mem_fragmentation_ratio,超过 2.0 就干预。先调 active-defrag-cycle-min 5 观察延迟变化,再逐步上调

生产避坑三条:maxmemory 一定要设,留 30% 余量给复制缓冲区和 AOF;maxmemory-policy 不要用 noeviction,缓存场景用 allkeys-lruallkeys-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

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。