Redis大通关系列-数据结构深入

官网地址:https://redis.io

一、Redis是做什么用的?


Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache, and message broker.

1、Redis概述

Redis是一个开源的,基于内存的数据结构存储,可用作于数据库、缓存、消息中间件

(另一种解释:Redis是一个开源的、使用C语言编写的、支持网络交互的、可基于内存也可持久化的Key-Value数据库)

从上述定义我们可以看出,Redis是基于内存进行设计的一种存储数据的载体。首先我们可以看到基于内存,那么相对于磁盘来说,他的特点肯定就是快。再者就是存储,那么我们就会联想到database数据库,可以进行数据的存储。

2、Redis特性

Redis provides data structures such as stringshasheslistssetssorted sets with range queries, bitmapshyperloglogsgeospatial indexes, and streams. Redis has built-in replicationLua scriptingLRU evictiontransactions, and different levels of on-disk persistence, and provides high availability via Redis Sentinel and automatic partitioning with Redis Cluster.

Redis支持多种数据结构,包括字符串、哈希表、链表、集合、带范围查询的有序集合、位图、超级日志、地理空间索引和流等。

Redis具备LRU淘汰、事务实现、以及不同级别的硬盘持久化等能力,并且支持副本集和通过Redis Sentinel实现的高可用方案,同时还支持通过Redis Cluster实现的数据自动分片能力。

3、Redis线程模型

You can run atomic operations on these types, like appending to a stringincrementing the value in a hashpushing an element to a listcomputing set intersectionunion and difference; or getting the member with highest ranking in a sorted set.

 Redis的主要功能都基于单线程模型实现,也就是说Redis使用一个线程来服务所有的客户端请求,同时Redis采用了非阻塞式IO,并精细地优化各种命令的算法时间复杂度,这些信息意味着:

  • Redis是线程安全的(因为只有一个线程),其所有操作都是原子的,不会因并发产生数据异常(Redis6.0之后的多线程主要体现在网络/IO层面,Redis处理命令还是单线程

  • Redis的速度非常快(因为使用非阻塞式IO,且大部分命令的算法时间复杂度都是O(1))

  • 使用高耗时的Redis命令是很危险的,会占用唯一的一个线程的大量处理时间,导致所有的请求都被拖慢。(例如时间复杂度为O(N)的KEYS命令,严格禁止在生产环境中使用)

todo

线程模型深入 

二、Redis的基本数据结构及常用命令

本节中将介绍Redis支持的主要数据结构(String、List、Hash、Set、Sorted Set、Bitmap、HyperLogLog),以及相关的常用Redis命令

首先还是得声明一下,Redis的存储是以key-value的形式的。Redis中的key一定是字符串,value可以是string、list、hash、set、sortset这几种常用的。

Redis并没有直接使用这些数据结构来实现key-value数据库,而是基于这些数据结构创建了一个对象系统

/**
  * Redis使用对象来表示数据库中的键和值。每次我们在Redis数据库中新创建一个键值对时,至少会创建出两个对象。一个是键对象,一个是值对象。
  */

Redis中的每个对象都由一个redisObject结构来表示:
typedef struct redisObject{
    // 对象的类型
    unsigned type 4:;

    // 对象的编码格式
    unsigned encoding:4;

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

}robj;

简单来说就是Redis对key-value封装成对象,key是一个对象,value也是一个对象。每个对象都有type(类型)、encoding(编码)、ptr(指向底层数据结构的指针)来表示。

本节只对Redis命令进行扼要的介绍,且只列出了较常用的命令。如果想要了解完整的Redis命令集,或了解某个命令的详细使用方法,请参考官方文档:https://redis.io/commands 

1、Redis Key 

Redis采用Key-Value型的基本数据结构,任何二进制序列都可以作为Redis的Key使用(例如普通的字符串或一张JPEG图片)

Redis Key的底层源码实现:

Redis Key设计原则:

  • 不要使用过长的Key。例如使用一个1024字节的key就不是一个好主意,不仅会消耗更多的内存,还会导致查找的效率降低

  • Key短到缺失了可读性也是不好的,例如"u1000flw"比起"user:1000:followers"来说,节省了寥寥的存储空间,却引发了可读性和可维护性上的麻烦

  • 最好使用统一的规范来设计Key,比如"object-type:id:attr",以这一规范设计出的Key可能是"user:1000"或"comment:1234:reply-to"

  • Redis允许的最大Key长度512MB(对Value的长度限制也是512MB)

2、String(SDS)

SDS:简单动态字符串(Simple dynamic string),柔性数组成员(flexible array member

Redis中的字符串跟C语言中的字符串,是有点差距的

Redis中的字符串分两类:二进制安全的和非二进制安全的,对于存储的值是二进制安全的,对于键是非二进制安全的。

Redis使用sdshdr结构来表示一个SDS值:

// sds 类型
typedef char *sds;

// sdshdr 结构
struct sdshdr{

    // 字节数组,用于保存字符串
    char buf[];

    // 记录buf数组中已使用的字节数量,也是字符串的长度
    int len;

    // 记录buf数组未使用的字节数量
    int free;
}

因为自定义类型加入了长度,每次获取字符串长度的时间复杂度就是O(1),而利用len和free属性对追加字符串进行优化,也可以降低重新分配内存的次数。但是这里也有要求就是len和free的更新要小心,不然很容易产生bug。

 例子:

2.1 使用SDS的好处

  • sdshdr数据结构中用len属性记录了字符串的长度。那么获取字符串的长度时,时间复杂度只需要O(1)
  • SDS不会发生溢出的问题,如果修改SDS时,空间不足。先会扩展空间,再进行修改!(内部实现了动态扩展机制)。
  • SDS可以减少内存分配的次数(空间预分配机制)。在扩展空间时,除了分配修改时所必要的空间,还会分配额外的空闲空间(free 属性)。
  • SDS是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据。

为什么sds是二进制安全的呢?

 

我们都知道redis底层是使用C语言写的,C语言是没有字符串类型的,因而,它把字符串存储到char数组之中。但是,C语言读取字符串时,一旦遇到 \0,便终止读取字符串,如下代码:

#include <stdio.h>

int main () {
    printf("hello word \0 hello word \0 hello word ");
    return 0;
}

输出结果是:

如果存储图片的二进制,肯定会有结束符 \0,此时,图片就不完整,因而,会出现编码的问题。
此外,C语言每次读取字符串,都要重新分配内存空间,这就在分配和回收上浪费了性能。

因而,这就让Redis开发者,不得不重新定义新的数据类型,动态扩展字符串,因而出现了sds的数据类型,全程 simple dynamic string,简单动态字符串。

2.2 SDS扩容原理 

那么sdsMakeRoomFor是怎么实现扩容的呢,具体扩容方案是什么呢?下面就是redis的源码:

/* 
 * 对 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 长度的两倍
    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;
}

可以看到,如果新字符串长度比SDS_MAX_PREALLOC小,则将其长度double,如果大于SDS_MAX_PREALLOC则再给SDS_MAX_PREALLOC空间。这个值redis定义的是1024*1024,即1MB。

这样一来,扩容一次多给一倍请求的空间,可以减少分配内存的次数,当然稍微有点浪费,但append操作一般情况不会太多,如果场景append很多还要优化redis的代码。

小结:

  1. Redis的字符串表示为sds,不是char*;
  2. 对比原生char*,sds有以下优势:
    1. 长度计算只需O(1)时间复杂度;
    2. 字符串追加更高效;
    3. 二进制安全;
  3. sds对追加操作有优化,加快追加速度,降低内存重新分配次数,代价是浪费一些内存,并且不会主动释放。 

3、List (链表)

Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素导列表的头部(左边)或者尾部(右边)

一个列表最多可以包含 232 - 1 个元素 (4294967295, 每个列表超过40亿个元素)。

Redis中链表的具体跟Java中的LinkedList链表有相似之处。Redis链表的底层实现原理:

使用listNode结构来表示每个节点:

typedef strcut listNode{

    //前置节点
    strcut listNode *pre;

    //后置节点
    strcut listNode *pre;

    //节点的值
    void *value;

}listNode

使用listNode是可以组成链表了,Redis中使用list结构来持有链表:

typedef struct list{

    //表头结点
    listNode *head;

    //表尾节点
    listNode *tail;

    //链表长度
    unsigned long len;

    //节点值复制函数
    void *(*dup) (viod *ptr);

    //节点值释放函数
    void (*free) (viod *ptr);

    //节点值对比函数
    int (*match) (void *ptr,void *key);

}list

3.1 Redis链表实现

  • 每个链表节点由一个 listNode 结构来表示, 每个节点都有一个指向前置节点和后置节点的指针, 所以 Redis 的链表实现是双端链表
  • 每个链表使用一个 list 结构来表示, 这个结构带有表头节点指针、表尾节点指针、以及链表长度等信息。
  • 因为链表表头节点的前置节点和表尾节点的后置节点都指向 NULL , 所以 Redis 的链表实现是无环链表
  • 通过为链表设置不同的类型特定函数, Redis 的链表可以用于保存各种不同类型的值。

3.2 Redis链表中的三个函数

  • dup函数用于复制链表节点所保存的值

  • free函数用于释放链表节点所保存的值

  • match 函数则用于对比链表节点所保存的值和另一个输入值是否相等

3.3 Redis链表的特性

  • 无环双向链表
  • 获取表头指针,表尾指针,链表节点长度的时间复杂度均为O(1)
  • 链表使用void *指针来保存节点值,可以保存各种不同类型的值,体现了多态思想

3.4 Redis链表的应用场景

Redis链表被广泛用于列表键、发布与订阅、慢查询、监视器等。

4、Hash表

Redis hash 是一个string类型的field和value的映射表,hash特别适合用于存储对象。

Redis 中每个 hash 可以存储 232 - 1 键值对(40多亿)。

在Redis中,key-value的数据结构底层就是哈希表来实现的。对于哈希表来说,我们也并不陌生。在Java中,哈希表实际上就是数组+链表的形式来构建的。下面我们来看看Redis的哈希表是怎么构建的吧。

4.1 hash的底层实现

在Redis里边,哈希表使用dictht结构来定义:

typedef struct dictht{

    //哈希表数组
    dictEntry **table;

    //哈希表大小
    unsigned long size;

    //哈希表大小掩码,用于计算索引值
    //总是等于size-1
    unsigned long sizemark;

    //哈希表已有节点数量
    unsigned long used;

}dictht

我们下面继续写看看哈希表的节点是怎么实现的吧:

typedef struct dictEntry {

    //键
    void *key;

    //值
    union {
        void *value;
        uint64_tu64;
        int64_ts64;
    }v;

    //指向下个哈希节点,组成链表
    struct dictEntry *next;

}dictEntry;

从结构上看,我们可以发现:Redis实现的哈希表和Java中实现的是类似的。只不过Redis多了几个属性来记录常用的值:sizemark(掩码)、used(已有的节点数量)、size(大小)。

同样地,Redis为了更好的操作,对哈希表往上再封装了一层(参考上面的Redis实现链表),使用dict结构来表示:

typedef struct dict {

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

    //私有数据
    void *privdata;
  
    //哈希表
    dictht ht[2];

    //rehash索引
    //当rehash不进行时,值为-1
    int rehashidx;  

}dict;


//-----------------------------------

typedef struct dictType{

    //计算哈希值的函数
    unsigned int (*hashFunction)(const void * key);

    //复制键的函数
    void *(*keyDup)(void *private, const void *key);
 
    //复制值得函数
    void *(*valDup)(void *private, const void *obj);  

    //对比键的函数
    int (*keyCompare)(void *privdata , const void *key1, const void *key2)

    //销毁键的函数
    void (*keyDestructor)(void *private, void *key);
 
    //销毁值的函数
    void (*valDestructor)(void *private, void *obj);  

}dictType

从代码实现和示例图上我们可以发现,Redis中有两个哈希表

  • ht[0]:用于存放真实key-vlaue数据
  • ht[1]:用于扩容(rehash)

Redis中哈希算法和哈希冲突跟Java实现的差不多,它俩差异就是:

  • Redis哈希冲突时:是将新节点添加在链表的表头
  • JDK1.8后,Java在哈希冲突时:是将新的节点添加到链表的表尾

4.2  rehash的过程

下面来具体讲讲Redis是怎么rehash的,因为我们从上面可以明显地看到,Redis是专门使用一个哈希表来做rehash的。这跟Java中的HashMap一次性直接rehash是有区别的,跟ConcurrentHashMap的rehash过程是一致的。

在对哈希表进行扩展或者收缩操作时,reash过程并不是一次性地完成的,而是 渐进式地完成的。

Redis在rehash时采取渐进式的原因:数据量如果过大的话,一次性rehash会有庞大的计算量,这很可能导致服务器一段时间内停止服务

Redis具体的rehash过程:

  • 在字典中维持一个索引计数器变量rehashidx,并设置为0,表示rehash开始。
  • 在rehash期间每次对字典进行增加、查询、删除和更新操作时,除了执行指定命令外;还会将ht[0]中rehashidx索引上的值rehash到ht[1],操作完成后rehashidx+1。
  • 字典操作不断执行,最终在某个时间点,所有的键值对完成rehash,这时将rehashidx设置为-1,表示rehash完成。
  • 在渐进式rehash过程中,字典会同时使用两个哈希表ht[0]和ht[1],所有的更新、删除、查找操作也会在两个哈希表进行。例如要查找一个键的话,服务器会优先查找ht[0],如果不存在,再查找ht[1],诸如此类。此外当执行新增操作时,新的键值对一律保存到ht[1],不再对ht[0]进行任何操作,以保证ht[0]的键值对数量只减不增,直至变为空表。(可以理解为多个工人一起搬运的过程)

5、Set

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

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

集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。

Redis的集合对象set的底层存储结构使用了intsethashtable两种数据结构存储的,intset我们可以理解为数组,hashtable就是普通的哈希表(key为set的值,value为null)。

Set的底层存储intset和hashtable是存在编码转换的,使用intset存储必须满足下面两个条件,否则使用hashtable,条件如下:

  • 结合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量不超过512个

5.1 intset的数据结构 

intset内部其实是一个数组(int8_t coentents[]数组),而且存储数据的时候是有序的,因为在查找数据的时候是通过二分查找来实现的。

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

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

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

} intset;

 intset存储结构

5.2 HashTable的数据结构

5.3 Redis Set 存储过程

以set的sadd命令为例子,整个添加过程如下:

  • 检查set是否存在不存在则创建一个set结合。
  • 根据传入的set集合一个个进行添加,添加的时候需要进行内存压缩。
  • setTypeAdd执行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);
}

稍微深入分析一下set的单个元素的添加过程,首先如果已经是hashtable的编码,那么我们就走正常的hashtable的元素添加,如果原来是intset的情况,那么我们就需要进行如下判断:

  • 如果能够转成int的对象(isObjectRepresentableAsLongLong),那么就用intset保存。
  • 如果用intset保存的时候,如果长度超过512(REDIS_SET_MAX_INTSET_ENTRIES)就转为hashtable编码。
  • 其他情况统一用hashtable进行存储。
/*
 * 多态 add 操作
 *
 * 添加成功返回 1 ,如果元素已经存在,返回 0 。
 */
int setTypeAdd(robj *subject, robj *value) {
    long long llval;

    // 字典
    if (subject->encoding == REDIS_ENCODING_HT) {
        // 将 value 作为键, NULL 作为值,将元素添加到字典中
        if (dictAdd(subject->ptr,value,NULL) == DICT_OK) {
            incrRefCount(value);
            return 1;
        }

    // intset
    } else if (subject->encoding == REDIS_ENCODING_INTSET) {
        
        // 如果对象的值可以编码为整数的话,那么将对象的值添加到 intset 中
        if (isObjectRepresentableAsLongLong(value,&llval) == REDIS_OK) {
            uint8_t success = 0;
            subject->ptr = intsetAdd(subject->ptr,llval,&success);
            if (success) {
                // 添加成功
                // 检查集合在添加新元素之后是否需要转换为字典
                // #define REDIS_SET_MAX_INTSET_ENTRIES 512
                if (intsetLen(subject->ptr) > server.set_max_intset_entries)
                    setTypeConvert(subject,REDIS_ENCODING_HT);
                return 1;
            }

        // 如果对象的值不能编码为整数,那么将集合从 intset 编码转换为 HT 编码
        // 然后再执行添加操作
        } else {
            setTypeConvert(subject,REDIS_ENCODING_HT);

            redisAssertWithInfo(NULL,value,dictAdd(subject->ptr,value,NULL) == DICT_OK);
            incrRefCount(value);
            return 1;
        }

    // 未知编码
    } else {
        redisPanic("Unknown set encoding");
    }

    // 添加失败,元素已经存在
    return 0;
}

 6、Sorted Set

Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。

有序集合的成员是唯一的,但分数(score)却可以重复。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。 集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。

以下表格是五种数据类型对应的11种编码格式:

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值