文章目录
1. 概念
Redis中保存数据的key-value的value内存的通用结构为:
typedef struct redisobject{
unsigned type:4; // 结构化类型
unsigned encoding:4; // 结构化类型的具体实现方式
unsigned lru:REDIS_LRU_BITS; // 对于长久不访问对象的清理
void *ptr; // 应用计数用于对象向的GC
} robj;
Redis中常用的存储数据的数据结构有5种:
- key-string:一个key对应一个值,一般用于存储单个值
- key-hash:一个key对应一个Map,一般用于存储一个对象数据
- key-list:一个key对应一个列表,一般可用于实现栈或队列结构
- key-set:一个key对应一个无序集合,一般可用于交集、差集和并集等关于集合的操作
- key-zset:一个key对应一个有序集合,一般可用于排行榜、积分存储等操作
另外,还有几种其他的数据结构:
- HyperLogLog:计算近似值
- GEO:地理位置
- BIT:存储一个字符串,本质上存储的是一个byte[]
各种数据结构的示意图如下所示:
2. String
Redis中string除了可以存放字面意义上的字符串外,还可以用于存放整数和浮点数,三种类型之间的转换由Redis负责。内存的存储结构中,int用于存储整型数据,SDS(simple dynamic string)用于存储字节/字符串和浮点型数据。SDS除了可以储存字符串值之外,还用作Redis中的各种缓冲区,如AOF持久化中的AOF缓冲区。
其中sds的结构定义如下:
typedef struct sdshdr{
unsigned int len; // 当前已存储数据的大小
unsigned int free; // 空闲空间,便于扩容
char buf[]; // 存储string内容
};
例如,"hello"在buf[]中的存储形式为:
其中\0的结束符遵从了C语言的规范,可以方便直接使用C语言中关于字符串操作的函数。free表示buf数组空闲空间的大小,len表示buf数组中已经占用的字节数,buf数组用于存储数据。
buf字节数组底层保存的是一系列的二进制数据,它依赖于len的数值判断内容的结束位置。因此,Redis不仅可以用来保存字符串,还能够保存其他类型的数据。
通常认为SDS相关的API都是二进制安全的,依据就是它底层的存储实现。
从保存数据的形式上看,Redis中String类型存储的数据和C语言的字符串类似,为什么还需要创建SDS而不直接使用C中的字符串形式保存呢?
首先,不假思索的给出这两种方式之间的区别,然后逐条分析:
| C字符串 | SDS |
|---|---|
| 获取字符串长度时间复杂度O(N) | 获取字符串长度时间复杂度O(1) |
| 缓冲区可能溢出 | 缓冲区不会溢出 |
| 修改字符串N次,必然会有N次内存重分配操作 | 修改字符串N次,最多N次内存重分配操作 |
| 只能保存文本数据 | 可以保存文本或二进制数据 |
| 可以使用所有<string.h>中的库函数 | 可以使用部分<string.h>中的库函数 |
获取字符串长度
由于C中的字符串并没有记录自身的长度,如果想要判断一个给定字符串的长度,就需要重头遍历所有的字符,直到遇到 \0结束符为止。因此,时间复杂度为O(N)。但是,SDS本身的len字段就保存了数据的长度,获取长度操作的时间复杂度为O(1)。
缓冲区情况
由于C中字符串不记录长度信息,那么在执行拼接等操作时,如果已经分配的空间不足以存放操作后的结果,就会发生缓冲区溢出现象。
SDS在len字段记录了数据的长度,此外在执行操作之前会判断空间是否足够。如果足够,则直接进行相关操作;如果不够,则会先进行扩容,然后才执行操作。
内存重分配次数
C中字符串底层的存储使用的是长度+1的数组,对于字符串的操作会影响底层数组的内存分配情况。具体情况如下:
- 如果操作后结果所需空间超过了已有空间,需要扩展数组空间
- 如果操作后所需空间不足以占满已有空间,需要回收多余的空间
因此,每一次对于字符串的操作都会涉及到一次内存空间的重分配操作。如果频繁的对字符串进行操作,那么由内存重分配所带来的性能开销将很大。
而SDS采用了内存空间预分配和惰性空间释放两种机制,有效的缓解了上述的问题。在涉及到扩容操作时,程序不仅会分配给SDS需要的空间,还会多分配一部分空闲空间,便于之后可能的操作使用。只要后续的操作所需的空间不超过空闲空间,就不会发生内存重分配操作。
具体来说,当buf[]仍有空间时,append操作直接添加到现有的string内容的后面,而且free的部分不会因string长度的变化而改变。但是当现有的buf[]无法存储string内容时,就需要进行扩容操作,触发的条件如下:
- 字符串初始化时,
buf[]的大小为len + 1。当对string的操作完成后预期的长度小于1MB时,扩容的大小等于string预期长度加1,即buf[]长度加倍 - 对于长度大于1MB的string,
buf[]总是留出1MB的free空间,即buf[]以新string长度的2倍进行扩容,但最大留出1MB空间
而惰性空间释放指的是:当操作后所需的空间反而减少时,系统并不会立即回收空闲空间,而是用free字段记录空闲空间大小,便于后续的使用。
3. list
list用于存储string序列,内部使用linkedlist或ziplist存储。当list中元素的个数和单元个数的长度较小时,Redis使用ziplist存储来减少内存空间的使用,否则使用linkedlist存储。
linkedlist的底层实现方式就是双向链表,list中定义了头尾指针和链表的长度,链表中的每个节点都包含前驱指针和后继指针,它是无环的。
typedef struct list{
listNode *head;
listNode *tail;
unsigned long len;
// ...
}
typedef struct listNode{
struct listNode *prev; // 前驱指针
struct listNode *next; // 后继指针
void *value; // 节点值
}
ziplist存储于连续的内存中,结构如下:
<zlbytes><zltial><zllen><entry><entry>...<zlend>
其中:
- zlbytes:ziplist的总长度,即记录整个ziplist占用的内存字节数
- zltail:记录ziplist表尾节点距离ziplist的起始位置有多少字节
- zllen:ziplist中元素的个数
- entry:用于存储元素的内容
- zlend:恒为
0xFF,用于不同ziplist之间的定界符
例如:
ziplist中每个节点由previous_entry_length、encoding、content三部分组成,其中:
- previous_entry_length:记录前一个entry的长度,如果前一个entry的长度小于254个字节,就是用1字节记录;如果长度大于254字节,就使用5字节记录,第一字节为
0xFE(254),后四个字节用于记录长度
如果知道ziplist的起始位置,通过ztail就可以达到ziplist的表尾,借助于每个entry的previous_entry_length就可以实现ziplist的从尾到头的遍历。
- encoding:记录content属性所保存的数据的类型和长度,大小有一字节、两字节或者五字节
如果编码以
00、01、10开头,表示content保存的是字节数组;如果是11开头,表示保存的是整数。
-
content:保存entry节点的值,例如:
4. hash
hash内部使用哈希表hashtable和ziplist存储内容,对于数据量较小的hash使用ziplist,否则使用hashtable。其中hashtable的实现自底向上分为三层,分别为:
- dictEntry:管理一个key-value对,同时保留同一个桶中相邻元素的指针,以此维护哈希桶的内部链
- dictht:维护哈希表所有的桶链
- dict:当dictht需要扩容或是缩容时,用于管理dictht的迁移
其中dictht结构定义如下:
typedef struct dictht{
dicEntry ** table; // 维护哈希桶,指向桶中的第一个dictEntry
unsigned long size; // 桶的个数
unsigned long sizemask; // 恒等于size - 1,用于快速得到哈希值的模
unsigned long used; // 已用的桶的个数
};
typedef struct dictEntry{
void *key; // 键
// 值
union{
void *val;
uint64_t u64;
int64_t s64;
}
// next指针
struct dictEntry * next;
} dictEntry;
结构存储示意图如下所示:
当有一个新key到达时,首先通过哈希算法(MurmurHash2算法)计算得到它对应的哈希值h,然后对size字段取模得到它对应的桶。进入对应的桶遍历全部的entry,判断是否存在相同的key。如果没有,则将其插入到桶头,并且更新dictht的used数量;如果有,则覆盖更新。
这里hashtable的实现方式和Jdk 1.7 中的HashMap的实现是类似的。
那Redis中的hash如何解决可能出现的key冲突问题呢?这里使用的也是链地址法,而且插入构建链表时使用头插法。
当桶中的entry很多时,遍历全部的entry寻找key的效率将变得很低,需要增加桶的数量来减少单个桶中entry的数量。hash这里同样使用了负载因子来决定是否需要扩容,负载因子等于已有元素和哈希桶的比值,具体扩容规则如下:
- 小于1时一定不扩容
- 大于5时一定扩容
- 1和5之间时,如果Redis没有进行
BGSAVE、BGREWRITEAOF操作时,则会扩容
当负载因子到达0.1时,Redis将对hash进行缩容,减少空闲桶的数量。扩容操作后新的桶的数量为现有桶的2n倍,缩容后桶的数量为现有桶的0.5n。另外,扩容和缩容都是通过新建hash表来实现的,扩容和缩容的过程中新旧两张表都是可以访问的,hashtable中的dict用于管理旧表到新表数据的迁移过程。如果迁移的过程中发生访问请求,首先回去访问旧表,如果发现对应的key所有的桶已经发生了迁移,则重新访问新表,否则继续在旧表操作。
其中dict的定义如下:
typedef struct dict{
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表
int trehashidx; // rehash索引
}
rehash的过程如下:
不管是扩容操作还是缩容操作,都是将ht[0]中所有的键值对rehash到ht[1]中,而且这里采用了渐进式rehash的方法实现数据的迁移。每次对一个键值对执行一次rehash操作,trehashidx值增1,当rehash全部完成后,trehashidx值设置为-1。
5.set
set用于存储不重复的数据,但是数据之间是无序的,set内部依赖于hashtable和intset(整数集合)实现。其中,当set中只有整型数据时,内部使用intset实现;否则,使用hashtable实现,而且它只利用了hashtable的key来保证数据的不重复,value永远为null。
hashtable和上面hash中hashtable的实现方式没有太大的差别,下面着重看一下intset的实现。intset核心就是一个字节数组,其中从小到大有序的存放着set的数据。其中数据的类型可以是int16_t、int32_t、int64_t,结构定义如下:
typedef struct intset{
uint32_t encoding; // 元素的编码方式,即一个元素占用多少个contents数组
uint32_t length; // set中元素的个数
int8_t contents[]; // 存储元素内容
};
例如:
由于intset使用数组的方式实现,因此,key的查询过程可以使用二分查找。另外,intset为了进一步提升查找的效率,规定存储在contents数组中的元素应该是定长的,即占用相同数量的格子。int8_t对应的大小范围是[-128, 127],如果set中元素的值都在这个范围内,那么使用一个content为存储就够了。如果某时插入的元素大小超过了127,那么需要使用最大需要的字节数进行存储。此时,intset中已有元素的大小也会升级到相同的字节数。
例如,起始intset中的1、3、4只使用一个content位,当插入32767时,它需要2个字节才能存放。那么除了使用两个字节存放32767外,已有的1、3、4也需要升级为使用2个content存放。
这种升级的机制具有两个好处:
- 提升整数集合的灵活性:不同类型的int型数据都可以存放,
- 尽可能的节约内存:尽量使用满足要求的内存空间大小即可,不满足要求时才升级
此外,intset是不支持降级机制的,如上所示,即使32767被删除了,1、3、4所使用的空间大小也不会变成之前的大小。
6. sorted-set
soert-set是一个类似于hash有序的key-value对,其中key为键,它在sorted-set中不重复;value是一个称为score的浮点数,sorted-set内部就是按照score的大小进行排序的。
sorted-set的value内部使用ziplist或是hashtable+skiplist实现。ziplist和hash中使用类似,不同之处在于ziplist中的entry之间按照value进行递增排序。当有新元素插入时,ziplist都需要移动排在新元素之后的元素。它适用于元素个数不多,且元素的内容变化不大的场景。
跳表(skiplist)的结构定义如下:
typedef struct skiplist{
struct zskiplistNode * header, *tail;
unsigned long length;
int level;
} zskiplist;
结构图如下所示:
如上图所示,此时skiplist中存储了5、10、20、30四个元素。假设想要查key=30的元素,首先从Head指针数组里最顶层的指针所指的20进行比较,发现30比20要大,则查找就是从20开始往后查找。从查找的过程中可以看出,skiplist跳过了5和10,直接和顶层的20进行比较,避免了向普通链表一样从头往后依次查找。
向skiplist中插入新元素时,由于元素所在的层级的随机性,此时的时间复杂度为O(logn)。例如,在上图所表示的skiplist中插入28,插入的过程示意图如下所示:
skiplist中元素的删除过程为:首先查找要删除的元素,找到后进行指针的移动,进行删除操作。如下所示:
sorted-set在使用skiplist的实现中,Redis为每一个层级的对象都增加span字段,用于表示该层级指向的forward节点和当前节点的距离。结构定义如下所示: