Skip to content

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 8 默认:字段数 ≤ 512 且每个值 ≤ 64 字节时使用 listpacklistpack连续内存,顺序查找hashtable哈希表 + 每个字段独立对象超过阈值500 字段:7,921 字节约 15.8 字节/字段600 字段:28,562 字节约 47.6 字节/字段编码转换是单向的:降到阈值以下也不会变回 listpack
图 1 · 同样的字段,500 个字段时是紧凑的 listpack,600 个字段时变成 hashtable,每个字段的内存开销涨到约 3 倍

Redis 对每种结构都准备了「小对象紧凑存储、大对象换高效结构」两套实现。在每个默认阈值的两侧各放一个对象,实测编码与 MEMORY USAGE:

text
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-entries512hashtable
hash-max-listpack-value64 字节hashtable
zset-max-listpack-entries128skiplist
set-max-intset-entries512全是整数时的上限,超过后按元素数选 listpack 或 hashtable
set-max-listpack-entries128hashtable
list-max-listpack-size-2(按 8KB 限制)quicklist

阈值是可以改的配置,改过的实例要以 CONFIG GET 的结果为准。

实测跨过阈值的代价:

text
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 key100,0007.05MB
每 500 条一个 Hash(key 为 user:h:{id/500},listpack)2002.47MB
每 1000 条一个 Hash(超过 512,变成 hashtable)1004.61MB

每 500 条一个 Hash 节省了 65%。原因是每个 key 在 Redis 里都有固定开销:全局字典中的条目、对象头、过期字典中的条目(如果设置了 TTL)。把 500 个 key 合并进一个 Hash,这部分开销被摊薄了。第三行说明分桶的大小不能超过阈值:每桶 1000 条时变成 hashtable,收益缩小到 35%。

代价也很实际:

  • 不能给单个字段设置 TTL,只能整个 Hash 一起过期;
  • 分桶大小要控制在 listpack 阈值内,否则失去紧凑编码的收益;
  • 单个 Hash 太大时会变成大 key,读写延迟和迁移都会受影响。

这个技巧适合「数据量大、单条很小、整体一起过期」的场景,比如映射关系表、配置字典。

4.2 计数场景选对结构 ​

一百万个用户 ID 的三种存法实测:

Set(精确成员)可以判断某个 ID 在不在,也能取交并差32.3 MBBitmap(精确计数)ID 必须是连续整数,按位存储131 KBHyperLogLog(近似计数)只能算总数,实测 1,009,972,误差 1.0%14 KBRedis 8.10.1 实测,MEMORY USAGE 返回值
图 2 · 需要精确成员判断才用 Set;只判断「在不在」用 Bitmap;只要一个近似总数用 HyperLogLog(横轴为对数刻度)
结构内存能回答的问题误差
Set32.3MB某个 ID 在不在、交并差集无
Bitmap131KB某个 ID 在不在、总数无,但 ID 必须是连续整数
HyperLogLog14.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)或元素很多(几十万)。它的问题不只是占内存:

text
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 的方式:

bash
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 内存满了会怎样。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权