Redis Set底层实现原理:比甄嬛传还精彩的宫斗大戏
一、底层编码的「双面人生」
Redis Set的底层有两种人格分裂式的实现方式,像极了职场老油条见人说人话:
| 编码类型 | 触发条件 | 内存开销 | 时间复杂度 |
|---|---|---|---|
| intset | 所有元素为整数 + 元素数量 ≤ 512 | 极致抠门 | O(n) |
| hashtable | 元素含字符串或数量 > 512 | 土豪式挥霍 | O(1) |
🔥 潜规则:通过
redis.conf中set-max-intset-entries可调整临界值(默认512),就像给intset的KPI指标松绑
二、intset:内存优化强迫症患者
1. 内存结构解剖(精打细算到字节)
intset本质上是个连续内存数组,数据结构像军训队列:
typedef struct intset {
uint32_t encoding; // 编码格式(int16/int32/int64)
uint32_t length; // 元素个数
int8_t contents[]; // 元素存储区(士兵宿舍)
} intset;
2. 升级机制:穷小子逆袭记
当插入超出当前存储范围的整数时,intset会触发「阶级跃迁」:
# 原有intset存储int16(-32768~32767)
intset = [1, 2, 3] # 使用2字节/元素
# 插入一个int32数字65535(突破int16上限)
intset.upgrade() # 全体元素升级为int32
intset.add(65535) # 现在每个元素占用4字节
💡 升级特性:
- 升级不可逆:一旦用上大户型,绝不回蜗居
- 插入时间复杂度:O(N)(需要全体重新分配内存)
3. 查找黑科技:二分查找
虽然内存布局紧凑,但元素是有序存储的!查找时用二分法:
// 伪代码:intset查找流程
int pos = binary_search(intset.contents, target);
return (pos != -1) ? "存在" : "不存在";
🚨 时间复杂度:O(log n)(比哈希表慢,但内存换速度)
三、hashtable:社会我哈希哥
当intset扛不住时,Redis会召唤真正的王者——字典(dict),其结构复杂程度堪比《盗梦空间》:
1. 字典的「套娃式结构」
// 第一层:字典本体(老板)
typedef struct dict {
dictType *type; // 类型特定函数(员工手册)
dictht ht[2]; // 两个哈希表(双缓冲区)
long rehashidx; // rehash进度(搬家进度条)
} dict;
// 第二层:哈希表(部门经理)
typedef struct dictht {
dictEntry **table; // 哈希表数组(工位格子间)
unsigned long size; // 哈希表大小(工位总数)
unsigned long sizemask; // 哈希掩码(工位号计算器)
unsigned long used; // 已使用节点数(实际员工数)
} dictht;
// 第三层:哈希节点(打工人)
typedef struct dictEntry {
void *key; // 元素值(Set中就是实际存储的值)
union { // 值(Set中值为NULL,纯工具人)
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 链表指针(解决哈希冲突)
} dictEntry;
2. 渐进式rehash:搬家式扩容
当哈希表负载因子(used/size)超过1时,触发扩容:
- 为
ht[1]分配新哈希表(大小是ht[0].used*2) - 将
rehashidx从0开始标记(老表搬迁起始位置) - 每次增删改查操作时,顺带迁移1个桶(bucket)的数据
- 迁移完成后,用
ht[1]取代ht[0],重置ht[1]
🌰 举个栗子:
就像餐厅换址营业,新店(ht[1])装修期间,老店(ht[0])继续接客。每服务一个客人(执行命令),就让一个服务员去搬一箱餐具(迁移一个哈希桶),直到全部搬完。
四、编码切换的「宫斗现场」
当intset遇到以下情况时,会触发「黑化」为hashtable:
- 插入非整数元素(比如字符串"42"伪装成数字)
- 元素数量超过set-max-intset-entries(默认512)
- 插入超大整数导致升级失败(比如突然塞入一个10^18)
转换过程像极了职场背叛:
// 伪代码:intset转hashtable
void convertToHashtable(intset *is) {
dict *d = dictCreate(...);
for (int i=0; i<is->length; i++) {
int64_t val = intsetGet(is, i);
dictAdd(d, val, NULL); // 所有值作为key,value为NULL
}
freeIntSet(is); // 无情抛弃原配
}
五、性能博弈论
| 操作 | intset | hashtable |
|---|---|---|
| 添加元素 | O(N)(可能触发升级) | O(1)(平均) |
| 查找元素 | O(log n) | O(1)(平均) |
| 内存占用 | 紧凑无浪费 | 每个元素额外24字节 |
| SPOP随机删除 | O(1) | O(1) |
💡 选型启示:
- 小规模纯数字集合:用intset省内存(但查找稍慢)
- 大规模或含字符串:用hashtable换速度(但内存翻车警告)
六、原理级避坑指南
-
慎用SMEMBERS查大集合
hashtable版SMEMBERS需要遍历整个哈希表,10万元素需要遍历10万次,网络传输直接爆炸——用SSCAN分批获取才是王道。 -
集合运算的隐藏炸弹
SINTER时间复杂度O(N*M),计算两个10万级集合的交集=1亿次操作,可能让Redis主线程卡顿——建议在从库或客户端处理。 -
内存增长的鬼故事
一个包含100万64位整数的Set:- intset需要:100万 * 8字节 = 7.6MB
- hashtable需要:100万 * (24字节Entry + 8字节key) ≈ 30.5MB
——相差4倍!强制用intset可大幅省内存。
七、高频面试暴击点
Q1:为什么Set要设计两种编码?
👉 答:本质是空间与时间的权衡。intset用紧凑内存+有序存储换取空间效率,hashtable用额外内存换O(1)操作速度。
Q2:渐进式rehash期间如何保证数据一致性?
👉 答:所有操作先在ht[0]查找,找不到再查ht[1]。新增数据直接写入ht[1],保证老数据只减不增。
Q3:SPOP如何实现随机删除?
👉 答:
- intset:生成随机索引,删除后把最后一个元素补到空缺(类似数组删除)
- hashtable:随机选择一个非空桶,再在链表/哈希桶中随机选一个元素
总结:Set的底层生存哲学
Redis Set就像职场中的变色龙:面对小数据时扮演内存节俭的会计(intset),遇到复杂场景秒变处理海量数据的大佬(hashtable)。理解它的双重人格,才能在高性能与资源消耗间找到完美平衡。记住:用intset要纯粹,用hashtable要节制,这才是驾驭Redis Set的终极奥义!