读书笔记-《Redis设计与实现》-第一部分:数据结构与对象

第二章:简单动态字符串 Simple Dynamic String

struct sdshdr {

    int len; // 已使用的字节数量

    int free; // 未使用的字节数量

    char buf[]; // 字节数组,保存二进制数据

}

SDS与C原生字符串(string.h)的区别

1.通过len可O(1)获取字符串的长度。

2.扩容时有判断,不会造成缓冲区溢出。

3.内存分配次数少:空间预分配 + 惰性释放内存。

空间预分配:当free不够用的时候,扩容后进行预分配,free = x < 1MB ? x:1MB

惰性释放内存:新增时如果free里空间足够则不用新申请内存,同样的释放时先把多的放在free里

4.可保存文本或者二进制数据:因为C标准是以空字符作为空字符串的结尾,所以原生C字符串无法保存二进制数据,但是SDS可以通过len来判断是否结尾,所以可保存二进制数据。

5.尽可能兼容string.h。(联想:Guava提供的新集合框架,也是对原生JDK集合框架的兼容)

(联想:如何扩容的?详见结尾)

第三章:链表

typedef stuct list {

    listNode * head; // 表头结点

    listNode *tail; // 表尾结点

    unsigned long len; // 结点数量

    void *(*dup)(void *ptr); // 结点复制

    void (*free)(void *ptr); // 结点释放

    int (*match)(void *ptr, void *key); // 结点值对比

}

typedef struct listNode {

    struct listNode * prev; // 前置结点

    struct listNode *next; // 后置结点

    void *value; // 值

}

特点:双向、无环、多态

第四章:字典

typedef struct dict {

    dictType *type; // 类型特定函数

    void *privdata; // 私有数据

    dictht ht[2]; // 哈希表

    int trehashidx; // rehash状态标识,开始为0,每次rehash一个结点+1,结束为-1

}

typedef struct dictht {

    dictEntry **table; // 数组

    unsigned long size; // 大小

    unsigned long sizemask; // 掩码,用于计算索引值,总是等于size-1

    unsigned long used; // 已有结点数量

}

typedef struct dictEntry {

    void *key; // 键

    union{ // 值

        void *val;

        uint64_t u64;

        int64_t s64;

    } v;

    struct dictEntry *next; // 指向下一个结点,用于解决hash碰撞

}

hash函数:MurmurHash Austin Appleby,优点是即便输入的键很规律,也能给出一个很好的随机分布,计算量也不大

hash碰撞:链地址法,即冲突的结点形成了一个单向链表(联想:处理冲突的方法和JDK1.7一样,JDK1.8后进一步优化了,如果某个链表的结点数大于8,将该链表转为红黑树。Redis是否也应该做这个优化呢?这里我的想法是Redis的hash函数已经很棒了,hash碰撞的概率不高,所以这个优化可能不会带来特别明显的效果)

rehash:

ht[0]用来使用,ht[1]用来rehash。load_factor = ht[0].used / ht[0].size(联想:注意这里负载因子是算出来的,和Java里直接设置不一样,Java里HashMap默认大小16,负载因子0.75,也可调用带参的构造函数进行赋值)

如果当前没有执行 BGSAVE / BGREWRITEAOF命令 且负载因子大于等于1 或者 当前正在执行上述命令且负载因子大于等于5,则进行扩容。这两个命令会导致Redis创建当前服务器进程的子进程,而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以这个时候要减少内存的开销。

如果是扩容,则ht[1].size 为大于等于 ht[0].used*2的第一个2的n次方;如果是缩容,ht[1].size为第一个大于等于ht[0].used的2的n次方。

渐进式rehash:

hashmap的rehash时一个很耗时的操作,因为需要将之前的数据取出来,重新hash到新的地址。Java里的HashMap我们可以通过提前预估数据量的大小,调用传了数组大小的构造函数,从而避免rehash;但Redis没有办法这样做,并且作为一个高可用的数据库,不可能让它停止服务来进行rehash,所以Redis采用了渐进式rehash的方式,非常的机智,在我们对hashmap进行增删改查的时候,它都会偷偷rehash一个过去。

(联想:这个解决rehash的思路和JDK11里ZGC对原有G1的优化有异曲同工之妙,G1里因为要遍历堆,所以性能始终和堆大小有关,无可避免的要Stop The World,而ZGC里通过彩色指针、负载屏障,虽然增加了原有对象指针的逻辑,但却因此无需再Stop The World;我们这里的rehash原本就需要Stop The World,但是通过增加了原有增删改查的逻辑,将这个时间平摊到了每一步操作中,从而就不需要再Stop The World了;这样解决问题的思路,我猜想在其他框架里也会遇到,但在业务场景中可能就不大适用了,当我们遇到一个很耗性能的业务场景时,应该不会去考虑将它和其它业务场景耦合)

第五章:跳跃表

通常,跳跃表的性能与平衡树相当,但其实现更简单,所有不少程序都是用跳跃表来代替平衡树。

(联想:跳表增删查的时间复杂度平均为log2N,最坏为N;而平衡树例如B-tree,平均和最坏都是log2N)

typedef struct zskiplist {

    structz skiplistNode *header, *tail; // 表头结点 表尾结点

    unsigned long length; // 结点数量

    int level; // 最高层的层数

}

typedef struct zskiplistNode {

    struct zskiplistNode *backward; // 后退指针

    double score; // 分值 结点按照其从小到大排列,如果相等则按照成员对象排

    robj *obj; // 成员对象 指向一个字符串对象

    struct zskiplistLevel { // 层 创建结点时随机生成 (1,32) 数字越大概率越小

        struct zskiplistNode *forward; // 前进指针 可用于向后遍历,为null是则已到表尾

        unsigned int span; // 跨度 可用于计算排位 即结点的位置

    }

}

第六章:整数集合

可保存int16_t、int32_t、int64_t类型的整数值,并且集合里元素不重复

typedef struct intset {

    uint32_t encoding; // 编码方式

    uint32_t length; // 元素数量

    int8_t cotents[]; // 元素,有序不重复,大小等于位数*length

}

升级:扩展数组长度,将所有元素类型转为新元素类型并放到新位置,将新元素加入到数组。例如已有1,2,3,加入65535。

实现机制的好处:

1.提升了灵活性。C语言是静态类型语言,保持所有元素的类型相同,可避免类型错误。

2.节约内存。

不支持降级。

(联想:如何扩容的?详见结尾)

第七章:压缩链表

为节约内存而开发,是由一系列特殊编码的连续内存块组成的顺序型数据结构。

zlbytes zltail zllen entry1 entry2 ... entryN zlend

zlbytes:记录整个表的内存字节数。在内存重分配或计算zlend位置时使用。

zltail:表尾结点距离起始地址的记录。

zllen:结点数量,如果等于UINT16_MAX(65535),则实际值需要进行遍历才能得出。

zlend:特殊值0xFF,末端标记。

每个结点保存一个字节数组或整数值。

previous_entry_length encoding content

previous_entry_length:记录了前一个结点的长度。可用来计算前一个结点的起始地址,从而实现逆向遍历。该属性占用的内存 = 上一结点长度 < 254字节 ? 1字节 : 5字节

连锁更新:新增、删除操作可能会造成连锁更新。例如当前结点全部介于250~253字节,此时新增一个大于等于254,每个结点的 previous_entry_length 都会变化。

第八章:对象

typedef struct redisObject{

    unsigned type : 4; // 类型

    unsigned encoding : 4; // 编码方式,即底层数据结构

    void *ptr; // 指向底层数据结构的指针

    int refcount; // 引用计数(联想:引用计数法是一个非常好实现的GC算法,但其缺点是无法解决互相引用的问题;这里由于该GC只在Redis自己的代码里使用,所以只要开发者避开互相引用就OK了。猜测是手动规避+IDE扫描检查有无互相引用,同时测试的时候测一下GC有没有无法回收对象的情况就OK了。)

    unsigned lru : 22; // 最后一次被命令访问的时间 可用来计算空窗时间 GC回收等等

    .......

}
类型 编码 备注
REDIS_STRING REDIS_ENCODING_INT 只包含long类型的整数时使用
REDIS_STRING REDIS_ENCODING_EMBSTR

保存短字符串的优化方式;通过一次内存分配,将redisObject和sdshdr放在一块连续的空间,缓存带来的效益更高。

只读的,一旦对其进行修改,将转为RAW编码。

REDIS_STRING REDIS_ENCODING_RAW
REDIS_LIST REDIS_ENCODING_ZIPLIST 元素个数小于512并且每个元素小于64字节时使用
REDIS_LIST REDIS_ENCODING_LINKEDLIST
REDIS_HASH REDIS_ENCODING_ZIPLIST 元素个数小于512并且每个元素小于64字节时使用
REDIS_HASH REDIS_ENCODING_HT
REDIS_SET REDIS_ENCODING_INTSET 元素个数小于512并且每个元素都是整数值时使用
REDIS_SET REDIS_ENCODING_HT
REDIS_ZSET REDIS_ENCODING_ZIPLIST 元素个数小于128并且每个元素小于64字节时使用
REDIS_ZSET REDIS_ENCODING_SKIPLIST

同时使用跳跃表和字典,获得了两种数据结构的优势;并且两种数据结构通过指针共享元素,不会浪费内存;

关于各个数据结构:平时我们谈起能够兼顾读写性能的数据结构,最先想到的就是平衡树和Hash,Redis里没有树,不过有SKIPLIST。整体看下来最棒的是在ZSET,它同时使用SKIPLIST和Hash,功能强大性能优秀,但实际业务场景中还是需要综合考量,具体的我想之后要阅读的《Redis实战》、《Redis开发与运维》应该能给出不错的建议。

类型检查与多态,无需多说。

对象共享机制:0~9999的字符串对象都是共享的。为什么只共享数字?因为字符串判断相等O(N),而数字O(1)。

(联想:这个共享并不陌生了,Java里的int也是有缓存的,源码如下)

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high){
        return IntegerCache.cache[i + (-IntegerCache.low)];
    }
    return new Integer(i);
}

1.SDS、整数集合的扩容方式?与Java里String、StringBuffer、StringBuilder对比有什么区别吗?(JDK1.7)

首先我们来看看SDS的初始化和拼接操作。初始化支持空或具体大小;拼接操作支持拼接SDS或char数组,逐步深入我们会发现,当free不够的时候,数组长度会扩展到(SDS.length + 参数.length)* 2,当然这里还有阀值1024*1024,也就是我们最开始提到的SDS的空间预分配策略。

举个栗子:SDS length=8 free=2,拼接的SDS.length=3,那么就需要扩展到(8+3)*2=22,此时新SDS.length=11,free=11

/*
 * 创建一个只包含空字符串 "" 的 sds
 *
 * T = O(N)
 */
sds sdsempty(void) {
    // O(N)
    return sdsnewlen("",0);
}

/*
 * 根据给定初始化值 init ,创建 sds
 * 如果 init 为 NULL ,那么创建一个 buf 内只包含 \0 终结符的 sds
 *
 * T = O(N)
 */
sds sdsnew(const char *init) {
    size_t initlen = (init == NULL) ? 0 : strlen(init);
    return sdsnewlen(init, initlen);
}

/*
 * 创建一个指定长度的 sds 
 * 如果给定了初始化值 init 的话,那么将 init 复制到 sds 的 buf 当中
 *
 * T = O(N)
 */
sds sdsnewlen(const void *init, size_t initlen) {
    struct sdshdr *sh;
    // 有 init ?
    // O(N)
    if (init) {
        sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
    } else {
        sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
    }
    // 内存不足,分配失败
    if (sh == NULL) return NULL;
    sh->len = initlen;
    sh->free = 0;
    // 如果给定了 init 且 initlen 不为 0 的话
    // 那么将 init 的内容复制至 sds buf
    // O(N)
    if (initlen && init)
        memcpy(sh->buf, init, initlen);
    // 加上终结符
    sh->buf[initlen] = '\0';
    // 返回 buf 而不是整个 sdshdr
    return (char*)sh->buf;
}

/*
 * 将一个 char 数组拼接到 sds 末尾 
 *
 * T = O(N)
 */
sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}

/*
 * 拼接两个 sds 
 *
 * T = O(N)
 */
sds sdscatsds(sds s, const sds t) {
    return sdscatlen(s, t, sdslen(t));
}

/*
 * 按长度 len 扩展 sds ,并将 t 拼接到 sds 的末尾
 *
 * T = O(N)
 */
sds sdscatlen(sds s, const void *t, size_t len) {
    struct sdshdr *sh;
    size_t curlen = sdslen(s);
    // O(N)
    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    // 复制
    // O(N)
    memcpy(s+curlen, t, len);
    // 更新 len 和 free 属性
    // O(1)
    sh = (void*) (s-(sizeof(struct sdshdr)));
    sh->len = curlen+len;
    sh->free = sh->free-len;
    // 终结符
    // O(1)
    s[curlen+len] = '\0';
    return s;
}

/* 
 * 对 sds 的 buf 进行扩展,扩展的长度不少于 addlen 。
 *
 * T = O(N)
 */
sds sdsMakeRoomFor(
    sds s,
    size_t addlen   // 需要增加的空间长度
) 
{
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);
    size_t len, newlen;
    // 剩余空间可以满足需求,无须扩展
    if (free >= addlen) return s;
    sh = (void*) (s-(sizeof(struct sdshdr)));
    // 目前 buf 长度
    len = sdslen(s);
    // 新 buf 长度
    newlen = (len+addlen);
    // 如果新 buf 长度小于 SDS_MAX_PREALLOC 长度
    // 那么将 buf 的长度设为新 buf 长度的两倍
    // #define SDS_MAX_PREALLOC (1024*1024)
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    // 扩展长度
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    if (newsh == NULL) return NULL;
    newsh->free = newlen - len;
    return newsh->buf;
}

其次是intset。intset只提供了一个空的构造函数;新增和删除修改操作都会进行resize,并且没有复杂的扩容技巧,只是加一或减一。

/*
 * 创建一个空的 intset
 *
 * T = theta(1)
 */
intset *intsetNew(void) {
    intset *is = zmalloc(sizeof(intset));
    is->encoding = intrev32ifbe(INTSET_ENC_INT16);
    is->length = 0;
    return is;
}

/*
 * 将 value 添加到集合中
 *
 * 如果元素已经存在, *success 被设置为 0 ,
 * 如果元素添加成功, *success 被设置为 1 。
 *
 * T = O(n)
 */
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
    uint8_t valenc = _intsetValueEncoding(value);
    uint32_t pos;
    if (success) *success = 1;
    // 如果有需要,进行升级并插入新值
    if (valenc > intrev32ifbe(is->encoding)) {
        return intsetUpgradeAndAdd(is,value);
    } else {
        // 如果值已经存在,那么直接返回
        // 如果不存在,那么设置 *pos 设置为新元素添加的位置
        if (intsetSearch(is,value,&pos)) {
            if (success) *success = 0;
            return is;
        }
        // 扩张 is ,准备添加新元素
        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;
}

/*
 * 把 value 从 intset 中移除
 *
 * 移除成功将 *success 设置为 1 ,失败则设置为 0 。
 *
 * T = O(n)
 */
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))            // 将位置保存到 pos
    {
        uint32_t len = intrev32ifbe(is->length);
        if (success) *success = 1;
        // 如果 pos 不是 is 的最末尾,那么显式地删除它
        // (如果 pos = (len-1) ,那么紧缩空间时值就会自动被『抹除掉』)
        if (pos < (len-1)) intsetMoveTail(is,pos+1,pos);

        // 紧缩空间,并更新数量计数器
        is = intsetResize(is,len-1);
        is->length = intrev32ifbe(len-1);
    }

    return is;
}

/*
 * 调整 intset 的大小
 *
 * T = O(n)
 */
static intset *intsetResize(intset *is, uint32_t len) {
    uint32_t size = len*intrev32ifbe(is->encoding);
    is = zrealloc(is,sizeof(intset)+size);
    return is;
}

现在,我们再来回顾一下Java里String、StringBuffer、StringBuilder的初始化、拼接流程。

构造函数就不用说了,三个类都提供了丰富的构造方式,值得一提的是String,我们平时使用String a = "a",实际上调用的是 String.valueOf(Object obj),和 Integer a = 1 一样。

拼接流程。String的+拼接,底层实现全是通过StringBuffer来来实现的,先调用StringBuffer(String str),然后调用append函数,最后调用toString函数。StringBuffer、StringBuilder的append都是调用AbstractStringBuilder的append。这个时候数组希望能扩到2倍再加2,如果不够,则等于最小可用值。

char[] value;

int count;

public AbstractStringBuilder append(String str) {
    if (str == null) str = "null";
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}

private void ensureCapacityInternal(int minimumCapacity) {
    if (minimumCapacity - value.length > 0)
        expandCapacity(minimumCapacity);
}

void expandCapacity(int minimumCapacity) {
    int newCapacity = value.length * 2 + 2;
    if (newCapacity - minimumCapacity < 0){
        newCapacity = minimumCapacity;
    }
    if (newCapacity < 0) {
        if (minimumCapacity < 0){
            throw new OutOfMemoryError();
        }
        newCapacity = Integer.MAX_VALUE;
    }
    value = Arrays.copyOf(value, newCapacity);
}

综上所述:intset是效率最低的,每次修改都是O(N),但其是有序的。SDS和StringBuffer比起来不分伯仲,虽然SDS有空间预分配,惰性释放,但这些StringBuffer也是有的。

当然,更重要的是我们要使用StringBuffer,如果嫌麻烦用String去拼接的话,性能就会大打折扣了,特别是在循环里拼接。

发布了25 篇原创文章 · 获赞 12 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/qq_25498677/article/details/86484051