Redis源码阅读笔记-基数树/Radix Tree

基数树/Radix Tree - rax.h

介绍与用途

Redis实现的基数树其实就是一个字典树,然后针对普通的字典树做了一些优化。
基数树是流的底层实现,除此之外,维护当前活跃的客户端连接、客户端键缓存(6.0新特性)、模块的定时器、ACL(访问控制列表,6.0新特性)中维护用户信息、redis-cluster集群的插槽管理。

结构介绍

普通的字典树就不做介绍了,网上有很多的教程,相信很多人都有个大概的了解,主要介绍一下redis做了什么优化。
在redis的实现中,一个存有foo, footer, foobar三个键的基数树如下图所示,使用[]括起来的则表示该节点是一个键,否则使用()。对于普通的基数树,比如节点[foo],则会分为3个节点f,o,o,所以redis会将多个连续,每个节点只有一个子节点,但只有最后一个节点构成键的节点压缩为一个节点,主要目的是为了减少整个树的深度,减少查询时间,尤其是在键有较长的公共前缀的情况下,比如流的ID。
在这里插入图片描述

但是,这样一种优化会导致其实现更加复杂,比如在上面的例子中添加一个键first或者移除键footer,就会涉及节点分裂或者节点合并。

// 基数树节点定义
typedef struct raxNode {
    uint32_t iskey:1; // 该节点是否构成键
    uint32_t isnull:1; // 键对应的值是否为null,为null则不存储
    uint32_t incompr:1; // 该节点是否为压缩节点
    uint32_t size:29; // 子节点数量,或者压缩后字符串长度
    /* 
     * 如果节点未被压缩,则存储一个长度为n的字符串。
     * 字符串后面是n个指针,分别指向对应位置的字符的子节点。
     * 末尾是一个该节点键对应的值(如果存在的话)。
     * 节点样例如下:
     * [header iscompr=0...][abc][a-ptr][b-ptr][c-ptr](value-ptr?)
     * --------------------------------------------
     * 如果节点被压缩,则存储一个长度为n的字符串。
     * 字符串后面是1个指针,指向字符串尾字符对应的子节点。
     * 末尾同样是一个节点键对应的值(如果存在的话)。
     * 节点样例如下:
     * [header iscompr=1...][xyz][z-ptr](value-ptr?)
     */
    unsigned char data[];
} raxNode;
// 基数树定义
typedef struct rax {
    racNode *head; // 树根节点
    uint64_t numele; // 元素数
    uint64_t numnodes; // 节点数
} rax;

// rax栈,在移除节点是会用到,用于记录遍历路径节点信息
#define RAX_STACK_STATIC_ITEMS 32
typedef struct raxStack {
    void **stack; // 节点访问顺序指针
    size_t items, maxitems;
    void *static_items[RAX_STACK_STATIC_ITEMS];
    int oom; 
} raxStack;

节点插入与分裂

在将节点分裂前,首先讲讲键插入:往树中插入长度为len的键s时,会首先调用接口raxLowWalk传入键s去在树中遍历,并记录下遍历返回时的节点node和节点中的位置j,然后返回键在树中匹配到的最长前缀i。然后根据ij的返回值,来判断是否需要分裂节点,更新节点字段等操作。
节点分裂主要有以下几种情况,此处直接引用源码的注射(懒得自己画图):

/*
 * 0) 插入之前
 *
 *     "ANNIBALE" -> "SCO" -> []
 *
 * 1) 插入 "ANNIENTARE"
 *
 *               |B| -> "ALE" -> "SCO" -> []
 *     "ANNI" -> |-|
 *               |E| -> (... continue algo ...) "NTARE" -> []
 *
 * 2) 插入 "ANNIBALI"
 *
 *                  |E| -> "SCO" -> []
 *     "ANNIBAL" -> |-|
 *                  |I| -> (... continue algo ...) []
 *
 * 3) 插入 "AGO" (类似情况1,但是需要将原节点得iscompr字段置为0)
 *
 *            |N| -> "NIBALE" -> "SCO" -> []
 *     |A| -> |-|
 *            |G| -> (... continue algo ...) |O| -> []
 *
 * 4) 插入 "CIAO"
 *
 *     |A| -> "NNIBALE" -> "SCO" -> []
 *     |-|
 *     |C| -> (... continue algo ...) "IAO" -> []
 *
 * 5) 插入 "ANNI"
 *
 *     "ANNI" -> "BALE" -> "SCO" -> []
 */

分裂算法1
对于以上的1情况1-4,都是在遍历时没能找到键字符串并且最后停在了压缩节点,对于这些情况,使用算法1,步骤如下:

  • 1 保存当前压缩节点的next指针。
  • 2 创建分裂节点splitnode,将原节点中与键第一个不匹配的字母作为其子节点,键对应字母的节点将在节点分裂完成后添加。
  • 3a 如果splitpos==0 (注:即前面提到的j)
    • 使用splitnode替换旧节点,并拷贝相关字段,修改父节点的引用,释放旧节点。
  • 3b 如果splitpos!=0
    • 为原节点创建修剪节点trimnode,删除多余的数据,如果修剪后节点的len仅为1,则需要将iscompr字段置0,将splitnode设为其子节点,并修改父节点的引用。
  • 4a 如果postfix len>0(注:postfix len-原节点分割后剩余的字符串长度)
    • 创建后缀节点postfixnode,如果len1,则将iscompr字段置0,否则置1,然后将postfixnode的子节点设为步骤1保存的next指针。
  • 4b 如果postfix len==0
    • 使用next指针作为postfixnode指针。
  • 5 将splitnodechild[0]设为postfixnode
  • 6 将splitnode设为当前节点,索引设为child[1],继续正常执行插入算法。

分裂算法2
分裂算法2就是为了情况5,就是在遍历时找到了键字符串,但是停在了压缩节点中间,所以情况也比较单一,步骤如下:

  • 1 保存当前压缩节点的next指针。
  • 2 创建后缀节点postfixnode,如果len1,则将iscompr字段置0,否则置1,将步骤1的next指针设为其子节点指针。
  • 3 创建修剪节点trimnode,使其包含spilitpost处的第一个字符,如果新节点的长度只有1,则将iscompr置0,将原节点的iskey以及其关联值拷贝到修剪节点中。
  • 4 将postfixnode设为trimnode的唯一子节点指针。

data字段字节对齐
因为data部分存储了字符数组、指针两种不同的数据,为了防止cpu读取其中的指针时访问两次内存或者缓存(详情百度字节对齐),就需要进行手动填充进行4个字节的字节对齐。
例如在如下情况:
[headers][abcd][a-ptr][b-prt][c-ptr][d-ptr](value-ptr)
此时,插入一个e以及对应的子节点指针e-ptr,如果直接紧凑排列,则是如下情况:
[headers][abcde][a-ptr][b-prt][c-ptr][d-ptr][e-ptr](value-ptr)
此时,按照4个字节划分,abcd被分到一组,ea-ptr的前三个字节被分到一组,此时,当cpu读取a-ptr,可能就需要读取两次内存或者缓存。
所以,就需要填充空字节来进行字节对齐,*代表分配但未使用的字节,如下:
[headers][abcde***][a-ptr][b-prt][c-ptr][d-ptr][e-ptr](value-ptr)
所以,本次插入虽然只插入了一个字符以及一个指针,却直接分配了8个字节。当下次再插入子节点的时候,如果还有未使用的字节,则只需要分配一个指针的空间,即4个字节。
同样,在删除子节点时,也需要考虑对齐填充,在以及存在abcde以及5个子节点指针的情况下,移除任一子节点均需要释放8个字节的空间,反之则只释放一个指针即4个字节的空间。

插入以及分裂节点演示
节点结构如下

  • 初始化radix tree
  • 插入键值对<ABCDE, V1>
  • 插入键值对<ABCDEFGH, V2>
  • 插入键值对<ABCXYZ, V3>
    在这里插入图片描述

节点合并/压缩

只有当从树中移除节点时,才可能会发生节点合并,合并其实就是插入的逆过程。
当从树中移除长度为len的键s时,也会调用raxLowWalk去树中遍历,并将遍历的节点按顺序压入一个栈ts中,如果返回的最长匹配i不为len,则表示没有找到该键,否则则表示找到该键,将键所在的节点h已经节点中的位置splitpos记录下来。
首先需要移除不在使用的节点,如果存在的话。这种情况发生在找到了根节点,此时h->size = 0,然后不断向上移除不为key且只有一个子节点的节点,这一般需要移除两个节点,向上移动3次;之后,删除最后被移除的节点的父节点oldh关于该子节点的信息,得到节点newh,如果newh发生过内存重分配,则需要改变newh的父节点的关于oldh的引用。
节点压缩并不是所有的移除节点都会发生,下面两种情况可能会改变树而需要进一步压缩:

  1. 一个只有一个子节点的节点之前是键,进行移除后不再是键;
  2. 一个有两个子节点的节点现在只有一个子节点;
/*
 * 情况1. 树中存了两个键 "FOO" = 1 、"FOOBAR" = 2:
 *
 * "FOO" -> "BAR" -> [] (2)
 *           (1)
 *
 * 移除"FOO"之后,会被压缩成如下:
 *
 * "FOOBAR" -> [] (2)
 *
 *
 * 对于情况2. 树中存了两个键 "FOOBAR" = 1 、"FOOTER" = 2:
 *
 *          |B| -> "AR" -> [] (1)
 * "FOO" -> |-|
 *          |T| -> "ER" -> [] (2)
 *
 * 移除"FOOTER"之后,结果如下:
 *
 * "FOO" -> |B| -> "AR" -> [] (1)
 *
 * 这会被压缩为:
 *
 * "FOOBAR" -> [] (1)
 */

压缩的过程简单来说,就是根据当前节点为中心,分别向树根端以及树叶端遍历,找到可压缩的节点链,将其压缩为一个压缩节点,修改对应的父节点的引用、添加对应的子节点的引用,释放掉节点链中的各节点的空间。
具体算法步骤如下:

  • ts中找到最后一个不为键仅有一个子节点的节点start;
  • start节点开始向下遍历压缩节点链,记录压缩节点大小以及需要压缩的节点数nodes
  • 如果nodes大于1,则表示需要进行压缩,创建压缩节点new
  • start节点开始再次遍历,拷贝遍历节点中的数据并释放节点;
  • 压缩完成;

其实还有迭代器之类的没有讲到,但重要的还是理解数据结构本身,插入、移除这两个是最重要的,其中涉及到节点分裂/压缩、子节点插入/移除,其实这两个过程本就是一个互逆的过程,理解其一也就理解的另一个。
当然,其中的一些代码实现细节并没有讲到,且个人能力有限,难免有遗漏的知识点或者片面的、错误的观点,欢迎各位大佬补充与提出。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 深蓝海洋 设计师:CSDN官方博客 返回首页