大家好,我是飓风。
前两篇文章我们认识了redis 今天我们来谈谈数据结构。redis 快的原因是什么?使用时应注意什么?
网上的说法,大多数都是redis 是基于内存的单线程io多路复用,但只说表面,比如为什么单线程快,redis 单线程都干了什么?什么是io 多路复用,redis 怎么和io多路复用组合等都没有说清楚。
为何采用单线程实现?
若采用多线程,则必然会有多线程上线文的费用。
若采用多线程,则对同一线程key 操作中必然会出现线程安全问题。什么是线程安全?当有多个端或线程来竞争公共资源时,就会出现线程安全问题。要解决这个问题,必然会加锁。如果加锁,会带来锁竞争的消耗,增加redis 的负杂性。
如果你做过性能测试,你应该知道,随着线程数量的增加,你应用的吞吐量会增加,但随着线程的增加,你应用的吞吐量会持平,慢慢下降。redis 这也是因为考虑到这一点,如果你能配置线程参数,性能可能会下降。
根据官网的说法,也就是说redis 基于内存操作,速度非常快,cpu 不是它的瓶颈。
所以redis 不采用多线程,一是会增加锁的竞争 线程切换消耗也会增加redis 的复杂性。
redis 采用io多路复用模型
在介绍io 多路复用前 我们先看下scoket 模型 哪些模型?
基本的阻塞 socket
如果客户端和服务器能够在网络中通信,则必须使用它 Socket 编程是进程间通信中一种特殊的方式,特别是可以跨主机间通信。 建 Socket 指定网络层使用时可以使用 IPv4 还是 IPv6.使用传输层 TCP 还是 UDP。 首先调用服务端 socket()
创建网络协议的函数 IPv以及传输协议 TCP 的 Socket ,接着调用 bind()
函数,给这个 Socket 绑定一个 IP 地址和端口。
绑定端口的目的:当内核收到时 TCP 报文,通过 TCP 找到我们的应用程序,然后将数据传递给我们。 绑定 IP 地址目的:一台机器可以有多张网卡,每张网卡都有相应的 IP 地址,绑定网卡时,核心会在收到网卡上的包后发给我们。
绑定完 IP 地址和端口可以调用 listen()
监控函数,此时对应 TCP 状态图中的 listen。
服务端进入监控状态后,通过调用 accept()
从核心获取客户端连接的函数,如果没有客户端连接,就会客户端连接的到来。
客户端是如何发起连接的?客户端正在创建中 Socket 后,调用 connect()
函数启动连接,函数参数应指示服务端 IP 地址和端口号。
在 TCP 在连接过程中,服务器的核心实际上是每个 Socket 维护两个队列:
-
一个是尚未完全建立连接的队列,称为 TCP 半连接队列,这个队列没有完成三次握手连接,此时服务端在 syn_rcvd 的状态;
-
一个是建立连接的队列,称为 TCP 全连接队列,这个队列完成了三次握手连接,此时服务端在 established 状态;
当 TCP 全连接队列不空后,服务端 accept()
函数将来自内核 TCP 在全连接队列中拿出一个已完成连接的 Socket 这是返回应用程序的后续数据传输 Socket。
- 一个叫监听 Socket;
- 一个叫已连接 Socket;
这个模型是最基本的io模型是一种同步阻塞的方式,每次只能处理一个客户端io请求,当读写堵塞时,其他客户端无法与服务端连接。
accept() 当没有客户端请求链接时,它将被阻塞。 read() 当没有可读数据时,它会被阻塞。 write() 当没有写作时,它会被阻塞。
前面提到的最基本的socket 模型,属于一对一通信,所以你的应用并发量可以想象,同时只能为客户服务,机器资源将是非常浪费的,所以我需要改进我们socket 支持更多客户端连接和读写操作的模型。 这个问题可以通过多个过程来解决,每个客户端的链接都可以分配一个进行读写操作。
一般流程如下:
- 服务主流程监控客户端连接,与客户端连接成功后,
accept()
函数将返回已连接的函数 socket - 主进程会fork() 一个子过程将复制主过程的文件描述符、内存地址空间、程序计数器、执行代码等。
- 已连接的子过程直接用于子过程socket与客户端通信。
看下图,基本流程:
采用多过程实现多客户端连接的服务,当客户数量很高时,您的过程数量会很高,然后系统负担会增加,会占据系统的大部分资源,此外,上午和下午的过程切换非常重,包括虚拟内存、堆栈、全球变量等用户资源,也包括核心堆栈、寄存器等核心资源。因此,这种方法并不是大并发量的最佳解决方案。
基于多线程实现,多线程可以理解为属于某一过程,同一过程中的线程可以共享部分过程资源,如文件描述列表、过程空间、代码、全球数据、堆、共享库等,这些共享资源不需要切换,只需要切换私人数据、寄存器等不共享数据,因此同一过程下的线程切换成本远小于过程。
此外,频繁的线程创建和破坏消耗资源,成本很小,所以我应该使用线程池来实现线程的重用,以避免线程的创建和破坏。
基本流程见下图:
事实上,使用线程处理连接的读写请求与过程处理连接的读写请求相同,这将给系统带来巨大的资源消耗。如果实现了c10k,那么服务端需要维护1万线程,那么此时的操作系统会是什么样子呢?
以上两种方法,无论是过程之间的切换,还是线程之间的切换,创建过程的数量和线程的数量,都会给系统带来负担,所以你可以用一个过程或线程来监控多个过程吗?socket呢? 所以我们今天的主角 就卡咔咔咔 亮相。
那我们怎样才能通俗地理解这个东西呢?
首先要看的是名字 .
重要的事情再说一遍: I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O同时管理多个流)的状态I/O流. 其原因是尽可能提高服务器的吞吐量。
。
来来来, 看下图:
每个人都应该听说过select/poll/epoll ,其实他们三个都是 以下是三剑客多路复用的来源和原理。
select 实现多路复用的方法是将连接起来 Socket 文件描述符合集合,然后调用 select 函数将文件描述符合复制到核心,让核心检查是否有网络事件,检查方法非常粗糙,是通过遍历文件描述符合集合,当检查事件发生时, Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后处理。
所以,对于 select 需要这样做 2 次「遍历」文件描述符合集合,一次在内核状态,一次在用户状态 ,而且还会发生 2 次「拷贝」文件描述符合集合,先从用户空间传输到内核空间,由内核修改后再传输到用户空间。
select 使用固定长度 BitsMap,文件描述符合要求,支持的文件描述符的数量有限 Linux 在系统中,由内核中 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 文件描述符。
poll 不再用 BitsMap 存储关注的文件描述符,动态数组,以链表的形式组织,突破 select 当然,文件描述符的数量也会受到系统文件描述符的限制。
epoll 通过两个方面,很好解决了 select/poll 的问题。
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
redis利用epoll来实现IO多路复用,内核可以同时存在多个监听socket套接字和已经连接的套接字,内核会一直监听这些socket,只要有socket事件发生,就会交给redis 线程来处理,这样可以看出,一个redis线程就能处理多个io流了。
下图中redis 会利用epoll,让内核帮助它来监听socket,此时redis 并不会阻塞在某一个特定的监听或者已经连接的socket上,如果有事件过来,epoll_wait()
就会返回事件就绪的FD给redis主线程,redis主线程就会将连接信息和事件放到队列中,然后主线程会让事件分派器消费队列.大致流程看下图:
图中FD 是指 有事件就绪的socket。
连接处理器会处理 accept 事件。 命令请求处理器处理 命令请求,也就是可读的事件。 命令回复处理器 处理 命令回复 ,也就是写事件。
IO多路的单线程模型:
- redis启动时,向epoll注册还未使用的FD的可连接事件。
- 连接事件产生时,epoll机制自动将其放入自己的可连接事件队列中。
- redis线程调用epoll的wait,获取所有事件,拷贝存入自己用户线程的内存队列中。
- 遍历这些内存队列,通过事件分派器,交由对应事件处理器处理。如是可连接事件则交由对应的连接应答事件处理器处理。可读事件交给命令请求处理器,可写事件交给命令回复处理器处理。
- 对应处理器执行相应逻辑。执行完成时,再次向epoll注册对应事件,比如连接事件的那个FD,接下来要注册可读事件,并重新注册可连接事件。命令请求处理器执行完后,要将FD注册可写事件。命令回复处理器执行完后,需要将FD取消注册可写事件。
Redis 底层数据结构
- redis 哈希表,在查找key 的时候,能够做o(1) 的复杂度。
- 对于有序的集合,采用跳表来实现,复杂度为 o(logN)。
- 对于压缩列表 和整数数组 可以对短数据进行压缩,节省了内存空间。
- 对于压缩列表和双端链表,可以实现队列, pop 和 push 操作 的时间复杂度为 o(1)。
- bitmap,对于只有两种值的判断来说,既能节能内存,也能做到低延迟。
- 集合类中元素的个数,redis 已经算好,例如LLEN ,SCARD 复杂度都是o(1)。
redis 速度慢了
- 尽量使用自己单个操作的时间很短。
- 合理使用redis 的数据结构,每种结构都有自己的应用场景。
- 减少对集合的全部查询,如HMGET HGETALL SMEMBBERS LRANGE 等 范围的查找。
- 不要使用keys* 之类的操作。
- 不要存储bigkey,bigkey 再分配空间(阻塞redis主线程)和删除释放空间(可以设置为异步)、带宽都会受到影响。
总结
今天学习了redis 快的原因,节下来我们来总结下。
Redis是纯内存数据库,相对于读写磁盘,读写内存的速度就不是几倍几十倍了,一般,hash查找可以达到每秒百万次的数量级。
Redis采用了单线程的模型(io解析和读写操作由一个线程来完成),保证了每个操作的原子性,也减少了线程的上下文切换、公共资源锁的开销与竞争。
redis 采用IO多路复用 ,“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。可以直接理解为:单线程的原子操作,避免上下文切换的时间和性能消耗;加上对内存中数据的处理速度,很自然的提高redis的吞吐量。
Redis全程使用hash结构,读取速度快,还有一些特殊的数据结构,对数据存储进行了优化,如压缩表,对短数据进行压缩存储,再如,跳表,使用有序的数据结构加快读取的速度。
今天的分享就到这里了,码字画图不易,期待你的,谢谢。
欢迎关注 github
微信添加: zookeeper0