在 Redis 中,字典和整数集合是集合的两种实现方式。

// redis.conf

# Sets have a special encoding in just one case: when a set is composed
# of just strings that happen to be integers in radix 10 in the range
# of 64 bit signed integers.
# The following configuration setting sets the limit in the size of the
# set in order to use this special memory saving encoding.
set-max-intset-entries 512

当集合中元素都是范围在 int64_t (-2^63 到 2^63)内的整数并且个数不超过 512 时,底层则用整数集合存储,否则就用字典。

127.0.0.1:6379> sadd members 20 10 99 1 0
(integer) 5
127.0.0.1:6379> object encoding members
"intset"
127.0.0.1:6379> sadd members fruit
(integer) 1
127.0.0.1:6379> object encoding members
"hashtable"

当用整数集合实现时,查看下集合成员。

127.0.0.1:6379> sadd num 20 10 99 1 0
(integer) 5
127.0.0.1:6379> smembers num 
1) "0"
2) "1"
3) "10"
4) "20"
5) "99"

发现展示的顺序是从小到大排序的,而不是插入的顺序。接下来就详细说下整数集合这个结构,来探知背后的原理。

// intset.h

typedef struct intset {
    uint32_t encoding; /* 编码 */
    uint32_t length; /* 数组 contents 长度 */
    int8_t contents[]; /* 柔性数组,存储集合的整数数组 */
} intset;

整数集合结构特别简单,占 8 个字节,共有三个字段。32 位无符号整型的 encoding 字段,这个和 redisObject 里的编码字段类似,表明 contents 数组里各个元素的类型,这里有三个类型:16 位的 INTSET_ENC_INT16、32 位的 INTSET_ENC_INT32 和 64 位的 INTSET_ENC_INT64。

// intset.c

/* Note that these encodings are ordered, so:
 * INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t)) /* 16 位,2 个字节,表示范围 -32,768~32,767  */
#define INTSET_ENC_INT32 (sizeof(int32_t)) /* 32 位,4 个字节,表示范围 -2,147,483,648~2,147,483,647 */
#define INTSET_ENC_INT64 (sizeof(int64_t)) /* 64 位,8 个字节,表示范围 -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 */

32 位无符号整型的 length 字段,通过计算 encoding 和 length 相乘就可以得出柔性数组占的内存。

redis7 有序集合 score 最大值_编码类


如上图,contents 占的内存为 2 * 5 = 10 个字节。

柔性数组 contents 不占 intset 结构体内存,其内存是紧邻 intset 结构体。

intset 里的 encoding 有三个类型,分别对应 contents 数组里存储的整数的范围。如集合 num 里的值在 INTSET_ENC_INT16 范围内,若这时添加一个整数 32768,这时就超出了 INTSET_ENC_INT16 存储范围,那么就要升级到 INTSET_ENC_INT32;相应的再添加个整数 2,147,483,648,超出了 INTSET_ENC_INT32 存储范围,那就要升级到 INTSET_ENC_INT64 了;若再添加个超出 INTSET_ENC_INT64 范围的整数,这时超出了 encoding 的类型的最大范围了,那么就会把 intset 转为 dict 了。这里还有一点需要注意的是,升级后就不会降级了,也就是把超出范围的元素删除了,encoding 还是原来的类型。

老规矩,最后说下整数集合里的常用的 API。

// intset.c

/* Create an empty intset. */
intset *intsetNew(void) {
    intset *is = zmalloc(sizeof(intset));
    is->encoding = intrev32ifbe(INTSET_ENC_INT16);
    is->length = 0;
    return is;
}

创建一个空的整数集合时,encoding 默认为 INTSET_ENC_INT16,长度为 0,注意这里没给 contents 数组分配内存,因为还没添加元素。

redis7 有序集合 score 最大值_编码类_02


intrev32ifbe 这个函数作用是把数据转成小端存储,默认 Redis 都是小端序存储。之所以有大小端,是由于在计算机中,数据都是二进制存储的,一个字节八位,比如 char 类型就一个字节,这在哪种类型的机子上存储都一样,但是 int 类型呢,4 个字节,也就是 32 位,这四个字节是低字节先存储呢还是高字节先储存呢,这个就是大小端(Big endian 和 Little endian)的由来。

比如 int num = 16777220; 这个数用十六进制表示就是 0x12345678 ,从左往右数, 0x12 是高位字节,0x78 是低位字节。在小端序机子上依次是 0x78、0x56、0x34、0x12 存储,数据高位(高字节)存储在内存高地址,低位(低字节)对应低地址;相应的大端序就是反过来 0x12、0x34、0x56、0x78,数据高位存储在内存低地址,低位对应高地址。

redis7 有序集合 score 最大值_编码类_03


那如何知道计算机是大端还是小端呢?可以通过 C 中 union (联合体或叫共同体)来检测,union 中所有成员的存放顺序是从低字节到高字节的。

#include <stdio.h>

int main(void)
{

    union {
        short i;
        char a[2];
    } u;

    u.a[0] = 0x11;
    u.a[1] = 0x22;

    printf("0x%x\n", u.i);
    return 0;


}

编译后运行,如果输出 0x2211 为小端,0x1122 为大端。当然了,还有个更简单的方法

int testEndian() {
    int x = 1;
    return *((char *)&x);
}

如果返回 1,那么为小端,否则就是大端。原理和上面介绍的一致,x 类型为 int,占 4 个字节,char 占 1 个字节,当用 char 指向 int 时,只会指向首字节,如果这个字节存储的为 1,那么说明是小端。因为 x 二进制为 00000000 00000000 00000000 00000001,高字节为 0,低字节为 1,按照上面说的低字节放在低地址,那就是小端了。

redis7 有序集合 score 最大值_编码类_04

// intset.c

/* Insert an integer in the intset */
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
    uint8_t valenc = _intsetValueEncoding(value); /* 获取元素的编码类型 */
    uint32_t pos;
    if (success) *success = 1;

    /* Upgrade encoding if necessary. If we need to upgrade, we know that
     * this value should be either appended (if > 0) or prepended (if < 0),
     * because it lies outside the range of existing values. */
    if (valenc > intrev32ifbe(is->encoding)) { /* 如果大于当前 intset 编码类型,就升级  */
        /* This always succeeds, so we don't need to curry *success. */
        return intsetUpgradeAndAdd(is,value);
    } else {
        /* Abort if the value is already present in the set.
         * This call will populate "pos" with the right position to insert
         * the value when it cannot be found. */
        if (intsetSearch(is,value,&pos)) { /* 找到 is 集合中值为 value 的下标,返回 1,并保存在 pos 中,没有找到返回 0,并将 pos 设置为 value 可以插入到数组的位置 */
            if (success) *success = 0;
            return is; /* 如果 value 已在数组内,则直接返回 */
        }

        is = intsetResize(is,intrev32ifbe(is->length)+1); /* value 在集合中不存在,且 pos 保存可以插入的位置,就对 intset 进行扩容 */
        if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1); /* 如果 pos 不是在数组末尾则要移动调整集合 */
    }

    _intsetSet(is,pos,value); /* 设置 pos下标的值为 value */
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1); /* 更新长度 */
    return is;
}

主要分三步:

  1. 第一步,先获取 vlaue 的编码类型
// intset.c

/* Return the required encoding for the provided value. */
static uint8_t _intsetValueEncoding(int64_t v) {
    if (v < INT32_MIN || v > INT32_MAX)
        return INTSET_ENC_INT64;
    else if (v < INT16_MIN || v > INT16_MAX)
        return INTSET_ENC_INT32;
    else
        return INTSET_ENC_INT16;
}
  1. 第二步,如果 value 的编码类型要大于当前的编码类型,那么进行升级。
// intset.c

/* Upgrades the intset to a larger encoding and inserts the given integer. */
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    uint8_t curenc = intrev32ifbe(is->encoding); /* 当前的编码类型 */
    uint8_t newenc = _intsetValueEncoding(value); /* 要升级的编码类型 */
    int length = intrev32ifbe(is->length); /* 整数集合个数 */
    /* 判断 value 要插入数组头部还是尾部,因为整数集合是从小到大排序,升级的话,要么排第一位要么排最后一位 */
    /* 排第一位就是 value 为负数,并且是超出当前编码类型范围的负数,所以才有 value < 0 的判断 */
    int prepend = value < 0 ? 1 : 0; /* 依据 value 判断是否前置 */
    

    /* First set new encoding and resize */
    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));

    /* Set the value at the beginning or the end. */
    if (prepend) /* value 是负数,放在最前端 */
        _intsetSet(is,0,value);
    else
        _intsetSet(is,intrev32ifbe(is->length),value); /* value为正数,放在最末尾 */
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1); /* 数组元素加 1 */
    return is;
}

/* Resize the intset */
/* 调整内存大小 */
static intset *intsetResize(intset *is, uint32_t len) {
    uint32_t size = len*intrev32ifbe(is->encoding); /* 计算 contents 数组的内存:长度*编码 */
    is = zrealloc(is,sizeof(intset)+size); /* 重新分配内存,就是结构体加上柔性数组 */
    return is; /* 返回调整后的结构体 */
}

/* Return the value at pos, given an encoding. */
/* 扩充整数集合 contents 数组内各元素的编码,因为升级了,之前的编码存储的值就要扩展到新的编码 */
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) {
    	/* ((int64_t*)is->contents)+pos 这一步类似 contents[pos],获取数组 pos 值为的指针 */
    	/* 复制 pos 位置的指针的值到新的 v64 内 */
    	/* 其实就是扩充了数值的占用内存,比如以前元素占两个字节,现在占八个字节了 */
        memcpy(&v64,((int64_t*)is->contents)+pos,sizeof(v64));
        memrev64ifbe(&v64);
        return v64;
    } else if (enc == INTSET_ENC_INT32) {
        memcpy(&v32,((int32_t*)is->contents)+pos,sizeof(v32));
        memrev32ifbe(&v32);
        return v32;
    } else {
        memcpy(&v16,((int16_t*)is->contents)+pos,sizeof(v16));
        memrev16ifbe(&v16);
        return v16;
    }
}

/* Set the value at pos, using the configured encoding. */
/* 在 pos 位置上存储 value 值 */
static void _intsetSet(intset *is, int pos, int64_t value) {
    uint32_t encoding = intrev32ifbe(is->encoding);

    if (encoding == INTSET_ENC_INT64) {
        ((int64_t*)is->contents)[pos] = value; /* 升级 pos 内的存储空间,毕竟 _intsetGetEncoded 已经把 value 值升级了,要是不扩充的话,那么数值就会变的 */
        memrev64ifbe(((int64_t*)is->contents)+pos);
    } else if (encoding == INTSET_ENC_INT32) {
        ((int32_t*)is->contents)[pos] = value;
        memrev32ifbe(((int32_t*)is->contents)+pos);
    } else {
        ((int16_t*)is->contents)[pos] = value;
        memrev16ifbe(((int16_t*)is->contents)+pos);
    }
}

redis7 有序集合 score 最大值_编码类_05


3. 第三步,如果 value 的编码类型不变,那么先判断 value 是否已在集合元素内,在的话就直接返回,不在的话就记录下要插入的位置,同时以此移动插入位置之后的元素到新的位置,也就是给 value 挪窝,最后插入 value,更新整数集合长度。

// intset.c

/* Search for the position of "value". Return 1 when the value was found and
 * sets "pos" to the position of the value within the intset. Return 0 when
 * the value is not present in the intset and sets "pos" to the position
 * where "value" can be inserted. */
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) { /* 集合数组为空时设置为 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. */
        /* 判断 value 是否为最大值或最小值,以此来设置 pos 位置 */
        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; /* (min+max)/2,找到中间数的下标 */
        cur = _intsetGet(is,mid); /* 中间数下标 mid 的值 cur */
        if (value > cur) { /* 大于当前值cur,后半截找 */
            min = mid+1;
        } else if (value < cur) { /* 前半截找 */
            max = mid-1;
        } else {
            break;
        }
    }

    if (value == cur) { /* 找到了,设置 pos 的位置 */
        if (pos) *pos = mid;
        return 1;
    } else { /* 没找到,此时 min 和 max 相等,所以 pos 可以设置为 min 或 max,返回 0 */
        if (pos) *pos = min;
        return 0;
    }
}

/* 将 from 位置移动 to 位置 */
static void intsetMoveTail(intset *is, uint32_t from, uint32_t to) {
    void *src, *dst;
    uint32_t bytes = intrev32ifbe(is->length)-from; /* 获得要移动的元素的个数 */
    uint32_t encoding = intrev32ifbe(is->encoding); /* 获得集合 is 的默认编码方式 */

    if (encoding == INTSET_ENC_INT64) {
        src = (int64_t*)is->contents+from; /* 获得要被移动范围的起始地址 */
        dst = (int64_t*)is->contents+to; /* 获得要被移动到的目的地址 */
        bytes *= sizeof(int64_t); /* 计算要移动多少个字节 */
    } else if (encoding == INTSET_ENC_INT32) {
        src = (int32_t*)is->contents+from;
        dst = (int32_t*)is->contents+to;
        bytes *= sizeof(int32_t);
    } else {
        src = (int16_t*)is->contents+from;
        dst = (int16_t*)is->contents+to;
        bytes *= sizeof(int16_t);
    }
    memmove(dst,src,bytes); /* 从 src 开始移动 bytes 个字节到 dst;先将 src 拷贝到一个临时缓冲区中,然后再从缓冲区中逐字节拷贝到 dst 地址 */
}

redis7 有序集合 score 最大值_编码类_06

intsetRemove 删除元素和 intsetFind 查找集合元素,其实在 intsetAdd 添加元素都有对应代码,这里就不一一介绍了,可以查看相关源码。

// intset.c

/* Delete integer from intset */
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)) {
        uint32_t len = intrev32ifbe(is->length);

        /* We know we can delete */
        if (success) *success = 1;

        /* Overwrite value with tail and update length */
        if (pos < (len-1)) intsetMoveTail(is,pos+1,pos);
        is = intsetResize(is,len-1);
        is->length = intrev32ifbe(len-1);
    }
    return is;
}

/* Determine whether a value belongs to this set */
uint8_t intsetFind(intset *is, int64_t value) {
    uint8_t valenc = _intsetValueEncoding(value);
    return valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,NULL);
}

【注】 此博文中的 Redis 版本为 5.0。

参考书籍 :

【1】redis设计与实现(第二版)
【2】Redis 5设计与源码分析