Redis这个NOSQL数据库在计算机界可谓是无人不知,无人不晓。只要涉及到数据那么就需要数据库,数据库类型很多,但是NOSQL的kv内存数据库也很多,redis作为其中一个是怎么做到行业天花板的呢?是怎么做到高性能的呢?怎么做到高可用的呢?今天这篇八股文我就整理一些redis的设计写写,本篇还是偏关于高性能这一块。
高效数据结构
Redis的数据库相比传统的关系数据库,在数据结构上也是比较特殊的,它的所有数据类型都可以看做是一个map的结构,key作为查询条件。
基本数据结构Redis基于KV内存数据库,它内部构建了一个哈希表,根据指定的KEY访问时,只需要O(1)的时间复杂度就可以找到对应的数据,而value的值又是一些拥有各种特性的数据结构,这就给redis在数据操作的时候提供很好的性能了。
基于内存存储
相比传统的关系数据库,数据文件可能以lsm tree 或者 b+ tree形式存在硬盘上,这个时候读取文件要有io操作了,而redis在内存中进行,并不会大量消耗CPU资源,所以速度极快。
存储金字塔内存从上图可以看到它介于硬盘和cpu缓存中间的,相比硬盘查找数据肯定是快的,当然这里笔者个人见解上,如果关系型数据库把一些平凡操作的数据库也放置在内存中缓存,也会得到一些性能的提升,像操作系统里面缺页异常一样处理,把数据片段通过一些特殊算法缓存在内存里面,减少文件io的开销。
io多路复用
传统对于并发情况,假如一个进程不行,那搞多个进程不就可以同时处理多个客户端连接了么?多进程是可以解决一些并发问题,但是还是有一些问题,上下文切换开销,线程循环创建,从PCB来回恢复效率较低。随着客户端请求增多,那么线程也随着请求数量直线上升,如果是并发的时候涉及到数据共享访问,有时候涉及到使用锁来控制范围顺序,影响其他线程执行效率。(进程在Linux也可以理解为线程,每个进程只是有一个线程,当然这里我上面写的进程,别纠结这些。。。)
线程是运行在进程上下文的逻辑流,一个进程可以包含多个线程,多个线程运行在同一进程上下文中,因此可共享这个进程地址空间的所有内容,解决了进程与进程之间通信难的问题,同时,由于一个线程的上下文要比一个进程的上下文小得多,所以线程的上下文切换,要比进程的上下文切换效率高得多。
像redis和Nginx这种应用就是单线程的程序,为什么他们能做到这么强的性能?首先看一个例子:
- Blocking IO
中午吃饭,我给餐厅老板说要一碗‘热干面’,然后我就在那边一直等着老板做,老板没有做好,我就一直在哪里等着什么也不做,直到‘热干面’做好。
这个流程就是我们常说的Blocking I/O如图:
blocking io同步阻塞 IO模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
- Non Blocking IO
切换一下常见:
非阻塞io同样你中午吃饭,给餐厅老板说要一碗‘热干面’,然后老板开始做了,你每隔几分钟向老板问一下‘好了吗?’,直到老板说好了,你取到‘热干面’结束。
同步非阻塞 IO模型中,应用程序会一直发起read调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间,通过轮询操作,避免了一直阻塞,取回热干面的过程就是内核把准备好的数据交换到用户空间过程。
综上两种模型,缺点都是差不多,都是在等待内核准备数据,然后阻塞等待,同样逃不开阻塞这个问题,应用程序不断进行I/O系统调用轮询数据是否已经准备好的过程是十分消耗CPU资源的。
- I/O Multiplexing
还是之前那个例子:
多路复用中午吃饭,给餐厅老板说要一碗‘热干面’,然后老板安排给下面的厨子做,具体哪个厨子做不知道,有好几个厨子,然后老板每隔一段时间询问下面的厨子有木有做好,如果做好了,就通知我来去取餐。
IO多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read调用,read 调用的过程(数据从内核空间->用户空间)还是阻塞的。
Reactor模式Reactor通过 I/O复用程序监控客户端请求事件,收到事件后通过任务分派器进行分发。针对建立连接请求事件,通过 Acceptor 处理,并建立对应的handler 负责后续业务处理,针对非连接事件,Reactor会调用对应的handler 完成 read->业务处理->write 处理流程,并将结果返回给客户端,整个过程都在一个线程里完成。
redis里面模型Redis 是基于 Reactor 单线程模式来实现的,IO多路复用程序接收到用户的请求后,全部推送到一个队列里,交给文件分派器。对于后续的操作,和在 reactor 单线程实现方案里看到的一样,整个过程都在一个线程里完成,因此 Redis 被称为是单线程的操作。
我们平时说的Redis单线程快是指它的请求处理过程非常地快!在单线程中监听多个Socket的请求,在任意一个Socket可读/可写时,Redis去读取客户端请求,在内存中操作对应的数据,然后再写回到Socket中。
单线程的好处:
- 没有了访问共享资源加锁的性能损耗
- 开发和调试非常友好,可维护性高
- 没有多个线程上下文切换带来的额外开销,不是没有,是减少了
单线程不是没有缺点,其实缺点也是很明显的,如果前一个请求发生耗时比较久的操作,那么整个Redis就会阻塞住,其他请求也无法进来,直到这个耗时久的操作处理完成并返回,其他请求才能被处理到,但是redis使用的Reactor 单线程模式来实现的可以缓解这种情况。
在Redis 4.0之后的版本,引入多线程,而这个多线程是只的异步释放内存,它主要是为了解决在释放大内存数据导致整个redis阻塞的性能问题,单机redis如果处理大数据请求时还是会出现瓶颈,但是redis有集群高可用解决方案可以解决,主节点只负责写,从节点负责读,io复用先写到这里,集群高可用我会另外在出一篇文章。
写时拷贝
有了高效的数据结构和io多路模型,目前能解决数据访问效率问题,但是redis为了保证了数据不丢失有快照机制,说到快照那么会操作磁盘,redis怎么解决的在数据操作的时候并且还能保证数据记录完整性的?不影响数据访问效率的呢?
答案是用了写时复制技术,什么是写时复制?如果你是一个科班的或者你的操作系统学的不错的话,这个问题很清楚。
在操作系统设计中进程的内存可分为 虚拟内存 和 物理内存,什么是虚拟内存?你可以去看我上一篇文章Virtual Memory。redis会从主进程中通过fork()系统调用,创建一个子进程,将父进程的 虚拟内存 与 物理内存 映射关系复制到子进程中,并将设置内存共享的,子进程只负责将内存里面数据写入到rdb进行持久化操作,如果在操作的时候主进程对内存修改了,使用写时拷贝技术,将对应的内存创建一个副本然后进行写入持久化。
示意图如上图主进程则提供服务,只有当有人修改当前内存数据时,才去复制被修改的内存页,用于生成快照。
管道通讯
除了本地服务器内存和数据结构的操作影响客户端读写效率的还有网络原因。redis的通讯协议是用一种文件协议,有兴趣自己去研究研究吧,我这里不打算写。每次客户端操作的时候,命令和元数据都被打包成redis协议进行传输到服务器上。
按照这样那每个命令的执行时间:客户端发送时间 + 服务器处理和返回时间 + 一个网络来回的时间。
数据包来回从上图可以看出来如果每操作一条命令,那么就要执行一次网络io,如果客户端频繁操作数据那么就频繁网络操作,这个过程也是非常耗时的,影响性能的。redis在客户端程序中做了一些优化引入了一个管道(pipelining)概念。
管道会把多条无关命令批量执行,以减少多个命令分别执行带来的网络交互时间,在一些批量操作数据的场景。
小结
简单始于复杂!,别看客户端就几个简单api call的事情,这后面还有很多设计值得去学习,看完这篇八股文你或许对redis高性能有新的认识了,不要小看某些细节优化和解决方案选型,有时候可以带来明显性能提升。当然这篇文章没有把redis设计写完,例如还有aof的内核文件描述符映射,异步写数据到硬盘上,零拷贝技术等等。。。。后续文章将会更新redis高可用是怎么做到的?
网友评论