本文多参照小林图解 xiaolincoding.com/redis/data_…
redis为什么速度比较快,包含以下原因:
- 数据存放在内存中,读取速度快
- 使用单线程加上NIO的模式读写数据,网络性能好,而且还避免了上下文切换的消耗
- 为每种数据类型都制定了新的数据结构,注重节省内存空间和存取效率
下面是redis针对每种数据类型创造的底层数据结构(图片大多来自于参考链接)
redis主要包含5种数据类型,String,Hash,List,Set,Zset,每种数据类型在不同情况下都会使用不同的数据结构,下面单个介绍每种数据类型:
String:只采用SDS(简单动态字符串)的数据类型,C语言中的string类型都是以\0作为结尾分隔符,当指针读取到\0时就代表字符串结束。但是这也导致以下问题:
- 查询字符串长度的时候需要遍历整个字符串,时间复杂度为O(n);
- 字符串中不能出现
\0字符,因此只能保存ascii编码的字符,不能保存音视频等二进制数据; - 当字符串进行拼接操作时,会因为字符串长度不够而导致溢出问题;
为了解决以上问题,redis自定义了字符串类型SDS,结构设计如下:
- len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂为O(1)。
- alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过
alloc - len计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。 - flags,用来表示不同类型的 SDS。表示sds的数据类型
- buf[],字符数组,用来保存实际数据。没有了获取长度的限制,所以不仅可以保存字符串,也可以保存二进制数据。而且为了兼容字符串,也同样以
\0结束。 数据结构优点: - SDS的扩容规则:上面说到,当修改字符串导致alloc不够时,会扩展空间。具体的操作是:当SDS的大小小于1M时,成倍扩容,当SDS的大小大于1M时,就增加1M。
- SDS的预分配空间:因为预分配的字符串会大于写入的长度
alloc > len,所以当字符串拼接时,计算出需要拼接的长度小于alloc - len时,不需要对字符串进行扩容,减少重新分配空间的消耗。 - 惰性空间释放:用于优化SDS的字符串缩短操作。当SDS缩短时,程序并不会立即回收缩短后多出来的空间,而是使用free属性将这些字节的数量记录起来,等待将来使用。
List:采用ziplist(压缩列表)和链表两种方式,其中Hash,Zset在数据量比较少的前提下,也会使用ziplist(压缩列表)
redis中的链表采用双向链表,结构如下:
链表优点:
- list结构不仅保存头尾指针,而且还添加了节点个数。这样查询头尾节点和个数的时间复杂度都是O(1)。
- 查询前后节点快,而且更新节点值不影响其他节点 链表缺点:每个链表节点都需要前后两个指针,造成了不必要的浪费,对于存储整数类型数据的节点尤为明显,存储数据可能只需要16位,但存储前后指针需要8个字节(32位地址空间),浪费了三分之二的空间。所以为了节省内存,redis又设计了一种ziplist(压缩列表的)的结构来存储数据量比较小的集合
ziplist(压缩列表):压缩列表只有在数据量比较少的情况下才会使用,主要是为了解决链表前后指针浪费的情况,结构如下:
- zlbytes,记录整个压缩列表占用占用的字节数
- zltail,记录压缩列表尾节点距离起始地址的长度
- zllen,记录压缩列表包含的节点数量
- zlend,标记压缩列表的结束点
- entry, 压缩列表中的每个元素三部分组成
- prevlen,前一个节点占用的字节数
- encoding,当前节点的类型
- data,当前节点的数据
ziplist(压缩列表)缺点:由于每个节点都记录的是前一个节点的字节长度,所以当一个节点长度变化时,下一个节点的prevlen长度也有可能发生变化,导致下个节点整个占用的字节数发生。导致连锁更新的问题。
最坏的情况下,当头节点发生变化,其后的所有节点都发生变化。所以为了保持ziplist的优点,同时解决连锁更新的问题,发明了listpack结构。
listpack:导致连锁更新的原因是因为节点保存了上一个节点的长度,导致上一个节点发生变化时。所以listpack不在存储prevlen,而改成了存储当前节点的长度。listpack结构如下:
在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表
quicklist(快速列表):
quicklist:是一种结合链表和压缩列表的思想,链表中的node节点不再是简单的data,而是一个压缩列表。
当新增一个新的节点时,会先在压缩列表节点中新增,如果空间不够,再新建一个压缩列表节点。
优点:这样可以有效减少无效指针的浪费。当然压缩列表存在的连锁更新问题,也可以将压缩列表替换成listpack而解决。
list应用场景:存储集合对象,实现消息队列
消息队列:redis中的list可以使用LPUSH + RPOP (或者RPUSH+LPOP)命令完成FIFO的操作,但是使用redis完成消息队列需要解决。①消息取出后list中就没有该元素了,无法保证消息消费失败后的重试;②重复消息的问题,redis没有为集合中的元素生成唯一id,所以如果将list用作消息队列需要添加唯一id,判断消息是否已经消费了。
Hash:采用ziplist(压缩列表)和哈希表两种方式
redis的哈希表与java的Hashmap的结构基本相同,都来自于数据结构中的哈希表,结构设计如下:
哈希表是数组与链表的组合。首先通过哈希函数定位到数组的某一个位置(大多数采用取模法),然后将元素放到对应位置上。如果出现冲突(例如取模得到值相同),则使用链接地址法拼接到已有元素的后面。理想情况下,哈希表的时间复杂度可以达到O(1)。
redis本身的就是一个大型的哈希表,所以可以快速的根据key定位元素
当Hash表中每个数组后面都链接了大量的元素怎么办?
- 这就需要对哈希表扩容,将Hash数组的长度扩大一倍,但是伴随而来的就是元素放在新的位置上。
redis渐进式rehash完成哈希扩容,过程如下:
- 首先创建两个哈希表,平时只用哈希表1,当需要进行哈希扩容时,分情况用到哈希表2
- 如果时新增,则直接放入哈希表2。
- 如果是查询,则先查询哈希表2,如果没有,则查询哈希表1,然后将查到对象放入哈希表2中,然后删除1中的元素。
- 如果是删除,则先查询哈希表2,如果有直接删除。如果没有去哈希表1删除。
- 如果是修改,则先查询哈希表2,如果有直接更新,如果没有则去哈希表1删除,并将新值在哈希表2中新增。 这样将rehash融入到每一次更新和查询中,不影响用户使用。
Hash应用场景:一般对象使用都会使用序列化后的json存储,但是想修改一些比较频繁的信息,例如购物车等就可以用到Hash
Set:采用intset(整数集合)和哈希表两种方式,当存储的数据都是整数的时候就会使用intset方式,所以只说整数集合。整数集合采用连续的存储空间结构如下:
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
其中:encoding是编码方式,包含int16_t,int32_t,int64_t三种类型,每种类型占用的内存也不同,为什么要这样?
- 因为这样做当保存int16_t类型的数据时,每个字节只需要16位即可,节省内存空间。 如果一个int16_t的类型的集合插入了一个32位的数据时该怎么办?
- 这时候会进行集合升级操作,即将int16_t类型的数组转换为int32_t类型的数组,具体步骤如下
1)根据新元素的类型,扩展整数集合底层数组的空间大。如图分配
4*32-3*16个空间。
2)将底层数组现有的所有元素都转换成与32位的类型,并按照倒叙移动到指定位置上。
3)将新元素添加到底层数组里面
set的使用场景:主要是为了解决去重的问题,包括点赞,抽奖等。
Zset:又称Sorted Set(简称Zset)意为有序集合,Zset主要使用两种数据结构,一种是ziplist(压缩列表),一种是skiplist(跳表),当Zset键值对的元素数小于128,而且值的长度小于64的时候,就会使用ziplist,否则使用skiplist,skiplist是一种结合dict与链表的结构,结构如下:
为什么不使用List\红黑树或平衡二叉树实现Zset呢?
- List是顺序存储,访问速度很快,但是添加和删除操作是一个(On)的操作,对于Redis这样要求高写入和高读取的数据库来说,List显然不能满足其要求。
- 红黑树和平衡二叉树,每次更新redis的值,都要调整树结构,这样会造成额外的开销,而跳跃表只需要调整表局部的链表结构就行,显然跳跃表更适合。
- 最后:跳跃表实现起来更简单,不像树结构这么繁琐
Zset的使用场景:
- top热点排名查询,根据贡献度,热点程序查询数据
- 延迟队列,当存在订单过期失效或者提前10分钟响应事件等问题时,可以使用Zset的特性完成延迟队列功能