Skip to content

Redis 原理

字数
3976 字
阅读时间
16 分钟

学习资料

黑马官方资料:G:\开发\Redis-笔记资料

Redis 数据结构

底层数据结构

SDS 动态字符串

我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。 不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:

  • 获取字符串长度的需要通过运算
  • 非二进制安全
  • 不可修改 Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。

源码案例

例如,我们执行命令:

那么Redis将在底层创建两个SDS,其中一个是包含“name”的SDS,另一个是包含“虎哥”的SDS。 Redis是C语言实现的,其中SDS是一个结构体,源码如下:

例如,一个包含字符串“name”的sds结构如下:

动态扩容

SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS: 假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:

  • 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
  • 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。 注意:alloc 不包含最后一个 \0 ,所以是 12。实际上申请的总内存是 13

优点总结

IntSet

IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。

结构

结构如下:

其中的encoding包含三种模式,表示存储的整数大小不同:

为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,结构如图:

现在,数组中每个数字都在int16_t的范围内,因此采用的编码方式是INTSET_ENC_INT16,每部分占用的字节大小为:

  • encoding:4字节
  • length:4字节
  • contents:2字节 * 3 = 6字节

添加数字

我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小。 以当前案例来说流程如下:

  • 升级编码为INTSET_ENC_INT32, 每个整数占4字节,并按照新的编码方式及元素个数扩容数组
  • 倒序依次将数组中的元素拷贝到扩容后的正确位置
  • 将待添加的元素放入数组末尾
  • 最后,将inset的encoding属性改为INTSET_ENC_INT32,将length属性改为4

源码

总结

Intset可以看做是特殊的整数数组,具备一些特点:

  • Redis会确保Intset中的元素唯一、有序
  • 具备类型升级机制,可以节省内存空间
  • 底层采用二分查找方式来查询

Dict

我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。

结构

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。

Dict 的 rehash

1) 2) 3)

总结

Dict的结构:

  • 类似java的HashTable,底层是数组加链表来解决哈希冲突
  • Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash

Dict的伸缩:

  • 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
  • 当LoadFactor小于0.1时,Dict收缩
  • 扩容大小为第一个大于等于used + 1的2^n
  • 收缩大小为第一个大于等于used 的2^n
  • Dict采用渐进式rehash,每次访问Dict时执行一次rehash
  • rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表

ZipList

ZipList 是一种特殊的“双端链表” (但不是由指针指向前后节点),由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)

ZipListEntry

Encoding 编码

  • 字符串:

  • 整数:

连锁更新问题

ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:

  • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
  • 如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据 现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。

总结

ZipList特性:

  • 压缩列表的可以看做一种连续内存空间的"双向链表"
  • 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时有可能发生连续更新问题

QuickList

控制 ZipList entry 数量

ZipList 压缩

源码

以下是QuickList的和QuickListNode的结构源码:

我们接下来用一段流程图来描述当前的这个结构:

总结

QuickList的特点:

  • 是一个节点为ZipList的双端链表
  • 节点采用ZipList,解决了传统链表的内存占用问题
  • 控制了ZipList大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

SkipList

源码

总结

SkipList的特点:

  • 跳跃表是一个双向链表,每个节点都包含score和ele值
  • 节点按照score值排序,score值一样则按照ele字典排序
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单

RedisObject

在 Redis 中,每个键值对的键都是一个由 SDS 存储的字符串,而它的值则会被封装成一个 RedisObject,也叫做Redis对象,源码如下:

什么是 RedisObject ?

  • 从Redis的使用者的角度来看: ⼀个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了从key space到object space的映射关系。这个映射关系的key是string类型,⽽value可以是多种数据类型,比如: string, list, hash、set、sorted set等。我们可以看到,key的类型固定是string,而value可能的类型是多个。
  • ⽽从Redis内部实现的⾓度来看: database内的这个映射关系是用⼀个dict来维护的。dict的key固定用⼀种数据结构来表达就够了,这就是动态字符串sds。而value则比较复杂,为了在同⼀个dict内能够存储不同类型的value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是robj,全名是redisObject。

编码方式

五种数据结构

数据类型

String

使用建议

使用 String 数据类型时,尽量存储数字或者是长度小于44的字符串,避免使用 RAW 编码

List

Redis的List类型可以从首、尾操作列表中的元素: 哪一个数据结构能满足上述特征?

  • LinkedList :普通链表,可以从双端访问,内存占用较高,内存碎片较多
  • ZipList :压缩列表,可以从双端访问,内存占用低,存储上限低
  • QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高

源码

Set

Set是Redis中的单列集合,满足下列特点:

  • 不保证有序性
  • 保证元素唯一
  • 求交集、并集、差集 可以看出,Set对查询元素的效率要求非常高,思考一下,什么样的数据结构可以满足?
  • HashTable,也就是Redis中的Dict,不过Dict是双列集合(可以存键、值对)

源码

结构图

encoding有两种编码方式:

  • intset:ptr 指向上面那张表格
  • HT:(dict)如下面的表格所示

ps:表格有误,dictht 的 used 应该为4

ZSet

编码

结构图

编码转换

zsetAdd函数:(考虑两种编码)

ZipList 本身不支持 ZSet 要求的功能,怎么实现?

Hash

编码

编码转换

Redis 网络模型

用户空间和内核空间

Linux 五种不同的 IO 模型

阻塞 IO

  • 性能不怎么好

非阻塞 IO

  • 性能和阻塞 IO 一样差,CPU 使用率还会暴增
  • 特定场合使用时反而会是个好选择

IO 多路复用

解决方案

FD

  • 类比餐厅:一次性监听多个顾客,而不是排队点餐

三种实现方式

IO多路复用-select

select模式存在的问题:

  1. 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  2. select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  3. fd_set监听的fd数量不能超过1024
IO多路复用-poll

  • 除了改进 select 模式的问题3,其他没改进,性能也没什么提升。
  • 链表随着数量的增加,因为链表遍历,性能会变差
  • Linux 基本没怎么使用
IO多路复用-epoll

优势(相比较select解决的问题):

  1. fd只需要拷贝一次(执行一次ctl),就会一直添加到红黑树上,以后的循环过程中不需要再拷贝
  2. 每次只拷贝就绪的fd,减少拷贝的次数(每个fd只拷贝一次),减少拷贝数量(只拷贝就绪的fd)
  3. 红黑树理论上讲支持的数量无限,红黑树增删改查的性能基本不会因为数量改变而改变,性能好而且稳定

总结

select模式存在的三个问题:

  • 能监听的FD最大不超过1024
  • 每次select都需要把所有要监听的FD都拷贝到内核空间
  • 每次都要遍历所有FD来判断就绪状态 poll模式的问题:
  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降 epoll模式中如何解决这些问题的?
  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降
事件通知机制

LT 模式缺点:

  1. 重复通知影响性能
  2. 惊群问题
web服务流程

信号驱动 IO

异步 IO

同步还是异步

Redis 网络模型

Redis到底是单线程还是多线程?

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个Redis,那么答案就是多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis是纯 内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
  • 多线程会导致过多的上下文切换,带来不必要的开销(单核情况下)
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

为什么单线程速度快?

  • Redis是纯 内存操作,执行速度非常快(最主要)
  • 其他:IO多路复用

不同的多路复用(用哪个取决于你的操作系统)

Redis 单线程网络模型的流程

Tips

源码解析见黑马视频&PPT 实在太干货了🤣 牢记第二幅图!!!可对面试官装逼

传统单线程模型下,真正影响性能的不是命令处理,也不是IO多路复用,而是IO:与数据库连接,与MySQL连接……

Redis 通信协议

RESP 协议

  • RESP2和RESP3兼容性问题比较大

数据类型

Redis 内存策略

过期策略

DB 结构

Redis 是如何知道一个key是否过期呢?

利用两个Dict分别记录key-value对及key-ttl对

是不是TTL到期就立即删除了呢?

  • 惰性删除
  • 周期删除

惰性删除

周期删除

淘汰策略

内存淘汰

淘汰策略

  • LRU:最近没用(用的时间远)
  • LFU:用的频率低

结构

流程

贡献者

页面历史

撰写