redis 常用的数据结构有以下五种:
- String
- List
- Hash
- Set
- Sorted Set
之前简单的介绍了 String 实现原理 SDS,本篇我打算从全局层面一起介绍:
无论什么类型的数据结构,在 Redis 中都可以通过 RedisObject 来表示,该类型主要包含以下三个属性:
- type:类型,对应上面提到五种数据类型,如:REDIS_STRING、REDIS_LIST 等等
- encoding:编码格式
- ptr:指针,指向具体的数据类型
Redis 中包含 incr 加一操作,也就是说可以存储 int 类型数据,然而实际使用时都使用 String 字符串来存储,此时实际就是通过 encoding 属性标记它为 int 类型数据,它的常见属性值如下:
#define REDIS_ENCODING_RAW 0 // 编码为字符串
#define REDIS_ENCODING_INT 1 // 编码为整数
#define REDIS_ENCODING_HT 2 // 编码为哈希表
#define REDIS_ENCODING_ZIPMAP 3 // 编码为 zipmap
#define REDIS_ENCODING_LINKEDLIST 4 // 编码为双端链表
#define REDIS_ENCODING_ZIPLIST 5 // 编码为压缩列表
#define REDIS_ENCODING_INTSET 6 // 编码为整数集合
#define REDIS_ENCODING_SKIPLIST 7 // 编码为跳跃表
实际通过 type 和 encoding 基本就可以确定 ptr 指针指向的数据类型,数据类型主要包含以下几种:SDS(简单动态字符串)、HashTable(哈希表、字典)、LinkedList(双端链表)、ZipList(压缩列表)、IntSet(整数集合)、SkipList(跳跃表)
SkipList 不只包含跳跃表,它实际包含一个字典和一个跳跃表,这点下面再细说
类型 + 编码和数据类型的对应关系如下图所示:
简单画个图总结一下:
一般情况下,ZipList 压缩列表总是在集合长度小于 512,元素长度都小于 64 字节时使用(ZSet 例外,长度大于 128 就用 SkipList)
有了上面的介绍,下面分别来看不同数据结构的源码:
SDS:
struct sdshdr{
int len;/*字符串长度*/
int free;/*未使用的字节长度*/
char buf[];/*保存字符串的字节数组*/
}
和传统的 c 语言字符数组相比,SDS 具有以下优势:
- O(1) 获得字符数组长度
- 避免缓冲区溢出
- 内存预分配、惰性释放
- 可以存二进制数据
- 可以复用一部分 C 字符数组
ZipList:
压缩列表,本质上就是一个字节数组,是 Redis 为了节约内存而设计的一种线性数据结构,可以包含任意多个元素,每个元素可以是一个字节数组或一个整数
- zlbytes:压缩列表的字节长度,占 4 个字节,因此压缩列表最长 ( 2 ^ 32 ) - 1 字节
- zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量,占 4 个字节
- zllen:压缩列表的元素数目,占两个字节
- entryX:压缩列表存储的若干个元素,可以为字节数组或者整数
- zlend:压缩列表的结尾,占一个字节,恒为0xFF
HashTable:
Redis HashTable 与 java 中的 hashMap 类似,都是数组 + 链表,它的源码如下:
typedf struct dict{
dictType *type;//类型特定函数,包括一些自定义函数,这些函数使得key和value能够存储
void *private;//私有数据
dictht ht[2];//两张hash表
int rehashidx;//rehash索引,字典没有进行rehash时,此值为-1
unsigned long iterators; //正在迭代的迭代器数量
}dict;
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}dictht;
typedf struct dictEntry{
void *key;//键
union{
void val;
unit64_t u64;
int64_t s64;
double d;
}v;//值
struct dictEntry *next;//指向下一个节点的指针
}dictEntry;
举个简单的例子:
redis HashTable 扩容和缩容的策略如下:
- 服务器目前没有执行 BGSAVE、BGREWRITEAOF命令,并且负载因子大于等于1
- 服务器目前正在执行 BGSAVE、BGREWRITEAOF命令,并且负载因子大于等于5
负载因子:已保存元素个数 / 哈希表大小
ht[0] 和 ht[1] 用于渐进式 hash 算法:也就是说扩容和收缩不是一次性,集中式地完成,而是通过多次逐渐地完成的
过程:ht[0] 和 ht[1] 会同时保存数据,ht[0] 指向旧哈希表,ht[1] 指向新哈希表,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 的元素迁移到 ht[1] 中
除了字段操作,redis 存在定时任务进行迁移
rehashidx 为 0 表示迁移开始,为 -1 表示迁移完成
好处:当元素特别大时,不会因为一下子迁移过多元素导致 redis 性能急剧下降
缺点:维护两个 hash 表,内存占用大,可能触发内存淘汰策略
LinkedList:
双向链表,就类似 java 中的 LinkedList,不过相比 LinkedList,它做了改进:
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
typedef struct listIter {
listNode *next;
int direction;
} listIter;
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len;
} list;
存在指针分别指向头和尾,还包含属性记录 LInkedList 长度
IntSet:
如果 value 可以转成整数值,并且长度不超过 512 的话就使用 intset 存储,否则采用 HashTable
typedef struct intset{
uint32_t encoding;//编码方式
uint32_t length;//集合包含的元素数量
int8_t contents[];//保存元素的数组
}intset;
使用 HashTable 时就像 java 中的 HashSet 和 HashMap 的关系
SkipList:
跳跃表,一个跳跃表包含一个 ZSkipList 和 一个 HashTable
这里既使用跳跃表,又使用字典的原因在于:单查询使用字典效率极高,范围查询使用跳跃表,效率极高。一种通过空间换时间的思路
跳跃表的核心在于跳跃,对于有序链表,每次跳过一个元素太慢了,通过跳跃表每次跳过多个,提高查询效率
参考:画图找源码太慢了,所以图和源码基本都是从其它地方复制过来的