「面试准备」Redis 数据结构 - 1

125 阅读5分钟

「这是我参与2022首次更文挑战的第 14 天,活动详情查看:2022首次更文挑战」。


此篇为自己在准备面试中写的 redis 笔记。

面向的是准备面试的同学,不是面试的,请还是去看看源码以及相关详细的文章。同时本文总结的很多也是来源于其他很多文章,只是为了准备,作者将其收录起来,面试而用。所以,本文几乎没有原创。谢谢各位的观看和评论。

本系列 redis 面试文章针对的是 redis5.x 的版本。

基本数据结构

这也是 redis 快的原因,优秀的数据结构设计以及省内存的编码格式。

SDS

Untitled.png

  1. 通过记录内部字符数组的使用长度和分配大小,避免了字符串的遍历 (比如创建、追加、复制、比较)
  2. 内部把空间检查和扩容都封装在:sdsMakeRoomFor(),避免开发忘记扩容
  3. 设计了不同的结构头来灵活保存不同大小的字符串,有效节省内存空间
    1. 使用专门的编译优化来节省内存空间,不使用字节对齐的方式,直接采用紧凑编码格式
    2. __attribute__((__packed__)) 标记
  4. 改善C语言中的二进制不安全字符串
    1. 由于有长度统计变量len的存在,读写buf不依赖 "\0" end
    2. buf依然是柔性数组,通过对数组指针作“减一”(buf[-1])操作,能方便地定位到flags,标识当前结构体的类型

dict

先说它的底层实现数据结构的实现原理:

  1. 使用「链式hash」存储;链式冲突严重,会开辟一个新的hashtable,翻倍扩容,并采取「渐进式rehash」迁移数据
  2. 「渐进式rehash」是把迁移数据的开销,平摊多次迁移操作,目的是降低主线程响应性能
  3. 作为上层数据结构Hash/Set/Sorted Set底层数据结构
  4. redis server每个DB都存放「全局数据键值对以及标志过期的key」都是使用dict类型
  5. 存储数据字段:
    1. dictEntry **table → 二维数组
    2. dictEntry → 存储键值对,next指向hash冲突的下一个键值对
    3. used:全部存储元素个数
    4. size:控制 table 一维数组的数量

Untitled 1.png

所以在整个 dict 类型存储的是两个 dictht(为了rehash) 的包转类型:

Untitled 2.png

rehash

  1. 初始化时,ht[1] 是没有size的;但扩容主体是在 ht[1] 中完成的
  2. 给 ht[1] 申请当前容量*2的空间 → 双倍扩容;并将 rehashidx 置为0
  3. 重新计算每一个key的hash和index,依次添加到 ht[1] 中,并把 ht[0] 中该键值对珊瑚,rehashidx 表示当前 ht[0] 正在进行rehash的节点的index (表示当前的进度)
  4. 操作完毕,清空 ht[0],ht[1] → ht[0],引用转移
  5. 在执行插入/删除/查找/修改操作之前,都会判断当前是否在 rehash,如果在则进行一次rehash,不断分摊每一个rehash的成本
  6. 在rehash中,ht[0] & ht[1] 共同承担查找工作

ziplist

这里只说上层数据结构和 ziplist 的关系:

hash

  1. field 较少而且 value 值比较小的时候,使用 ziplist 做压缩处理
  2. 随着field增多和value值增大,改为dict实现。存储效率就没办法和序列化string相比 (这个和string效率)
  3. 原因:
    1. ziplist在元素增多的过程中,插入/修改引发的realloc操作会有大概率的内存拷贝,降低性能
    2. 查找只能遍历,优化了一下反向遍历 (加上了前一个entry的占用字节数)

sorted set

  1. 数据较少时,采用一个ziplist存储
  2. 数据多时,采用zset存储:
    1. 由dict+skiplist存储
    2. dict:查询 member → score 的映射
    3. skiplist:范围查询,只需要通过指针的跳转操作
    4. 具体的存储则交给 dict 做实际数据存储和编码

关于ZipList和Redis的实现

如果使用 zset 存储,就涉及到 skiplist 的存储:

skiplist

  1. 每一个元素存储:zadd key score value

    1. 数据本身:value
    2. 数据对应的分数:score
    3. 根据分数大小和数据本身进行 rank:zrank key member
    typedef struct zskiplistNode {
        //Sorted Set中的元素
        sds ele;
        //元素权重值
        double score;
        //后向指针 -> 便于反向遍历
        struct zskiplistNode *backward;
        //节点的level数组,保存每层上的前向指针和跨度
        struct zskiplistLevel {
            struct zskiplistNode *forward;
            unsigned long span;
        } level[];
    } zskiplistNode;
    
  2. 如何比较:

    1. cur→score < target→score, cur = cur→forward (比target权重小,查看链表的下一个节点)
    2. cur→score = target→score, cur→val < target→val, cur = cur→forward (权重相等,元素值小查看链表的下一个节点)
    3. 都不满足 ⇒ 开始 forrange level[],查看下一层的指针
    //获取跳表的表头
    x = zsl->header;
    //从最大层数开始逐一遍历
    for (i = zsl->level-1; i >= 0; i--) {
       ...
    	 // 当前层不满足,则开始下一轮循环,找下一层的指针,往后找 
       while (x->level[i].forward && (x->level[i].forward->score < score 
    			|| (x->level[i].forward->score == score 
        && sdscmp(x->level[i].forward->ele,ele) < 0))) {
          ...
          x = x->level[i].forward;
        }
        ...
    }
    
  3. 插入:

    1. 需要额外两个空间:
      1. update[]: 记录查询过程中每一层的前一个节点 (链表插入最重要的就是找到前一个节点)
      2. rank[]: 记录header走到 update[i] 所经历的步长。更新 update[i].span 以及插入newNode.span,做一个缓存
    2. 通过算法计算新插入节点的 level
    3. 循环 level,将 update[i].forward 和 newNode.level[i] 对应起来
      1. 更新 update[i].forward 指向
      2. 更新每一层的 span
    4. 插入 skiplist 和 dict 是分开操作的