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

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 后台更新缓存

  • 业务线程读取到缓存之后不再负责更新缓存, 让缓存永久有效, 并将更新缓存的工作给后台线程定时更新

不设置过期时间缓存也会因为内存限制而被淘汰, 而这个空档期数据就查询不到了

两种方式解决

  1. 后台线程不仅负责定时更新缓存, 也负责频繁的监测缓存是否有效 这种方法就需要设计较为合理的监测间隔时间, 不至于影响用户体验也不会经常出现空值
  2. 业务线程发现缓存被淘汰之后, 通过消息队列发送消息通知后台线程更新缓存, 后台线程再判断是否需要更新 这种方式相对来说比较好, 不会太影响用户体验
  • 缓存预热: 业务刚上线时, 或者是秒杀活动以及其他高并发场景时, 我们可以提前把数据缓存起来, 保证缓存的命中率, 减小后台数据库的压力, 这也算是后台更新缓存的一种

2.2 Redis 故障宕机

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

  • 暂停业务对缓存的访问, 直接返回错误, 不用再继续访问数据库, 但是这样业务就无法正常运行
  • 所以可以在此基础上使用请求限流机制, 只将少部分请求发送到数据库处理, 其他请求入口处就拒绝服务

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

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

2. 缓存击穿

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

3. 缓存穿透

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

3.1 非法请求限制

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

3.2 缓存空值或默认值

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

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

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

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

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)

当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

5. 主从复制

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

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

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

5.1 全量复制

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

5.2 基于长连接的命令传播

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

5.3 增量复制

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

但这也有一个问题, 就是在哈希表1 数据过多时, 迁移的时候就会占用很长时间, 导致 redis 阻塞较长时间, 而渐进式 rehash 可以解决这个问题

7.4.3 渐进式 rehash

过程:

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

另外, 在 rehash 过程中, 新增一个 key-value 时, 会直接保存到 哈希表 2 中, 这样就保证了 哈希表1 的 key-value 只会减少, 不会增加

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;

content 的类型跟 encoding 相关, 如 encoding 为 INTSET_ENC_INT16, 那么 content 的类型就是 int16_t, 其他如 INTSET_ENC_INT32, INTSET_ENC_INT64 也同理

7.5.2 整数集合的升级操作

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

整数集合升级的好处?

节省内存资源, 如果让一个数组同时保存 16位, 32 位, 64 位的数据, 那么最简单做法就是大小扩充为 64 位, 但是如果使用升级的策略, 在 32 位 和 64 位数据进入数组之前, 数据的空间占用就会小很多, 也就节省了内存

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 设计与实现
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇