我个人觉得Redis是学习数据结构的绝佳项目,结合了理论性和实际情况下对于时间和空间的取舍。
一般来说,常见的数据结构有:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树等。之后打算整理几篇文章总结下常用结构的特性。
Redis主要用到的:数组、链表(双向)、散列表、压缩列表、跳表
Redis数据类型原理
Key-Value类型数据库
redishi一个典型的键值数据库,Key是字符串类型,Value有五种类型,分别是:string、list、hash、set、sorted set。 后面有对各类型的解析。
键值对是用散列表存储的
kv对存储在散列表中
散列表保存的kv,用Key 找 Value的复杂度是O(1)
存储结构如图所示:
-
散列表是基于数组实现的
-
散列(hash)函数将Key转化成数组的下标
-
通过数组的下标找到找到存储Value的这块地址是O(1)的,因为:
- 数组是内存中占用的是连续空间,所以拿到数组的起始地址和下标就能计算出下标对应的地址
- target_address = start_address + n * blockSize(n是数组下标,blockSize是数组中每一个元素分配的空间大小)
- 大部分编程语言,数组下标都是从0开始,这样可以省去一次(n-1)的计算
hash冲突
可以看到,刚刚的数组中,存储的元素是dicEntry,它由三个指针组成
key,value都很好理解,但这有一个next指针
因为散列(hash)函数将Key转化成数组的下标,key的数量理论上肯定是远远大于数组长度的,那么一定会出现冲突,也就是说可能多个key的计算结果对应着同一个下标,这叫hash冲突。
那发生hash冲突的下标对应的内存地址,就应该存不止一个元素。dicEntry实际上是一个链表的node:
那么链表上从头向尾去找目标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开始
- key、value都是一个8B的指针,指向RedisObject
- next也是一个8B的指针,指向下一个entry
RedisObject
RedisObject是用来记录一些数据的元信息的,比如最后一次访问时间、被引用次数等
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)
压缩列表是一个加强版的数组
- zlbytes:列表占用内存长度
- zltail:列表尾的偏移量
- zlen:列表中entry个数
这样查询第一个元素和最后一个元素的复杂度是O(1),但是查询其他元素是O(n)。
压缩列表中每一个entry的组成:
-
prev_len:表示前一个entry的长度
-
这也是区别于普通数组的一个地方,数组中为每一个下标对应的空间分配的大小是一样的,比如一个字符串数组,为每一个空间分配了32字节,对于有一些很短的字符串,这就存在了空间浪费
-
这个简化的示例图中可以表现出压缩列表节省空间的能力
-
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级索引
从链表查询元素21时,从1开始,需要寻找8个节点
从顶层索引的1开始,发现目标值21比自己大,往后找到12,发现后一个节点30大于目标值,所以往下找到下一行所有的12,找到20后,往下找到链表中的20,再往后走一个找到目标节点。
总共寻找了6个节点,数据量越大时,缩小的效果越明显
通过数学证明,链表的搜索复杂度是O(n),而跳表是O(logn)
增加元素
也是和寻找目标节点一个思路,找到在链表中的新增位置,然后加入新节点。
退化
当链表某两个节点之间的元素越来越多的时候,索引的作用会大大失效
解决方式是,通过随机函数算出一个值,比如是k,那就是从第k级索引到第1级索引加上该元素
为什么要用跳表
搜索、新增、删除这些操作,红黑树也能很高效的做到,为什么要用跳表? 因为 跳表的范围查询更高效
当数据量小的时候,会使用压缩列表实现
总结
在什么业务场景选择哪种数据类型,我觉得第一是考虑能否实现需求,能不能选对数据结构就需要熟悉他们的特点
- List是和时间有关系的场景
- Set的特点是不重复
- Sorted Set的特点是有序
- Hash的功能一般String类型一般都可以实现(序列化与反序列化)
其次就要考虑哪个类型能快速或者能节省内存空间,这要视项目具体情况来考虑,比如身份证:用户id的那个例子。