Redis底层细节笔记(个人向)| 青训营笔记

279 阅读11分钟

Redis为什么快

  • Redis完全基于内存

    大部分都是简单的存取操作,大量的时间花费在IO上。Redis绝大部分操作时间复杂度为O(1),所以速度十分快。

  • 高效的网络io模型

    Redis采用多路IO复用模型,在内部采用epoll代理。多路是指多个网络连接,IO复用是指复用同一个线程。

  • 单线程

    不存在上下文切换问题,也不用考虑锁的问题,不存在加锁释放锁的操作

Redis数据库设计

用key进行hash,对哈希值取模

链地址法

hash冲突:链表法,一个entry有next指针,(尾插法/头插法)

为什么用头插法

因为redis用在缓存,放了之后有希望马上被访问到

redis用的头插法会退化到O(n)

怎么解决退化到O(n)

当链表比当前hashtable长之后,redis进行hashmap扩容

rehash渐进式哈希

扩容之后进行重新取模,进行数据迁移

数据的迁移不是一次性完成的,而是可以通过dictRehash()这个函数分步规划的,并且调用方可以及时知道是否需要继续进行渐进式哈希操作。

rehash是以bucket(桶)为基本单位进行渐进式的数据迁移的

一个bucket对应哈希表数组中的一条entry链表

redis是单线程模型,渐进式哈希则将这种代价可控地分摊了

利用了两个哈希表进行的 , 有点类似数据库的迁移 , 读的时候先读旧库 , 读不到读新库 , 写的时候只写新库 ; 其他旧数据一点点的往新库上搬

Redis key数据类型

可以是任意类型

将任意key转换为string对象

Redis value数据类型

Redis String类型

没用直接使用c语言的string

自创了SDS(simple dynamic string)

SDS可以在O(1)的时间复杂度获取字符串长度

避免了字符串中出现\0

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

在后续版本,分了sdshdr5,sdshdr8,sdshdr16等等

sdshdr5:只有一个flag 类型是char(一个byte,8个bit,前3bit是type,后5是长度)和buf

sdshdr8/16:有alloc(对应之前的free),成倍扩容,后续变成alloc

Redis数据库源码设计

/* Redis数据库结构体 */
typedef struct redisDb {
    // 数据库键空间,存放着所有的键值对(键为key,值为相应的类型对象)
    dict *dict;                 
    // 键的过期时间
    dict *expires;              
    // 处于阻塞状态的键和相应的client(主要用于List类型的阻塞操作)
    dict *blocking_keys;       
    // 准备好数据可以解除阻塞状态的键和相应的client
    dict *ready_keys;           
    // 被watch命令监控的key和相应client
    dict *watched_keys;         
    // 数据库ID标识
    int id;
    // 数据库内所有键的平均TTL(生存时间)
    long long avg_ttl;         
} redisDb;

当redis 服务器初始化时,会预先分配 16 个数据库,该数字配置在redis.conf配置文件中

所有数据库保存到结构 redisServer 的一个成员 redisServer.db 数组中,而redisClient中存在一个名叫db的指针指向当前使用的数据库(默认为0号数据库)。

typedef struct dict {
    dictType *type;//一个指向dictType结构的指针。它使得dict的key和value能够存储任何类型的数据。
    void *privdata;
    dictht ht[2];//个字典中存放两个hash表,平常使用的是ht[0]上边的,只有扩容缩容的时候才会使用到另一个
    long rehashidx; //正如注释所说,当值为-1的时候,表示没有进行rehash,否则,该值用来表示Hash表ht[0]执行rehash到了哪个元素,并记录该元素的数组下标值。
    unsigned long iterators; //用来记录当前运行的安全迭代器数,当不为0的时候表示有安全迭代器正在执行,这时候就会暂停rehash操作。
} dict;
typedef struct dictType {
    // hash方法,根据关键字计算哈希值
    unsigned int (*hashFunction)(const void *key);
    
    // 复制key
    void *(*keyDup)(void *privdata, const void *key);
    
    // 复制value
    void *(*valDup)(void *privdata, const void *obj);
    
    // 关键字比较方法,确认是原key相同还是hash冲突
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    
    //  销毁key
    void (*keyDestructor)(void *privdata, void *key);
    
   // 销毁value
    void (*valDestructor)(void *privdata, void *obj);
​
} dictType;
typedef struct dictht {
​
    dictEntry **table;
​
    unsigned long size;
​
    unsigned long sizemask;
​
    unsigned long used;
​
} dictht;
typedef struct dictEntry {
​
    void *key;
​
    union {
​
        void *val;  //value -->redisObject(类型)
​
        uint64_t u64;
​
        int64_t s64;
​
        double d;
​
    } v;
​
    struct dictEntry *next;
​
} dictEntry;
typedef struct redisObject {
​
    // 类型
    unsigned type:4;
​
    // 对齐位
    unsigned notused:2;
​
    // 编码方式 根据数据不同的值,使用不同方式存储
    unsigned encoding:4;
​
    // LRU 时间(相对于 server.lruclock)
    unsigned lru:22;
​
    // 引用计数
    int refcount;
​
    // 指向对象的值
    void *ptr;
​
} robj;

Redis String int编码

因为 void *ptr;占8Byte,而longint也是8Byte,如果用8Byte的地址去存8Byte的longint就太蠢了,所以有做判断,

if len<=20 ,调用一个string2l函数,尝试将这个值转换为整形值,存到*ptr

Redis String embstr编码

embstr-->raw

cpu进行一次内存io,cache line读取64个byte

redisObject本身16byte,4byte格外支出(sdshdr8:len alloc flag \0),还剩44个字节

所以,如过小于44个byte,则称为embstr(嵌入式string)

Redis List

L/RPUSH

L/RPOP(POP之后会删除)

同向类似栈,反向类似队列

BRPOPLPUSH,pop后push到另一个list做备份

Redis List底层实现

采用quicklist和ziplist编码

没有用双端链表(next和pre指针加起来占了16byte)

而是使用了ziplist压缩链表

ziplist是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。它的设计目标就是为了提高存储效率。ziplist可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供push和pop操作。

struct ziplist<T> {
  int32 zlbytes;   //整个压缩列表占用字节数,包含本身
  int32 zltail_offset; //最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点,从而可以在ziplist尾部快速的执行push,pop操作
  int16 zllength; //元素个数,该字段只有16bit所以可以表达的最大值为2^16-1,如果ziplist元素超了该值呢?这里规定,如果zllength小于等于   2^16-2,该字段表示为ziplist中元素的个数,否则想知道ziplist长度需要遍历整个ziplist
 T[] entries; 
 int8 zlend; //ziplist最后一个字节,标志压缩列表的结束,值恒为 0xFF(255)
}

倒着遍历ziplist怎么能找到上个一个节点的开始位置呢?

由于压缩列表中的数据以一种不规则的方式进行紧邻,无法通过后退指针来找到上一个元素,而通过保存上一个节点的长度,用当前的地址减去这个长度,就可以很容易的获取到了上一个节点的位置,通过一个一个节点向前回溯,来达到从表尾往表头遍历的操作

struct entry {
    int<var> prevlen; # 前一个 entry 的字节长度
    int<var> encoding; # 元素类型编码
    optional byte[] content; # 元素内容
}

它的 prevlen 字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于 254(0xFE) 时,使用一个字节表示;如果达到或超出 254(0xFE) 那就使用 5 个字节来表示。

Redis Rehash实现

/* Performs N steps of incremental rehashing. Returns 1 if there are still
 * keys to move from the old to the new hash table, otherwise 0 is returned.
 *
 * Note that a rehashing step consists in moving a bucket (that may have more
 * than one key as we use chaining) from the old to the new hash table, however
 * since part of the hash table may be composed of empty spaces, it is not
 * guaranteed that this function will rehash even a single bucket, since it
 * will visit at max N*10 empty buckets in total, otherwise the amount of
 * work it does would be unbound and the function may block for a long time. */
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;
​
    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
​
        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        
        //如果正在遍历的hash槽索引是null
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        
        //如果从非空的哈希槽头节点遍历
        de = d->ht[0].table[d->rehashidx];
        /* Move all the keys in this bucket from the old to the new hash HT */
        //把ht[0]的数据迁移到ht[1]上
        while(de) {
            uint64_t 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;
            //老的used--,新的++
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        //搬完之后置零
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }
​
    /* Check if we already rehashed the whole table... */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }
​
    /* More to rehash... */
    return 1;
}
  • 判断dict是否正在rehashing,只有是,才能继续往下进行,否则已经结束哈希过程,直接返回。
  • 接着是分n步进行的渐进式哈希主体部分(n由函数参数传入),在while的条件里面加入对.used旧表中剩余元素数目的观察,增加安全性。
  • 一个runtime的断言保证一下渐进式哈希的索引没有越界。
  • 接下来一个小while是为了跳过空桶,同时更新剩余可以访问的空桶数,empty_visits这个变量的作用之前已经说过了。
  • 现在我们来到了当前的bucket,在下一个while(de)中把其中的所有元素都迁移到ht[1]中,索引值是辅助了哈希表的大小掩码计算出来的,可以保证不会越界。同时更新了两张表的当前元素数目。
  • 每一步rehash结束,都要增加索引值,并且把旧表中已经迁移完毕的bucket置为空指针。
  • 最后判断一下旧表是否全部迁移完毕,若是,则回收空间,重置旧表,重置渐进式哈希的索引,否则用返回值告诉调用方,dict内仍然有数据未迁移。

渐进式哈希的精髓在于:数据的迁移不是一次性完成的,而是可以通过dictRehash()这个函数分步规划的,并且调用方可以及时知道是否需要继续进行渐进式哈希操作。

Redis Set实现

顺序排列,去除重复

intset hashtable

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。

集合对象的编码可以是 intset 或者 hashtable(过长,或者不是int)。

Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

typedef struct intset {
    
    // 编码方式
    uint32_t encoding;
​
    // 集合包含的元素数量
    uint32_t length;
​
    // 保存元素的数组
    int8_t contents[];
​
} intset;
  • 检查set是否存在不存在则创建一个set结合。
  • 根据传入的set集合一个个进行添加,添加的时候需要进行内存压缩
void saddCommand(redisClient *c) {
    robj *set;
    int j, added = 0;
​
    // 取出集合对象
    set = lookupKeyWrite(c->db,c->argv[1]);
​
    // 对象不存在,创建一个新的,并将它关联到数据库
    if (set == NULL) {
        set = setTypeCreate(c->argv[2]);
        dbAdd(c->db,c->argv[1],set);
​
    // 对象存在,检查类型
    } else {
        if (set->type != REDIS_SET) {
            addReply(c,shared.wrongtypeerr);
            return;
        }
    }
​
    // 将所有输入元素添加到集合中
    for (j = 2; j < c->argc; j++) {
        c->argv[j] = tryObjectEncoding(c->argv[j]);
        // 只有元素未存在于集合时,才算一次成功添加
        if (setTypeAdd(set,c->argv[j])) added++;
    }
​
    // 如果有至少一个元素被成功添加,那么执行以下程序
    if (added) {
        // 发送键修改信号
        signalModifiedKey(c->db,c->argv[1]);
        // 发送事件通知
        notifyKeyspaceEvent(REDIS_NOTIFY_SET,"sadd",c->argv[1],c->db->id);
    }
​
    // 将数据库设为脏
    server.dirty += added;
​
    // 返回添加元素的数量
    addReplyLongLong(c,added);
}

Redis Hash实现

数据量或者元素大小比较小,用ziplist,大就用hashtable

元素个数超过512或者单个元素大小超过64个byte

当为ziplist时,每个entry分别存k和v

Redis Zset实现

有score,数据量小用ziplist,大用skiplist

元素个数超过128或者单个元素大小超过64byte

跳表skiplist原理

skiplisttypedf struct zskiplist{
    //头节点
    struct zskiplistNode *header;
    //尾节点
    struct zskiplistNode *tail;
    // 跳表中的元素个数
    unsigned long length;
    //表内节点的最大层数
    int level;
}zskiplist;

有点像B+树(空间换时间)