Redis数据结构的底层实现

1,333 阅读13分钟

Redis中各个常用数据结构对应的底层实现

image.png 具体分析

RedisDB结构

Redis中存在“数据库”的概念,该结构由redis.h中的redisDb定义。

当redis 服务器初始化时,会预先分配 16 个数据库

所有数据库保存到结构 redisServer 的一个成员 redisServer.db 数组中

redisClient中存在一个名叫db的指针指向当前使用的数据库

结构源码

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;

RedisObject结构

Redis对象头,所有的Redis对象都有下面这个结构

结构源码

typedef struct redisObject { 
   unsigned type:4;//类型 对象类型 
   unsigned encoding:4;//编码 
   void *ptr;//指向底层实现数据结构的指针 
   //... 
   int refcount;//引用计数 
   //... 
   unsigned lru:LRU_BITS; //LRU_BITS为24bit 记录最后一次被命令程序访问的时间 
   //... 
   }robj;

redisObject中属性解释

4位type

type 字段表示对象的类型,占 4 位;

REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。

当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型

127.0.0.1:6379> set name:1 22 
OK 
127.0.0.1:6379> type name:1 
string 
127.0.0.1:6379> lpush list:1 1 2 3 4 5 3 
(integer) 6 
127.0.0.1:6379> type list:1 
list

4位encoding

encoding 表示对象的内部编码,占 4 位

每个对象有不同的实现编码

Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。 通过 object encoding 命令,可以查看对象采用的编码方式

127.0.0.1:6379> set name:2 9999999999999999999999999999999999999999999999999999999999999999999999999999999999 
OK 
127.0.0.1:6379> object encoding name:2 
"raw" 
127.0.0.1:6379> object encoding name:1 
"int"

24位LRU

lru 记录的是对象最后一次被命令程序访问的时间,( 4.0 版本占 24 位,2.6 版本占 22 位)。

高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu : 最近访问次数)

refcount

refcount 记录的是该对象被引用的次数,类型为整型。

refcount 的作用,主要在于对象的引用计数和内存回收。

当对象的refcount>1时,称为共享对象

Redis 为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。

ptr

ptr 指针指向具体的数据,比如:set hello world,ptr 指向包含字符串 world 的 SDS。

字符串

Redis 中的字符串是可以修改的字符串,在内存中它是以字节数组的形式存在的。

Redis 的字符串叫着“ SDS”,也就是 Simple Dynamic String。它的结构是一个带长度信息的字节数组。

源码中SDS的结构

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

image.png

用这个SDS有什么好处呢?

1、改起来更快

C语言中字符串数组的长度是无法变动的。如果Redis中使用的字符串是C的字符串,而不是SDS,当我们变动一个键所对应的字符串,如果新字符串的长度小于等于原先字符串的长度,那么我们只要替换字符数组上的内容,再把代表字符串结尾的提前(如果新旧字符串长度相等,则空字符串还留在原先的位置)。但如果新字符串的长度大于原先旧字符串的长度,那么很不幸,我们只能重新申请一个能容纳新字符串长度的数组,用于保存新字符串,这对Redis无疑是不利的。

SDS因为会申请比字符串长度更长的数组,所以改起来更快。

2、获取字符串长度更快

C语言中得到字符串长度需要调用strlen(byte)的方式,要一个一个遍历,直到遇到‘\0’,就认为字符串结束了,所以时间复杂度是O(n),SDS存了字符串长度,所以时间复杂度是O(1)。

3、C语言字符串里不能保存二进制数据,而SDS是可以的

SDS的扩容策略 字符串长度小于 1MB 之前,扩窑空间采用加倍策略,也就是保留 100% 的冗 余空间。当字符串长度超过 1MB 之后,为了避免加倍后的冗余空间过大而导致浪费, 每次扩窑只会多分配 1MB 大小的冗余空间。

字符串对象的存储对应三种编码格式

分别是 int、emstr、raw

127.0.0.1:6379> set name:1 1
OK
127.0.0.1:6379> type name:1
string
127.0.0.1:6379> object encoding name:1
"int"
127.0.0.1:6379> set name:2 11111111111111111111111111111111111
OK
127.0.0.1:6379> type name:2
string
127.0.0.1:6379> object encoding name:2
"embstr"
127.0.0.1:6379> set name:3 asdasdasdasdasdasdsadasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdas
OK
127.0.0.1:6379> type name:3
string
127.0.0.1:6379> object encoding name:3
"raw"

int int类型的整数

embstr 编码的简单动态字符串 小字符串 长度小于44个字节

raw 简单动态字符串 大字符串 长度大于44个字节

dict(字典)

字典是 Redis 服务器中出现最为频繁的复合型数据结构,除了 hash 结构的数据会用到字典( dict)外,整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典, 还有带过期时间的 key 集合也是一个字典。 zset 集合中存储 value 和 score 值的映射关系也是通过字典结构实现的。

字典的内部结构

typedef struct dict { 
  dictType *type; // 该字典对应的特定操作函数 
  void *privdata; // 上述类型函数对应的可选参数 
  dictht ht[2]; /* 两张哈希表,存储键值对数据,ht[0]为原生ht[1]为 rehash 哈希表 */ 
  long rehashidx; /*rehash标识 当等于-1时表示没有在否则表示正在进行rehash操作,存储的值表示 ht[0]的rehash进行到哪个索引值hash表 (数组下标)*/ 
  int iterators; // 当前运行的迭代器数量 
  } dict;

先看一下hashtable的结构,hashtable的结构和jdk1.8之前的hashmap几乎是一样的。都是数组+链表的结构。

struct dictEntry { 
  void* key ; 
  void* val ; 
  dictEntry* next;//链接 下一个entry 
  } 
  
struct dictht { 
  dictEntry** table; // 哈希表数组 
  long size ; // 哈希表数组的大小 
  long used ; // 哈希表中已有节点的数量 
  // ... 
  }

字典的存储过程图解

image.png

字典的内部包含两个hashtable,为什么是两个呢?

另一个是在扩容的时候用的,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩窑时,需要分配新的 hashtable,然后进行渐进式搬迁, 这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后, 旧的 hashtable被删除,新的 hashtable取而代之。

字典的扩容

假设两个hashtable分别是h[0]、h[1] 条件:达到扩容阈值 流程:

  1. 申请新内存,新内存的地址赋值给h[1]
  2. 将rehashidx置为0,表示要进行rehash操作
  3. 新增加的数据存到hash[1]
  4. 执行查找、更新、删除命令,先在ht[0]中查找元素,没有找到再去ht[1]中找
  5. 将老的hash表h[0]的数据重新计算索引值后全部迁移到新的hash表h[1]中,这个过程称为 rehash

ziplist(压缩列表)

Redis 为了节约内存空间使用, zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表( ziplist)进行存储。压缩列表是一块连续的内存空间,元素之间紧接着存储,没有任何冗余空隙 。

struct ziplist<T> { 
  int32 zlbytes ; //整个压缩列表占用字节数 
  int32 zltail_offset; //最后一个元素距离压缩列表起始位直的偏移量,用于快速定位到最后一个节点 
  int16 zllength ; //元素个数 
  T[] entries ; //元素内容列表,依次紧凑存储 
  int8 zlend; //标志压缩列表的结束,值恒为 OxFF 
  }
  
  struct entry{ 
    int<var> prevlen; // 前一个 entry 的字节长度 
    int<var> encoding; // 元素类型编码 
    int<var> len; // 当前entry的长度 
    optional byte[] content; // 元素内容
    //... 
    }

image.png 压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位

到最后一个元素,然后倒着遍历。查找下一个是根据长度查找的。

因为ziplist的结构是紧挨着的,没有冗余空间(对比SDS)。所以新增元素的时候可能需要重新分配内存。

如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗,所以 ziplist 不适合存储大型字符串,存储的元素也不能过多。

hash在元素个数比较少,且元素都是小整数或短字符串时 是用ziplist存储的

127.0.0.1:6379> hmset user:001  username zhangfei password 111 age 23 sex M
OK
127.0.0.1:6379> object encoding user:001
"ziplist"
127.0.0.1:6379> hmset user:003 username111111111111111111111111111111111111111111111111111111111111111111111111 zhangfei password 111 num 330000000000000000000000000000000000000
OK
127.0.0.1:6379> object encoding user:003
"hashtable"

zset当元素的个数比较少,且元素都是小整数或短字符串时 是用ziplist存储的

127.0.0.1:6379> zadd hit:1 100 item1 20 item2 45 item3
(integer) 3
127.0.0.1:6379> object encoding hit:1
"ziplist"
127.0.0.1:6379> zadd hit:2 100 item99999999999999999999999999999000000000000000000003333333333333333333 20 item2 45 item3
(integer) 1
127.0.0.1:6379> object encoding hit:2
"skiplist"

intset小整数集合

当set集合容纳的元素都是整数并且元素个数较少时, Redis会使用intset来存储集合元素。 intset是紧凑的数组结构,同时支持16位、32位和64位整数。

不满足上述条件时,set集合结构的底层实现是字典,只不过所有的value都是null,其他特性和字典一样。

127.0.0.1:6379> sadd set:001 1 3 5 6 2 
(integer) 5 
127.0.0.1:6379> object encoding set:001 
"intset" 
127.0.0.1:6379> sadd set:004 1 100000000000000000000000000 9999999999 
(integer) 3 
127.0.0.1:6379> object encoding set:004 
"hashtable"

quickList(快速列表)

quicklist是一个双向链表,链表中的每个节点时一个ziplist结构。quicklist中的每个节点ziplist都能够存储多个数据元素。

typedef struct quicklist { 
  quicklistNode *head; // 指向quicklist的头部 
  quicklistNode *tail; // 指向quicklist的尾部 
  unsigned long count; // 列表中所有数据项的个数总和 
  unsigned int len; // quicklist节点的个数,即ziplist的个数 
  int fill : 16; // ziplist大小限定,由list-max-ziplist-size给定(Redis设定) 
  unsigned int compress : 16; // 节点压缩深度设置,由list-compress-depth给定(Redis设定) 
  } quicklist;
  
  typedef struct quicklistNode {
    struct quicklistNode *prev; // 指向上一个quicklistNode节点
    struct quicklistNode *next; // 指向下一个quicklistNode节点
    unsigned char *zl; // 数据指针,如果没有被压缩,就指向ziplist结构,反之指向quicklistLZF结构
    unsigned int sz; // 表示指向ziplist结构的总长度(内存占用长度)
    unsigned int count : 16; // 表示ziplist中的数据项个数
    unsigned int encoding : 2; // 编码方式,1--ziplist,2--quicklistLZF
    unsigned int container : 2; // 预留字段,存放数据的方式,1--NONE,2--ziplist
    unsigned int recompress : 1; // 解压标记
    // ...
}quicklistNode;    

根据上面的代码画的图 image.png ziplist长度

quicklist内部默认单个ziplist长度为8KB ,超出了这个字节数,就会另起个ziplist。ziplist的长度由配置参数list-max-ziplist-size决定。

为了进一步节约空间 , 对ziplist进行压缩存储,使用LZF算法压缩,可以选择压缩深度。

压缩深度

quicklist默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数 list-compress-depth 决定。为了支持快速的 push/pop 操作, quicklist 的首尾两个 ziplist 不压缩,此时压缩深度就是 1。如果压缩深度为 2,就表示 quicklist 的首尾第一个 ziplist以及首尾第二个 ziplist都不压缩。如下图,压缩深度是 1。

image.png Redis中列表(List)是的底层实现是quicklist

skiplist(跳跃列表)

zset结构比较复杂。 一方面它需要一个 hash 结构来存储 value 和 score 的对应关系,另一方面需要提供按照 score 排序的功能,还需要能够指定 score 的范围来获取 value列表的功能,这就需要另外一个结构“跳跃列表“;

举例:

普通链表

image.png 查找元素9,按道理我们需要从头结点开始遍历,一共遍历8个结点才能找到元素9。

跳跃表会对链表进行分层

第一次分层 遍历5次找到元素9(红色的线为查找路径)

image.png

第二次分层 遍历4次找到元素9

image.png 第三层分层 遍历4次找到元素9

image.png 有点类似二分查找,时间复杂度变成了O(lg(n))

Redis中跳跃表的实现

//跳跃表节点
typedef struct zskiplistNode {
   sds ele; /* 存储字符串类型数据 redis3.0版本中使用robj类型表示, 但是在redis4.0.1中直接使用sds类型表示 */
   double score;//存储排序的分值
   struct zskiplistNode *backward;//后退指针,指向当前节点最底层的前一个节点 
   struct zskiplistLevel {
       struct zskiplistNode *forward; //指向本层下一个节点
       unsigned int span;//本层下个节点到本节点的元素个数 
       } level[];
  } zskiplistNode;
  
//链表
typedef struct zskiplist{
   //表头节点和表尾节点
   structz skiplistNode *header, *tail; 
   //表中节点的数量
   unsigned long length; 
   //表中层数最大的节点的层数
   int level;
}zskiplist;

// zset结构 字典+跳跃表组成
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

image.png 特点:双向链表,有序排列

查找的过程就是定位最后一个比“我”小的元素,然后从这个节点开始降一层再遍历定位最后一个比我小的元素。

插入的时候也是先按照查找的过程定位到要插入的位置。

那么插入的元素是几层呢?

int zslRandomLevel(void) {       
    int level = 1;       
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
          level += 1;       
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;   
   }

0xFFFF转换为二进制为1111111111111111十进制是65535。random()产生的随机数为0到65535

ZSKIPLIST_P = 0.25。所以level += 1执行的概率为0.25。

因此,最终返回level为1的概率是1-0.25=0.75,返回level为2的概率为0.250.75,返回level为3的概率为0.250.25*0.75......