Skip to content

内存模型与编码切换:内存都花在哪了

本文是 Redis 系统学习系列的 L2 核心篇。前置:29. 数据结构拆解:SDS、Dict、Listpack 与 Skiplist。 学完可以配合面试题食用:10-big-key-problem-detection-mitigation24-redis-memory-analysis-optimization

一个 key 到底占多少内存

新手常犯的一个错误:以为存一个 key "hello" value "world" 只占 10 字节。实际上 Redis 里的开销远不止这些。

一个 key-value 对落地到内存,至少包含四层:

  1. redisObject:每个 value 用 redisObject 包装(16 字节 + 引用计数 + LRU 等元数据)
  2. 键值本身的 SDS:key 和 value 各自有自己的 SDS 头
  3. dict entry:哈希表中的一个条目(两个指针 + 下一个冲突指针,约 24 字节)
  4. jemalloc 对齐:Redis 默认用 jemalloc 分配器,实际分配按 2/4/8/16/32... 的幂次对齐,会有浪费

MEMORY USAGE 命令就能看到真实开销,不是靠感觉估的。

127.0.0.1:6379> SET hello world
OK
127.0.0.1:6379> MEMORY USAGE hello
(integer) 72

一个 key 20 字节、value 5 字节的键值对,实际占了 72 字节——其中 47 字节是元数据和结构开销。这就是为什么 Redis 不适合存大量小对象,结构开销会淹掉数据本身。

编码切换:Redis 的"省内存三板斧"

Redis 在 value 不大时,不会用完整的数据结构,而是用更紧凑的编码。这就是 OBJECT ENCODING 看到的内容。

编码切换全景表

类型低阈值编码高阈值编码切换条件
Hashlistpackhashtablehash-max-listpack-entries 512 + hash-max-listpack-value 64
ZSetlistpackskiplistzset-max-listpack-entries 128 + zset-max-listpack-value 64
Setintsethashtableset-max-intset-entries 512
Listquicklistquicklist(listpack 节点,不切换编码,但压缩深度可配)

关键性质:编码切换是不可逆的。一旦从 listpack 升级到 hashtable,即使删到只剩 1 个元素,也不会降级回 listpack。这叫"只升不降"——因为降级扫描成本太高。

实验:观察编码切换

127.0.0.1:6379> HSET user:1 name "tom" age "25"
(integer) 2
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack"

写 513 个字段(超过了默认 512 阈值),编码就变了:

bash
# 用脚本批量插入
for i in $(seq 1 513); do
  redis-cli HSET user:big f$i v$i
done
redis-cli OBJECT ENCODING user:big
# "hashtable"

hashtable 的每个 entry 额外消耗 24+ 字节,对几百个字段来说,listpack 比 hashtable 省 40% 以上。

内存碎片率:你该关注哪个指标

INFO memory 里有个 mem_fragmentation_ratio,计算公式很简单:

mem_fragmentation_ratio = used_memory_rss / used_memory
  • > 1.5:碎片率高。可能原因:频繁创建/删除 key(特别是字符串/小对象)、jemalloc 与对象大小不匹配。可用 activedefrag yes 开启主动碎片整理。
  • < 1.0:比系统分配还少?这不是好事,说明部分内存被 swap 出物理内存了,RSS 比已用量还低,是严重信号。
  • 1.0 ~ 1.5:正常范围,不用管。

碎片率过高的典型场景:一个业务每 5 分钟创建一批临时 key 并在 10 分钟后过期,持续大量 allocate/free,jemalloc 的 slab 缓存里塞满了空洞。

bigkey 的内存放大效应

一个百万字段的 Hash 会吃掉多少内存?我们来算一笔账。

假设 Hash 的 key 是 10 字节,value 是 20 字节:

  • 每个 dict entry:24 字节(2 指针 + 冲突指针)
  • 每个 key 的 SDS:16 头 + 10 数据 = 26(对齐到 32)
  • 每个 value 的 SDS:16 头 + 20 数据 = 36(对齐到 40)
  • 一个 redisObject:16 字节
  • 单个字段:24 + 32 + 40 + 16 = 112 字节(约)
  • 100 万字段:约 112 MB

加上 Hash 本身的 dict 表(大约 2 的幂,1M 字段约 2^20 = 1,048,576 个槽,每个 8 字节指针)≈ 8 MB。

所以 100 万字段的 Hash 大概在 120 MB 左右。更大的问题是取一条数据也慢——HGETALL 要遍历整个结构,序列化加网络传输,一次操作可能卡几百毫秒。

拆分方案:按 field 的 hash 值分桶,比如分成 128 个 Hash:

java
public class HashSharding {
    private final RedisTemplate<String, Object> redis;
    private static final int SHARD_COUNT = 128;

    public void hset(String key, String field, String value) {
        String shard = shardKey(key, field);
        redis.opsForHash().put(shard, field, value);
    }

    public Map<Object, Object> hgetAll(String key) {
        Map<Object, Object> result = new HashMap<>();
        for (int i = 0; i < SHARD_COUNT; i++) {
            String shard = key + ":" + i;
            result.putAll(redis.opsForHash().entries(shard));
        }
        return result;
    }

    private String shardKey(String key, String field) {
        int shard = Math.abs(field.hashCode()) % SHARD_COUNT;
        return key + ":" + shard;
    }
}

分桶后每个 Hash 只有约 1/128 的字段,HGETALL 单次操作不到 1 MB,避免了大 key 阻塞。

容量估算练习

问题:100 万条 key=20 字符、value=100 字符的字符串,大概占多少内存?

逐层算:

  • key 的 SDS:sdshdr8(16 头 + 长度 20 + 1 结束符)= 37,jemalloc 对齐到 48
  • value 的 SDS:sdshdr8(16 头 + 100 + 1)= 117,对齐到 128
  • redisObject:16 字节,对齐到 16
  • dict entry:24 字节,对齐到 32
  • dict 指针表:100 万条(负载因子约 1 时,表大小 2^20 = 1,048,576 个 slot,每个 8 字节)≈ 8 MB

总和:每个 key 约 48 + 128 + 16 + 32 = 224 字节,100 万条 ≈ 224 MB,加上 dict 表 ≈ 232 MB。

INFO memory 验证:

# 实际跑 100 万条后
# used_memory: 243,500,000 (约 232 MB,跟估算一致)

常见误区与小结

  • 误区 1:MEMORY USAGE 等于 value 大小。实际上它返回的是 redisObject + SDS + 编码内部结构的总和,不等于 value 长度。
  • 误区 2:编码切换可以降级。升上去就降不下来,设计时要用压测确认阈值不会在生产被突破。
  • 误区 3:碎片率 > 1.5 就开 activedefrag。activedefrag 本身会消耗 CPU(在后台线程扫描),低峰期才开,线上高峰期建议先扩实例缓解。
  • 误区 4:小对象省内存。小对象的元数据占比极高,一个 10 字节的 key + 10 字节的 value 实际占 70+ 字节,70% 是结构开销。批量用 Hash 或 String 分段更省。
  • 误区 5:bigkey 只影响查询。bigkey 在主从复制时全量同步的 RDB 传输、AOF 重写时也会放大内存和 CPU 开销。

小结:内存是 Redis 最贵的资源(没有之一),理解 redisObject → SDS → dict entry → jemalloc 对齐这四层开销,才能做准容量估算,避免 bigkey 和碎片率失控。下一篇进入 31. 事件循环与线程模型,看单线程为什么快、多线程 IO 又解决了什么问题。

参考

参考:Redis 官方文档 Memory Optimization — 编码切换的完整阈值表 参考:Redis 源码 object.c — redisObject 定义与 OBJ_ENCODING_* 常量 参考:jemalloc 官方文档 — 对齐规则与 arena 策略

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1