《redis设计与实现》-第6章整数集合intset

一 序

  intset是Redis集合的底层实现之一,当存储整数集合并且数据量较小的情况下Redis会使用intset作为set的底层实现。当数据量较大或者集合元素为字符串时则会使用dict实现set。这一章看书相对简单,看一下源码对应的api,发现自己对于字节序不懂,所以先把预备知识贴一下,熟悉的可以跳过无视了。

  计算机硬件有两种储存数据的方式:大端字节序(big endian)和小端字节序(little endian)。不论大端小端,内存中数据按照8位(1字节)分割。举例来说,数值0x2211使用两个字节储存:高位字节是0x22,低位字节是0x11。

  • 大端字节序:高位字节在前,低位字节在后,这是人类读写数值的方法。
  • 小端字节序:低位字节在前,高位字节在后,即以0x1122形式储存。

对于反人类的设计真是不理解,统一大端字节序多好,弄这么复杂干什么?
首先,为什么会有小端字节序?
答案是,计算机电路先处理低位字节,效率比较高,因为计算都是从低位开始的。所以,计算机的内部处理都是小端字节序。
但是,人类还是习惯读写大端字节序。所以,除了计算机的内部处理,其他的场合几乎都是大端字节序,比如网络传输和文件储存。计算机处理字节序的时候,不知道什么是高位字节,什么是低位字节。它只知道按顺序读取字节,先读第一个字节,再读第二个字节。如果是大端字节序,先读到的就是高位字节,后读到的就是低位字节。小端字节序正好相反。只有读取的时候,才必须区分字节序,其他情况都不用考虑.
     处理器读取外部数据的时候,必须知道数据的字节序,将其转成正确的值。然后,就正常使用这个值,完全不用再考虑字节序。C语言的高效还真是有原因的。作者还举个了个例子,运行一下:

《redis设计与实现》-第6章整数集合intset_第1张图片

在80X86平台上,系统将多字节中的低位存储在变量起始地址,使用小端法。htonl将i_num转换成网络字节序,可见网络字节序是大端法。

二 intset数据结构

 intset 数据结构相对简单,源码在intset.c,intset.h

typedef struct intset {
    
    // 编码方式
    uint32_t encoding;

    // 集合包含的元素数量
    uint32_t length;

    // 保存元素的数组
    int8_t contents[];

} intset;

    需要注意contents数组成员被声明为int8_t类型并不表示contents里存的是int8_t类型的成员,这个类型声明对于contents来说可以认为是毫无意义的,因为intset成员是什么类型完全取决于encoding变量的值。encoding提供下面三种值:

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

      虽然每个成员的“实际类型”是int8_t,无法直接通过contents[x]取出索引为x的成员元素,但是intset.c里提供了些函数,可以按照不同的encoding方式设置/取出contents的成员。(用指针设置,memcpy取出)。这里就牵扯一开列出来的字节序。
     如果通过contents[x]的方式赋值取值,我们就不需要考虑这个字节序的问题,但是intset根据encoding的值指定元素的地址偏移,暴力地对内存进行操作。若数据被截断了,则大端机器和小端机器会表现出不统一的状况。为了避免这种情况发生,intset不管在什么机器上都按照同一种字节序(小端)在内存中存intset的成员变量。

具体跟字节序有关的代码在endianconv.c,endianconv.h

#ifndef __ENDIANCONV_H
#define __ENDIANCONV_H

#include "config.h"
#include 

void memrev16(void *p);
void memrev32(void *p);
void memrev64(void *p);
uint16_t intrev16(uint16_t v);
uint32_t intrev32(uint32_t v);
uint64_t intrev64(uint64_t v);

/* variants of the function doing the actual convertion only if the target
 * host is big endian */
#if (BYTE_ORDER == LITTLE_ENDIAN)
#define memrev16ifbe(p)
#define memrev32ifbe(p)
#define memrev64ifbe(p)
#define intrev16ifbe(v) (v)
#define intrev32ifbe(v) (v)
#define intrev64ifbe(v) (v)
#else
#define memrev16ifbe(p) memrev16(p)
#define memrev32ifbe(p) memrev32(p)
#define memrev64ifbe(p) memrev64(p)
#define intrev16ifbe(v) intrev16(v)
#define intrev32ifbe(v) intrev32(v)
#define intrev64ifbe(v) intrev64(v)
#endif

/* The functions htonu64() and ntohu64() convert the specified value to
 * network byte ordering and back. In big endian systems they are no-ops. */
#if (BYTE_ORDER == BIG_ENDIAN)
#define htonu64(v) (v)
#define ntohu64(v) (v)
#else
#define htonu64(v) intrev64(v)
#define ntohu64(v) intrev64(v)
#endif

#ifdef REDIS_TEST
int endianconvTest(int argc, char *argv[]);
#endif

#endif


/* Toggle the 16 bit unsigned integer pointed by *p from little endian to
 * big endian */
void memrev16(void *p) {
    unsigned char *x = p, t;

    t = x[0];
    x[0] = x[1];
    x[1] = t;
}

/* Toggle the 32 bit unsigned integer pointed by *p from little endian to
 * big endian */
void memrev32(void *p) {
    unsigned char *x = p, t;

    t = x[0];
    x[0] = x[3];
    x[3] = t;
    t = x[1];
    x[1] = x[2];
    x[2] = t;
}

/* Toggle the 64 bit unsigned integer pointed by *p from little endian to
 * big endian */
void memrev64(void *p) {
    unsigned char *x = p, t;

    t = x[0];
    x[0] = x[7];
    x[7] = t;
    t = x[1];
    x[1] = x[6];
    x[6] = t;
    t = x[2];
    x[2] = x[5];
    x[5] = t;
    t = x[3];
    x[3] = x[4];
    x[4] = t;
}

uint16_t intrev16(uint16_t v) {
    memrev16(&v);
    return v;
}

uint32_t intrev32(uint32_t v) {
    memrev32(&v);
    return v;
}

uint64_t intrev64(uint64_t v) {
    memrev64(&v);
    return v;
}

看代码可知道这些方法是用来低位与高位交换,实现统一的目的。

三API实现

底层赋值/取值操作

通过_intsetSet和_intsetGet这两个工具函数,可以根据intset的encoding 读/写contents里索引为pos的值。这是后续intset操作的基础。


/* Return the value at pos, using the configured encoding. 
 *
 * 根据集合的编码方式,返回底层数组在 pos 索引上的值
 *
 * T = O(1)
 */
static int64_t _intsetGet(intset *is, int pos) {
    return _intsetGetEncoded(is,pos,intrev32ifbe(is->encoding));
}

/* Set the value at pos, using the configured encoding. 
 *
 * 根据集合的编码方式,将底层数组在 pos 位置上的值设为 value 。
 *
 * T = O(1)
 */
static void _intsetSet(intset *is, int pos, int64_t value) {

    // 取出集合的编码方式
    uint32_t encoding = intrev32ifbe(is->encoding);

    // 根据编码 ((Enc_t*)is->contents) 将数组转换回正确的类型
    // 然后 ((Enc_t*)is->contents)[pos] 定位到数组索引上
    // 接着 ((Enc_t*)is->contents)[pos] = value 将值赋给数组
    // 最后, ((Enc_t*)is->contents)+pos 定位到刚刚设置的新值上 
    // 如果有需要的话, memrevEncifbe 将对值进行大小端转换
    if (encoding == INTSET_ENC_INT64) {
        ((int64_t*)is->contents)[pos] = 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);
    }
}

创建一个空intset

空intset的默认encoding是INTSET_ENC_INT16,contents每个成员的逻辑类型是int16_t(虽然还没有成员)

/* Create an empty intset. 
 *
 * 创建并返回一个新的空整数集合
 *
 * T = O(1)
 */
intset *intsetNew(void) {

    // 为整数集合结构分配空间
    intset *is = zmalloc(sizeof(intset));

    // 设置初始编码
    is->encoding = intrev32ifbe(INTSET_ENC_INT16);

    // 初始化元素数量
    is->length = 0;

    return is;
}

插入节点

/* Insert an integer in the intset 
 * 
 * 尝试将元素 value 添加到整数集合中。
 *
 * *success 的值指示添加是否成功:
 * - 如果添加成功,那么将 *success 的值设为 1 。
 * - 因为元素已存在而造成添加失败时,将 *success 的值设为 0 。
 *
 * T = O(N)
 */
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
   // 计算编码 value 所需的长度
    uint8_t valenc = _intsetValueEncoding(value);
    uint32_t pos;
    if (success) *success = 1;
     //valenc: 新元素适合的存储编码
     //is->encoding: 当前集合的编码
     //当 valenc> is->encoding时,表明当前集合无法存储新元素,那么此时需要对集合进行升级。
     //反之,检查集合是否存在该元素
    if (valenc > intrev32ifbe(is->encoding)) {
        return intsetUpgradeAndAdd(is,value);
    } else {
        //查找新元素是否在集合中,如果存在,则return 1, 否则返回0,并设置插入的位置
        if (intsetSearch(is,value,&pos)) {
            if (success) *success = 0;
            return is;
        } 
          // 为 value 在集合中分配空间       
        is = intsetResize(is,intrev32ifbe(is->length)+1);
        //移动pos之后的元素,为新元素腾出空间 
        if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
    }
     // 将新值设置到底层数组的指定位置中
    _intsetSet(is,pos,value);
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}

 通过调用 _intsetValueEncoding 得到value合适的编码valenc。 

/* Return the required encoding for the provided value. 
 *
 * 返回适用于传入值 v 的编码方式
 *
 * T = O(1)
 */
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;
}

intsetUpgradeAndAdd: 仅供内部使用,其功能是对集合进行升级,并添加新元素。

static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    //当前集合编码
    uint8_t curenc = intrev32ifbe(is->encoding);
    //新编码
    uint8_t newenc = _intsetValueEncoding(value);
    //集合大小
    int length = intrev32ifbe(is->length);
    //方向标识
    int prepend = value < 0 ? 1 : 0;
    is->encoding = intrev32ifbe(newenc);
    //扩充空间
    is = intsetResize(is,intrev32ifbe(is->length)+1);
    //移动集合元素
    while(length--)
        _intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
    //prepend = 1:表明value是最小的负数,将新元素添加到集合首位。
    //prepend = 0:表明value是最大的整数,将新元素添加至集合尾部。
    if (prepend)
        _intsetSet(is,0,value);
    else
        _intsetSet(is,intrev32ifbe(is->length),value);
    //设置长度
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}

书上画图讲了升级的过程,结合代码来看升级过程:根据集合原来的编码方式,从底层数组中取出集合元素
     然后再将元素以新编码的方式添加到集合中。    当完成了这个步骤之后,集合中所有原有的元素就完成了从旧编码到新编码的转换。因为新分配的空间都放在数组的后端,所以程序先从后端向前端移动元素,就是length--的过程。

   然后插入头或者尾,升级之后新元素的摆放位置

因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大, 所以这个新元素的值要么就大于所有现有元素, 要么就小于所有现有元素:

  • 在新元素小于所有现有元素的情况下, 新元素会被放置在底层数组的最开头(索引 0 );
  • 在新元素大于所有现有元素的情况下, 新元素会被放置在底层数组的最末尾(索引 length-1 )。

intsetSearch : 它的作用是在整数集合里用二分法找到value的位置,并把位置写给pos参数,函数返回1;若没找到,则写给pos的是能被插入的value的位置(intset按顺序存储),函数返回0。

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) {
        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 比数组中最后一个值都要大
        // 那么 value 肯定不存在于集合中,
        // 并且应该将 value 添加到底层数组的最末端
        if (value > _intsetGet(is,intrev32ifbe(is->length)-1)) {
            if (pos) *pos = intrev32ifbe(is->length);//value可以被插入的位置
            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;
        cur = _intsetGet(is,mid);
        if (value > cur) {
            min = mid+1;
        } else if (value < cur) {
            max = mid-1;
        } else {
            break;
        }
    }

    if (value == cur) {
        if (pos) *pos = mid;//找到了
        return 1;
    } else {
        if (pos) *pos = min;
        return 0;
    }
}

movetoTail:移动元素到末尾

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);

    // 根据不同的编码
    // src = (Enc_t*)is->contents+from 记录移动开始的位置
    // dst = (Enc_t*)is_.contents+to 记录移动结束的位置
    // bytes *= sizeof(Enc_t) 计算一共要移动多少字节
    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);
    }

    // 进行移动
    // T = O(N)
    memmove(dst,src,bytes);
}

集合from位置之后的元素 移至 to位置,内部使用c语言memmove函数保证移动过程中数据的完整性。注意复制之后from值不变,但是可以被覆盖。

intsetRemove:从集合移除某个元素

intset *intsetRemove(intset *is, int64_t value, int *success) {

    // 计算 value 的编码方式
    uint8_t valenc = _intsetValueEncoding(value);
    uint32_t pos;

    // 默认设置标识值为删除失败
    if (success) *success = 0;

    // 当 value 的编码大小小于或等于集合的当前编码方式(说明 value 有可能存在于集合)
    // 并且 intsetSearch 的结果为真,那么执行删除
    // T = O(log N)
    if (valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,&pos)) {

        // 取出集合当前的元素数量
        uint32_t len = intrev32ifbe(is->length);

        /* We know we can delete */
        // 设置标识值为删除成功
        if (success) *success = 1;
        //往前移位
         if (pos < (len-1)) intsetMoveTail(is,pos+1,pos);
        // 缩小数组的大小,移除被删除元素占用的空间
        // T = O(N)
        is = intsetResize(is,len-1);
        // 更新集合的元素数量
        is->length = intrev32ifbe(len-1);
    }

    return is;
}

移除一个成员时不会改变intset的encoding,尽管移除这个成员之后所有成员的encoding都小于所在intset的encoding。也就是说intset内部只有”编码升级”的过程,没有”降级”的操作。当将唯一一个高位元素从将集合移除时,此时,集合不会转换为低位编码集合。 

总结:

  • 整数集合是集合键的底层实现之一。
  • 整数集合的底层实现为数组, 这个数组以有序、无重复的方式保存集合元素, 在有需要时, 程序会根据新添加元素的类型, 改变这个数组的类型。
  • 升级操作为整数集合带来了操作上的灵活性, 并且尽可能地节约了内存。
  • 整数集合只支持升级操作, 不支持降级操作。

看代码可以知道,代码设计很巧妙,在灵活节约内存,因为基于有序数组的一些操作:SADD、SREM 操作一个成员时,时间复杂度会是O(logn)。所以当整数集合数据量变大的时候,redis会用dict作为集合的底层实现,将SADD、SREM、SISMEMBER这些命令的时间复杂度降至O(1)。

 

参考:

https://www.jianshu.com/p/7cc35fbdd1fb

https://blog.csdn.net/mijar2016/article/details/52197963

https://www.cnblogs.com/gremount/p/8830707.html

你可能感兴趣的:(数据库,redis从入门到放弃)