简单动态字符串(SDS)
SDS的定义
struct sdshdr {
//记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[]; }
;
SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。
示例图:
SDS与C字符串的区别
- 对于C字符串,获取字符串长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,耗时o(n); 而SDS字符串则是o(1), 直接取len属性值。
- 避免缓冲区溢出,c字符串在进行字符串拼接操作时,如果内存分配不足,就会发生缓冲区溢出。
- c语言字符串在修改时,需要发生内存重分配,而sds因为存在预分配机制,可以减少连续执行字符串增长操作所需的内存重分配次数,同时,在缩短字符串时,sds并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用(惰性空间释放极致)。
- c字符串必须包含某种编码,只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。而sds是二进制安全的。
链表
当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。
除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)。
链表和链表节点的实现:
Redis的链表实现的特性可以总结如下:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
- 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
- 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
- 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
哈希表(字典)
字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上。
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
} dictht;
总结:
- Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
- 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
- 哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
- 在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的。
跳表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。实际上,就是一种可以进行二分查找的有序列表,跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。
在大部分情况下,跳跃表的效率可以和平衡树相媲美,且跳跃表的实现比平衡树要来得更为简单。
typedef struct zskiplistNode {
//层
struct zskiplistLevel {
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
} zskiplistNode;
场景题:分值相同的两个元素,如何按照创建时间排序?
Sorted Set 每个元素有两部分组成(member + score),可利用 score 进行排序,正好满足我们的场景。用 score 保存元素的分数,member 保存元素的内容。 既然时间也会影响到排序,那么就需要把时间戳考虑到score中,因为redis相同分数时,是内容的字符串比较排序。
按照这样的思路:
最后score = 原始分数 + ((基准时间 - 当前时间) / 基准时间) ,就实现了分数相同,先达到该分数的排在前面的功能。同理,如果需要倒叙,那么括号内换成加法即可。
场景题:如果引入一个置顶规则,那么又该如何处理?
Redis的sorted set的score存储类型是双精度64位float,能表示的整形范围 - 2^53 ~ 2^53,即能表示的最大范围-9007199254740992 and 9007199254740992.
实际业务的score达不到最大范围时,我们可以把score能表示的值分拆两部分来表示,如9007199254740992最大值,取9那位留作置顶字段值使用,剩余部分给score。
代码示例:
//置顶标识
up := 1000000000000000
score := 20 //假如分数为20
finalScore = up + score //1000000000000020
//TODO: zadd key finalScore member
整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现.
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
整数升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。
整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存。
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
连续更新问题
压缩列表里恰好有多个连续的、长度介于250字节至253字节之间的节点,当拓展其中某个节点时,导致后续多个节点都要更新。但发生的概率小,而且如果节点数量小,对性能影响也不大。
对象类型
由上面的基本数据结构组成字符串对象(string)、列表对象(list)、哈希对象(hash)、集合对象(set)和有序集合对象(zset)这五种类型的对象.
typedef struct redisObject {
//类型, 用于表示是字符串还是list、hash、set等
unsigned type:4;
//编码,表示使用了什么数据结构作为对象的底层实现,例如:列表对象的编码可以是ziplist或者linkedlist
unsigned encoding:4;
//指向底层实现数据结构的指针
void *ptr;
// ...
} robj;