Redis数据结构及对象(上)

Redis对象及底层数据结构


  redis一共有五大经常使用的对象,用type命令便可查看当前键对应的对象类型,分别是string(字符串)、hash(哈希)、list(列表)、set(集合)、zset(有序集合),可是这些只是对外的数据结构,实际上每个对象都有两到三种不一样底层数据结构实现,能够经过object encoding命令查看键值对应的底层数据结构实现,redis

下表即为每种对象所对应的底层数据结构实现。算法

类型 编码 底层数据结构
string int 整数值
string raw 简单动态字符串
string embstr 用embstr编码的简单动态字符串
hash ziplist 压缩列表
hash hashtable 字典
list ziplist 压缩列表
list linkedlist 双端列表
set intset 整数集合
set hashtable 字典
zset ziplist 压缩列表
zset skiplist 跳表和字典

简单动态字符串(SDS)


定义

  redis并无使用C字符串,而是使用了名为简单动态字符串(SDS)的结构,SDS的定义以下:数组

struct sdshdr {
    // 记录 buf 数组中已使用字节的数量
    // 等于 SDS 所保存字符串的长度
    int len;

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

    // 字节数组,用于保存字符串
    char buf[];
};
复制代码
  • len:记录字符串长度,大小为4个字节
  • free: 记录buf[]中未被使用字节数量,大小为4个字节
  • buf[]: 保存字符串,大小为字符串大小+1,由于buf[]最后一个字节保存'\0' 因此sds的总大小为 = 4 + 4 + size(str) + 1
    sds图示

SDS的做用

  那么redis为何要使用看起来更占空间的SDS结构呢?主要有如下几个缘由:安全

  1. O(1)复杂度得到string的长度  相比于C字符串须要遍历string才能得到长度(复杂度O(N)),SDS直接查询len的数值便可。
  2. 防止缓冲区溢出  当修改C字符串时,若是没有分配够足够的内存,很容易形成缓冲区溢出。而使用SDS结构,当修改字符串时,会自动检测当前内存是否足够,若是内存不够,则会扩展SDS的空间,从而避免了缓冲区溢出。
  3. 减小修改字符串带来的频繁的内存分配  每次增加或缩短C字符串,都须要从新分配内存,而redis常常被用在数据修改频繁的场合,因此SDS采用了两种策略从而避免了频繁的内存分配。  ①空间预分配   如上文所述,SDS会自动分配内存,若是修改后字符串内存占用小于1MB,则会分配一样大小的未使用内存空间。(eg len: 20kb free: 10kb→ len: 40kb free 40kb),若是大于1MB,则分配1MB未使用内存空间。如此一来就能够避免由于字符串增加带来的频繁空间分配。  ②惰性删除   当缩短字符串时,SDS并无释放掉相应的内存,而是保留下来,用free记录未使用的空间,为之后的增加字符串作准备。
  4. 二进制安全  SDS会以处理二进制数据的形式存取buf中的内容,从而让SDS不只能够保存任意编码的文本信息,还能够保存诸如图片、视频、压缩文件等二进制数据。

双端列表


定义

  双端列表做为一种经常使用的数据结构,当一个list的长度超过512时,那么redis将使用双端列表做为底层数据结构。下面是一个列表节点的定义:bash

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;
复制代码

  能够看到双端列表是一个无环双端带表头表尾节点的链表。函数

字典


定义

散列表Hash table,也叫哈希表),是根据键而直接访问在内存存储位置的数据结构。也就是说,它经过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称作散列函数,存放记录的数组称作散列表性能

  当hashtable的类型没法知足ziplist的条件时(元素类型小于512且全部值都小于64字节时),redis会使用字典做为hashtable的底层数据结构实现。redis的字典(dict)中维护了两个哈希表(table),而每一个哈希表包含了多个哈希表节点(entry)。下面分别来介绍这三个对象。优化

哈希表节点

typedef struct dictEntry {

    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

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

} dictEntry;
复制代码
  • key:键值对中的键。
  • v: 键值对中的值,能够看到值能够为一个指针,或者是一个uint64整数或者int64整数。
  • next:是为了用链地址法解决hash冲突。

哈希表

typedef struct dictht {

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

   // 哈希表大小
   unsigned long size;

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

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

} dictht;
复制代码
  • table:是一个保存着指向全部节点指针的数组。
  • size: 记录了table数组的大小。
  • sizemask: 用于和hash值一块儿计算索引值(index = hash & sizemask )

字典

typedef struct dict {

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

   // 私有数据
   void *privdata;

   // 哈希表
   dictht ht[2];

   // rehash 索引
   // 当 rehash 不在进行时,值为 -1
   int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;
复制代码
  • type 属性和 privdata 属性是针对不一样类型的键值对, 为建立多态字典而设置的。
  • 字典内部有两个哈希表,这样作的目的是为rehash作准备。
    字典图示

hash算法

  当在哈希表中存取数据时,首先须要用hash算法算出键值对中的键所对应的hash值,而后再根据根据table数组的大小取模,计算出对应的索引值,再继续接下来的操做。redis使用了MurmurHash2 算法来计算键的哈希值,又使用了快速幂取模算法下降了取模的复杂度。整个过程以下:ui

hash = dict->type->hashFunction(k0);
index = hash & dict->ht[0].sizemask;
复制代码

  当hash冲突发生时则采用链地址法解决hash冲突。

rehash

  当哈希表保存的键值对愈来愈多时,哈希表的负载因子(load factor = used / size)愈来愈大, 本来O(1)复杂度的查找也会渐渐趋向于O(N),为了保证哈希表的负载因子在必定的范围以内。redis须要动态的调整table数组的大小,其中最重要的即是rehash过程。rehash分如下的几个步骤:

  1. 为字典的 ht[1] 哈希表分配空间,须要注意的是新的size必须是2^n,这主要是为了配合快速幂取模算法。
  2. 将ht[0]上的键值对rehash到ht[1]上,即从新计算ht[0]上全部键值对的hash值和索引值,而后分配到ht[1]上,当原来的哈希表数据量很大时可能会引发线程的阻塞,因此redis采用渐进式的rehash方式。
  3. ht[0]表释放,原子性的替换ht[1]至ht[0],并建立一个空的哈希表分配至ht[1]

渐进式rehash

  redis的rehash过程并非一次性集中rehash,而是分批间隔式的,在dict中的rehashidx即是为此服务。   相较于一次性的rehash,渐进式的rehash多了下面这些步骤:

  1. 开始rehash时,将rehashidx置为0。
  2. 当完成了一次rehash后,将rehashidx自增1,直到遍历完全部的table数组。
  3. 在rehash过程当中,若是有对字典进行增长,则只增长ht[1],若是是查找,则先查找ht[0],若是找不到则去查找ht[1],而若是是删除和更新,则ht[0]和ht[1]同步操做。
  4. 完成全部rehash后,将rehashidx置为-1。

  这是比较典型的分而治之的思想,将一次性集中做业分散,下降了系统的风险。

跳跃表


定义

  跳表的的查找复杂度为平均O(logN)/最坏O(N)。在不少场合下做为替代平衡树的数据结构,在redis中,若是有序集合的属性不知足ziplist的要求,则将跳表做为有序集合的底层实现。

跳表图示
  上图即为一个完整的跳表,其中有几点比较重要,这个跳表一共有三个节点再加上一个头节点,最高有五层。一个跳跃表包含了两种对象,一个是跳跃表节点,一个是跳跃表。

跳跃表节点

typedef struct zskiplistNode {
    // 后退指针
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成员对象
    robj *obj;

    // 层
    struct zskiplistLevel {

        // 前进指针
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;
复制代码
  • backward:后退指针,和双端列表同样,指向上一个节点。
  • score:分值,有序列表的排序依据。
  • obj:成员对象,实际上为一个SDS,在有序集合中分值能够重复,但成员对象不能重复。
  • level:层,跳表的关键所在,在条表中每一层包含了1到n个节点,在有序的状况下,能够快速遍历数组。
  • forward:下一个节点的对象,这里的下一个表明是第一个或者是第n个。
  • span: 下一个节点和如今节点的距离。

跳跃表

typedef struct zskiplist {

    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 表中层数最大的节点的层数
    int level;

} zskiplist;
复制代码

跳跃表中保存了头尾节点,方便遍历,还保存了节点的数量,能够在O(1) 复杂度内返回跳跃表的长度。

整数集合


定义

  当集合的值全为整数且集合的长度不超过512时,redis采用整数集合做为集合的底层数据结构。

typedef struct intset {

    // 编码方式
    uint32_t encoding;

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

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

} intset;
复制代码
  • encoding:整数集合中元素的编码方式

INTSET_ENC_INT16 , contents 就是一个 int16_t 类型的数组(最小值为 -32,768 ,最大值为 32,767 )。 INTSET_ENC_INT32 , contents 就是一个 int32_t 类型的数组(最小值为 -2,147,483,648 ,最大值为 2,147,483,647 )。 INTSET_ENC_INT64 , contents 就是一个 int64_t 类型的数组(最小值为 -9,223,372,036,854,775,808 ,最大值为 9,223,372,036,854,775,807 )。

  • length:数量
  • contents:集合元素 虽然contents看起来是int8_t,可是它的具体内容的存取仍是按encoding的方式完成。

升级

  redis采用多种编码的方式,主要仍是为了省内存。当集合中加入了不符合当前集合编码的数字时,数组集合会自动更新至能匹配到的编码,值得注意的是,这种升级是不可逆的,只能由小往大,不能降级。如此一来,就可以在存放小数据时,剩下很大的空间,并且也没必要为编码不匹配的事情而烦恼了。

压缩列表


  压缩列表是redis又一个为了节省内存所作的优化,是list/hash/zset的底层数据结构之一,当数据值不大且数量较低时,redis都会使用压缩列表。

压缩列表图示

  • zlbytes:记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
  • zltail:记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 经过这个偏移量,程序无须遍历整个压缩列表就能够肯定表尾节点的地址。
  • zllen:记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量须要遍历整个压缩列表才能计算得出。
  • entryX:压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
  • zlend:特殊值 0xFF (十进制 255 ),用于标记。压缩列表的末端。

  压缩列表和双端列表有些相似,不过一个用指针衔接起来,一个则是用数组和长度衔接起来。下面来看一看压缩列表节点的定义:

节点图示

  • prevrawlen:前置节点的长度,至关于双端列表中的前置指针,经过它能够计算出前置节点的地址。
  • coding: 和正数集合相似,是为了代表content中是何种数据
  • content: 数据

总结


  本文对于redis常见的数据结构及其底层实现进行了分析和梳理,但愿可以理清这些底层数据结构对于redis高性能的做用和影响。

相关文章
相关标签/搜索