Redis之常用的数据类型以及数据结构

420 阅读13分钟

常见的Redis数据类型包括五种:String,List,Set,ZSet和Hash,以上五种类型主要是指key-value中value的类型。

String:

String类型可以算是这五种类型中最基础的类型了,Redis中的String类型是二进制安全的类型,也就是你可以将一个序列化的对象或者图片之类的媒体文件存储到Redis中,但是要注意String类型支持的大小上限是512M。在实际应用中String类型常被用做计数器,分布式锁,缓存等。

底层数据结构:

Redis是使用C语言编写的nosql数据库,但是Redis的String类型并不是C语言原生的字符串类型,而是Redis使用C语言自己构建的一种简单动态字符串(Simple Dynamic String)。来看一下它的实现:

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

可以看到Redis底层存储字符串的其实是一个字符数组,出了一个字符数组之外还有两个属性,一个是len用来记录数组中已经使用的长度,free则用来记录数组中未使用的即剩下的长度。不过这个是Redis3.2之前的版本,在3.2版本之后对简单动态字符串做了细分,会根据存储字符串的长度,构建特定的简单动态字符串来存储,具体细分如下:

struct __attribute__ ((__packed__)) sdshdr5 { 
    unsigned char flags;
    /* 3 lsb of type, and 5 msb of string length */ 
    char buf[]; 
}; 
struct __attribute__ ((__packed__)) sdshdr8 { 
    uint8_t len;
    /* used */ 
    uint8_t alloc; 
    /* excluding the header and null terminator */ 
    unsigned char flags;
    /* 3 lsb of type, 5 unused bits */ 
    char buf[]; 
}; 
struct __attribute__ ((__packed__)) sdshdr16 { 
    uint16_t len; 
    /* used */ 
    uint16_t alloc; 
    /* excluding the header and null terminator */ 
    unsigned char flags; 
    /* 3 lsb of type, 5 unused bits */ 
    char buf[]; 
}; 
struct __attribute__ ((__packed__)) sdshdr32 { 
    uint32_t len; 
    /* used */ 
    uint32_t alloc; 
    /* excluding the header and null terminator */ 
    unsigned char flags; 
    /* 3 lsb of type, 5 unused bits */ 
    char buf[]; 
}; 
struct __attribute__ ((__packed__)) sdshdr64 { 
    uint64_t len; 
    /* used */ 
    uint64_t alloc; 
    /* excluding the header and null terminator */ 
    unsigned char flags; 
    /* 3 lsb of type, 5 unused bits */ 
    char buf[]; 
};
  • len:当前字节数组已使用的长度,不包括空字符
  • alloc:当前字节数组分配的长度
  • buf:存储字符串
  • flags:记录当前sds的类型,是sdshdr8还是16或者是其他 可以看到具体做了五种划分,3.2版本之后根据存储的字符串长度不同会使用不同的类型:
static inline char sdsReqType(size_t string_size) { 
    if (string_size < 1<<5) // 32 
        return SDS_TYPE_5; 
    if (string_size < 1<<8) // 256 
        return SDS_TYPE_8; 
    if (string_size < 1<<16) // 65536 64k 
        return SDS_TYPE_16; 
    if (string_size < 1ll<<32) // 4294967296 4G 
        return SDS_TYPE_32; 
    return SDS_TYPE_64; 
 }

相较于C语言的原生字符串的一些优点:

  • 获取字符串的长度可以直接通过len属性获取,时间复杂度为O(1),而原生的C语言字符串没有特定的属性记录长度,获取长度需要遍历数组,所以时间复杂度为O(n)。

  • 不会出现内存溢出,C语言中当对字符串进行修改时,必须要对内存进行重分配,如果使用append修改后的字符串长度大于修改前的字符串,但是有没有重新分配内存,则会造成内存溢出,而简单动态字符串通过free属性,在进行修改前会判断内存是否足够,避免内存溢出。

  • 预分配和懒回收,当我们给字符串分配空间时可以多分配一些预定的空间,那么当字符串发生修改增长之后,如果长度没有达到上限就不需要重新分配新的空间,懒回收则是字符串的长度减少时,不立刻回收多余的空间,那么当下次字符串增长时可以直接使用剩余的空间,也可以减少内存重分配的次数,所以相对于C语言原生的字符串,修改n次内存重分配的次数从重分配n次到最多重分配n次。

  • 二进制安全,相较于C语言原生的字符串通过空字符来判断字符串是否结束,简单动态字符串不通过空字符来判断字符串是否结束,而是通过len来判断,这对于某些二进制中包含空字符的媒体文件来说是安全的。

  • 虽然是通过len来判断字符串是否结束,但是他依然遵守了C语言中以空格符结束的规范,所以可以使用C语言中的一部分字符串函数。

List:

类似Java中的List,Redis中的List也是可以存储多个有序可重复的值的,可以理解为一个key对应一个List,而这一个List里面有多个值。List由于其有序的特性可以被用来作为消息队列,或者进行分页查询,以及用来存储一篇博客下的评论等。

底层数据结构

List底层结构是有多种,其中一种是用C语言实现的链表

  1. 链表 先看其节点的实现:
typedef struct listNode { 
    // 前置节点 
    struct listNode *prev; 
    // 后置节点 
    struct listNode *next; 
    // 节点值 
    void *value; 
} listNode;

可以看到他和持有三个属性:

  • prev:指向上一个节点
  • next:指向下一个节点
  • value:节点存储的数据

再看其链表的实现:

typedef struct list { 
    // 链表头节点 
    listNode *head; 
    // 链表尾节点 
    listNode *tail; 
    // 节点值复制函数 
    void *(*dup)(void *ptr); 
    // 节点值释放函数 
    void (*free)(void *ptr); 
    // 节点值对比函数 
    int (*match)(void *ptr, void *key); 
    // 链表所包含的节点数量 
    unsigned long len; 
} list;
  • head:指向头结点
  • tail:指向尾结点
  • len:链表包含的节点数量
  • 剩下的就是相关的三个函数

特点:

  • 可以直接通过len获取链表的长度
  • 双向链表,无环。头节点的prev为null,尾结点的next为null

当列表中存储的元素比较少时,可以用ziplist结构

  1. 压缩列表(ziplist) 由于普通的链表每个节点需要另外存储前后指针,会造成额外的内存消耗,并且由于节点在内存中不是连续的,会造成内存零散的问题,而压缩列表则可以解决这些问题,压缩列表是在一块连续的内存中,一个列表中可以包含多个节点,而节点之间由于相邻的关系,也不需要通过指针来定位,有点类似于数组,下面是ziplist的结构: image.png

ziplist:

属性名所占字节数作用
zlbytes4ziplist所分配的总字节数
zltail4尾节点相对于ziplist首地址的偏移量,如果首地址已知,则可以通过zltail计算出尾节点的位置
zllen2ziplist的的节点数量
entry取决于存储的数据节点
zlend1用来标记ziplist的末端,是一个特殊值0XFF,十进制是255

假设zl指针指向ziplist的首地址的话,可以通过上面的信息推算出其他的地址,如:

  • zl指向zlbytes字段
  • zl + 4指向zltail字段
  • zl + 8指向zllen字段
  • zl + ztail指向尾节点的首地址

ziplistNode:

属性名所占字节作用
prevlen1/5记录上一个节点所占字节数,如果长度小于或等于254字节,则占用1字节,否则占用5字节
encoding待定当前节点存储的元素编码
entrydata待定存储当前节点的数据
  • 当存储的元素是一个字符串时,根据encoding的前两位来确定长度编码的类型,剩下的n位存储字符串的长度,当存储的是一个整数值时,则encoding的前两位都为1,后续两位则用来确定具体的整数类型.

Set:

也是类似Java中的Set,可以用来存储多个的无序的不重复的值,集合中的元素是没有顺序的,关于Set的常见使用,比如qq好友的标签,以及一些抽奖之类的。

底层数据结构:
  1. 整数集合(intset) 整数集合是Redis用来保存整数的一种数据结构,可以保存多种数据类型包括int16_t,int32_t,int64_t,同时可以保证多个元素不重复。整数集合是Set底层实现之一,如果一个集合只包含整数值元素且元素数量不多则可以使用整数集合。看一下

代码实现

typedef struct intset { 
    // 编码方式 
    uint32_t encoding; 
    // 集合包含的元素数量 
    uint32_t length; 
    // 保存元素的数组 
    int8_t contents[]; 
} intset;
  • encoding:存储的元素类型
  • length:存储的元素数量,里面的元素不可重复,值从小到大排列
  • contents:存储元素的数组,注意这里虽然是int8_t类型的,但是实际存储的类型是encoding属性指定的

集合的升级

当集合中存储一个新的数值类型,并且这个类型比原来存储的类型占据更大的空间时,那么就会进行升级。

  • 根据新来的类型化扩展数组的大小,为新来的数据分配空间。
  • 将数组中原有的元素转化成新的类型有序存入数组中。
  • 将新的元素添加进数组。

整数集合的升级策略可以节省内存占用,就是一开始不分配最大的空间,等到更大的类型数值需要被存储时,才会进行升级,整数集合升级后无法再降级

ZSet:

Zset可以看成Set的有序版本,每次往这个ZSet中存储一个元素时,都需要一个相应的score,Zset会根据你存储Score大小来排序。Zset常被用来以点击次数作为score来实现热点排行榜,或者以某个字段作为score来分页缓存等。

底层数据结构

Zset底层数据结构是一张跳跃表,跳跃表可以理解成优化之后的链表,跳跃表的每个节点都会持有一个level数组,多个节点同一索引的数组元素可以构成一张链表,数组有多个元素,所以跳跃表其实也是由多条链表组成的.跳跃表节点的实现:

typedef struct zskiplistNode { 
    // 成员对象 (robj *obj;) 
    sds ele; 
    // 分值 
    double score; 
    // 后退指针 
    struct zskiplistNode *backward; 
    // 层 
    struct zskiplistLevel { 
        // 前进指针 
        struct zskiplistNode *forward;
        // 跨度 
        // 跨度实际上是用来计算元素排名(rank)的,在查找某个节点的过程中,将沿途访过的所有层的跨度累积起来,得到的结果就是目标节点在跳跃表中的排位 
        unsigned long span; 
    } level[]; 
} zskiplistNode;
  • ele:成员对象,就是我们存储的数据,ZSet里多个成员对象不可重复,当score相等时,通过比较ele来确定大小
  • score:当我们再ZSet中存储一个元素时,会顺表存储一个score,根据score来给元素排序
  • backword:指向前一个结点的指针,如果时头节点则为null
  • level:一个数组,里面的元素包含forward和span两个属性,forward指向当前层的下一个节点,如果没有下一个节点则为null,span代表距离下一个节点的跨度,没有下一个节点则为0

跳跃表的实现:

typedef struct zskiplist { 
    // 表头节点和表尾节点 
    struct zskiplistNode *header, *tail; 
    // 表中节点的数量 
    unsigned long length; 
    // 表中层数最大的节点的层数 
    int level; 
} zskiplist;
  • head:头节点
  • tail:尾节点
  • length:跳跃表中节点的数量
  • level表中除了头节点之外最大的层数 结构图:

image.png 跳跃表在被创建时会初始化一个头节点,这个头节点默认level数组的大小为32,score为0,ele为null.当有新的元素到来时,会封装成对应的节点,节点的level数组大小随机为1-32,然后通过比较score和ele将其放到对应的位置.

image.png 当我们去查找某一个元素时先从最上层链表(除去head节点外最高的那一层)的head节点开始查找,当确通过score和ele确定这个节点在a节点后面b节点前面后,就到a节点对应的下一层去,然后再和后面的节点进行对比,如果确定了某个范围,那么就再找对应的下一层,直到找到具体的节点返回.

Hash:

Hash类似Java中的map对象,其中可以存储多个键值对,hash类型常被用来存储用户的信息,比于一个用户的信息,可以将其id作为key,将姓名,年龄,性别之类的信息以键值对的形式存储到hash里面,还可以用来存储一个帖子的相关信息,点赞数,评论数和分享数之类的

底层数据结构

Hash底层的数据结构有多种

  1. 字典 字典底层的实现其实就是哈希表,和hashmap的实现有点类似吗,如果看过hashmap的源码,理解字典就很简单 首先来看一下哈希表的实现:
typedef struct dictht { 
    // 哈希表数组 
    dictEntry **table; 
    // 哈希表大小 
    unsigned long size; 
    // 哈希表大小掩码,用于计算索引值,等于size-1 
    unsigned long sizemask; 
    // 哈希表已有节点的数量 
    unsigned long used; 
} dictht;

typedef struct dictEntry { 
    // 键 
    void *key; 
    // 值 
    union { 
        void *val; 
        uint64_t u64; 
        int64_t s64; 
        double d; 
    } v; 
    // 指向下一个哈希表节点,形成链表 
    struct dictEntry *next; 
} dictEntry;

可以看到其实和hashmap的实现类似,哈希表持有一个hash表数组,数组中的每一个元素则持有一个key和value,这里的value可以是指针,整型或者浮点型.然后还持有指向下一个节点的指针,当产生哈希冲突时可以将节点链化.

再来看看字典的实现:

typedef struct dict { 
    // 和类型相关的处理函数 
    dictType *type; 
    // 私有数据 
    void *privdata; 
    // 哈希表 
    dictht ht[2]; 
    // rehash 索引,当rehash不再进行时,值为-1 
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */ 
    // 迭代器数量 
    unsigned long iterators; /* number of iterators currently running */ 
} dict;
  • type,privatedata:标识不同类型的键值对,多用来创建不同类型的字典
  • ht:哈希表数组,可以看到这里并不是直接持有一个哈希表,而是持有一个容量为2的哈希表数组,这里主要考虑到哈希表在扩容或者收缩时(rehash)需要创建一个新的哈希表,会同时持有一新一旧两个哈希表,所以这里是数组
  • 同时由于Redi存储的数据量可能非常大,那么在rehash时去搬迁元素到新的hash表不会再很短的时间内完成,往往都时渐进式rehash,就是分多次搬迁.当Redis在每次执行执行增删改查的命令时,还会另外将rehashidx索引位置对应所有元素搬迁到新的哈希表上,然后将rehashidx加一,当值为-1时,说明没有发生rehash,在rehash过程中,新增的元素会直接在新的哈希表上添加,查询则是两张哈希表都查询.

2.ziplist,具体上面已经做过描述这里就不再展示

其实除了以上这五种类型,Redis还有其他的几种比较少见的数据类型,像是bitmap,HyperLogLog,Geo,之类的,这里我们就不详细展开说了。

bitmap:是一种基于位操作的数据类型,以bit为单位

HyperLogLog:基于基数运算的类型

Geo:对经纬度进行存储并提供地理位置的一些运算