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 的区别就是精确和省的取舍。