Redis存储结构介绍

520 阅读7分钟

Redis存储结构介绍

一、什么是Redis?

Redis全称是Remote Dictionary Server(远程字典服务),是一个开源的、使用C语言编写、可基于内存也可以持久化的Key-Value数据库。

二、Redis数据结构

Redis有以下这五种基本类型:

  • String(字符串)
  • Hash(哈希)
  • List(列表)
  • Set(集合)
  • zset(有序集合)

它还有四种特殊的数据结构类型(不作详细解释):

  • Geospatial( 一个球面数据结构,可直接计算两个经纬度之间的距离等等)

  • Hyperloglog(提供了—种不太精确的基数统计方法,用来统计—个集合中不重复的元素个数,比如统计网站的UV,或者应用的日活、 月活,存在—定的误差。在 Redis 中实现的 Hyperloglog, 只需要12K内存就能统计2A 64个数据)

  • Bitmap( Bitmaps是在字符串类型上面定义的位操作。 —个字节由8个二进制位组成)

  • Streams( 5.0 推出的数据类型。支持多播的可持久化的消息队列,用于实现发布订阅功能, 借鉴了kafka的设计。)

三、Redis存储结构(数组+链表)

Redis采用了哈希表作为最底层的数据存储结构, 在Redis中,hashtable被称为字典(dictionary) 。的。而在Hashtable中,又对RedisObject(存储实际值的对象)进行了多层的封装。

关系结构图

redis结构图.jpg

RedisObject对象

typedef struct redisObject {


// 类型

unsigned type:4;

// 编码

unsigned encoding:4;

// 对象最后一次被访问的时间

unsigned lru:

// 引用计数

int refcount;

// 指向实际值的指针

void *ptr;

} robj;

(1)type属性

type主要存储当前value对象的数据类型,如:

  • String(字符串)

  • Hash(哈希)

  • List(列表)

  • Set(集合)

  • zset(有序集合)

(2)enconding属性

        存储当前值对象底层编码的实现方式,不同type对象对应不同的编码。

Image.png

编码转换:

Image.png (3)lru属性

lru记录此对象最后一次访问的时间。

当redis内存回收算法设置为volatile-lru或者allkeys-lru时候redis会优先释放最久没有被访问的数据。

(4)refcount属性

用于共享计数,类似于jvm的引用计数垃圾回收算法,当refcount为0时,表示没有其它对象引用,可以进行释放此对象。

(5)ptr 指针属性

ptr 指针是指向对象的底层实现数据结构

哈希算法定位索引:

Image.png

假如我们现在模拟将 hash值从0到5的哈希表节点 放入 size为4的哈希表数组 中,也就是将包含键值对的哈希表节点放在哈希表数组的指定索引上。

索引

index = hash & ht[x].sizemask, 哈希表大小掩码sizemark属性的值总是等于size-1,size是hash表大小

比如上面 hash值为0的节点,0 % 4 = 0,所以放在索引0的位置上,

hash值为1的节点,1 % 4 = 1,所以放在索引1的位置上,

hash值为5的节点,5 % 4 = 1,也等于1,也会被分配在索引1的位置上,并且因为dictEntry节点组成的链表没有指向链表表尾的指针,所以会将新节点添加在链表的表头位置,排在已有节点的前面。

我们把上面索引相同从而形成链表的情况叫键冲突,而且因为形成了链表!那么就意味着查找等操作的复杂度变高了!

例如你要查找hash=1的节点,你就只能先根据hash值找到索引为1的位置,然后找到hash=5的节点,再通过next指针才能找到最后的结果,也就意味着键冲突发生得越多,查找等操作花费的时间也就更多。

如果解决键冲突?

其实rehash操作很好理解,可以简单地理解为哈希表数组扩容或收缩操作,即将原数组的内容重新hash放在新的数组里。

比如还是上面的数据,我们这次把它们放在 size等于8的哈希表数组 里。

如下图,此时size = 8,hash为5的键值对,重新计算索引:5 % 8 = 5,所以这次会放在索引5的位置上。

Image.png 那么假如我们还要找hash=1的节点,因为没有键冲突,自然也没有链表,我们可以直接通过索引来找到对应节点。

可以看到,因为rehash操作数组扩容的缘故,键冲突的情况少了,进而我们可以更高效地进行查找等操作。

rehash

typedef struct dict {

//类型特定函数

//是一个指向dictType结构的指针,可以使dict的key和value能够存储任何类型的数据

dictType *type;

//私有数据

//私有数据指针,不是讨论的重点,暂忽略

void *privdata;

//哈希表

dictht ht[2];

//rehash 索引

//当 rehash 不在进行时,值为 -1

int rehashidx;}

重点关注两个属性就可以:

  • ht 属性:

可以看到ht属性是一个 size为2 的 dictht哈希表数组,在平常情况下,字典只用到 ht[0],ht[1] 只会在对 ht[0] 哈希表进行rehash时才会用到。

  • rehashidx 属性:

它记录了rehash目前的进度,如果现在没有进行rehash,那么它的值为-1,可以理解为rehash状态的标识。 触发rehash操作的条件:

首先我们先引入一个参数,叫做负载因子(load_factor),它是一个会动态变化的参数,等于哈希表的 used属性值/size属性值,也就是 实际节点数/哈希表数组大小。

假如一个size为4的哈希表有4个哈希节点,那么此时它的负载因子就是1;size为8的哈希表有4个哈希节点,那么此时它的负载因子就是0.5。

满足下面任一条件,程序就会对哈希表进行rehash操作:

  • 扩容操作条件:
    • 服务器目前**没有执行 **BGSAVE 或者 BGREWRITEAOF 命令,负载因子大于等于1。
    • 服务器目前正在执行 BGSAVE 或者 BGREWRITEAOF 命令,负载因子大于等于5。
  • 收缩操作条件:
    • 负载因子小于0.1时。(2/30<0.1)

BGSAVE 和 BGREWRITEAOF 命令可以统一理解为redis的实现持久化的操作。

  • BGSAVE 表示通过fork一个子进程,让其创建RDB文件,父进程继续处理命令请求。

  • BGREWRITEAOF 类似,不过是进行AOF文件重写。

渐进式rehash:

首先我们知道redis是单线程,并且对性能的要求很高,但是rehash操作假如碰到了数量多的情况,比如需要迁移百万、千万的键值对,庞大的计算量可能会导致服务器在一段时间里挂掉!

为了避免rehash对服务器性能造成影响,redis会分多次、渐进式地进行rehash,即渐进式rehash。

对哈希表进行渐进式rehash的步骤如下:

  1. 首先为 ht[1] 哈希表分配空间,size的大小取决于要执行的操作,以及 ht[0] 当前的节点数量(即ht[0]的used属性值):

    • 扩展操作,ht[1]的size值为第一个大于等于ht[0].used属性值乘以2的 2^n(如果 ht[0].used 当前的值为 4 , 4 * 2 = 8 , 而 8 (2^3)恰好是第一个大于等于 8 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 8 。)
    • 收缩操作,ht[1]的size值为第一个小于ht[0].used属性值的 2^n(如果 ht[0].used 当前的值为 8, 而 4(2^3)恰好是第一个小于8 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 4。)
  2. 将哈希表的rehashidx值从-1置为0,表示rehash工作开始。

  3. 节点转移,重新计算键的hash值和索引值,再将节点放置到ht[1]哈希表的对应索引位置上。

  4. 每次rehash工作完成后,程序会将rehashidx值加一。

       注:这里的每次rehash就指渐进式rehash

  1. 当ht[0]的所有节点都转移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白的hash表,等待下次rehash再用到。(其实就是数据转移到ht[1]后,再恢复为 ht[0]储存实际数据,ht[1]为空白表的状态)
  2. 最后程序会将rehashidx的值重置为-1,代表rehash操作已结束。