Redis问题

一、认识Redis

1. 什么是 Redis?

Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)。并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。
除此之外,Redis 还支持事务持久化多种集群方案(主从复制模式、哨兵模式、切片机群模式)、发布/订阅模式内存淘汰机制过期删除机制等等。

2. Redis和Memcached有什么区别?

  1. Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet),而 Memcached 只支持最简单的 key-value 数据类型
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;
  3. Redis 原生支持集群模式Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;
  4. Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持

3. 为什么用Redis作为MySQL的缓存?

  1. Redis 具备高性能
    操作 Redis 缓存就是直接操作内存,所以速度相当快。
  2. Redis具备高并发
    Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的

二、Redis数据结构

1. Redis 数据类型以及使用场景分别是什么?

  • String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
  • List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
  • BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

2. Redis 数据类型是怎么实现?

  1. SDS
    (1)引出:C语言字符串(字符数组)的不足:
    获取字符串长度的时间复杂度为 O(N);
    字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
    字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;
    (2)SDS
    Redis问题_第1张图片
    优点如下:
    len —— O(1)复杂度获取字符串长度
    不需要用 “\0” 字符来标识字符串结尾 —— 二进制安全
    当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小 —— 不会发生缓冲区溢出
    能灵活保存不同大小的字符串,从而有效节省内存空间 —— 节省内存空间

  2. 链表
    (1)list概念:Redis 在 listNode(双向链表节点) 结构体基础上又封装了 list 这个数据结构。
    (2)优点
        获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表;
        获取链表的表头节点和表尾节点的时间复杂度只需O(1);
        获取链表中的节点数量的时间复杂度只需O(1);
        链表节点可以保存各种不同类型的值;
    (3)缺点
        链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。
        保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

  3. 压缩列表
    (1)结构
    Redis问题_第2张图片
    压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。
    这就会导致连锁更新
    (2)连锁更新
    压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。

  4. 哈希表
    (1)引出Hash 对象的底层实现是压缩列表(最新Redis已将压缩列表替换成 listpack)或者 哈希表。
    (2)结构(redis哈希表 和 哈希表节点):
    Redis 的哈希表结构如下:
    Redis问题_第3张图片
    哈希表节点的结构如下:
    Redis问题_第4张图片
    综上,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
    Redis问题_第5张图片
    (3)哈希冲突rehash渐进式rehash
    哈希冲突:当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。
    rehash:
    在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。rehash操作如下:
    第一步、给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
    第二步、将「哈希表 1 」的数据迁移到「哈希表 2」 中;
    第三步、迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。
    问题:可能会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
    渐进式rehash: 也就是 将数据的迁移的工作不再是一次性迁移完成,而是 分多次迁移

  5. 整数集合
    (1)结构
    Redis问题_第6张图片
    contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。所以,不同类型的 contents 数组,意味着数组的大小也会不同。这就会出现 整数集合的升级操作。
    (2)整数集合的升级操作
    过程如下:Redis问题_第7张图片

  6. 跳表
    (1)举例
    Redis问题_第8张图片
    以上图中所有层的各个节点都为跳表的节点。
    (2)跳表查询过程简介:是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
    (3)跳表查询过程:
    根据以下判断条件在层中或层间 跳来跳去
    判断条件一、如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
    判读条件二、如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。
    如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找
    (4)跳表节点层数设置
    具体做法为:跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
    (5)为什么用跳表而不用平衡树
    Redis问题_第9张图片

  7. quicklist
    (1)举例
    Redis问题_第10张图片
    向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
    (2)问题:quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

  8. listpack(Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表避免连锁更新
    (1)结构( listpack 结构 和 其节点结构):
    Redis问题_第11张图片
    这样的话,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

三、Redis线程模型

1. Redis 是单线程吗?

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的
但是,Redis 程序并不是单线程的,Redis 在启动的时候,是**会启动后台线程(BIO)**的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件AOF 刷盘这两个任务;
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。

之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

2. Redis 单线程模式是怎样的?

待更

3. Redis 采用单线程为什么还这么快?

  • Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  • Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  • Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

4. Redis 6.0 之前为什么使用单线程?

  • Redis采用单线程避免多线程直接的上下文切换和进程调度等,所以CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制
  • 可维护性高,多线程模型虽然在某些方面表现优异,但是需要考虑线程之间的同步和通信等。
  • 避免竞争和锁 带来性能的消耗。

5. Redis 6.0 之后为什么引入了多线程?

在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升Redis 的性能瓶颈有时会出现在网络 I/O 的处理上

所以,为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。

四、Redis持久化

Redis持久化篇

五、Redis集群

Redis集群

六、Redis过期删除和内存淘汰

过期删除策略

  1. 过期字典(实际上是哈希表):每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
    当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:
    如果不在,则正常读取键值;
    如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。
  2. 过期删除策略有哪些?
    (1)定时删除;
    在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
    (2)惰性删除;
    不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
    (3)定期删除;
    每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
    最终,Redis 选择「惰性删除+定期删除」这两种策略配和使用

内存淘汰策略

  1. 不进行数据淘汰的策略
    noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,则会触发 OOM,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。
  2. 进行数据淘汰的策略(对设置过期时间的数据淘汰 和 所有数据范围内淘汰)
    (1)在设置了过期时间的数据中进行淘汰:
    volatile-random:随机淘汰设置了过期时间的任意键值;
    volatile-ttl:优先淘汰更早过期的键值。
    volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
    volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
    (2)在所有数据范围内进行淘汰:
    allkeys-random:随机淘汰任意键值;
    allkeys-lru:淘汰整个键值中最久未使用的键值;
    allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
  3. 如何查看和修改Redis 内存淘汰策略
    查看:config get maxmemory-policy
    修改:(1)config set maxmemory-policy <策略> (2)通过修改 Redis 配置文件修改,设置maxmemory-policy <策略>
  4. LRU算法 和 LFU算法
    (1)LRU算法:LRU 全称是 Least Recently Used 翻译为最近最少使用,会选择淘汰最近最少使用的数据。传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可。
    缺点如下:
    缺点一、需要用链表管理所有的缓存数据,这会带来额外的空间开销;
    缺点二、当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
    (2)Redis是如何实现LRU算法的?
    Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
    当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个
    (3)LFU算法:LFU 全称是 Least Frequently Used 翻译为最近最不常用,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。
    (4)Redis 是如何实现 LFU 算法的?
    LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。
    Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。
    在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。
    在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。
    两个变量代表的含义:
    ldt 是用来记录 key 的访问时间戳
    logc 是用来记录 key 的访问频次,注意,logc 是访问频次(访问频率

七、Redis缓存设计

Redis缓存篇

八、Redis实战

1. Redis 如何实现延迟队列?

在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。

在使用 zadd score1 value1 命令就可以一直往内存中生产消息之后。再利用 zrangebyscore 查询就能得到待处理的任务了,通过循环执行队列任务即可。
Redis问题_第12张图片

2. Redis 的大 key 如何处理?

  1. 如何找到大 key ?
    (1)redis-cli --bigkeys 查找大key
    (2)使用 SCAN 命令查找大 key
    (3)使用 RdbTools 工具查找大 key
  2. 如何删除大 key?
    (1)分批次删除
    删除大 Hash,使用 hscan 命令;删除大 List,通过 ltrim 命令;删除大 Set,使用 sscan 命令;删除大 ZSet,使用 zremrangebyrank
    (2)异步删除(Redis 4.0版本以上)
    从 Redis 4.0 版本开始,可以采用异步删除法,用 unlink 命令代替 del 来删除
    除了主动调用 unlink 命令实现异步删除之外,我们还可以通过配置参数达到某些条件的时候自动进行异步删除
    主要有 4 种场景,默认都是关闭的:
    lazyfree-lazy-eviction no —— 内存超过 maxmeory
    lazyfree-lazy-expire no —— 设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除
    lazyfree-lazy-server-del —— 隐式的 del,是否开启 lazy free 机制删除(例如 rename)
    noslave-lazy-flush no —— lave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据(是否开启 lazy free 机制删除)

3. Redis 管道有什么用?

  1. 主要作用:使用管道技术可以解决多个命令执行时的网络等待。(所以管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。)

4. Redis 事务支持回滚吗?

  1. 一个事务在开启过程之后,向命令队列中加入命令时候,若此时执行DISCARD则清空命令队列中的任务。并不会执行事务任何命令。
  2. 若一个事务在开启之后,向命令队列中加入命令,当事务执行之后(EXEC命令),若事务中有一句命令是错误的,则该条命令之前都会执行成功,它之后的不会执行。也就是没有回滚机制

5. 如何用 Redis 实现分布式锁的?

  1. 概念:在分布式环境下完成并发控制。用于控制某个资源在同一时刻只能被一个应用使用。
  2. 实现加锁操作(需要满足三个条件)
    (1)使用SET命令时候带上NX选项。加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,需要以原子操作的方式完成。
    (2)SET 命令执行时加上 EX/PX 选项,设置其过期时间。锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放。
    (3) SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值(用于标识客户端)。锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作。
    满足上面三个条件之后,SET命令是这个样子:
    Redis问题_第13张图片
  3. 实现解锁操作(Lua脚本):解锁的时候,先判断锁的 unique_value 是否为加锁的客户端,是的话,将 lock_key 键删除。
    因为解锁操作需要两步。Redis 在执行 Lua 脚本时,可以以原子性的方式执行,所以可以通过Lua脚本来执行解锁的两步,来保证锁释放操作的原子性
  4. 优点
    (1)性能高效
    (2)实现方便(setnx命令)
    (3)避免单点故障
  5. 缺点
    (1)超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。
    怎么改善?  基于续约的方式设置超时时间 —— 写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可
    (2)Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁
    怎么改善? Redlock(红锁)。核心思想为:是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为客户端成功地获得分布式锁,否则加锁失败。

你可能感兴趣的:(redis,缓存,数据库)