Redis底层数据结构

《Redis 设计与实现》
《Redis 设计与实现》图片集

1. 简单动态字符串(SDS)

Redis 的字符串数据类型是个包装了字节数组的结构体。它的结构是这样的:

struct sdshdr {
    int len;
    int free;
    char buf[];
}

其中,

  • buf 是字节数组
  • len 记录了 buf 中保存字符串的长度
  • free 记录 buf 字节数组中空闲位置的数量

实际 len(buf) == len + free + 1,这是因为 SDS 中存的字符串结尾总会有一个空字符 \0,但对于使用者是透明的。使用这一特性是为了方便重用一些 C 语言的库函数。

SDS 优点

Redis 中使用 SDS(Simple Dynamic String),有以下优点:

  • 计算长度的复杂度为 O(1)
  • 先自检再修改,杜绝缓冲区溢出
  • 修改时减少内存重分配次数(预分配和惰性释放)
  • 二进制安全
  • 兼容部分 C 语言库函数

“先自检再修改”,对于 C 语言的话,在拼接字符串时都需要先手动检查内存空间够不够再可以拼接,不然可能会造成缓冲区溢出,即覆盖其他已用内存。

“减少内存重分配次数”,在字符串修改时,增长字符串要注意缓冲区溢出,剪短字符串要注意回收内存避免内存泄露。SDS 自动处理了这两个问题。空间预分配就是在分配一些额外备用的空间,即 free 字段所示。具体策略是,在分配时,若 len<1M 那free 是等量的;当 len>=1M 时,free 总是 1M。
惰性释放,就说在有需要时,释放 free 记录的空间。

2. 链表

提供节点重排,顺序访问的能力。

链节点的结构定义:

typedef struct listNode {
  struct listNode *prev;
  struct listNode *next;
  void *value; // 节点的值
} listNode;

链表的结构定义:

typedef struct list {
  listNode *head;
  listNode *tail;
  unsigned long len; // 节点总数量,读取长度 O(1)
  void *(*dup) (void *ptr); // 复制函数,多态,根据数据类型而变化
  void *(*free) (void *ptr); // 释放函数
  int *(*match) (void *ptr); // 匹配函数
} list;

链表特点

由链表及链节点的定义,可见特性:

  • 双向
  • 无环(表头的 prev、表尾的 next 都是 null)
  • 带表头表尾
  • 长度计算 O(1)
  • 多态

链表应用场景

作为底层实现的应用场景:

  • 列表的键(当键的数量比较多或包含的元素字符串比较长时)
  • 发布于订阅
  • 慢查询
  • 监视器
  • 服务器本身使用链表保存多个客户端的状态信息
  • 使用链表来构建客户端输出缓冲区(output buffer)

3. 字典

图解-字典

是数据库的底层实现,是哈希中键的底层实现。
字典由哈希表实现,哈希表中每个节点就存储了字典中的键值对。

字典结构

哈希表结构定义:

typedef struct dictht {
  dictEntry **table; // 表指针指向表数组
  unsigned long size; // 表大小,注意:是表中对应几个空间(每个空间是个单链)
  unsigned long sizemask; // 掩码,用于计算索引值(==size-1)
  unsigned long used; // 表中所有节点个数
} dictht;

哈希表节点结构定义:

typedef struct dictEntry {
  void *key; // 键
  union { // 联合体,可存一个可变类型的值
    void *val; // 
    uint64_t u64;
    int64_t s64;
  } v;
  struct dictEntry *next; // 下一个节点,形成链表
} dictEntry;

字典结构定义:

typedef struct dict {
  dictType *type; // 多态,控制字典中不同类型数据操作的一组函数
  void *privdata; // 多态,私有数据,保存不同类型特定函数的可选参数
  dictht ht[2]; // 2个 hashTable,ht[1]是在 rehash 中使用
  int rehashidx; // 没在 rehash 进度中时为 -1;渐进式递增控制 rehash 进度
}

上述 dictType 是一组函数集合,为适配不同类型多态变化,其结构定义:

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); // 销毁值的函数
}

哈希算法:MurmurHash2

Redis 使用 MurmurHash2 算法实现哈希计算,优点:即使输入的键是有规律的,算法也能给出一个好的随机分布性,并且计算速度快。

拉链法解决键冲突

Redis 使用链地址法解决键冲突,也叫拉链法,即不同键的相同hash值下使用链表存储。

为考虑速度,总是将新节点插入在链表表头位置,即插入速度O(1)。

rehash 前后

是否需要 rehash 要看负载因子 load_factor = ht[0].used / ht[0].size。过大时扩展,过小时收缩。

rehash 时机的条件

  • 若服务器进行 BGSAVEBGREWRITEAOF 并且 load_factor>=1 时,扩展;
  • 若服务其正在进行 BGSAVEBGREWRITEAOF 并且 load_factor>=5 时,扩展;
  • load_factor<0.1,收缩。

Redis 判断是否在执行上述两个操作有不同的策略是为了节省内存,因为多数操作系统采用写时复制技术来优化子进程使用效率,在子进程存在期间,加大 rehash 时负载因子的阈值,尽量避免不必要的内存操作。

rehash 前为 ht[1] 分配内存空间,公式

  • 扩展时,满足 ht[1].size = 2^n,2^n >= ht[0].used * 2,n 取最小满足值
  • 收缩时,满足 ht[1].size = 2^n,2^n >= ht[0].used,n 取最小满足值

rehash 的过程就是将键按新的掩码重新计算 hash 将表 ht[0] 迁移到表 ht[1],其过程为渐进式地,详细看下一节。

rehash 后会释放 ht[0](指向 null),并将 ht[0] 和 ht[1] 互换,此时 ht[0] 为有数据的哈希表,ht[1]为备用表。

rehash 过程:渐进式

  • 字典中维持一个计数器 rehashidx,设置为 0 时表示正式开始 rehash;
  • rehash 不是一旦开始就持续进行,而是在字典进行 CURD 时顺带每次执行一步。执行时,将 rehashidx 索引上的键值对 rehash 到 ht[1] 上,完成后 rehashidx++
  • 随着 CRUD 的进行,最终 ht[0] 所有节点都被迁移到 ht[1],此时将 rehashidx 设置为 -1,表示结束。

渐进式的特点避免了集中式操作带来的大计算量(真机智);
并且通过上述过程来看,不难知道在 rehash 期间 ht[0] 和 ht[1] 都是在使用的;
并且在执行期间,新添加的键会一律保存到 ht[1] 中,从而保证了 ht[0] 只减不增(又是个机智的地方)。

4. 跳表skiplist

图解-跳表

跳表

跳表的本质是内部维护了多个指向后续节点的指针,从而达到快速访问节点的目的。

跳表的效率可以和红黑树相媲美,平均复杂度O(logN),最差O(N)。

跳表使用场景

Redis中有序集合(ZSet)在元素数量较多或元素成员字符串较长时使用跳表作为底层实现,还有在集群节点中用作内部数据结构

跳表实现

跳表节点结构定义:

typedef struct zskiplistNode {
  struct zskiplistNode *backward; // 只向前一个节点(跨度为1)
  double score; // 分值
  robj *obj; // 成员对象,一个SDS
  struct zskiplistLevel {
    struct zskiplistNode *forward; // 指向后续节点的指针
    unsigned int span; // 跨度,表示距后续指向节点跨越了多少个元素
  } level[]; // 层数组
} zskiplistNode;

跳表的节点组合起来挂在跳表上,跳表维持了整个表的一些信息,实现:

typedef struct zskiplist {
  struct zskiplistNode *header, *tail; // 表头节点和表尾节点
  unsigned long length; // 节点数量(表头节点不算在内),O(1)
  int level; // 除表头节点外的所有节点中的最大层数
}

由定义可见:

  • “跳跃”的性质只能从前往后,从后往前是遍历;
  • 返回跳跃表长度复杂度为 O(1);

一个 ZSet 中,键名是唯一、不重复的,即上方定义的 obj;分数是可以重复的,即 score;ZSet 中首先按 score 由小到大排序,然后按 obj 的字典序排序。

注意:表头节点特殊,数据结构中只使用了它的层的数据,其他字段没用,层数总为最大层数(MaxLevel)32。同时,表头节点占用了0号下标,也方便从1开始计数。

每创建一个节点,节点的 level 长度会根据幂次定律随机生成一个介于1~32之间的值作为大小。这个大小也被称为“高度”。

跨度是用来计算排名的,在查到某个元素后,计算历史的跨度和就是此元素的排名。

跳表数据的增删改查

参考:知乎上的一篇文章 Skiplist,这里只简单提一下关键点。

查询操作会从 level 的高层开始查,如果发现下一个跳转点的值大于目标值,则降层继续查。

增加和删除需要改变前后节点的指针指向以及跨度。其实也很简单,可参考Skip List--跳表,只需改搜索路径中每一个节点的对应 level 及插入或删除节点的 level 就可以了。

另外,增删操作还可能会改变链表的 level 字段。

修改操作略。

5. 整数集合 intset

当集合中只包含整数值并且数量不多时,就时用整数集合作为底层实现。(可用 OBJECT ENCODING xxx 来查看底层实现)

结构定义

typedef struct intset {
  uint32_t encoding; // 元素类型
  uint32_t length; // 元素数量
  int8_t contents[]; // 元素数组,由小到大排列,不重复
} intset;

整数集合中元素的真正类型取决于 encoding 字段,而非定义 contents 时的类型 int8_t,encoding 字段是个枚举类型,值有 INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64,取元素时是按此类型强取对应地址数据的。

contents 中的元素根据升级规则进行类型的自动升级,没有降级

升级规则

整数集合会按输入数据的大小自动判断类型进行存储,当需要增大类型时,就会出现升级操作——将所有元素的类型都升级为较大的类型。三步:

  • 扩容。根据新元素的类型扩展底层数组的容量。
  • 元素类型转换。按排序规则(新值参与排序)中从后向前一个个移动到对应的位置(排序不变)。
  • 最后更改属性 encoding 和 length。

这样的移动规则完全在数组所占内存空间的内部执行,也是很 tricky 的。
但由于每次升级都会进行类型转换,所以复杂度是固定的O(N)

由于类型增大,所以新增的数值要么大于原集合中所有元素,要么小于原集合中所有元素(负数),所以升级完后新元素的位置要么在最后,要么在最前。

升级的好处

  • 提升灵活性。C语言是静态类型,为避免类型错误
  • 节约内存。集合可同时保存三种不同的类型,需要时升级

6. 压缩列表 ziplist

当列表键或哈希键只包含少量且为小整型或者短字符串时,压缩列表是其底层实现。

压缩列表是一种用使用了紧凑的一段内存(连续内存块)组成的一种顺序型数据结构。其节点可以保存一个字节数组或一个整数值。

压缩列表的结构组成
  • zlbytes 记录了整个压缩列表的总字节数
  • zltail 标记了最后一个元素(即entryN)对于 zlbytes 的偏移量(字节)
  • zllen 记录了节点的数量
  • entryX 列表节点,长度不定,是个结构体,参考下方节点的构成
  • zlend 标记列表结束,固定的 0xFF

压缩列表节点的构成:

压缩列表节点的组成
  • previous_entry_length 记录了上一个节点占用的字节大小,可以方便从表尾向表头进行遍历。本身位1字节还是5字节可以根据自己的第一个字节判断,当首字节标记为 0xFE 时表示本值为5字节大小,同时也表示前一个节点长度>=254字节
  • encoding 保存了数据类型和长度;其值的最高两位映射了类型,其他位表示数据长度(整型中其他位还映射了不同类型,具体自行查阅)
  • content 节点的值,其类型和长度都由 encoding 决定

列表也由插入动作,注意不是只有在两端增删。

连锁更新问题

由上我们可以发现。压缩列表这样设计,就是为了把内存使用地很紧凑,其可以通过自身存储的规则实现由前向后遍历和由后向前遍历。

但同时为了保持这种规则,也会带来一些不便。比如插入一个节点后,其后的节点都需要移动。更有特殊的,可能其后的 previous_entry_length 需要由1字节变为5字节(1字节存小于253的数,5字节存了>=254的数),若恰巧因为下一个节点变长了4个字节,下下一个节点的记录(假如原值位253,要变成257,就需要5字节存储)也需要增长4个字节……这样就发生了连锁反应。即所有的数据不仅仅是移动这么简单了,具体每个节点中还需要插入空间进行更新数据。这样操作复杂度最坏是O(N²)。

不仅插入会造成连锁更新,删除操作也会发生连锁更新。

但这种“连锁更新”的问题在实际中并不多见(条件苛刻:如在插入场景下需要正好连续的部分每个节点长度都是 250~253)。其次,被更新的节点数量不多(压缩列表的使用场景是节点数量不多时才用)。所以连锁更新的问题不会影响性能的

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容