Redis6新特性之多线程IO

一、Redis单线程

在Redis6.0版本之前,Redis可以被认为是单线程服务(除少量的后台定时任务、文件异步操作、惰性删除外),它的处理过程主要包括:

1接收命令。通过TCP或者UDP接收到命令
2解析命令。将命令取出来
3执行命令。到对应的地方将value读出来
4返回结果。将结果返回给客户端

Redis在启动后会产生一个死循环aeMain,在这个循环里通过IO多路复用(linux系统采用epoll方式)等待事件发生。

事件分为IO事件和timer事件,timer事件即开头提到的后台定时任务,如expire key等。IO事件即来自客户端的命令转化的事件,由epoll处理。

epoll多路复用的处理结构如下图所示

即多个网络连接复用一个IO线程。由于单个线程可以记录每个socket的状态来同时管理多个IO流,所以能够提高服务的吞吐能力。

 

二、单线程IO的瓶颈

前面简单的介绍了单线程IO的处理过程以及高性能的原因,官方压测的结果是10万QPS,我们现网使用经验在3~4万QPS。

虽然这个性能指标已经挺高了,但是单线程IO模型有几个明显的缺陷:

1、只能使用一个CPU核(忽略后台线程和子线程)

2、如果涉及到大key,Redis的QPS会下降的厉害

3、由于解析和返回的限制,QPS难以进一步提高

针对以上的缺陷,又为了避免多线程执行命令带来的控制key、lua脚本、事务等并发复杂问题,Redis作者选择了多线程IO模型,即执行命令仍采用单线程,解析和返回等步骤采用多线程。

 

三、多线程IO

多线程IO的设计思路大体如下图

 

整体的读流程包括:

1、主线程负责接收建连请求,读事件到来(收到请求)则放到一个全局等待读处理队列
2、主线程处理完读事件之后,通过轮询将这些连接分配给这些 IO 线程,然后主线程忙等待(spinlock 的效果)状态
3、IO 线程将请求数据读取并解析完成(这里只是读数据和解析并不执行)
4、主线程执行所有命令并清空整个请求等待读处理队列(执行部分串行)

我们通过源码来确认上面的处理流程。主线程收到请求时会回调network.c里面的readQueryFromClient 函数: 
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    /* Check if we want to read from the client later when exiting from
     * the event loop. This is the case if threaded I/O is enabled. */
    if (postponeClientRead(c)) return;
    ...
}

readQueryFromClient 之前的实现是负责读取和解析请求并执行命令,加入多线程 IO 之后加入了postponeClientRead 函数,它的实现逻辑如下:

int postponeClientRead(client *c) {
    if (io_threads_active &&   // 多线程IO是否在开启状态,在待处理请求较少时会停止IO多线程
        server.io_threads_do_reads && // 读是否开启多线程 IO
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))  // 主从库复制请求不使用多线程 IO
    {
        // 连接标识为 CLIENT_PENDING_READ 来控制不会反复被加队列,
        // 这个标识作用在后面会再次提到
        c->flags |= CLIENT_PENDING_READ;
        // 连接加入到等待读处理队列
        listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {
        return 0;
    }
}
postponeClientRead 判断如果开启多线程 IO 且不是主从复制连接的话就放到队列然后返回 1,在 readQueryFromClient 函数会直接返回不进行命令解析和执行。
接着主线程在处理完读事件之后将这些连接通过轮询的方式分配给这些 IO 线程:
int handleClientsWithPendingReadsUsingThreads(void) {
  ...
    // 将等待处理队列的连接按照 RR 的方式分配给多个 IO 线程
    listRewind(server.clients_pending_read,&li);
    int item_id = 0;
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }
    ...

    // 一直忙等待直到所有的连接请求都被 IO 线程处理完
    while(1) {
        unsigned long pending = 0;
        for (int j = 0; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }

代码里面的 io_threads_list 用来存储每个 IO 线程对应需要处理的连接,然后主线程将这些连接分配给这些 IO 线程后进入忙等待状态(相当于主线程 blocking 住)。

IO 处理线程入口是 IOThreadMain 函数:

void *IOThreadMain(void *myid) {
  while(1) {
        // 遍历线程 id 获取线程对应的待处理连接列表
        listRewind(io_threads_list[id],&li);
        while((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            // 通过 io_threads_op 控制线程要处理的是读还是写请求
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                writeToClient(c->fd,c,0);
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                readQueryFromClient(NULL,c->fd,c,0);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        io_threads_pending[id] = 0;
  }
}

IO 线程处理根据全局 io_threads_op 状态来控制当前 IO 线程应该处理读还是写事件,这也是上面提到的全部 IO 线程同一时刻只会执行读或者写。另外,已经加到等待处理队列的连接会被设置 CLIENT_PENDING_READ 标识。postponeClientRead 函数不会把连接再次加到队列,readQueryFromClient 会继续执行读取和解析请求。readQueryFromClient 函数读取请求数据并调用 processInputBuffer 函数进行解析命令,processInputBuffer 会判断当前连接是否来自 IO 线程,如果是的话就只解析不执行命令。IOThreadMain 线程是没有任何 sleep 机制,在空闲状态也会导致每个线程的 CPU 跑到 100%,但简单 sleep 则会导致读写处理不及时而导致性能更差。Redis 当前的解决方式是通过在等待处理连接比较少的时候关闭这些 IO 线程来避免长期CPU跑满。

 

四、性能对比

本次性能压测采用了控制单变量法多次测试,变量包括线程数、request请求数、客户端连接数。一共测试了set、get、incr、hset4种命令。每组参数测试5次取平均值为最终测试结果。详细测试数据见附件Excel

由于压测结果显示QPS与客户端连接数关系不大。故作图数据采用get命令,50个客户端为例,绘制了QPS与线程数及请求数的关系。

结论:

1、随着IO线程数增多,每个IO线程可支持约4.5万QPS,呈线性关系,4IO线程时,可达到20万QPS。但是从4线程IO之后,线程数对QPS的提升效果逐渐降低。

2、随着IO线程数增多,QPS随着request的增加有一定的增加,但是差距不明显。

 

五、建议

由于Redis6支持绑定IO线程、持久化子线程及后台线程,对于QPS高的业务线,可通过Redis配置文件绑定多核开启多线程IO提高CPU利用率来提高QPS。

相关配置参数如下

io-threads 4
# Setting io-threads to 1 will just use the main thread as usually.
# When I/O threads are enabled, we only use threads for writes, that is
# to thread the write(2) syscall and transfer the client buffers to the
# socket. However it is also possible to enable threading of reads and
# protocol parsing using the following configuration directive, by setting
# it to yes:
 
io-threads-do-reads yes
# Jemalloc background thread for purging will be enabled by default
jemalloc-bg-thread yes

# It is possible to pin different threads and processes of Redis to specific
# CPUs in your system, in order to maximize the performances of the server.
# This is useful both in order to pin different Redis threads in different
# CPUs, but also in order to make sure that multiple Redis instances running
# in the same host will be pinned to different CPUs.
#
# Normally you can do this using the "taskset" command, however it is also
# possible to this via Redis configuration directly, both in Linux and FreeBSD.
#
# You can pin the server/IO threads, bio threads, aof rewrite child process, and
# the bgsave child process. The syntax to specify the cpu list is the same as
# the taskset command:
#
# Set redis server/io threads to cpu affinity 0,2,4,6:
server_cpulist 7-15
#
# Set bio threads to cpu affinity 1,3:
# bio_cpulist 1,3
#
# Set aof rewrite child process to cpu affinity 8,9,10,11:
# aof_rewrite_cpulist 8-11
#
# Set bgsave child process to cpu affinity 1,10,11
# bgsave_cpulist 1,10-11

 

附件:Redis多线程压测.xlsx

posted @ 2020-11-14 23:31  洲渚皓月掩映  阅读(960)  评论(0编辑  收藏  举报