Redis 数据结构与编码:同样的数据,内存可以差一倍
存 10 万条用户信息,用 10 万个独立 key 要多占 7.05MB,按 Hash 分桶只多占 2.47MB;统计一百万个用户 ID,Set 要 32MB,HyperLogLog 只要 14KB。Redis 的内存开销更多取决于结构和编码的选择,而不是数据本身的大小。
本文的实验都在 Redis 8.10.1(Docker 容器,关闭持久化)上完成,内存数字来自 MEMORY USAGE 和 INFO memory,脚本与原始输出见文末「配套实验」。
一、先说结论
- 选结构先看访问方式:要不要按成员判断、要不要范围查询、要不要排序、能不能接受近似值。
- 同一种结构有多种底层编码。 小对象用紧凑的
listpack、intset,超过阈值后转成hashtable、skiplist。实测 Hash 从 500 字段到 600 字段,每个字段的内存开销从 15.8 字节涨到 47.6 字节。 - 编码转换是单向的,元素减少回阈值以内也不会变回紧凑编码。
- key 本身有固定开销。 把大量小对象合并成 Hash 分桶,实测 10 万条数据新增的内存从 7.05MB 降到 2.47MB。
- 计数类需求不要默认用 Set:判断「在不在」用 Bitmap,只要总数用 HyperLogLog,实测内存相差两个数量级。
- 删除大 key 用
UNLINK:实测DEL一个百万元素的 Set 阻塞了 37.1ms,UNLINK只用 12 微秒。
二、五种基本结构怎么选
| 结构 | 适合的问题 | 典型用法 | 注意 |
|---|---|---|---|
| String | 单个值的读写、计数 | 缓存对象的 JSON、INCR 计数器、分布式锁的 key | 值太大时网络和内存都吃紧 |
| Hash | 一个对象的多个字段 | 用户资料、商品属性、按字段局部更新 | 不能给单个字段设置过期时间 |
| List | 有序、可从两端操作 | 简单队列、最近浏览记录 | 按下标随机访问是 O(N) |
| Set | 成员判断与集合运算 | 标签、去重、共同好友 | 元素多时内存开销大 |
| Sorted Set | 按分数排序、范围查询 | 排行榜、延时队列、滑动窗口限流 | 每个元素多一份分数的开销 |
还有几种专用结构值得记住:Bitmap(按位的布尔值,签到、活跃标记)、HyperLogLog(近似基数统计)、Stream(只追加的日志,带消费组和确认机制,和 List 做的简单队列不是一回事)、GEO(地理位置,底层是 Sorted Set)。
三、底层编码:同一种结构的两副面孔
Redis 对每种结构都准备了「小对象紧凑存储、大对象换高效结构」两套实现。在每个默认阈值的两侧各放一个对象,实测编码与 MEMORY USAGE:
Hash 512 个字段 listpack 8,113 字节
Hash 513 个字段 hashtable 25,604 字节
Hash 字段值 64 / 65 字节 listpack → hashtable
ZSet 128 / 129 个成员 listpack(945 字节)→ skiplist(10,584 字节)
Set 512 / 513 个整数 intset(1,059 字节)→ hashtable(18,938 字节)
Set 128 / 129 个字符串 listpack(692 字节)→ hashtable(4,859 字节)
List 100 / 2000 个元素 listpack → quicklist
String 12345 int 26 字节
String 100 字节 raw 138 字节Redis 8.10.1 的默认阈值(CONFIG GET 实测):
| 配置 | 默认值 | 超过后的编码 |
|---|---|---|
hash-max-listpack-entries | 512 | hashtable |
hash-max-listpack-value | 64 字节 | hashtable |
zset-max-listpack-entries | 128 | skiplist |
set-max-intset-entries | 512 | 全是整数时的上限,超过后按元素数选 listpack 或 hashtable |
set-max-listpack-entries | 128 | hashtable |
list-max-listpack-size | -2(按 8KB 限制) | quicklist |
阈值是可以改的配置,改过的实例要以 CONFIG GET 的结果为准。
实测跨过阈值的代价:
500 字段的 Hash:encoding=listpack memory=7,921 字节 (约 15.8 字节/字段)
600 字段的 Hash:encoding=hashtable memory=28,562 字节 (约 47.6 字节/字段)listpack 是一段连续内存,没有指针开销,代价是查找要顺序扫描——元素少时这反而更快,因为对 CPU 缓存友好。hashtable 每个字段都是独立的对象,多出指针、元数据和内存碎片的开销。
编码只升不降:把 600 字段删回 500 个,编码仍然是 hashtable,占 25,162 字节。所以设计时就要估好单个对象的规模,而不是指望它「瘦回去」。
四、省内存的三种做法
4.1 把小对象合并成 Hash 分桶
10 万条 user:{id} -> value(值 16 字节)的三种存法实测,统计的是写入前后 used_memory 的差值:
| 存法 | key 数量 | 新增内存 |
|---|---|---|
| 每条一个 String key | 100,000 | 7.05MB |
每 500 条一个 Hash(key 为 user:h:{id/500},listpack) | 200 | 2.47MB |
| 每 1000 条一个 Hash(超过 512,变成 hashtable) | 100 | 4.61MB |
每 500 条一个 Hash 节省了 65%。原因是每个 key 在 Redis 里都有固定开销:全局字典中的条目、对象头、过期字典中的条目(如果设置了 TTL)。把 500 个 key 合并进一个 Hash,这部分开销被摊薄了。第三行说明分桶的大小不能超过阈值:每桶 1000 条时变成 hashtable,收益缩小到 35%。
代价也很实际:
- 不能给单个字段设置 TTL,只能整个 Hash 一起过期;
- 分桶大小要控制在 listpack 阈值内,否则失去紧凑编码的收益;
- 单个 Hash 太大时会变成大 key,读写延迟和迁移都会受影响。
这个技巧适合「数据量大、单条很小、整体一起过期」的场景,比如映射关系表、配置字典。
4.2 计数场景选对结构
一百万个用户 ID 的三种存法实测:
| 结构 | 内存 | 能回答的问题 | 误差 |
|---|---|---|---|
| Set | 32.3MB | 某个 ID 在不在、交并差集 | 无 |
| Bitmap | 131KB | 某个 ID 在不在、总数 | 无,但 ID 必须是连续整数 |
| HyperLogLog | 14.4KB | 只有总数 | 实测 1,009,972,误差 1.0% |
选择顺序很清楚:需要成员运算用 Set;ID 是连续整数、只要判断和计数用 Bitmap(SETBIT / BITCOUNT);只要一个「大概多少」的数字用 HyperLogLog(PFADD / PFCOUNT),比如日活、页面 UV。
Bitmap 有个前提要注意:SETBIT 会直接分配到该偏移量所需的空间。实测只在偏移量 1 亿处设置一位,这个 key 就占了 12.6MB。用稀疏的大 ID(如雪花 ID)做偏移量会浪费大量内存,通常需要先把业务 ID 映射成连续序号。
4.3 控制 value 的大小
- 缓存对象时只存业务需要的字段,不要把整个实体序列化进去;
- 用更紧凑的序列化方式(如 Protobuf)替代 JSON;
- 短字符串用
embstr编码,一次内存分配搞定,比raw更省。常见的「44 字节以内」只适用于旧版本:Redis 8 把 key 和短值放进同一块内存,值的上限随 key 变长而下降。实测 key 为user:1001(9 字节)时,值不超过 32 字节才是embstr;key 为 26 字节时只剩 15 字节。
五、大 key 与热 key
大 key 指单个 key 的 value 很大(几 MB)或元素很多(几十万)。它的问题不只是占内存:
DEL 一个 100 万元素的 Set: 37.1ms(5 次中位数,36.9—41.2ms)
UNLINK 同样大小的 Set: 12 微秒以上是 SLOWLOG 记录的服务端执行时间。Redis 的命令在主线程上依次执行,一条 DEL 阻塞 37ms,意味着这段时间内所有其他请求都在排队。UNLINK 只是把 key 从键空间移除,真正的内存释放交给后台线程,所以几乎瞬间返回;后台释放仍然消耗 CPU。删除大 key 用 UNLINK,清空数据库用 FLUSHALL ASYNC。也可以打开 lazyfree-lazy-user-del(默认 no),实测打开后 DEL 同样只用 12 微秒,适合改不动调用方代码的情况。
排查大 key 的方式:
redis-cli --bigkeys # 采样扫描,给出每种类型最大的 key
redis-cli --memkeys # 按内存占用采样
redis-cli MEMORY USAGE key # 单个 key 的精确占用热 key 指单个 key 的访问量极高,集群模式下会让某个分片成为瓶颈。常见处理方式:在应用侧加本地缓存(见 多级缓存、预热与热点数据),或者把一个热 key 拆成多个副本 key,读取时随机选一个。
六、常见误区
- 「Redis 是单线程所以慢」:命令在主线程上依次执行,避免了锁竞争;网络 I/O 在 6.0 起可以多线程处理,持久化和 lazy free 也在子进程或后台线程里完成。瓶颈通常在网络往返和大 key,不在 CPU,排查方法见 Redis 为什么突然变慢。
- 「用 Hash 一定比 String 省」:字段数超过阈值转成
hashtable后,优势会缩小甚至反转。 - 「编码会自动优化回去」:转换是单向的。
- 「
KEYS只是查一下不影响」:它在一条命令里遍历整个键空间,期间其他请求都在排队。SCAN把遍历拆成多次小调用,但它不是快照:遍历期间一直存在的元素保证会返回,期间新增或删除的元素可能返回也可能不返回,同一个元素还可能返回多次,调用方要能处理重复。 - 「设置了
EXPIRE内存就会自动回收」:过期 key 采用惰性删除加定期采样删除,内存不会在到期瞬间释放。
小结
Redis 的内存开销取决于三件事:选了什么结构、触发了什么编码、有多少个 key。设计缓存时先问清楚要回答什么问题——精确成员判断、存在性、还是近似计数,再据此选结构;然后估算单个对象的规模,让它落在紧凑编码的阈值内;最后注意 key 数量本身的开销,必要时用 Hash 分桶合并。
内存写满之后会发生什么、淘汰策略怎么选,见 Redis 内存满了会怎样。
配套实验
- codesphere-labs/cache/redis-data-structures-memory:编码阈值与 embstr 分界、Hash 跨阈值与分桶、三种计数结构、DEL 与 UNLINK 的服务端耗时(验证记录)
参考资料