一、数据结构
1、简单动态字符串 SDS
①在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS实现的。
②redis> RPUSH fruits "apple”“banana”"cherry"
(integer) 3
键值对的键是一千字符串对象,对象的底层实现是-斗保存了字符串 ” fruits” 的 sos。
键值对的值是一个列表对象,列表对象包含了三个字符串对象,这三个字符串对象分别由三个 sos 实现:第一个SDS保存着字符串 ” apple”,第二个SDS保存着字符串 "banana ”,第三个SDS保存着字符串 " cherry” 。
③除了用来保存数据库中的字符串值之外,sos 还被用作缓冲区(buffer ) : AOF模块中的AOF缓冲区, 以及客户端状态中的输人缓冲区,都是由SDS实现的。
④ free属性的值为0, 表示这个SDS没有分配任何未使用空间。
len属性的值为5, 表示这个SDS保存了一个五字节长的字符串。
buf属性是一个char类型的数组, 数组的前五个字节分别保存了 'R'、'e'、'd'、'i'、's'五个字符,最后添加了一个空字符'\0'
⑤保存空字符的1字节空间不计算在SDS的 len属性里面,好处是SDS可以直接重用一部分C字符串函数库里面的函数。
⑥总结C字符串与SDS的区别:
比起C字符串, SDS具有以下优点
1)常数复杂度获取字符串长度。
2) 杜绝缓冲区溢出。
3) 减少修改字符串长度时所需的内存重分配次数。
4) 二进制安全。
5) 兼容部分C字符串函数。
SDS简单动态字符串,比起C字符串的优势:
- 获取字符串长度,时间复杂度为O(1)
- 增加、减少 字符串长度时,操作步骤小于等于C字符串
- API安全,防止了缓冲区溢出
- 内容上,可以保存二进制数据,因为C字符串遇到空字符就会认为到末端了,SDS不会这样认为,会依据len属性判断是否到末端
- 由于最后按照C字符串的格式,存储了一个空字符,所以可以使用部分现成的C函数。
2、链表
①C言并没有内置链表数据结构,所以Redis构建了自己的链表实现
②链表结构,除了用于链表键之外, 发布与订阅、 慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息, 以及使用链表来构建客户端输出缓冲区
③链表节点结构图
④list结构为链表提供了表头指针head、表尾指针tail, 以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定画数。
⑤一个list结构和三个listNode 归结构组成的链表。
链表结构优势:
- 双端,查看当前节点的下一个节点、上一个节点,时间复杂度是O(1)
- 无环,头节点和尾节点的指针都指向null
- 带表头指针和表尾指针,可直接获取头节点和尾节点,时间复杂度为O(1)
- len属性可以直接获取链表长度,时间复杂度为O(1)
疑问点:24页三个函数值
3、字典
Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。
哈希表dictht结构:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
table
属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry
结构的指针, 每个 dictEntry
结构保存着一个键值对。
size
属性记录了哈希表的大小, 也即是 table
数组的大小, 而 used
属性则记录了哈希表目前已有节点(键值对)的数量。
sizemask
属性的值总是等于 size - 1
, 这个属性和哈希值一起决定一个键应该被放到 table
数组的哪个索引上面。
哈希表节点dictEntry结构:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
字典dict结构:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
当字典被用作数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值。
举个例子, 对于图 4-4 所示的字典来说, 如果我们要将一个键值对 k0
和 v0
添加到字典里面, 那么程序会先使用语句:
hash = dict->type->hashFunction(k0);
计算键 k0
的哈希值。
假设计算得出的哈希值为 8
, 那么程序会继续使用语句:
index = hash & dict->ht[0].sizemask = 8 & 3 = 0;
计算出键 k0
的索引值 0
, 这表示包含键值对 k0
和 v0
的节点应该被放置到哈希表数组的索引 0
位置上
当新增节点,与已有节点哈希值相同时,会造成哈希冲突,新节点将会放到头节点位置,时间复杂度为O(1)
Redis 对字典的哈希表执行 rehash 的步骤如下:
- 为字典的
ht[1]
哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及ht[0]
当前包含的键值对数量 (也即是ht[0].used
属性的值):
- 如果执行的是扩展操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used * 2
的 2^n (2
的n
次方幂); - 如果执行的是收缩操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used
的 2^n 。
- 将保存在
ht[0]
中的所有键值对 rehash 到ht[1]
上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到ht[1]
哈希表的指定位置上。 - 当
ht[0]
包含的所有键值对都迁移到了ht[1]
之后 (ht[0]
变为空表), 释放ht[0]
, 将ht[1]
设置为ht[0]
, 并在ht[1]
新创建一个空白哈希表, 为下一次 rehash 做准备。
渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。
渐进式 rehash 使用了rehashidx属性控制进度。
因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0]
和 ht[1]
两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0]
里面进行查找, 如果没找到的话, 就会继续到 ht[1]
里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1]
里面, 而 ht[0]
则不再进行任何添加操作: 这一措施保证了 ht[0]
包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。
- 字典被广泛用于实现 Redis 的各种功能, 其中包括数据库和哈希键。
- Redis 中的字典使用哈希表作为底层实现, 每个字典带有两个哈希表, 一个用于平时使用, 另一个仅在进行 rehash 时使用。
- 当字典被用作数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值。
- 哈希表使用链地址法来解决键冲突, 被分配到同一个索引上的多个键值对会连接成一个单向链表。
- 在对哈希表进行扩展或者收缩操作时, 程序需要将现有哈希表包含的所有键值对 rehash 到新哈希表里面, 并且这个 rehash 过程并不是一次性地完成的, 而是渐进式地完成的。
4、跳跃表zskiplist:
跳跃表节点zskiplistNode
-
header
:指向跳跃表的表头节点。 -
tail
:指向跳跃表的表尾节点。 -
level
:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。 -
length
:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。 - 层(level):节点中用
L1
、L2
、L3
等字样标记节点的各个层,L1
代表第一层,L2
代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。 - 后退(backward)指针:节点中用
BW
字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。 - 分值(score):各个节点中的
1.0
、2.0
和3.0
是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。 - 成员对象(obj):各个节点中的
o1
、o2
和o3
是节点所保存的成员对象。 - 分值相同的节点将按照成员对象在字典序中的大小来进行排序,小的对象在前,大的对象在后。
5、整数集合
虽然 contents
数组保存的四个整数值中, 只有 -2675256175807981027
是真正需要用 int64_t
类型来保存的, 而其他的 1
、 3
、 5
三个值都可以用 int16_t
类型来保存, 不过根据整数集合的升级规则, 当向一个底层为 int16_t
数组的整数集合添加一个 int64_t
类型的整数值时, 整数集合已有的所有元素都会被转换成 int64_t
类型, 所以 contents
数组保存的四个整数值都是 int64_t
类型的, 不仅仅是 -2675256175807981027
。
整数集合,当新增元素时,有可能会引起升级。
升级之后新元素的摆放位置:
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大, 所以这个新元素的值要么就大于所有现有元素, 要么就小于所有现有元素:
- 在新元素小于所有现有元素的情况下, 新元素会被放置在底层数组的最开头(索引
0
); - 在新元素大于所有现有元素的情况下, 新元素会被放置在底层数组的最末尾(索引
length-1
)。
整数集合不支持降级操作, 一旦对数组进行了升级, 编码就会一直保持升级后的状态。
6、压缩列表
每个压缩列表节点都由 previous_entry_length
、 encoding
、 content
三个部分组成
previous_entry_length
属性的长度可以是 1
字节或者 5
字节:
- 如果前一节点的长度小于
254
字节, 那么previous_entry_length
属性的长度为1
字节: 前一节点的长度就保存在这一个字节里面。 - 如果前一节点的长度大于等于
254
字节, 那么previous_entry_length
属性的长度为5
字节: 其中属性的第一字节会被设置为0xFE
(十进制值254
), 而之后的四个字节则用于保存前一节点的长度。
要注意的是, 尽管连锁更新的复杂度较高, 但它真正造成性能问题的几率是很低的:
- 首先, 压缩列表里要恰好有多个连续的、长度介于
250
字节至253
字节之间的节点, 连锁更新才有可能被引发, 在实际中, 这种情况并不多见; - 其次, 即使出现连锁更新, 但只要被更新的节点数量不多, 就不会对性能造成任何影响: 比如说, 对三五个节点进行连锁更新是绝对不会影响性能的;
因为以上原因, ziplistPush
等命令的平均复杂度仅为 O(N) , 在实际中, 我们可以放心地使用这些函数, 而不必担心连锁更新会影响压缩列表的性能。
7、对象
Redis 中的每个对象都由一个 redisObject
结构表示, 该结构中和保存数据有关的三个属性分别是 type
属性、 encoding
属性和 ptr
属性:
typedef struct redisObject { // 类型 unsigned type:4; // 编码 unsigned encoding:4; // 指向底层实现数据结构的指针 void *ptr; // ... } robj;
type:
类型常量 | 对象的名称 |
| 字符串对象 |
| 列表对象 |
| 哈希对象 |
| 集合对象 |
| 有序集合对象 |
字符串对象:
long double
类型表示的浮点数在 Redis 中也是作为字符串值来保存的: 如果我们要保存一个浮点数到字符串对象里面, 那么程序会先将这个浮点数转换成字符串值, 然后再保存起转换所得的字符串值。
有需要的时候, 程序会将保存在字符串对象里面的字符串值转换回浮点数值, 执行某些操作, 然后再将执行操作所得的浮点数值转换回字符串值, 并继续保存在字符串对象里面。