简单动态字符串SDS
在Redis中,使用简单动态字符串(simple dynamic string)作为默认字符串表示。
SDS 结构如下:
struct sdshdr {
int len; // 字符串长度 单位:字节
int free; // 未使用长度
char buf[]; // 字符串内容
}
SDS保留C字符串以空字符结尾的惯例,结尾所需空间不计算在len长度里。
同时,也因为保留C字符串空字符结尾的惯例,能够兼容部分C字符串函数,避免了不必要的代码重复。
与C字符串的区别
- C字符串没有记录len,获取长度的时间复杂度是O(n)。SDS中使用len记录了字符串长度,因此获取长度的时间复杂度是O(1)。
- C字符串不记录长度,在进行一些如strcat操作的时候,默认认为已经分配了足够的空间,因此在空间不足的时候,会产生缓冲区溢出,造成数据混乱。SDS在修改前会先检查当前空间是否满足修改需求,若不足会进行自动扩展,再执行操作,杜绝了缓冲区溢出。
- C字符串在存储的时候,总是N+1个字符长的数组,因此每增加/缩短字符串的时候,都要对字符串数组进行重分配操作。SDS会在需要空间扩展的时候,进行空间预分配,若修改后SDS长度<1MB,则会分配当前字符串长度的free空间,即实际长度为len+len+1byte;若修改后SDS长度≥1MB,则会分配1MB的free空间,即实际长度为1MB+len+1byte。此外,SDS还采用惰性空间释放,在字符串缩短的时候,不进行内存重分配。因此SDS减少了字符串长度改变带来的内存重分配次数。
- 由于C字符串没有记录长度,只以 \0 作为结尾标志,若存储了空字符,会出现读取字符串不完整的情况,这导致C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。SDS是二进制安全的,所有SDS的API会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,被读取就是怎么样。
链表
Redis中的链表是双端、无环( head 节点的 prev 指针和 tail 节点的 next 指针都指向 null )、具有多态性(链表节点使用 void* 来保存值)的链表。
// 定义链表节点
typedef struct listNode {
struct listNode *prev; // 前节点
struct listNode *next; // 后节点
void *value; // 值
} listNode;
// 定义链表
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;
字典
字典是一种用于保存键值对的抽象数据结构。
Redis字典使用哈希表作为底层实现,一个哈希表里面可以有多个节点,而每个节点就保存了字典中的一个键值对。
// 哈希表节点
typedef struct dictEntry {
void *key; // 键
union { // 值 可以是指针/uint64_t整数/int64_t整数
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next; // 下个节点
} dictEntry;
// 哈希表
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码 和哈希值一起决定一个键应该放在table的哪个索引上
unsigned long used; // 已有节点数量
} dictht;
// 字典
typedef struct dict {
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表
int rehashidx; // rehash进度 若当前不在rehash,则为-1
} dict;
从上面的定义我们可以看到字典的哈希表 ht 有2个项,正常情况下只会使用 ht[0] , rehashidx 的值也默认为-1。只有在 rehash 的时候会使用 ht[1] ,并用 rehashidx 记录当前 rehash 的进度。
渐进式 rehash
在进行 rehash 的时候,不是马上将 ht[0] 的哈希节点重分配到 ht[1] 的,而是渐进完成的,详细步骤如下:
- 为 ht[1] 分配空间。
- 将字典中的 rehashidx 值设置为0,表示rehash开始工作。
- 在 rehash 进行期间,每次对字典进行操作,会顺带将 ht[0] 哈希表在 rehashidx 上的所有键值对重新分配到 ht[1] 中,完成后将 rehashidx+1。
- 重复步骤3直至 ht[0] 所有键值对被 rehash 到 ht[1]。
- 释放 ht[0] ,并将 ht[1] 设置为 ht[0] , 同时为 ht[1] 分配一个空白的哈希表。并将 rehashidx 设置为-1,表示 rehash 工作完成。
如下图,为 ht[1] 分配完空间后,将 rehashidx改为0,然后在第一次操作字典的时候,会将 ht[0] 里哈希表索引为0的所有哈希节点(k2)进行 rehash 到 ht[1],然后修改rehashidx=rehashidx+1=1;在第二次操作字典的时候,会将 ht[0] 里哈希表索引为1的所有哈希节点(k0)进行 rehash 到 ht[1],然后修改rehashidx=rehashidx+1=2;一直重复直到遍历完哈希表。
跳跃表
跳跃表是一种有序的数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
// 跳跃表节点
typedef struct zskiplistNode {
struct zskiplistNode *backward; // 后退指针
double score; // 分值
robj *obj; // 成员对象
struct zskiplistLevel { // 层
struct zskiplistNode *forword; // 前进指针
unsigned int span; // 跨度
} level[];
} zskiplistNode;
// 跳跃表
typedef struct zskiplist {
structz skiplistNode *header, *tail; // 头、尾指针
unsigned long length; // 节点数量(除头结点)
int level; // 节点最大层次(除头结点)
}
示例如下:
整数集合
当一个集合只包含整数值元素,且元素数量不多的时候,Redis 会使用整数集合作为集合键的底层实现。
// 整数集合
typedef struct intset {
uint32_t encoding; // 编码方式 int16_t/int32_t/int64_t
uint32_t length; // 元素数量
int8_t contents[]; // 保存元素数组 从小到大排序
} intset;
升级
若向整数集合新增一个比原本元素的类型都要长的元素的时候,需要先进行升级。具体步骤如下:
- 按新元素类型的大小,扩展数组空间,同时为新元素分配空间。
- 将原本的元素进行类型转换,并放置在正确的位置。(原本就是有序的,因此后移即可)
- 添加新元素。
好处:提高灵活性(无需重新创建、复制集合),尽可能节约内存(不在一开始就创建最大的内存)
整数集合不支持降级操作,一旦进行了升级,编码就会一直保持升级后的状态,即使原本“大类型”的元素被删除。
压缩列表
当一个列表键只包含少量列表项,并且每个列表项要么是小整数,要么是长度较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
压缩列表具体组成如下:
压缩节点具体组成如下:
通过 previous_entry_length 和当前节点的起始地址,能够计算出前一个节点的起始地址。
通过 encoding 的前两位来区分是字节数组编码,还是整数编码。整数编码(1字节)前2位是 11,此外都是字节编码。在字节编码中,又根据编码长度进行细分,00表示1字节的字节数组编码;01表示2字节的字节数组编码;10表示5字节的字节数组编码。
连锁更新
阅读文章:https://blog.youkuaiyun.com/weixin_45729809/article/details/123789656
参考
参考《Redis设计与实现》第一部分:数据结构与对象