前言
Redis诞生于2009年,全称是Remote Dictionary Server,解释为远程词典服务,是一个基于内存的键值型NoSQL数据库。
由于其基于内存的性质,有较快的访问速度,所以很多场景下均用其作为缓存来使用。而为了更好的发挥其性能,提升缓存的读写效率,我们是有必要了解Redis中的数据类型以及其底层的数据结构的。下面文本就以Redis中最基本的五种数据类型来分别展开讲解Redis中的数据结构。
Redis中的5中基本数据类型
Redis是基于K-V存储的,其中Key一般是String,Value确实多样的:
数据类型 | value样例 |
---|---|
String | hello Redis |
Hash | {name: “Tom”, age: 21} |
List | [A -> B -> C -> D] |
Set | {A, B, C} |
SortedSet | {A: 1, B: 2, C: 3} |
下面我们就针对以上5中数据类型来刨析其底层的数据结构。
Redis中的redisObject
在具体说某种数据结构之前,我们先了解一下redis对象,因为redis中每个value值其实存储的是一个redisObject。先看一个内存图(以String为例):
Redis源码中的RedisObject结构体声明:
typedef struct redisObject robj;
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU时间(相对于全局lru_clock)或
* LFU数据(最低有效频率为8位
* 和最高16位访问时间)。 */
int refcount;
void *ptr;
};
对象数据类型对应的编码方式,每种编码方式就对应一种数据结构后面会展开分析:
数据类型 | 编码方式 | 描述 |
---|---|---|
OBJ_STRING | OBJ_ENCODING_RAW | raw编码的动态字符串 |
OBJ_ENCODING_EMBSTR | embstr编码的动态字符串 | |
OBJ_ENCODING_INT | long类型整数字符串 | |
OBJ_LIST | OBJ_ENCODING_LINKEDLIST | 双向列表(3.2之前) |
OBJ_ENCODING_ZIPLIST | 压缩列表(3.2之前) | |
OBJ_ENCODING_QUICKLIST | 快速列表(3.2之后) | |
OBJ_SET | OBJ_ENCODING_INTSET | 整数集合 |
OBJ_ENCODING_HT | hash表(字典dict) | |
OBJ_ZSET | OBJ_ENCODING_ZIPLIST | 压缩列表 |
OBJ_ENCODING_HT | hash表(字典dict) | |
OBJ_ENCODING_SKIPLIST | 跳表 | |
OBJ_HASH | OBJ_ENCODING_ZIPLIST | 压缩列表 |
OBJ_ENCODING_HT | hash表(字典dict) |
Redis中的String
简介
字符串类型(String)作为Redis中最简单的数据类型,也得到了最广泛的使用。其中,根据字符串的格式又划分为简单字符串(string)、整数(int)和小数(float)这三种格式。例如:
value | 格式 |
---|---|
hello Redis | string |
12 | int |
80.5 | float |
虽然这三种格式的存储在底层均为字符串,但是其编码格式是不同的。
常用命令
String的常见命令有:
- SET:添加或者修改已经存在的一个String类型的键值对
- GET:根据key获取String类型的value
- MSET:批量添加多个String类型的键值对
- MGET:根据多个key获取多个String类型的value
- INCR:让一个整型的key自增1
- INCRBY:让一个整型的key自增并指定步长,例如:incrby num 2 让num值自增2
- INCRBYFLOAT:让一个浮点类型的数字自增并指定步长
- SETNX:添加一个String类型的键值对,前提是这个key不存在,否则不执行
- SETEX:添加一个String类型的键值对,并且指定有效期
String的底层数据结构SDS
SDS简述
SDS(Simple Dynamic String),它是Redis自己实现的动态字符串,与C语言中的字符串相比,SDS具有以下优点:
-
动态扩容:SDS可以根据字符串的长度动态地扩展内存空间,而C语言中的字符串长度是固定的。
-
高效的修改操作:SDS支持常数时间复杂度的字符串修改操作,而C语言的字符串修改操作需要线性时间复杂度。
-
二进制安全:SDS支持存储二进制数据,并且不会在字符串中添加任何特殊字符,这使得SDS是一个二进制安全的字符串。
源码
以hisdshdr8结构体为例:
(另外还有:hisdshdr5、hisdshdr16、hisdshdr32、hisdshdr64)
struct __attribute__ ((__packed__)) hisdshdr8 {
uint8_t len; /* 使用的:buf已保存的字符串字节数,不包含结束标示 */
uint8_t alloc; /* buf申请的总的字节数,不包含结束标示 */
unsigned char flags; /* 不同SDS的头类型,用来控制SDS的头大小; 低3位用作标识位,高5位保留*/
char buf[]; /* 数据具体存放数组 */
};
flags的取值:
#define HI_SDS_TYPE_5 0
#define HI_SDS_TYPE_8 1
#define HI_SDS_TYPE_16 2
#define HI_SDS_TYPE_32 3
#define HI_SDS_TYPE_64 4
SDS扩容
当容量不足时,触发动态扩容机制,其扩容又分为两种情况:
- 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
- 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配
Redis-String内存图
对于一个String来说不同的编码方式,对应的内存图也不一样。
RAW编码方式
最通用的编码方式,String对象指向一个SDS,SDS的信息又包括头信息和数据信息以及结束符,存储上限是512MB。
embstr编码方式
如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时obiect head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。
long类型整数字符串
如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在Redisobject的ptr指针位置(刚好8字节),不再需要SDS了。
可以看出这种方式不仅效率更高,并且内存占用更少,但是限制也很大,仅能保存整数值。
String小结
对于Redis中的字符串来说,编码方式均是自动实现,使用者不用过多关注,但是,需要了解每一种编码方式是在什么场景下触发的,以便于在业务数据处理的时候尽量的挖掘出Redis的效率,并且减少内存的占用。
Redis中的List
简介
Redis中的List类型与Java中的LinkedList类似,可以看做是一个双向链表结构。既可以支持正向检索和也可以支持反向检索。特征也与LinkedList类似:
- 有序
- 元素可以重复
- 插入和删除快
- 查询速度一般
常用来存储一个有序数据,例如:朋友圈点赞列表,评论列表等。
常用命令
- LPUSH key element … :向列表左侧插入一个或多个元素
- LPOP key:移除并返回列表左侧的第一个元素,没有则返回nil
- RPUSH key element … :向列表右侧插入一个或多个元素
- RPOP key:移除并返回列表右侧的第一个元素
- LRANGE key star end:返回一段角标范围内的所有元素
- BLPOP和BRPOP:与LPOP和RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回nil
List相关的数据结构
linkedList
普通的双端链表,类似于java中的链表。如下示意图所示:
普通的双端链表内存空间是不连续的,在使用中不可避免的要浪费时间来寻址,尤其是在遍历的时候,因此,普通的双端链表的查询效率是相对低的。
zipList
Redis中引入另一种特殊的双端链表-ZipList,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作并且该操作的时间复杂度为 o(1)。
其中ZipList中包含以下属性:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录压缩列表占得内存字节数 |
zltail | uint32_t | 4字节 | 记录列表起始地址到尾节点的偏移量,通过这个偏移量可以确定尾节点地址 |
zllen | uint16_t | 2字节 | 记录节点数量,最大值为uint16_MAX(65534),超过最大值保存65535,实际的长度则需要遍历整个列表 |
entry | 节点 | 不定 | 保存具体内容 |
zlend | uint8_t | 1字节 | 标识末端,特殊值0xFF(十进制的255) |
注:uint32_t
u:代表 unsigned 即无符号,即定义的变量不能为负数;
int:代表类型为 int 整形;
32:代表四个字节,即为 int 类型;
_t:代表用 typedef 定义的;
整体代表:用 typedef 定义的无符号 int 型宏定义;
内存图
zipList在内存中是一块连续的空间:
zipList中的Entry
由内存图可以看出,ZipList中的Entry并没有采取传统双端链表用指针链接的方式。而是采取了这种比指针更节省内存的方式,即计算entry大小得到next。(连续内存才能采用)其中:
previous_entry_length: 前一节点的长度占1或5字节。
- 前一节点的长度小于254,用一个字节保存。
- 前一节点的长度大于等于254时,用5个字节保存,第一个字节是0xFE,后面是具体长度数据。
encoding: 数据(content)的数据类型及长度,占1、2、5个字节。
content: 节点的具体数据,字符串或者整数。
注:ZipList中所有存储长度的数值均采用小端字节席,即低位字节在前,高位字节在后。
例如:数值0x1234,采用小端字节序后实际存储值为:0x3412
Entry中的编码
字符串编码
如果encoding是以“00”、“01”或者“10”开头,则证明content是字符串。
编码 | 编码长度 | 大小 |
---|---|---|
00pppppp | 1字节 | <= 63 bytes |
01pppppp qqqqqqqq | 2字节 | <= 16383 bytes |
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt | 5字节 | <= 4294967295 bytes |
样例:保存字符串“ab”和“bc”
整数编码
如果encoding是以“11"开始,则证明content是整数,且encoding固定只占用1个字节。
编码 | 编码长度 | 整数类型 |
---|---|---|
11000000 | 1 | int16_t(2 bytes) |
11010000 | 1 | int32_t(4 bytes) |
11100000 | 1 | int64_t(8 bytes) |
11110000 | 1 | 24位有符号整数(3 bytes) |
11111110 | 1 | 8位有符号整数(1 bytes) |
1111xxxx | 1 | xxxx位置保存数值,范围0001~1101,减1后结果为实际值 |
样例:保存整数“2”和“5”
可以看出上述样例就是采取 1111xxxx编码方式,数值放在编码里
注意:无论哪种编码方式,在使用zipList的时候,都要关注其连锁更新问题。即碰巧一个插入导致每个节点的previous_entry_length属性都要从1字节扩容到5字节。
quickList
上文中介绍了zipList,效率方面要比普通链表强大,但是其也有一些痛点,比如ZipList必须申请连续的内存空间,这就不能让其无限大,需要限制ZipList的大小。这样又造成无法满足存储大量数据。为了解决这个痛点,Redis又创造了新的数据结构QuickList。可以看成是linkedList+ZipList的组合体,先看一下它的示意图:
Redis提供了配置项:list-max-ziplist-size来限制ZipList的大小(默认值:-2)。
- 数值为正则是限制entry的个数
- 数值为负,则分为以下5中情况:
-1:zipList内存不能超过4kb
-2: 每个zipList的内存占用不能超过8kb
-3:每个zipList的内存占用不能超过16kb
-4:每个zipList的内存占用不能超过32kb-5: 每个zipList的内存占用不能超过64kb
Redis通用提供配置项:list-compress-depth来控制是否对节点的zipList压缩(默认不压缩)。
- 0:不压缩
- 1:QuickList首尾一个节点不压缩,其余压缩
- 2:两个节点不压缩,其余压缩
- 以此类推
QuickList源码
typedef struct quicklist {
//头节点指针
quicklistNode *head;
//尾节点指针
quicklistNode *tai1;
//所有zipist的entry的数量
unsigned Tong count;
// ziplists总数量
unsigned long len;
// ziplist的entry上限,默认值 -2
int fi11 : QL_FILL_BITS;
//首尾不压缩的节点数量
unsigned int compress : QL_COMP_BITS;
//内存重分配时的书签数量及数组,一般用不到
unsigned int bookmark_count: QL_BM_BITS;
quickTistBookmark bookmarks[];
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev; /* 前一个节点指针 */
struct quicklistNode *next;/* 下一个节点指针 */
unsigned char *zl;/* 当前节点指针 */
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of entry in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 zipLis或者压缩*/
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 容器类型(预留)*/
unsigned int recompress : 1; /* was this node previous compressed?是否被解压了,1是 */
unsigned int attempted_compress : 1; /* node can't compress; too small 测试用*/
unsigned int extra : 10; /* more bits to steal for future usage 预留*/
} quicklistNode;
QuickList内存图
List的数据结构选型
- 在3.2版本之前,Redis采用ziplist和Linkedlist来实现List,当元素数量小于512并且元素大小小于64字节时采用zipList编码,超过则采用LinkedList编码。
- 在3.2版本之后,Redis开始采用QuickList来实现List(后面的新版本可能还会对其迭代),请看下面源码:
源码
void pushGenericCommand(client *c, int where) {
int j, waiting = 0, pushed = 0;
// 找到该List
robj *lobj = lookupKeyWrite(c->db,c->argv[1]);
if (lobj && lobj->type != OBJ_LIST) {
addReply(c,shared.wrongtypeerr);
return;
}
for (j = 2; j < c->argc; j++) {
// 检查编码
c->argv[j] = tryObjectEncoding(c->argv[j]);
if (!lobj) {
// 为空则创建一个QuicklistObject
lobj = createQuicklistObject();
quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,
server.list_compress_depth);
dbAdd(c->db,c->argv[1],lobj);
}
listTypePush(lobj,c->argv[j],where);
pushed++;
}
addReplyLongLong(c, waiting + (lobj ? listTypeLength(lobj) : 0));
if (pushed) {
char *event = (where == LIST_HEAD) ? "lpush" : "rpush";
signalModifiedKey(c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_LIST,event,c->argv[1],c->db->id);
}
server.dirty += pushed;
}
robj *createQuicklistObject(void) {
// 创建QuicklistObject
quicklist *l = quicklistCreate();
// 创建一个Redis对象指向QuicklistObject
robj *o = createObject(OBJ_LIST,l);
// 为Redis对象指定编码
o->encoding = OBJ_ENCODING_QUICKLIST;
return o;
}
List内存图(Redis3.2)
Redis中的Set
简介
Redis的Set结构与Java中的HashSet类似,可以看做是一个value为null的HashMap。因为也是一个hash表,因此具备与HashSet类似的特征:
- 无序
- 元素不可重复
- 查找快
- 支持交集、并集、差集等功能
常用命令
- SADD key member …:向set中添加一个或多个元素。
- SREM key member…:移除set中的指定元素。
- SCARD key:返回set中元素的个数。
- SISMEMBER key member:判断一个元素是否存在于set中。
- SMEMBERS:获取set中的所有元素
- SINTER keyl key2…:求key1与key2的交集。
- SDIFF key1 key2 … :求key1与key2的差集
- SUNION key1 key2 …:求key1和key2的并集
set的数据结构选型
- 为了查询效率和唯一性,set采用HT编码 (Dict)。Dict中的key用来存储元素,value统一为null。
- 当存储的所有数据都是整数,并目元素数量不超过set-max-intset-entries(默认512)时,Set会采用IntSet编码,以节省内存。
源码
/* 工厂方法返回一个可以保存"value"的集合。当对象有
*如果是可整型编码的值,则返回intset。否则就是常规的
*哈希表。*/
robj *setTypeCreate(robj *value) {
// 判断value是否是数值类型 1ong 1ong
if (isObjectRepresentableAsLongLong(value,NULL) == C_OK)
// 如果是数值类型,则采用IntSet编码
return createIntsetObject();
// 否则采用默认编码,也就是HT(哈希)
return createSetObject();
}
set的数据结构
由源码可以看出,在set创建的时候会判断值类型,然后选用不同的编码,不同的底层数据结构,那么我们就看一下这两种不同的数据结构:
intSet
IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。
源码
typedef struct intset {
uint32_t encoding; // 编码,支持16、32、64位整数
uint32_t length; // 元素个数
int8_t contents[]; // 内容数组,保存具体数据
} intset;
内存图
注:计算元素地址:startPtr + (sizeof(int16) * index)
intset小结
Intset可以看做是特殊的整数数组,具备一些特点:
- Redis会确保Intset中的元素唯一、有序
- 具备类型升级机制,可以节省内存空间
- 底层采用二分查找方式来查询
这些表现出来的特点,均有Redis中具体逻辑实现,这里只讲数据结构,不剖析具体实现逻辑。
Redis中的Dict
Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
Dict由三部分组成,分别是: 哈希表 (DictHashTable) 、哈希节点 (DictEntry) 、字典 (Dict)。
源码
// 字典
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int iterators; /* number of iterators currently running */
} dict;
/* 这是哈希表结构,每个哈希表有两个字典用于重哈希 */
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
// 节点
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
内存图
Dict的扩容与缩容
Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子(LoadFactor=used/size),满足以下两种情况时会触发哈希表扩容:
4. 哈希表的LoadFactor>=1,并且服务器没有执行BGSAVE或者BGREWRITEAOF等后台进程;
5. 哈希表的LoadFactor>5;
/* 检查是否扩容 */
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. 在rehash*/
if (dictIsRehashing(d)) return DICT_OK;
/* If the hash table is empty expand it to the initial size. 为空*/
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
/* 当负载因子 (used/size) 达到1以上,
* 并且当前没有进行bgrewrite等子进程操作/或者负载因子超过5,
* 则进行 dictExpand ,也就是扩容 */
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
// 扩容大小为used + 1,底层会对扩容大小做判断,实际上找的是第一个大于等于 used+1 的 2^n
return dictExpand(d, d->ht[0].used*2);
}
return DICT_OK;
}
Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactor<0.1 时,会做哈希表收缩:
int hashTypeDelete(robj *o, robj *field) {
...
/* Always check if the dictionary needs a resize after a delete. */
if (htNeedsResize(o->ptr)) dictResize(o->ptr);
...
}
int htNeedsResize(dict *dict) {
long long size, used;
// 哈希表大小
size = dictSlots(dict);
// entry数量
used = dictSize(dict);
// size > 4 && 负载因子 < 0.1
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL));
}
/* Resize the table to the minimal size that contains all the elements,
* but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
int minimal;
// 如果在bgsave或bgrewriteo或rehash 返回 ERR
if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
// 获取entry数量
minimal = d->ht[0].used;
// 如果小于4则重置为4
if (minimal < DICT_HT_INITIAL_SIZE)
minimal = DICT_HT_INITIAL_SIZE;
// 否则重置为大于minimal的最小2^n
return dictExpand(d, minimal);
}
Dict的rehash
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。
因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:
6. 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:
1-1如果是扩容,则新size为第一个大于等于dictht[0].used+1的2n。
1-2如果是收缩,则新size为第一个大于等于dictht[0]used的2n(不得小于4)。
7. 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]。
8. 设置dict.rehashidx=0,标示开始rehash。
9. 将dictht[0]中的每一个dictEntry都rehash到dict.ht[1]。
10. 将dict.ht[1]赋值给dictht[0],给dictht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存。
rehash过程:
1、新增元素引起扩容:
2、按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
3、将dict.ht[1]赋值给dictht[0],给dictht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存。
Dict的rehash并不是一次性完成的。试想一下,如果Dict中包含数百万的entry,要在一次rehash完成,极有可能导致主线程阻塞。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash。流程如下:
- 计算新hash表的size,值取决于当前要做的是扩容还是收缩:
如果是扩容,则新size为第一个大于等于dictht[0].used+1的2n。
如果是收缩,则新size为第一个大于等于dict.ht[0].used的2n(不得小于4)。 - 按照新的size申请内存空间,创建dictht,并赋值给dict.ht[1]。
- 设置dict.rehashidx=0,标示开始rehash。
- 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]。
- 将dict.ht[1]赋值给dictht[0],给dictt[1]初始化为空哈希表,释放原来的dict.ht[0]的内存。
- 将rehashidx赋值为-1,代表rehash结束。
- 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空。
Set数据类型小结
由上文所知,set数据类型会根据存储数据类型和元素个数为约束选择合适的数据结构。
1、当存储的所有数据都是整数,并目元素数量不超过set-max-intset-entries(默认512)时:
2、反之:
Redis中的SortedSet(ZSet)
简介
Redis的SortedSet是一个可排序的set集合,与Java中的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加hash表。
SortedSet具备下列特性:
- 可排序
- 元素不重复
- 查询速度快
- 因为SortedSet的可排序特性,经常被用来实现排行榜这样的功能。
常用命令
- ZADD key score member:添加一个或多个元素到sortedset,如果已经存在则更新其score值
- ZREM key member:删除sortedset中的一个指定元素
- ZSCORE key member:获取sortedset中的指定元素的score值
- ZRANK key member:获取sortedset中的指定元素的排名
- ZCARD key:获取sortedset中的元素个数
- ZCOUNT key min max:统计score值在给定范围内的所有元素的个数
- ZINCRBY key increment member:让sortedset中的指定元素自增,步长为指定的increment值
- ZRANGE key min max:按照score排序后,获取指定排名范围内的元素
- ZRANGEBYSCORE key min max:按照score排序后,获取指定score范围内的元素
- ZDIFF、ZINTER、ZUNION:求差集、交集、并集
注意:所有的排名默认都是升序,如果要降序则在命令的Z后面添加REV即可
源码
zset结构体:
typedef struct zset {
// 字典
dict *dict;
// 跳表
zskiplist *zsl;
} zset;
/* 创建Zset */
robj *createZsetObject(void) {
zset *zs = zmalloc(sizeof(*zs));
robj *o;
// 字典
zs->dict = dictCreate(&zsetDictType,NULL);
// 跳表
zs->zsl = zslCreate();
// 创建redis对象
o = createObject(OBJ_ZSET,zs);
// 编码
o->encoding = OBJ_ENCODING_SKIPLIST;
return o;
}
ZSet底层相关数据结构
ZSet实现是一个跳表(SkipList)加hash表(dict)
- SkipList:可以排序,并且可以同时存储score和ele值(member)
- HT(Dict):可以键值存储,并且可以根据key找value
满足ZSet的特点:
- 可以根据score值排序后
- member必须唯一
- 可以根据member查询分数
SkipList
对于dict前文已经涉及,这里就不做赘述了。现在具体解释一下SkipList
SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
- 元素按照升序排列存储
- 节点可能包含多个指针,指针跨度不同。
源码
下面看一下跳表的源码:
// 跳表结构体
typedef struct zskiplist {
// 头尾指针
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 索引层级
int level;
} zskiplist;
// 跳表的节点
typedef struct zskiplistNode {
robj *obj;// 数据
double score; // 分数
struct zskiplistNode *backward; // 前驱节点
struct zskiplistLevel {
struct zskiplistNode *forward; // 后继结点
unsigned int span; // 跨度
} level[]; // 多层级索引数组
} zskiplistNode;
内存图
为更好的理解跳表的结构,请看下图:
SkipList特点
- 跳跃表是一个双向链表,每个节点都包含score和ele值
- 节点按照score值排序,score值一样则按照ele字典排序
- 每个节点都可以包含多层指针,层数是1到32之间的随机数(Redis跳表中的每个节点都有一个层级,而节点的层级是通过随机化获得的。在插入新节点时,算法会生成一个1~32之间的随机整数,作为新节点的层级。通过随机化层级,可以保证跳表的平衡性,避免出现极端情况下的查找效率下降。)
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
- 增删改查效率与红黑树基本一致,实现却更简单
ZSet内存图
通常情况下ZSet是基于skipList和dict实现的:
特殊情况
当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:
- 元素数量小于zset_max_ziplist_entries,默认值128;
- 每个元素都小于zset_max_ziplist_value字节,默认值64;
ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:
- ZipList是连续内存,因此score和element是紧挨在一起的两个entry,element在前,score在后
- score越小越接近队首,score越大越接近队尾,按照score值升序排列
Redis中的Hash
简介
Hash类型,也叫散列,其value是一个无序字典,类似于Java中的HashMap结构。
String结构是将对象序列化为JSON字符串后存储,当需要修改对象某个字段时很不方便。
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD:
例如,商品价格发生变化,我们能后针对性的更新。
常用命令
- HSET key field value:添加或者修改hash类型key的field的值。
- HGET key field:获取一个hash类型key的field的值。
- HMSET:批量添加多个hash类型key的field的值。
- HMGET:批量获取多个hash类型key的field的值。
- HGETALL:获取一个hash类型的key中的所有的field和value。
- HKEYS:获取一个hash类型的key中的所有的field。
- HVALS:获取一个hash类型的key中的所有的value。
- HINCRBY:让一个hash类型key的字段值自增并指定步长。
- HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行。
Hash结构
hash数据类型的特点:
- 都是键值存储。
- 都需求根据键获取值。
- 键必须唯一。
可以看出,Hash特点跟ZSet的特点有些类似,但是也存在区别:
- hash表无序。
- hash的键值是任意的。
所以,相对Zset,hash看起来更简单一些,好像是用不到跳表来排序了~
底层数据结构
Hash结构默认采用ziplist编码,用以节省内存。 ziplist中相邻的两个entry 分别保存field和value:
robj *createHashObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_HASH, zl);
o->encoding = OBJ_ENCODING_ZIPLIST;
return o;
}
当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
- zipList中的元素数量超过了hash-max-ziplist-entries (默认512)
- zipList中的任意entry大小超过了hash-max-ziplist-value (默认64字节)
void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
int i;
// 不是ZIPLIST编码,返回
if (o->encoding != OBJ_ENCODING_ZIPLIST) return;
for (i = start; i <= end; i++) {
if (sdsEncodedObject(argv[i]) &&
sdslen(argv[i]->ptr) > server.hash_max_ziplist_value)
{
hashTypeConvert(o, OBJ_ENCODING_HT);
break;
}
}
}
...
/* Check if the ziplist needs to be converted to a hash table */
if (hashTypeLength(o) > server.hash_max_ziplist_entries)
hashTypeConvert(o, OBJ_ENCODING_HT);
...
总结
看到这里,相信你对Redis中五中基本的数据类型以及其底层使用的数据结构都有了一定了解,并且应该掌握在特定的节点会产生底层数据结构的变化。在使用Redis时,理解其底层数据结构,并掌握其数据结构的切换,对数据类型的选型是很重要的。在某些特定的场景,会带来意想不到的效率升。