Redis Set 如何节省内存?从整数集合到哈希表的设计取舍

Set 是最能体现"编码就是省内存"的类型。同一个 SADD 出来的集合,底层可能是三种完全不同的结构,OBJECT ENCODING 分别返回 intset、listpack、hashtable,从最省的整数数组到最费的哈希表。

差距有多大:一个存了 100 万个整数的 Set,用 intset 大概 4MB,用 hashtable 要几十 MB。多出来的部分是每个元素的 dictEntry、robj 和独立的 key 对象。这篇讲的就是这三种结构之间怎么选、怎么切。

intset

intset(整数集合)专门给"全是整数"的集合用,结构很简单:

c 复制代码
typedef struct intset {
    uint32_t encoding;   // 每个元素几字节:INTSET_ENC_INT16 / INT32 / INT64
    uint32_t length;     // 元素个数
    int32_t contents[];  // 真正的内容,宽度由 encoding 决定
} intset;

contents 声明成 int32_t[],但实际怎么解释由 encoding 说了算:INT16 就当 2 字节一个读,INT32 当 4 字节,INT64 当 8 字节。这是 C 里柔性数组按类型解读的常见写法。

有序

contents 里的元素从小到大排好序。

有序带来两个好处。判断元素在不在,可以二分查找,O(log n),不用从头扫。两个 intset 做交集的时候,可以像归并那样双指针走一遍,O(n + m)。这两件事在集合运算里都用得上。

代价在插入。往有序数组里插一个数,找到位置后要把后面所有元素整体往后挪,memmove 是 O(n)。不过 intset 有个 512 的上限,超过就换编码,规模不大,这个代价认了。

宽度升级

encoding 有三档,占的字节分别是 2、4、8,存哪些数用什么档,取决于最大那个数要多宽。

shell 复制代码
127.0.0.1:6379> SADD s 1 2 3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING s
"intset"

最大是 3,INT16 就够(范围 -32768 到 32767),整个数组按 2 字节一个存。现在塞一个超过范围的数:

shell 复制代码
127.0.0.1:6379> SADD s 100000
(integer) 1

100000 超过 32767,INT16 装不下。整个 intset 升级成 INT32,原来那三个元素 1、2、3 也得从 2 字节改写成 4 字节,数组重新分配、逐个搬过去。之后再插 INT16 范围内的数,也还是按 INT32 存,不会退回去。

宽度升级是"整个数组一起换大一档",不是"只把这一个大数单独存成宽的"。数组要求同质,一个元素要宽,所有元素都得跟着宽。这就是升级要 O(n) 的原因,也是它不降级的原因。

为什么不干脆一开始就用 INT64?因为绝大多数计数、ID 场景,数值都在 INT16 或 INT32 范围里。一律 INT64,每个元素都占 8 字节,比按需升级多好几倍。按需升级把"平时省"和"偶尔扩"平衡了一下,代价是偶尔要付一次 O(n) 的升级。

listpack 和 hashtable

intset 只认整数,集合里一出现字符串就用不了它:

shell 复制代码
127.0.0.1:6379> SADD tags redis
(integer) 1
127.0.0.1:6379> OBJECT ENCODING tags
"listpack"

redis 不是整数,集合退到 listpack(7.2 之前没有这一档,直接退到 hashtable)。listpack 是一块连续内存,元素挨着存,比哈希表省,结构见List 那篇。

元素再多,或者某个元素太长,listpack 也撑不住,就换成 hashtable。到这一步,每个元素在哈希表里都是一个独立节点,要付 dictEntry 加 robj 的开销,一个元素几十字节,这是最费的形态。

编码转换

Set 选编码的判断顺序是这样:

text 复制代码
1. 全整数,且元素数 <= set-max-intset-entries( 默认 512 )
        → intset

2. 元素数 <= set-max-listpack-entries( 默认 128 )
   且每个元素 <= set-max-listpack-value( 默认 64 字节 )
        → listpack

3. 都不满足 → hashtable

这里有个容易绕的地方:intset 的上限是 512,listpack 的上限是 128。512 比 128 大,所以一个纯整数的集合一旦突破 512,元素数早就超过 listpack 的 128 上限了,它不会绕道 listpack,直接落到 hashtable。listpack 那一档其实是留给"含字符串、但元素不多"的集合的。

触发 转换
往 intset 里插入非整数,且不超过 128 个 intset → listpack
往 intset 里插入非整数,且已超过 128 个 intset → hashtable
intset 元素数超过 512 intset → hashtable
listpack 元素数超过 128 或元素超过 64 字节 listpack → hashtable

这条链是单向的。升上去之后,删元素、删掉那个大整数,编码也不会退回来,和第 1 篇里说的一样,转换检查只在写入路径上做。


交并差集

Set 的核心价值在集合运算,SINTER、SUNION、SDIFF 三个命令,复杂度差别不小。

SINTER

交集最典型,两个人各关注了 1000 个人,求共同关注:

shell 复制代码
SINTER user:1:follow user:2:follow

SINTER 的复杂度是 O(N × M),N 是最小的那个集合的元素数,M 是参与运算的集合个数。

实现方式是:先挑出基数最小的那个集合,遍历它的每个元素,拿这个元素去其他每一个集合里查存不存在,全都存在才留下。

为什么挑最小的那个当基准?因为遍历的代价正比于被遍历集合的大小,挑最小的能让外层循环最短。每个元素去其他集合里查一次,查 M - 1 次,所以是 N × M。

这里还有一层:每次查找的成本取决于被查集合的编码。被查的是 intset,二分 O(log n);是 listpack,线性 O(n);是 hashtable,哈希 O(1)。所以两个全是整数的集合做交集,会比两个含字符串的集合明显快,即使元素数一样。

SINTER 卡的不是"集合有多大",而是"最小那个集合有多大",外加"其余集合用的是什么编码"。线上如果一个交集命令很慢,先看那个最小的集合是不是其实是个大 hashtable。

SUNION 和 SDIFF

SUNION(并集)和 SDIFF(差集)都是 O(N),N 是所有参与集合的元素总数。因为结果里每个元素都得被访问到一次,不管怎么优化都躲不掉。

SDIFF 是"第一个集合里有、后面集合里都没有"的元素,注意第一个集合的位置很重要,SDIFF a b 和 SDIFF b a 结果不一样。

带 STORE 和 CARD 的变体

text 复制代码
SINTERSTORE dest key [key ...]          交集结果存到 dest
SUNIONSTORE dest key [key ...]          并集结果存到 dest
SDIFFSTORE  dest key [key ...]          差集结果存到 dest
SINTERCARD numkeys key ... LIMIT n      只算交集元素个数,不返回元素(7.0+)

SINTERCARD 是 7.0 加的,适合"只想知道共同好友有多少个"而不需要具体名单的场景,配上 LIMIT 还能在数够 n 个之后提前退出,不用算完整个交集。


应用场景

去重。 点赞的用户集合、已中奖名单、文章的已读用户,SADD 自动去重,SISMEMBER 判断在不在。元素是整数 ID 的时候用 intset,省内存。

关系运算。 共同关注用 SINTER,可能认识的人用 SDIFF(我关注的减去我关注的人关注的),标签筛选用 SINTER。

抽奖。 SRANDMEMBER key n 随机取 n 个但不动集合,SPOP key n 取出来并从集合删掉。抽奖一般用前者,保证一个人能重复参与;发红包这种要避免重复的用后者。

精确计数。 一个 Set 的 SCARD 就是精确的唯一值个数。但如果只是为了数 UV,1000 万用户用一个 Set 要几百 MB,这种情况换 HyperLogLog,12KB 就能给出 0.81% 误差内的估算,见《签到、UV、附近的人》那篇。Set 和 HyperLogLog 的区别就是精确和省的取舍。

相关推荐
无名猿1 小时前
map / set 完全指南:红黑树与有序容器
数据结构·c++·性能优化·stl·标准库
ly76891 小时前
Spring Boot 集成 Redis 企业级实践:连接池、序列化与缓存穿透雪崩的工程化防御
spring boot·redis·缓存·缓存穿透·布隆过滤器·lettuce 连接池
All for pursuit.1 小时前
【回溯-3】39.组合总和
数据结构·c++·算法·leetcode
不会就选b2 小时前
算法日常・每日刷题--<动态规划>6
数据结构·算法·leetcode
漂流瓶jz2 小时前
UVA-12545 比特变换器 题解答案代码 算法竞赛入门经典第二版
数据结构·算法·题解·aoapc·算法竞赛入门经典·uva·12545
x-Achan2 小时前
Redis实现方案:更新策略、穿透、雪崩、击穿、工具封装
java·spring boot·redis·spring·bootstrap·mybatis
吹什么轩3 小时前
数据结构复习:二叉搜索树
数据结构·算法
H.莓飛3 小时前
【数据结构】堆
linux·数据结构·算法
H.莓飛15 小时前
【数据结构】队列
linux·数据结构·centos