《Redis设计与实现》读书笔记
简单动态字符串
SDS的定义
结构:
buf数组:用于保存字符串
len属性:记录SDS中保存字符串的长度
free属性:记录buf中未使用字节数量
遵循C字符串以空字符串结尾的惯例,保存空字符串的字节不计入长度
SDS与C字符串的区别
常数复杂度获取字符串长度
因为SDS中的len属性已经记录了字符串长度,所以不需要像C字符串一样获取长度时需要遍历一遍字符串。确保获取字符串长度的工作不会限制Redis的性能瓶颈
杜绝缓冲区溢出
当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需要的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需要的大小
减少修改字符串时带来的内存重分配次数
C字符串修改时,需要程序重新分配内存,防止内存溢出或泄露。对于一个数据库来说吗,对于速度的要求时苛刻的,且数据会被频繁的修改。而重分配会占用大量时间,修改频繁的话,可能会对性能照成影响
而SDS通过free属性,实现了空间预分配与惰性空间释放两种优化策略
1.空间预分配
SDS进行修改后len小于1MB时:程序会分配和len相同大小的未使用空间
SDS进行修改后len大于1MB时:程序会分配1MB的未使用空间
2.惰性空间释放
当需要缩短SDS的字符串时,程序不会立刻重分配来回收多余字节,而是先使用free将这些字节记录起来,等待将来再使用
二进制安全
SDS API会以二进制的方式来处理SDS存放在数组里面的数据
兼容部分C字符串函数
因为SDS遵循了C字符串以空字符串结尾的惯例
SDS API
链表
链表与链表节点的实现
每个链表节点使用一个 adlist.h/listNode
结构来表示:
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
多个 listNode
可以通过 prev
和 next
指针组成双端链表
虽然仅仅使用多个 listNode
结构就可以组成链表, 但使用 adlist.h/list
来持有链表的话, 操作起来会更方便:
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
list
结构为链表提供了表头指针 head
、表尾指针 tail
, 以及链表长度计数器 len
, 而 dup
、 free
和 match
成员则是用于实现多态链表所需的类型特定函数:
dup
函数用于复制链表节点所保存的值;free
函数用于释放链表节点所保存的值;match
函数则用于对比链表节点所保存的值和另一个输入值是否相等。
Redis 的链表实现的特性可以总结如下:
- 双端: 链表节点带有
prev
和next
指针, 获取某个节点的前置节点和后置节点的复杂度都是。
- 无环: 表头节点的
prev
指针和表尾节点的next
指针都指向NULL
, 对链表的访问以NULL
为终点。 - 带表头指针和表尾指针: 通过
list
结构的head
指针和tail
指针, 程序获取链表的表头节点和表尾节点的复杂度为。
- 带链表长度计数器: 程序使用
list
结构的len
属性来对list
持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为。
- 多态: 链表节点使用
void*
指针来保存节点值, 并且可以通过list
结构的dup
、free
、match
三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。
链表和链表节点的API
字典
字典的实现
哈希表
Redis 字典所使用的哈希表由 dict.h/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
结构表示, 每个 dictEntry
结构都保存着一个键值对:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
key
属性保存着键值对中的键, 而 v
属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t
整数, 又或者是一个 int64_t
整数。
next
属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。
举个例子, 图 4-2 就展示了如何通过 next
指针, 将两个索引值相同的键 k1
和 k0
连接在一起。
字典
Redis 中的字典由 dict.h/dict
结构表示:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
type
属性和 privdata
属性是针对不同类型的键值对, 为创建多态字典而设置的:
type
属性是一个指向dictType
结构的指针, 每个dictType
结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。- 而
privdata
属性则保存了需要传给那些类型特定函数的可选参数。
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
ht
属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht
哈希表, 一般情况下, 字典只使用 ht[0]
哈希表, ht[1]
哈希表只会在对 ht[0]
哈希表进行 rehash 时使用。
除了 ht[1]
之外, 另一个和 rehash 有关的属性就是 rehashidx
: 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1
。
哈希算法
将新的键值插入字典时,程序需要先根据键值对的键计算出哈希值和索引值,然后根据索引值将新键值对所在的哈希表节点放到哈希表数组对应的索引上面
Redis 计算哈希值和索引值的方法如下:
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
解决键冲突
当两个或两个以上的键分配到哈希数组的同一个索引上时,Redis使用链地址法
解决链冲突
链地址法
哈希表节点都有一个next指针,可以使用next指针构成单向链表。
且dictEntry节点构成的链表没有指向链表末尾的指针,为了节省时间,新的节点一般直接添加到表头位置
rehash(重新散列)
目的
随着哈希表键值对的逐渐增加或减少,为了让哈希表的负载因子维持在一定范围内
步骤
- 为字典的
ht[1]
哈希表分配空间,这个哈希表的空间大小取决于要执行的操作, 以及ht[0]
当前包含的键值对数量 (也即是ht[0].used
属性的值):- 如果执行的是扩展操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used * 2
的(
2
的n
次方幂); - 如果执行的是收缩操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used
的。
- 如果执行的是扩展操作, 那么
- 将保存在
ht[0]
中的所有键值对 rehash 到ht[1]
上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到ht[1]
哈希表的指定位置上。 - 当
ht[0]
包含的所有键值对都迁移到了ht[1]
之后 (ht[0]
变为空表), 释放ht[0]
, 将ht[1]
设置为ht[0]
, 并在ht[1]
新创建一个空白哈希表, 为下一次 rehash 做准备。
哈希表的收缩与扩展
以下条件满足任意一个时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
1
- 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
5
负载因子计算公式:
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
当哈希表的负载因子小于 0.1
时, 程序自动开始对哈希表执行收缩操作
渐进式rehash
目的:
假如哈希表中保存了大量的数据,一次性将这些数据进行rehash时会产生庞大的计算量,为了防止rehash对redis的性能产生影响
渐进式rehash实现的详细步骤:
- 为
ht[1]
分配空间, 让字典同时持有ht[0]
和ht[1]
两个哈希表。 - 在字典中维持一个索引计数器变量
rehashidx
, 并将它的值设置为0
, 表示 rehash 工作正式开始。 - 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将
ht[0]
哈希表在rehashidx
索引上的所有键值对 rehash 到ht[1]
, 当 rehash 工作完成之后, 程序将rehashidx
属性的值增一。 - 随着字典操作的不断执行, 最终在某个时间点上,
ht[0]
的所有键值对都会被 rehash 至ht[1]
, 这时程序将rehashidx
属性的值设为-1
, 表示 rehash 操作已完成。
渐进式rehash执行期间的哈希表操作
- 字典的删改查等操作都会在两个哈希表之间共同进行
- 新增加的键只添加
ht[1]
上
字典API
跳跃表
跳跃表通常使用一个zskiplist结构来持有这些节点
typedef struct zskiplist{
//表头节点和表尾节点
structz skiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点的层数
int level;
} zskiplist;
跳跃表的API
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L9VGQI1W-1683477581980)(D:\图片\typora\image-20230427202149017.png)]
整数集合
集合键的底层实现之一,只用于保存整数值元素
整数集合的实现(intset)
每个 intset.h/intset
结构表示一个整数集合:
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
contents
数组是整数集合的底层实现: 整数集合的每个元素都是 contents
数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项。
length
属性记录了整数集合包含的元素数量, 也即是 contents
数组的长度。
虽然 intset
结构将 contents
属性声明为 int8_t
类型的数组, 但实际上 contents
数组并不保存任何 int8_t
类型的值 —— contents
数组的真正类型取决于 encoding
属性的值:
- 如果
encoding
属性的值为INTSET_ENC_INT16
, 那么contents
就是一个int16_t
类型的数组, 数组里的每个项都是一个int16_t
类型的整数值 (最小值为-32,768
,最大值为32,767
)。 - 如果
encoding
属性的值为INTSET_ENC_INT32
, 那么contents
就是一个int32_t
类型的数组, 数组里的每个项都是一个int32_t
类型的整数值 (最小值为-2,147,483,648
,最大值为2,147,483,647
)。 - 如果
encoding
属性的值为INTSET_ENC_INT64
, 那么contents
就是一个int64_t
类型的数组, 数组里的每个项都是一个int64_t
类型的整数值 (最小值为-9,223,372,036,854,775,808
,最大值为9,223,372,036,854,775,807
)。
升级
目的:
解决新元素插入整数集合时,新元素过长,集合存储空间不足
步骤:
- 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
升级的好处
提升灵活性
通常一个数据结构中只使用一种类型的值
而整数集合可以通过升级底层数组来适应新的元素,防止出现类型错误,这种做法非常的灵活
节约内存
整数集合既可以让集合能同时保存三种不同类型的值, 又可以确保升级操作只会在有需要的时候进行, 这可以尽量节省内存。
降级
整数集合不支持降级
整数集合的API
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uoe4IeO9-1683477581981)(D:\图片\typora\image-20230427205549145.png)]
压缩列表
是列表键和哈希键的底层实现之一,用来存储较小的整数值或长度较短的字符串
压缩列表的构造
压缩列表的各个组成部分及用途
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gCdSAVXw-1683477581982)(D:\图片\typora\image-20230427214709482.png)]
- zlbytes
- 类型:uint32_t
- 长度:
4
字节 - 用途:记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算
zlend
的位置时使用
- zltail
- 类型:uint32_t
- 长度:
4
字节 - 用途:记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
- zllen
- 类型:uint16_t
- 长度:
2
字节 - 用途:记录了压缩列表包含的节点数量: 当这个属性的值小于
UINT16_MAX
(65535
)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于UINT16_MAX
时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
- entryX
- 类型:列表节点
- 长度:不定
- 用途:压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
- zlend
- 类型:uint8_t
- 长度:
1
字节 - 用途:特殊值
0xFF
(十进制255
),用于标记压缩列表的末端。
压缩列表节点的构成
每个压缩列表节点都由 previous_entry_length
、 encoding
、 content
三个部分组成
previous_entry_length
**作用:**以字节为单位,记录了压缩列表前一个节点的长度,可以根据当前节点的起始地址来计算出前一个节点的起始地址。
- 如果前一节点的长度小于
254
字节, 那么previous_entry_length
属性的长度为1
字节: 前一节点的长度就保存在这一个字节里面。 - 如果前一节点的长度大于等于
254
字节, 那么previous_entry_length
属性的长度为5
字节: 其中属性的第一字节会被设置为0xFE
(十进制值254
), 而之后的四个字节则用于保存前一节点的长度。
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的: 只要我们拥有了一个指向某个节点起始地址的指针, 那么通过这个指针以及这个节点的 previous_entry_length
属性, 程序就可以一直向前一个节点回溯, 最终到达压缩列表的表头节点。
encoding
节点的 encoding
属性记录了节点的 content
属性所保存数据的类型以及长度:
- 一字节、两字节或者五字节长, 值的最高位为
00
、01
或者10
的是字节数组编码: 这种编码表示节点的content
属性保存着字节数组, 数组的长度由编码除去最高两位之后的其他位记录; - 一字节长, 值的最高位以
11
开头的是整数编码: 这种编码表示节点的content
属性保存着整数值, 整数值的类型和长度由编码除去最高两位之后的其他位记录;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1boitoX9-1683477581982)(D:\图片\typora\image-20230427222657490.png)]
content
节点的 content
属性负责保存节点的值, 节点值可以是一个字节数组或者整数, 值的类型和长度由节点的 encoding
属性决定。
连锁更新
**原因:**由于previous_entry_length
的特性,新加入的节点大于或等于254,导致下一个节点的previous_entry_length
需要修改,修改后又大于或等于254,由此往复,出现需要连续修改大量的previous_entry_length
(也有可能由于删除而导致连锁更新)
但是,由于一连串250左右的节点出现几率很小,且重新分配的时间复杂的为O(N)
,所以连锁更新的复杂度为O(N)
,不必担心连锁更新会影响性能
压缩列表API
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BFlab1CZ-1683477581982)(D:\图片\typora\image-20230427224053600.png)]
对象
对象类型与编码
Redis中的对象使用redisObject结构来表示:
该结构中和保存数据有关的三个属性分别是 type
属性、 encoding
属性和 ptr
属性:
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
类型
- 当我们称呼一个数据库键为“字符串键”时, 我们指的是“这个数据库键所对应的值为字符串对象”;
- 当我们称呼一个键为“列表键”时, 我们指的是“这个数据库键所对应的值为列表对象”
type属性:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h9RGO1I0-1683477581983)(D:\图片\typora\image-20230427230753390.png)]
TYPE命令:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NvpfcEoS-1683477581983)(D:\图片\typora\image-20230427230824817.png)]
编码和底层实现
ptr
指向底层实现
encoding
记录了对象使用的编码
属性的值为下面常量中的一个:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uxVFqttt-1683477581983)(D:\图片\typora\image-20230427231356913.png)]
每种类型的对象至少含有两种不同的编码:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6nb1pfYc-1683477581984)(D:\图片\typora\image-20230427231439165.png)]
不同编码的对象所对应的 OBJECT ENCODING 命令输出:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8arHD7LS-1683477581984)(D:\图片\typora\image-20230427231516146.png)]
通过 encoding
属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率
字符串对象
字符串对象的编码可以是int、raw或者embstr
- 如果字符串对象保存的是整数值,那么编码设置为int
- 如果字符串对象保存的是字符串值,并且字节长度大于39字节,那么编码设置为raw
- 如果字符串对象保存的是字符串值,并且字节长度小于等于39字节,那么编码设置为embstr
使用 embstr
编码的字符串对象来保存短字符串值有以下好处:
embstr
编码将创建字符串对象所需的内存分配次数从raw
编码的两次降低为一次。- 释放
embstr
编码的字符串对象只需要调用一次内存释放函数, 而释放raw
编码的字符串对象需要调用两次内存释放函数。 - 因为
embstr
编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起raw
编码的字符串对象能够更好地利用缓存带来的优势。
字符串对象保存各种类型值得编码方式:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rajvvj6B-1683477581984)(D:\图片\typora\image-20230427233301448.png)]
编码的转换
int
编码的字符串对象和 embstr
编码的字符串对象在条件满足的情况下, 会被转换为 raw
编码的字符串对象。
- 对于int编码的字符串对象,我们执行一些命令,使得不再是整数值,而是一个字符串值,那么将从int转变为raw类型
- embstr编码的字符串对象实际上是只能读的,当我们对embstr编码的对象执行任何操作时,都会将对象编码从embstr转变为raw
字符串命令的实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EQQV3VAo-1683477581984)(D:\图片\typora\image-20230427234214642.png)]
列表对象
列表对象的编码可以是 ziplist
或者 linkedlist
- ziplist编码的列表对象是使用压缩列表作为底层实现的
- linkedlist编码的列表对象是使用双端链表作为底层实现的
编码转换
当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist
编码:
- 列表对象保存的所有字符串元素的长度都小于
64
字节; - 列表对象保存的元素数量小于
512
个;
不能满足这两个条件的列表对象需要使用 linkedlist
编码。
注意:以上两个条件的上限是可以修改的,具体需查看配置文件
当使用ziplist编码的对象无法满足上面的两个条件中的任意一个时,对象编码装换操作将被执行,原本保存在压缩列表里面的元素将会被移到双端列表中。ziplist编码也会变成linkedlist编码
列表命令的实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e4blneuN-1683477581985)(D:\图片\typora\image-20230428000124963.png)]
哈希对象
哈希对象的编码可以是ziplist或者hashlist
ziplist编码的哈希对象,有新的键值加入哈希对象时,程序先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值得压缩对象节点保存到压缩列表表尾
hashtable编码的哈希对象,哈希对象每个字典键值对保存一个哈希键值对
编码装换
当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist
编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
64
字节; - 哈希对象保存的键值对数量小于
512
个;
不能满足这两个条件的哈希对象需要使用 hashtable
编码。
注意:以上两个条件的上限是可以修改的,具体需查看配置文件
转换方式与列表对象转换相同
哈希命令的实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7kKApRqm-1683477581985)(D:\图片\typora\image-20230428161738198.png)]
集合对象
集合对象的编码可以是intset和hashtable
hashtable编码的集合对象,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字符串的值则是全部设置为NULL;
编码的转换
当集合对象可以同时满足以下两个条件时, 对象使用 intset
编码:
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过
512
个;
不能满足这两个条件的集合对象需要使用 hashtable
编码。
注意:第二个条件的上限是可以修改的,具体需查看配置文件
转换方式与列表对象转换相同
集合命令的实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Npzh5Rsn-1683477581985)(D:\图片\typora\image-20230428162538380.png)]
有序集合对象
有序集合的编码可以是ziplist或者skiplist
ziplist编码的有序集合对象,第一个节点保存元素的成员,二第二个节点保存元素的分值。压缩列表内的集合元素按分值从小到大进行排序
skiplist编码的有序集合使用zset结构作为底层实现,zset结构包含一个字典和一个跳跃表
skiplist编码的有序集合同时使用字典和跳跃表的原因:
- 跳跃表和字典都会使用指针指向同一个元素和分值,不会造成空间浪费
- 单独使用字典时,虽然查找操作的时间复杂度为
O(1)
,但是当需要对集合对象进行范围性操作时,需要的时间复杂度较高,且需要而外的内存空间 - 单独使用跳跃表时,范围操作的时间复杂度较小,但是查找起来相比字典会低很多
编码的转换
当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist
编码:
- 有序集合保存的元素数量小于
128
个; - 有序集合保存的所有元素成员的长度都小于
64
字节;
不能满足以上两个条件的有序集合对象将使用 skiplist
编码。
注意:以上两个条件的上限是可以修改的,具体需查看配置文件
转换方式与列表对象转换相同
有序集合命令的实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hdTXt3Xn-1683477581986)(D:\图片\typora\image-20230428164626499.png)]
类型检查与命令多态
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
属性来实现的
多态命令的实现
Redis除了根据对象的类型来判断键是否能够执行该命令之外,还会根据值对象的编码方式来选择正确的命令
内存回收
Redis自己创建了一个应用计数
技术来实现内存的回收机制,程序可以通过跟踪对象的应用计数信息,在适当的时候自动释放对象并进行内存回收
每个对象的引用计数信息由 redisObject
结构的 refcount
属性记录:
typedef struct redisObject {
// ...
// 引用计数
int refcount;
// ...
} robj;
对象的引用计数信息会随着对象的使用状态而不断变化:
- 在创建一个新对象时, 引用计数的值会被初始化为
1
; - 当对象被一个新程序使用时, 它的引用计数值会被增一;
- 当对象不再被一个程序使用时, 它的引用计数值会被减一;
- 当对象的引用计数值变为
0
时, 对象所占用的内存会被释放。
修改对象引用计数的API
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-65pPuXsw-1683477581986)(D:\图片\typora\image-20230428170816948.png)]
对象共享
除了用于实现引用计数内存回收机制之外, 对象的引用计数属性还带有对象共享的作用
在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增一。
目前来说,Redis会在初始化服务器时创建一万个字符串对象,这些对象包含了0~9999
创建共享字符串对象的数量可以通过修改 redis.h/REDIS_SHARED_INTEGERS 常量来修改
例外,数据结构中嵌套了字符串对象的,也可以使用这些共享字符串对象
为什么Redis不使用包含共享字符串的对象?
- 由于当服务器考虑将共享对象设置为键的值对象时,需要先检查共享对象与待创建的目标对象是否完全一致。而越复杂的对象,对比起来所需要的复杂度越高,消耗的CPU时间也会越高
对象的空转时长
除了前面介绍过的 type
、 encoding
、 ptr
和 refcount
四个属性之外, redisObject
结构包含的最后一个属性为 lru
属性, 该属性记录了对象最后一次被命令程序访问的时间:
typedef struct redisObject {
// ...
unsigned lru:22;
// ...
} robj;
OBJECT IDLETIME 命令可以打印出给定键的空转时长, 这一空转时长就是通过将当前时间减去键的值对象的 lru
时间计算得出的
如果服务器打开了 maxmemory
选项, 并且服务器用于回收内存的算法为 volatile-lru
或者 allkeys-lru
, 那么当服务器占用的内存数超过了 maxmemory
选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存
第二部分(单机数据库的实现)
数据库
服务器中的数据库
Redis服务器将所有的数据库都保存在服务器状态redis.h/redisServer结构的db数组中,每个db数组项中保存了一个redisDb结构,每个redisDb结构代表一个数据库
struct redisServer{
// ...
// 一个数组,保存着服务器中的所有数据库
redisDb *Db;
// ...
// 服务器的数据库数量
int dbnum;
// ...
};
初始化数据库时,程序回根据数据库状态的dbnum属性来决定创建多少个数据库
切换数据库
实现是由redisClient结构中的db属性指向redisServer结构中的db数组中的一个元素,指向的元素为选择的数据库,切换则切换指向
typedef struct redisClinet{
// ...
// 记录客户端当前使用的数据库
redisDb *Db;
// ...
}redisClinet;
数据库键空间
Redis是一个键值对数据库服务器,由redisDb结构实现,其中redisDb结构中的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间
typedef struct redisDb {
// ...
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
// ...
} redisDb;
键空间和用户所见的数据库是直接对应的:
- 键空间的键也就是数据库的键, 每个键都是一个字符串对象。
- 键空间的值也就是数据库的值, 每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象在内的任意一种 Redis 对象。
针对数据库的操作实际是对键空间的操作
添加新键
实际是添加键空间的键值对象,键为字符串对象,值为任意一种类型的Redis对象
删除键
实际是删除键空间中的键值对
更新键
实际是更新键空间中键所对应的值对象,根据值对象得不同,更新的具体方法也不同。
对键取值
实际是获取键空间中键所对应的值对象,根据值对象得不同,获取的具体方法也不同。
其他键空间操作
这些命令都是通过对键空间的操作实现的
读写键空间时的维护操作
- 读取键之后(读写操作都需要读取键),服务器回根据键是否存在来更新键空间命中(hit)次数或键空间不命中(miss)次数, 这两个值可以在 INFO stats 命令的
keyspace_hits
属性和keyspace_misses
属性中查看。 - 读取键之后,服务器会更新键的 LRU (最后一次使用)时间, 这个值可以用于计算键的闲置时间, 使用命令 OBJECT idletime 命令可以查看键
key
的闲置时间。 - 如果服务器读取一个键时,发现该键已经失效,会先删除该键再进行之后的操作
- 如果有客户端使用 WATCH 命令监视了某个键, 那么服务器在对被监视的键进行修改之后, 会将这个键标记为脏(dirty), 从而让事务程序注意到这个键已经被修改过
- 服务器每次修改一个键之后, 都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行
- 如果服务器开启了数据库通知功能, 那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知
设置键的生存时间和过期时间
- 通过客户端可以以秒、毫秒为精度设置数据库中的某个键的生存时间,服务器会自动删除生存时间为0的键
- 用户可以以毫秒、秒为单位给数据库中的某个键设置过期时间,当过期时间来临时,服务器会自动从数据库中删除该键
设置过期时间
保存过期时间
redisDb中的expires字典保存了数据库中所有键的过期时间,简称过期字典
- 过期字典中的键是一个指针,这个指针指向键空间中的某个键对象
- 过期字典的值是一个long long类型的指针,用于保存过期时间----一个毫秒进度的UNIX时间戳
typedef struct redisDb {
// ...
// 过期字典,保存着过期键的时间
dict *expires;
// ...
} redisDb;
过期键的判定
- 检查给定键是否存在与过期字典:如果存在,那么取得键的过期时间
- 检查当前UNIX时间戳是否大于键的过期时间