Redis入门--万字长文详解epoll

初始Redis

Redis自此开始,希望善始善终

一、计算机基础常识

1、磁盘与内存

数据最早是保存在文件中的,如何进行文件数据查询?在 Linux 操作系统中,可以通过 grep、awk 等命令进行查看;也可以通过 Java 语言写一个程序,实现基于 I/O 流的读取查找

在此存在一个基本的 I/O 常识:

数据是存放在磁盘,对于磁盘查询数据的 I/O 速度有两个指标

  • 寻址:ms(毫秒级)
  • 带宽:G(单位时间的 I/O 速度)

如果数据 load 到内存,基于内存进行数据 I/O 的啥时候,速度指标

  • 寻址:ns(纳秒级)(秒–> 毫秒 --> 微秒 --> 纳秒),所以内存的寻址时间是磁盘的数十万倍
  • 带宽:很高,因为内存是直接通过 I/O 总线与 CPU 打交道的

2、I/O Buffer:4K

我们知道磁盘的两个基本常识:1、扇区;2、磁道

扇区:在磁盘中保存数据的时候,整个磁盘会分为一个个小的存储单元,称之为扇区。每个扇区 512 byte大小,如果数据都以扇区为K检索单元,则一个 1TB 的数据将会分为很多个很多个扇区,由此带来一个问题——索引成本变大

所以实际在硬件中存在 4K 对齐——每次在读取数据的时候,并不是按照扇区 512 byte 读取,而是每次最少读取 4K 数据,以 4K 大小划分磁盘,因此以 4K 为存储单元维护索引的成本就变小

随着文件变大,读取文件的速度回收到硬盘的限制,速度必然变慢,这称之为 I/O 瓶颈,这是硬件级别的、目前不可逾越的问题,因此诞生了数据库

3、Data page:4K

在数据库层面存储数据的时候,数据都是按照 Data page 来存放,每个 Data page 的大小为 4K,数据库会为每一个Data Page做一个编号

进行数据查询的时候,每次最少读取一个 Data Page ,大小刚好对应于磁盘的缓冲页大小,所以数据库每次读取数据刚好对应一次磁盘 I//O ,不会产生 I/O 浪费

但是如果只是在数据库里面建表,那么每次读取数据的时候,要一个个遍历每个 Data page 小格子,走的仍然是全量的磁盘 I/O ,和上面磁盘查询数据相同,效率很低

为了使查询某个指定的 Data page 中的数据的速度变快,在数据库层面可以使用索引

4、索引

索引仍然是按照 Data page 来组织,大小也是 4K,使用索引在根据某一个字段查询数据的时候,不需要全量 I/O 查询,可以直接定位到数据所在的 Data page,全量遍历的速度与此相比就和乌龟一样慢

所以在关系型数据库中建表的时候,必须给出 schema,每一列的数据类型就确定了,则每一列所占字节的宽度就定死了。假设一行记录共 10 个字段,这样在插入数据的时候,如果只给了其中几个字段的值,其余列我们就可以根据数据类型直接开辟相同大小空间,并赋零值占位

这样做有什么好处呢?好处就是当需要对这些空字段进行增删改的时候,不需要移动数据,直接在对应的位置上进行复写即可,省却了大量的数据移动的开销

但是索引和记录一样都是数据,也是需要存储在硬盘当中的,由于和内存相比磁盘的速度实在是太慢了,所以真正进行查询的时候,需要在内存中组织一棵 B+ 树

5、B+树

B+树的每一个叶子节点都是 4K 大小的小格子,B+树的所有的树干是在内存里的(也就是区间和偏移),此时用户的查询 SQL 语句中的 while 条件只要命中了索引,那么查询在 B+ 树中会走树干,最终查询到某一个叶子,在根据叶子中的信息找到磁盘中的缓存页,最终只需要进行一次磁盘 I/O 就可以找到所需要的记录

在这里插入图片描述

使用索引能够定向的,沿着一条路径很快的查询到数据

为什么在内存中使用 B+ 树?

索引和记录作为数据,利用磁盘存储量大和持久性的特点,都存放在磁盘中;内存速度快但是容量小,所以在内存中内存中只存放了树干,也就是区间和偏移量,使用一种数据结构 B+ 树来加快查询的速度,这样充分发挥了磁盘容量大和内存速度快的特点

这样使数据分而治之,而且查询极快,最终的目的就是减少 IO 的流量,减少寻址的过程

如果数据库中的表很大,行很多,性能就会降低?如果面试这样问,应该怎么答?

如果表有索引,且在内存中能把索引组织出 B+ 树:

增删改需要维护索引,就会造成效率变低;

查询速度需要分两点表述:

​ 1、一个或者少量查询,效率基本不影响

​ 2、并发查询或者复杂 SQL ,因为走索引,所以寻址时间基本不会受影响;但是由于需要加载大量的I/O buffer 缓冲页到内存,受硬盘带宽影响,查询效率会变低

如果数据量很大且并发量很大,基于硬盘存储的关系型数据库的增删改查都受影响,此时怎么解决呢?极端情况下我们可能幻想如果整个数据库都可以加载到内存就行了,其实还真有这种基于内存的数据库——HANA

SAP 公司的 HANA 数据库,是基于内存的关系型数据库

但是这东西很贵,硬件很贵,整台机器搭建起来要两亿;培训也很贵,一周培训要十几万

既然基于硬盘的关系型数据库,当数据量很大且并发量很大,必然会存在效率问题;如果使用基于内存的 HANA 数据库,没几家公司能用得起。此时就出现了一种这种的方案——缓存

在这里插入图片描述

二、缓存

从第一个计算机出现,到现在的整个 IT 世界,发展了这麽多年,有两个基础设施:

  • 冯诺依曼硬件体系
  • 以太网、TCP/IP 的网络

由于冯诺依曼硬件体系的制约,永远不可能突破 IO 瓶颈,如果出现了量子计算机,硬盘 IO 带宽解决了,那么也就不会再有人使用 Redis 缓存

根据以太网和 TCP/IP 构建起来的网络世界,其潜台词就是不稳定,此时如果整合多个技术就一定会带来很多问题,如数据不一致、双写等等很多问题,所以使用 Redis 缓存也是大势所趋

数据库引擎和数据库系统:https://db-engines.com/en/

三、Redis

Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。

它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。

Redis 内置了 复制(replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。

1、Redis优势–计算向数据移动

又来做缓存的技术有很多,比如在 Redis 出现之前很火的 memcached ,它和 Redis 一样都是基于 k-v 键值对形式的存储,为什么现在更多的使用 Redis 而不是 memcached 呢?

因为 memcached 中的 value 没有类型的概念,而 Redis 中有如下 5 种数据类型,这是两者最本质的区别

在这里插入图片描述

我们知道世界上有基本的三种数据表示

  • k = a, k = 1
  • k = [1, 2, 3], k = [a, x, v]
  • k = {x, y}, k = {[a,v], [1, 5, 7]}

我们还知道 Json 可以表达任何类型的数据,既然 memcached 以键值对来存储数据,就能通过 json 来存储所有的数据,那么为什么使用 Redis 而不是 memcached 呢?

在这里插入图片描述

使用 memcached :

  • 由于返回全量的 JSON 数据,数据量大,server 端的网卡IO将会成为瓶颈
  • 由于返回全量数据,client 要有具体的代码对返回的数据进行解码,才能得到具体的属性字段值

如果使用 Redis:

  • 它的 value 支持5种数据类型, redis 的server中对每种类型都封装、提供了丰富的 API,如index()、lpop() 等,可以按照需求检索指定的数据,完美规避掉了 memcache 的缺点

简单来说,Redis 的优势可以用一个词来描述——计算向数据移动

因为客户端并不是拿到数据在客户端解析计算,而是在调用方法的时候,在服务端进行计算,直接返回给客户端经过筛选的、少量的数据即可

2、Redis 如何处理高并发?–Epoll

我们知道 Redis 是单进程、单进程、单实例的,那么当大量并发请求同时访问 Redis ,Redis 是如何保证效率呢?

在这里插入图片描述

高并发场景下最需要担心和处理的就是如何保证数据的一致性,即保证线程安全

其中针对同一条记录的增删改查请求,应该尽量由同一个线程发出,这一点需要在到达服务端之前的业务进行控制,比如在代码层面通过 Spring 事务来保证

尽管如此,很多场景下事务是无法保证对同一个记录的增删改查都由一条线程发出,尤其是大量用户共同访问同一共享资源的时候,同一时刻仍然有大量的针对同一记录的增删改查到达 redis 服务端,此时 redis 如何保证并发场景下线程安全和并发效率呢?

保证并发效率–epoll

同一时刻很多请求到达 redis 的 server 端,简而言之就是有大量的 IO 处理。我们知道服务器通常部署在 linux 环境下,此时有 linux 中内核的 epoll 技术来保证对于大量任务的快速的、高效率的处理

所以 epoll 是 redis 保证并发效率的关键,在下面我们将会来详细讲述针对 IO 效率的优化

BIO – NIO – mmap – epoll

保证线程安全–单线程

由于 Redis 被设计成单进程、单线程、单实例的,所以当大量的并发任务同时到达 redis 的 server 端,经过 epoll 技术的处理,所有的任务都被保存在一个链表中,这样一个 redis 服务从链表中处理任务的时候,一定不会出现并发安全问题,省略了使用锁机制保证线程安全带来的系统消耗,这是 redis 在高并发情况下效率保证高效率的关键点之一

四、IO 技术的发展

高并发业务场景下,当很多任务同时到达服务端的时候,对于所有任务的处理可以认为就是 IO,在上面我们已经解释过 IO 是我们整个系统的最大瓶颈,使用数据库、索引、缓存等技术也是为了尽最大可能的减少磁盘 IO

Linux 操作系统内核所使用的 epoll 技术是一种针对 IO 的高性能的优化,epoll 也是理解 redis 的关键所在,下面我们就来详细剖析一下 epoll 技术的前世今生

1、BIO

BIO 即 Blocking IO ,也叫做同步阻塞 IO

每一个 Client 连接对应的都是一个 socket,每一个 socket 对于操作系统内核 kernal 而言都是一个文件描述符,对于每个连接,服务器会启动一个线程调用 read 命令去处理文件描述符 fd

在 BIO 时期 socket 是阻塞的,意思是说socket 产生的文件描述符,线程去读它的时候,如果数据包一直没有发送过来,线程就会一直阻塞等待数据包,这样当其他 socket 任务到来的时候,阻塞线程虽然闲置但是也不能处理已经到来的任务,所以对于新来到的每一个请求,服务器只能抛出更多线程去处理任务

在这里插入图片描述

BIO 的问题:socket 阻塞,导致服务器抛出大量的线程

由于线程会阻塞,服务器只能抛出更多的线程;当线程过多的时候,操作系统对于线程的调度、线程的上下文切换,会涉及到的用户态到内核态的转换,将会严重的系统资源的浪费

此时操作系统资源是很难被有效的利用起来的,所以操作系统内核 kernal 进行了优化,那就是 NIO

2、NIO

NIO 即 NonBlocking IO,也被叫做同步非阻塞 IO

我们知道 BIO 的问题在于 socket 产生的文件描述符是阻塞的,造成服务器抛出大量的线程,系统资源浪费严重

我们的线程都是基于 JVM 产生的,默认的每个线程将会占用 1 M内存,线程多了会带来如下弊端:

​ 1、线程多了会有很高的调度成本,造成 CPU 浪费

​ 2、线程多了本身会占用大量的内存,内存成本也很高

所以 Linux 内核向前发展,使文件描述符不再阻塞–nonblocking,这一时期称为 NIO——同步非阻塞

在这里插入图片描述

由于文件描述符不再是阻塞的,所以线程不会只处理一个 socket 请求,此时可以服务器只需要抛出一个线程,此线程通过一个死循环即可轮询所有的文件描述符,处理所有的 socket 请求,不必再抛出大量线程

NIO 的问题:大量的系统内核调用

如果有1000个客户端线程请求,就会有100个文件描述符 fd,这样每次轮询过程就会调用 1000 次系统内核 kernal;然而实际上有很多 soeket 的数据还没到,触发 IO 事件的线程可能只有两三个,有很多系统内核调用是被浪费的,也会造成系统资源浪费

系统资源浪费:发生系统内核调用的时候,CPU 需要进行用户态到内核态的切换,需要对线程的上下文进行保护现场和恢复现场,将会占用大量的 CPU 事件

为了减少无谓的系统内核调用,内核向前发展,产生了多路复用

3、多路复用

多路复用:NIO 的轮询由用户空间处理变成内核空间的轮询,减少系统调用

NIO 中轮询发生在用户空间,是全量文件符的轮询,没有数据到达的、不需要处理的文件描述符也会触发系统内核调用,进行用户态到内核态的切换,因此发展出了多路复用技术

在多路复用中服务器线程只需要调用一次系统内核,这一次调用会发送所有的文件描述符,系统内核会进行轮询和判断,只返回给服务器线程有数据到达的少量的文件描述符,服务器线程再调用 read 命令处理 socket 请求

在这里插入图片描述

多路复用的 NIO 的核心思想就是为了减少用户态到内核态的切换

多路复用的问题:无谓的文件描述符也需要拷贝

多路复用相较于 NIO 而言减少了很多次的系统内核 kernal 调用,但是因为需要文件描述符进行决策,所以需要在用户空间和内核空间不断拷贝文件描述符,文件描述符这样的数据称为累赘了

出现这种问题的原因在于用户和内核两者的内存空间不共享,所以两者间的数据(文件描述符)传递就需要不断进行数据拷贝和传递。如果用户和内核空间两者存在一块共享内存区间,两者都能够进行操作和访问,就不再需要进行文件描述符的拷贝了

操作系统内核基于这种思想实现了空闲空间技术——mmap

4、mmap–共享空间

mmap的核心就在于实现了用户和内核两者的一个共享空间

在多路复用的时候,文件描述符需要不断在用户空间和内核空间的来回拷贝,基于内核 kernal 的 mmap 系统调用,在用户空间和内核空间中实现了一个共享空间,两者都可以直接操作内存修改数据,避免了数据的拷贝过程

在这里插入图片描述

基于系统调用 mmap ,操作系统实现了一块用户和内核 kernal 的共享空间,全量的文件描述符不需要拷贝到内核空间,只需要放入共享空间,组织成一棵红黑树,内核空间就能够直接观察所有的文件描述符

内核空间通过 IO 中断,判断哪些文件描述符有数据到达,有效的文件描述符不需要拷贝到用户空间,只需要放入共享空间,组织成链表,用户空间就能够从链表中得知哪些 socket 有数据到达,再去处理相应的任务

5、epoll

epoll 是基于 mmap 实现的一种事件驱动模型

epoll 是一个大的概念,里面包含三个系统调用:

  • epoll_create:创建一个 epoll 的文件描述符–epfd,有 client 连接到来,就会把连接写给 epoll 的文件描述符,epoll 会注册所有的连接,在共享空间 mmap 中维护一棵红黑树
  • epoll_ctl:会有 add、delete 调用,是添加或删除真正的 socket 文件描述符
  • epoll_wait:用户空间调用 wait 会阻塞,如果红黑树中的 socket 节点有数据到达,会把此节点放入链表,此时wait 事件到达,wait 调用就可以返回,用户线程就从链表中依次处理任务

epoll 的处理事件任务的流程如下图:

在这里插入图片描述

当有客户端连接到服务器服务线程,epoll 的系统调用处理流程:

  1. 调用 epoll_create 系统调用,将连接注册到 mmap 中,维护成一棵红黑树
  2. 服务线程调用 epoll_ctl,对 socket 产生的文件描述符进行增加和删除
  3. 调用 epoll_wait,服务线程会阻塞并对所有的文件描述符进行监听,内核经过IO中断判断,把有数据到达的 socket 节点文件描述符放入共享空间 mmap 的链表中,wait 监听到链表中有事件需要处理就会返回,所以称之为事件驱动模型
  4. 服务线程根据链表中返回的有数据的文件描述符,对有 IO 事件的 socket 进行任务处理

总结:epoll 是任务驱动模型,其在高并发场景下保证效率的关键点如下:

  • socket 不再阻塞,服务器线程只需要抛出一个线程,避免大量线程的系统开销(BIO缺陷);
  • 使用 select 多路复用,轮询发生在内核空间而不是在用户空间,不需要进行频繁的内核调用,避免大量的用户态到内核态转换带来的开销(NIO缺陷);
  • 使用 mmap 共享内存,不需要进行多次的文件描述符数据的复制过程,节省系统资源(select 多路复用缺陷)
  • 使用 wait 系统调用,即事件驱动模型,且链表天然有序(先后顺序),使事件任务能够顺序处理

6、总结

IO 模型从 BIO–NIO–select 多路复用–epoll 的发展历程整体流程图如下:
在这里插入图片描述

7、补充:零拷贝技术–sendfile

最早开始的时候,client 端想要读取服务器的文件,内核经过系统调用 read 和 write 指令,把文件会先读取到内核缓冲区 buffer,并拷贝到服务线程的用户空间,才能经过网卡发送给 client 服务端

并发量低的场景下,数据拷贝可能不会带来很严重的效率问题,但是如果高并发场景下,大量的文件数据都需要从内核缓存中拷贝到服务线程的话,将会带来很严重的系统浪费

在这里插入图片描述

后来内核为了解决服务器线程和内核空间缓存之间存在的数据拷贝过程,出现了零拷贝技术,对应于内核的系统调用——sendfile,零拷贝技术实现的关键就是 sendfile 系统调用

在这里插入图片描述

Linux 实现的零拷贝技术在 JVM 中也做了相关实现,对应在 JVM 中的零拷贝的实现,就是 JVM 所管理的直接内存,基于直接内存实现了在 JVM 的服务线程与服务器内存中的零拷贝

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_42583242/article/details/108632800