redis夺命连环问1--为什么单线程Redis能那么快?

为什么单线程的 Redis 能那么快?(概括)

  • 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速;

  • 数据结构简单,对数据操作也简单;

  • 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;

  • 使用多路 I/O 复用模型,非阻塞 IO。

1.先谈单线程是啥

首先咱得知道Redis是单线程。主要是指 Redis的网络 IO 和键值对读写是由一个线程来完成,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化等其实是由额外线程执行的。

2.再谈单线程用来干啥

其次咱得知道Redis 为什么用单线程?其实主要是为了避免多线程的开销问题。为了避免多线程编程面临的共享资源的并发访问控制问题,锁的控制以及死锁问题导致的性能开销,为了系统代码的易调试性和可维护性。

3.再谈Redis的单线程怎么用

最后 咱得知道Redis 是采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

  • 先说阻塞模式下三个网络IO可能的阻塞点,监听请求(bind/listen),建立连接(accept),读取请求(recv)会导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。所以redis采用非阻塞模式,当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。
  • 基于多路复用的高性能 I/O 模型,就是一个线程处理多个 IO 流。简单来说,在 Redis 单线程的情况下,允许内核中存在多个监听套接字和已连接套接字。内核监听套接字上的请求。一旦请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。

在这里插入图片描述
在这里插入图片描述

  • 再说回调机制,select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。事件会被放进一个队列,Redis 单线程对该事件队列不断进行处理调用相应的处理函数。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费,提升 Redis 的响应性能。

4.再谈它是内存数据库并有高效数据结构

埋坑 Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。

Redis 6.0 以下版本,属于单 Reactor 单线程模型,监听请求、读取数据、处理请求、写回数据都在一个线程中执行,这样会有 3 个问题:

  • 单线程无法利用多核
  • 处理请求发生耗时,会阻塞整个线程,影响整体性能
  • 并发请求过高,读取/写回数据存在瓶颈

Redis 6.0 进行了优化,引入了 IO 多线程,把读写请求数据的逻辑,用多线程处理,提升并发性能,但处理请求的逻辑依旧是单线程处理

IO多路复用的实现

基本的 Socket 编程模型的话,只能对一个监听套接字或一个已连接套接字进行监听。而当 Redis 实例面临很多并发的客户端时,这种处理方式的效率就会很低。
在这里插入图片描述

select 函数进行网络通信的基本流程
在这里插入图片描述
select 函数不足

  • 首先,select 函数对单个进程能监听的文件描述符数量是有限制,1024。
  • 其次,需要遍历描述符集合,才能找到是哪些描述符就绪。会产生一定开销,从而降低程序的性能。

poll 函数的流程
在这里插入图片描述
和 select 函数相比,poll 函数的改进之处主要就在于,它允许一次监听超过 1024 个文件描述符。但是当调用了 poll 函数后,我们仍然需要遍历每个文件描述符,检测该描述符是否就绪,然后再进行处理。

epoll 进行网络通信的流程
在这里插入图片描述
epoll 能自定义监听的描述符数量,以及可以直接返回就绪的描述符。
在这里插入图片描述

Redis 在设计和实现网络通信框架时,就基于 epoll 机制中的函数和读写事件,进行了封装开发,实现了用于网络通信的事件驱动框架,从而使得 Redis 虽然是单线程运行,但是仍然能高效应对高并发的客户端访问。

1、单线程服务器模型,面临的最大的问题就是,一个线程如何处理多个客户端请求?解决这种问题的办法就是「IO 多路复用」。它本质上是应用层不用维护多个客户端的连接状态,而是把它们「托管」给了操作系统,操作系统维护这些连接的状态变化,之后应用层只管问操作系统,哪些 socket 有数据可读/可写就好了,大大简化了应用层的复杂度

2、IO 多路复用机制要想高效使用,一般还需要把 socket 设置成「非阻塞」模式,即 socket 没有数据可读/可写时,应用层去 read/write socket 也不会阻塞住(内核会返回指定错误,应用层可继续重试),这样应用层就可以去处理其它业务逻辑,不会阻塞影响性能

3、为什么 Redis 要使用「单线程」处理客户端请求?本质上是因为,Redis 操作的是内存,操作内存数据是极快的,所以 Redis 的瓶颈不在 CPU,优化的重点就在网络 IO 上,高效的 IO 多路复用机制,正好可以满足这种需求,模型简单,性能也极高

4、但成也萧何败也萧何,因为 Redis 处理请求是「单线程」,所以如果有任意请求在 Server 端发生耗时(例如操作 bigkey,或一次请求数据过多),就会导致后面的请求发生「排队」,业务端就会感知到延迟增大,性能下降

5、基于此,Redis 又做了很多优化:一些耗时的操作,不再放在主线程处理,而是丢到后台线程慢慢执行。例如,异步关闭 fd,异步释放内存、后台 AOF 刷盘这些操作。所以 Redis Server 其实是「多线程」的,只不过最核心的处理请求逻辑是单线程的,这点一定要区分开


1、很多人认为 Redis 是单线程,这个描述是不准确的。准确来说 Redis 只有在处理「客户端请求」时,是单线程的。但整个 Redis Server 并不是单线程的,还有后台线程在辅助处理一些工作

2、Redis 选择单线程处理请求,是因为 Redis 操作的是「内存」,加上设计了「高效」的数据结构,所以操作速度极快,利用 IO 多路复用机制,单线程依旧可以有非常高的性能

3、但如果一个请求发生耗时,单线程的缺点就暴露出来了,后面的请求都要「排队」等待,所以 Redis 在启动时会启动一些「后台线程」来辅助工作,目的是把耗时的操作,放到后台处理,避免主线程操作耗时影响整体性能

4、例如关闭 fd、AOF 刷盘、释放 key 的内存,这些耗时操作,都可以放到后台线程中处理,对主逻辑没有任何影响

5、后台线程处理这些任务,就相当于一个消费者,生产者(主线程)把耗时任务丢到队列中(链表),消费者不停轮询这个队列,拿出任务就去执行对应的方法即可


1、Redis 6.0 之前,处理客户端请求是单线程,这种模型的缺点是,只能用到「单核」CPU。如果并发量很高,那么在读写客户端数据时,容易引发性能瓶颈,所以 Redis 6.0 中新设计实现的多 IO 线程机制。这个机制的设计主要是为了使用多个 IO 线程,来并发处理客户端读取数据、解析命令和写回数据。使用了多线程后,Redis 就可以充分利用服务器的多核特性,从而提高 IO 效率。

2、Redis 6.0 先是在初始化过程中,根据用户设置的 IO 线程数量,创建对应数量的 IO 线程。

3、当 Redis server 初始化完成后正常运行时,它会调函数决定是否推迟客户端读操作。同时,Redis server 会在调函数决定是否推迟客户端写操作。而待读写的客户端会被分别加入到两个列表中。

5、每当 Redis server 要进入事件循环流程前,将待读写客户端以轮询方式分配给 IO 线程。主线程自己也会读写客户端 socket(主线程也要分担一部分读写操作),之后「等待」所有 IO 线程完成读写,再由主线程「串行」执行后续逻辑。

6、每个 IO 线程,不停地从链表中取出客户端,并根据指定类型进行读、写处理。

7、Redis 官方表示,开启多 IO 线程后,性能可提升 1 倍。当然,如果 Redis 性能足够用,没必要开 IO 线程


在这里插入图片描述

1、无论是 IO 多路复用,还是 Redis 6.0 的多 IO 线程,Redis 执行具体命令的主逻辑依旧是「单线程」的

2、执行命令是单线程,本质上就保证了每个命令必定是「串行」执行的,前面请求处理完成,后面请求才能开始处理

3、所以 Redis 在实现分布式锁时,内部不需要考虑加锁问题,直接在主线程中判断 key 是否存在即可,实现起来非常简单

课后题:如果将命令处理过程中的命令执行也交给多 IO 线程执行,除了对原子性会有影响,还会有什么好处和坏处?

好处:

  • 每个请求分配给不同的线程处理,一个请求处理慢,并不影响其它请求
  • 请求操作的 key 越分散,性能会变高(并行处理比串行处理性能高)
  • 可充分利用多核 CPU 资源

坏处:

  • 操作同一个 key 需加锁,加锁会影响性能,如果是热点 key,性能下降明显
  • 多线程上下文切换存在性能损耗
  • 多线程开发和调试不友好

多 IO 线程实际并不会加快命令的执行,而是只会将读取解析命令并行化执行,以及写回结果并行化执行,并且读取解析命令还是针对收到的第一条命令。实际上,这一设计考虑还是由于网络 IO 需要加速处理。那么,如果命令执行本身成为 Redis 运行时瓶颈了,你其实可以考虑使用 Redis 切片集群来提升处理效率。


Redis 启动流程,主要的工作有:

1、初始化前置操作(设置时区、随机种子)

2、初始化 Server 的各种默认配置

3、加载配置启动参数,覆盖默认配置

4、初始化 Server,还会启动 3 类后台线程,协助主线程工作(异步释放 fd、AOF 每秒刷盘、lazyfree)。

5、启动事件循环处理请求、任务

刚刚你说到了高效的数据结构,能具体讲讲redis是怎么实现的吗?

首先 谈谈redis值有五种数据类型,
String(字符串)可以做简单的键值对缓存、
List(列表)可以存储一些列表型的数据结构、
Hash(哈希)可以存结构化的数据,比如一个对象、
Set(集合)可以做交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集、
Sorted Set(有序集合)去重但可以排序,如获取排名前几名的用户。

它们实现关系如下:
在这里插入图片描述
就随便谈谈String 类型的底层实现只有一种数据结构,简单动态字符串。Hash是当数据量较小时用压缩列表实现,较大时用哈希表实现并且不可逆就行了。

1.数据类型方面:可以谈一下Hash

首先一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
在这里插入图片描述
优点:
O(1) 的时间复杂度快速查找到键值对
缺点:
哈希表的冲突问题和 rehash 可能带来的操作阻塞。

  • Redis 中写入大量数据后,就可能发现操作有时候会突然变慢
    为什么哈希表操作变慢了?
    哈希冲突,也就是指,两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。
    方案一
    拉链法。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
    方案二
    rehash
    过程分为三步:
    1.给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
    2.把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
    3.释放哈希表 1 的空间。
    方案三
    方案二第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。
    渐进式 rehash
    简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

2.底层数据结构方面:可以谈压缩列表、跳表

首先 redis集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表。

  • 压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
    在这里插入图片描述
  • 跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位
    在这里插入图片描述

可以谈谈复杂度
在这里插入图片描述

  • 单元素操作是基础(指每一种集合类型对单个数据实现的增删改查操作);
  • 范围操作非常耗时(这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。);
  • 统计操作通常高效(集合类型对集合中所有元素个数的记录);
  • 例外情况只有几个(压缩列表和双向链表都会记录表头和表尾的偏移量。)。

3.String数据类型和它具体实现也可扯一波

  • 谈谈String 类型内存开销大的问题
    RedisObject(embstr 编码方式或raw 编码模式:挨着或指着的SDS有元信息开销、int 编码方式(long):long无元信息开销):
    有元信息和指针开销
    在这里插入图片描述

  • 用什么数据结构可以节省内存
    压缩列表:表头有列表长度、列表尾的偏移量、列表中的 entry 个数。列表尾还有 zlend表示列表结束。
    在这里插入图片描述
    为什么呢?
    一、它是用一系列连续的 entry 保存数据,entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。
    二、Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,节省了 dictEntry 的开销。当你用 String 类型时,一个键值对就有一个 dictEntry。但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。

  • 那怎么用压缩列表保存单值的键值对呢?
    用基于 Hash 类型的二级编码方法
    分配某字段前几位作键,然后把后几位作为 Hash 集合的 key,对应的另一个字段作 Hash 集合的 value
    二级怎么用?
    二级编码方法中采用的 位的长度是有讲究的。
    涉及到一个问题–Hash 类型底层结构小于设定值时使用压缩列表,大于设定值时使用哈希表。
    一旦从压缩列表转为了哈希表,Hash 类型会一直用哈希表进行保存,而不会再转回压缩列表。
    在节省内存空间方面,哈希表就没有压缩列表那么高效。为能充分使用压缩列表的精简内存布局,一般要控制保存在 Hash 中的元素个数。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值