Redis五种常用的数据类型及实现

44 阅读11分钟

1. redis简介

Redis是一种key-value的数据库,它支持丰富的value类型,包括string、list、set、zset和hash。为了保证效率,数据都是缓存在内存中,Redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。

2. Redis的两层数据结构简介

  1. 第一个层面,是从使用者的角度,这一层面也是Redis暴露给外部的调用接口,比如:string、list、hash、set、sorted set。
  2. 第二个层面,是从内部实现的角度,属于底层的实现,比如:dict、sds、ziplist、quicklist、skiplist、intset。

redis每一个键值对都会有一个dictEntry,dictEntry的key 是字符串,但是 Redis 没有直接使用 C 的字符数组,而是存储在redis自定义的 SDS中。value 既不是直接作为字符串存储,也不是直接存储在 SDS 中,而是存储在redisObject 中。

实际上五种常用的数据类型的任何一种,都是通过 redisObject 来存储的。 redisObject是两层数据结构的桥梁。

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    void *ptr;
    ...
} robj;
  • type: 对象的数据类型。占4个bit。可能的取值有5种:OBJ_STRING, OBJ_LIST, OBJ_SET, OBJ_ZSET, OBJ_HASH,分别对应Redis对外暴露的5种数据结构
  • encoding: 对象的内部编码方式。同一种数据类型可能有不同的编码方式。(比如String就提供了3种:int embstr raw)
  • ptr: 数据指针。指向真正的数据。比如,一个代表string的robj,它的ptr可能指向一个sds结构;一个代表list的robj,它的ptr可能指向一个quicklist。

redisObject具体是那种数据类型(string, list, hash, set, sorted set)是由type区分的,而每一种数据类型的底层实现所对应的是哪些第二层面的数据结构(dict, sds, ziplist, quicklist, skiplist等),则是通过不同的encoding来区分。所以说,redisObject是联结两个层面的数据结构的桥梁。

3.Redis五种常用的数据类型的底层实现

1. string数据结构介绍

string是最简单的数据类型,一般用于简单的键值对缓存、分布式锁和计数器等等。

底层实现方式:动态字符串sds或者 long。

3大编码格式

  1. int

保存long 型(长整型)的64位(8个字节)有符号整数。范围:-2^63 ~ 2^63-1。

只有整数才会使用 int,如果是浮点数, Redis 内部其实先将浮点数转化为字符串值,然后再保存。

  1. embstr

表示嵌入式的String。从内存结构上来讲 即字符串 sds结构体与其对应的 redisObject 对象分配在同一块连续的内存空间,就像字符串sds嵌入在redisObject对象之中一样。

代表 embstr 格式的 SDS(Simple Dynamic String 简单动态字符串),保存长度小于44字节的字符串。

  1. raw

保存长度大于44字节的字符串。

确切地说,String在Redis中是用一个robj来表示的。而用来表示String的robj可能编码成3种内部表示:OBJ_ENCODING_RAW,OBJ_ENCODING_EMBSTR,OBJ_ENCODING_INT。其中前两种编码使用的是sds来存储,最后一种OBJ_ENCODING_INT编码直接把string存成了long型。

SDS介绍

下图就是 Redis 5.0 的 SDS 的数据结构:

  1. len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  2. alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
  3. buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

Redis为什么重新设计一个 SDS 数据结构 不使用C的?

C语言字符串介绍

C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符,字符数组的结尾位置就用“\0”表示,意思是指字符串的结束。

缺陷:

  1. 获取字符串长度的时间复杂度为 O(N);
  2. 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
  3. 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

所以对于string数据类型,redis没有直接使用c语言的字符串,而是使用sds。

2.Hash数据结构介绍

底层实现方式:压缩列表ziplist 或者 字典dict

当数据量较少的情况下,hash底层会使用压缩列表ziplist进行存储数据,也就是同时满足下面两个条件的时候:

hash-max-ziplist-entries 512:当hash中的数据项(即键值对)的数目小于512时

hash-max-ziplist-value 64:当hash中插入的任意一个value的长度小于64字节

ziplist 编码格式

ziplist是一个经过特殊编码的双向链表,可以用于存储字符串或整数。

一个普通的双向链表,链表中每一项都占用独立的一块内存,并通过地址指针连接起来,但这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。而ziplist使用了一整块连续的内存,类似于一个数组。

压缩列表在表头有三个字段:

1、zlbytes,记录整个压缩列表占用的内存字节数。

2、zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量。

3、zllen,记录压缩列表包含的节点数量。

4、entry 列表节点,长度不定,由内容决定。

5、zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。

压缩列表节点(zlentry)包含三部分内容:

a、prevlen,记录了「前一个节点」的长度;

b、encoding,记录了当前节点实际数据的类型以及长度;

c、data,记录了当前节点的实际数据;

prevlen

根据前一个节点的长度不同,prevlen属性设置不同的空间大小,其属性长度可以是1字节、5字节。逻辑如下:

1、当前一个节点的长度小于254字节,那么就是一个字节表示

2、如果前一个字节的长度大于等于254字节,那么prevlen的属性的长度就为5字节,其中第一字节会被设置为0xFE(十进制254),后面的四个字节就用于保存前一节点的长度

连锁更新

压缩列表除了查找复杂度高的问题,还有一个问题。

压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题。

现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:

因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。

这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图:

因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。

这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」

hashtable 编码格式

在 Redis 中,hashtable 被称为字典(dictionary),它是一个数组+链表的结构。

Redis 的哈希表结构如下:

可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。

3. list数据结构介绍

(1)Redis3.2之前的底层实现方式:压缩列表ziplist 或者 双向循环链表linkedlist。当list存储的数据量较少时,会使用ziplist存储数据,也就是同时满足下面两个条件:

列表中数据个数少于512个

list中保存的每个元素的长度小于 64 字节

当不能同时满足上面两个条件的时候,list就通过双向循环链表linkedlist来实现了

(2)Redis3.2及之后的底层实现方式:quicklist

quicklist是一个双向链表,而且是一个基于ziplist的双向链表,quicklist的每个节点都是一个ziplist,结合了双向链表和ziplist的优点。

4.set数据结构介绍

Redis 用 intset 或 hashtable 存储set。如果元素都是整数类型且存储的数据元素个数小于512个

,就用intset存储。

如果不是整数类型,就用hashtable(数组+链表的存来储结构)。key就是元素的值,value为null。

intset本质上是一块连续内存空间,它的结构定义如下:

可以看到,保存元素的容器是一个 contents 数组。

整数集合的升级操作

整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里.

5.ZSet数据结构介绍

zset 相比 set 多了一个权重参数score,集合中的元素能够按score进行排列。可以做排行榜应用,取TOP N操作。另外,sorted set可以用来做延时任务。

当zset中包含的元素数量超过128(默认值),或者新添加元素的长度大于64字节(默认值),redis会使用跳跃表作为有序集合的底层实现。否则会使用ziplist作为有序集合的底层实现。

跳表是一种可以进行二分查找的有序链表,采用空间换时间的设计思路,跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。

typedef struct zskiplist {
    //头节点和尾节点
    struct zskiplistNode *header, *tail;
    //表中节点的数量
    unsigned long length;
    //表中层数最大的节点层数
    int level;
} zskiplist;
typedef struct zskiplistNode {
    //数据
    sds ele;
    //分值
    double score;
    //后退指针
    struct zskiplistNode *backward;
    //层
    struct zskiplistLevel {
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned long span;
    } level[];
} zskiplistNode;

l****listpack 结构设计

因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。

于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表

listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。

我们先看看 listpack 结构:

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。

每个 listpack 节点结构如下:

主要包含三个方面内容:

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding+data的总长度;

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题