Redis 的集合类型在保存元素时,底层并不只依赖哈希表。当一个集合对象全部由整数组成,并且元素数量没有超过 set-max-intset-entries 配置的阈值时,内部编码会采用 intset,也就是整数集合。它的核心思路是:用一段连续内存保存有序整数,去掉哈希表节点中的指针、键和值元数据,从而在小集合场景下大幅降低内存占用。

intset 的结构与编码字段
intset 在 Redis 源码中的定义非常精简,通常可以概括为一个包含编码类型、长度和柔性数组的结构体。字段 encoding 并不直接存储枚举名称,而是存储每个元素占用的字节数,因此 INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64 分别对应数值 2、4 和 8。
典型结构如下:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
contents 声明为 int8_t 只是为了表示柔性数组的起始地址,真实元素宽度由 encoding 决定。例如编码为 4 时,第 0 个元素占据 contents[0] 到 contents[3],第 1 个元素从 contents[4] 开始。这样设计的好处是访问元素时只需要根据索引乘以编码宽度即可定位,不依赖每种整数类型单独的数组。
为了保证检索效率,intset 中的所有元素都会按照从小到大顺序排列,并且不允许重复。长度字段 length 记录当前包含的元素个数,而不是字节长度。后续插入、删除和查找都依赖这个有序特性,以及编码宽度快速计算偏移量。
插入与编码升级机制
intset 创建时默认采用 16 位整数编码。插入一个整数时,Redis 会先调用 _intsetValueEncoding 判断这个值适合 16 位、32 位还是 64 位。如果新值适合的编码不高于当前编码,就会直接按有序数组插入;如果新值超出了当前编码的范围,就需要触发一次编码升级。
编码升级并不是直接把新元素写进去那么简单。假设当前 intset 使用 16 位编码保存了 1、3、5 三个元素,现在要插入 40000,由于 40000 超出 int16 的最大值 32767,整数集合必须升级为 32 位。Redis 会先根据新编码重新分配更大的连续内存,然后从旧数组的尾部开始向前迁移元素,把它们逐项转换成 32 位后写入新位置。这个从后往前的迁移顺序非常关键:如果从前往后写,较早的目标区域会覆盖尚未读取的旧数据,导致元素丢失。
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value);
int length = (int)intrev32ifbe(is->length);
uint32_t origlen = (uint32_t)length;
int prepend = value < 0 ? 1 : 0;
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is, length + 1);
while (length--) {
_intsetSet(is, length + prepend,
_intsetGetEncoded(is, length, curenc));
}
if (prepend) {
_intsetSet(is, 0, value);
} else {
_intsetSet(is, origlen, value);
}
is->length = intrev32ifbe(origlen + 1);
return is;
}
在上面的简化代码里,prepend 用来判断新插入的值是否为负数。由于 intset 保持升序,负数通常会排到数组头部。如果新值是负数,旧元素迁移时需要整体向后移动一位,给新值留出下标 0 的位置;如果是正数,则直接追加到数组末尾。升级完成后,encoding 被更新为新的宽度,后续插入同范围内整数就不再触发升级。
需要特别注意的是,intset 的升级是单向的。即使之后删除了那个导致升级的大整数,集合仍然会保持高编码宽度,不会自动降级。这样设计避免了频繁重编码带来的 CPU 和内存抖动,代价是一旦集合中短暂出现过大整数,所有后续存储都会按更大宽度分配。
查找、删除与有序性保障
有序数组是 intset 实现高效操作的基础。查找函数 intsetSearch 会在连续内存上做二分查找:它先取中间位置,根据编码宽度读出对应整数,再与目标值比较,决定继续在左半区还是右半区查找。得益于元素宽度固定,按索引取值的复杂度为 O(1),整体查找复杂度为 O(log N)。
插入时同样会先通过二分查找定位 position。如果目标值已经存在,函数直接返回,不会产生重复元素;如果不存在,Redis 会把 position 之后的所有元素向后移动一个槽位,再写入新值。删除操作则相反:找到元素后,把后面的元素整体向前移动,覆盖被删元素,最后通过 intsetResize 缩小内存。
int pos;
if (intsetSearch(is, value, &pos)) {
uint32_t len = intrev32ifbe(is->length);
int enc = intrev32ifbe(is->encoding);
if (pos < len - 1) {
memmove(is->contents + pos * enc,
is->contents + (pos + 1) * enc,
(len - pos - 1) * enc);
}
is = intsetResize(is, len - 1);
is->length = intrev32ifbe(len - 1);
}
这里使用 memmove 而不是 memcpy,因为源地址和目标地址可能重叠。删除中间元素时,目标区域和源区域都位于同一个连续缓冲区中,memmove 能正确处理重叠拷贝。由于元素编码宽度一致,移动长度可以通过剩余元素数量乘以编码宽度快速计算。
与哈希表、listpack 的对比及使用边界
在小集合场景下,intset 的主要优势是紧凑。哈希表的每一个节点都需要维护指针、键、值以及哈希链冲突信息,即使只存一个整数,也要付出大量元数据开销。而 intset 只保留编码、长度和连续整数缓冲区,既省内存又提升缓存局部性。对批量读取和范围扫描来说,连续内存也更容易命中 CPU 缓存。
不过 intset 的适用边界非常清晰。Redis 通过 set-max-intset-entries 参数控制最大元素数量,默认是 512。超过这个数量,intset 会被转换成其他结构,例如旧版本会转成哈希表,较新版本可能采用 listpack。即便元素数量很少,只要插入了一个非整数元素,集合也会放弃 intset,因为 intset 只能表达有符号整数。
综合来看,intset 适合读多写少、元素均为整数且规模稳定的业务场景。理解它的编码升级、有序数组和单向升级策略,有助于预测内存占用变化,避免因为一次超大整数插入或集合规模超限而引发底层结构切换,从而影响请求延迟。
Redis intset整数集合编码原理修改时间:2026-09-23 01:58:54