文章目录
- 1. Redis 执行过程
- 2. 缓存雪崩
- 2. 缓存击穿
- 3. 缓存穿透
- 4. Redis 的持久化技术 AOF 与 RDB
- 5. 主从复制
- 6. 更新数据库和缓存的并发问题
- 7. Redis 数据结构
- 参考
1. Redis 执行过程
- 用户发来大量请求, 首先访问 Redis 缓存, 找到直接返回
- 若没有找到则找从数据库查找
- 查找到之后将数据重新缓存到 Redis 中
- 返回数据
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 命令规则
*3
表示当前命令有三个部分, 每部分都是以$+数字
开头, 后面紧跟命令, 键, 值
数字
代表当前这部分的字节数, 如$3 set
表示 set 命令字符串的长度
4.1.3 先执行命令后存入 AOF 日志的好处
-
避免额外的检查开销, 如果先存入 AOF 日志, 命令发生了语法错误, 那么使用日志恢复时, 会出现错误
但如果先执行命令, 就可以保证存入日志的命令都是正确的, 就避免了检查日志的操作
-
不会阻塞当前写操作的进行, 只有写操作成功后, 才会存入日志
但是会带来一些风险
-
Redis 在执行命令之后, 没来得及将命令写入硬盘, 服务器宕机了, 数据会有丢失的风险
-
可能会阻塞下一个写命令
也就是说, 风险的产生与命令写入硬盘的时机有关
4.1.4 三种写回策略
-
Redis 写入 AOF 日志的过程
-
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 使用
-
两个命令,
save
和bgsave
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 全量复制
- 一般用于第一次主服务器与从服务器同步
- 通常采用全量复制的方式, 把主服务器的数据全部同步给从服务器
- 在
bgsave
子线程生成的 RDB 文件并给从服务器使用时, 主服务器执行写命令产生的新数据存放在replication buffer
缓冲区里 - 最后 主服务器 产生的 RDB 文件 和
replication buffer
缓冲区中的数据 都发送后, 从服务器执行,至此 第一次同步工作就完成了
5.2 基于长连接的命令传播
- 第一次同步之后, 主从之间会形成一个 TCP 连接
- 主服务器后续通过这个连接将写操作命令传播给从服务器, 保证数据一致性
- 此后, 主服务器为了避免长时间花在 生成 RDB 文件 和 传输 RDB 文件,会让从服务器成为其他从服务的主服务器, 也就是相当于 老板管理着经理, 经理再管理着员工
5.3 增量复制
- 发生在网络断开时, 用于传输期间从服务器没有接受到的数据, 如果使用 全量复制 效率太低, 所以就针对所需要的那部分数据产生了增量复制
repl_backlog_buffer
,是一个环形缓冲区,用于主从服务器断连后,从中找到差异的数据;- 根据 该缓冲区从而确定 是使用 增量复制 还是 全量复制
- 也需要根据实际情况动态修改该值保证主从服务器断开重连后, 尽量使用 增量复制 而不是全量复制
6. 更新数据库和缓存的并发问题
-
先更新数据库再更新缓存 或 先更新缓存再更新数据库, 都会发生并发问题, 导致缓存中的数据与数据库中的数据不一致
-
可以采用 Cache Aside 策略 (旁路缓存策略),
-
这时, 针对 写策略, 有两种情况
-
先删除缓存, 再更新数据库
在 读 + 写 并发时, 还是会容易出现并发问题
-
先更新数据库, 再删除缓存
在 读 + 写 并发时, 也会出现并发问题,
但是 出现的概率非常低 , 因为 写回缓存的时间 比 写回数据库的时间 要快很多, 因此很难出现这种情况
还可以给缓存中数据 加上过期时间, 保证过期之间之后数据也能保持一致
所以我们通常选择, 第二种, 先更新数据库, 再删除缓存
-
-
但是这有引发出另一个问题, 就是 更新数据库 和 删除缓存 这两个操作是不能保证同时都成功的, 如果缓存删除失败, 那么也会读取到错误的数据
有两种解决办法, 两种方法都采用异步方式操作缓存
-
重试机制
引入消息队列, 将第二个操作数据加入到消息队列, 由消费者 来操作数据
如果删除缓存失败, 可以从消息队列重新读取数据, 然后再次删除缓存
如果删除缓存成功, 就把数据从消息队列中移除, 避免重复操作
-
订阅 MySQL binlog, 再操作缓存
更新数据库成功后, 会产生一条变更日志, 存放在 binlog 里, 可以通过订阅 binlog 日志, 拿到具体要操作的数据, 再执行删除缓存
例如 阿里的开源中间件 Canal
-
7. Redis 数据结构
概念: Redis 中的 String, List, Hash, Set, Zset 是Redis 中的数据类型(Redis 对象), 数据结构指的是实现的底层结构
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 结构设计
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 还使用
list
对listNode
进行封装提供了链表头尾节点 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;
7.2.2 链表的优缺点
优点:
- 获取某节点 前后 节点的时间复杂度为O(1) . listNode 提供 prev 和 next
- 获取表头和表尾节点的时间为 O(1). list 提供 head 和 tail
- 获取链表节点数量时间为 O(1). list 提供 len
- 链表节点可以保存各种不同类型的值. listNode 链表节使用 void* 指针保存节点值
缺点:
- 节点之间内存不连续, 无法很好的利用 CPU 缓存, 因为内存不连续
- 保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大
7.3 压缩列表
- 类似于结构体数组, 比链表更加节省内存, 但同样修改对于系统的影响较大; 并且保存数据过多时, 查询效率会降低
7.3.1 压缩列表结构设计
zlbytes
: 记录整个压缩列表占用的内存字节数zltail
: 记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;zllen
: 记录压缩列表包含的节点数量zlend
: 标记压缩列表的结束点,固定值 0xFF(十进制255)
压缩列表节点包含
prevlen
: 记录前一个节点的长度encoding
: 记录当前实际节点的长度 以及 类型data
: 记录实际数据
7.3.2 如何实现节约内存
-
节点会根据 数据是字符串还是整数, 以及数据大小使用不同 空间大小的
prevlen
和encoding
来标识, 从而节约内存 -
对于
prevlen
:前一字节长度 < 254 : 使用 1 字节
前一字节长度 >= 254 : 使用 5 字节
对于
encoding
:当前节点是整数: 使用 1 字节
当前节点是字符串: 根据字符串长度, 使用 1 / 2 / 5 字节空间进行编码
7.3.3 连锁更新
- 类似于 数组 中进行增删, 会导致后面的数据的 prevlen 占用空间发生变化, 从而引起 连锁更新的问题, 造成性能下降, 即进行多次空间拓展操作
- 所以压缩列表适用于保存节点数量不多的场景
7.3.4 List,Hash,Zset数据少时使用压缩列表
-
对于 Hash 对象, 使用 压缩列表 的情况
- 键值对的数据量小于 512 个
- 键值对中键和值的字符串长度小于 64 个字节
由配置文件中的
hash-max-ziplist-value
和hash-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;
-
哈希表是一个数组(
dictEntry **table
),数组的每个元素是一个指向「哈希表节点(dictEntry
)」的指针 -
dictEntry
里不仅有 key 和 value ,还有 next 指向下一个节点来解决哈希冲突, 这就是链式哈希(被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来) -
dictEntry
中的 value 是一个union
联合体, 也就是说 value 的值可以根据实际填充的值来实现, 从而节省内存空间, 比如说存放的是整数值就可以直接存入 value , 而不需要再用一个指针指向实际的值
7.4.2 rehash
- 随着数据的增多, 哈希桶的链表长度会逐渐变长, 导致查询效率下降, 所以就要对哈希表进行扩展, 即 rehash
dictht
定义了两个, 就是为了 进行 rehash
下图是 rehash 的大致过程
7.4.3 渐进式 rehash
过程:
- 给 哈希表 2 分配空间
- 在 rehash 过程中, 每次哈希表进行增删改查时, Redis 执行操作时, 也会将 涉及到操作的 哈希表 1 中的 key-value 迁移到 哈希表 2 上
- 随着请求越来越多, 最终全部数据都会迁移完成
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 位。
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;
-
保存了 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), 如下图
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
之间的关系如下图
- quicklist 在新增一个元素的时候, 不会像普通的链表一样, 直接创建一个链表节点, 而是检查插入位置的压缩列表能不能容纳该元素, 如果可以就直接保存 quicklistNode 结构里的压缩列表, 否则, 才会创建一个新的 quicklistNode, 但是这并没有解决 连锁更新 的问题
7.8 listpack
- 连锁更新 问题来源于 压缩列表中有保存前一个元素长度的 prevlen 属性 (主要作用是方便从左向右正向查询列表,或是从右向左反向), 所以如果解决掉这个字段, 那么连锁更新的问题也就没有了
- 而 listpack 就是针对此问题提出的解决方案, 是代替 压缩列表的 一个新结构, 估计未来将会替换掉 压缩列表
7.8.1 listpack 结构设计
-
listpack 保存了 压缩列表 很多有点, 比如使用连续的一段内存空间紧凑的保存数据, 不同节点采用不同编码来节省内存空间
具体结构如下图
- encoding: 元素类型的编码
- data: 实际数据
- len: encoding + data 的长度
listpack 没有记录上一节点的长度了, 只记录当前节点的长度, 所以当我们向 listpack 中添加新元素时, 不会影响其他节点的长度变化, 从而避免了连锁更新问题
参考
- 小林coding, 图解 Redis
- Redis 设计与实现