《Redis 8.10.3 深度剖析》系列 ·第 13 章 Set:三种编码、集合运算与随机采样
Set 是 Redis 里唯一一个"三种编码之间没有明确优先级"的类型。Hash 的 listpack 和 hashtable 是按大小分的,List 的 listpack 和 quicklist 是按大小分的,ZSet 的 listpack 和 skiplist 也是按大小分的------它们都是一条数轴上的两个刻度 。Set 不一样:intset 和 listpack 的边界不是大小,而是"元素是否全是整数",listpack 和 hashtable 的边界才是大小。于是出现了两条互不相交的判定路径,和一个"从 intset 出发但永远回不去"的单向拓扑。
这一章要回答的核心问题是:为什么需要一个专门的 intset,它比 listpack 好在哪、又付出了什么代价;SPOP/SRANDMEMBER 的两套采样策略(lpNextRandom 的蓄水池式单遍抽样、lpRandomEntries 的排序式重复抽样)分别解决什么问题;SINTERCARD 的 LIMIT 能提前退出而 SUNIONCARD 的 LIMIT 不能,原因在哪;以及 8.10 新增的 SUNIONCARD/SDIFFCARD 与 7.0 的 SINTERCARD 在实现上为什么走了两条完全不同的路。
1. 三个编码与三种"有序"
先说结论:Set 在语义上无序,但 intset 的内部表示严格有序 ,listpack 的内部表示按插入顺序 ,hashtable 的内部表示完全无序。这三种"有序性"没有一种是 Set 类型系统要求的,每一种都只为一个具体操作服务。
| 编码 | 内部顺序 | 查找 | 插入 | 遍历顺序可观察? |
|---|---|---|---|---|
intset |
按 int64 值严格升序 | 二分 O(log n) |
O(n)(要 memmove) |
是,永远升序 |
listpack |
按插入顺序,删除后保持剩余元素相对次序 | 线性 O(n) |
O(1) 追加 |
是,插入序 |
hashtable |
按桶散列 | 哈希 O(1) |
O(1) 均摊 |
否(且 rehash 期间会变) |
intset 的有序是二分查找的前提条件 ,不是副产物。为了让二分成立,每一次插入都必须把新值放到正确位置,而这个"放"的代价是 intsetMoveTail 的一次 memmove------平均搬一半元素。这是 intset 唯一的弱点,也是 set-max-intset-entries 存在的原因:n 个元素逐个插入的总代价是 O(n²) 字节搬运,n = 512 时约 512²/2 × 2 = 262 KB 的内存搬移,还不算慢;n = 100 万 时就是 1 TB,不可接受。
1.1 intset 的物理布局
c
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
src/intset.h:41-45 的这八行定义了全部。sizeof(intset) = 8(两个 uint32_t),contents 是柔性数组,实际元素按 encoding 指定的宽度紧密排列,没有 padding、没有空洞。
三个编码值是元素宽度本身,不是枚举:
c
#define INTSET_ENC_INT16 (sizeof(int16_t)) /* 2 */
#define INTSET_ENC_INT32 (sizeof(int32_t)) /* 4 */
#define INTSET_ENC_INT64 (sizeof(int64_t)) /* 8 */
src/intset.c:41-43。这个定义的后果是所有地址计算都变成"字节数 ÷ 宽度",而不需要查表:
c
static int64_t _intsetGetEncoded(intset *is, int pos, uint8_t enc) {
int64_t v64;
int32_t v32;
int16_t v16;
if (enc == INTSET_ENC_INT64) {
memcpy(&v64,((int64_t*)is->contents)+pos,sizeof(v64));
memrev64ifbe(&v64);
return v64;
} else if (enc == INTSET_ENC_INT32) {
...
src/intset.c:56-74。注意 ((int64_t*)is->contents)+pos ------ 指针算术自动乘了 8。这就是"编码值 = 宽度"换来的简洁。
三个编码值有序 (2 < 4 < 8)这件事也是刻意的,src/intset.c:39-40 的注释明说了:
Note that these encodings are ordered, so: INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64.
有序性让"是否需要升级"变成一个整数比较 valenc > is->encoding,不需要三路分支。
1.2 一个空 intset 是 8 字节
c
intset *intsetNew(void) {
intset *is = zmalloc(sizeof(intset));
is->encoding = intrev32ifbe(INTSET_ENC_INT16);
is->length = 0;
return is;
}
src/intset.c:98-103。zmalloc(8) 落在 jemalloc 的 8 字节 size class 上,zmalloc_usable_size 也是 8。
SADD k 1 创建的 intset 加一个元素后是 8 + 2 = 10 字节,jemalloc 放进 16 字节 class。作为对比,listpack 编码的空 Set 是 lpNew(0) = 7 字节(6 字节 header + 1 字节 EOF),落在 8 字节 class。所以单元素 的 Set 反而是 listpack 更省。
n 一涨就反过来了:n = 100 时 intset 是 8 + 200 = 208 字节,listpack 在"全 1 字节整数"时是 6 + 100×2 + 1 = 207 字节,与 intset 打平;一旦有字符串元素(假设 10 字节),每元素从 2 字节涨到 12 字节以上,listpack 迅速落后。
1.3 三种编码的切换拓扑
markdown
SADD 整数 超出 max_intset_entries
∅ ───────────────► intset ───────────────────────► HT
│ │
│ SADD 非整数 │ 遇到非整数值,且 listpack 装得下
└──────────────► listpack ◄────────────────────┘
│
│ 超出 max_listpack_entries / value
└────────────────────────────► HT
创建时的三条边:∅ → intset(首值是整数且 size_hint <= set-max-intset-entries)、∅ → listpack(首值非整数且 size_hint <= set-max-listpack-entries)、∅ → HT(size_hint 超过对应阈值)。
转换时的四条边:intset → HT(元素数超限,或遇到装不进 listpack 的非整数)、intset → listpack(遇到非整数且三个上限都容得下)、listpack → HT(元素数或长度超限)、HT → intset(只有 SINTERSTORE 的结果收尾会走,maybeConvertToIntset,不是通用降级路径)。
HT 永远不会主动降级。这与 List 的 LIST_CONV_SHRINKING 形成对比:List 有回程票(第 12 章 §10),Set 没有。原因是 Set 没有"收缩触发点"------SREM 从 200 个删到 3 个之后,代码不会检查"现在能不能降级"。sremCommand 里唯一的收缩动作是元素删空后的 dbDelete(src/t_set.c:680-686)。
2. intset 的二分:三次短路与无符号中点
intsetSearch 是 intset 全部性能的来源。它是 src/intset.c:123-162,不到 40 行,但每一段都有讲究。
c
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {
int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
int64_t cur = -1;
/* The value can never be found when the set is empty */
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
/* Check for the case where we know we cannot find the value,
* but do know the insert position. */
if (value > _intsetGet(is,max)) {
if (pos) *pos = intrev32ifbe(is->length);
return 0;
} else if (value < _intsetGet(is,0)) {
if (pos) *pos = 0;
return 0;
}
}
while(max >= min) {
mid = ((unsigned int)min + (unsigned int)max) >> 1;
cur = _intsetGet(is,mid);
if (value > cur) {
min = mid+1;
} else if (value < cur) {
max = mid-1;
} else {
break;
}
}
if (value == cur) {
if (pos) *pos = mid;
return 1;
} else {
if (pos) *pos = min;
return 0;
}
}
2.1 三次短路
三次提前返回,覆盖了"空集 / 插到末尾 / 插到开头"三种情况:
length == 0→ 返回pos = 0。value > get(max)→ 返回pos = length(插到末尾)。value < get(0)→ 返回pos = 0(插到开头)。
短路 2 和 3 的收益不是"省几次比较",而是让插入变成 O(1) 均摊 。回到 intsetAdd:
c
is = intsetResize(is,intrev32ifbe(is->length)+1);
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
src/intset.c:232-233。pos == length 时不调用 intsetMoveTail------直接追加。所以对一个递增序列 的 SADD k 1 2 3 ... n,每次插入都是:O(1) 判断(比一下最大值)+ O(1) 追加。整体 O(n),没有二次项。
这是 SADD 最常见的用法(用户 ID、时间戳、自增计数器),所以这个短路是整个 intset 能否实用的关键。如果把短路删掉,SADD k 1 2 3 ... 512 会退化成 512²/2 = 13 万次 2 字节搬移。
短路 3 的情况类似但少见:递减序列(SADD k 512 511 510 ...)每次插到开头,pos = 0 < length 恒成立,intsetMoveTail(is, 0, 1) 要搬全部元素。所以递减插入是 O(n²) ,递增插入是 O(n)。这个不对称没有任何规避手段------intset 的有序性要求新元素必然落在两端之一或中间,落在一端时只有"末尾"是免费的。
2.2 为什么中点要走无符号
c
mid = ((unsigned int)min + (unsigned int)max) >> 1;
如果写成 (min + max) / 2,当 min 和 max 都接近 INT_MAX 时,min + max 有符号溢出,是未定义行为。改成无符号加法后,(unsigned)min + (unsigned)max 在 32 位无符号范围内不会溢出(两个都 < 2^31,和 < 2^32),右移一位就得到正确的平均值。
这个写法在 C 里是标准做法,但 intsetSearch 里它还有一个额外的意义:它限制了 intset 的元素数上限。
max 的类型是 int,初值 intrev32ifbe(is->length)-1。length 是 uint32_t,如果它超过 INT_MAX + 1,赋给 int max 就会变成负数,循环直接不执行,然后 value == cur(cur 初值 -1)几乎必然为假,返回"没找到"。整个 intset 就废了。
所以理论上限是 length <= INT_MAX + 1 = 2^31。而 intsetMaxEntries() 取的是 1<<30,正好是上限的一半:
c
static size_t intsetMaxEntries(void) {
size_t max_entries = server.set_max_intset_entries;
/* limit to 1G entries due to intset internals. */
if (max_entries >= 1<<30) max_entries = 1<<30;
return max_entries;
}
src/t_set.c:70-75。注释说 "limit to 1G entries due to intset internals","internals" 指的就是这里:int min, max 的宽度,以及 intsetResize 里 (uint64_t)len * encoding 的地址计算。
1<<30 个 INT64 元素是 8 GB 连续内存。这个上限在实践中不是瓶颈------真到那个量级,用户应该用 hashtable。
2.3 pos 的出参语义
intsetSearch 的 pos 是"入参兼出参":找到时是元素下标,没找到时是插入点 。这个双语义让 intsetAdd 只调一次查找就够:
c
if (intsetSearch(is,value,&pos)) {
if (success) *success = 0;
return is;
}
is = intsetResize(is,intrev32ifbe(is->length)+1);
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
_intsetSet(is,pos,value);
查找结果直接当插入位置用。代价是调用方必须知道"返回 0 时 pos 是插入点、返回 1 时 pos 是命中点"------两种情况下 pos 都能安全地传给 _intsetSet,因为命中时不动、未命中时插在那里。
intsetRemove 复用同一个 pos 做删除起点:
c
if (valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,&pos)) {
src/intset.c:247。找到之后 intsetMoveTail(is,pos+1,pos) 把 pos 之后的元素前移一格覆盖掉它。
3. intsetAdd 的升级:为什么新值必然在两端
intsetAdd 的第一段是个判断:
c
if (valenc > intrev32ifbe(is->encoding)) {
/* This always succeeds, so we don't need to curry *success. */
return intsetUpgradeAndAdd(is,value);
}
src/intset.c:220-222。valenc > encoding 意味着"新值需要用更宽的编码",也就是 intsetUpgradeAndAdd。
这个判断依赖一条推理链:
升级发生 ⟺ 新值的绝对值超出了当前编码能表示的范围 ⟺ 新值比所有现有元素都大,或比所有现有元素都小。
因为现有元素全都能用当前编码表示,都在 [ENC_MIN, ENC_MAX] 区间内。新值需要更宽的编码,说明它不在这个区间内。不在区间内、且是一个点,那它只能在区间的外面 ------要么比 ENC_MAX 大,要么比 ENC_MIN 小。
所以升级后的插入位置是确定的:大的追加到末尾,小的插到开头 。不需要二分,不需要 memmove 一半。这就是 prepend 的由来:
c
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value);
int length = intrev32ifbe(is->length);
int prepend = value < 0 ? 1 : 0;
src/intset.c:165-169。prepend = value < 0 ? 1 : 0。
这个推理的严密性值得停一下:如果 value < 0 且需要更宽编码,说明 value < INT16_MIN(假设 curenc = INT16)------负数方向溢出。所有现有元素 >= INT16_MIN > value,所以 value 是最小的,插到下标 0。反过来 value > 0 且溢出,说明 value > INT16_MAX,插到下标 length。
不存在"新值是正数但比某些现有元素小"的情况 ,因为所有现有元素都 <= INT16_MAX < value。
3.1 倒序搬移
c
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is,intrev32ifbe(is->length)+1);
/* Upgrade back-to-front so we don't overwrite values.
* Note that the "prepend" variable is used to make sure we have an empty
* space at either the beginning or the end of the intset. */
while(length--)
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
src/intset.c:171-179。
先 intsetResize 到 (length+1) × newenc 字节。然后从后往前,把每个旧元素从 curenc 宽度读到 newenc 宽度写。
为什么要倒序?因为新宽度大于旧宽度,第 i 个元素的新地址 >= 旧地址 。如果正序写,写到第 i 个时会把第 i+1 个的旧数据覆盖掉,而那个旧数据还没读。
以 curenc = 2、newenc = 4、length = 3、prepend = 0 为例,旧数据占 [8,14)、新空间是 [8,24):
- 第一轮
length由 3 减到 2:读8+2×2 = 12,写8+4×2 = 16,不重叠。 - 第二轮
length = 1:读 10,写 12------写的 12 正是上一轮读过的位置,但已读完,覆盖无害。 - 第三轮
length = 0:读 8,写 8。C 保证实参先于调用求值,_intsetGetEncoded一定先于_intsetSet执行,所以读到的是旧值。
正序则相反:length = 0 先用 4 字节覆盖 [8,12),length = 1 再去读 10 已经是脏数据。
所以"倒序 + 参数先求值"两个条件合起来才安全。_intsetSet(is, i, _intsetGetEncoded(is, i, curenc)) 这个写法是安全的,但如果有人改成先算好指针再写,就可能出问题。
3.2 prepend 制造的"空位"
length+prepend 这个偏移量把"空位"推到正确的一端:
prepend = 0:写入位置0..length-1,空位在length。prepend = 1:写入位置1..length,空位在0。
然后:
c
if (prepend)
_intsetSet(is,0,value);
else
_intsetSet(is,intrev32ifbe(is->length),value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
src/intset.c:182-186。注意 is->length 在这里还是旧值 ------intsetResize 只改了分配大小,没改 length。所以 _intsetSet(is, is->length, value) 写的是旧 length 位置,也就是空位。
is->length = intrev32ifbe(intrev32ifbe(is->length)+1) 的双侧调用作用不同:内层读出主机字节序,外层写回小端存储。length 字段在内存里永远是小端,只有计算时转主机序。
3.3 升级后不能降级
intsetRemove 不检查"删完之后能不能降回更窄的编码":
c
is = intsetResize(is,len-1);
is->length = intrev32ifbe(len-1);
src/intset.c:255-256。encoding 字段一个字都不动。
后果是:SADD k 1 → INT16;SADD k 99999999999 → 升级到 INT64;SREM k 99999999999 → 仍是 INT64,每元素 8 字节而不是 2。一个 100 元素的 Set 插入一个大数再删掉,内存从 208 字节涨到 808 字节且永不回落。
这不是 bug。降级要"检查所有元素能否塞进更窄编码",是 O(n) 扫描,而 SREM 是 O(1) 均摊命令,为省几百字节给每次删除加 O(n) 不划算。更重要的是,允许降级会让 encoding 从"历史最大值的宽度"变成"当前最大值的宽度"------同一份数据在不同操作序列下有不同的内存布局和 MEMORY USAGE 输出,运维排查时会很困惑。
listpack 没有这个问题,因为它的"宽度"是每个元素自己的编码属性,不是全局状态。
4. intsetRemove 的前置类型检查
c
intset *intsetRemove(intset *is, int64_t value, int *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 0;
if (valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,&pos)) {
src/intset.c:242-247。valenc <= encoding 这个前置判据经常被忽略,但它省掉了一整次二分。
道理很简单:如果 value 需要比当前编码更宽的表示(valenc > encoding),那它不可能 存在于这个 intset 里------因为 intset 里所有元素都能用当前编码表示,值域是 [ENC_MIN, ENC_MAX],而 value 在区间外。
所以 SREM k 99999999999(针对一个 INT16 编码的 set)是一次整数比较就返回 0,不需要二分。
intsetFind 用同一个判据:
c
uint8_t intsetFind(intset *is, int64_t value) {
uint8_t valenc = _intsetValueEncoding(value);
return valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,NULL);
}
src/intset.c:262-265。SISMEMBER 在 intset 上的最坏路径由此变成"一次 _intsetValueEncoding 三路比较 + 一次二分"。
但注意 setTypeIsMemberAux 的 INTSET 分支还要先 string2ll:
c
} else if (set->encoding == OBJ_ENCODING_INTSET) {
long long llval;
return string2ll(str, len, &llval) && intsetFind(set->ptr, llval);
}
src/t_set.c:317-319。所以 SISMEMBER 一次要:string2ll 解析(可能失败)→ _intsetValueEncoding 三路比较 → 二分。三步都是 O(1) 或 O(log n)。
对比 hashtable 分支:一次 dictFind(哈希 + 链表/桶内比较)。intset 在 n = 512 时是 9 次比较,hashtable 是 1 次哈希 + 1 次比较。所以 intset 的查找不比 hashtable 快------它快在内存密度和缓存局部性上(512 个 2 字节元素占 1 KB,全在一个 cache line 组里)。
4.1 删除的 memmove
c
if (pos < (len-1)) intsetMoveTail(is,pos+1,pos);
is = intsetResize(is,len-1);
is->length = intrev32ifbe(len-1);
src/intset.c:254-256。删最后一个元素(pos == len-1)时不动内存,只收缩。删中间的元素要把后面的全部前移一格。
intsetMoveTail(is, from, to) 计算 bytes = (length - from) * encoding,然后 memmove(dst, src, bytes)。注意 bytes 用的是 length - from,也就是"从 from 到末尾"的全部元素数------包括被删的那个。
所以删下标 pos 时,intsetMoveTail(is, pos+1, pos) 搬的是 length - (pos+1) 个元素,正好是"被删元素之后的所有元素"。
memmove 而不是 memcpy:源和目标重叠(to = from - 1),memcpy 是 UB。
然后 intsetResize(is, len-1) 调 zrealloc 缩到 8 + (len-1)*encoding。这一步在 jemalloc 上是 rallocx,如果新大小还在同一个 size class,是 no-op;跨 class 才会真的搬。
所以 SREM 一个 512 元素的 INT16 set:
- 最坏
memmove1022 字节 zrealloc从 1032 缩到 1030,同在 1280 class,no-op
每删一个元素搬 1 KB 内存 。这是 intset 的删除代价,比 listpack 的 memmove 好一点(listpack 还要更新 backlen),但都是 O(n)。
5. setTypeCreate:第一个元素决定初始编码
c
robj *setTypeCreate(sds value, size_t size_hint) {
if (isSdsRepresentableAsLongLong(value,NULL) == C_OK && size_hint <= server.set_max_intset_entries)
return createIntsetObject();
if (size_hint <= server.set_max_listpack_entries)
return createSetListpackObject();
/* We may oversize the set by using the hint if the hint is not accurate,
* but we will assume this is acceptable to maximize performance. */
robj *o = createSetObject();
dictExpand(o->ptr, size_hint);
return o;
}
src/t_set.c:46-57。三岔,判据是"第一个值能否整数化"与 size_hint 分别和两个阈值比较。
默认值(src/config.c:3600-3602):
| 配置 | 默认 | 作用 |
|---|---|---|
set-max-intset-entries |
512 | intset 的元素数上限 |
set-max-listpack-entries |
128 | listpack 的元素数上限 |
set-max-listpack-value |
64 | listpack 单个元素的字节上限 |
注意 intset 的阈值(512)比 listpack 的(128)大四倍。这是因为 intset 的每元素成本是 2 字节(元素本身就是值),而 listpack 至少 2 字节(编码 + backlen)且字符串元素更贵。在 512 个整数时,intset 是 8 + 1024 = 1032 字节,listpack 至少 6 + 512×2 + 1 = 1031 字节------打平。超过 512,intset 就开始赢。
5.1 isSdsRepresentableAsLongLong 只看第一个元素
setTypeCreate 的第一个参数名叫 value,实际传的是 c->argv[2]->ptr------SADD 的第一个成员。
c
if (set == NULL) {
robj *o = setTypeCreate(c->argv[2]->ptr, c->argc - 2);
set = dbAddByLink(c->db, c->argv[1], &o, &link);
}
src/t_set.c:635-637。所以 SADD k 1 a b c 先建 intset("1" 是整数),加 "a" 时触发 intset → listpack 转换。如果成员几乎全是字符串、只是恰好第一个是数字,就白建了一次 intset------但创建只是 zmalloc(8),转换时 n 通常还是 1,是常数开销。
真正的不对称在反方向:SADD k a 1 2 3 建的是 listpack 且永远不会转 intset (listpack 没有到 intset 的路径,只有 SINTERSTORE 的收尾会调 maybeConvertToIntset)。而 SADD k a 之后再 SREM k a,剩下的空集合仍是 listpack------删除不触发降级,直到键被删空重建。
所以精确的表述是:一旦某个 Set 用过 listpack 编码,它就再也不会回到 intset ,除非经过一次 SINTERSTORE。
5.2 dictExpand(o->ptr, size_hint) 的预分配
第三个分支:
c
robj *o = createSetObject();
dictExpand(o->ptr, size_hint);
return o;
dictExpand(d, size) 会算出 _dictNextExp(size)------不小于 size 的最小 2 的幂------然后一次性分配 ht_table[0]。所以 SADD k a b c ... 1000个 直接分配 1024 个桶的数组(1024 × 8 = 8 KB),之后 1000 次插入都不触发 rehash。
如果不预分配,从 4 个桶开始,每次 used >= size 就翻倍:4 → 8 → 16 → ... → 1024,共 8 次 rehash,每次搬一半。总计约 1000 次桶搬移。预分配把这个成本降到 0。
代价是如果 size_hint 不准(比如用户传了 1000 个但其中 900 个是重复的,实际只加了 100 个),就浪费了 900 个桶的空间。注释承认了这点:
We may oversize the set by using the hint if the hint is not accurate, but we will assume this is acceptable to maximize performance.
dictExpand 之后 dictShrinkIfNeeded 会在合适的时机缩回来(sremCommand 显式调用,见 §9.2)。所以浪费是临时的。
5.3 size_hint = c->argc - 2
SADD key v1 v2 ... vn 的 argc = n + 2,所以 size_hint = n------本次命令要加的元素数,不是"加完后的总数"。
这个语义在 setTypeMaybeConvert 里被利用(§6),但在 setTypeCreate 里它是字面意义的"我要加这么多个,请按这个大小预分配"。
SMOVE 里也调 setTypeCreate(ele->ptr, 1)(src/t_set.c:761),因为只加一个元素。
SINTERSTORE 不调 setTypeCreate,它自己按源集合的编码选 dstset 初值(§14)。
6. setTypeMaybeConvert:写前预判与三条阈值的错位
c
void setTypeMaybeConvert(robj *set, size_t size_hint) {
if ((set->encoding == OBJ_ENCODING_LISTPACK && size_hint > server.set_max_listpack_entries)
|| (set->encoding == OBJ_ENCODING_INTSET && size_hint > server.set_max_intset_entries))
{
setTypeConvertAndExpand(set, OBJ_ENCODING_HT, size_hint, 1);
}
}
src/t_set.c:61-67。调用点只有一处,在 saddCommand 里:
c
} else {
if (server.memory_tracking_enabled)
oldsize = kvobjAllocSize(set);
setTypeMaybeConvert(set, c->argc - 2);
...
}
src/t_set.c:638-644。
6.1 两个判据的语义错位
setTypeMaybeConvert 的 size_hint 是"本次要加的元素数",而阈值是"集合允许的总元素数"。两者不是同一个量纲。
但逻辑是成立的:
如果本次要加的元素数本身就超过了阈值,那么不管当前集合有多少元素,加完之后一定超阈值(因为现有元素只会让总数更大)。
所以这是一个充分条件,不是必要条件。判真了一定要转,判假了不一定不用转。
判假的情况交给 setTypeAddAux 里的写中判定:
c
if (lpLength(lp) < server.set_max_listpack_entries && ...)
src/t_set.c:165。这里用的是当前实际长度,是精确判据。
所以两条判据形成互补:
| 判据 | 位置 | 量纲 | 性质 |
|---|---|---|---|
| 写前 | setTypeMaybeConvert |
本次元素数 vs 阈值 | 充分不必要,为性能 |
| 写中 | setTypeAddAux |
当前长度 vs 阈值 | 精确,为正确性 |
写前判据的字面目的是避免"边加边转" :一个 126 元素的 listpack 收到 SADD k a1 ... a200,无判据的版本是"先 2 个走 lpFind(各 O(128))、第 3 个触发转换、剩下 198 个走 dictAdd",有判据的版本是"直接转、200 个全走 dictAdd"。差别只有 2 次 lpFind------listpack 这一支的收益其实很小。
真正拉开数量级的是 intset 那一支。SADD k 1 2 3 ... 1000 作用在一个已存在的 100 元素 intset 上:size_hint = 1000 > 512,直接转 HT。没有判据的话,1000 次 intsetAdd 每次都要 intsetSearch 加可能的 intsetMoveTail,是 O(1000 × 100) 的搬移;有判据则是一次 O(n) 转换加 1000 次 dictAdd。intset 插入 O(n)、hashtable 插入 O(1),这才是判据存在的理由------listpack 那一支只是顺带。
6.2 三处阈值的口径不一致
set-max-intset-entries 这个配置在三处被读,读法各不相同:
setTypeCreate(src/t_set.c:47):size_hint <= server.set_max_intset_entriessetTypeMaybeConvert(src/t_set.c:63):size_hint > server.set_max_intset_entriesmaybeConvertIntset(src/t_set.c:80):intsetLen(subject->ptr) > intsetMaxEntries()
前两处直接用配置值,第三处经过 intsetMaxEntries() 加了 1<<30 的上限。
后果:如果运维把 set-max-intset-entries 设成 LONG_MAX(表示"我不想让它转 HT"):
setTypeCreate的判据恒真 → 永远建intset。setTypeMaybeConvert的判据恒假 → 永不预先转 HT。maybeConvertIntset在 10 亿元素时转 HT。
所以"不想转"实际上是"转到 10 亿为止",不会真的无限增长。这是 intsetMaxEntries 的兜底作用。
但 setTypeMaybeConvert 没有兜底:如果一个 intset 已经有 10 亿元素(不可能,因为 maybeConvertIntset 会先转),再 SADD 一个,setTypeMaybeConvert 判假,然后 setTypeAddAux 里 intsetAdd 之后 maybeConvertIntset 判真,转 HT。所以最终还是会转,只是晚一步。
这个不一致是无害的,因为兜底路径总在后面。但它说明这三个函数不是同一个抽象的三个实现,而是三处各自为政的检查。改配置语义时容易漏掉一处。
6.3 set-max-listpack-value 没有写前判据
setTypeMaybeConvert 只检查数量,不检查元素长度。所以往一个 10 元素的 listpack 上 SADD k <100 字节字符串> 时,size_hint = 1 <= 128 不转,直到 setTypeAddAux 里因 len > set_max_listpack_value(64)才转 HT。
长度超限的判定只在写中发生 ,代价是每个 Set 一生只发生一次。真正的问题在混合长度 :一个 100 个短字符串的 listpack 插入一个 100 字节长字符串后转 HT,之后所有操作都按 HT 走,即使后续全是短字符串。没有回程票。
Hash 也有单向性,但它的 listpack 阈值有两道(entries 和 value),中间还有 LISTPACK_EX 这个态。Set 只有三道判据,没有中间态。
7. setTypeAddAux:六条路径与一个临时缓冲
setTypeAddAux 是 Set 的写入核心,src/t_set.c:126-236,111 行里有六条不同的路径。它的接口设计值得先看一眼:
c
int setTypeAddAux(robj *set, char *str, size_t len, int64_t llval, int str_is_sds);
三个"值"参数加一个标志:str(字符串或 NULL)、len、llval(整数值)、str_is_sds(str 是否已经是一个 sds)。这个签名是"零拷贝"和"零转换"的折衷:
str == NULL表示"值是整数llval",不需要任何字符串形式。str != NULL && str_is_sds == 0表示"str是一段临时缓冲,len字节"。str != NULL && str_is_sds == 1表示"str本身就是 sds",不需要拷贝。
三条信息覆盖了所有调用者的形态:命令层拿到的是 robj*(可能 INT 或 RAW/EMBSTR),迭代器拿到的是"字符串指针 + 长度"或"整数",HIMPORT 拿到的是 argv 的 sds。
7.1 路径一:整数 + intset,最短的一条
c
char tmpbuf[LONG_STR_SIZE];
if (!str) {
if (set->encoding == OBJ_ENCODING_INTSET) {
uint8_t success = 0;
set->ptr = intsetAdd(set->ptr, llval, &success);
if (success) maybeConvertIntset(set);
return success;
}
/* Convert int to string. */
len = ll2string(tmpbuf, sizeof tmpbuf, llval);
str = tmpbuf;
str_is_sds = 0;
}
src/t_set.c:127-139。如果调用方给的是整数、且集合是 intset,那整个插入就是一次 intsetAdd------没有字符串化、没有分配、没有哈希。
这是 intset 最理想的场景:SADD k 1 2 3 的每个参数,c->argv[i]->ptr 都是 robj,但 SADD 调用的是 setTypeAdd(set, c->argv[j]->ptr):
c
int setTypeAdd(robj *subject, sds value) {
return setTypeAddAux(subject, value, sdslen(value), 0, 1);
}
src/t_set.c:116-118。注意这里传的是 str = value(sds),llval = 0,str_is_sds = 1。它没有利用 robj 的 INT 编码。
所以 SADD k 1 2 3 实际走的是路径五(intset + 字符串):
c
} else if (set->encoding == OBJ_ENCODING_INTSET) {
long long value;
if (string2ll(str, len, &value)) {
src/t_set.c:186-188。每个参数都要 string2ll 一次。
真正走路径一的是迭代器 驱动的批量操作,比如 SINTERSTORE:
c
if (str != NULL)
addReplyBulkCBuffer(c, str, len);
else
addReplyBulkLongLong(c,intobj);
src/t_set.c:1504-1507。setTypeNext 对 intset 返回 str = NULL + llele,所以后续的 setTypeAddAux(dstset, str, len, intobj, ...) 传 str = NULL,走路径一。
SMOVE 不走路径一,它用 setTypeAdd(dstset, ele->ptr)(src/t_set.c:771),走路径五。所以路径一的调用者只有 setTypeNext 驱动的批量构建(SINTERSTORE/SUNIONSTORE/SDIFFSTORE/SPOP 的 CASE 3)和 setTypeRandomElement 驱动的去重采样(SRANDMEMBER 的 CASE 3/4)。
这是 intset 的隐藏优势:批量操作在 intset 编码下元素全程以 int64 流动,一次字符串化都没有。
7.2 路径二:整数 + 非 intset,tmpbuf 栈缓冲
c
len = ll2string(tmpbuf, sizeof tmpbuf, llval);
str = tmpbuf;
str_is_sds = 0;
src/t_set.c:136-138。tmpbuf 是 char tmpbuf[LONG_STR_SIZE],LONG_STR_SIZE = 21(20 位数字 + 终止符)。ll2string 写进栈,不分配堆;str_is_sds = 0 告诉后续路径"这个指针不能 sdsfree、也不能直接当 sds 存"。
栈缓冲的生命周期只到函数返回,所以路径三必须 sdsnewlen(str, len) 拷贝、路径四必须 lpAppend(lp, str, len) 拷贝。两条都拷贝,栈缓冲才安全。但路径四有一个捷径:
c
if (str == tmpbuf) {
/* This came in as integer so we can avoid parsing it again.
* TODO: Create and use lpFindInteger; don't go via string. */
lp = lpAppendInteger(lp, llval);
} else {
lp = lpAppend(lp, (unsigned char*)str, len);
}
src/t_set.c:169-175。如果 str 指针恰好等于 tmpbuf,说明这个值是从整数转过来的,可以直接 lpAppendInteger 走整数编码,不需要把字符串解析回整数。
指针比较当"来源标记"是省事的做法,依赖 tmpbuf 是函数内的局部数组、与任何外部指针都不相等。如果有人把这个函数重构成"复用调用方的缓冲",判据就失效了。
lpAppendInteger 与 lpAppend(str, len) 的差别只在编码选择:前者一定用整数编码,后者会先 lpStringToInt64 试一次(lpEncodeGetType),能转就用整数编码。所以对 "123" 两者结果一样,lpAppendInteger 只是省掉那次解析------在批量插入里是实打实的收益。
7.3 路径三:hashtable,dictFindLink + dictSetKeyAtLink
c
if (set->encoding == OBJ_ENCODING_HT) {
/* Avoid duping the string if it is an sds string. */
sds sdsval = str_is_sds ? (sds)str : sdsnewlen(str, len);
dict *ht = set->ptr;
dictEntryLink bucket, link = dictFindLink(ht, sdsval, &bucket);
if (link == NULL) {
/* Key doesn't already exist in the set. Add it but dup the key. */
if (sdsval == str) sdsval = sdsdup(sdsval);
dictSetKeyAtLink(ht, sdsval, &bucket, 1);
*htGetMetadataSize(ht) += sdsAllocSize(sdsval);
return 1;
} else if (sdsval != str) {
/* String is already a member. Free our temporary sds copy. */
sdsfree(sdsval);
return 0;
}
}
src/t_set.c:142-157。这段有四个要点。
一、不重复拷贝 :sdsval = str_is_sds ? (sds)str : sdsnewlen(str, len)。调用方给的是 sds(SADD 直接传 argv->ptr)就不拷,否则临时刻一个。
二、一次遍历拿两个结果 :dictFindLink(ht, sdsval, &bucket) 同时给出"这个 key 该在哪个桶"(bucket)和"找到了的话 entry 在哪"(link)。没找到时 bucket 直接喂给 dictSetKeyAtLink,不用重新哈希定位 。8.0 之前是 dictFind + dictAdd 两次查找,dictAdd 内部再算一次哈希再走一遍桶------对"大部分插入都是新元素"的 Set,等于把哈希成本翻倍。
三、所有权转移 vs 复制 :if (sdsval == str) sdsval = sdsdup(sdsval)。sdsval 就是调用方的 sds 时必须复制(dict 要接管所有权);如果它是刚 sdsnewlen 出来的一次性拷贝,所有权可以直接转移。判据写成 sdsval == str 而不是 str_is_sds,因为 tmpbuf 路径上 sdsval != str(堆分配),语义等价但更直接。
四、顺手记账 :*htGetMetadataSize(ht) += sdsAllocSize(sdsval),每插入一个元素累加它的 sds 分配大小(§11)。
7.4 路径四:listpack,三道上限
c
} else if (set->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *lp = set->ptr;
unsigned char *p = lpFirst(lp);
if (p != NULL)
p = lpFind(lp, p, (unsigned char*)str, len, 0);
if (p == NULL) {
/* Not found. */
if (lpLength(lp) < server.set_max_listpack_entries &&
len <= server.set_max_listpack_value &&
lpSafeToAdd(lp, len))
{
src/t_set.c:158-167。
三个条件全部满足才插入:
lpLength(lp) < set_max_listpack_entries------ 元素数还没到上限(128)。len <= set_max_listpack_value------ 单个元素不超过上限(64 字节)。lpSafeToAdd(lp, len)------ 加完之后不超过 1 GB(LISTPACK_MAX_SAFETY_SIZE)。
注意第 1 条用的是 < 不是 <=。所以 set_max_listpack_entries = 128 时,listpack 最多存 128 个元素(lpLength 从 0 涨到 127 时通过,128 时不通)。这里没有差一错误:lpLength == 127 时 < 128 成立,插入后变成 128;下次 lpLength == 128,< 128 不成立,转 HT。所以稳态最大值是 128。
第 3 条的 lpSafeToAdd:
c
#define LISTPACK_MAX_SAFETY_SIZE (1<<30)
int lpSafeToAdd(unsigned char* lp, size_t add) {
size_t len = lp? lpGetTotalBytes(lp): 0;
if (add > LISTPACK_MAX_SAFETY_SIZE || len > LISTPACK_MAX_SAFETY_SIZE - add)
return 0;
return 1;
}
src/listpack.c:126-132。1 GB 的上限来自 listpack header 里 total-bytes 字段的宽度(uint32_t)。超过就溢出。
注意这个检查是先判 add > MAX 再判 len > MAX - add ,避免 len + add 溢出。如果先算 len + add,两个都接近 2^30 时会得到 2^31,在某些平台上仍不溢出但语义已经错了。
第 3 条在 set-max-listpack-entries = 128、set-max-listpack-value = 64 的默认配置下几乎永远不会触发:128 × 64 = 8 KB,离 1 GB 差五个数量级。它只在运维把这两个值调到极大时才起作用。
如果三个条件有任何一个不满足:
c
} else {
/* Size limit is reached. Convert to hashtable and add. */
setTypeConvertAndExpand(set, OBJ_ENCODING_HT, lpLength(lp) + 1, 1);
sds newval = sdsnewlen(str,len);
serverAssert(dictAdd(set->ptr,newval,NULL) == DICT_OK);
*htGetMetadataSize(set->ptr) += sdsAllocSize(newval);
}
src/t_set.c:177-183。注意这里用的是 dictAdd 而不是 dictSetKeyAtLink------因为刚转换完,dict 是全新的,没有"已知的 bucket"可用。一次 dictAdd 要做完整的哈希定位,但只做一次。
7.5 路径五:intset + 可整数化的字符串
c
} else if (set->encoding == OBJ_ENCODING_INTSET) {
long long value;
if (string2ll(str, len, &value)) {
uint8_t success = 0;
set->ptr = intsetAdd(set->ptr,value,&success);
if (success) {
maybeConvertIntset(set);
return 1;
}
} else {
src/t_set.c:186-195。
注意 string2ll 成功但 intsetAdd 返回"已存在"(success == 0)时,代码没有 return ------它继续往下走,落到函数末尾的 return 0。这是对的(元素已存在,返回 0 表示没添加)。
有一条容易忽略的路径:string2ll 成功、intsetAdd 失败(已存在),然后不进入 else 分支 ,直接跳出 if,落到 return 0。所以不会触发任何转换。
如果 string2ll 失败(不是整数),进入 else,才是"要不要转 listpack"的判断。
7.6 路径六:intset → listpack 的三重估算
c
} else {
/* Check if listpack encoding is safe not to cross any threshold. */
size_t maxelelen = 0, totsize = 0;
unsigned long n = intsetLen(set->ptr);
if (n != 0) {
size_t elelen1 = sdigits10(intsetMax(set->ptr));
size_t elelen2 = sdigits10(intsetMin(set->ptr));
maxelelen = max(elelen1, elelen2);
size_t s1 = lpEstimateBytesRepeatedInteger(intsetMax(set->ptr), n);
size_t s2 = lpEstimateBytesRepeatedInteger(intsetMin(set->ptr), n);
totsize = max(s1, s2);
}
if (intsetLen((const intset*)set->ptr) < server.set_max_listpack_entries &&
len <= server.set_max_listpack_value &&
maxelelen <= server.set_max_listpack_value &&
lpSafeToAdd(NULL, totsize + len))
{
/* In the "safe to add" check above we assumed all elements in
* the intset are of size maxelelen. This is an upper bound. */
setTypeConvertAndExpand(set, OBJ_ENCODING_LISTPACK,
intsetLen(set->ptr) + 1, 1);
unsigned char *lp = set->ptr;
lp = lpAppend(lp, (unsigned char *)str, len);
lp = lpShrinkToFit(lp);
set->ptr = lp;
return 1;
} else {
src/t_set.c:195-220。
这是全文件最晦涩的一段。它要在不真的转换的前提下,判断"转换后会不会超限"。四道判据:
intsetLen < set_max_listpack_entrieslen <= set_max_listpack_value(新元素)maxelelen <= set_max_listpack_value(现有元素的最大位数)lpSafeToAdd(NULL, totsize + len)(估算的总字节数)
判据 3 和 4 需要"现有元素的字符串形式",但 intset 里只有 int64,所以只能用极值做上界:
maxelelen = max(sdigits10(min), sdigits10(max))。sdigits10返回十进制字符数(负数含负号,src/util.c:380-389)。intset的所有元素都在[min, max]内,而|v|的最大值必然在区间两端取到,所以这是"所有元素字符串形式长度"的上界。totsize = max(lpEstimateBytesRepeatedInteger(min, n), lpEstimateBytesRepeatedInteger(max, n))。
c
size_t lpEstimateBytesRepeatedInteger(long long lval, unsigned long rep) {
return LP_HDR_SIZE + lpEntrySizeInteger(lval) * rep + 1;
}
src/listpack.c:1685-1687。LP_HDR_SIZE = 6,+1 是 EOF,lpEntrySizeInteger 是"编码字节数 + backlen 字节数"。
为什么取 max 而不是求和? 因为不知道 n 个元素里有几个 min、几个 max、几个在中间。取 max 是假设"全部元素都是编码最宽的极值",这成立的前提是编码宽度随绝对值单调不减 。listpack 的六档整数编码(第 11 章 §2.1)确实按区间划分:0~127 占 2 字节(含 backlen)、-4096~4095 占 3、-32768~32767 占 4、24 位占 5、32 位占 6、其余占 10。区间有序,没有反例。
所以 max(estimate(min), estimate(max)) 是严格上界,注释也这么写:
In the "safe to add" check above we assumed all elements in the intset are of size maxelelen. This is an upper bound.
四道判据全过之后 ,转 listpack:
c
setTypeConvertAndExpand(set, OBJ_ENCODING_LISTPACK, intsetLen(set->ptr) + 1, 1);
unsigned char *lp = set->ptr;
lp = lpAppend(lp, (unsigned char *)str, len);
lp = lpShrinkToFit(lp);
set->ptr = lp;
lpAppend 传的是 str 而不是走 tmpbuf 判断------走到这里说明 string2ll 失败了,值不是整数,不可能是从 llval 转来的。lpShrinkToFit 是必需的:setTypeConvertAndExpand 预分配的 estcap 是估算值(§9),实际用不了那么多。
7.7 六条路径的判定顺序
ini
str == NULL?
├── 是 → INTSET? → 是 → intsetAdd + maybeConvert 【路径一】
│ → 否 → ll2string 到 tmpbuf 继续
└── 否 → 按 encoding 分派
├── HT → dictFindLink + dictSetKeyAtLink 【路径三】
├── LISTPACK → lpFind + 三道上限 【路径四】
└── INTSET → string2ll?
├── 成功 → intsetAdd + maybeConvert 【路径五】
└── 失败 → 四道估算 → listpack 或 HT 【路径六】
路径一和路径二的分离只发生在 str == NULL 时。这个设计意味着"整数输入的 Set 操作"在 intset 编码下完全避免字符串化,在 listpack/HT 编码下必须字符串化一次(因为这两个容器存的是字节)。
hashtable 存字符串是不可避免的------哈希函数吃字节。listpack 存整数编码是可能的,但代码选择"先字符串化再让 lpAppendInteger 转回去",只有 str == tmpbuf 时才走捷径。
8. lpFindCmp:一次解析,n 次比较
listpack 编码的 SISMEMBER 走:
c
if (set->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *lp = set->ptr;
unsigned char *p = lpFirst(lp);
return p && lpFind(lp, p, (unsigned char*)str, len, 0);
}
src/t_set.c:313-316。lpFind 是线性扫描,每个元素调一次比较器。如果比较器每次都要把 str 解析成整数(因为 listpack 里存的是整数编码),那 n 个元素就要解析 n 次同一个字符串。
lpFindCmp 用 struct lpFindArg 缓存了这次解析:
c
struct lpFindArg {
unsigned char *s; /* Item to search */
uint32_t slen; /* Item len */
int vencoding;
int64_t vll;
};
static inline int lpFindCmp(const unsigned char *lp, unsigned char *p,
void *user, unsigned char *s, long long slen) {
struct lpFindArg *arg = user;
if (s) {
if (slen == arg->slen && memcmp(arg->s, s, slen) == 0) {
return 0;
}
} else {
/* Find out if the searched field can be encoded. Note that
* we do it only the first time, once done vencoding is set
* to non-zero and vll is set to the integer value. */
if (arg->vencoding == 0) {
if (arg->slen >= 32 || arg->slen == 0 || !lpStringToInt64((const char*)arg->s, arg->slen, &arg->vll)) {
arg->vencoding = UCHAR_MAX;
} else {
arg->vencoding = 1;
}
}
if (arg->vencoding != UCHAR_MAX && slen == arg->vll) {
return 0;
}
}
return 1;
}
src/listpack.c:948-991。
三个状态:
vencoding == 0:还没试过。第一次遇到整数元素时解析,成功置 1、失败置UCHAR_MAX。vencoding == 1:可解析,vll已缓存。之后每个整数元素只做slen == arg->vll比较。vencoding == UCHAR_MAX:不可解析,之后所有整数元素直接跳过(返回"不匹配")。
slen >= 32 的短路:lpStringToInt64 要求字符串是严格 int64 表示,最长 20 字符(含符号)。32 是安全余量。
这个缓存的存在意味着 lpFind 的成本是 O(n) 次比较 + 1 次解析 ,而不是 O(n) 次解析。对 SISMEMBER k 12345 作用在一个 128 元素的 listpack 上:128 次 memcmp 或 int64 比较,加一次 lpStringToInt64。
lpCompare 是另一套同样目的的实现,带显式的缓存参数:
c
unsigned int lpCompare(unsigned char *p, unsigned char *s, uint32_t slen,
long long *cached_longval, int *cached_valid) {
src/listpack.c:1844-1846。cached_valid 是三态(0 = 未试、1 = 可解析、-1 = 不可解析),语义与 vencoding 一致,只是用 -1 而不是 UCHAR_MAX。
两套实现并存是因为 lpCompare 被 lpDeleteRangeWithEntry/lpReplace 之类需要"按值定位"的地方用,而 lpFind 有自己的回调协议。8.10 还没有把它们统一。
8.1 lpFind 的 skip 参数
c
unsigned char *lpFind(unsigned char *lp, unsigned char *p, unsigned char *s,
uint32_t slen, unsigned int skip)
{
struct lpFindArg arg = {
.s = s,
.slen = slen
};
return lpFindCbInternal(lp, p, &arg, lpFindCmp, skip);
}
src/listpack.c:994-1002。skip 是"每比较一次跳几个元素"。Set 用 skip = 0(逐个),Hash 的 listpack 用 skip = 1(field/value 交替,只比 field)。
lpFindCbInternal 里 p = lpSkip(p) 之后还要再 lpSkip 若干次。这个参数让 lpFind 能直接用在键值对的 listpack 上,不用先抽出所有 field。
8.2 listpack 编码的真实成本
一个 128 元素的 listpack Set,SISMEMBER 最坏要扫完 128 个。每个元素要走 lpCurrentEncodedSizeBytes + lpSkip(解析编码长度并跳到下一个)、lpGetValue(整数返回 NULL + lval,字符串返回指针 + 长度)、lpFindCmp 比较------约 5-10 个指令的分支。
128 个元素约 1000 个指令,现代 CPU 上 300-500 ns。对比 hashtable:一次 SipHash 约 20 ns + 一次 memcmp,总共不到 50 ns。慢 6-10 倍 ,这就是 set-max-listpack-entries 默认只有 128 的原因。
但 listpack 的遍历 比 hashtable 快得多(内存连续、无指针追逐),内存占用只有 hashtable 的 1/3 到 1/5。所以对"写入后主要遍历"的场景(SINTER/SUNION 的读取侧),listpack 更好------这个方向性差异是 §12.2 那个排序的伏笔。
9. setTypeConvertAndExpand:预分配与半释放
c
int setTypeConvertAndExpand(robj *setobj, int enc, unsigned long cap, int panic) {
setTypeIterator si;
serverAssertWithInfo(NULL,setobj,setobj->type == OBJ_SET &&
setobj->encoding != enc);
src/t_set.c:522-525。断言 encoding != enc------不允许"转换到自己的编码"。所有调用点都保证这点。
9.1 HT 分支:预分配 + 逐元素插入
c
if (enc == OBJ_ENCODING_HT) {
dict *d = dictCreate(&setDictType);
sds element;
/* Presize the dict to avoid rehashing */
if (panic) {
dictExpand(d, cap);
} else if (dictTryExpand(d, cap) != DICT_OK) {
dictRelease(d);
return C_ERR;
}
/* To add the elements we extract integers and create redis objects */
size_t *alloc_size = htGetMetadataSize(d);
setTypeInitIterator(&si, setobj);
while ((element = setTypeNextObject(&si)) != NULL) {
serverAssert(dictAdd(d,element,NULL) == DICT_OK);
*alloc_size += sdsAllocSize(element);
}
setTypeResetIterator(&si);
freeSetObject(setobj); /* frees the internals but not setobj itself */
setobj->encoding = OBJ_ENCODING_HT;
setobj->ptr = d;
}
src/t_set.c:527-550。
dictExpand vs dictTryExpand 是 8.10 新增的区分:前者 OOM 时 zmalloc 直接 abort,后者返回 C_ERR。panic 参数决定用哪个。
panic = 0 的唯一调用点是 RDB 加载(src/rdb.c:2997、src/rdb.c:3020)。理由是加载损坏的 RDB 时如果 OOM,应该报"RDB 损坏"而不是让进程 abort------虽然结果都是启动失败,但前者能给出可诊断的错误信息。
setTypeNextObject 是"COW 不友好但好用"的版本:
c
sds setTypeNextObject(setTypeIterator *si) {
int64_t intele = 0;
char *str;
size_t len = 0;
if (setTypeNext(si, &str, &len, &intele) == -1) return NULL;
if (str != NULL) return sdsnewlen(str, len);
return sdsfromlonglong(intele);
}
src/t_set.c:409-417。每次返回一个新分配的 sds,因为 hashtable 要接管所有权。
注释(src/t_set.c:402-408)说这是"not copy on write friendly"------如果转换发生在 BGSAVE 的 fork 子进程里,每个元素一次 sdsnewlen 会大量写页,破坏 COW。但转换永远在主进程发生(命令执行路径),所以没问题。
freeSetObject(setobj) 是"只释放内部、不释放 robj 本身":
c
void freeSetObject(robj *o) {
switch (o->encoding) {
case OBJ_ENCODING_HT:
...
dictRelease((dict*) o->ptr);
break;
case OBJ_ENCODING_INTSET:
case OBJ_ENCODING_LISTPACK:
zfree(o->ptr);
break;
}
}
src/object.c:566-580。它清掉 o->ptr 指向的容器,但保留 robj 的头部(type/encoding/lru/refcount)。所以转换可以在原地完成------不需要"新建一个 robj 再替换、再把旧的 decrRefCount"。
这个设计让 setTypeConvertAndExpand 的所有调用者都不必处理"robj 指针变化"。ob->ptr 换了,ob 本身不变。对 db 里的条目、对客户端持有的引用、对 copy-on-write 快照,都是同一个 robj。
9.2 LISTPACK 分支:两种预分配估算
c
} else if (enc == OBJ_ENCODING_LISTPACK) {
/* Preallocate the minimum two bytes per element (enc/value + backlen) */
size_t estcap = cap * 2;
if (setobj->encoding == OBJ_ENCODING_INTSET && setTypeSize(setobj) > 0) {
/* If we're converting from intset, we have a better estimate. */
size_t s1 = lpEstimateBytesRepeatedInteger(intsetMin(setobj->ptr), cap);
size_t s2 = lpEstimateBytesRepeatedInteger(intsetMax(setobj->ptr), cap);
estcap = max(s1, s2);
}
unsigned char *lp = lpNew(estcap);
src/t_set.c:551-560。
cap * 2 是"每元素至少 2 字节"(1 字节编码 + 1 字节 backlen)。这是下界 ,不是上界------所以对字符串元素会不够,lpAppend 中途会 realloc。
从 intset 转换时用极值估算(与路径六同一套逻辑),得到的是上界,所以不会 realloc。
两个估算的差异体现在 cap 很大时:cap = 512,estcap = 1024(下界),实际可能是 6 + 512×3 + 1 = 1543(INT16 范围的整数是 2 字节编码 + 1 backlen = 3)。所以要 realloc 一次。
为什么不用上界估? 因为源是 hashtable 或 listpack 时,元素是任意字符串,上界是 cap × (max_elelen + 10),可能大几十倍。宁可 realloc 也不能预分配过多。
从 intset 转换时有极值信息,所以能用上界。这是 intset 特有的优势:它的所有元素都在一个已知的闭区间内。
9.3 转换之后不调用 lpShrinkToFit
setTypeConvertAndExpand 的 LISTPACK 分支结尾:
c
freeSetObject(setobj); /* frees the internals but not setobj itself */
setobj->encoding = OBJ_ENCODING_LISTPACK;
setobj->ptr = lp;
src/t_set.c:573-575。没有 lpShrinkToFit ------调用方自己决定:路径六(src/t_set.c:218)调了,saddCommand 的路径四不调(lpAppend 之后 total-bytes 已经准确,只是分配容量有冗余)。
lpShrinkToFit 的成本是一次 zrealloc:同 size class 内是 no-op,跨 class 才真的搬。所以"该不该调"取决于省下的内存是否值一次可能的 memcpy。SINTERSTORE 的收尾调了(src/t_set.c:1551),因为它的 dstset 按源集合大小预分配,结果可能远小于源。
10. hashtable 编码的 metadata 记账
8.10 的 Set 有一个 8.0 没有的字段:每个 hashtable 编码的 Set,其 dict 的 metadata 区里存着一个 size_t,记录所有 key 的 sds 分配字节数之和。
c
size_t setDictMetadataBytes(dict *d) {
UNUSED(d);
return sizeof(size_t);
}
dictType setDictType = {
dictSdsHash, /* hash function */
NULL, /* key dup */
NULL, /* val dup */
dictSdsKeyCompare, /* key compare */
setSdsDestructor, /* key destructor */
NULL, /* val destructor */
NULL, /* allow to expand */
.no_value = 1, /* no values in this dict */
.keys_are_odd = 1, /* an SDS string is always an odd pointer */
.dictMetadataBytes = setDictMetadataBytes,
};
src/server.c:409-412 和 src/server.c:633-644。
10.1 为什么 no_value = 1
Set 的 dict 只有 key 没有 value。传统 dict 的每个 entry 是 24 字节(next + key + v 各 8 字节)。no_value = 1 让 dict 用 dictEntryNoValue(16 字节):
c
struct dictEntry {
struct dictEntry *next; /* Must be first */
void *key; /* Must be second */
union { void *val; uint64_t u64; int64_t s64; double d; } v;
};
typedef struct dictEntryNoValue {
dictEntry *next; /* Must be first */
void *key; /* Must be second */
} dictEntryNoValue;
src/dict.c:49-63。省下 8 字节/元素------100 万元素的 Set 省 8 MB。
static_assert 在 src/dict.c:65-66 保证 next 和 key 的偏移一致,所以 dictEntryNoValue* 可以安全地当 dictEntry* 用(共同的头部)。
而且更进一步:keys_are_odd = 1 允许把 key 直接存在桶里,根本不分配 entry:
c
* - Odd-address key (keys_are_odd=1): Direct pointer to a
* key with odd address (e.g., all SDS strings), Left intact:
* ENTRY_PTR_IS_ODD_KEY=XX1
src/dict.h:32-34。桶里存的就是 sds 指针本身,最低位是 1(因为 sds 的 header 大小总是奇数:sdshdr5 1 字节、sdshdr8 3、sdshdr16 5、sdshdr32 9、sdshdr64 17),所以可以和"真正的 entry 指针"(8 字节对齐,最低三位为 0)区分。
ENTRY_PTR_IS_ODD_KEY = XX1 ------ 最低位为 1 的是 odd key,最低三位为 000 的是 entry 指针,最低三位为 010 的是 even key。
这意味着 Set 的每个元素可以完全不分配 dictEntry,只有 sds 本身 + 一个桶指针。128 元素以上的 Set 从"每元素 24 字节 entry + sds"降到"每元素一个桶槽 8 字节 + sds"。
桶槽是共享的,不是每元素一个。负载因子 1 时,dictBuckets ≈ dictSize,所以每元素约 8 字节桶。加上 sds 的 header(3 字节起)+ 数据 + null 终止符。
一个 10 字节的元素(如 "user:10001")在 Set 里:sds 是 sdshdr8(3)+ 10 + 1 = 14 字节,jemalloc 16 class;桶槽 8 字节(分摊)。总计约 24 字节/元素。
对比 listpack:6 + 128×(1+10+1) + 1 ≈ 1543 字节 / 128 = 12 字节/元素。
所以 listpack 在小元素 上仍然更省,但差距不到 2 倍。hashtable 省的是"查找时间"。
10.2 记账的加减
加:*htGetMetadataSize(ht) += sdsAllocSize(sdsval)(src/t_set.c:151、182、228、544)
减:
c
void setSdsDestructor(dict *d, void *val) {
*htGetMetadataSize(d) -= sdsAllocSize(val);
sdsfree(val);
}
src/server.c:404-407。参数名叫 val 是误导------它对应 dictType 的 keyDestructor 槽位,签名 void (*)(dict *d, void *key),实际收到的是被删的 key。字段顺序是:
bash
hash, keyDup, valDup, keyCompare, keyDestructor, valDestructor, ...
所以 setSdsDestructor 是 keyDestructor ,签名为 void (*keyDestructor)(dict *d, void *key)。参数名 val 是历史遗留,实际传的是 key。
freeSetObject 的 HT 分支有个 DEBUG_ASSERTIONS 下的检查:
c
#ifdef DEBUG_ASSERTIONS
dictEmpty(o->ptr, NULL);
debugServerAssert(*htGetMetadataSize(o->ptr) == 0);
#endif
src/object.c:569-572。清空 dict(触发所有 key destructor)之后,metadata 必须归零。这验证了"加和减严格配对"。
10.3 记账口径是逻辑大小不是 usable
sdsAllocSize:
c
static inline size_t sdsAllocSize(sds s) {
switch(sdsType(s)) {
case SDS_TYPE_5:
return sizeof(struct sdshdr5) + SDS_TYPE_5_LEN(s) + 1;
case SDS_TYPE_8:
return sizeof(struct sdshdr8) + SDS_HDR(8,s)->alloc + 1;
...
src/sds.h:192-206。这是逻辑分配大小(header + alloc + 1),不是 jemalloc 的 usable size。
所以一个 sdshdr8 结构、alloc = 10 的 sds,sdsAllocSize = 3 + 10 + 1 = 14,但 jemalloc 实际给了 16。那 2 字节的 class 间隙没有记进 metadata。
这与第 12 章 List 的 alloc_size 形成对比 :List 的 quicklist.alloc_size 是用 zmalloc_usable_size 精确记账的(src/quicklist.c 的 zfree_usable/zrealloc_usable),quicklistRelease 结尾还有 debugAssert(alloc_size == zmalloc_usable_size(ql))。
Set 选择逻辑大小,原因是:sdsAllocSize 是 O(1) 且不需要调 zmalloc_usable_size(后者在 jemalloc 上是一次 malloc_usable_size,有函数调用开销)。而 sdsAllocSize 是纯位运算,可以内联。
代价是 setTypeAllocSize 报出的数字比实际占用少 0-15%(jemalloc class 间隙的比例)。MEMORY USAGE 会低估,且低估的幅度依赖元素长度的分布。
10.4 setTypeAllocSize 的三口径
c
size_t setTypeAllocSize(const robj *o) {
size_t size = 0;
if (o->encoding == OBJ_ENCODING_HT) {
dict *d = o->ptr;
size += sizeof(dict) + dictMemUsage(d) + *htGetMetadataSize(d);
} else if (o->encoding == OBJ_ENCODING_INTSET) {
size = intsetAllocSize(o->ptr);
} else if (o->encoding == OBJ_ENCODING_LISTPACK) {
size = lpBytes(o->ptr);
}
return size;
}
src/t_set.c:495-509。
三种编码的口径:
| 编码 | 计算 | 口径 |
|---|---|---|
HT |
sizeof(dict) + dictMemUsage + metadata |
逻辑(sizeof + sdsAllocSize) |
INTSET |
8 + length × encoding |
逻辑(精确) |
LISTPACK |
lpBytes = total-bytes header 的值 |
逻辑(精确) |
dictMemUsage:
c
size_t dictMemUsage(const dict *d) {
return dictSize(d) * dictEntryMemUsage(d->type->no_value) +
dictBuckets(d) * sizeof(dictEntry*);
}
src/dict.c:1091-1100。注释明说 "This is a strict over-estimate still"------因为 inline 存储的 key 根本不分配 entry,但这里还是按 dictSize × 16 算了。
所以 Set 的 MEMORY USAGE:
INTSET和LISTPACK:准确 (逻辑大小就是分配大小,因为都是单块zmalloc,total-bytes就是实际字节数)。HT:高估 (假设每个元素都有dictEntryNoValue,实际可能 inline)。
这个不对称在编码切换时会造成"内存跳变":一个 128 元素的 listpack Set 报 1543 字节,转成 HT 后报 sizeof(dict) + 128×16 + 128×8 + sds 总和 ≈ 100 + 2048 + 1024 + 1800 ≈ 5000 字节。涨到 3 倍,但实际占用可能只涨到 2 倍。
11. 三种迭代器形态与 SCAN 的全集返回
c
typedef struct {
robj *subject;
int encoding;
int ii; /* intset iterator */
dictIterator di;
unsigned char *lpi; /* listpack iterator */
} setTypeIterator;
src/server.h:3153-3158。四个字段并存,按 encoding 只用其中一个:
| 编码 | 用哪个字段 | 语义 |
|---|---|---|
INTSET |
ii |
当前下标 |
LISTPACK |
lpi |
当前元素指针 |
HT |
di |
dictIterator |
ii 是 int 而不是 uint32_t ------ 因为 intsetGet(is, pos, &value) 的参数是 uint32_t pos,传 int 会隐式转换。负值会被转成巨大值然后被 pos < length 拒掉。所以 ii 的类型不影响正确性,只是丑。
11.1 setTypeNext 的三条返回约定
c
int setTypeNext(setTypeIterator *si, char **str, size_t *len, int64_t *llele) {
if (si->encoding == OBJ_ENCODING_HT) {
dictEntry *de = dictNext(&si->di);
if (de == NULL) return -1;
*str = dictGetKey(de);
*len = sdslen(*str);
*llele = -123456789; /* Not needed. Defensive. */
} else if (si->encoding == OBJ_ENCODING_INTSET) {
if (!intsetGet(si->subject->ptr,si->ii++,llele))
return -1;
*str = NULL;
} else if (si->encoding == OBJ_ENCODING_LISTPACK) {
...
*str = (char *)lpGetValue(lpi, &l, (long long *)llele);
*len = (size_t)l;
}
return si->encoding;
}
src/t_set.c:372-400。
三条约定:
HT:str指向 sds(可直接当 sds 用),llele填充-123456789(一个"你一用就崩"的哨兵值)。INTSET:str = NULL,llele是值。LISTPACK:两者都可能------str == NULL表示整数(llele有效),否则str+len。
返回值是 encoding 而不是 0/1。调用方用它判断"str 是不是 sds":
c
setTypeAddAux(dstset, str, len, intobj, encoding == OBJ_ENCODING_HT)
src/t_set.c:1526。最后一个参数 str_is_sds 只在 HT 时为真。
这个设计有个漏洞 :HT 的 str 是 sds,但 LISTPACK 的 str 指向 listpack 内部,不是 sds。如果调用方误把 LISTPACK 的 str 当 sds 传给 setTypeAddAux,会在 sdslen 上读到 listpack 的字节,行为未定义。
所以 encoding == OBJ_ENCODING_HT 这个判据不是"优化",是正确性要求 。写成 str_is_sds = 1 常量会崩。
-123456789 这个哨兵值的作用:如果有人忘记判 encoding 而直接用了 llele,会得到一个明显不可能是合法元素的值,容易在测试里暴露。
11.2 setTypeRandomElement:lpSeek 的 O(n)
c
} else if (setobj->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *lp = setobj->ptr;
int r = rand() % lpLength(lp);
unsigned char *p = lpSeek(lp, r);
src/t_set.c:441-444。lpSeek(lp, r) 是 O(min(r, n-r)) 的遍历------取随机下标要付一次线性扫描的代价。
所以 SRANDMEMBER k 100(count = 100,listpack 编码)如果走 CASE 4(逐个 setTypeRandomElement),就是 100 次 O(n/2) 扫描。这是 CASE 2.5 存在的原因(src/t_set.c:1224-1250):对 listpack 用 lpNextRandom 一遍扫完。
而 setTypePopRandom 对 listpack 直接用 lpNextRandom:
c
if (set->encoding == OBJ_ENCODING_LISTPACK) {
/* Find random and delete it without re-seeking the listpack. */
unsigned int i = 0;
unsigned char *p = lpNextRandom(set->ptr, lpFirst(set->ptr), &i, 1, 1);
...
set->ptr = lpDelete(set->ptr, p, NULL);
}
src/t_set.c:457-468。注释明说 "without re-seeking"。
对比 intset 的 intsetRandom:
c
int64_t intsetRandom(intset *is) {
uint32_t len = intrev32ifbe(is->length);
assert(len); /* avoid division by zero on corrupt intset payload. */
return _intsetGet(is,rand()%len);
}
src/intset.c:268-272。_intsetGet 是真正的 O(1) ------固定宽度,contents + pos*encoding。
所以随机访问的代价:intset O(1) < hashtable O(1)(dictGetFairRandomKey)<< listpack O(n)。
这个顺序解释了为什么 SRANDMEMBER/SPOP 有那么多 listpack 特化分支------不特化就没法用。
11.3 SCAN 对小容器返回全集
c
} else if (o->type == OBJ_SET) {
unsigned long array_reply_len = 0;
void *replylen = NULL;
vecRelease(&keys);
...
/* Cursor is always 0 given we iterate over all set */
addReplyBulkLongLong(c,0);
src/db.c:1984-1997。SSCAN 对 intset/listpack 一次返回全部元素,cursor 恒为 0。
理由在注释里:
Note that if the object is encoded with a listpack, intset, or any other representation that is not a hash table, we are sure that it is also composed of a small number of elements. So to avoid taking state we just return everything inside the object in a single call, setting the cursor to zero to signal the end of the iteration.
"small number" 由 set-max-listpack-entries = 128 和 set-max-intset-entries = 512 保证,一次返回最多 512 个元素,不构成阻塞风险。
副作用是 SSCAN 的 COUNT 对 intset/listpack 完全无效 :传 COUNT 10 仍返回 512 个。SCAN 的通用约定是"COUNT 是提示不是保证",所以不算违约,但依赖它控制响应大小的客户端会失算。
11.4 setTypeDup 的三条路
COPY 命令要"复制后编码相同":
c
robj *setTypeDup(robj *o) {
if (o->encoding == OBJ_ENCODING_INTSET) {
intset *is = o->ptr;
size_t size = intsetBlobLen(is);
intset *newis = zmalloc(size);
memcpy(newis,is,size);
...
} else if (o->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *lp = o->ptr;
size_t sz = lpBytes(lp);
unsigned char *new_lp = zmalloc(sz);
memcpy(new_lp, lp, sz);
...
} else if (o->encoding == OBJ_ENCODING_HT) {
set = createSetObject();
dict *d = o->ptr;
dictExpand(set->ptr, dictSize(d));
...while (setTypeNext(&si, &str, &len, &intobj) != -1) {
setTypeAdd(set, (sds)str);
}
}
src/t_set.c:587-624。
intset 和 listpack 是单块内存 ,memcpy 就完事------O(n) 但常数极小。
hashtable 必须逐个 setTypeAdd------因为要重新哈希。注意这里没有 用 dictSetKeyAtLink,因为每个 key 都要 sdsdup,而 setTypeAdd 里已经处理了。
intsetBlobLen 与 intsetAllocSize 是同一个公式(8 + length × encoding)。用 intsetBlobLen 命名更准确------它是"blob 的逻辑长度",用于 memcpy 的大小。
12. SINTER:按基数排序的驱动集
SINTER / SINTERSTORE / SINTERCARD 共用一个函数:
c
void sinterGenericCommand(client *c, robj **setkeys,
unsigned long setnum, robj *dstkey,
int cardinality_only, unsigned long limit);
src/t_set.c:1398-1400。三个命令的差异只在最后两个参数。
12.1 先查空集,再排序
c
for (j = 0; j < setnum; j++) {
kvobj *kv = lookupKeyRead(c->db, setkeys[j]);
if (!kv) {
/* A NULL is considered an empty set */
empty += 1;
sets[j].set = NULL;
sets[j].oldsize = 0;
continue;
}
...
}
/* Set intersection with an empty set always results in an empty set.
* Return ASAP if there is an empty set. */
if (empty > 0) {
zfree(sets);
if (dstkey) {
if (dbDelete(c->db,dstkey)) { ... }
addReply(c,shared.czero);
} else if (cardinality_only) {
addReplyLongLong(c,cardinality);
} else {
addReply(c,shared.emptyset[c->resp]);
}
return;
}
src/t_set.c:1411-1446。一个不存在的键就让整个交集为 ∅,提前返回。
注意 SINTERSTORE dest missing key 仍然要 dbDelete(dest) ------因为这次操作的结果是空集,而 dest 之前可能有值。dbDelete 返回 1 时才发 del 通知并 server.dirty++。
SINTER k1 k2 k3 里如果 k2 不存在,SINTER k1 k2 k3 = ∅,即使 k3 也不存在。这个"短路"是 O(M) 的(只看键是否存在),比真的算交集快得多。
12.2 qsort 按基数升序
c
/* Sort sets from the smallest to largest, this will improve our
* algorithm's performance */
qsort(sets,setnum,sizeof(setopsrc),qsortCompareSetsByCardinality);
src/t_set.c:1448-1450。
c
int qsortCompareSetsByCardinality(const void *s1, const void *s2) {
robj *o1 = ((setopsrc*)s1)->set, *o2 = ((setopsrc*)s2)->set;
if (setTypeSize(o1) > setTypeSize(o2)) return 1;
if (setTypeSize(o1) < setTypeSize(o2)) return -1;
return 0;
}
src/t_set.c:1371-1376。注意这里没有 NULL 检查 ------因为 empty > 0 时已经提前返回了,走到这里的所有 sets[j].set 都非 NULL。
升序排序的收益是迭代最小集合 。SINTER 的主循环:
c
setTypeInitIterator(&si, sets[0].set);
while((encoding = setTypeNext(&si, &str, &len, &intobj)) != -1) {
for (j = 1; j < setnum; j++) {
if (sets[j].set == sets[0].set) continue;
if (!setTypeIsMemberAux(sets[j].set, str, len, intobj,
encoding == OBJ_ENCODING_HT))
break;
}
...
}
src/t_set.c:1486-1493。外层遍历 sets[0](最小集合),内层对每个元素查其余 M-1 个集合。
总代价 = min(|S_i|) × (M-1) × 单次查找成本。所以驱动集越小越好。
但内层查找的成本取决于目标集合的编码 ,不只是大小。一个 100 万元的 hashtable 查一次是 O(1),一个 128 元素的 listpack 查一次是 O(128)。所以"按基数排序"对 listpack 反而是次优的:
SINTER A B,|A| = 1000(hashtable),|B| = 128(listpack)。排序后 sets[0] = B,外层 128 次,内层查 A 一次 O(1) → 128 次查找。
如果不排序,sets[0] = A,外层 1000 次,内层查 B 一次 O(128) → 128,000 次比较。
所以排序是对的,但理由不是"驱动集小",而是"驱动集小 + 内层查大集合 "。如果反过来(外层大集合、内层查 listpack),代价是 1000 × 128。排序避免了这种最坏情况。
理论最优 是"外层选最小、内层按编码排(hashtable 优先)",但 qsort 只按基数。好在默认配置下 listpack 最多 128 元素、hashtable 至少 129,按基数升序恰好把 listpack 排到内层前面,最坏情况被这个巧合避开了。
巧合会被配置打破:把 set-max-listpack-entries 设成 10000,一个 5000 元素的 listpack 排在一个 3000 元素的 hashtable 之后,于是 3000 次外层 × 5000 次内层 = 1500 万次比较------排序反而有害。
12.3 sets[j].set == sets[0].set 的跳过
c
if (sets[j].set == sets[0].set) continue;
src/t_set.c:1489。SINTER k k 时两个指针相同,跳过内层查找。这是"同一键出现多次"的优化。
但这个判据只跳过"与 sets[0] 相同"的,不跳过 sets[1] == sets[2]。后者重复查两次,结果一样,只是浪费。
SINTER k k k:排序后三个指针全等,内层两次 continue,外层仍是 |k| 次。所以 SINTER k k k 的代价是 O(|k|),与 SINTER k 相同。
12.4 结果的三条出口
c
if (cardinality_only) {
addReplyLongLong(c,cardinality);
} else if (dstkey) {
if (setTypeSize(dstset) > 0) {
if (only_integers) maybeConvertToIntset(dstset);
if (dstset->encoding == OBJ_ENCODING_LISTPACK) {
dstset->ptr = lpShrinkToFit(dstset->ptr);
}
setKey(c, c->db, dstkey, &dstset, 0);
addReplyLongLong(c,setTypeSize(dstset));
...
} else {
addReply(c,shared.czero);
if (dbDelete(c->db,dstkey)) { ... }
decrRefCount(dstset);
}
} else {
setDeferredSetLen(c,replylen,cardinality);
}
src/t_set.c:1541-1569。
出口一 (SINTERCARD):直接回 cardinality。dstset 是什么?在 cardinality_only 且 dstkey == NULL 时:
c
if (dstkey) {
...
} else if (!cardinality_only) {
replylen = addReplyDeferredLen(c);
}
src/t_set.c:1457-1480。dstkey == NULL && cardinality_only 时两个分支都不进 ,dstset 保持 NULL。所以主循环里的 dstset 只在 dstkey != NULL 时被使用:
c
} else {
if (str && only_integers) { ... }
setTypeAddAux(dstset, str, len, intobj, encoding == OBJ_ENCODING_HT);
}
这段在 if (cardinality_only) / else if (!dstkey) / else 的第三个分支里。所以 SINTERCARD 完全不构建结果集 ,只计数。这是它比 SINTER 省内存的全部原因。
出口二 (SINTER):addReplyDeferredLen 占位,先写元素,最后回填长度。因为交集大小事先不知道。
replylen 指向输出缓冲里那个占位的长度字段,setDeferredSetLen(c, replylen, cardinality) 最后改写它。这是 Redis 协议层的一个常用技巧(第 3 章讲过)。
出口三 (SINTERSTORE):setKey 写入 dstkey。注意 setTypeSize(dstset) > 0 的判断------空结果要删除 dstkey 而不是存一个空集合。因为 Redis 不允许"空集合键"存在。
decrRefCount(dstset) 释放刚建的 dstset(它是个崭新分配的 robj,refcount = 1,decr 到 0 就 free)。
12.5 SINTERSTORE dest dest key 的自赋值
dstkey 就是某个源键时,sets[j].set 指向它的旧 robj(lookupKeyRead 不加引用),dstset 是新建的 robj。setKey(c, db, dstkey, &dstset, 0) 会用 dstset 替换值并对旧 robj decrRefCount------如果旧 robj 的 refcount == 1 它就 free 了,而 sets[j].set 变成悬垂指针。
安全的原因是 setKey 之后只碰 dstset:
c
setKey(c, c->db, dstkey, &dstset, 0);
addReplyLongLong(c,setTypeSize(dstset));
notifyKeyspaceEvent(NOTIFY_SET,"sinterstore", dstkey,c->db->id);
server.dirty++;
src/t_set.c:1553-1557。之后 zfree(sets) 只释放数组本身。这条安全性依赖"setKey 是最后一步"------有人在 setKey 之后加一句"更新源集合的内存记账"就会读到已释放内存。must_track_memory 的循环就放在 setKey 之前 (src/t_set.c:1532-1539),正是这个约束的体现。
13. SINTERSTORE 的目标编码预判
dstset 的初始编码不是一个固定值,而是按 sets[0](排序后的最小集合)的编码三岔:
c
if (sets[0].set->encoding == OBJ_ENCODING_INTSET) {
/* The first set is an intset, so the result is an intset too. The
* elements are inserted in ascending order which is efficient in an
* intset. */
dstset = createIntsetObject();
} else if (sets[0].set->encoding == OBJ_ENCODING_LISTPACK) {
/* To avoid many reallocs, we estimate that the result is a listpack
* of approximately the same size as the first set. Then we shrink
* it or possibly convert it to intset in the end. */
unsigned char *lp = lpNew(lpBytes(sets[0].set->ptr));
dstset = createObject(OBJ_SET, lp);
dstset->encoding = OBJ_ENCODING_LISTPACK;
} else {
/* We start off with a listpack, since it's more efficient to append
* to than an intset. Later we can convert it to intset or a
* hashtable. */
dstset = createSetListpackObject();
}
src/t_set.c:1460-1477。
13.1 为什么源是 intset 时结果用 intset
交集 ⊆ 任一源集合。所以如果 sets[0] 是 intset,交集的元素都是整数,一定能用 intset。
而且迭代 intset 时元素是升序 的(§1.1),逐个 intsetAdd 时每次都命中"插到末尾"的短路(§2.1)------每次 O(1)。所以构建一个 intset 结果集的总代价是 O(|结果|),不是 O(|结果|²)。
注释明说了这点:"The elements are inserted in ascending order which is efficient in an intset."
这是 intset 有序性的第二个回报(第一个是二分查找)。一个"无序集合"的内部有序性,让另一个操作的复杂度降了一整个数量级。
13.2 为什么源是 listpack 时预分配同样大小
lpNew(lpBytes(sets[0].set->ptr)) 直接分配与 sets[0] 一样大的 listpack。
交集大小 <= |sets[0]|,所以这个预分配是上界 ------一次分配,零 realloc。代价是结果集可能远小于它,最后要 lpShrinkToFit。
为什么不用"更紧的上界"?因为交集大小事先不知道。用 |sets[0]| 是唯一可用的上界。
13.3 为什么源是 hashtable 时用空 listpack
createSetListpackObject() = lpNew(0),从零开始。
注释的理由是 "listpack is more efficient to append to than an intset"。
这个理由有点奇怪------intset 的追加在"递增序"下也是 O(1)。真正的理由是:
hashtable的元素是任意顺序 的(哈希序)。逐个插入intset时,intsetSearch可能命中"插到中间"的路径,触发intsetMoveTail。总代价O(n²)。listpack的追加永远 O(1)(除非 realloc)。- 交集结果通常远小于源集合,所以先用
listpack装,最后再判断要不要转intset。
第 3 点是关键:only_integers + maybeConvertToIntset 的组合,让"事后判断"成为可能。
13.4 only_integers 的延迟判定
c
if (str && only_integers) {
/* It may be an integer although we got it as a string. */
if (encoding == OBJ_ENCODING_HT &&
string2ll(str, len, (long long *)&intobj))
{
if (dstset->encoding == OBJ_ENCODING_LISTPACK ||
dstset->encoding == OBJ_ENCODING_INTSET)
{
/* Adding it as an integer is more efficient. */
str = NULL;
}
} else {
/* It's not an integer */
only_integers = 0;
}
}
setTypeAddAux(dstset, str, len, intobj, encoding == OBJ_ENCODING_HT);
src/t_set.c:1510-1526。
only_integers 是"到目前为止,交集的元素全部 是整数"的标记。一旦置 0,就再也不会回到 1(这个 if (str && only_integers) 的短路保证)。
三种情况:
str == NULL(源是intset,或listpack的整数元素):不进if,标记保持 1。str != NULL且encoding == HT且string2ll成功:str置 NULL 改用整数形式插入,标记保持 1。- 其余:
only_integers = 0。
encoding == HT 这个前置条件是必需的:源是 HT 时所有元素都是 sds,没有整数形式可用,必须试 string2ll;而源是 listpack/intset 时 setTypeNext 已经用 str == NULL 给出了"是整数"的信号,str != NULL 就说明是字符串------代码因此假设"listpack 里的字符串一定不是数字形式"。
这个假设有反例 :listpack 里存着字符串编码的 "123"。lpGetValue 返回指针,走 else,only_integers = 0;而 "123" 确实是整数,maybeConvertToIntset 能转。后果不是错误,是错过一次优化 ------结果集留在 listpack 而不是 intset。
这个假设在 SADD k 123 时不成立:SADD 走 setTypeAdd → setTypeAddAux(subject, value, sdslen(value), 0, 1),str = "123",str_is_sds = 1。路径四(listpack + 字符串):
c
if (str == tmpbuf) {
lp = lpAppendInteger(lp, llval);
} else {
lp = lpAppend(lp, (unsigned char*)str, len);
}
src/t_set.c:169-175。str != tmpbuf(因为传的是 sds),所以走 lpAppend(lp, str, len)。
lpAppend 内部 lpEncodeGetType 会试 lpStringToInt64,成功就用整数编码!所以 SADD k 123 存进 listpack 的是整数编码。
那什么时候 listpack 里会有字符串形式的 "123"?
RDB加载:rdbLoadObject的RDB_TYPE_SET_LISTPACK分支直接memcpylistpack 字节,里面可能是字符串编码(老版本写的)。lpAppendInteger之外的路径不会产生。HLL、SINTERSTORE的lpAppend也会走整数编码。
所以这个漏判只在"从外部加载的 listpack"上发生,概率极低。属于"理论上不完美、实践中无所谓"的范畴。
13.5 maybeConvertToIntset 的一次遍历
c
static void maybeConvertToIntset(robj *set) {
if (set->encoding == OBJ_ENCODING_INTSET) return; /* already intset */
if (setTypeSize(set) > intsetMaxEntries()) return; /* can't use intset */
intset *is = intsetNew();
char *str;
size_t len = 0;
int64_t llval = 0;
setTypeIterator si;
setTypeInitIterator(&si, set);
while (setTypeNext(&si, &str, &len, &llval) != -1) {
if (str) {
/* If the element is returned as a string, we may be able to convert
* it to integer. This happens for OBJ_ENCODING_HT. */
serverAssert(string2ll(str, len, (long long *)&llval));
}
uint8_t success = 0;
is = intsetAdd(is, llval, &success);
serverAssert(success);
}
setTypeResetIterator(&si);
freeSetObject(set); /* frees the internals but not robj itself */
set->ptr = is;
set->encoding = OBJ_ENCODING_INTSET;
}
src/t_set.c:87-110。
serverAssert(string2ll(...)) 是关键:它断言"如果 only_integers 说全是整数,那每个字符串元素都必须能解析"。这个断言在 DEBUG_ASSERTIONS 之外也生效(serverAssert 是永久的)。
如果 only_integers 的判定有 bug,这里会 abort。这是"用断言把不变式固化"的典型做法。
freeSetObject(set) 又是"半释放"------清掉 listpack,保留 robj。然后换 ptr 和 encoding。
注意这里没有 lpShrinkToFit 的机会 :freeSetObject 直接 zfree(o->ptr)。所以 SINTERSTORE 的 lpShrinkToFit 在 maybeConvertToIntset 之后:
c
if (setTypeSize(dstset) > 0) {
if (only_integers) maybeConvertToIntset(dstset);
if (dstset->encoding == OBJ_ENCODING_LISTPACK) {
dstset->ptr = lpShrinkToFit(dstset->ptr);
}
src/t_set.c:1546-1552。顺序是"先尝试转 intset,如果还是 listpack 才 shrink"。如果已经转了,dstset->ptr 是 intset*,lpShrinkToFit 会把它当 listpack 解析------所以必须用 encoding 判断隔开。
13.6 intsetAdd 到 listpack 的隐性转换
maybeConvertToIntset 里 intsetAdd(is, llval, &success) 的返回值被赋给 is------因为 intsetAdd 可能 zrealloc(甚至 intsetUpgradeAndAdd 换了编码)。
serverAssert(success) 断言"每个元素都是新的"。如果源集合里有重复元素,会 abort。Set 语义保证没有重复,所以安全。
但如果源是 listpack 且里面有重复(损坏的数据),intsetAdd 返回 success = 0,断言失败。这是用断言做数据校验 的副作用------损坏的 listpack 会导致 abort 而不是优雅报错。不过 RDB 加载时 lpValidateIntegrityAndDups 已经查过重复了(src/rdb.c:3729),所以正常路径不会遇到。
14. SUNION 与 SDIFF:两套算法与一个"偏袒"
SUNION 和 SDIFF 共用一个函数(sunionDiffGenericCommand),SUNIONCARD/SDIFFCARD 也是。op 参数是 SET_OP_UNION 或 SET_OP_DIFF。
14.1 dstset_encoding 的三态演进
c
int dstset_encoding = OBJ_ENCODING_INTSET;
...
for (j = 0; j < setnum; j++) {
kvobj *setobj = lookupKeyRead(c->db, setkeys[j]);
if (!setobj) { sets[j].set = NULL; continue; }
...
if (!dstkey && dstset_encoding == OBJ_ENCODING_INTSET &&
(setobj->encoding == OBJ_ENCODING_LISTPACK || setobj->encoding == OBJ_ENCODING_HT)) {
dstset_encoding = OBJ_ENCODING_HT;
}
sets[j].set = setobj;
...
}
src/t_set.c:1662、src/t_set.c:1699-1702。
三个限制条件值得逐条读:
!dstkey:只在不 STORE 的时候优化。SUNIONSTORE/SDIFFSTORE一律用intset起步(因为dstkey != NULL,条件不成立,dstset_encoding保持初值INTSET)。dstset_encoding == OBJ_ENCODING_INTSET:只在"还没被改成HT"时判断。一旦改成HT就不再回头(因为是==而不是赋值)。- 源集合是
LISTPACK或HT:intset源的纯整数性会让dstset_encoding保持INTSET。
结果:
| 场景 | dstset_encoding |
dstset 初值 |
|---|---|---|
SUNION k1 k2,全 intset |
INTSET |
createIntsetObject() |
SUNION k1 k2,有一个 listpack |
HT |
createSetObject() |
SUNIONSTORE dest k1 k2 |
INTSET |
createIntsetObject() |
为什么"有 listpack 就升级到 HT"?注释(src/t_set.c:1696-1698)说:
If one of the set is OBJ_ENCODING_LISTPACK, let's set 'dstset' to hashtable default encoding, the hashtable is more efficient when find and compare than the listpack. The corresponding time complexity are O(1) vs O(n).
这是对的:UNION 要对每个元素做"是否已存在"的判定,HT 是 O(1),listpack 是 O(n)。用 listpack 装结果集,每次 setTypeAddAux 都要 lpFind------n 个元素就是 O(n²)。
那为什么 SUNIONSTORE 不也升到 HT? SUNIONSTORE 同样走 setTypeAddAux,同样需要查重,listpack 的 O(n) 查重一样存在。区别在于结果的生命周期 :SUNION 的 dstset 是临时的(回复完就 decrRefCount),SUNIONSTORE 的 dstset 直接变成 dstkey 的值,长期存在。
所以前者优先速度(HT 的 O(1)),后者优先内存(intset 每元素 2 字节,约 HT 的 1/3 到 1/5),并接受 intset 的 O(log n) 查重代价------比 listpack 的 O(n) 好得多。这个取舍在代码里只体现为 !dstkey 一个条件。
14.2 sameset 短路
c
if (j > 0 && sets[0].set == sets[j].set) {
sameset = 1;
}
...
} else if (op == SET_OP_DIFF && sameset) {
/* At least one of the sets is the same one (same key) as the first one, result must be empty. */
}
src/t_set.c:1706-1708、src/t_set.c:1804-1805。
SDIFF a b a:sets[0].set == sets[2].set,sameset = 1,结果为空。
这是正确的(A - B - A = ∅),而且 O(1)------不用真的算。
注意判据是指针相等 不是键名相等。SDIFF a b a 里两个 a 是同一个 robj(lookupKeyRead 从同一个 dict entry 取出),所以指针相等。
如果两个键指向内容相同的不同 robj(不可能,因为 db 里一个键只有一个 robj),判据会失效。所以指针相等是充分的。
14.3 diff_algo 的 /2 偏袒
c
if (op == SET_OP_DIFF && sets[0].set && !sameset) {
long long algo_one_work = 0, algo_two_work = 0;
for (j = 0; j < setnum; j++) {
if (sets[j].set == NULL) continue;
algo_one_work += setTypeSize(sets[0].set);
algo_two_work += setTypeSize(sets[j].set);
}
/* Algorithm 1 has better constant times and performs less operations
* if there are elements in common. Give it some advantage. */
algo_one_work /= 2;
diff_algo = (algo_one_work <= algo_two_work) ? 1 : 2;
src/t_set.c:1720-1733。
算法 1:迭代 sets[0],对每个元素查其余 M-1 个集合。工作量 ≈ |S0| × M。 算法 2:把 sets[0] 全部加入 dstset,再把 sets[1..M-1] 全部删除。工作量 ≈ Σ|Si|。
algo_one_work = |S0| × M_nonempty,algo_two_work = Σ|Si|。然后 /2。
除以 2 是给算法 1 一个 2 倍的宽容度。理由是算法 1 的"单位操作"更便宜:
- 算法 1 的每次操作是
setTypeIsMemberAux(只读查找),可能提前break(一旦发现某个集合包含该元素,就不用再查剩下的)。 - 算法 2 的每次操作是
setTypeAddAux或setTypeRemoveAux(写操作),要改dstset,还可能有 realloc。
所以 algo_one_work <= algo_two_work 这个判据实际是"算法 1 的名义工作量不超过算法 2 的 2 倍"。
注释还说 "performs less operations if there are elements in common"------交集越大,算法 1 的 break 越早,实际工作量远小于 |S0| × M。而 /2 是把这个"提前退出"的红利折算成一个常数因子。
这个偏袒的边界很模糊:如果 S0 与所有其他集合完全不相交 ,算法 1 每次都要查满 M-1 个,/2 的偏袒就是纯损失(选了更慢的算法)。如果 S0 与第一个集合就高度相交,/2 是赚的。
代码没有尝试更精确的估算(比如采样估计重叠度),因为 SDIFF 不是热点命令。
14.4 算法 1 的集合排序
c
if (diff_algo == 1 && setnum > 1) {
/* With algorithm 1 it is better to order the sets to subtract
* by decreasing size, so that we are more likely to find
* duplicated elements ASAP. */
qsort(sets+1,setnum-1,sizeof(setopsrc),
qsortCompareSetsByRevCardinality);
}
src/t_set.c:1735-1741。注意排序范围是 sets+1(从第二个开始),sets[0] 是驱动集不能动。
qsortCompareSetsByRevCardinality 是降序:
c
int qsortCompareSetsByRevCardinality(const void *s1, const void *s2) {
robj *o1 = ((setopsrc*)s1)->set, *o2 = ((setopsrc*)s2)->set;
unsigned long first = o1 ? setTypeSize(o1) : 0;
unsigned long second = o2 ? setTypeSize(o2) : 0;
if (first < second) return 1;
if (first > second) return -1;
return 0;
}
src/t_set.c:1380-1388。注意这里有 NULL 检查(o1 ? ... : 0),因为 sets+1 里可能有 NULL(不存在的键)。
降序的理由:内层循环是"查到一个集合包含就 break"。大集合更可能包含该元素,所以先查大集合能更早 break。
这是一个概率性优化 :如果被删的集合与 S0 高度重叠,大集合先查确实省。如果完全不重叠,顺序无关。
14.5 算法 2 的 cardinality == 0 提前退出
c
} else if (op == SET_OP_DIFF && sets[0].set && diff_algo == 2) {
for (j = 0; j < setnum; j++) {
if (!sets[j].set) continue;
setTypeInitIterator(&si, sets[j].set);
while((encoding = setTypeNext(&si, &str, &len, &llval)) != -1) {
if (j == 0) {
cardinality += setTypeAddAux(dstset, str, len, llval, ...);
} else {
cardinality -= setTypeRemoveAux(dstset, str, len, llval, ...);
}
}
setTypeResetIterator(&si);
/* Exit if result set is empty as any additional removal
* of elements will have no effect. */
if (cardinality == 0) break;
}
}
src/t_set.c:1861-1887。
cardinality == 0 时 break------结果已经空了,再删也没有意义。
这个判断放在每个集合处理完之后,不是每个元素之后。所以如果删到一半就空了,还要把当前集合跑完。
为什么不逐元素判断?因为 cardinality 只会因 setTypeRemoveAux 减少,而在一个集合内减少到 0 之后,后续的 setTypeRemoveAux 都返回 0(元素已不在)。所以提前退出省不了多少。
14.6 must_track_memory 的判据
c
/* Memory tracking is only needed for SET_OP_DIFF. UNION just iterates the
* source sets; it never calls dictFind/dictAdd/dictDelete on a passed key,
* so it can't advance a rehash and change a source set's allocation size. */
int must_track_memory = (op != SET_OP_UNION && server.memory_tracking_enabled);
src/t_set.c:1670-1673。
这段注释解释了一个非平凡的事实:UNION 不会改变源集合的 alloc_size。
为什么?因为 UNION 只做 setTypeAddAux(dstset, ...)------操作的是 dstset,不是源集合。
但 DIFF 会:
- 算法 1 调
setTypeIsMemberAux(sets[j].set, ...)→dictFind→ 推进 rehash。 - 算法 2 调
setTypeRemoveAux(dstset, ...)→dictDelete→ 同样推进 rehash。
dict 的 rehashidx 每次操作推进一格,导致 ht_table[0] 的桶数组被逐步释放(dictRehash 搬完后 free)。桶数组的大小变了,alloc_size 就变了。
所以 DIFF 必须在操作后重新计算源集合的 alloc_size 并更新 updateSlotAllocSize。
SINTER 那边是无条件检查 server.memory_tracking_enabled(src/t_set.c:1532),因为它一定会调 setTypeIsMemberAux。
这个优化的前提是"UNION 不触发源集合的任何 dict 操作" 。如果将来给 UNION 加一个"顺便统计源集合的编码"的优化,这个前提就破了。
14.7 算法 2b:为 LIMIT 专门造的第四条路
c
} else if (op == SET_OP_DIFF && sets[0].set && diff_algo == 2 && cardinality_only && limit > 0) {
/* DIFF Algorithm 2b (cardinality-only with LIMIT):
*
* Collect sets[1..N] into a single auxiliary set and iterate
* the first set, counting elements absent from it with
* a single membership lookup each. This lets us stop as
* soon as LIMIT is reached. */
for (j = 1; j < setnum; j++) {
if (!sets[j].set) continue;
setTypeInitIterator(&si, sets[j].set);
while ((encoding = setTypeNext(&si, &str, &len, &llval)) != -1)
setTypeAddAux(dstset, str, len, llval, encoding == OBJ_ENCODING_HT);
setTypeResetIterator(&si);
}
setTypeInitIterator(&si, sets[0].set);
while ((encoding = setTypeNext(&si, &str, &len, &llval)) != -1) {
if (!setTypeIsMemberAux(dstset, str, len, llval, encoding == OBJ_ENCODING_HT)) {
cardinality++;
if (cardinality >= limit) break;
}
}
src/t_set.c:1837-1860。
这是全文件最值得说的设计。算法 2 的 cardinality 非单调:
c
cardinality += setTypeAddAux(...); // 加
cardinality -= setTypeRemoveAux(...); // 减
先加后减,中途会冲高再回落。所以"cardinality >= limit 就 break"是错的 ------可能中途冲过 limit 然后回落。
算法 1 的 cardinality 单调(只增),所以能 break。
算法 2b 把"减"改成了"查":先把所有要减的集合并成一个 dstset,然后迭代 sets[0],每个元素查一次 dstset。这样 cardinality 只增不减,可以 break。
代价:多了一个 dstset 的构建(Σ|sets[1..n]| 次插入),但省掉了 sets[0] 的 O(|S0|) 次删除。而且能提前退出。
所以 SDIFFCARD ... LIMIT 的复杂度是 O(Σ_{i>=1}|Si| + 到达 limit 所需的 |S0| 前缀) ,而不是 O(Σ|Si|)。
如果 limit 很小(比如 1),算法 2b 在 sets[0] 的第一个"不在 dstset 里"的元素就退出。而算法 2 必须把 sets[0] 全部加进去、再把其他全部删完。
这个"为 LIMIT 换算法"的设计说明:LIMIT 不只是一个参数,它改变了最优算法的形态。
15. 三张 CARD 命令与 LIMIT 的三种命运
SINTERCARD(7.0)、SUNIONCARD(8.10)、SDIFFCARD(8.10)都支持 LIMIT。三者的 cardinality 单调性不同,所以 LIMIT 的实现方式也不同。
15.1 SINTERCARD:天然单调
c
if (cardinality_only) {
cardinality++;
/* We stop the searching after reaching the limit. */
if (limit && cardinality >= limit)
break;
}
src/t_set.c:1497-1502。每找到一个共同元素就 cardinality++,达到 limit 就 break。
因为 SINTER 的语义是"在所有集合里都出现的元素",cardinality 只增。所以 LIMIT 的语义是"最多数到 limit 个就停"。
注意 break 的位置 :只跳出 while(外层遍历 sets[0] 的循环),内层 for 已经结束了(因为要 j == setnum 才进这个分支)。
limit 是 unsigned long,0 表示无限制。判据写成 if (limit && cardinality >= limit),所以 LIMIT 0 等价于"无限制"。
15.2 SUNIONCARD:也能单调,但代价不同
c
if (!approx) {
cardinality += setTypeAddAux(dstset, str, len, llval, encoding == OBJ_ENCODING_HT);
if (cardinality_only && limit > 0 && cardinality >= limit) {
early_exit = 1;
break;
}
continue;
}
src/t_set.c:1766-1772。
cardinality 是 setTypeAddAux 的返回值累加------只有真正插入新元素时才 +1 (重复元素返回 0)。所以 cardinality 单调递增,可以 break。
early_exit 标志控制外层循环:
c
for (j = 0; j < setnum && !early_exit; j++) {
src/t_set.c:1761。因为有两层循环,内层 break 之后还要让外层也停。
SUNIONCARD 的 LIMIT 仍然要建 dstset ------因为要去重。这是它和 SINTERCARD 的根本区别:SINTERCARD 不需要结果集(只查成员),SUNIONCARD 必须记住"见过哪些元素"。
所以 SUNIONCARD ... LIMIT 10 在一个 1000 万元的集合上,仍然要插入最多 10 个元素到 dstset(达到 10 就停)。而如果不带 LIMIT,要插入 1000 万个。
LIMIT 在这里的作用是"限制内存",不只是"限制时间" 。这是 8.10 新增 SUNIONCARD 时的一个设计目标。
15.3 SDIFFCARD:算法 2 的单调性问题
SDIFF 的算法 2 用"先加后减",cardinality 非单调。所以:
- 如果选中算法 1:
cardinality++单调,能break(src/t_set.c:1826-1830)。 - 如果选中算法 2 且
limit > 0:强制走算法 2b(src/t_set.c:1837),单调,能break。 - 如果选中算法 2 且
limit == 0:走原算法 2,cardinality非单调,但不需要break。
三种组合覆盖了所有情况。
代价是算法 2b 和算法 2 是两套几乎重复的代码 (src/t_set.c:1837-1887,50 行)。它们的前半段不同(2b 建辅助集、2 建 dstset 再删),后半段都在处理输出。如果将来 SDIFF 的语义变了,两处都要改。
15.4 limit 的类型与 SINTERCARD 的边界
c
if (getPositiveLongFromObjectOrReply(c, c->argv[j], &limit,
"LIMIT can't be negative") != C_OK)
return;
src/t_set.c:1633-1635。limit 是 long,传给 sinterGenericCommand 的参数是 unsigned long。
getPositiveLongFromObjectOrReply 允许 0 和正数,拒绝负数。所以 LIMIT 0 是合法的,语义是"无限制"。
SINTERCARD 1 key LIMIT 0 = 返回 |key|。
LIMIT 没有上界检查,所以 LIMIT 9223372036854775807 是合法的,等价于无限制(因为 cardinality 是 unsigned long,不可能到那么大)。
15.5 numkeys 的校验
三张 CARD 命令的解析逻辑几乎一样:
c
if (getRangeLongFromObjectOrReply(c, c->argv[1], 1, LONG_MAX,
&numkeys, "numkeys should be greater than 0") != C_OK)
return;
if (numkeys > (c->argc - 2)) {
addReplyError(c, "Number of keys can't be greater than number of args");
return;
}
src/t_set.c:1619-1625。numkeys >= 1,且不超过剩余参数数。
第二个检查允许 numkeys < argc - 2(剩下的参数当成 LIMIT/APPROX 选项解析)。如果 numkeys == argc - 2,没有选项。
SINTERCARD 2 k1 k2 LIMIT 5:argc = 6,numkeys = 2,2 <= 4 通过。然后从 j = 2 + 2 = 4 开始解析选项。
SINTERCARD 2 k1 k2 k3(多给了一个键):numkeys = 2,j = 4 开始解析 k3 ------ 不是 LIMIT,报语法错。
所以 numkeys 是权威的,多余的键会被当选项解析然后报错。
16. SUNIONCARD APPROX:HLL 与自适应检查间隔
SUNIONCARD ... APPROX 是 8.10 新增的近似基数计算。它不走 setTypeAddAux,而是把元素喂进一个 HyperLogLog:
c
if (approx) hllobj = createHLLObject();
int early_exit = 0;
long elements_processed = 0;
long check_after = limit; /* For approx: first check after `limit` elements. */
src/t_set.c:1757-1760。
16.1 createHLLObject 是 sparse 编码
HLL 的默认编码是 HLL_SPARSE(src/hyperloglog.c),初始大小约 3 字节 + 16 字节 header。当基数增长到一定程度(寄存器里非零值变多)时,hllSparseAdd 会自动转成 HLL_DENSE(12 KB + header)。
hllAdd 分派:
c
int hllAdd(robj *o, unsigned char *ele, size_t elesize) {
struct hllhdr *hdr = o->ptr;
switch(hdr->encoding) {
case HLL_DENSE: return hllDenseAdd(hdr->registers,ele,elesize);
case HLL_SPARSE: return hllSparseAdd(o,ele,elesize);
default: return -1; /* Invalid representation. */
}
}
src/hyperloglog.c:1015-1021。
hllAdd 只会在 HLL 头部损坏时返回 -1,而这里 hllobj 是刚 createHLLObject 出来的,头部不可能损坏。所以:
c
/* hllAdd() only fails on a corrupted HLL header, which cannot
* happen for an HLL we just created ourselves. */
serverAssert(retval != -1);
src/t_set.c:1786-1788。
16.2 整数元素要先字符串化
c
if (str != NULL) {
retval = hllAdd(hllobj, (unsigned char *)str, len);
} else {
char buf[LONG_STR_SIZE];
size_t slen = ll2string(buf, sizeof(buf), (long long)llval);
retval = hllAdd(hllobj, (unsigned char *)buf, slen);
}
src/t_set.c:1779-1785。HLL 吃字节,整数要先转字符串。这与 SUNIONCARD 精确路径的"整数直通"形成对比------精确路径可以把 llval 直接传给 setTypeAddAux,近似路径必须字符串化。
所以 SUNIONCARD ... APPROX 在一个 intset 上的成本比精确路径更高 (每元素一次 ll2string)。只有当元素数极大、dstset 的内存不可接受时,APPROX 才划算。
16.3 自适应的 check_after
c
elements_processed++;
if (limit > 0 && elements_processed >= check_after) {
uint64_t est = hllCount(hllobj->ptr, NULL);
if (est >= (uint64_t)limit) {
early_exit = 1;
break;
}
long remaining = (long)limit - (long)est;
check_after = elements_processed +
(remaining > HLL_CHECK_INTERVAL_FLOOR ? remaining : HLL_CHECK_INTERVAL_FLOOR);
}
src/t_set.c:1790-1800。
第一次检查在 elements_processed == limit 时(check_after 初值是 limit)。
之后的间隔是 max(remaining, HLL_CHECK_INTERVAL_FLOOR):
remaining=limit - est,即"还需要多少才能到 limit"。HLL_CHECK_INTERVAL_FLOOR = 1024(src/t_set.c:25)。
为什么是 max 而不是固定 1024?
HLL 的标准误差是 1.04/sqrt(16384) ≈ 0.81%。当 est 离 limit 还差 remaining 个时,要观察到有意义的变化,需要处理大约 remaining 个元素(因为 est 每处理 remaining/est 比例的元素才变化 remaining)。
所以"间隔 = remaining"是按误差比例自适应 的:remaining 大说明离得远,可以等更久;remaining 小说明快到了,要更频繁地查。
而 HLL_CHECK_INTERVAL_FLOOR 保证"至少 1024 个元素才查一次"------因为 hllCount 本身要遍历 16384 个寄存器(dense)或解析 sparse 编码,不是免费的。如果 remaining 只有 10,每次处理 10 个元素就查一次,检查成本会超过处理成本。
设 processed = p、est = e(有重复时 e < p),则 check_after = p + max(limit - e, 1024):
- 低重复(
e ≈ p) :check_after = p + (limit - e) ≈ limit,第一次就在p = limit处查,此时e ≈ limit,est >= limit成立,退出。只查一次。 - 50% 重复(
e ≈ p/2) :p = limit时remaining = limit/2,check_after = 1.5×limit;届时e ≈ 0.75×limit、remaining = 0.25×limit,check_after = 1.75×limit;再下次e ≈ 0.875×limit......收敛到2×limit。检查次数O(log(limit)),间隔几何递增。
也就是说,无重复场景下这个自适应退化成"只在 limit 处查一次",重复率越高才越频繁地查------正是需要的方向。
但有个隐患 :HLL 的 est 有 0.81% 的误差。当 e 接近 limit 时,est 可能报出 limit × 1.008,于是提前退出------返回的 cardinality 会被截断到 limit(src/t_set.c:1902-1903),客户端看到 limit,以为到了,实际可能只有 0.992 × limit。
这是 APPROX 的固有语义:返回值是近似值,LIMIT 是"软上限"。
16.4 最后一步的截断
c
if (cardinality_only) {
if (approx) {
cardinality = hllCount(hllobj->ptr, NULL);
if (limit > 0 && cardinality > limit)
cardinality = limit;
decrRefCount(hllobj);
}
addReplyLongLong(c, cardinality);
src/t_set.c:1899-1906。
两条路径都要截断:
- 早退路径(
early_exit = 1):cardinality还是 0(因为没走setTypeAddAux的累加),必须靠hllCount重新算。 - 正常结束路径:
hllCount算出完整估计,如果超过limit也截断。
为什么早退时 cardinality 是 0 ?因为 approx 路径下 cardinality 从来没被累加(只有 !approx 分支才 cardinality += ...)。所以必须靠 hllCount 兜底。
这意味着 SUNIONCARD ... APPROX LIMIT n 在早退时还要一次完整的 hllCount。这个成本是 16384 个寄存器的遍历(dense)或 sparse 编码的解析,约几十微秒。
如果 limit 很小(比如 10),而这个集合有 1000 万个元素,早退发生在处理 10 个元素之后------但 hllCount 的成本远大于那 10 次 hllAdd。所以 APPROX + 小 LIMIT 是不划算的 :直接用精确路径(SINTERCARD 式的手工去重)更快。
APPROX 的合理场景是"limit 很大(几十万以上)且集合极大"------此时 HLL 的固定 12 KB 内存远小于 dstset 的几 GB。
16.5 serverAssert 保证 approx 只和 UNION 组合
c
/* Approximate cardinality is only ever requested for SUNIONCARD, i.e. a
* non-storing UNION that returns a count. */
serverAssert(!approx || (op == SET_OP_UNION && cardinality_only && dstkey == NULL));
src/t_set.c:1654-1656。
三个条件同时成立才允许 approx。SDIFFCARD 没有 APPROX(src/t_set.c:2020-2028 只解析 LIMIT),SINTERCARD 也没有。
为什么 DIFF 不能用 HLL?A - B ≈ A ∪ B - B 是错的(HLL 无法做差集)。HLL 只支持并集(PFMERGE)和基数估计,不支持交集/差集。所以 SINTERCARD/SDIFFCARD 只能精确算。
这也是 SINTERCARD 比 SUNIONCARD 早两年出现的原因之一------SINTERCARD 的精确算法(迭代最小集合)本身就很省内存,不需要近似。
17. SPOP:三种策略与复制改写
SPOP key count 是 Set 里最复杂的命令,src/t_set.c:838-1063,225 行。
17.1 三种策略的选择
c
unsigned long remaining = size-count; /* Elements left after SPOP. */
#define SPOP_MOVE_STRATEGY_MUL 5
...
if (remaining*SPOP_MOVE_STRATEGY_MUL > count &&
set->encoding == OBJ_ENCODING_LISTPACK)
{
/* Specialized case for listpack. Traverse it only once. */
...
} else if (remaining*SPOP_MOVE_STRATEGY_MUL > count) {
...
} else {
/* CASE 3: we extract random elements that we don't want to return
* (the elements that will remain part of the set), creating a new set
* as we do this. */
}
src/t_set.c:900-970。remaining = size - count,判据 remaining × 5 > count 即 count < size × 5/6:小于就逐个弹出(CASE 2),大于等于就反向挑出 remaining 个保留(CASE 3)。
为什么分界点是 5/6 而不是 1/2? 如果两种策略单位成本相同,分界点应该是 count == remaining。但 CASE 3 更贵:它要建一个新集合(setTypeAddAux 可能触发 realloc 或编码转换),最后还要 dbReplaceValue 替换原键;CASE 2 只在原集合上删。5 这个系数就是"CASE 3 单位成本约是 CASE 2 的 5 倍"的估算------remaining × 5 > count 等价于 count < 5 × remaining,即只有当 count 比 remaining 大 5 倍以上才值得反向挑。
17.2 CASE 1:整个集合返回
c
if (count >= size) {
/* We just return the entire set */
sunionDiffGenericCommand(c,c->argv+1,1,NULL,SET_OP_UNION,0,0,0);
/* Delete the set as it is now empty */
dbDelete(c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[1],c->db->id);
...
robj *aux = server.lazyfree_lazy_server_del ? shared.unlink : shared.del;
rewriteClientCommandVector(c, 2, aux, c->argv[1]);
src/t_set.c:869-883。复用 SUNION 来输出整个集合 ------sunionDiffGenericCommand(c, c->argv+1, 1, NULL, SET_OP_UNION, 0, 0, 0) 就是"对一个集合求并集",输出格式与 SPOP key count 相同(数组)。零重复代码,不写"遍历集合、回复每个元素"。
然后 dbDelete + rewriteClientCommandVector(c, 2, aux, key) 把 SPOP key 100 改写成 DEL key(或 UNLINK key)------"弹出全部"等价于"删除",客户端已经拿到所有元素。shared.unlink vs shared.del 取决于 lazyfree_lazy_server_del,这是 SPOP 唯一能利用 lazyfree 的路径。
17.3 CASE 2 的 listpack 特化
c
unsigned char *lp = set->ptr;
unsigned char *p = lpFirst(lp);
unsigned int index = 0;
unsigned char **ps = zmalloc(sizeof(char *) * count);
for (unsigned long i = 0; i < count; i++) {
p = lpNextRandom(lp, p, &index, count - i, 1);
unsigned int len = 0;
str = (char *)lpGetValue(p, &len, (long long *)&llele);
...
/* Store pointer for later deletion and move to next. */
ps[i] = p;
p = lpNext(lp, p);
index++;
}
lp = lpBatchDelete(lp, ps, count);
src/t_set.c:915-947。lpNextRandom(lp, p, &index, count - i, 1) 的第四个参数是"还需要挑几个",第五个是 tuple_len = 1(每个元素是一个逻辑项),返回值是选中元素指针并把 index 更新为它的下标(§18.4 详述)。
选中之后先 ps[i] = p(存进待删列表)再 p = lpNext(lp, p),index++ 与 p 保持同步。
lpBatchDelete 一次删掉所有选中元素:
c
unsigned char *dst = ps[0];
...
for (unsigned long i = 0; i < count; i++) {
unsigned char *skip = ps[i];
unsigned char *keep_start = lpSkip(skip);
unsigned char *keep_end;
if (i + 1 < count) {
keep_end = ps[i + 1];
if (keep_start == keep_end) continue;
} else {
keep_end = lp_end;
}
memmove(dst, keep_start, bytes_to_keep);
dst += bytes_to_keep;
}
src/listpack.c:1521-1560。一次 memmove 完成所有删除 :count 个删除点把 listpack 分成 count+1 段,只搬保留段。对比 count 次 lpDelete(每次要搬被删元素之后的所有字节,总成本 O(count × n)),lpBatchDelete 是 O(n)。
对 SPOP k 100 作用在 128 元素的 listpack 上:lpDelete 100 次约搬 60 万字节,lpBatchDelete 搬约 28 个元素 × 3 字节 = 84 字节。差三个数量级。
17.4 CASE 2 的通用路径
c
for (unsigned long i = 0; i < count; i++) {
propargv[propindex] = setTypePopRandom(set);
addReplyBulk(c, propargv[propindex]);
propindex++;
if (propindex == 2 + batchsize) { ... }
}
src/t_set.c:954-966。setTypePopRandom 每次"随机取 + 删除",非 listpack 分支:
c
int encoding = setTypeRandomElement(set, &str, &len, &llele);
if (str)
obj = createStringObject(str, len);
else
obj = createStringObjectFromLongLong(llele);
setTypeRemoveAux(set, str, len, llele, encoding == OBJ_ENCODING_HT);
src/t_set.c:455-481。setTypeRemoveAux 的 str_is_sds = (encoding == OBJ_ENCODING_HT),HT 下 str 是 dictGetKey 的 sds,不拷贝。但 createStringObject 已经给回复拷了一份------这个拷贝是必需的 ,因为 setTypeRemoveAux 之后 str 指向的内存被释放,obj 必须自己持有一份。
17.5 CASE 3:反向挑保留
c
robj *newset = NULL;
if (set->encoding == OBJ_ENCODING_LISTPACK) {
/* Specialized case for listpack. Traverse it only once. */
newset = createSetListpackObject();
...
for (unsigned long i = 0; i < remaining; i++) {
p = lpNextRandom(lp, p, &index, remaining - i, 1);
...
setTypeAddAux(newset, str, len, llele, 0);
ps[i] = p;
p = lpNext(lp, p);
index++;
}
lp = lpBatchDelete(lp, ps, remaining);
...
} else {
while(remaining--) {
int encoding = setTypeRandomElement(set, &str, &len, &llele);
if (!newset) {
newset = str ? createSetListpackObject() : createIntsetObject();
}
setTypeAddAux(newset, str, len, llele, encoding == OBJ_ENCODING_HT);
setTypeRemoveAux(set, str, len, llele, encoding == OBJ_ENCODING_HT);
}
}
src/t_set.c:983-1012。
listpack 特化 :挑出 remaining 个放进 newset,从原 listpack 删掉这 remaining 个,剩下的 count 个返回客户端。通用路径 逐次"随机取 + 加到 newset + 从原集合删",newset 惰性创建且初值由第一个元素的形态决定(str != NULL 用 listpack,整数用 intset)。
remaining == 0 时通用路径的循环不执行、newset 保持 NULL------但那只可能是 count == size,已在 CASE 1 返回,所以这里 remaining >= 1。
然后把原集合剩下的 count 个元素回复给客户端,用 newset 替换原键:
c
updateKeysizesHist(c->db, OBJ_SET, size, size-count);
...
dbReplaceValue(c->db, c->argv[1], &newset, 0);
set = newset;
src/t_set.c:1040-1044。updateKeysizesHist 的注释值得读:
Update key size histogram "explicitly" and not indirectly by dbReplaceValue() since function dbReplaceValue() assumes the entire set is being replaced, but here we're building the new set from the existing one. As a result, the size of the old set has already changed by the time we reach this point.
dbReplaceValue 会认为"旧集合大小 = 旧大小",但此时原集合已经被删到 count 个了,所以直方图要手动更新,用 size(原始)和 size - count(新)。这类"直方图口径"的细节是 8.10 新增 keysizes_hist 带来的(第 3 章),容易在"值被原地修改后替换"的路径上出错。
17.6 复制改写:分批 SREM
c
unsigned long batchsize = count > 1024 ? 1024 : count;
robj **propargv = zmalloc(sizeof(robj *) * (2 + batchsize));
propargv[0] = shared.srem;
propargv[1] = c->argv[1];
unsigned long propindex = 2;
addReplySetLen(c,count);
src/t_set.c:889-894。SPOP 弹出的元素是随机的 ,不能原样传播------副本上随机数不同,会弹出不同元素。所以改写成一串 SREM key elem1 ... elemN,每批最多 1024 个。
分批的理由:alsoPropagate 的参数数组预分配(2 + batchsize 个 robj*)不能无限增长,且单条命令受 proto-max-bulk-len(默认 512 MB)限制。1024 是平衡点------太小会让复制流命令数量爆炸(SPOP key 100000 变成 10000 条 SREM),太大则单条 SREM 的参数数组占几 MB 内存、在从库上执行时阻塞。SPOP key 1000000 因此变成约 977 条 SREM。
AOF 重放的一致性由此保证:原来 SPOP key 100 弹 100 个随机 元素,重放时 SREM key elem1 ... elem100 删掉这 100 个具体 元素,结果相同。如果 AOF 里存 SPOP,重放会再随机弹一次,数据就错了。
17.7 preventCommandPropagation 与 server.dirty
c
/* Don't propagate the command itself even if we incremented the
* dirty counter. We don't want to propagate an SPOP command since
* we propagated the command as a set of SREMs operations using
* the alsoPropagate() API. */
preventCommandPropagation(c);
src/t_set.c:1057-1061。而 server.dirty += toRemove 在函数开头就加了(src/t_set.c:864),其中 toRemove = (count >= size) ? size : count 是实际要删的数量。
所以 dirty 增加了、命令本身却不传播。这个不一致是刻意的:dirty 用于"是否需要 BGSAVE"的判断,而 SREM 确实修改了 toRemove 个元素,所以 dirty 该增加;复制流的形式与 dirty 无关。
alsoPropagate 把 SREM 塞进待传播队列,preventCommandPropagation 把当前命令标记为不传播,两者配合后复制流里只有 SREM。这个模式在所有"随机命令"里都存在 :SPOP、SRANDMEMBER(只读不传播)、RANDOMKEY(不传播)、EXPIRE(改写为 PEXPIREAT)、INCRBYFLOAT(改写为 SET)。
18. SRANDMEMBER:四种策略与 lpNextRandom
SRANDMEMBER key count 有五种情况(src/t_set.c:1130-1332):
| 情况 | 条件 | 是否允许重复 | 顺序 |
|---|---|---|---|
| CASE 1 | count < 0 或 count == 1 |
是(负数)/ 无意义(1) | 随机 |
| CASE 2 | count >= size |
否 | 集合遍历序 |
| CASE 2.5 | listpack 且 count < size |
否 | 随机(不重复) |
| CASE 3 | count × 3 > size |
否 | 字典序 |
| CASE 4 | 其余 | 否 | 字典序 |
18.1 CASE 1:允许重复
c
if (!uniq || count == 1) {
addReplyArrayLen(c,count);
if (set->encoding == OBJ_ENCODING_LISTPACK && count > 1) {
/* Specialized case for listpack, traversing it only once. */
unsigned long limit, sample_count;
limit = count > SRANDFIELD_RANDOM_SAMPLE_LIMIT ? SRANDFIELD_RANDOM_SAMPLE_LIMIT : count;
listpackEntry *entries = zmalloc(limit * sizeof(listpackEntry));
while (count) {
sample_count = count > limit ? limit : count;
count -= sample_count;
lpRandomEntries(set->ptr, sample_count, entries);
for (unsigned long i = 0; i < sample_count; i++) { ... }
if (c->flags & CLIENT_CLOSE_ASAP) break;
}
zfree(entries);
return;
}
while(count--) {
setTypeRandomElement(set, &str, &len, &llele);
...
}
return;
}
src/t_set.c:1166-1202。!uniq 就是 count < 0;count == 1 也走这里,因为"取 1 个不重复的"和"取 1 个"是一回事,不需要去重字典。
listpack 特化用 lpRandomEntries(允许重复):
c
void lpRandomEntries(unsigned char *lp, unsigned int count, listpackEntry *entries) {
struct pick {
unsigned int index;
unsigned int order;
} *picks = lp_malloc(count * sizeof(struct pick));
unsigned int total_size = lpLength(lp);
assert(total_size);
for (unsigned int i = 0; i < count; i++) {
picks[i].index = rand() % total_size;
picks[i].order = i;
}
/* Sort by index. */
qsort(picks, count, sizeof(struct pick), uintCompare);
unsigned char *p = lpFirst(lp);
unsigned int j = 0;
for (unsigned int i = 0; i < count; i++) {
while (j < picks[i].index) {
p = lpNext(lp, p);
j++;
}
int storeorder = picks[i].order;
...
lpSaveValue(str, len, llval, &entries[storeorder]);
}
lp_free(picks);
}
src/listpack.c:1919-1948。先随机生成 count 个下标、排序,然后一遍扫描填充 。order 字段记住"这个样本对应输出数组的第几个位置",所以输出顺序随机(按 order)、扫描顺序线性(按 index)。
这一步排序把"count 次 O(n) 的 lpSeek"降成"一次 O(n) 扫描 + count log count 排序"。count = 1000、n = 128 时:排序约 10000 次比较 + 128 步扫描,比 1000 次 lpSeek(平均 64 步 × 1000 = 64000 步)快 6 倍。
SRANDFIELD_RANDOM_SAMPLE_LIMIT = 1000(src/t_set.c:1128)限制单批大小,避免 entries 数组过大(listpackEntry 是 24 字节,1000 个 = 24 KB),分批后 entries 复用。
if (c->flags & CLIENT_CLOSE_ASAP) break; 在每批之后 检查------SRANDMEMBER k -1000000000 试图生成十亿个元素,最坏情况是多生成 1000 个才发现客户端断了。这是吞吐与响应性的折衷。
18.2 CASE 2:count >= size 返回全集
c
if (count >= size) {
addReplyArrayLen(c,size);
setTypeInitIterator(&si, set);
while (setTypeNext(&si, &str, &len, &llele) != -1) { ... size--; }
setTypeResetIterator(&si);
serverAssert(size==0);
return;
}
src/t_set.c:1207-1222。count >= size 时返回全部元素 且不重复(Set 语义保证),即使 count > size。serverAssert(size==0) 校验"迭代次数 == 声明的大小",集合在迭代期间被修改(单线程下不可能)才会失败。
18.3 CASE 2.5:listpack 的唯一采样
c
if (set->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *lp = set->ptr;
unsigned char *p = lpFirst(lp);
unsigned int i = 0;
addReplyArrayLen(c, count);
while (count) {
p = lpNextRandom(lp, p, &i, count--, 1);
unsigned int len;
str = (char *)lpGetValue(p, &len, (long long *)&llele);
...
p = lpNext(lp, p);
i++;
}
return;
}
src/t_set.c:1232-1250。remaining 参数是 count--(先取值再减),与 SPOP 的 count - i 等价。这里没有删除 ------SRANDMEMBER 是只读的,所以 p = lpNext(lp, p) 只是前进一格。
注释解释了为什么不用 CASE 3/4:
Listpack encoded sets are meant to be relatively small, so SRANDMEMBER_SUB_STRATEGY_MUL isn't necessary and we rather not make copies of the entries. Instead, we emit them directly to the output buffer.
And it is inefficient to repeatedly pick one random element from a listpack in CASE 4. So we use this instead.
两个理由:不想拷贝元素(CASE 3 要把全集拷进 dict),且 CASE 4 的重复采样在 listpack 上太慢。
18.4 lpNextRandom:一遍扫描的等概率抽样
这是 SPOP/SRANDMEMBER 在 listpack 上的核心算法,src/listpack.c:2079-2112:
c
unsigned char *lpNextRandom(unsigned char *lp, unsigned char *p, unsigned int *index,
unsigned int remaining, int tuple_len)
{
assert(tuple_len > 0);
/* To only iterate once, every time we try to pick a member, the probability
* we pick it is the quotient of the count left we want to pick and the
* count still we haven't visited. This way, we could make every member be
* equally likely to be picked. */
unsigned int i = *index;
unsigned int total_size = lpLength(lp);
while (i < total_size && p != NULL) {
if (i % tuple_len != 0) {
p = lpNext(lp, p);
i++;
continue;
}
/* Do we pick this element? */
unsigned int available = (total_size - i) / tuple_len;
double randomDouble = ((double)rand()) / RAND_MAX;
double threshold = ((double)remaining) / available;
if (randomDouble <= threshold) {
*index = i;
return p;
}
p = lpNext(lp, p);
i++;
}
return NULL;
}
核心思想 :扫到第 i 个时,"还没访问的有 available 个"、"还要挑 remaining 个",所以当前元素被选中的概率取 remaining / available。
均匀性的归纳证明 :设总数 n、要挑 k。第 1 个被选中的概率是 k/n。若第 1 个被选中(概率 k/n),剩 k-1 个从 n-1 个里挑,第 2 个的条件概率是 (k-1)/(n-1);若没被选中(概率 (n-k)/n),剩 k 个从 n-1 个里挑,条件概率是 k/(n-1)。第 2 个被选中的总概率:
scss
(k/n)·(k-1)/(n-1) + (n-k)/n·k/(n-1) = [k(k-1) + (n-k)k] / [n(n-1)] = k(n-1)/[n(n-1)] = k/n
与第 1 个相同,归纳成立。
这个算法的价值:一次遍历、O(1) 空间、精确均匀、无重复。标准做法"随机洗牌取前 k"要 O(n) 空间,"k 次随机索引 + 去重"在 k 接近 n 时可能死循环。
代价 :每扫一个元素一次 rand() + 一次浮点除法(约 10 ns)。n = 128、k = 100 时 128 次 rand() 约 1.3 μs,仍远快于 100 次 lpSeek。
threshold 等于 1.0 时(remaining == available)必然命中,语义是"剩下的全要"。它不会大于 1,因为调用方保证 remaining <= available;若传错了,threshold > 1 会导致全选然后返回 NULL,assert(tuple_len > 0) 拦不住这种错。
18.5 CASE 3:count × 3 > size
c
d = dictCreate(&sdsReplyDictType);
if (count*SRANDMEMBER_SUB_STRATEGY_MUL > size) {
setTypeInitIterator(&si, set);
dictExpand(d, size);
while (setTypeNext(&si, &str, &len, &llele) != -1) {
int retval = DICT_ERR;
if (str == NULL) {
retval = dictAdd(d,sdsfromlonglong(llele),NULL);
} else {
retval = dictAdd(d, sdsnewlen(str, len), NULL);
}
serverAssert(retval == DICT_OK);
}
setTypeResetIterator(&si);
serverAssert(dictSize(d) == size);
/* Remove random elements to reach the right count. */
while (size > count) {
dictEntry *de;
de = dictGetFairRandomKey(d);
dictUnlink(d,dictGetKey(de));
sdsfree(dictGetKey(de));
dictFreeUnlinkedEntry(d,de);
size--;
}
}
src/t_set.c:1253-1292。SRANDMEMBER_SUB_STRATEGY_MUL = 3,count × 3 > size 即 count > size/3 时用"建全集再删"。
删除要三行显式步骤,因为 sdsReplyDictType 的 keyDestructor 是 NULL(src/server.c:700-708):
c
dictType sdsReplyDictType = {
dictDdsHash, NULL, NULL, dictDdsKeyCompare,
NULL, /* key destructor */
NULL, /* val destructor */
NULL
};
没有 key destructor 是因为最后的输出要把 sds 的所有权转移给客户端:
c
dictInitIterator(&di, d);
while((de = dictNext(&di)) != NULL)
addReplyBulkSds(c,dictGetKey(de));
dictResetIterator(&di);
dictRelease(d);
src/t_set.c:1326-1330。addReplyBulkSds 接管 sds 所有权塞进回复缓冲,由回复机制负责释放。如果 dictRelease 时还去 sdsfree 每个 key,就 double free 了。所以 CASE 3 的删除必须手动 sdsfree------dict 不会帮你做。
18.6 CASE 4:随机采样去重
c
else {
unsigned long added = 0;
sds sdsele;
dictExpand(d, count);
while (added < count) {
setTypeRandomElement(set, &str, &len, &llele);
if (str == NULL) {
sdsele = sdsfromlonglong(llele);
} else {
sdsele = sdsnewlen(str, len);
}
if (dictAdd(d,sdsele,NULL) == DICT_OK)
added++;
else
sdsfree(sdsele);
}
}
src/t_set.c:1298-1318。count <= size/3 时"随机取 + 去重",added < count 是循环条件,重复命中时 added 不增。
复杂度是 O(count × size/(size-count))(几何分布的期望)。count = size/3 时是 1.5 × count,count = size/2 时是 2 × count,count → size 时趋于无穷------这就是分界点取 size/3 的原因:再多就超过 50% 的浪费,不如换 CASE 3 建全集再删。
18.7 输出的顺序
CASE 3 和 CASE 4 的输出都是 dictInitIterator 遍历临时 dict,所以是哈希序,不是随机序。
这与 CASE 1(允许重复)形成对比------CASE 1 的注释明确说:
CASE 1: The count was negative, so the extraction method is just: "return N random elements" sampling the whole set every time. This case is trivial and can be served without auxiliary data structures. This case is the only one that also needs to return the elements in random order.
src/t_set.c:1161-1165。只有负 count 保证随机顺序 ,正 count 只保证"元素集合是随机的"。这个区别客户端可观察:SRANDMEMBER k -5 每次顺序不同,SRANDMEMBER k 5 顺序可能是固定的。
18.8 SRANDMEMBER 与 SPOP 的策略对照
SPOP |
SRANDMEMBER |
|
|---|---|---|
| 分界系数 | 5 | 3 |
| 分界含义 | count < 5/6 × size 用逐个删 |
count <= size/3 用采样去重 |
| 反向策略 | 挑 remaining 建新集合并替换 |
建全集 dict 再删到 count |
| 是否改数据 | 是 | 否 |
| listpack 特化 | CASE 2 和 CASE 3 都有 | CASE 1 和 CASE 2.5 有 |
SPOP 的反向策略要改数据(dbReplaceValue),所以更贵、分界点更高;SRANDMEMBER 的反向策略只建临时 dict,所以更便宜、分界点更低。同一个算法思想在两个命令上的不同参数化,系数不是拍脑袋,是"单位成本比"的估算。
19. 陷阱与小结
19.1 九个容易踩的陷阱
一、intset 永不降级。 一次 SADD k 9999999999999 再 SREM 掉,剩下的元素仍然按 8 字节/个存。MEMORY USAGE 不会回落。要真正回收,只能 DEL 重建。
二、setTypeCreate 只看第一个元素。 SADD k 1 a 会先建 intset 再转 listpack。反过来 SADD k a 1 2 3 建的是 listpack 且永远不会转回 intset (只有 SINTERSTORE 的结果才会调 maybeConvertToIntset)。
三、三处阈值的口径不一致。 setTypeCreate 和 setTypeMaybeConvert 直接用 server.set_max_intset_entries,maybeConvertIntset 用 intsetMaxEntries()(带 1<<30 上限)。把配置设成 LONG_MAX 不等于"永不转换"。
四、setTypeAllocSize 的 HT 分支是高估。 dictMemUsage 假设每个元素都有 dictEntryNoValue,但 keys_are_odd = 1 时 key 可能直接存在桶里。所以 MEMORY USAGE 在 hashtable 编码下偏高,listpack/intset 下准确。这造成编码切换时数字跳变。
五、setTypeNext 的 str 只在 HT 时是 sds。 LISTPACK 的 str 指向 listpack 内部字节。把它当 sds 传给 sdslen 会读到 listpack 的编码字节。str_is_sds = (encoding == OBJ_ENCODING_HT) 这个判据是正确性要求,不是优化。
六、SSCAN 的 COUNT 对 intset/listpack 无效。 一次返回全部元素(最多 512 个),cursor 恒为 0。
七、SINTERSTORE dest dest other 之后 sets[] 悬垂。 setKey 释放了旧的 robj,sets[0].set 还指向它。安全性依赖"setKey 是最后一步",代码里没有注释说明这个约束。
八、SRANDMEMBER 正 count 不保证随机顺序。 只有负 count 才"顺序也随机"。CASE 3/4 的输出是哈希序。
九、SUNIONCARD APPROX 在 intset 上比精确路径慢。 每元素一次 ll2string。只有元素数极大、dstset 内存不可接受时,APPROX 才划算。
19.2 三种编码的分工
回到开头的问题:为什么需要一个专门的 intset。
intset |
listpack |
hashtable |
|
|---|---|---|---|
| 查找 | O(log n) |
O(n) |
O(1) |
| 插入 | O(n)(memmove) |
O(1) 追加 |
O(1) 均摊 |
| 删除 | O(n) |
O(n) |
O(1) |
| 随机 | O(1) |
O(n) |
O(1) |
| 有序遍历 | 升序,O(n) |
插入序,O(n) |
哈希序,O(n) |
| 每元素内存 | 2/4/8 字节 | 2 + 长度 | ~16-24 字节 + sds |
intset 在查找 (O(log n) vs O(n))和随机 (O(1) vs O(n))上明显优于 listpack,在内存 上优于 hashtable 约 3-10 倍。
listpack 在插入 上优于 intset(O(1) vs O(n))------这就是"递减插入是 O(n²)"的那条路。
hashtable 在查找和删除上是唯一可用的选择。
所以三个编码的"分工"是:
- 全整数、中小规模(
<= 512):intset。二分查找 + 2 字节/元素 +O(1)随机。 - 含字符串、小规模(
<= 128):listpack。插入快 + 内存紧凑。 - 大规模:
hashtable。唯一的O(1)查找。
没有一个编码在所有维度上占优,这是三个编码并存的根本原因。
19.3 LIMIT 的三种命运
| 命令 | cardinality 单调? |
LIMIT 的实现 |
需要结果集? |
|---|---|---|---|
SINTERCARD |
是(只增) | 直接 break |
否 |
SUNIONCARD |
是(setTypeAddAux 返回 0/1) |
early_exit 标志 |
是 |
SDIFFCARD |
算法 1 是,算法 2 否 | 强制算法 2b | 算法 2b 需要 |
SINTERCARD 最省:不建结果集,LIMIT 直接 break。 SUNIONCARD 要建 dstset(去重必需),LIMIT 限制的是 dstset 的大小。 SDIFFCARD 需要一个专门的算法(2b)才能让 cardinality 单调。
这个差异不是实现风格,是算法本质:交集"查成员"不需要记住见过什么,并集"去重"必须记住,差集"先加后减"的中间值超过最终值。
19.4 随机采样的三种算法
| 算法 | 用途 | 空间 | 时间 | 允许重复 |
|---|---|---|---|---|
lpNextRandom |
listpack 无重复采样 | O(1) | O(n) 一次 | 否 |
lpRandomEntries |
listpack 有重复采样 | O(k) | O(n + k log k) | 是 |
| dict 采样去重 | intset/HT 无重复 |
O(k) | O(k × n/(n-k)) | 否 |
| 全集建 dict 再删 | intset/HT 大 count |
O(n) | O(n) | 否 |
lpNextRandom 的"一遍扫描 + 等概率"是这四种里唯一"时间 O(n) 且空间 O(1) 且精确均匀"的,但它只能用于顺序容器 ------因为它依赖"扫描位置"和"剩余数量"这两个信息。hashtable 没有稳定的遍历顺序,第 i 个元素是什么无法预知,所以只能用"随机取 + 去重"或"建全集"。
这是 listpack 编码在随机采样上的唯一优势 :它能用 lpNextRandom,而 hashtable 不能。
19.5 本章涉及的文件
| 文件 | 行数 | 内容 |
|---|---|---|
src/t_set.c |
2047 | Set 全部命令与编码逻辑 |
src/intset.c |
566 | intset 容器实现 |
src/intset.h |
58 | intset 结构定义 |
src/object.c |
566-580 | freeSetObject |
src/server.c |
404-412, 633-644, 700-708 | setDictType、setSdsDestructor、sdsReplyDictType |
src/server.h |
3153-3158, 4020-4038 | setTypeIterator、htGetMetadataSize |
src/dict.c |
49-66, 1091-1103 | dictEntryNoValue、dictMemUsage |
src/listpack.c |
126-132, 948-991, 1521-1560, 1685-1687, 1919-1948, 2079-2112 | lpSafeToAdd、lpFindCmp、lpBatchDelete、lpEstimateBytesRepeatedInteger、lpRandomEntries、lpNextRandom |
src/db.c |
121-147, 1900-2000 | updateKeysizesHist、scanGenericCommand 的 Set 分支 |
src/rdb.c |
703-711, 1183-1215, 2943-3030, 3713-3745 | Set 的 RDB 保存与加载 |
src/defrag.c |
634-642, 744-755, 1191-1200 | scanLaterSet、defragSet |
src/hyperloglog.c |
1015-1021 | hllAdd 分派 |
src/config.c |
3600-3602 | 三个 Set 阈值配置 |
src/commands/ |
sunioncard.json、sintercard.json、sdiffcard.json |
命令元数据 |
19.6 下一章预告
第 14 章要回答:ZSet 的 skiplist 是全书唯一手写的多层有序索引------span 字段如何在 O(log n) 内支撑 ZRANK、ele 指针如何在 skiplist 与 dict 之间共享同一个 sds(所有权归谁)、ZADD GT/LT/NX/XX 的四种组合如何用同一个 flags 参数表达、ZRANGESTORE 的 LIMIT 为什么能在 skiplist 上提前退出而在 listpack 上不能、ZRANDMEMBER 的 WITHSCORES 与 ZRANGESTORE BYSCORE/BYLEX 的边界处理、以及 ZINCRBY 为什么必须"先删后插"而不能原地改。