笔记:Redis面试金典

Redis属于单线程还是多线程?不同的版本有什么区别

在redis4.0之前是单线程,之后是多线程

为什么采用单线程(4.0之前)

  1. 单线程模型开发基于维护更简单

  2. 基于多路复用使得单线程模型的redis也能并发处理

  3. 对于redis系统来说,主要的性能瓶颈是内存和网络带宽而非CPU

Redis为什么这么快

  1. 基于内存操作,性能高

  2. 优秀的数据结构设计,比如SDS字符串类型,在存储的数据为int类型的时候SDS使用int类型存储,在存储的数据为字符串类型是存储类型为embstr,当字符串长度大于44字节时使用raw存储;在有序集合中使用了skiplist,查询时间复杂度为log(n)

  3. 多路复用机制,redis同时监听多个socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免I/O阻塞

  4. 避免上下文切换,单线程模型避免了不必要的上下文切换和多线程竞争。节省了多线程切换带来的时间和性能上的消耗

I/O多路复用

套接字的读写方法默认是阻塞的(例如当调用读取操作read方法时,缓冲区没有任何数据,那么这个线程就会阻塞卡在这里,知道缓冲区有数据或者是连接被关闭时,read方法才可以返回,线程才可以继续处理其他业务)

而对于非阻塞I/O尽管性能上得到了提升,但是在执行读取数据操作时,有可能只读取了一部分数据,同时写入数据也是可能出现这种情况,当缓冲区满了之后我们的数据还没有写完,剩余的数据何时写与何时读就成了一个问题。

基于以上问题,提出了I/O多路复用(一种简单的实现方式就是基于select实现)

img

IO多路复用(事件驱动模型)是指内核一旦发现程序指定的一个或者多个IO条件准备读取,它就通知该程序

(select(遍历注册信息进行通知,O(n),在文件描述符特别多的时候性能低),epoll(直接查询注册信息,O(1))

Redis单线程的缺点

单线程的机制导致 Redis 的 QPS(Query Per Second,每秒查询率)很难得到有效的提高;官方数据是10W QPS,如果业务中需要100W QPS那么单线程模型的

Redis多线程(4.0版本)

在redis4.0版本中虽然引入了多线程,但是该版本中的多线程只是用于大数据量的异步删除,对于非删除操作的意义不是很大

Redis多线程(6.0版本)

引入多线程的原因:redis虽然使用了I/O多路复用,并且是基于非阻塞I/O进行的,但是I/O的读和写本身是阻塞的(比如当socket中有数据时,redis会通过调用先将数据从内核态空间拷贝到用户态空间,再交给redis调用,而拷贝过程就是阻塞的)

引入多线程的目的:分摊redis同步读写I/O的压力,充分利用多核CPU资源,并且可以有效提升redis的QPS。

实现思路:主要是将主线程的IO读写任务拆分给一组独立的线程去执行,这样多个socket的读写可以并行化了,但redis的命令依旧是由主线程串行执行的。

redis6.0中默认是禁用多线程的,可以通过修改redis的配置文件redis.conf使io-threads-do-reads=true来开启多线程,但是要正确激活多线程功能还需要修改配置io-threads=需要开启的线程数(官方建议线程数一定要小于机器核数)

redis有哪些数据类型

字符串、字典、列表、集合以及有序集合

字符串:基于SDS结构实现,在存储类型为int以int对象接收,在存储类型为string时以embstr接收,当接收的字符串大小超过44字节时以raw对象接收

字典:基于hashMap实现(发生冲突时使用链表解决冲突(拉链法)),详情可参考数据结构hashmap篇

列表:基于链式结构存储的有序结构(基于双链表实现的双端队列),元素的插入和删除操作时间复杂度为O(1),查询时间复杂度为O(n)

集合:无序并唯一的键值集合,在存储的键值均为int类型时使用整数集合存储(基于有序数组实现),在存储其他类型时使用字典存储(key为集合元素,value为null)

有序集合:有序,键值唯一的元素。有序集合相比于集合类型多了一个排序属性score(分值)。基于跳表存储。

skiplist

redis高级数据结构以及用途

  1. GEO(地理位置类型):用于存储和查询地理位置。如附近的人、查询附近的商家等功能

  2. Stream(流类型):是redis5.0新增的数据类型。提供可靠性,可提供具备订阅发布功能(基于分组实现)的消息队列

  3. HyperLogLog(统计类型,简称HLL),高性能基数(去重)统计功能,存在极低误差率。

如何实现查询附近的人功能

使用GEO。

GEO是基于有序集合实现的

如何实现限流功能

滑动时间窗口

在一段时间(从当前时间往前取一定时间,如1分钟或者1小时)内最大处理一定数量的请求

image.png

漏桶

漏桶算法类似于生活中的漏斗,无论请求有多少,它都以均匀的速度慢慢流出。

当进入漏斗的速率大于流出漏斗的速率,漏斗就会慢慢变满(满时新来的请求就会被丢弃)

漏桶算法实现:

  1. 可以声明一个队列(漏斗)来保存请求,当队列容量满了就丢弃新来的请求

  2. 声明一个线程定期从该队列中取出一定数量的任务执行

image.png

令牌桶

令牌桶算法中有一个程序以某种恒定速度生成令牌,并存入令牌中,而每个请求需要线获取到令牌才能执行(若没有获取到令牌可以选择等待或者放弃)

image.png

redis分布式限流方案

redis4.0提供的Redis-Cell模块基于漏斗算法实现,并且提供了原子的限流指令

Redis-Cell 实现限流的方法也很简单,只需要使用一条指令 cl.throttle 即可,使用示例如下:

> cl.throttle mylimit 15 30 60
1)(integer)0 # 0 表示获取成功,1 表示拒绝
2)(integer)15 # 漏斗容量
3)(integer)14 # 漏斗剩余容量
4)(integer)-1 # 被拒绝之后,多长时间之后再试(单位:秒)-1 表示无需重试
5)(integer)2 # 多久之后漏斗完全空出来

其中 15 为漏斗的容量,30 / 60s 为漏斗的速率。

Redis内存用完会怎么样

在redis中配置了最大使用内存,redis中内存用完通常是redis超过了其设置的最大内存,该值可以通过修改配置文件进行修改(如果设置为0,则表示没有内存大小限制,知道耗尽机器中所有内存为止,在64位操作系统下默认为0,在32位操作系统下默认值为3GB)

当内存用完之后(且最大内存配置不为0)就会触发redis内存淘汰策略

内存淘汰策略

redis4.0版本之后一共有8种:

  1. noeviction:不淘汰任何数据,当内存不足时,新增操作会报错,Redis 默认内存淘汰策略;

  2. allkeys-lru:淘汰整个键值中最久未使用的键值;

  3. allkeys-random:随机淘汰任意键值;

  4. volatile-lru:淘汰所有设置了过期时间的键值中最久未使用的键值;

  5. volatile-random:随机淘汰设置了过期时间的任意键值;

  6. volatile-ttl:优先淘汰更早过期的键值;

  7. volatile-lfu:淘汰所有设置了过期时间的键值中,最少使用的键值;

  8. allkeys-lfu:淘汰整个键值中最少使用的键值。

内存淘汰算法

  1. LRU算法(最近最少使用的)

    基于链表实现,最新操作的元素会被移动到表头,当内存需要淘汰时,移除链表尾部的元素

  2. redis的近LRU算法

    为了更好的节约内存,redis额外增加一个字段记录元素的最后一次时间,在内存淘汰时,会使用随机采样的方式来淘汰数据(默认取5个值,淘汰最久没有使用的那个元素)

  3. LFU算法(最不常用的)

    淘汰使用频次最小的元素; 基于双向链表与频次map实现,在内存淘汰时,移除最小频次链表中的最后一个元素节点

    type Cache struct {
        // 缓存存储
        cache *sync.Map // map[interface{}]*Node
        // 存储每个频次对应的双向链表
        freqMap *sync.Map // map[uint32]*NodeList
        // 缓存大小
        size uint32
        // 缓存容量
        capacity uint32
        // 当前缓存中的最小频次
        min uint32
    }
    
    type Node struct {
        // Key 键
        key interface{}
        // 频次
        freq uint32
    
        next, prev *Node
    
        value interface{}
    }
    
    type NodeList struct {
        // 头哨兵
        head *Node
        // 尾哨兵
        tail *Node
    }

如何处理已过期的数据

在redis中维护了一个过期字典,会将所有已经设置了过期时间的键值对全部存储到此字典中

image.png

在获取键值时会先判断是否在过期字典中(是否设置过期时间),如果在则判断过期时间是否小于当前时间,如果不小于当前时间则说明已经过期了则删除键值并返回nil(默认的是惰性删除和定期删除)

过期数据删除策略

定时删除

在设置键值过期时间时,创建一个定时事件,当过期时间到达时,由事件处理器自动执行键的删除操作。优点:保证内存可以被尽快的释放;缺点:在redis高负载或大量过期键需要同时处理时,会造成redis服务卡顿,影响主业务执行

惰性删除

不主动删除过期键,每次从数据库获取键值时判断是否过期,如果过期则删除键值,并返回null。优点:因为每次访问时,才会判断过期键,所以此策略只会使用很少的系统资源;缺点:系统占用空间删除不及时,导致空间利用率低,造成了一定的空间浪费

定期删除

每隔一段时间检查一次数据库,随机删除一些过期键。redis默认每秒进行10次过期扫描,此配置可通过 redis的配置文件redis.conf配置字段hz,redis每次扫描并不是通过遍历过期字典中的所有键,而是采用随机抽取判断并删除过期键。优点:通过限制删除操作的时长和频率,来减少删除操作对redis主业务的影响,同时也能删除一步分过期的数据减少了过期键对空间的无效占用;缺点:内存清理方面没有定时删除效果好,同时没有惰性删除使用的资源少。

redis如何实现分布式锁

image.png

分布式锁可以通过incrset nx实现

在操作某个资源时,第一次获取到锁的用户A执行incr lock使得lock的值变为1,此时(用户A未释放锁)如果用户B要操作该资源执行incr lock使得lock的值变为2,lock值不为1说明获取锁失败。当用户A事务完成使用del lock释放锁

使用set key value nx 实现分布式锁,当使用命令创建键值成功(返回"OK")时说明加锁成功,否则加锁失败(返回"nil")

分布式死锁问题

当一个程序创建了分布式锁之后,因为某些特殊的原因导致程序意外退出,那么这个锁将永远不会被释放,就造成了死锁问题

一个简单的解决方法是给锁设置过期时间set key value nx ex 过期时间。但是在极端的情况下这种方式还是存在问题:误删锁

误删锁问题

假设锁的最大超时时间是 30s,应用 1 执行了 35s,然而应用 2 在 30s,锁被自动释放之后,用重新获取并设置了锁,然后在 35s 时,应用 1 执行完之后,就会把应用 2 创建的锁给删除掉,如下图所示:

分布式锁-锁被误删.png

我们可以在锁的value值中写入一个唯一标识来区分锁的所属,这样来解决锁被误删的情况。

如何保证redis消息队列中的数据不丢失

使用stream实现消息队列,stream支持消息持久化,支持消息消费确认。

redis消息队列优缺点分析

List 优点:

  • 消息可以被持久化,借助 Redis 本身的持久化 (AOF、RDB 或者是混合持久化),可以有效的保存数据;

  • 消费者可以积压消息,不会因为客户端的消息过多而被强行断开。

List 缺点:

  • 消息不能被重复消费,一个消息消费完就会被删除;

  • 没有主题订阅的功能。

ZSet 优点:

  • 支持消息持久化;

  • 相比于 List 查询更方便,ZSet 可以利用 score 属性很方便的完成检索,而 List 则需要遍历整个元素才能检索到某个值。

ZSet 缺点:

  • ZSet 不能存储相同元素的值,也就是如果有消息是重复的,那么只能插入一条信息在有序集合中;

  • ZSet 是根据 score 值排序的,不能像 List 一样,按照插入顺序来排序;

  • ZSet 没有向 List 的 brpop 那样的阻塞弹出的功能。

Pub/Sub 优点:

  • 提供了普通消息订阅模式和主题订阅模式。

Pub/Sub 缺点:

  • 无法持久化保存消息,如果 Redis 服务器宕机或重启,那么所有的消息将会丢失;

  • 发布订阅模式是“发后既忘”的工作模式,如果有订阅者离线重连之后不能消费之前的历史消息。

Stream 优点:

  • 支持消息持久化;

  • 支持消息消费确认。

Stream 缺点:

  • 实现代码比较繁琐;

  • 没有提供主题订阅的功能。

Redis如何实现延时队列

在redis中延迟消息队列(延时队列)一般是基于ZSet(有序集合)实现的。它的核心思想是在程序中开启一个循环的延迟任务检测器,用于检测以及执行延迟任务

img

基于ZSet实现延迟队列的方法有两种:

  1. zrangebyscore 查询所有任务,此实现方式是一次性查询出所有的延迟任务,然后再进行执行

  2. 判断最早的任务,此实现方式是每次查询最早的一条任务,再与当前时间进行判断,如果任务执行时间大于当前时间则表示应该立即执行延迟任务

布隆过滤器的应用:在海量数据中查询一个值是否存在

使用布隆过期检索一个元素是否在一个集合中(有一定误差率,可以设置理想误差率,但是要保证误差率越低需要的空间也就越大)。

优点:消耗空间少,查询速度快

缺点:存在一定误差,不能删除元素

常见使用场景:

  • 垃圾邮件过滤;

  • 爬虫里的 URL 去重;

  • 判断一个值在亿级数据中是否存在

常用redis优化手段

最有效的提供redis性能的方案就是在没有必要开启持久化的情况下,关闭持久化功能,这样每次对redis的操作就无需进行IO磁盘写入操作了,因此性能会提升很多;

缩短键值对的存储长度;

使用耗时长的命令,禁用慢查询;

更多见:[笔记:Redis核心原理与实战分析(下)]Redis性能优化方案

最后更新于

这有帮助吗?