0
点赞
收藏
分享

微信扫一扫

Redis数据结构、持久化技术和三大问题

霸姨 2022-05-30 阅读 132

文章目录

1. Redis 执行过程

  1. 用户发来大量请求, 首先访问 Redis 缓存, 找到直接返回
  2. 若没有找到则找从数据库查找
  3. 查找到之后将数据重新缓存到 Redis 中
  4. 返回数据

2. 缓存雪崩

当**大量缓存数据同时失效(过期)**或者 Redis 故障, 就会有大量请求直接访问数据库, 导致数据库宕机, 从而产生的连锁反应, 导致系统瘫痪

2.1 大量数据过期

2.1.1 均匀设置过期时间

  • 给每个缓存中的数据过期时间加上一个随机数, 保证不会同一时间过期

2.1.2 互斥锁

  • 访问的数据不在 Redis 中, 那么先给该请求一个互斥锁, 保证同一时间只有一个请求来构建缓存, 等待请求将数据缓存进 Redis 之后就开始释放锁

  • 如果此时又有新的请求来, 要么就等待互斥锁释放, 要么就直接返回默认空值

  • 应该给锁添加一个超时时间, 防止出现故障导致锁无法释放, 形成阻塞

2.1.3 双 key 策略

  • 设置一个主 key 和 副 key, 主 key 保存有过期时间, 副 key 则没有过期时间, 相当于主 key 的一个副本, 两者之间的区别只有 key 不同, 缓存 value 值是一样的

  • 主 key 的数据拿不到时, 就访问 副 key 的值, 这时返回的数据可能是旧数据, 也就是说, 场景允许出现旧数据

  • 当请求从后台拿到数据之后, 应该再次给 主 key 和 副 key 都更新缓存

2.1.4 后台更新缓存

  • 业务线程读取到缓存之后不再负责更新缓存, 让缓存永久有效, 并将更新缓存的工作给后台线程定时更新
  • 缓存预热: 业务刚上线时, 或者是秒杀活动以及其他高并发场景时, 我们可以提前把数据缓存起来, 保证缓存的命中率, 减小后台数据库的压力, 这也算是后台更新缓存的一种

2.2 Redis 故障宕机

2.2.1 服务熔断或请求限流(发生故障后的方案)

  • 暂停业务对缓存的访问, 直接返回错误, 不用再继续访问数据库, 但是这样业务就无法正常运行

  • 所以可以在此基础上使用请求限流机制, 只将少部分请求发送到数据库处理, 其他请求入口处就拒绝服务

2.2.2 构建 Redis 缓存高可靠集群(发生之前就可以做的方案)

  • 通过主从节点的方式构建 Redis 缓存集群, 如果主节点宕机, 从节点可以切换为主节点, 继续服务

2. 缓存击穿

  • 热点数据过期, 导致大量请求访问数据库, 导致数据库被高并发数据冲垮
  • 是缓存雪崩的一个子集
  • 解决方案:
    • 互斥锁: 跟缓存雪崩类似
    • 不给热点数据设置过期时间, 后台异步更新缓存; 在热点数据过期前提前通知后台线程更新过期时间

3. 缓存穿透

  • 概念: 用户访问的数据, 既不在缓存中, 也不在数据库中
  • 出现情况:
    • 业务误操作: 缓存中和数据库中的数据都被删除了
    • 故意攻击: 故意大量访问一些不存在的数据

3.1 非法请求限制

  • 请求入口处就对参数进行判断, 查看参数是否存在或含有非法值, 如果判断是恶意就直接返回错误

3.2 缓存空值或默认值

  • 发现有缓存穿透现象时, 针对被查询的数据, 在缓存中设置一个空值或者默认值, 保证以后访问该数据时, 不用访问数据库

3.3 使用布隆过滤器判断数据是否存在

  • 在写入数据库数据时, 使用布隆过滤器做标记, 之后用户请求来时, 可以只用查询 Redis 缓存和 布隆过滤器 即可判断是否存在该数据, 不用通过查询数据库来判断是否存在该数据
  • 由于布隆过滤器是使用哈希函数实现, 所以会存在哈希冲突, 也就是说, 查询布隆过滤器说数据存在, 并不一定存在, 但如果查询到数据不存在, 那数据就肯定不存在

4. Redis 的持久化技术 AOF 与 RDB

4.1 AOF 日志

4.1.1 概念

Redis 每执行一条命令后, 就会将该命令存入 AOF 日志(硬盘)中, 即(Append Only File), 只会记录写操作, 不会记录读操作

默认不会开启, 需要在 redis.conf 配置文件中修改

appendonly 			yes				 // 表示是否开启 AOF 持久化(默认 no)
appendfilename		"appendonly.aof" // AOF 持久化文件的名称

4.1.2 命令规则

image-20220525164734102

*3表示当前命令有三个部分, 每部分都是以$+数字开头, 后面紧跟命令, 键, 值

数字代表当前这部分的字节数, 如$3 set表示 set 命令字符串的长度

4.1.3 先执行命令后存入 AOF 日志的好处

  • 避免额外的检查开销, 如果先存入 AOF 日志, 命令发生了语法错误, 那么使用日志恢复时, 会出现错误

    但如果先执行命令, 就可以保证存入日志的命令都是正确的, 就避免了检查日志的操作

  • 不会阻塞当前写操作的进行, 只有写操作成功后, 才会存入日志

但是会带来一些风险

  1. Redis 在执行命令之后, 没来得及将命令写入硬盘, 服务器宕机了, 数据会有丢失的风险

  2. 可能会阻塞下一个写命令

也就是说, 风险的产生与命令写入硬盘的时机有关

4.1.4 三种写回策略

  • Redis 写入 AOF 日志的过程

    image-20220525165808359

  • Redis 提供的三种写回策略, 控制的主要是 内核缓冲区 中的数据什么时候写入硬盘

  • redis.conf文件中的appendsync项有三个参数可填

    • Always, 每次执行完命令后, 就将 AOF 日志的数据写回硬盘
    • Everysec, 每次写命令执行完后, 先写入 内核缓冲区, 每隔一秒将内容写回硬盘
    • No, Redis 不控制写回, 每次执行完命令放入 内核缓冲区之后, 由操作系统决定什么时候写入硬盘

    Always, 高可靠, 但是不可避免的影响主进程的性能

    No, 高性能, 但是操作系统写入硬盘的时机是不可预知的, 可靠性较低

    Everysec, 折中的一种办法

  • 内核发起的写操作, 其实就是调用 fsync() 函数, 将内核缓冲区的数据写入到硬盘

    Always 每次写入 AOF 文件后, 就执行 fsync() 函数

    Everysec 创建一个异步任务来执行 fsync() 函数

    No 不会执行 fsync() 函数

4.1.5 AOF 重写机制

  • 为了避免日志过大, 导致的读取时间过长, 提供了重写机制, 简单来说就是, 读取当前数据库中的所有键值对, 然后将每一个键值对都用一条命令记录在新的 AOF 文件中, 全部记录完成后, 用新的 AOF 文件覆盖旧 AOF 文件

4.1.6 AOF 后台重写

  • 是由后台子进程 bgrewriteaof 来完成的

    • 好处1, 子进程重写期间, 主进程可以继续处理命令请求, 避免阻塞主进程
    • 好处2, 子进程带有主进程的数据副本双方共享数据, 开始是父子双方都是只读的状态, 只有当父子任何一方修改了共享内存之后, 发生写时复制, 于是父子进程就都拥有了独立的数据副本, 也就不需要加锁来保证数据安全
  • 子进程是通过主进程 在产生 子进程时会将 页表 复制一份给子进程, 也就是说, 子进程和父进程的虚拟空间不同, 但是其对应的物理地址是同一个

    • 不过当父子进程对共享数据发起写操作时, CPU 会触发缺页中断, 操作系统会进行物理内存的复制, 重新设置映射关系, 并将父子进程的权限设置为可读写, 最后对内存进行写操作, 这个过程也被成为 写时复制
  • 导致父进程阻塞的方法情况

    • 创建子进程的过程中, 由于要复制父进程的页表结构, 主进程会被阻塞
    • 发生写时复制时, 需要拷贝物理内存, 此时也会阻塞主进程
    • 信号处理函数执行时, 阻塞主进程
  • 主进程修改了已经存在 key-value,就会发生写时复制,注意这里只会复制主进程修改的物理内存数据,没修改物理内存还是与子进程共享的

    • 但是在重写过程中,主进程修改已经存在的 key-value, 此时子进程中的 key-value 就与主进程的不一样了,所以设立了 AOF重写缓冲区,该缓冲区在 bgrewriteaof 子进程创立之后使用, 之后由主进程负责将新的写命令存入 AOF 重写缓冲区
  • 当子进程重写完成之后, 会像主进程发送一条信号, 主进程接收到后会调用一个函数, 主要处理

    • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 文件中, 使得子进程更新在重写阶段没有记录的一些写信息
    • 新的 AOF 文件覆盖旧文件

4.2 RDB 快照

4.2.1 使用

  • 两个命令, savebgsave

    • save 在主线程生成 RDB 文件, 会阻塞主线程
    • bgsave 创建一个子进程生成 RDB 文件, 不会阻塞主线程
  • RDB 文件是在服务器启动时自动执行的, Redis 并没有直接命令

  • Redis 会通过配置文件的方式 默认执行 RDB 快照

  • RDB 快照是 全量快照, 也就是会将内存中所有数据都存入硬盘中, 对性能影响较大

4.2.2 执行快照时, 对数据的修改

  • 在 执行 bgsave 时, Redis 主线程仍然可以处理操作命令, 用到的就是 AOF 中的 写时复制技术
  • 也就是说, 发生写实复制之后, 也就是子线程在执行快照的过程中, 主线程进行修改的数据时不会被子线程保存, 子线程保存的还是原来的数据
  • 极端情况下, 如果快照时, 所有内存都被修改, 那么内存占用会翻倍, 子线程占旧数据一份, 主线程占新数据一份

4.2.3 RDB 与 AOF 合体

  • 混合持久化过程发生在 AOF 重写过程中

  • 混合持久化之后的 AOF 文件 前半部分是 RDB 内容, 后半部分是 AOF 内容

  • 这样即可以避免 RDB 快照过程中不会缺少主线程修改的数据(因为记录在了 AOF 内容中) 也保证了加载的时候速度较快(RDB)

5. 主从复制

对多台 Redis 服务器组成的主从关系, 进行信息备份的方式, 以保证服务器之间的数据一致性

主服务器可以进行读写操作, 并且在发生读写操作之后将数据同步给从服务器, 从服务器一般只进行读操作, 并且接受来自主服务器的同步来的写操作.

主从复制一般分为三种方式

5.1 全量复制

  • 一般用于第一次主服务器与从服务器同步

image-20220525192943740

  • 通常采用全量复制的方式, 把主服务器的数据全部同步给从服务器
  • bgsave 子线程生成的 RDB 文件并给从服务器使用时, 主服务器执行写命令产生的新数据存放在 replication buffer 缓冲区里
  • 最后 主服务器 产生的 RDB 文件replication buffer 缓冲区中的数据 都发送后, 从服务器执行,至此 第一次同步工作就完成了

5.2 基于长连接的命令传播

  • 第一次同步之后, 主从之间会形成一个 TCP 连接
  • 主服务器后续通过这个连接将写操作命令传播给从服务器, 保证数据一致性
  • 此后, 主服务器为了避免长时间花在 生成 RDB 文件传输 RDB 文件,会让从服务器成为其他从服务的主服务器, 也就是相当于 老板管理着经理, 经理再管理着员工

5.3 增量复制

  • 发生在网络断开时, 用于传输期间从服务器没有接受到的数据, 如果使用 全量复制 效率太低, 所以就针对所需要的那部分数据产生了增量复制

image-20220525193553134image-20220525193707579

  • repl_backlog_buffer,是一个环形缓冲区,用于主从服务器断连后,从中找到差异的数据;
  • 根据 该缓冲区从而确定 是使用 增量复制 还是 全量复制
  • 也需要根据实际情况动态修改该值保证主从服务器断开重连后, 尽量使用 增量复制 而不是全量复制

6. 更新数据库和缓存的并发问题

  • 先更新数据库再更新缓存 或 先更新缓存再更新数据库, 都会发生并发问题, 导致缓存中的数据与数据库中的数据不一致

  • 可以采用 Cache Aside 策略 (旁路缓存策略),

    image-20220525193849410

  • 这时, 针对 写策略, 有两种情况

    1. 先删除缓存, 再更新数据库

      在 读 + 写 并发时, 还是会容易出现并发问题

      image-20220525194010982

    2. 先更新数据库, 再删除缓存

      在 读 + 写 并发时, 也会出现并发问题,

      image-20220525194026784

      但是 出现的概率非常低 , 因为 写回缓存的时间 比 写回数据库的时间 要快很多, 因此很难出现这种情况

      还可以给缓存中数据 加上过期时间, 保证过期之间之后数据也能保持一致

    所以我们通常选择, 第二种, 先更新数据库, 再删除缓存

  • 但是这有引发出另一个问题, 就是 更新数据库 和 删除缓存 这两个操作是不能保证同时都成功的, 如果缓存删除失败, 那么也会读取到错误的数据

    有两种解决办法, 两种方法都采用异步方式操作缓存

    1. 重试机制

      引入消息队列, 将第二个操作数据加入到消息队列, 由消费者 来操作数据

      如果删除缓存失败, 可以从消息队列重新读取数据, 然后再次删除缓存

      如果删除缓存成功, 就把数据从消息队列中移除, 避免重复操作

      image-20220525194455393

    2. 订阅 MySQL binlog, 再操作缓存

      更新数据库成功后, 会产生一条变更日志, 存放在 binlog 里, 可以通过订阅 binlog 日志, 拿到具体要操作的数据, 再执行删除缓存

      例如 阿里的开源中间件 Canal

      image-20220525194547479

7. Redis 数据结构

概念: Redis 中的 String, List, Hash, Set, Zset 是Redis 中的数据类型(Redis 对象), 数据结构指的是实现的底层结构

image-20220525195143935

7.1 SDS

  • Redis 是由 C 语言实现的, 但是没有直接使用 C 语言的 char* 字符数组来实现 字符串 , 而是自己封装了一个数据结构, 简单动态字符串(Simple Dynamic String), 即, Redis 底层实现 String 的数据结构是 SDS

7.1.1 C 语言中 char* 字符数组的缺陷

  • 获取字符串长度的 strlen 时间复杂度是 O(N)
  • 字符串里面不能包含 \0, 也就是说不能保存图片, 音频视频等二进制文件
  • C 中 字符串不会记录自身缓冲区大小, 也就是说发生缓冲区溢出时程序可能会直接终止

7.1.2 SDS 结构设计

image-20220525195230538

  • len 记录字符串长度, 即 获取字符串长度的时间复杂度为 O(1)
  • alloc 记录已经分配的空间长度, 也就是说可以程序可以通过 api , 直接 查看alloc - len 即字符串剩余长度从而判断拼接字符串时是否会发生缓冲区溢出 ,并自动进行扩容从而达到所需大小
  • flags 表示不同类型的 SDS, 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64, 区别是所占用的字节数不同, 即根据情景选择合适的类型可以避免空间浪费
  • buf[] 保存实际的数据

Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 flags 的变量如 sdshdr5 等结构体的 struct 中声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。从而解决了 C 语言中 char* 所存在的几个缺陷

7.1.3 SDS 的内存重新分配

  • 空间预分配: 对 SDS 进行修改和空间扩充时, 除了分配已经使用的空间之外, 还会分配未使用的空间

    SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配1M的使用空间

  • 惰性空间释放: SDS 缩短时, 不会回收多余的内存空间, 而是使用 free 字段将多余的空间记录下来.

    如果后续有变更操作, 直接使用 free 中记录的空间, 减少了内存的分配

7.2 链表

7.2.1 链表的结构设计

  • Redis 中的链表 listNode 有前置节点和后置节点两个链表节点, 也就是双向链表

  • Redis 还使用 listlistNode 进行封装

    提供了链表头尾节点 head , tail 以及链表节点数量 len, 还有可以自定义实现的 dup(复制) , free(释放) , match(比较) 函数

typedef struct list {
    //链表头节点
    listNode *head;
    //链表尾节点
    listNode *tail;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值比较函数
    int (*match)(void *ptr, void *key);
    //链表节点数量
    unsigned long len;
} list;
typedef struct listNode {
    //前置节点
    struct listNode *prev;
    //后置节点
    struct listNode *next;
    //节点的值
    void *value;
} listNode;

image-20220525195616383

7.2.2 链表的优缺点

优点:

  1. 获取某节点 前后 节点的时间复杂度为O(1) . listNode 提供 prev 和 next
  2. 获取表头和表尾节点的时间为 O(1). list 提供 head 和 tail
  3. 获取链表节点数量时间为 O(1). list 提供 len
  4. 链表节点可以保存各种不同类型的值. listNode 链表节使用 void* 指针保存节点值

缺点:

  1. 节点之间内存不连续, 无法很好的利用 CPU 缓存, 因为内存不连续
  2. 保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

7.3 压缩列表

  • 类似于结构体数组, 比链表更加节省内存, 但同样修改对于系统的影响较大; 并且保存数据过多时, 查询效率会降低

7.3.1 压缩列表结构设计

image-20220525195758584

  • zlbytes: 记录整个压缩列表占用的内存字节数
  • zltail: 记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen: 记录压缩列表包含的节点数量
  • zlend: 标记压缩列表的结束点,固定值 0xFF(十进制255)

压缩列表节点包含

  • prevlen: 记录前一个节点的长度
  • encoding: 记录当前实际节点的长度 以及 类型
  • data: 记录实际数据

7.3.2 如何实现节约内存

  • 节点会根据 数据是字符串还是整数, 以及数据大小使用不同 空间大小的 prevlenencoding 来标识, 从而节约内存

  • 对于prevlen:

    前一字节长度 < 254 : 使用 1 字节

    前一字节长度 >= 254 : 使用 5 字节

    对于 encoding:

    当前节点是整数: 使用 1 字节

    当前节点是字符串: 根据字符串长度, 使用 1 / 2 / 5 字节空间进行编码

7.3.3 连锁更新

  • 类似于 数组 中进行增删, 会导致后面的数据的 prevlen 占用空间发生变化, 从而引起 连锁更新的问题, 造成性能下降, 即进行多次空间拓展操作
  • 所以压缩列表适用于保存节点数量不多的场景

7.3.4 List,Hash,Zset数据少时使用压缩列表

  • 对于 Hash 对象, 使用 压缩列表 的情况

    1. 键值对的数据量小于 512 个
    2. 键值对中键和值的字符串长度小于 64 个字节

    由配置文件中的 hash-max-ziplist-valuehash-max-ziplist-entries 选项定义上限值

7.4 哈希表

  • 是一种保存 key-value 键值对的结构
  • Redis 采用 链式哈希 来解决哈希表冲突

7.4.1 哈希结构设计

typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;  
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希表已有的节点数量
    unsigned long used;
} dictht;
typedef struct dictEntry {
    //键值对中的键
    void *key;

    //键值对中的值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

image-20220525200327630

  • 哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针

  • dictEntry里不仅有 key 和 value ,还有 next 指向下一个节点来解决哈希冲突, 这就是链式哈希(被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来)

  • dictEntry中的 value 是一个 union联合体, 也就是说 value 的值可以根据实际填充的值来实现, 从而节省内存空间, 比如说存放的是整数值就可以直接存入 value , 而不需要再用一个指针指向实际的值

7.4.2 rehash

  • 随着数据的增多, 哈希桶的链表长度会逐渐变长, 导致查询效率下降, 所以就要对哈希表进行扩展, 即 rehash
  • dictht 定义了两个, 就是为了 进行 rehash

下图是 rehash 的大致过程

image-20220525200507017

7.4.3 渐进式 rehash

过程:

  1. 给 哈希表 2 分配空间
  2. 在 rehash 过程中, 每次哈希表进行增删改查时, Redis 执行操作时, 也会将 涉及到操作的 哈希表 1 中的 key-value 迁移到 哈希表 2 上
  3. 随着请求越来越多, 最终全部数据都会迁移完成

7.4.4 rehash 的触发条件

  • 跟**负载因子(load factor)**有关
  • 负载因子 = 哈希表以保存节点数量 / 哈希表大小
  • 负载因子 >= 1, 并且 redis 没有进行 bgsave(RDB 快照) 和 bgrewriteaof(AOF 重写) 命令时, 执行 rehash 操作
  • 负载因子 > 5 时, 说明哈希冲突严重, 此时不管有没有执行 RDB快照 和 AOF 重写, 都会强制执行 rehash

7.5 整数集合

  • 是 set 对象的底层实现之一, 当一个 set 对象只包含整数值元素时, 并且元素个数不超过512时, 就会使用整数集合作为底层实现

    不满足任意一条时就使用哈希表来实现

7.5.1 整数结合结构设计

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

7.5.2 整数集合的升级操作

  • 如果有一个 int32_t 的数据加入到 contents 类型全为 int16_t 中, 那么整数集合会先进行升级
    • 先按照新元素的大小拓展空间, 拓展到每个元素都为 32 位的大小
    • 然后升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割
    • 如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。

image-20220525200949310

7.5.3 整数集合支持降级操作吗?

不支持降级操作

假如说支持降级操作, 就会出现两个问题:

  • 什么时候降级?

    假如说删除了一个 int64_t 的数据, 那么如果需要降级, 就需要遍历一遍整个数组, 保证内部没有 int64_t 类型的数据才能降级, 这个时间是O(n), 而且就算我们此时遍历完之后进行了降级, 我们也无法确定之后会不会继续插入 64 位的数据, 如果插入了, 那么 redis 又要对 intset 进行升级, 这样来回的升降级会极大的影响系统性能, 所以降级不合适

  • 降级要何种级别的数据类型?

    这个也是同理, 要确定降低到什么级别的数据类型, 是 int32_t 还是 int16_t 我们必须要对数据进行一次遍历, 之后当前最大元素之后才能进行降级, 时间开销同样大

所以, redis 就只负责给 intset 升级, 而忽略了降级操作

7.6 跳表

  • redis 只有在 zset 对象的底层实现用到了跳表, 优势是能够支持平均 O(logN) 的复杂度查找
  • Zset 是唯一一个使用了两种数据结构来实现的 Redis 对象, 跳表和哈希表, 既能进行高效的范围查询(如 ZRANGEBYSCORE), 也能进行高效的单点查询(如 ZSCORE)
// zset结构
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

7.6.1 跳表的结构设计

  • 跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表, 好处是能快速定位数据
// 跳表节点
typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;

    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

image-20220525201256336

  • 保存了 Zset 对象的 元素值(sds ele) 和 权重值(double score)

  • 后向指针, 指向前一个节点, 方便从跳表尾结点开始访问节点, 方便倒序查找

  • 跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组

    level 中的每一个元素就代表跳表的每一层, level[0] 就是第 1 层.

    同时还定义了指向下一个跳表节点的指针 forward, 还有跨度, 跨度主要是记录两个节点之间的距离(跨度实际上是为了计算这个节点在跳表中的排位 排位就是从头节点到该节点的查询路径上经过的所有层跨度的和)

// 跳表
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;// 头尾节点, O(1) 查询头尾结点
    unsigned long length;               // 跳表长度, O(1) 获取跳表节点数量 
    int level;							// 跳表最大层数, O(1) 获取跳表层高
} zskiplist;
  • 负责定义哪个跳表节点是头节点

7.6.2 跳表节点查询过程

  • 从最高层开始, 遍历每一层的跳表节点, 使用 SDS 类型的元素值元素的权重来判断, 有两个规则

    • 如果当前节点权重 小于 要查找的权重时, 访问下一个
    • 如果当前节点权重 等于 要查找的权重时, 并且 当前节点 SDS 类型 小于 要查找的数据时, 访问下一个

    如果都不满足, 直接进入下一层查找

7.6.3 跳表节点层数设计

  • 跳表相邻两层节点数量会影响跳表查询性能

    跳表相邻两层节点数量最理想的比例是 2 : 1 , 查找复杂度可以降低到 O(logN), 如下图

image-20220525201419272

7.6.4 怎么才能维持相邻两层节点数量比例位 2 : 1 呢 ?

  • 如果在 删除节点 和 添加节点 时, 来调整跳表节点以维持比例的话, 会带来额外的开销

  • Redis 采用一种巧妙的方法是, 跳表创建节点时, 随机生成每个节点的层数

    跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数

  • 这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64 (源码中规定的)

7.7 quicklist

  • 是 List 对象的底层结构, 相当于 双向链表 + 压缩列表 的组合, 因为 quicklist 就是一个链表, 链表中的每个元素又是一个压缩列表
  • quicklist 通过控制每个 压缩列表 的大小, 防止在新增或删除元素时, 产生的 连锁更新 影响性能, 从而保证了更好的性能, 因为压缩列表元素越少或越小,连锁更新带来的影响就越小

7.7.1 quicklist 结构设计

typedef struct quicklist {
    //quicklist的链表头
    quicklistNode *head;
    //quicklist的链表尾
    quicklistNode *tail; 
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;       
    ...
} quicklist;
typedef struct quicklistNode {
    //前一个quicklistNode
    struct quicklistNode *prev;     //前一个quicklistNode
    //下一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    //quicklistNode指向的压缩列表
    unsigned char *zl;              
    //压缩列表的的字节大小
    unsigned int sz;                
    //压缩列表的元素个数
    unsigned int count : 16;        //ziplist中的元素个数 
    ....
} quicklistNode;

quicklistNode 与双向链表节点的区别是, 保存的数据结构内容是 压缩列表

quicklist, quicklistNode 和 ziplist之间的关系如下图

image-20220525201649973

  • quicklist 在新增一个元素的时候, 不会像普通的链表一样, 直接创建一个链表节点, 而是检查插入位置的压缩列表能不能容纳该元素, 如果可以就直接保存 quicklistNode 结构里的压缩列表, 否则, 才会创建一个新的 quicklistNode, 但是这并没有解决 连锁更新 的问题

7.8 listpack

  • 连锁更新 问题来源于 压缩列表中有保存前一个元素长度的 prevlen 属性 (主要作用是方便从左向右正向查询列表,或是从右向左反向), 所以如果解决掉这个字段, 那么连锁更新的问题也就没有了
  • 而 listpack 就是针对此问题提出的解决方案, 是代替 压缩列表的 一个新结构, 估计未来将会替换掉 压缩列表

7.8.1 listpack 结构设计

  • listpack 保存了 压缩列表 很多有点, 比如使用连续的一段内存空间紧凑的保存数据, 不同节点采用不同编码来节省内存空间

    具体结构如下图

image-20220525201804322

  • encoding: 元素类型的编码
  • data: 实际数据
  • len: encoding + data 的长度

listpack 没有记录上一节点的长度了, 只记录当前节点的长度, 所以当我们向 listpack 中添加新元素时, 不会影响其他节点的长度变化, 从而避免了连锁更新问题

参考

  • 小林coding, 图解 Redis
  • Redis 设计与实现
举报

相关推荐

0 条评论