Redis基本数据类型原理

262 阅读10分钟

我个人觉得Redis是学习数据结构的绝佳项目,结合了理论性和实际情况下对于时间和空间的取舍。

一般来说,常见的数据结构有:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树等。之后打算整理几篇文章总结下常用结构的特性。

Redis主要用到的:数组、链表(双向)、散列表、压缩列表、跳表

Redis数据类型原理

Key-Value类型数据库

redishi一个典型的键值数据库,Key是字符串类型,Value有五种类型,分别是:string、list、hash、set、sorted set。 后面有对各类型的解析。

键值对是用散列表存储的

kv对存储在散列表中

散列表保存的kv,用Key 找 Value的复杂度是O(1)

存储结构如图所示:

kv存储.png

  • 散列表是基于数组实现的

  • 散列(hash)函数将Key转化成数组的下标

  • 通过数组的下标找到找到存储Value的这块地址是O(1)的,因为:

    • 数组是内存中占用的是连续空间,所以拿到数组的起始地址和下标就能计算出下标对应的地址
    • target_address = start_address + n * blockSize(n是数组下标,blockSize是数组中每一个元素分配的空间大小)
    • 大部分编程语言,数组下标都是从0开始,这样可以省去一次(n-1)的计算

hash冲突

可以看到,刚刚的数组中,存储的元素是dicEntry,它由三个指针组成

entry结构.png

key,value都很好理解,但这有一个next指针

因为散列(hash)函数将Key转化成数组的下标,key的数量理论上肯定是远远大于数组长度的,那么一定会出现冲突,也就是说可能多个key的计算结果对应着同一个下标,这叫hash冲突。

那发生hash冲突的下标对应的内存地址,就应该存不止一个元素。dicEntry实际上是一个链表的node:

entry链表.png

那么链表上从头向尾去找目标Key的复杂度变成了O(n),当数据量大的时候,会变得很慢,这对redis来说是无法接受的

rehash

redis采用rehash的方法来避免上诉问题

redis默认使用了两个全局哈希表:哈希表1和哈希表2

一开始插入数据时,默认使用了哈希表1,此时哈希表2没有被分配空间当数据量变大了,哈希冲突也多了,这时候哈希表2来作用了:

  • 给哈希表2分配更大的空间,意味着有更多的下标,能减少hash冲突的可能性。

  • 将哈希表1中的数据重新映射并拷贝到哈希表2中

    • 重新映射的过程中需要执行区分度更好的hash函数
  • 释放哈希表1的空间

但是这个操作会很耗时,Redis不会一次性全部去执行,会采取渐进式的方式:

  • 当一个key被访问到时,它本身的entry会被迁移,这条链上的其他entry也会被迁移
  • 另外,Redis也会有定时任务会去搬迁一部分数据,来应对长时间都不会被访问到的key

Value的类型

value数据类型底层实现数据结构
string简单动态字符串
list双向链表,压缩列表
hash压缩列表,哈希表
sorted set压缩列表,跳表
set哈希表,整数数组

value的5个类类型又可以分为单值类型和集合类型

  • string是单值类型
  • 其他的都是集合类型

string类型

string类型是基于简单动态字符串(Simple Dynamic String,SDS),就有点像绝大部分的编程语言中的map/dict类型了。

比如要存储 身份证号码:系统内用户id这时候,string是一个很好的选择。

但是如果用户量更多的时候,会出现一个问题:占用掉的redis空间远远大于实际需要的空间

因为,Redis保存了很多的指针,从entry开始

entry_ptr.png

  • key、value都是一个8B的指针,指向RedisObject
  • next也是一个8B的指针,指向下一个entry

RedisObject

RedisObject是用来记录一些数据的元信息的,比如最后一次访问时间、被引用次数等

RedisObject.png

ptr部分一般不是实际内容,而是指向实际数据的指针

  • 有时候ptr会直接保存数据,比如8byte可以表示的long类型

SDS

ptr最终指向的才是SDS,前面已经有很多和数据本身无关的空间消耗了。

而SDS自身又分为三部分,SDS = len + alloc + buf

  • buf表示实际数据
  • len表示buf已用的长度
  • alloc表示buf分配长度

只有buf是真正的数据,其他指针看起来都是无用的空间消耗。

并且Redis 使用的内存分配库jemalloc,为了减少频繁分配次数,每次还会多分配一些空间

当然有些场景没办法,只能买更好的硬件存,但是刚刚举例子的场景更优化的设计,思路是减少entry的个数,因为就它浪费的最多,后续会提到。

hash类型

Key-Value中的Value是hash类型,这相当于go语言中,map类型通过key取出来的值,还是个map。不过redis中,如何实现这个map呢?

首先描述下Redis中用到的数据结构:压缩列表(ZipList)

ziplist.png

压缩列表是一个加强版的数组

  • zlbytes:列表占用内存长度
  • zltail:列表尾的偏移量
  • zlen:列表中entry个数

这样查询第一个元素和最后一个元素的复杂度是O(1),但是查询其他元素是O(n)。

压缩列表中每一个entry的组成:

  • prev_len:表示前一个entry的长度

    • 这也是区别于普通数组的一个地方,数组中为每一个下标对应的空间分配的大小是一样的,比如一个字符串数组,为每一个空间分配了32字节,对于有一些很短的字符串,这就存在了空间浪费

压缩列表vs数组.png

这个简化的示例图中可以表现出压缩列表节省空间的能力

  • encoding:表示编码

  • len:表示自身长度

  • content:表示实际数据

prev_len、encoding、len都用不了多少字节。

如果用压缩列表来实现value的hash类型呢?这看上去好像不太正常,用散列表不是O(1)的搜素复杂度吗,比压缩列表好多了。还是为了节省空间。Redis有两个特点:高速和基于内存。除了速度还需要考虑内存空间。

现在要查询key=wang对应的value,就需要从压缩列表头往后走,挨个匹配,复杂度是O(n)的,如果数量不大的时候,可以接受的。

但是当这个Hash表中的Key太多时,就太耗时了,还是需要转成散列表来实现。这个阈值可以配置。

那刚刚的string类型介绍中提到例子中,就是因为用户id太多了,所以导致有太多value,从而浪费了空间。而hash类型,一个value可以保存好多用户id,剩下的问题就是如何构建这个hash。

  • 同地区的身份证号码,前几位是一样的,如果用这一部分做大key,剩余部分做hash类型的小key。

List

List类型是一个队列,队列的特点自然就是fifo,和时间相关。

遇上和时间相关需求,如点赞列表,这些可以考虑用List来实现

List的底层是基于压缩列表和双向链表来实现的

大体的逻辑时,有数据进来时,先存在压缩列表内,当达到了一个压缩列表的存储限制后,就创建一个新的压缩列表,压缩列表用双向链表的形式连接起来。

  • 两种结构结合的方式比链表省空间
  • 全用压缩列表的话,当数据量过大时,没有那么多连续的空间

Set

Set是无序集合,且不重复

不重复的特性可以做很多事情,比如统计系统一天内有多少不同的用户登录,或者计算一些交集之类的,如两个人的共同好友等

说到不重复的数据结构,结合到go语言中,能马上想到的就是map中的key

Set的底层实现原理也是利用散列表,但是只需要用到key来表示set中的元素。

  • 当一个key插入set中时,经散列函数求出下标,数组对应的空间中没有值,直接存起来就好。

  • 当有值时,有两种可能:

    • 存在的值,和新插入的key相等,这证明key重复了,直接跳过了。
    • 存在的值,和新插入的key不相等,则是哈希冲突,有新的算法为该key找到新的下标然后再保存。

如果Set数据量不够大的话,会使用整数数组来实现Set,这样判断是否重复的复杂度会增加,但是会少一次散列函数的计算过程。

Sorted Set

Sorted Set是有序集合

有序的特性可以做排行榜之类的场景,特别是实时的排行榜

Sorted Set是基于跳表实现的

跳表

跳表是在链表上,向上加索引,索引也是链表,索引会有很多层级,图中是对最下面的链表加上了2级索引

跳表.png

从链表查询元素21时,从1开始,需要寻找8个节点

从顶层索引的1开始,发现目标值21比自己大,往后找到12,发现后一个节点30大于目标值,所以往下找到下一行所有的12,找到20后,往下找到链表中的20,再往后走一个找到目标节点。

跳表找目标节点.png

总共寻找了6个节点,数据量越大时,缩小的效果越明显

通过数学证明,链表的搜索复杂度是O(n),而跳表是O(logn)

增加元素

跳表新增元素.png

也是和寻找目标节点一个思路,找到在链表中的新增位置,然后加入新节点。

退化

当链表某两个节点之间的元素越来越多的时候,索引的作用会大大失效

跳表退化.png

解决方式是,通过随机函数算出一个值,比如是k,那就是从第k级索引到第1级索引加上该元素

为什么要用跳表

搜索、新增、删除这些操作,红黑树也能很高效的做到,为什么要用跳表? 因为 跳表的范围查询更高效

当数据量小的时候,会使用压缩列表实现

总结

在什么业务场景选择哪种数据类型,我觉得第一是考虑能否实现需求,能不能选对数据结构就需要熟悉他们的特点

  • List是和时间有关系的场景
  • Set的特点是不重复
  • Sorted Set的特点是有序
  • Hash的功能一般String类型一般都可以实现(序列化与反序列化)

其次就要考虑哪个类型能快速或者能节省内存空间,这要视项目具体情况来考虑,比如身份证:用户id的那个例子。