上一篇【第16篇】跳跃表vs平衡树——Redis为什么不用红黑树?
下一篇【第18篇】压缩列表——Redis的内存压缩黑科技
你有没有想过,当你在Redis里执行
SADD myset 1 2 3的时候,这些整数是怎么存储的?如果用哈希表存三个整数,光哈希表本身的元数据(指针、桶数组、链表节点)就比数据本身大了好几倍。Redis当然不会做这种亏本买卖——它有一个专门的秘密武器:整数集合(intset)。
一、intset是什么?
intset(Integer Set)是Redis用于存储纯整数集合的一种紧凑数据结构。当Redis的Set对象中所有元素都是整数,且元素数量不超过配置阈值时,就会使用intset作为底层实现,而不是哈希表。
它的核心设计思想非常简单:把所有整数紧凑地存在一个连续数组里,像C语言数组一样高效。
哈希表存储方式(笨重): ┌─────────────────────────────────────┐ │ Hash Table │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │bucket│ │bucket│ │bucket│ ... │ │ └──┬──┘ └──┬──┘ └──┬──┘ │ │ │ │ │ │ │ ┌──▼──┐ ┌──▼──┐ ┌──▼──┐ │ │ │entry│ │entry│ │entry│ ... │ ← 指针、next指针、key、value... │ └─────┘ └─────┘ └─────┘ │ └─────────────────────────────────────┘ 存3个整数可能需要200+字节 intset存储方式(紧凑): ┌─────────────────────────────────────┐ │ intset │ │ ┌───┬───┬───┬───┬───┐ │ │ │enc│len│ 1 │ 2 │ 3 │ │ ← 只需要几个字节(取决于编码) │ └───┴───┴───┴───┴───┘ │ └─────────────────────────────────────┘ 存3个int16整数只需14字节差距一目了然。
二、intset的结构体定义
intset的结构非常简洁,定义在intset.h中:
typedefstructintset{uint32_tencoding;// 编码方式:INT16/INT32/INT64uint32_tlength;// 元素数量int8_tcontents[];// 柔性数组,实际存储整数数据}intset;三个字段,清清楚楚:
- encoding:当前使用的编码方式,决定了
contents数组中每个元素占多少字节 - length:集合中元素的数量,也就是
contents数组的实际使用长度 - contents:柔性数组(C语言的trick,数组大小在运行时确定),紧凑存储所有整数
踩坑提示:注意
contents的类型是int8_t,而不是某个具体的整数类型。这是一个"占位类型",实际存储时根据encoding的值,以2字节、4字节或8字节为单位来解读这块内存。这是C语言柔性数组的经典用法。
三、三种编码方式
intset支持三种编码,对应三种整数大小:
| 编码宏定义 | 值 | 每元素大小 | 存储范围 |
|---|---|---|---|
INTSET_ENC_INT16 | 2 | 2字节 | -32768 ~ 32767 |
INTSET_ENC_INT32 | 4 | 4字节 | -2147483648 ~ 2147483647 |
INTSET_ENC_INT64 | 8 | 8字节 | -2^63 ~ 2^63-1 |
三种编码的内存布局对比:
INT16编码(每个元素2字节): ┌──────────┬────────┬──────┬──────┬──────┬──────┐ │encoding │length │ 1 │ 2 │ 3 │ 5 │ │(4 bytes) │(4bytes)│(2b) │(2b) │(2b) │(2b) │ └──────────┴────────┴──────┴──────┴──────┴──────┘ INT32编码(每个元素4字节): ┌──────────┬────────┬──────────┬──────────┬──────────┐ │encoding │length │ 1 │ 2 │ 100000 │ │(4 bytes) │(4bytes)│ (4 bytes)│ (4 bytes)│ (4 bytes)│ └──────────┴────────┴──────────┴──────────┴──────────┘ INT64编码(每个元素8字节): ┌──────────┬────────┬──────────────┬──────────────┐ │encoding │length │ 42 │ 9999999999 │ │(4 bytes) │(4bytes)│ (8 bytes) │ (8 bytes) │ └──────────┴────────┴──────────────┴──────────────┘初始创建的intset默认使用INT16编码——毕竟大部分场景下小整数就够了,能省就省。
四、有序存储:为二分查找而生
intset中的元素始终按从小到大的顺序排列。这不是偶然,而是精心设计——有序数组支持二分查找,查找时间复杂度为 O(logN)。
// intset的查找实现(简化版)uint8_tintsetFind(intset*is,int64_tvalue){// 二分查找if(value>intsetGet(is,is->length-1)||value<intsetGet(is,0)){return0;// 不在范围内,直接返回}// 标准二分查找intlo=0,hi=is->length-1;while(lo<=hi){intmid=(lo+hi)/2;int64_tcur=intsetGet(is,mid);if(cur==value)return1;elseif(cur<value)lo=mid+1;elsehi=mid-1;}return0;}intset二分查找示例:在 {1, 3, 5, 7, 9, 11, 13} 中查找 7 索引: 0 1 2 3 4 5 6 内容: [ 1 3 5 7 9 11 13 ] ^ 第一次: lo=0, hi=6, mid=3 → is[3]=7 == 7 ✓ 找到了!虽然二分查找是 O(logN),但别忘了intset元素数量通常很小(默认上限512个),实际上查找非常快。
五、整数集合升级详解
intset最有意思的机制就是编码升级。当你要插入一个新整数,而它的值超出了当前编码能表示的范围时,intset会自动"升级"编码方式。
升级的触发条件
当前编码是INT16,存了 {1, 3, 5} 现在插入 70000(超过了INT16的32767上限) → 触发升级!INT16 → INT32升级的三步过程
升级不是简单地把数组变大,而是涉及到数据的重新排列:
第一步:空间重分配
根据新的编码方式,重新计算需要的内存大小,然后扩展contents数组。
升级前(INT16,3个元素): ┌──────────┬────────┬────┬────┬────┐ │encoding │length │ 1 │ 3 │ 5 │ 总大小: 4+4+6 = 14字节 │ = 2 │ = 3 │(2b)│(2b)│(2b)│ └──────────┴────────┴────┴────┴────┘ 升级后(INT32,4个元素,插入了70000): ┌──────────┬────────┬──────┬──────┬──────┬──────────┐ │encoding │length │ 1 │ 3 │ 5 │ 70000 │ 总大小: 4+4+16 = 24字节 │ = 4 │ = 4 │(4b) │(4b) │(4b) │ (4b) │ └──────────┴────────┴──────┴──────┴──────┴──────────┘第二步:数据迁移(从后往前)
这是升级最关键的一步。因为元素大小变了(比如从2字节变成4字节),需要把所有已有元素移动到新位置。迁移是从后往前进行的——这样可以避免覆盖还没迁移的数据。
从后往前迁移过程(INT16→INT32): 原始位置: [0] [2] [4] [6] (字节偏移) 1 3 5 目标位置: [0] [4] [8] [12] (字节偏移) 1 3 5 70000 迁移顺序: 5 → 3 → 1(从后往前) 最后: 70000 直接放到位置 [12]// 升级后重新放置元素(简化版)staticvoidintsetUpgradeAndAdd(intset*is,int64_tvalue){uint8_tcurenc=is->encoding;uint8_tnewenc=_intsetValueEncoding(value);// 计算新元素应该插入的位置intpos=(value<0)?0:is->length;// 扩展空间is->encoding=newenc;is=intsetResize(is,(is->length+1)*newenc);// 从后往前迁移已有元素while(is->length>pos){intsetSet(is,is->length,intsetGetEncoded(is,is->length-1,curenc));is->length--;}// 设置新元素intsetSet(is,pos,value);is->length++;}第三步:更新encoding和length
修改encoding为新的编码方式,length加1。
升级过程完整图示
INT16 → INT32 升级全过程 时间轴 → 步骤1(原始状态): [enc=2][len=3][ 1 ][ 3 ][ 5 ] 字节: 0-3 4-7 8-9 10-11 12-13 步骤2(扩展空间): [enc=2][len=3][ 1 ][ 3 ][ 5 ][ ???? ] 字节: 0-3 4-7 8-11 12-15 16-19 20-23 步骤3(从后往前迁移,5从offset 12-13移到16-19): [enc=2][len=3][ 1 ][ 3 ][ ?? ][ 5 ] 字节: 0-3 4-7 8-11 12-15 16-19 20-23 步骤4(迁移3从offset 10-11移到12-15): [enc=2][len=3][ 1 ][ ?? ][ 3 ][ 5 ] 步骤5(迁移1从offset 8-9移到8-11): [enc=2][len=3][ 1 ][ ?? ][ 3 ][ 5 ] 步骤6(插入70000,更新enc=4, len=4): [enc=4][len=4][ 1 ][ 70000][ 3 ][ 5 ]六、升级的好处:灵活性与内存节省兼得
intset的升级机制是一个很好的"按需付费"设计:
| 方面 | 说明 |
|---|---|
| 灵活性 | 可以同时存储不同大小的整数,不需要预先确定最大值 |
| 内存节省 | 只在需要时才使用更大的编码,小整数场景下不浪费空间 |
| 自动管理 | 开发者完全不需要关心编码切换,Redis自动处理 |
想象一下,如果你的集合里存的是用户ID,大部分是1-1000的小数字,偶尔来一个几百万的ID。如果一开始就用INT64,每个ID占8字节;但用intset的话,大部分时候只用2字节(INT16),偶尔升级到INT32或INT64。
七、只能升级,不能降级
intset的设计中有一个重要规则:编码只能升级,永远不能降级。
什么意思?如果你把一个INT32的intset中最大的那个大数字删掉了,intset不会自动降级回INT16。
# 演示:删除大数字后不会降级127.0.0.1:6379>SADD myset123(integer)3127.0.0.1:6379>OBJECT ENCODING myset"int16"# 小数字,INT16编码127.0.0.1:6379>SADD myset100000(integer)1127.0.0.1:6379>OBJECT ENCODING myset"int32"# 升级到INT32127.0.0.1:6379>SREM myset100000(integer)1127.0.0.1:6379>OBJECT ENCODING myset"int32"# 删除后仍然是INT32,没有降级!为什么不支持降级?
- 避免频繁转换的开销:如果频繁地添加大数又删除大数,会导致频繁的升级/降级操作
- 节省内存的收益不大:降级节省的内存通常很少,不值得增加代码复杂度
- 简化的设计:只升级不降级让代码逻辑更简单,也更不容易出bug
这种"只涨不跌"的设计哲学在Redis中很常见——和我们前面讨论的ziplist、skiplist一样,Redis倾向于用简单的规则换取代码的可维护性。
八、intset → hashtable的升级触发条件
intset虽然省内存,但它不是万能的。当满足以下任一条件时,Redis会将Set的底层实现从intset切换为hashtable:
| 条件 | 配置参数 | 默认值 |
|---|---|---|
| 元素数量超过阈值 | set-max-intset-entries | 512 |
| 插入了非整数值 | - | - |
触发条件示意: 插入元素 │ ├─ 是整数? ─── 否 ───→ 升级为hashtable │ (集合中出现字符串,intset无法存储) │ └─ 是整数 │ ├─ 集合元素数 < 512 → 保持intset │ └─ 集合元素数 ≥ 512 → 升级为hashtable (避免超大intset的O(N)插入开销)# 条件1:元素数量超过阈值127.0.0.1:6379>EVAL"for i=1,513 do redis.call('SADD','myset',i) end"0(integer)513127.0.0.1:6379>OBJECT ENCODING myset"hashtable"# 超过512个元素,自动切换# 条件2:插入非整数值127.0.0.1:6379>SADD myset2123(integer)3127.0.0.1:6379>OBJECT ENCODING myset2"int16"127.0.0.1:6379>SADD myset2"hello"(integer)1127.0.0.1:6379>OBJECT ENCODING myset2"hashtable"# 插入字符串,自动切换踩坑提示:一旦从intset升级为hashtable,即使你删掉了那个字符串元素或者把元素数量减到512以下,也不会降级回intset。和"只升级不降级"的编码策略一样,这是Redis的一贯设计。
九、intset的API一览
Redis为intset提供了一组简洁的API:
// 创建一个新的intset(默认INT16编码)intset*intsetNew(void);// 向intset中添加元素(可能触发升级)intset*intsetAdd(intset*is,int64_tvalue,uint8_t*success);// 从intset中删除元素intset*intsetRemove(intset*is,int64_tvalue,int8_t*success);// 判断元素是否存在uint8_tintsetFind(intset*is,int64_tvalue);// 获取intset中的元素数量uint32_tintsetLen(constintset*is);// 获取intset占用的字节数size_tintsetBlobLen(intset*is);// 随机返回一个元素int64_tintsetRandom(constintset*is);每个API的设计都很直观,这里重点提几个点:
intsetAdd返回新的intset指针:因为升级可能需要重新分配内存,所以返回的是可能变化的新指针(虽然大多数情况下地址不变)intsetRemove同样返回新指针:删除后可能需要压缩内存intsetRandom:用于SRANDMEMBER命令,直接通过索引随机访问,O(1)复杂度
十、实际演示:用redis-cli观察intset
让我们通过实际的redis-cli操作来验证intset的行为:
# 创建一个小整数集合127.0.0.1:6379>SADD nums12345(integer)5# 查看底层编码127.0.0.1:6379>OBJECT ENCODING nums"int16"# 查看内存占用127.0.0.1:6379>MEMORY USAGE nums(integer)56# 非常小!# 添加一个大数字触发升级127.0.0.1:6379>SADD nums100000(integer)1127.0.0.1:6379>OBJECT ENCODING nums"int32"# 升级到INT32# 添加一个超大数字127.0.0.1:6379>SADD nums99999999999999(integer)1127.0.0.1:6379>OBJECT ENCODING nums"int64"# 升级到INT64# 查看内存增长127.0.0.1:6379>MEMORY USAGE nums(integer)96# 比之前大了,但仍然很小# 验证元素存在性127.0.0.1:6379>SISMEMBER nums3(integer)1# 存在127.0.0.1:6379>SISMEMBER nums999(integer)0# 不存在# 随机返回一个元素127.0.0.1:6379>SRANDMEMBER nums"2"十一、总结
intset的设计哲学可以用一句话概括:简单就是美。
| 特性 | 说明 |
|---|---|
| 存储方式 | 连续有序数组 |
| 查找 | O(logN) 二分查找 |
| 插入 | O(N)(需要移动元素) |
| 删除 | O(N)(需要移动元素) |
| 内存 | 极度紧凑,接近理论最小值 |
| 升级 | 只升不降,按需扩展 |
| 降级 | 升级为hashtable(不可逆) |
intset不是什么花哨的数据结构,它就是C语言里最朴素的数组加上一点自动管理。但正是这种简单,带来了极致的内存效率和可预测的性能。在Redis的世界里,有时候最简单的方案就是最好的方案。
下一篇,我们将学习另一个紧凑数据结构——压缩列表(ziplist),它的设计比intset更复杂,也更精彩。准备好了吗?
上一篇【第16篇】跳跃表vs平衡树——Redis为什么不用红黑树?
下一篇【第18篇】压缩列表——Redis的内存压缩黑科技