书接上回Redis学习开篇概述,本篇开始理解Redis数据结构的设计。
Redis作为简单高效的数据库,其数据结构是使得其高效的关键。
理解Redis数据类型
Redis数据库里面的每个键值对(key-value pair)都是由对象(object)组成的,其中key总是一个字符串对象(string object),而value的值则可以是字符串对象、列表对象(list object)、哈希对象(hash object)、集合对象(set object)、有序集合对象(sorted set object)等……
目前官方文档显示支持的数据类型已经有13种。
字符串 String
Redis追求字符串的安全性、效率以及功能方面的要求。
Redis的字符串可以是各种类型字符串(包括二进制数据、序列化对象)。值的大小不能超过512MB。
Redis自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。
官网文档➡️sds
sds结构 sds.c 如下:
struct sdshdr {
long len;
long free;
char buf[];
};
buf 字符数组存储实际的字符串。
len 字段存储 buf 的长度。这使得获取 Redis 字符串的长度是一个 O(1)操作。
free 字段存储可用于使用的额外字节数。
len 和 free 字段可以看作是存储 buf 字符数组的元数据。
SDS和传统C语言定义的字符串(以空字符结尾的字符数组)有哪些不同呢?
多了长度信息len与未使用空间与其说明属性free。
定义长度信息
- 获取长度信息的时间复杂度降低
根据传统,C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符’\0’。
C字符串并不记录自身的长度信息,获取长度时需要遍历整个字符数组至空字符以获取长度。时间复杂度为O(n)。 - 长度信息解决缓冲区溢出问题
已知长度则可以在操作字符串前检查是否有足够的内存。即可以依据长度分配内存。
定义未使用空间
SDS的buf[]字符串数组存在未使用的空间,buf[]的长度不一定就是字符数量加一,数组里面可以包含未使用的字节。属性free值说明未使用空间长度。
Redis需求频繁修改数据,每次修改字符串都需要重新分配内存,频繁内存重分配带来时间占用影响性能。
SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联。(这也能解耦的啊)
具体实现有"空间预分配"和"惰性空间释放"两种优化策略。用于解决“频繁内存重分配”,对应分配内存或释放情况。
- 空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。
通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
- 惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
总结
定义长度是现在的数据类型其都会做的事情,空间预分配在Java也有基础的HashMap扩容。
惰性释放则提供了一种基于频繁分配空间的设计思路。它延迟释放操作,以适应可能出现的扩容需求。
链表 List
虽然在各个网站都翻译是列表,但是从其官网介绍来看,和《设计与实现》一书中一样翻译为链表更符合Redis本意。
Redis 列表是字符串值的链表。Redis 列表常用于:
- 实现栈和队列
- 为后台工作系统构建队列管理。
Redis链表是一个双端链表,和我们学习数据结构了解到的链表差不多。
字典 Hash
字典,又称为符号表(symbol table)、关联数组(associativearray)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。
字典经常作为一种数据结构内置在很多高级编程语言里面,但Redis所使用的C语言并没有内置这种数据结构,因此Redis构建了自己的字典实现。
Redis使用Hash Table来作为字典的底层实现。可以使用散列来表示基本对象,以及存储计数器等分组。
-
Redis的Hash冲突
和其他的Hash作为底层的数据结构一样,Redis的Hash也需要解决Hash冲突。
Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。 -
扩展与收缩-rehash
Redis的扩展是条件为:
1)服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
2)服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
与Redis String类似的思路,Redis Hash也提供了未使用空间。不同于String是在字符串数组中预留,Redis Hash在哈希表的槽位数组中预留空间(ht[0]、ht[1]),并通过渐进式 rehash 动态调整大小。
执行rehash操作时,流程为确定新大小、分配新表、逐步迁移键(ht[0]迁移到ht[1])、完成替换(释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表)。是一个渐进的过程。
附书上图例以理解ht[0] ht[1]。
总结
Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
Redis Hash较Java的Hash Map,其大小可以做到扩展与收缩,Java Hash只实现了扩展。
Java HashMap没有主动收缩机制,避免了频繁调整带来的性能开销,虽然会导致内存浪费。
Redis设计了预留槽位则适合Redis频繁修改数据场景,可以减少内存分配开销。且Redis Hash采用渐进式的rehash,将调整过程分摊到多次操作中,使单次操作的时间开销更低、更稳定。
这两者设计的差异来源于设计目标和使用场景与运行环境的差异。
Redis Hash追求高并发(实时性)、低延迟和内存效率。其读写频繁,需要避免阻塞,因此使用预留空间、槽位的方式减少频繁扩容的概率。但内存资源宝贵,尽管使用了“空间换时间”的理念,还是要尽可能的节省内存,做动态调整。因此有这样的Hash设计。
而Java HashMap追求单机环境下的简单性和性能。内存回收又GC进行统一管理,无需过于复杂的动态调整。更关注插入和查询的平均性能,而非实时性。