一、基础数据结构
二、底层数据结构
1、底层数据结构初步认识
2、底层数据结构深入理解
(1)SDS动态字符串
SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。
设置和更新SDS长度的工作是由SDS的API在执行时自动完成的,使用SDS无须进行任何手动修改长度的工作。❑Redis只会使用C字符串作为字面量,在大多数情况下,Redis使用SDS(Simple Dynamic String,简单动态字符串)作为字符串表示。
❑比起C字符串,SDS具有以下优点:
1)常数复杂度获取字符串长度。
2)杜绝缓冲区溢出。
3)减少修改字符串长度时所需的内存重分配次数。
4)二进制安全。
5)兼容部分C字符串函数。
SDS数据结构示图如下
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
SDS-空间扩展收缩策略
涉及该数据结构的数据空间扩展策略及算法如下:
/*
* 空间预分配
* 将给定字符串 t 追加到 sds 的末尾
*
* 返回值
* sds :追加成功返回新 sds ,失败返回 NULL
*
* 复杂度
* T = O(N)
*/
sds sdscat(sds s, const char *t) {
return sdscatlen(s, t, strlen(t));
}
/*
* 惰性空间释放
* 将长度为 len 的字符串 t 追加到 sds 的字符串末尾
*
* 返回值
* sds :追加成功返回新 sds ,失败返回 NULL
*
* 复杂度
* T = O(N)
*/
sds sdscatlen(sds s, const void *t, size_t len) {
struct sdshdr *sh;
// 原有字符串长度
size_t curlen = sdslen(s);
// 扩展 sds 空间
// T = O(N)
s = sdsMakeRoomFor(s,len);
// 内存不足?直接返回
if (s == NULL) return NULL;
// 复制 t 中的内容到字符串后部
// T = O(N)
sh = (void*) (s-(sizeof(struct sdshdr)));
memcpy(s+curlen, t, len);
// 更新属性
sh->len = curlen+len;
sh->free = sh->free-len;
// 添加新结尾符号
s[curlen+len] = '\0';
// 返回新 sds
return s;
}
/*
* 对 sds 左右两端进行修剪,清除其中 cset 指定的所有字符
*
* 比如 sdsstrim(xxyyabcyyxy, "xy") 将返回 "abc"
*
* 复杂性:
* T = O(M*N),M 为 SDS 长度, N 为 cset 长度。
*/
sds sdstrim(sds s, const char *cset) {
struct sdshdr *sh = (void*) (s-(sizeof(struct sdshdr)));
char *start, *end, *sp, *ep;
size_t len;
// 设置和记录指针
sp = start = s;
ep = end = s+sdslen(s)-1;
// 修剪, T = O(N^2)
while(sp <= end && strchr(cset, *sp)) sp++;
while(ep > start && strchr(cset, *ep)) ep--;
// 计算 trim 完毕之后剩余的字符串长度
len = (sp > ep) ? 0 : ((ep-sp)+1);
// 如果有需要,前移字符串内容
// T = O(N)
if (sh->buf != sp) memmove(sh->buf, sp, len);
// 添加终结符
sh->buf[len] = '\0';
// 更新属性
sh->free = sh->free+(sh->len-len);
sh->len = len;
// 返回修剪后的 sds
return s;
}
SDS-API
(2)链表
Redis的链表实现的特性:
❑双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
❑无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
❑带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
❑带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
❑多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
/*
* 双端链表节点
*/
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
/*
* 双端链表结构
*/
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;
链表API
(3)字典-哈希表-哈希节点
❑字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。
❑Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
❑当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
/*
* 字典
*/
typedef struct dict {
dictType *type;// 类型特定函数
void *privdata;// 私有数据
dictht ht[2]; // 哈希表
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
/*
* 哈希表
*
* 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {
dictEntry **table;// 哈希表数组
unsigned long size; // 哈希表大小
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
/*
* 哈希表节点
*/
typedef struct dictEntry {
void *key;// 键
union {// 值
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
哈希冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。
Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。
Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
字典API
(4)跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息 ❑header:指向跳跃表的表头节点。
❑tail:指向跳跃表的表尾节点。
❑level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
❑length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。
每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。
/*
* 跳跃表
*/
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;
❑跳跃表是有序集合的底层实现之一。
❑Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点。
❑每个跳跃表节点的层高都是1至32之间的随机数。
❑在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。
❑跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。
在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位
跳跃表API
(5)整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
typedef struct intset {
uint32_t encoding; // 编码方式
uint32_t length; // 集合包含的元素数量
int8_t contents[];// 保存元素的数组
} intset;
□ 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引0);
□ 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)。
❑整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
❑升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
❑整数集合只支持升级操作,不支持降级操作。
整数API
(6)压缩列表
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
/*
* 保存 ziplist 节点信息的结构
*/
typedef struct zlentry {
// prevrawlen :前置节点的长度
// prevrawlensize :编码 prevrawlen 所需的字节大小
unsigned int prevrawlensize, prevrawlen;
// len :当前节点值的长度
// lensize :编码 len 所需的字节大小
unsigned int lensize, len;
// 当前节点 header 的大小
// 等于 prevrawlensize + lensize
unsigned int headersize;
// 当前节点值所使用的编码类型
unsigned char encoding;
// 指向当前节点的指针
unsigned char *p;
} zlentry;
每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成。
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节:
❑如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。
❑如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。
因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
每个压缩列表节点可以保存一个字节数组或者一个整数值 字节数组可以是以下三种长度的其中一种:
❑长度小于等于63(2 6–1)字节的字节数组;
❑长度小于等于16383(2 14–1)字节的字节数组;
❑长度小于等于4294967295(2 32–1)字节的字节数组;
整数值则可以是以下六种长度的其中一种:
❑4位长,介于0至12之间的无符号整数;
❑1字节长的有符号整数;
❑3字节长的有符号整数;
❑int16_t类型整数;
❑int32_t类型整数;
❑int64_t类型整数。
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度。
节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
连锁更新问题(连续的内存块)
压缩列表存在多个连续的、长度介于250字节到253字节之间的节点e1至eN 1
增加 长度大于等于254字节的新节点,会造成连锁更新 1-》5
删除节点也可能会引发连锁更新 b>=254 s <254 删除 s, s后面的为了记录b 就必须扩展
连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N 2)
❑首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见; ❑其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的;
因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。
压缩列表API
三、对象系统
Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,
这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,
每种对象都用到了至少一种我们前面所介绍的数据结构。
通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。
可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率
Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;
另外,Redis还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 指向实际值的指针
void *ptr;
} robj;
四、关联&对象
1、字符串对象
Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,
至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。
键总是一个字符串对象,值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。
embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构。
案例
❑embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。
❑释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。
❑因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。
2、列表对象
当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:
❑列表对象保存的所有字符串元素的长度都小于64字节;
❑列表对象保存的元素数量小于512个;
不能满足这两个条件的列表对象需要使用linkedlist编码。
案例
PS:最新的redis引入了 quicklist,quicklist结构在quicklist.c中的解释为A doubly linked list of ziplists意思为一个由ziplist**组成的双向链表**。
3、哈希对象
(1)ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:
❑保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
❑先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
(2)hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
❑字典的每个键都是一个字符串对象,对象中保存了键值对的键;
❑字典的每个值都是一个字符串对象,对象中保存了键值对的值。
案例
4、集合对象
集合对象的编码可以是intset或者hashtable。
(1)intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
(2)hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。
案例
5、有序集合对象
(1)ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向
(2)skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表
zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值
zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值
有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数
两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。
案例
五、类型--命令检查
Redis中用于操作键的命令基本上可以分为两种类型。其中一种命令可以对任何类型的键执行,比如说DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等。
而另一种命令只能对特定类型的键执行,比如说:❑SET、GET、APPEND、STRLEN等命令只能对字符串键执行;
❑HDEL、HSET、HGET、HLEN等命令只能对哈希键执行;
❑RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行;
❑SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;
❑ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行
类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的:
❑在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令;
❑否则,服务器将拒绝执行命令,并向客户端返回一个类型错误。
DEL、EXPIRE等命令和LLEN等命令的区别在于,前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码
附录:Redis数据类型
key操作
keys * exists key的名字,判断某个key是否存在 move key db --->当前库就没有了,被移除了 expire key 秒钟:为给定的key设置过期时间 ttl key 查看还有多少秒过期,-1表示永不过期,-2表示已过期 type key 查看你的key是什么类型string(单值单value)
set/get/del/append/strlen Incr/decr/incrby/decrby,一定要是数字才能进行加减 getrange/setrange 获取/设置区间范围内的值 setex:设置带过期时间的key,动态设置。setex 键 秒值 真实值。 mset/mget/msetnx mset:同时设置一个或多个 key-value 对。 mget:获取所有(一个或多个)给定 key 的值。 msetnx:同时设置一个或多个 key-value 对,当且仅当所有给定 key 都不存在。 getset:将给定 key 的值设为 value ,并返回 key 的旧值(old value)。先get然后立即set。lish(单值多value)
lpush/rpush/lrange/ lpop/rpop/ llen lindex,按照索引下标获得元素(从上到下)lindex key index lrem key 删N个value ltrim key 开始index 结束index,截取指定范围的值后再赋值给key rpoplpush 源列表 目的列表(移除列表的最后一个元素,并将该元素添加到另一个列表并返回) lset key index value linsert key before/after 值1 值2(在list某个已有值的前后再添加具体值) 总结: 一个字符串链表,left、right都可以插入添加;如果键不存在,创建新的链表;如果键已存在,新增内容;如果值全移除,对应的键也就消失了。链表的操作无论是头和尾效率都极高,但假如是对中间元素进行操作,效率就很惨淡了。set(单值多value)
sadd/smembers/sismember scard,获取集合里面的元素个数 srem key value 删除集合中元素 srandmember key 某个整数(随机出几个数) spop key 随机出栈 smove key1 key2 在key1里某个值 作用是将key1里的某个值赋给key2 集合处理类 差集:sdiff 交集:sinter 并集:sunionzset(单值多value)
zadd/zrange/ zrevrange zrangebyscore key 开始score 结束score zrem key 某score下对应的value值,作用是删除元素 zcard/zcount key score区间/zrank key values值,作用是获得下标值/zscore key 对应值,获得分数 zcard :获取集合中元素个数 zcount :获取分数区间内元素个数,zcount key 开始分数区间 结束分数区间 zrank: 获取value在zset中的下标位置 zscore:按照值获得对应的分数 zrevrank key values值,作用是逆序获得下标值 zrevrangebyscore key 结束score 开始scorehash(KV模式不变,但V是一个键值对)
hset/hget/hmset/hmget/hgetall/hdel/ hlen hexists key 在key里面的某个值的key hkeys/hvals hincrby/hincrbyfloat hsetnx