Redis有6种基本数据结构:
- 简单动态字符串(SDS)
- 双向链表(linkedlist/quicklist)
- 哈希表 (hash)
- 跳表(skiplist)(重点关注,面试常问)
- 整数列表(intset)
- 压缩列表(ziplist)
- radix-tree (Stream会用到)
一、简单动态字符串(SDS)
1、数据结构
Redis中所有键的类型都是SDS,值可能是不同类型,当然常见的值类型是SDS。
(图片来源:极客时间Redis专栏)
如:RPUSH zoo "dog" "panda" "cat"
Key: zoo 是 字符串对象类型,value :dog、panda、cat都是字符串对象类型。
SDS结构定义和数据结构如下:
- free:分配未使用的空间;
- len:分配已使用的空间大小
- buf:char类型的数组
注:新的版本中SDS结构已调整:
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; // 已使用的空间大小
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[]; // 字节数组 保存字符串
};
2、与C字符串区别(为什么一个字符串要定义成这样的数据结构?)
-
获取字符串长度由O(n)变为O(1),直接读取len字段即可知道(Redis关心性能);
-
空间分配时杜绝缓存溢出:Redis在存储数据之前会检查SDS空间是否足够,不够会先扩展空间然后再进行拼接;
Redis在分配内存时,会额外多分配一些内存。如:当SDS长度小于1M时,会再多分配len大小的未使用空间,buf数组长度就是为 2*len +1;当SDS长度大于1M时,会多分配1M。通过这种方法,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
二、双向链表 linkedlist
Redis中的链表结构是由 list结构 + 双向链表结点 组成的。
Redis中链表特点如下:
- 双端:每个链表结点都有prev和next指针,获取某个节点的前、后置节点时间复杂度都是O(1);
- 表头指针和表尾指针:通过head和tail获取链表头、尾节点复杂度都是O(1);
- len:获取链表节点长度
注:3.2版本之后使用quicklist代替了linkedlist,原因是linkedlist的前后指针太占用空间。
quicklist 将list按段切分,每一段是ziplist,通过指针连接。如果ziplist过大,会进一步压缩。
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl; // 保存的数据 指向ziplist
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* ziplist数量 */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* 所有ziplists中元素数量 */
unsigned long len; /*quicklistNodes 个数 */
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
三、哈希表 - hash table
1、hash table的数据结构
Redis中字典dict 使用哈希表dictht(Table + Entry数组)来实现。(字典可以理解为管理哈希表rehash时使用,重点理解哈希表就可以)
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 哈希表,只会用ht[0], ht[1]用于rehash
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
/* 每个字典会有2个hash表的结构,为rehashing的时候使用。 */
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
// 哈希表中的entry节点
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
Redis的字典dictionary定义了两个哈希表,一般只会使用ht[0]哈希表,ht[1]哈希表在rehash时使用。
(1)哈希算法 - 添加元素过程
- 首先根据key算出哈希值: hash
- 再算出在哈希桶中的位置:hash & ht[0].sizemark。(sizemark=size - 1)
(2)解决键冲突
Redis哈希表使用链地址法来解决键冲突,新节点会被放在链表的头部。
(3)rehash (渐进式hash)
当哈希表负载因子过大、键值对数量过多或过少时,Redis都会调整哈希表,建立一个2倍已使用内存的大小:Redis会为ht[1]分配 >= ht[0].used * 2 的2^n空间、
但是rehash且复制元素的过程比较耗时,会造成Redis线程阻塞,无法处理其他请求。
Redis采用渐进式的思想避免rehash对服务器性能造成影响,即:
- 查找、更新、删除都是先在ht[0]哈希表上操作,ht[0]上没有再到ht[1]上操作;
- 新增元素只会在ht[1]上操作;
- 在这期间会逐渐将ht[0]的键值rehash,再复制到ht[1]上。
注:哈希表rehash过程源码在文章末尾。
四、跳表(skiplist)
跳跃表是一种有序数据结构,通过空间换时间的思想在链表的基础上增加了多级索引,通过索引可以快速定位到别的节点的数据,时间复杂度为O(logN) ~ O(N).
跳跃表主要是 有序集合(Sorted Set - Zset)中使用。
// 跳表
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
// 跳表节点
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
// Zset数据结构使用hash表和跳表两种数据结构
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
1、跳跃表数据结构
跳跃表由zskiplist (跳跃表)和 zskiplistNode (跳跃表节点)两个组成。
zskiplist用于保存跳跃表信息(表头节点、表尾节点、长度),zskiplistNode用于表示跳跃表节点。
zskiplist : 可以快速访问跳跃表头节点和尾节点,获取跳跃表节点数量。
- header:指向跳跃表的表头节点;
- tail:指向跳跃表的表尾节点;
- level:记录目前跳跃表节点中层数最大的节点(1-32之间,表头节点不算);
- length:跳跃表节点个数(表头节点不算)
zskipListNode:
-
层(level):节点中用L1、L2…表示。每个层有两个属性:前进指针和跨度。前进指针是指向下一个层数相同的节点,数字代表跨度;
-
后退指针(backward):指向当前节点前一个节点;
-
分值(score):节点中按分值从小到大排序;
-
成员对象(ele):各个节点中o1、o2是节点保存的成员对象。成员对象是一个指针,最终指向的是一个SDS字符串对象。
分值可以相同,但是成员对象必须是唯一的。分值相同按成员对象在字典中的大小来排序。
Redis为什么用跳表而不用红黑树?
- 跳表查询效率Ologn
- 跳表可以在Ologn时间范围内进行区间查询,效率比红黑树高
- 跳表代码实现起来没有红黑树简单点
Redis跳表介绍:
https://redisbook.readthedocs.io/en/latest/internal-datastruct/skiplist.html
五、整数集合(intset)
整数集合是集合键(set)底层实现之一,保证集合中元素不重复、数组元素按从小到大排列、可保存16(short)、32(int)、64(long)大小的数字。
根据encoding决定存储的是16还是32还是64字节的整数。
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
1、升级规则
当添加新元素时,如果新元素类型比现有元素类型都要长,则进行升级:先分配空间对底层元素进行空间重分配,然后将原有元素包括新添加的元素放入到新的空间中,且需要保持顺序不变。
如:假设原集合保存了3个int_16的元素,则占用空间为3*16=48位。当新添加一个int-32的元素时,就需要升级,先重新分配空间:4 * 32 = 128位,然后再逐渐将元素移动到新位置上,最后将整数集合encoding改成int_32.
升级可以提升灵活性,不用担心3个元素类型不一样带来的出错,也可以节约内存(一开始可以是int_16,需要的时候才会升级)。
六、压缩列表( ziplist)
压缩列表时列表键和集合键底层实现之一,可以理解成是数组,只是首尾存储了列表长度、个数等。存放较小元素或整数值时使用。查找首尾节点时间复杂度为O(1),查找其他元素复杂度为O(N)
数据结构
1、压缩列表可包含任意多个节点,每个节点保存一个字节数组或整数值。
- zlbytes:压缩列表总长
- zltail:到表尾节点entry5的距离;
- zllen:列表节点个数;
- entry:压缩列表节点;
- zlend:末尾标志位;
2、压缩列表节点
压缩列表节点entry可以保存字节数组或者整数值。
- previous_entry_length:前一个节点长度;
- encoding:记录content数据类型,如11000000代表 int16类型整数。
- content:可以是字节数组’hello world’ 或者是整数。
哈希表rehash的源码
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
// 确定 哈希表ht[0]有数据:used != 0
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
// rehashidx: 找到哈希桶中第1个元素不为null的位置
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
// de:需要拷贝的哈希桶位置的首节点
de = d->ht[0].table[d->rehashidx];
while(de) {
uint64_t h;
// 先保存下个节点
nextde = de->next;
// 计算待拷贝的该节点de在新哈希表ht[1]在的hash值
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 放到哈希桶的头位置,指向已经存在的节点
de->next = d->ht[1].table[h];
// 赋值
d->ht[1].table[h] = de;
// 更新ht新、老表已使用内存值
d->ht[0].used--;
d->ht[1].used++;
// 循环复制哈希桶中的下个结点
de = nextde;
}
// 复制完的哈希桶置手动释放内存,rehashidx+1 指向下个哈希桶
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* 老表的used=0时表示完成扩容,将ht[1]的地址赋值给ht[0] */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}