3.高性能Redis:Why?

438 阅读8分钟

Redis是单线程的?抛去Redis6.0版本就是单线程了?

单线程指的是Redis的网络IO和键值对的读写是由一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。BUT
Redis的持久化,异步删除,集群数据同步等,都是由额外的线程执行的.严格意义上来说,Redis并不是单线程的,是由于 Redis 的单线程设计机制以及多路复用机制。

Redis 为什么用单线程?

1. 多线程的开销?

"使用多线程,可以增加系统吞吐率,或是可以增加系统扩展性" 这是真的吗 ,这个是对的,在合理的控制资源的分配情况下,可以增加系统中处理请求操作的资源 实体,进而提升系统能够同时处理的请求数,吞吐量等 线程数与系统吞吐率.jpg 我们共享一个数据结构,当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就必须有额外的机制进行保证,而这个额外的机制,会带来很多额外的开销
例如 : Redis 有 List 的数据类型,并提供出队(LPOP)和入队(LPUSH)操作。假设 Redis 采用多线程设计, 现在有两个线程 A 和 B,线程 A 对一个 List 做 LPUSH 操作,并对队列长度加 1。同时,线程 B 对该 List 执行 LPOP 操作, 并对队列长度减 1。为了保证队列长度的正确性,Redis 需要让线程 A 和 B 的 LPUSH 和 LPOP 串行执行,这样一来,Redis 可以无误地记录它们对 List 长度的修改。否则,我们可能就会得到错误的长度结果。这就是多线程编程模式面临的共享资源的并发访问控制问题. 多线程并发访问Redis.jpg

2. 粗粒度互斥锁

比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加
而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。

单线程 Redis 为什么那么快?

通常来说,单线程的处理能力要比多线程差很多,但是 Redis 却能使用单线程模型达到每秒数十万级别的处理能力
一方面:Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构
例如: 例如哈希表和跳表,这是它实现高性能的一个重要原因
二方面:Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率, Redis 采用单线程进行 IO,如果线程被阻塞了,就无法进行多路复用了。

基本 IO 模型与阻塞点

阻塞模式

KV 为了处理一个 Get 请求,
需要监听客户端请求(bind/listen),
和客户端建立连接(accept),
从 socket 中读取请求(recv),
解析客户端发送请求(parse),
根据请求类型读取键值数据(get),
最后给客户端返回结果,即向 socket 中写回数据(send).
Redis基本IO模型.jpg 在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()
当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时, 会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。 类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。

非阻塞模式

Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上

调用方法 返回套接字类型 非阻塞模式 效果
socket() 主动套接字 ......
listen() 监听套接字 可设置 accept()非阻塞
accept() 已连接套接字 可设置 send()/recv()非阻塞
监听套接字,可以设置非阻塞模式:
当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,

调用 accept() 时,已经存在监听套接字了。调用 accept() 时,已经存在监听套接(listen())字了。

针对已连接套接字设置非阻塞模式:
Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。

基于多路复用的高性能 I/O 模型

Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。在 Redis 只运行单线程的情况下, 该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。 一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

下图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。Redis 线程不会阻塞在某一个特定的监听或已连接套接字上, 也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。图示 :

基于多路复用的Redis高性能IO模型.jpg 为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。

这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。 同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。

以连接请求(Accept事件)和读数据请求(Read事件)为例

针对这两个 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和 get 回调函数。当 Linux 内核监听到有连接请求或读数据请求时,
就会触发 Accept 事件和 Read 事件,此时,内核就会回调 Redis 相应的 accept 和 get 函数进行处理。

Redis单线程处理IO请求性能瓶颈主要包括2个方面:

  问题1、任意一个请求在server中一旦发生耗时,都会影响整个server的性能,也就是说后面的请求都要等前面这个耗时请求处理完成,自己才能被处理到。耗时的操作包括以下几种:
  a、操作bigkey:写入一个bigkey在分配内存时需要消耗更多的时间,同样,删除bigkey释放内存同样会产生耗时;
  b、使用复杂度过高的命令:例如SORT/SUNION/ZUNIONSTORE,或者O(N)命令,但是N很大,例如lrange key 0 -1一次查询全量数据;
  c、大量key集中过期:Redis的过期机制也是在主线程中执行的,大量key集中过期会导致处理一个请求时,耗时都在删除过期key,耗时变长;
  d、淘汰策略:淘汰策略也是在主线程执行的,当内存超过Redis内存上限后,每次写入都需要淘汰一些key,也会造成耗时变长;
  e、AOF刷盘开启always机制:每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢Redis的性能;
  f、主从全量同步生成RDB:虽然采用fork子进程生成数据快照,但fork这一瞬间也是会阻塞整个线程的,实例越大,阻塞时间越久;
  问题2、并发量非常大时,单线程读写客户端IO数据存在性能瓶颈,虽然采用IO多路复用机制,但是读写客户端数据依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核。
  
  针对问题1,一方面需要业务人员去规避,一方面Redis在4.0推出了lazy-free机制,把bigkey释放内存的耗时操作放在了异步线程中执行,降低对主线程的影响。
  
  针对问题2,Redis在6.0推出了多线程,可以在高并发场景下利用CPU多核多线程读写客户端数据,进一步提升server性能,当然,只是针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的。