# 概述

《redis 设计与实现》是基于 2.9 版本写作的,现在(2023.06.27)redis 已经更新到 7.0.11 版本了,虽然书中有些介绍已经过时,但是还是可以对 redis 建立起大致的认知的。

# 一、数据结构与对象

redis 有五种基本类型:String、List、Hash、Set、ZSet,目前 7 版本还有五种特殊类型:HyperLogLog、Geospatial、Stream、Bitmaps、Bitfields。

# 数据结构

五种常用的基本类型,底层使用的数据结构有:SDS(Simple Dynamic String)、list、hashtable、zipList、intset、skiplist。

graph TB;
String ==> SDS
List ==> list
List ==> ziplist
Hash ==> ziplist
Hash ==> hashtable
Set	==> hashtable
Set ==> intset
Zset ==> ziplist
Zset ==> skiplist

在 redis3.2 版本之后 List 底层数据采用快表(quicklist)实现。

# SDS

简单动态字符串,其(3.2 版本之前)结构是:

struct sdshdr {
    unsigned int len;   //buf 中已经使用的长度
    unsigned int free;  //buf 中未使用的长度
    char buf[];         // 柔性数组 buf
};

SDS 与 C 字符串区别:

  • 与 C 字符串不同,使用额外两个变量记录长度,可以使得我们可以 O (1) 时间复杂度级别获取字符串长度。
  • 二进制安全:C 字符串只能使用 ASCII 编码,可能因为二进制文件中可能包含空字符 '\0',导致 C 字符串误以为终止符,而 sds 使用 len 属性判定终止,不会与文件内容冲突。
  • 杜绝缓存区溢出:C 字符串中,如果出现两个字符串在内存中相邻,修改前字符串 1(长度变长)而不分配空间,就会导致字符串 2 内容被修改。SDS 在修改字符串会先判断给定空间是否足够,足够就直接修改,不够就会先分配空间再去修改。
  • 减少字符串修改带来的内存重分配次数:C 字符串并不记录自身长度,每次修改都需要进行内存重分配:增长字符串不进行内存重分配 — 缓冲区溢出,缩小字符串长度不进行内存重分配 — 内存泄露。Redis 作为数据库,如果每次修改都去做内存中分配,就会对性能造成影响,所以 redis 采用空间预分配以及惰性空间释放策略。

# 空间预分配

在 SDS 增长字符串长度时,如果发现原本的空间不足以修改,需要内存重分配,除了为 SDS 分配修改所必需的空间,还会分配额外的空间:

  • 如果 SDS 修改之后长度小于 1MB,程序会分配和 len 属性同样大小的未使用空间,此时 SDS 的值等于 free 的值
  • 如果 SDS 修改之后长度大于等于 1MB,会分配 1MB 的未使用空间

执行过 APPEND 命令的字符串会带有额外的预分配空间,这些预分配空间不会被释放,除非该字符串所对应的键被删除,或者等到关闭 Redis 之后,再次启动时重新载入的字符串对象将不会有预分配空间。

# 链表 list

作为 List 类型的底层实现,3.2 之后就废用了,使用快表代替。

typedef struct listNode{
    struct listNode *prev;
    struct listNode *next;
    void *value;
}

双端链表

typedef struct list{
	struct listNode *head;
    struct listNode *tail;
    unsigned long len;

}

# 压缩列表 ziplist

压缩列表是列表(3.2 版本之前)和 Hash 表底层实现。

大致结构:

zlbytesztailzllenentry1...entryXzlend
  • zlbytes:压缩列表总字节数
  • ztail:尾部偏移量
  • zllen:节点数量
  • entryX:节点

entry 结构:

previous_entry_lengthencodingcontent
  • previous_entry_length: 前一个节点的长度
  • encoding:值的编码
  • content:存储内容(指针指向)
/* ziplist header 的大小:两个 32 位整数,用于总字节计数 (bytes) 和最后一项偏移量 (ZIPLIST_TAIL_OFFSET)。一个 16 位整数,节点数 (ZIPLIST_LENGTH (zl))。*/
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))
/* ziplist 结尾标识,1 字节. */
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))
/* 创建一个新的压缩列表 . */
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE + ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    // 压缩列表总字节长度
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    // 尾节点的偏移量
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    // 节点个数
    ZIPLIST_LENGTH(zl) = 0;
    // 特殊结尾值
    zl[bytes-1] = ZIP_END;
    return zl;
}
// 压缩列表节点
typedef struct zlentry {    
    unsigned int prevrawlensize //prevrawlen 的大小,有 1 字节和 5 字节两种
    unsigned int prevrawlen;    //prevrawlen 是前一个节点的长度
    unsigned int lensize;		//  lensize 为编码 len 所需的字节大小
    unsigned int len 			//len 为当前节点长度
    unsigned int headersize;    // 当前节点的 header 大小
    unsigned char encoding; 	// 节点的编码方式
    unsigned char *p;   		// 指向节点的指针
} zlentry;

# 快表 quicklist

3.2 版本之后作为 List 类型的底层实现,是双端链表和压缩列表的结合,使用压缩列表作为双端链表的节点

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *entry; /* 指向压缩列表 */
    size_t sz;             /* ziplist 字节数 */
    unsigned int count : 16;     /* ziplist 节点数量 */
    unsigned int encoding : 2;   /* 编码 RAW=1,LZF=2 */
    unsigned int container : 2;  /* 容器,NONE=1,ziplist=2 */
    unsigned int recompress : 1; /*  节点是否已被压缩 */
    unsigned int attempted_compress : 1; /*  测试使用,在压缩时,设为 1,解压时设为 0 */
    unsigned int dont_compress : 1; /* 防止压缩稍后将使用的节点 */
    unsigned int extra : 9; /* 留用字段 */
} quicklistNode;
// 存储 LZF 压缩后的数据,对于 quicklistNode 来说:
// 如果数据未被压缩,数据直接存储在 node->zl 中
// 如果数据被压缩,则 node->zl 存储一个 quicklistLZF,压缩后的数据存在 quicklistLZF 中
typedef struct quicklistLZF {
    unsigned int sz; //compressed 的大小
    char compressed[]; // LZF 压缩后的数据
} quicklistLZF;
// 书签 
typedef struct quicklistBookmark {
    quicklistNode *node; // 书签标记的节点
    char *name; // 书签名
} quicklistBookmark;
// 快表定义,size: 40 bytes
typedef struct quicklist {
    quicklistNode *head; // 头节点
    quicklistNode *tail; // 尾节点
    unsigned long count; // 元素的数量
    unsigned long len; //quicklistNode 的数量
    int fill : QL_FILL_BITS; /* QL_FILL_BITS=16 单个节点的填充系数
        如果为负数则使用的为默认值,参考 optimization_level*/
    unsigned int compress : QL_COMP_BITS;  /* QL_COMP_BITS=16,
        压缩深度,对整个列表压缩时,只压缩头尾往中各 compress 个节点,如果为 0 则表示不压缩 */
    unsigned int bookmark_count : QL_BM_BITS;  /* QL_BM_BITS=4,
        书签的数量;默认只给了 4 位,空了 4 位没有使用,使用过多的书签会导致性能下降 */
    quicklistBookmark bookmarks[]; // 当前使用的书签
} quicklist;
// 快表条目
typedef struct quicklistEntry {
    const quicklist *quicklist; // 条目所属快表
    quicklistNode *node; // 条目所属节点
    unsigned char *zi; //ziplist item 指针
    unsigned char *value; // 条目的字符串值起始指针
    long long longval; // 条目的整数值
    unsigned int sz; // 条目的大小
    int offset; // 条目在节点中的偏移量(位置)
} quicklistEntry;
快表源码解释原文链接:https://blog.csdn.net/weixin_44016271/article/details/110286635

# 字典 / 哈希表 dict

字典是 Set 和 Hash 的底层数据结构。以下是 7 版本的源码。

struct dict {
    dictType *type;
	// 哈希表数组,有两个用于 rehash
    dictEntry **ht_table[2];
    // 两张哈希表中,分别使用了多少
    unsigned long ht_used[2];
    long rehashidx; /* 记录 rehash 进度的标志,-1 表示 rehash 未进行 */
    /* Keep small vars at end for optimal (minimal) struct padding */
    //pauserehash 为 6 后新加入的特性,为保证效率新增 rehash 的 paused 的操作。
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
    signed char ht_size_exp[2]; /* hash 表的长度,2 的 n 次方 */
    void *metadata[];           /* An arbitrary number of bytes (starting at a
                                 * pointer-aligned address) of size as defined
                                 * by dictType's dictEntryBytes. */
};
/*
* dictType 中是一个存放函数的结构体,定义了一些函数指针。
*/
typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);// 哈希函数
    void *(*keyDup)(dict *d, const void *key);// 复制 key
    void *(*valDup)(dict *d, const void *obj);// 复制 val
    int (*keyCompare)(dict *d, const void *key1, const void *key2);// 比较 key
    void (*keyDestructor)(dict *d, void *key);// 删除 key
    void (*valDestructor)(dict *d, void *obj);// 删除 val
    int (*expandAllowed)(size_t moreMem, double usedRatio);
    /* Allow a dictEntry to carry extra caller-defined metadata.  The
     * extra memory is initialized to 0 when a dictEntry is allocated. */
    size_t (*dictEntryMetadataBytes)(dict *d);
} dictType;
typedef struct dictEntry {
    void *key;// 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;// 值
    struct dictEntry *next;     /* Next entry in the same hash bucket. */
    void *metadata[];           /* An arbitrary number of bytes (starting at a
                                 * pointer-aligned address) of size as returned
                                 * by dictType's dictEntryMetadataBytes(). */
} dictEntry;

在书中有提到 dict 是这样的:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    int trehashidx;
} dict;
typedef struct dictht {
    dictEntry **table;
    dictType *type;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
};

但是在 7 版本并未使用,而是直接使用 dictEntry **ht_table[2]; 等属性来构造字典

# rehash

  • 当负载因子小于 0.1 时,进行收缩操作。
  • 当未执行 BGSAVE/BGREWRITEAOF 命令时,负载因子大于等于 1 时,进行扩展操作。
  • 当在执行 BGSAVE/BGREWRITEAOF 命令时,负载因子大于等于 5 时,进行扩展操作。

当执行 BGSAVE/BGREWRITEAOF 时提高扩展所需负载因子,主要是因为执行这两个命令时都需要 redis 创建子进程,而此时进行 rehash 操作可能会触发子进程的 "写时复制" 机制。所以此时减少 rehash 操作即可避免不必要的内存写入操作,最大限度的节约内存。

rehash 步骤:

  1. 为字典两个哈希表中下标为 1 的未使用的哈希表分配空间:
    • 扩展操作:ht [1] 大小 = 大于等于 ht [0].used*2 的 2 的 n 次方。(15 扩展后 32)
    • 收缩操作:ht [1] 大小 = 大于等于 ht [0].used 的 2 的 n 次方。 (12 收缩后 16)
    • 注意是按照已使用也就是元素个数来 rehash,并不是按照 ht [0] 的空间大小来分。
  2. 将保存在 ht [0] 的所有键值对 rehash 到 ht [1] 中;
  3. 迁移完成后 ht [0] 变为空表,释放 ht [0] 空间,将 ht [1] 设置成 ht [0],并为 ht [1] 创建空白哈希表,为下一次 rehash 做准备。

# 渐进式 rehash

为了防止在进行扩展 / 收缩的 rehash 时,由于数据过多造成服务器停止服务,采用了渐进式 rehash 思路。

  1. 为 ht [1] 分配空间,此时字典同时持有两个哈希表;
  2. 将 rehashidx 设置为 0,表示 rehash 开始;
  3. 在 rehash 期间,每次对字典执行增删改查都会额外将 ht [0] 中 rehashidex 索引上的的键值对 rehash 到 ht [1] 中,然后对于 rehashidx++;
  4. 所有键值对都被 rehash 到 ht [1] 上之后,rehashidx 置为 - 1,rehash 完成。

# 跳跃表 skiplist

跳跃表是 ZSet 的底层数据结构,跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构。redis 跳跃表并没有在单独的类(比如 skplist.c) 中定义,而是其定义在 server.h 中:

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    // 持有数据,是 sds 类型
    sds ele;
    // 结点之间凭借得分来判断先后顺序,跳跃表中的结点按结点的得分升序排列.
    double score;
    // 原版跳跃表中所没有的。该指针指向结点的前一个紧邻结点.
    struct zskiplistNode *backward;
    //level 数组用以记录所有结点 (除过头节点外),每个结点中最多持有 32 个 zskiplistLevel 结构
    struct zskiplistLevel {
        // 指向比自己得分高的某个结点 (不一定是紧邻的)
        struct zskiplistNode *forward;
        // 表 forward 字段指向的结点,距离当前结点的距离. 
        unsigned long span;
    } level[];
} zskiplistNode;
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

为什么不使用其他数据结构?

  • skiplist 和各种平衡树(如 AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个 key 的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
更新于 阅读次数 本文阅读量:

请我喝[茶]~( ̄▽ ̄)~*

Windlinxy 微信支付

微信支付

Windlinxy 支付宝

支付宝