Redis 数据结构与对象

Redis 数据结构与对象

  • 底层数据结构
    • 简单动态字符串
    • 压缩列表
    • 链表
    • 字典
    • 跳表
    • 整数集合
    • quicklist
    • redisDb
  • 表层
    • 列表键
    • 哈希键
    • BitMaps
    • Set
    • ZSet(SortedSet)

底层数据结构

使用redis的数据结构做操作的时候,应该时刻注意操作数据结构的方法的时间复杂度

redis 的命令行使用可以参考菜鸟教程,redis 默认接口是6379

底层数据结构是 redis 实现各种功能的方式,它们大多被封装成各个功能

简单动态字符串

key 与 value 的底层数据结构一般都是简单动态字符串(SDS,Simple Dynamic String)实现的,而不是c语言实现的字符串,SDS 类似 java 中的 ArrayList,当值的空间存满了的时候这个字符串会进行自动扩容(1M之下会扩容2倍,1M以上会每次加1M)

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; // 表示该数组总共占用多少空间
    uint8_t alloc; // 表示该数组的空闲空间有多少
    unsigned char flags;
    char buf[]; // 存放数据的数组
};

SDS 一共有五种结构,方便是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,用 flags 来表示

SDS 有以下特性:

  • 动态扩容以杜绝缓冲区溢出:当SDSAPI需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,空间预分配指每次扩容时程序不仅会为了 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用的空间。分配策略为:如果对SDS进行修改之后,SDS的长度小于1MB,那么程序分配和len属性同样大小的未使用空间,这时alloc的大小为len的两倍。如果对SDS进行修改之后,SDS的长度大于等于1MB,那么程序分配1MB未使用空间,这时alloc的大小为len的两倍
  • 惰性空间释放:用于优化SDS的字符串缩短操作,当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不是立即使用内存重新分配来回收多出来的字节。同时程序提供了自己的 API 减少空间
  • 二进制安全:因为 SDS 使用len属性的值而不是空字符串来判断字符串是否结束,数据在写入的时候时什么样的,被读取的时候就说什么样的,不会对其中的数据做任何限制。而C语言自带的字符串读取到 ‘/0’ 的时候就会停止读取
  • 如果储存的值是数字,那么Redis内部会把它转成long类型来存储,从而减少内存的使用
  • SDS 的物理储存就是一块连续的内存空间

压缩列表

ziplist 压缩列表是列表键与哈希键的底层实现之一,所有的节点在物理空间中被紧紧压在一起,以减少碎片空间增加空间利用率。在逻辑上对应 java 的 ArrayList

压缩列表主要关注列表的构成、列表节点的构成以及压缩列表可能造成的连锁更新问题。Redis 使用字节数组表示一个压缩列表,字节数组逻辑划分为多个字段,先来看看列表的结构
Redis 数据结构与对象_第1张图片

  • zlbytes:记录整个压缩列表占用的字节数
  • zltail:记录所有的节点占用的字节数
  • zllen:记录所有的节点数量,节点数量小于65535时才能用这个值表示,如果节点数目过多只能遍历所有节点
  • entryX:列表节点
  • zlend:特殊的结束符。在对底层进行操作的时候就会经常出现这些,头标尾标计数之类的东西

以下是压缩列表节点的构成:
Redis 数据结构与对象_第2张图片
previous_entry_length:这个属性记录了压缩列表前一个节点的长度,该属性根据前一个节点的大小不同可以是1个字节或者5个字节。这个特性是连锁更新的罪魁祸首之一,但是该特性也是实现从表尾遍历到表头的原理

如果前一个节点的长度小于254个字节,那么previous_entry_length的大小为1个字节,即前一个节点的长度可以使用1个字节表示

如果前一个节点的长度大于等于254个字节,那么previous_entry_length的大小为5个字节,第一个字节会被设置为0xFE(十进制的254,这也是特殊标记,redis 读到这个254时就知道该节点是5字节的了),之后的四个字节则用于保存前一个节点的长度

encoding:通过一些特定的编码方式来表示该节点记录的是字节数组还是整数

content:该属性负责保存节点的值,节点值可以是一个字节数组或者一个整数

连锁更新在一般的业务情况下不太影响性能,因为高时间复杂度的连锁更新操作出现的条件极其严格

当数组中都是253字节的节点时,向列表头部添加一个大于254字节的节点,此时下一个节点的previous_entry_length更新为5字节,此时下一个节点也大于254字节了,因此造成了连锁反应

在最坏情况下,会从插入位置一直连锁更新到末尾,即执行了N次空间重分配, 而每次空间重分配的最坏复杂度为 O(N) , 所以连锁更新的最坏复杂度为 O(N^2)。注意,是最坏

链表

单键多值,是一个链表数据结构,链表键的底层实现之一

typedef struct listNode {

    // 前置节点
    struct listNode *prev;// 后置节点
    struct listNode *next;// 节点的值
    void *value;} listNode;

很简单对不对,但是单个的链表节点是无法形成链表的

typedef struct list {// 表头节点
    listNode *head;// 表尾节点
    listNode *tail;// 链表所包含的节点数量
    unsigned long len;// 节点值复制函数
    void *(*dup)(void *ptr);// 节点值释放函数
    void (*free)(void *ptr);// 节点值对比函数
    int (*match)(void *ptr, void *key);} list;

这是一个正常的链表结构,为了方便用户包含了头尾指针以及节点个数,还添加了节点操作函数

字典

字典这种数据结构在 redis 中被大量使用,除了用来表示数据库之外,字典还是hash 键的底层实现之一,当一个 hash 键包含的键值对比较多,又或者键值对中的元素是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现

字典由哈希表、字典与哈希表节点构成,哈希表结构几乎和 java 中的 hashmap 一模一样,通过一个数组来存放节点,同时为了管理方便,在中间添加了大小、装了多少数据等属性

typedef struct dictht{
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    unsigned long sizemask;
    //该hash表已有节点数量
    unsigned long used;
}

在节点中使用拉链法来解决哈希冲突,键值对中的值可以是一个整数,也可以是一个指针,而键则是一个指针

typedef struct dictEntry{
	void *key;
	// 值
	union{
      void *val;
      uint_64_tu64;
      int64_ts64;
	} v;
	// 指向下一个哈希节点
	struct dictEntry *next;
}dictEntry;

但是就靠这两种结构是无法组成哈希的,哈希表需要解决扩容问题,因此 redis 又将表封装了一层

typedef struct dict{
   //类型特定函数
   dictType *type;
   //私有数据
   void *privdata;
   //哈希表
   dictht ht[2];
   //rehash索引
   //当rehash不在进行时,值为-1
   int trehashidex
}

type 属性和 privdata 是针对不同类型的键值对,为创建多态准备的

ht 与 trehashidex 则是为了实现扩容准备的,ht 表示两个哈希表,rehash 时可以从一个表将数据复制到另外一张表中,同时 redis 中采用渐进式 rehash,trehashidex 是一个计数器。拓展和收缩哈希表的工作可以通过执行 rehash 操作来完成,进行 rehash 的步骤如下:

1,为字典的ht[1]哈希表分配空间,这个哈希表空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)

2,将保持在ht[0]中所有键值对慢慢的rehash到ht[1]上面,rehash指的是重新计算hash值和索引值,然后将键值对放置到ht[1]哈希表指定位置上。这个慢慢的是指 redis 中可能存放过多数据,不可能一次性 rehash,那样时间复杂度太大。因此每次删改查操作是都会将一个位置的数据 rehash 到ht[2]上

3,当ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],当ht[1]设置为ht[0],并在ht[1]新创建空白哈希表,为下一次hash做准备

渐进式 rehash 有以下特点:

  • trehashidex 标志了该次 rehash 的数组下标,完成该次 rehash 后会将计数器值加一
  • 增加操作直接在ht[1]上进行,删改查操作会先查找数据,此时先在0表查找,没查到的话去1表查
  • 0表会慢慢减少,1表会慢慢增加
  • trehashidex 在 rehash 开始时会被置为0,慢慢的增加,完成之后被置为-1

跳表

跳表是已经给数据排序并且可以快速查找的链表,它的实现是在原来的链表上加了多级索引,每个索引节点包含两个指针,一个向下,一个向右,最低层包含所有的元素。跳表是有序集合

跳表在插入数据时会根据一个随机函数得到该数据的层数,并且搜索到对应位置进行插入,插入时小于该分值的上一个节点都需要进行修改

跳表其实类似给链表建立索引,它的查找时间复杂度近似平衡二叉树
Redis 数据结构与对象_第3张图片

查找时从头节点的最高层开始查找,如果当前层数的下一个数据比需要查找的数据大,进入该节点下一层尝试进行查找,循环反复直到找到

redis 用两个结构来实现跳表,一个是 zskiplist。指向的头节点必定为32层,并且不包含数据, 最大节点数则方便确定头节点应该在哪一层开始查找

typedef struct zskiplist {
    // 头节点,尾节点
    struct zskiplistNode *header, *tail;
    // 节点数量
    unsigned long length;
    // 目前表内节点的最大层数
    int level;
} zskiplist;

zskiplistNode 就是实现跳表的节点。每个节点都有一个分值来确定该节点的大小,如果大小一样的分值则使用成员对象的值来确定大小

typedef struct zskiplistNode {
    // member 对象
    robj *obj;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 这个层跨越的节点数量
        unsigned int span;
    } level[];
} zskiplistNode;

整数集合

整数集合是集合键的实现之一,当一个集合只有整数元素,并且元素数量不多是就会使用整数集合,其实现相当简单,只有一个结构体

typedef struct intset {

    // 编码方式
    uint32_t encoding;

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

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

} intset;

整数集合的每个元素都是 contents 数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项

唯一需要着重说的是整数集合的升级优化。encoding 编码方式决定了数组存放了什么数据,每当我们要将一个新元素添加到整数集合里面, 需要比较新元素的类型是否比整数集合现有所有元素的类型都要长,如果是,则整数集合需要先进行升级(upgrade), 然后才能将新元素添加到整数集合里面。升级就是将原来数组中的所有占用低字节的数据转换为多字节的数据。比如将32位转为64位

整数集合越升级就能存放范围越大的数据,同时,因为新插入数据的范围大,因此插入的不是数组的最左边就是数组的最右边。在插入时不会新建一个数组来存放数据,而是会在该数组后面新增一块储存空间并且使用尾插法转换

整数集合不支持降级操作

quicklist

quicklist依赖于ziplist和linkedlist来实现,它是两个结构的结合。它将ziplist来进行分段存储,也就是分成一个个的quicklistNode节点来进行存储。每个quicklistNode指向一个ziplist,然后quicklistNode之间是通过双向指针来进行连接的

quicklist一般两边结点为ziplist,中间结点叫quicklistZF,中间部分节点是ziplist进一步压缩形成的

redisDb

redis是使用c语言编写的,在了解具体数据结构之前,先来看看它大体的储存数据结构

typedef struct redisDb { 

int id; //id是数据库序号,为0-15(默认Redis有16个数据库) 

long avg_ttl; //存储的数据库对象的平均ttl(time to live),用于统计 

dict *dict; //存储数据库所有的key-value 

dict *expires; //存储key的过期时间 

dict *blocking_keys;//blpop 存储阻塞key和客户端对象 

dict *ready_keys;//阻塞后push 响应阻塞客户端 存储阻塞后push的key和客户端对象 dict *watched_keys;//存储watch监控的的key和客户端对象 

} redisDb;

Redis 默认会创建 16 个数据库,每个数据库互不影响

dict 用来维护一个 Redis 数据库中包含的所有 Key-Value 键值对,它就是传说中的键空间。它的键是字符串,它的每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种 Redis 对象

表层

我们通过命令可以直接调用以下的对象

列表键

底层数据结构是双向链表,类似 java 中的,如果在数据量比较少的情况下,会分配一个连续的内存空间 ziplist(减少内存碎片)ziplist 被设计为各个数据项挨在一起组成连续的内存空间,这种结构并不擅长做修改操作

数据量较多时,会将多个 ziplist 连接成一个 quicklist

使用场景:发布订阅(这是一种消息的通信模式,服务器可以发布多条消息,客户端可以订阅多个频道,使用命令来实现)

哈希键

普通的hash,hash特别适合用于存储对象

在数据量较少的时候使用ziplist,数据量较多的时候使用字典dict(因为此时使用ziplist读写效率会降低),有些人将其叫做hashtable,它的原理和java中的HashMap差不多

使用场景:储存用户信息,存储对象

BitMaps

键为对象,值为true或false

一byte有8字节,而bitmap的值只有一字节,使用这个极大提高了内存利用率

应用场景:用户是否签到

Set

无序不可重复集合

关于底层数据结构的实现,当数据量较少(小于512个),并且存放的都是整数的时候,它会使用整数集合(intset)来储存,整数集合中使用数组来存放数据,数据没有重复

当不同时满足这两个条件的时候,Redis 就使用dict(字典)来存储集合中的数据,具体实现也和java中HashSet实现一样,字典的每个键都是一个字符串对象,同时每个值都被设成了 null

应用场景:储存无序不可重复的数据时使用

ZSet(SortedSet)

可以排序的set集合,又称有序集合,保证了内部 value 的唯一性,另方面它可以给每个 value 赋予一个score,代表这个value的排序权重

当有序集合保存的元素个数小于128个,且所有元素成员长度都小于64字节时,使用 ziplist

有序集合会使用压缩列表作为底层实现,每个集合元素使用两个紧挨着一起的两个压缩列表节点表示,第一个节点保存元素的成员(member),第二个节点保存元素的分值(score)

否则,使用skiplist+字典dict的方式实现(字典的键为元素,值为score),其中字典用来根据数据查找对应的分数,而跳表用来根据分数查询数据(由于多个数据会有相同的分数,因此可能是范围查找)

应用场景:直播间礼物排行榜

你可能感兴趣的:(数据库,redis,数据结构,java)