底层数据结构:sds、list、dict、ziplist、intset、skiplist

1.String

Redis构建了简单动态字符串SDS来作为默认字符串表示,属于可修改字符串的值。
当一些如打印日志等不需被修改的字符串则用C语言传统字符串表示。
sds用于存储字符串、AOF缓冲区、客户端状态中的输入缓冲区等。
sds实际是char型指针,即C语言的字符串表述形式
sdshdr是redis中的简单动态字符串结构,而实际上在使用字符串时,
依旧是使用char* 而不是sdshdr,在C中可根据地址偏移,
得到该char* (sds)所在的sdshdr的地址,借用指针进行操作。

sds定义:
        struct sdshdr{
            int len;//记录buf数组中已使用字节长度,等于SDS保存的字符串长度
            int free;//记录buf数组中未使用的字节长度
            char buf[];//字符数组,用于保存字符串
        }

buf[]保存字符,最后一个字节保存空字符’\0’结尾,这1字节空间不计算在len属性中。
遵循空字符结尾惯例,可对C字符串函数库中进行一些重用。

/* 根据给定的初始化字符串 init 和字符串长度 initlen
         * 创建一个新的 sds
         * 参数
         *  init :初始化字符串指针
         *  initlen :初始化字符串的长度
         * 返回值
         *  sds :创建成功返回 sdshdr 相对应的 sds
         *        创建失败返回 NULL
         * 复杂度
         *  T = O(N) */
        sds sdsnewlen(const void *init, size_t initlen) {
            struct sdshdr *sh;
            // 根据是否有初始化内容,选择适当的内存分配方式
            // T = O(N)
            if (init) {
                // zmalloc 不初始化所分配的内存
                sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
            } else {
                // zcalloc 将分配的内存全部初始化为 0
                sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
            }
            // 内存分配失败,返回
            if (sh == NULL) return NULL;
            // 设置初始化长度
            sh->len = initlen;
            // 新 sds 不预留任何空间
            sh->free = 0;
            // 如果有指定初始化内容,将它们复制到 sdshdr 的 buf 中
            // T = O(N)
            if (initlen && init)
                memcpy(sh->buf, init, initlen);
            // 以 \0 结尾
            sh->buf[initlen] = '\0';
            // 返回 buf 部分,而不是整个 sdshdr
            return (char*)sh->buf;
        }

SDS与C字符串相比:
①SDS结构的len属性记录了字符串长度,当要获取时,复杂度仅为O(1),无需进行O(n)的遍历;
②杜绝缓冲区溢出:在对SDS字符串进行修改时,会检查SDS剩余空间(free属性)是否充足,
若不足则先进行扩展。
③减少修改字符串时的内存重分配次数
④可保存二进制数据
⑤兼容部分C字符串函数

2.链表

Redis链表结构(adlist.h/listNode)

typedef struct listNode{
                struct listNode *prev;//前置节点
                struct listNode *next;//后置节点
                void *value;//节点值
            }listNode;

对listNode进行一层包装(adlist/list)

typedef struct list{
                listNode *head//表头节点
                listNode *tail;//表尾结点
                unsigned long len;//链表包含的节点数量
                void *(*dup)(void *ptr);//节点值复制函数
                void (*free)(void *ptr);//节点值释放函数
                int (*match)(void *ptr,void *key);//节点值对比函数
            }list;

|list listNode <– listNode <–listNode
|head –> value=.. –> value=..–> value=.. –>null
|tail —————————————↑
|len=3
|dup –>…..list结构中的3个listNode
|free –>….
|match –>…
Redis链表特性:
双端:链表节点有prev和next指针
无环:表头节点的prev和表尾节点的next指向null 不循环
带头指针和尾指针:list结构的head指针和tail指针
计数器:list结构的len属性保存节点个数
多态:链表节点使用void*指针保存节点值,
可通过list结构的dup、free、match属性为节点值设置类型特定函数
因此链表可保存各种不同类型的值。
(①void指针可以指向任意类型的数据,亦即可用任意数据类型的指针对void指针赋值
②可以用void指针来作为函数形参,就可以接受任意数据类型的指针作为参数)

3.字典

(map映射,用于保存键值对 key-value)
Redis哈希表结构(dict.h/dictht)

typedef struct dictht{
                dictEntry **table;//哈希表数组
                unsigned long size;//哈希表大小
                unsigned long sizemask;//哈希表大小掩码用于计算索引值(=size-1)
                unsigned long used;//已有节点数量
            }dictht;

table数组中每个元素指向dict.h/dictEntry结构的指针。
每个dictEntry结构保存一个键值对。size属性记录哈希表大小(table数组大小)
哈希表节点dictEntry

typedef struct dictEntry{
                void *key;//键
                union{ //值
                    void *val;
                    uint64_tu64;
                    int64_ts64;
                }v;
                struct dictEntry *next;//下个哈希表节点
            }dictEntry;

key属性保存键值对中的键,v属性保存值,值可以是一个指针、uint64_t整数或int64_t整数。
next属性指向另一个哈希表节点指针,可以将多个哈希值相同的键值对连接起来,解决键冲突问题。
例:

|dictht   两个索引值相同的键k1 k0 通过dictEntry结构的next指针连接起来
|table  ---->   dictEntry*[4]
|size=4         |0 -->null
|sizemask=3     |1 -->null
|used=2         |2 -->null
                |3 --> dictEntry  --> dictEntry -->null
                       |k1 |v1        |k0 |v0

dict.h/dict结构表示字典(在dictht上再包装一层)

typedef struct dict{
                dictType *type;//类型特定函数
                void *privdata;//私有数据
                dictht ht[2];//哈希表
                int trehashidx;//rehash索引,当rehash不在进行时,为-1
            }dict;

type属性和privdata属性针对不同类型的键值对,

type指向dictType结构指针,每个dictType结构保存了一簇特定类型键值对的操作函数

privdata属性保存了需要传给特定函数的可选参数

typedef struct dictType{

unsigned int (*hashFunction)(const void *key);

void *(*keyDup) (void *privdata,const void *key);

void *(*valDup) (void *privdata,const void *key);

int (*keyCompare)(void *privdata,const void *key1,const void *key2);

void *(*keyDestructor) (void *privdata, void *key);

void *(*valDestructor) (void *privdata,void *obj);

}dictType;

ht属性是包含两个项(dictht)的数组,一般只使用ht[0]哈希表,当对ht[0]进行rehash时才使用ht[1]

rehashidx属性记录rehash,若当前没有在进行,则值为-1。

例:普通状态下(没有rehash)的字典 (图4-3普通状态下的字典)

redis 错误日志输出 redis日志内容_redis 错误日志输出

4.哈希算法

当将一个新键值对加入到字典中时,先计算键的哈希值和索引值(哈希值对sizemask取模),

再根据索引值将新键值节点放入到dictht的table数组中合适的dictEntry链表中。

hash=dict->type->hashFunction(key);

index=hash& dict->ht[x].sizemask;

例:添加一个新键值对的过程图(图4-5添加新键值对)

redis 错误日志输出 redis日志内容_c语言_02


键冲突问题:有两个或以上数量的键分配到同一个索引上时

开放地址法(再散列,直到索引不冲突):反复计算索引,并要求有足够的索引能用来存储。

链地址法:当索引冲突时,在该索引下以链表的方式存储

Redis中dictEntry节点组成的链表没有指向尾部的指针,因此采用头插法,将新节点添加到链表表头O(1)

5.rehash

当哈希表保存的键值对逐渐增多或减少时,为了维持合理的负载因子,对哈希表大小进行相应的扩展或收缩

步骤如下:

1)为字典ht[1]哈希表分配空间,

若是扩展操作,则ht[1]的大小为第一个大于等于ht[0].used*2 的2^n (即size=2^n>=ht[0].used*2)

若是收缩操作,则ht[1]的大小为第一个大于等于ht[0].used的2^n (即size=2^n>=ht[0].used)

2)将保存在ht[0]中的所有键值对重新计算散列到ht[1]中

3)完成上述rehash操作后,释放ht[0],更换ht[1]为ht[0],创建一个新的空ht[1]为下次rehash使用

(即 free(ht[0]),*ht[0]=*ht[1],ht[1]=new dictht)

过程图(图4-8rehash扩展)

redis 错误日志输出 redis日志内容_字符串_03

int dictRehash(dict *d, int n) {
            // 只可以在 rehash 进行中时执行
            if (!dictIsRehashing(d)) return 0;
            // 进行 N 步迁移
            // T = O(N)
            while(n--) {
                dictEntry *de, *nextde;
                /* Check if we already rehashed the whole table... */
                // 如果 0 号哈希表为空,那么表示 rehash 执行完毕
                // T = O(1)
                if (d->ht[0].used == 0) {
                    // 释放 0 号哈希表
                    zfree(d->ht[0].table);
                    // 将原来的 1 号哈希表设置为新的 0 号哈希表
                    d->ht[0] = d->ht[1];
                    // 重置旧的 1 号哈希表
                    _dictReset(&d->ht[1]);
                    // 关闭 rehash 标识
                    d->rehashidx = -1;
                    // 返回 0 ,向调用者表示 rehash 已经完成
                    return 0;
                }
                /* Note that rehashidx can't overflow as we are sure there are more
                 * elements because ht[0].used != 0 */
                // 确保 rehashidx 没有越界
                assert(d->ht[0].size > (unsigned)d->rehashidx);
                // 略过数组中为空的索引,找到下一个非空索引
                while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
                // 指向该索引的链表表头节点
                de = d->ht[0].table[d->rehashidx];
                /* Move all the keys in this bucket from the old to the new hash HT */
                // 将链表中的所有节点迁移到新哈希表
                // T = O(1)
                while(de) {
                    unsigned int h;
                    // 保存下个节点的指针
                    nextde = de->next;
                    /* Get the index in the new hash table */
                    // 计算新哈希表的哈希值,以及节点插入的索引位置
                    h = dictHashKey(d, de->key) & d->ht[1].sizemask;
                    // 插入节点到新哈希表
                    de->next = d->ht[1].table[h];
                    d->ht[1].table[h] = de;
                    // 更新计数器
                    d->ht[0].used--;
                    d->ht[1].used++;
                    // 继续处理下个节点
                    de = nextde;
                }
                // 将刚迁移完的哈希表索引的指针设为空
                d->ht[0].table[d->rehashidx] = NULL;
                // 更新 rehash 索引
                d->rehashidx++;
            }
            return 1;
        }

哈希表扩展与收缩条件:(以下条件满足一个即可)
负载因子=哈希表已保存节点数量/哈希表大小
load_factor=ht[0].used/ht[0].size;
扩展:
1)服务器目前没有在执行BGSAVE/BGREWRITEAOF命令,且哈希表负载因子>=1
2)服务器目前正在执行BGSAVE/BGREWRITEAOF命令,但哈希表负载因子>=5
收缩:
当负载因子<0.1时,执行收缩操作。

6.渐进式rehash

当哈希表中的键值对比较多时,如果采用集中式一次性完成rehash会造成一定的影响
为了避免对服务器性能造成影响,采用多次渐进式地将ht[0]里的键值对rehash到ht[1]
步骤如下:
1)为ht[1]分配空间,让字典同时持有ht[0]、ht[1]
2)维持索引计数器变量rehashidx,设置为0,表示rehash开始。
3)rehash期间,对字典进行正常操作的同时,会顺带将ht[0]上rehashidx索引上的键值对rehash到ht[1],完成后rehashidx+1
4)随着字典操作的不断进行,最终使ht[0]上所有键值对rehash到ht[1]上,修改rehashidx值为-1,过程结束。

// 在给定毫秒数内,以 100 步为单位,对字典进行 rehash 。
                int dictRehashMilliseconds(dict *d, int ms) {
                    // 记录开始时间
                    long long start = timeInMilliseconds();
                    int rehashes = 0;
                    while(dictRehash(d,100)) {
                        rehashes += 100;
                        // 如果时间已过,跳出
                        if (timeInMilliseconds()-start > ms) break;
                    }
                    return rehashes;
                }

注意:rehashidx的值范围为[-1,ht[0].sizemask]
在渐进式rehash期间,对字典的操作会在ht[0]中先查找对应键,没有命中则在ht[1]中查找,
对于新增的键,会一律存在ht[1]中,使ht[0]逐渐变成空表。

7.跳跃表

有序数据结构,在每个节点中维持多个指向其他节点的指针,达到快速访问节点的目的。

有序链表中,节点具有多个指向,可加快搜索,复杂度O(logn)

level 3 -INF———–21↓————————-55↓

level 2 -INF—2↓——-21↓——-37↓————55↓

level 1 -INF–>2–>17–>21–>33–>37–>46–>55

Redis跳跃表由redis.h/zskiplistNode和redis.h/zskiplist结构定义

zskiplistNode表示跳跃表节点, zskiplist表示关于节点的相关信息,如节点数量、头尾指针等

(图5-1跳跃表)

redis 错误日志输出 redis日志内容_redis 错误日志输出_04

typedef struct zskiplistNode{
                struct zskiplistLevel{ //层
                    struct zskiplistNode *forward; //前进指针
                    unsigned int span; //跨度
                }level[];

                struct zskiplistNode *backward;//后退指针
                double score;//分值
                robj *obj;//成员对象
            }zskiplistNode;

1)层:跳跃表节点的level数组可包含多个元素,每个元素包含指向其他节点的指针。
level[i].forward代表 本节点在第i层中的指向的下一个节点
每次创建一个新跳跃表节点时,根据幂次定律随机生成一个介于1和32之间的值作为level数组大小,即高度
2)跨度:level[i].span属性,记录两个节点之间的距离。跨度用于计算目标节点在跳跃表中的排位。(将沿途访问过的所有层跨度累加)
3)后退指针:用于从尾部逆向访问至表头,每次仅后退一个节点。
4)分值和成员:跳跃表中节点按分值从小到大排序,obj成员指向一个字符串对象,保存SDS值
(同一个跳跃表中各节点的成员对象是唯一的,但分值可以重复)
使用zskiplist结构维持跳跃表,快速访问表头、表尾节点,获取节点数量等信息。

typedef struct zskiplist{
struct zskiplistNode *header,*tail;//表头尾节点
unsigned long length;//表中节点数量
int level;//表中最高层数
}zskiplist;
/* 创建一个层数为 level 的跳跃表节点,
* 并将节点的成员对象设置为 obj ,分值设置为 score 。
* 返回值为新创建的跳跃表节点  */
zskiplistNode *zslCreateNode(int level, double score, robj *obj) {
// 分配空间
zskiplistNode *zn = zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
// 设置属性
zn->score = score;
zn->obj = obj;
return zn;
}
/* 创建并返回一个新的跳跃表,ZSKIPLIST_MAXLEVEL=32  */
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
// 分配空间
zsl = zmalloc(sizeof(*zsl));
// 设置高度和起始层数
zsl->level = 1;
zsl->length = 0;
// 初始化表头节点
// T = O(1)
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
zsl->header->level[j].forward = NULL;
zsl->header->level[j].span = 0;
}
zsl->header->backward = NULL;
// 设置表尾
zsl->tail = NULL;
return zsl;
}
/**由于跳跃表的第一层level[0]是简单顺序链表形式保存所有节点关系的。
因此在需要释放表时遍历level[0]依次释放即可。*/
void zslFree(zskiplist *zsl) {
zskiplistNode *node = zsl->header->level[0].forward, *next;
// 释放表头
zfree(zsl->header);
// 释放表中所有节点
// T = O(N)
while(node) {
next = node->level[0].forward;
zslFreeNode(node);
node = next;
}
// 释放跳跃表结构
zfree(zsl);
}
e.HyperLogLog:hyperloglog.c 中的 hllhdr
struct hllhdr {
char magic[4];      /* "HYLL" */
uint8_t encoding;   /* HLL_DENSE or HLL_SPARSE. */
uint8_t notused[3]; /* Reserved for future use, must be zero. */
uint8_t card[8];    /* Cached cardinality, little endian. */
uint8_t registers[]; /* Data bytes. */
};

7.5 HyperLogLog

可以接受多个元素作为输入,并给出输入元素的基数估算值:
基数:集合中不同元素的数量。比如 {‘apple’, ‘banana’, ‘cherry’, ‘banana’, ‘apple’} 的基数就是 3 。
估算值:算法给出的基数并不是精确的,可能会比实际稍微多一些或者稍微少一些,但会控制在合
理的范围之内。
HyperLogLog 的优点是,即使输入元素的数量或者体积非常非常大,计算基数所需的空间总是固定
的、并且是很小的。
在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基
数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以
HyperLogLog 不能像集合那样,返回输入的各个元素。
redis> PFADD str1 “apple” “banana” “cherry”
(integer) 1
redis> PFCOUNT str1
(integer) 3
redis> PFADD str2 “apple” “cherry” “durian” “mongo”
(integer) 1
redis> PFCOUNT str2
(integer) 4
redis> PFMERGE str1&2 str1 str2
OK
redis> PFCOUNT str1&2
(integer) 5

8.整数集合

用于保存整数值的集合抽象数据结构,保存int16_t、int32_t、int64_t,无重复元素。
intset.h/intset结构:

typedef struct intset {
uint32_t encoding;//编码方式
uint32_t length;//元素数量
int8_t contents[];//元素数组
}intset;

contents数组中元素按从小到大排列,不含重复项。
contents元素类型取决于encoding
|intset
|encoding=INT16
|length=5
|contents –> |-5|18|89|252|14632|
sizeof(int16_t)*5=80位空间大小
当新添加的整数类型比原集合编码类型要大时,则对集合进行升级更新,将数组内元素都变为较大的类型并调整内存空间位
如,当原集合类型为INT_16,新增一个INT_64时,则将原元素都更改为INT_64 调整集合空间大小.
升级:更改编码并修改原底层数组中元素值的地址,改为新编码方式赋予
详见 源码intset.c/intsetUpgradeAndAdd 函数
例: 原先contents[0] 为INT_16编码存储的整数5 ,地址范围为 0X….a - 0X….b
当contents编码升级为INT_32时,对于整数5的地址可用空间变大了0X…a -0X…c
所以需要对整数5以INT_32编码形式重新赋予contents[0],覆盖整个可用空间地址。

// 根据集合原来的编码方式,从底层数组中取出集合元素
// 然后再将元素以新编码的方式添加到集合中
// 当完成了这个步骤之后,集合中所有原有的元素就完成了从旧编码到新编码的转换
// 因为新分配的空间都放在数组的后端,所以程序先从后端向前端移动元素
// 举个例子,假设原来有 curenc 编码的三个元素,它们在数组中排列如下:
// | x | y | z | 
// 当程序对数组进行重分配之后,数组就被扩容了(符号 ? 表示未使用的内存):
// | x | y | z | ? |   ?   |   ?   |
// 这时程序从数组后端开始,重新插入元素:
// | x | y | z | ? |   z   |   ?   |
// | x | y |   y   |   z   |   ?   |
// |   x   |   y   |   z   |   ?   |
// 最后,程序可以将新元素添加到最后 ? 号标示的位置中:
// |   x   |   y   |   z   |  new  |
// 上面演示的是新元素比原来的所有元素都大的情况,也即是 prepend == 0
// 当新元素比原来的所有元素都小时(prepend == 1),调整的过程如下:
// | x | y | z | ? |   ?   |   ?   |
// | x | y | z | ? |   ?   |   z   |
// | x | y | z | ? |   y   |   z   |
// | x | y |   x   |   y   |   z   |
// 当添加新值时,原本的 | x | y | 的数据将被新值代替
// |  new  |   x   |   y   |   z   |
while(length--)
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
//_intsetGetEncoded返回以旧编码获得的length位置上的整数值value,
//_intsetSet将value以新编码放到contents数组的正确位置上。

9.压缩列表

ziplist,列表键和哈希键的底层实现之一。
压缩列表采取的方式犹如单向链表,比双端链表要节省空间,
在节点方面,与双端链表相比,数据结构更加简单。
但双端链表结构记录更详细信息,对于复杂情况更加快速。
例:
redis> RPUSH lst 1 3 5 12345 “hello” “good”
(integer)6
redis> OBJECT ENCODING lst
“ziplist”
列表键lst中包含的是较小的整数值及短字符串。
当一个哈希键中只包含少量键值对,且键值是小整数值或短字符串,也会使用压缩列表
例:
redis> HMSET profile “name” “Jack” “age” 28 “job” “Programmer”
OK
redis> OBJECT ENCODING profile
“ziplist”
压缩列表由一系列特殊编码的连续内存块组成,顺序型数据结构。
一个压缩列表可包含任意多个结点entry,每个节点可保存一个字节数组或整数值。
压缩列表组成部分:
|zlbytes|zltail|zllen|entry1|entry2|…|entryN|zlend|
zlbytes: uint32_t 记录整个压缩列表占用的内存字节数
zltail :uint32_t 记录压缩列表表尾节点距离起始地点的偏移量。
zllen :uint16_t 记录压缩列表包含的节点数量(数量大于uint16_t_MAX时需要遍历计算)
entryX : 列表节点
zlend :uint8_t 标记末端。
压缩列表节点组成部分:
|previous_entry_length|encoding|content|
节点可保存一个字节数组或一个整数值
字节数组:
1)长度小于等于63(2^6 -1)字节的字节数组
2)长度小于等于16383(2^14 -1)字节的字节数组
3)长度小于等于4294967295(2^32 -1)字节的字节数组
整数值:
1)4位长,介于0-12的无符号整数
2)1字节长的有符号整数
3)3字节长的有符号整数
4)int16_t类型整数
5)int32_t类型整数
6)int64_t类型整数
|previous_entry_length|:1或5字节 记录压缩列表中前一个节点的长度
|encoding|:记录节点content属性所保存数据的类型及长度
|content|:保存节点的值,值的类型和长度由encoding属性决定

/* 保存 ziplist 节点信息的结构  */
typedef struct zlentry {
// prevrawlen :前置节点的长度
// prevrawlensize :编码 prevrawlen 所需的字节大小 用来计算节点的编码
unsigned int prevrawlensize, prevrawlen;
// lensize :编码 len 所需的字节大小
unsigned int lensize, len;
// 当前节点 header 的大小
// 等于 prevrawlensize + lensize
unsigned int headersize;
// 当前节点值所使用的编码类型
unsigned char encoding;
// 指向当前节点的指针
unsigned char *p;
} zlentry; // len :当前节点值的长度

压缩列表小结{
是一种为节约内存开发的顺序型数据结构
用作列表键和哈希键的底层实现之一
可包含多个节点,每个节点保存一个字节数组或整数值
添加新节点或删除节点,可能引发连锁更新操作,不过出现的几率不高
}