Redis的数据类型总结
Redis数据库里面的每一个键值对(key-value pair)都是对象(object)组成的,其中:
数据库键总是一个对象(string object);
而数据库的值则可以是字符串对象、列表对象(list object)、哈希对象(hash object)、集合对象(set object)、有序集合对象(sorted set object)这五种对象其中的一种。
Redis中的数据类型所使用的底层数据结构
String | List | Hash | Set | Sorted Set |
---|---|---|---|---|
简单动态字符串(SDS) | 双向链表、压缩列表 | 压缩列表、哈希表 | 哈希表、整数集合 | 压缩列表、跳表 |
简单动态字符串(SDS)
c语言定义方法:
struct sdshdr {
int len; // 记录buf数组中已使用字节的数量
int free; // 记录buf数组中未使用字节的数量
char buf[]; // 字节数组,用于保存字符串
};
SDS遵循C字符串以空字符串结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间(如Redis字符串保存在数组中是’R’,‘e’,‘d’,‘i’,‘s’,’\0’)。添加空字符到字符串末尾是完全透明的。遵循空字符串结尾这一惯例的好处是:SDS可以重用c字符串库里面的函数。
比起C字符串,SDS具有的特点:
- O(1)时间复杂度获取字符串长度,程序只需要访问len属性,就可以立即获取到字符串长度。
- 拒绝缓冲区溢出(buffer overflow), 当SDS API需要对SDS进行修改时,API会先检查SDS空间
- 减少修改字符串时带来的内存重分配次数
- 二进制安全:Redis使用buf属性保存的是一系列二进制数据。
- 兼容部分C字符串函数。
链表
链表节点listNode的表示方法:
typedef struct listNode {
//表头节点
struct listNode *prev;
struct listNode *next;
viod *value;
}listNode;
链表的实现方法:
type struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
//节点复制函数
void *(*dup) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
// 节点值对比函数
int (*match) (void *opt, void *key);
} list;
链表中需要了解的:
- 链表被广泛用于列表键、发布与订阅、慢查询、监视器等。
- 每个链表节点由一个listNode结构来表示,每个节点都有一个前驱和后继,因此Redis的链表实现是双端队列。
- 每个链表使用一个list结构来表示,这个结构带有表头指针和表尾指针以及链表长度等信息。
哈希表(字典)
哈希表的定义如下:
typedef struct dictht {
// 哈希表数组
dicctEntry **table;
// 哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//由于索引从0开始,总是等于size - 1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
} dictht;
哈希表节点的定义如下:
typedef struct dictEntry {
void *key;
union {
void *val;
unit64_t u64;
int64_t s64;
} v;
// 指向下一个节点,形成链表
struct dictEntry *next;
} dictEntry;
问1:为什么会有next属性?
答:next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,从而解决键冲突(hash collision)的问题。
问2:那讲讲解决哈希冲突的常用办法?
答:解决哈希冲突的方法有两大类。
一是上文提到的链表法:当插入的我们通过哈希函数(也称散列函数)计算出对应的哈希值相同时,只需要将其插入到链表的末尾。
二是使用开放寻址法:如果出现了哈希冲突,我们就重新探测一个空闲的位置,探测的方法也五花八门,有线性探测(从当前位置继续向后找,直到找到一个空闲的位置)。还有多此哈希等等。
关于哈希表中的哈希算法,Redis使用的是MurmurHash2算法来计算键的哈希值。
随着哈希表中写入更多的数据时,哈希冲突会越来越多,进而导致从链中查找元素的查找时间耗时长,效率降低。显然高性能的Redis是不能忍受这样的事件复杂度的退化的。为了让哈希表的负载因子(load factor)在一个合理的范围内,Redis会对哈希表做rehash操作。Rehash操作就是同时使用两个全局哈希表。这里分别称其为哈希表1和哈希表2。一开始插入数据时,默认使用哈希表1,此时的哈希表2并没有被分配空间。随着数据的增多,Redis开始执行Rehash操作。
- 给哈希表2分配一个更大的空间,例如是当前哈希表1的两倍大小。
- 在哈希表中维持一个索引计数器变量rehashidx , 并将其值设置为0,表示rehash工作正式开始。
- 在rehash的过程中,每次处理一个客户端请求时,就从rehashidx上面所有的键值对移动到哈希表2,当本次rehash工作完成后,程序将rehashidx属性的值增加1。
- 随着不断的执行,在最终哈希表1中的所有键值对都被rehash到哈希表2后,程序会将rehashidx属性的值设置为-1。
渐近式rehash的好处在于巧妙地把一次性大量拷贝的开销,分摊到了多次请求处理的过程中,避免了耗时操作,保证了数据的快速访问。
跳表(skiplist)
跳跃表是有序集合的实现之一,也是集群节点中用作内部数据结构,它通过在每一个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
网上对跳表的理论讲解和各类代码实现都比较多,这里引用简书上面的一篇文章https://www.jianshu.com/p/dc252b5efca6。其基本原理为添加多级索引来加快查找速度实现O(logN)的时间复杂度, 通过随机函数确定节点插入到几级索引中来防止跳表退化为单链表。
整数集合(intset)
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
typedef struct intset {
// 编码方式
unit32_t encoding;
// 集合包含的元素数量
unit32_t length;
// 保存元素的数组
int8_t contents[];
} inset;
其中contents数组是整数集合的底层实现,整数集合的每一个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到达有序排列,不包括任何重复项。其中encoding属性决定了contents数组中的类型。当添加一个新的元素到整数集合中,并且新元素的类型比整数集合现有所有的类型都要长时,整数数组会先进行升级(upgrade),才能将新元素添加到整数集合里面去。但是整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
知识点整理:
- 整数集合是集合键的底层实现之一。
- 整数集合的底层实现是数组,这个数组以有序、无重复的方式保存了集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
- 升级操作作为整数集合带来了操作的灵活性,并且尽可能地节约了内存。
- 整数集合只支持升级操作,不支持降级。
压缩列表(ziplist)
压缩列表是Redis为了节约内存而开发的,
- 当每一个列表项或当一个哈希键只包含少量的键值对,且每个列表项要么是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表做列表键的底层实现。
redis> RPUSH lst 1 3 4 1032 "hello" "redis"
(integer)6
redis> OBJECT ENCODING lst
"ziplist"
redis> HMSET profile "name" "Jake" "age" 18 "job" "student"
OK
redis> OBJECT ENCODING profile
"ziplist"
压缩列表是由一系列连续内存块组成的顺序型(sequential)数据结构。
zlbytes | zltail | zllen | entry1 | entry2 | … | entryN | zlend |
---|---|---|---|---|---|---|---|
记录整个列表占用的字节数 | 记录ziplist表尾节点距离起始节点有多少字节 | 压缩列表包含的节点数量 | 列表节点1 | 列表节点2 | 列表节点N | 标记压缩列表的末端。OxFF |
压缩列表总结:
- 压缩列表是为了节约内存而开发的顺序型数据结构
- 压缩列表被用作列表键和哈希键的底层实现之一
- 添加新的节点到压缩列表,或者从压缩列表中删除某个节点,可能会引发连锁更新操作,但这种操作出现的机率不高。
最后思考
整数数组和压缩列表在查找时间复杂度方面并没有太大优势,为什么还会将它们作为底层数据结构呢?
答:两方面原因:
1、内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
2、数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。
参考文章:
Redis的设计与实现
极客时间专栏 https://time.geekbang.org/column/article/268253
极客时间专栏评论区@Kaito https://time.geekbang.org/discuss/detail/239388