单线程的Redis为什么高并发场景下还是很快

缓存在高并发的场景的作用不言而喻,号称高并发架构的基石,其中最为典型代表非Redis莫属。无论你是想面试通关,还是实战中用好Redis,理解Redis的设计精髓,就变得很重要了。今天主要分享Redis关于单线程以及高并发场景的核心设计。

一、Redis到底有多快?

Redis采用的是基于内存的采用的是单进程单线程模型的 KV 数据库由C语言编写,官方提供的数据是可以达到100000+的QPS(每秒内查询次数)。这个数据不比采用单进程多线程的同样基于内存的 KV 数据库 Memcached 差!有兴趣的可以参考官方的基准程序测试《How fast is Redis?》(https://redis.io/topics/benchmarks),下图是Redi官方给出的一个在不同连接数下Redis吞吐量的曲线,横纵表示连接数,纵轴表示是吞吐量(q/s):

这张图反映了一个数量级,希望大家在面试的时候可以正确的描述出来,不要问你的时候,你回答的数量级相差甚远!几个关键的数量级:最大QPS:100万+,30_0000连接:60_0000QPS,60_0000连接:50_0000万QPS

一、Redis的高并发和快速原因

(1)redis是基于内存的非关系型数据库,内存的读取速度非常快;

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

(3)最重要的原因是redis使用了IO多路复用模型,非阻塞IO,可以处理并发的连接。

下面我们重点来了解一下Redis设计者把Redis设计成单线程以及IO多路复用技术的原理。

二、为什么Redis要采用单线程的模式

1、官方的解释

官方FAQ表示,因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了(毕竟采用多线程会有很多麻烦!)。可以参考:https://redis.io/topics/faq

并且在截图中我们也可以看到,Redis官方给我们建议:为了最大程度地利用CPU,您可以在同一框中启动多个Redis实例,并将它们视为不同的服务器。 在某个时候,单个Redis可能还不够,因此,如果您要使用多个CPU,则可以开始考虑更早地分片的某种方法。

因此,Redis采用线程的原因就是因为Redis目前在爱单线程下性能已经很高了,也就没必要着急着使用多线程了。不过在截图的最后官方表示在Redis未来的版本中会逐渐的使用上多线程,这一目标在Redis 6版本中首次被实现了,感兴趣的小伙伴可以去了解一下。

注意!

这里我们一直在强调的单线程,只是在处理我们的网络请求的时候只有一个线程来处理,一个正式的Redis服务运行的时候肯定是不止一个线程的,这里需要大家明确的注意一下!例如Redis进行持久化的时候会以子进程或者子线程的方式执行….

2、我的理解

1)不需要各种锁的性能消耗

Redis的数据结构并不全是简单的Key-Value,还有list,hash等复杂的结构,这些结构有可能会进行很细粒度的操作,比如在很长的列表后面添加一个元素,在hash当中添加或者删除

一个对象。这些操作可能就需要加非常多的锁,导致的结果是同步开销大大增加。

总之,在单线程的情况下,就不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。

2)单线程多进程集群方案

单线程的威力实际上非常强大,每核心效率也非常高,多线程自然是可以比单线程有更高的性能上限,但是在今天的计算环境中,即使是单机多线程的上限也往往不能满足需要了,需要进一步摸索的是多服务器集群化的方案,这些方案中多线程的技术照样是用不上的。

所以单线程、多进程的集群不失为一个时髦的解决方案。

3)CPU消耗

采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU。

但是如果CPU成为Redis瓶颈,或者不想让服务器其他CUP核闲置,那怎么办?

可以考虑多起几个Redis进程,Redis是key-value数据库,不是关系数据库,数据之间没有约束。只要客户端分清哪些key放在哪个Redis进程上就可以了。

三、IO多路复用技术

Redis 采用网络IO多路复用技术来保证在多连接的时候, 系统的高吞吐量。

多路指的是多个socket连接,复用指的是复用一个线程。意思就是使用一个线程处理多个socket连接。Linux系统中IO多路复用提供了三个系统调用:select、poll、epoll。epoll是目前最先进的多路复用技术。select/poll/epoll 核心是可以同时处理多个connection,而不是更快,所以连接数不高的话,性能不一定比多线程+阻塞IO好,多路复用模型中,每一个socket,设置为non-blocking,阻塞是被select这个函数block,而不是被socket阻塞的。

1、select机制

函数定义如下:

/* According to POSIX.1-2001, POSIX.1-2008 */
    #include <sys/select.h>

    /* According to earlier standards */
    #include <sys/time.h>
    #include <sys/types.h>
    #include <unistd.h>

    int select(int nfds, fd_set *readfds, fd_set *writefds,
                fd_set *exceptfds, struct timeval *timeout);

    int pselect(int nfds, fd_set *readfds, fd_set *writefds,
                fd_set *exceptfds, const struct timespec *timeout,
                const sigset_t *sigmask);

    void FD_CLR(int fd, fd_set *set);
    int  FD_ISSET(int fd, fd_set *set);
    void FD_SET(int fd, fd_set *set);
    void FD_ZERO(fd_set *set);

(1)基本原理

select调用过程示意图:

客户端在操作服务器的时候会产生三种文件描述符fd:writefds(写)、readfds(读)、和exceptfds(异常)。select会阻塞并分别监视这3类文件描述符,等有数据可读、可写、出异常或超时的时候就会返回。返回后通过遍历fd_set整个数组来找到就绪的描述符fd,然后进行对应的IO操作。

(2)优点

支持几乎所有的平台,跨平台性好。

(3)缺点

  • 由于是采用轮询方式全盘扫描,会随着文件描述符FD数量增多而性能下降。
  • 每次调用 select(),需要把 fd 集合从用户态拷贝到内核态,并进行遍历
  • 默认单个进程打开的FD有限制是1024个,可修改宏定义,但是效率仍然慢
2、poll机制
    #include <poll.h>

    int poll(struct pollfd *fds, nfds_t nfds, int timeout);

    #include <signal.h>
    #include <poll.h>

    int ppoll(struct pollfd *fds, nfds_t nfds,
            const struct timespec *tmo_p, const sigset_t *sigmask);

    struct pollfd {
        int fd; /* file descriptor */
        short events; /* requested events to watch */
        short revents; /* returned events witnessed */
    };

和select用三组文件描述符不同的是,poll只有一个pollfd数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。events参数是我们需要关心的事件,revents是所有内核监测到的事件。

(1)基本原理

基本原理与select一致,也是轮询+遍历;唯一的区别就是poll采用链表的方式替换select中的fd_set(数组)数据结构,而使其没有连接数的限制

3、epoll机制

函数定义如下:

    #include <sys/epoll.h>

    int epoll_create(int size);
    int epoll_create1(int flags);

    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

    int epoll_wait(int epfd, struct epoll_event *events,
                int maxevents, int timeout);
    int epoll_pwait(int epfd, struct epoll_event *events,
                int maxevents, int timeout,
                const sigset_t *sigmask);

epoll_create&epoll_create1用于创建一个epoll实例,而epoll_ctl用于往epoll实例中增删改要监测的文件描述符,epoll_wait则用于阻塞的等待可以执行IO操作的文件描述符直到超时。

(1)基本原理

epoll调用过程示意图:

epoll也没有FD个数限制,用户态到内核态拷贝只需要一次,使用时间通知机制来触发。通过epoll_ctl()方法注册FD,一旦FD就绪就会通过回调机制来激活对应的FD,进行相关的IO操作。

  • epoll_create()系统启动时,在Linux内核里面申请一个B+树结构文件系统,返回epoll对象,也是一个fd
  • epoll_ctl() 每新建一个连接,都通过该函数操作epoll对象,在这个对象里面修改添加删除对应的链接fd, 绑定一个callback函数
  • epoll_wait() 轮训所有的callback集合,并完成对应的IO操作

(2)优点

  • 没fd这个限制,所支持的FD上限是操作系统的最大文件句柄数,1G内存大概支持10万个句柄
  • 效率提高,使用回调通知而不是轮询的方式,性能不会随着FD数目的增加效率下降
  • 内核和用户空间mmap同一块内存实现(mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间)
例子:100万个连接,里面有1万个连接是活跃,我们可以对比 select、poll、epoll 的性能表现

  select:不修改宏定义默认是1024,则需要100w/1024=977个进程才可以支持 100万连接,会使得CPU性能特别的差。
  poll: 没有最大文件描述符限制,100万个链接则需要100w个fd,遍历都响应不过来了,还有空间的拷贝消耗大量的资源。
  epoll: 请求进来时就创建fd并绑定一个callback,只需要遍历1w个活跃连接的callback即可,即高效又不用内存拷贝。

参考资料

留言区

还能输入500个字符