Redis Set底层实现原理:比甄嬛传还精彩的宫斗大戏

190 阅读5分钟

Redis Set底层实现原理:比甄嬛传还精彩的宫斗大戏


一、底层编码的「双面人生」

Redis Set的底层有两种人格分裂式的实现方式,像极了职场老油条见人说人话:

编码类型触发条件内存开销时间复杂度
intset所有元素为整数 + 元素数量 ≤ 512极致抠门O(n)
hashtable元素含字符串或数量 > 512土豪式挥霍O(1)

🔥 潜规则:通过redis.confset-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时,触发扩容:

  1. ht[1]分配新哈希表(大小是ht[0].used*2
  2. rehashidx从0开始标记(老表搬迁起始位置)
  3. 每次增删改查操作时,顺带迁移1个桶(bucket)的数据
  4. 迁移完成后,用ht[1]取代ht[0],重置ht[1]

🌰 举个栗子
就像餐厅换址营业,新店(ht[1])装修期间,老店(ht[0])继续接客。每服务一个客人(执行命令),就让一个服务员去搬一箱餐具(迁移一个哈希桶),直到全部搬完。


四、编码切换的「宫斗现场」

当intset遇到以下情况时,会触发「黑化」为hashtable:

  1. 插入非整数元素(比如字符串"42"伪装成数字)
  2. 元素数量超过set-max-intset-entries(默认512)
  3. 插入超大整数导致升级失败(比如突然塞入一个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); // 无情抛弃原配
}

五、性能博弈论

操作intsethashtable
添加元素O(N)(可能触发升级)O(1)(平均)
查找元素O(log n)O(1)(平均)
内存占用紧凑无浪费每个元素额外24字节
SPOP随机删除O(1)O(1)

💡 选型启示

  • 小规模纯数字集合:用intset省内存(但查找稍慢)
  • 大规模或含字符串:用hashtable换速度(但内存翻车警告)

六、原理级避坑指南

  1. 慎用SMEMBERS查大集合
    hashtable版SMEMBERS需要遍历整个哈希表,10万元素需要遍历10万次,网络传输直接爆炸——用SSCAN分批获取才是王道。

  2. 集合运算的隐藏炸弹
    SINTER时间复杂度O(N*M),计算两个10万级集合的交集=1亿次操作,可能让Redis主线程卡顿——建议在从库或客户端处理。

  3. 内存增长的鬼故事
    一个包含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的终极奥义!