WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

Redis 网络模型

分类:Redis创建时间:2021-12-25 00:00:00

2020 年正式推出的 Redis 6.0 引入了多线程模型,这让 Redis 的性能得到进一步的提高,这还颠覆了不少人对 Redis 的认识。在 Redis 6.0 之前,可以说 Redis 采用单线程网络模型。但现在有必要重新了解 Redis 的网络模型,并改变 Redis 采用单线程网络模型的旧观念。

单线程网络模型

在 6.0 版本之前,Redis 采用单线程 Reactor 模型,有了 I/O 多路复用,Redis 的 I/O 是很高效的。除了持久化相关命令外,Redis 命令只涉及对内存的操作,且大部分命令的时间复杂度都低于 O(N),因此 Redis 处理命令时速度是相当快的。综上两点,Redis 可以做到很高的处理能力,单个进程扛10万左右的 OPS 没太大问题。 关于 Reactor 模型的原理,我就不再讲了。

多线程网络模型

单线程的网络模型很高效,很容易理解,实现起来也相对容易,那么为什么要引入多线程呢?虽然前面描述的单线程网络模型中,Redis 处理 I/O 的效率已经很高了,并不存在 CPU 阻塞在 I/O 上的情况,CPU 始终在读写 I/O 或者执行命令。但 I/O 操作毕竟是要花时间的,当请求量非常大的时候,大量时间会花费在 I/O 读写、命令解析上,单个 CPU 已经忙不过来了。此时引入多线程,可有利用多个 CPU 来执行数据的读写和解析,进一步提高 Redis 处理效率。

多线程 Reactor 模型是一种广泛采用的多线程网络模型,但是 Redis 并没有采用这种模型。Redis 中很多地方依赖于单线程模型,比如事务的处理、SETNX 等。如果使用多线程模型,还需要用锁来对主哈希表做保护,主从同步、持久化等都需要改造。因此采用常规的多线程 Reactor 模型,Redis 中需要改造的点太多了,短期内实现不太现实。为了提供多线程支持且对现有的功能不做大幅度的改变,Redis 采用了一种保守的实现方式。

一个命令的处理包含 4 个步骤:读取、解析、执行、发送响应,其中只有“执行”这一步需要在单个线程中执行,因此 Redis 将读取、解析、发送响应这三个步骤放到多个线程中执行,但”执行“这一步需要在主线程中执行。

Redis 的命令大多执行效率很高,多个线程读取并解析的命令全交给主线程来执行,这不会给主线程造成太大的压力。当然,如果子线程数很多请求量很大,主线程会出现扛不住的情况。

可以使用如下配置开启此特性:

# 子线程数量
io-threads 4

# 子线程是否执行读操作,如果设置为 no,子线程只会执行写操作
io-threads-do-reads yes

单线程网络模型的实现

Redis 单线程网络模型就是 Reactor 模型,这个模型很简单,只需要关注读写回调、处理函数、输入输出缓冲区即可。

客户端创建

在 Redis 启动阶段,会给监听端口注册回调,一旦有可读事件触发,就执行 accept 得到客户端 socket,将此 socket 封装为 connection 对象,并使用此 connection 对象创建一个 client 对象,可以关注下面这几个函数来了解实现细节:

// server.c
if (createSocketAcceptHandler(&server.ipfd, acceptTcpHandler) != C_OK) {
    serverPanic("Unrecoverable error creating TCP socket accept handler.");
}

// connection.c
connection *connCreateAcceptedSocket(int fd);

// networking.c
void acceptCommonHandler(connection *conn, int flags, char *ip);

// networking.c
client *createClient(connection *conn);

命令读取与解析

创建客户端的时候,会注册此客户端对应 socket 的可读事件,回调函数为 readQueryFromClient,可以阅读 createClient 了解详情。

readQueryFromClient 中会读取 socket,并将读入数据存储在 client->querybuf 中。数据读取完成后,会尝试解析命令,解析出完整命令后,会立刻执行命令。可以阅读以下函数来了解实现细节:

// networking.c
void readQueryFromClient(connection *conn);

// networking.c
void processInputBuffer(client *c);

// networking.c
int processCommandAndResetClient(client *c);

命令解析完成后,会将命令存储在 client->argv 中,然后调用 processCommandAndResetClient 来执行命令。

命令执行

processCommandAndResetClient 中,会对命令进行各种过滤,比如验证权限、是否未知命令、是否禁止执行等等。最后会根据命令的类型得到对应命令的处理函数,比如如果是 SET 命令,则会调用 setCommand 来处理。

每个命令都有自己的实现逻辑,当命令处理完成后会生成响应,这些响应会通过 addReply* 系列函数保存在 client->buf 或者 client->reply 中,前者是固定大小的小 buf,后者是内存块组成链表构成的大 buf。

发送响应

事件循环处理完成后,会在新一轮事件循环开始之前,即在 beforeSleep 回调函数中,遍历所有需要发送响应的客户端,并调用 writeToClient 尝试发送响应。如果此客户端对应的 socket 的发送缓冲区已经满了,由于使用的是非阻塞 I/O,writeToClient 调用会失败。如果发送失败,则会注册可写事件的回调函数,回调函数为 sendReplyToClient。通常客户端连接都是可写的,因此新一轮事件循环开始后,此函数就会被调用,保存在发送缓冲区中的数据会被发送给客户端。

可以阅读下面几个函数了解实现细节:

// server.c
void beforeSleep(struct aeEventLoop *eventLoop);

// networking.c
int handleClientsWithPendingWritesUsingThreads(void);

// networking.c
int handleClientsWithPendingWrites(void);

以上就是 Redis 单线程网络模型的实现,采用广为人知的 Reactor 模型,很容易理解。

多线程网络模型的实现

Redis 6.0 引入了多线程网络模型,此模型的主体还是一个单线程的 Reactor 模型,依然是在主线程中处理所有的事件,不过这里事件处理的方式变了。

命令读取与解析

对可读事件,仅仅是将对应的客户端串在 server.clients_pending_read 这个链表上。

// networking.c
void readQueryFromClient(connection *conn);

// networking.c
int postponeClientRead(client *c) {
    // ...
    listAddNodeHead(server.clients_pending_read,c);
}

本轮事件循环处理完成后,需要读取的客户端就全都收集在 server.clients_pending_read 这个链表上了,在 beforeSleep 中调用 handleClientsWithPendingReadsUsingThreads 将此链表上的客户端均匀分配给多个子线程。

将待读取的客户端分配给各子线程,本质上就是将 client 加入 io_threads_list[i] 这个链表中,并设置 io_threads_pending[i] 告知第 i 个线程有多少任务需要执行。在设置 io_threads_pending[i] 之前主线程会设置全局原子变量 io_threads_op = IO_THREADS_OP_READ,告知子线程中需要执行的是读取任务。子线程中不断检查 io_threads_pending[i] 中的值,感知到有任务需要执行,就会根据 io_threads_op = IO_THREADS_OP_READ 决定执行读取操作。

在子线程中会调用 readQueryFromClient 执行读取和解析两个步骤。待子线程都读取解析完成后,主线程中会执行所有命令。

关于命令的读取和解析,可以阅读下面的函数来了解详情:

// networking.c
int handleClientsWithPendingReadsUsingThreads(void);

// networking.c
void *IOThreadMain(void *myid);

命令读取与解析

命令的执行是在子线程读取并解析完成后,实现代码在 handleClientsWithPendingReadsUsingThreads 中。在子线程中执行读取和解析,存在一个明显的问题。子线程中最多只能解析出一个命令,因为命令的信息目前是保存在 client->argv 中的。因此,在主线程中执行完解析出的命令后,还需要再次尝试解析,并执行。所以,如果客户端采用 pipeline 来发送请求,那么子线程中一次性会读取出很多条请求,但只会解析第一条命令,后面的命令还是要依赖主线程来解析。

发送响应

多线程实现中,读取、解析和执行命令是在 beforeSleep 中调用 handleClientsWithPendingReadsUsingThreads 来完成的。在执行命令期间,自然会调用 addReply* 系列函数,将待发送的内容保存在 client 上的发送缓冲区中,同时还会将此客户端挂在 server.clients_pending_write 上。

beforeSleep 中,调用完 handleClientsWithPendingReadsUsingThreads 读取并执行完命令后,会调用 handleClientsWithPendingWritesUsingThreads 来发送响应。做法和前面命令读取与解析差不多,将 server.clients_pending_write 中的客户端分配给子线程,然后设置 io_threads_op = IO_THREADS_OP_WRITE,在子线程中会调用 writeToClient 来发送响应给客户端。子线程处理完成后,如果有客户端还有数据需要发送,则会在主线程中注册回调函数 sendReplyToClient

以上就是 Redis 多线程网络模型的实现原理,整体而言条理还是比较清楚的。在事件循环中,收集可读的客户端,在 beforeSleep 中将可读的客户端分配给子线程,子线程读取解析完成后,主线程中执行命令。执行完成后,将有数据待发送的客户端在分配给子线程们,子线程中执行发送操作。Redis 的这种实现中,没有需要加锁的地方。Redis 使用 io_threads_pending 来做主线程和子线程间的同步,子线程中通过轮询来处理提交的任务。

子线程的实现

下面是子线程的主流程:

void *IOThreadMain(void *myid) {
    /* The ID is the thread number (from 0 to server.iothreads_num-1), and is
     * used by the thread to just manipulate a single sub-array of clients. */
    long id = (unsigned long)myid;
    char thdname[16];

    snprintf(thdname, sizeof(thdname), "io_thd_%ld", id);
    redis_set_thread_title(thdname);
    redisSetCpuAffinity(server.server_cpulist);
    makeThreadKillable();

    while(1) {
        // 轮询,一旦发现有任务提交了,就跳出循环
        for (int j = 0; j < 1000000; j++) {
            if (getIOPendingCount(id) != 0) break;
        }

        /* 如果没有任务可以执行,开启很多子线程,很非常浪费资源,
         * 子线程会不断地执行上面的 for 循环,CPU 消耗很大。在主线程中,
         * 如果发现任务很少,则可以执行 pthread_mutex_lock(&io_threads_mutex[id])
         * 加锁,此时子线程就会阻塞在互斥锁上,这样就会被调度出去,避免了无意义的轮询。
         * 可以阅读 stopThreadedIOIfNeeded 了解详情。*/
        if (getIOPendingCount(id) == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(getIOPendingCount(id) != 0);

        /* Process: note that the main thread will never touch our list
         * before we drop the pending count to 0. */
        // 执行主线程提交的任务,根据 io_threads_op 判断执行读还是写。
        listIter li;
        listNode *ln;
        listRewind(io_threads_list[id],&li);
        while((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                writeToClient(c,0);
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        /* 设置 io_threads_pending[id] 为 0,主线程此时在轮询,当它发现
         * io_threads_pending[id] == 0 就知道该线程已经执行完成。*/
        setIOPendingCount(id, 0);
    }
}

子线程中没有使用条件变量、互斥锁这样的常规的线程间同步的工具,而是使用了轮询的策略,这是因为 Redis 不希望子线程频繁地被调度出去。另外,如果 Redis 发现当前需要处理的任务量很少,就会使用互斥锁将所有子线程阻塞起来,避免无意义地轮询。

不过这 100万 次的轮询,还是会常常发生,比如在主线程执行命令期间,这会导致子线程在某些瞬间将 CPU 打满,这让我感觉不够环保。为了提高 Redis 的处理能力,这些资源就是需要付出的代价。

多线程网络模型的性能

下面使用不同的配置来执行压测,观察启动 I/O 多线程后的性能变化。以下压测使用 Redis 6.2.1 版本,压测机器 48 核,2.20GHz。

单线程

不启动 I/O 线程,压测结果如下:

$ memtier_benchmark -p 11002 -n 10000 --key-maximum=10000 --ratio=1:1

ALL STATS
=========================================================================
Type         Ops/sec     Hits/sec   Misses/sec      Latency       KB/sec 
-------------------------------------------------------------------------
Sets        61016.80          ---          ---      1.64000      4522.31 
Gets        61016.80     24260.28     36756.52      1.63500      3003.09 
Waits           0.00          ---          ---      0.00000          --- 
Totals     122033.60     24260.28     36756.52      1.63700      7525.40

4 个 I/O 线程

配置如下:

io-threads 4
io-threads-do-reads yes

压测结果如下:

$ memtier_benchmark -p 11002 -n 10000 --key-maximum=10000 --ratio=1:1

ALL STATS
=========================================================================
Type         Ops/sec     Hits/sec   Misses/sec      Latency       KB/sec 
-------------------------------------------------------------------------
Sets       130685.11          ---          ---      0.75900      9685.83 
Gets       130685.11     69184.70     61500.41      0.75800      7003.88 
Waits           0.00          ---          ---      0.00000          --- 
Totals     261370.23     69184.70     61500.41      0.75800     16689.71

启动 I/O 多线程后,性能翻了一倍多,效果不错。

压测期间查看 TOP 信息,可以看到其中包含三个子线程 io_thd_*。配置中明明设置的是 io-threads 4,为什么只有三个子线程呢?因为主线程也算一个 I/O 线程。四个线程的 CPU 都达到了 100%。

$ top -H

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND                    
12683 work      20   0  207m 6528 1540 R 100.0  0.0   0:45.86 io_thd_3                   
12675 work      20   0  207m 6528 1540 R 99.7  0.0   0:46.01 redis-server                
12681 work      20   0  207m 6528 1540 R 99.7  0.0   0:45.86 io_thd_1                    
12682 work      20   0  207m 6528 1540 R 99.7  0.0   0:45.86 io_thd_2                    
25879 work      20   0  355m 5944 2380 R 87.2  0.0   0:18.10 memtier_benchma             
25880 work      20   0  355m 5944 2380 R 87.2  0.0   0:18.44 memtier_benchma             
25881 work      20   0  355m 5944 2380 R 85.9  0.0   0:18.18 memtier_benchma             
25882 work      20   0  355m 5944 2380 R 85.6  0.0   0:18.03 memtier_benchma     

8 个 I/O 线程

配置如下:

io-threads 8
io-threads-do-reads yes
$ memtier_benchmark -p 11002 -n 10000 --key-maximum=10000 --ratio=1:1 --threads=8

ALL STATS
=========================================================================
Type         Ops/sec     Hits/sec   Misses/sec      Latency       KB/sec 
-------------------------------------------------------------------------
Sets       173091.55          ---          ---      1.36500     12828.82 
Gets       173091.55     91634.67     81456.88      1.36500      9276.59 
Waits           0.00          ---          ---      0.00000          --- 
Totals     346183.10     91634.67     81456.88      1.36500     22105.41 

启动 8 个 I/O 线程后,各线程 CPU 使用情况如下:

$ top -H

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND                               
43153 work      20   0  322m  10m 1552 R 100.0  0.0   0:10.47 redis-server                          
43159 work      20   0  322m  10m 1552 R 100.0  0.0   0:10.27 io_thd_1                              
43160 work      20   0  322m  10m 1552 R 100.0  0.0   0:10.27 io_thd_2                              
43162 work      20   0  322m  10m 1552 R 100.0  0.0   0:10.27 io_thd_4                              
43165 work      20   0  322m  10m 1552 R 100.0  0.0   0:10.26 io_thd_7                              
43161 work      20   0  322m  10m 1552 R 99.6  0.0   0:10.26 io_thd_3                               
43163 work      20   0  322m  10m 1552 R 99.6  0.0   0:10.26 io_thd_5                               
43164 work      20   0  322m  10m 1552 R 99.6  0.0   0:10.26 io_thd_6                               
46547 work      20   0  652m 5872 2380 R 63.8  0.0   0:02.18 memtier_benchma                        
46545 work      20   0  652m 5872 2380 R 61.8  0.0   0:02.13 memtier_benchma                        
46546 work      20   0  652m 5872 2380 R 61.8  0.0   0:02.13 memtier_benchma                        
46540 work      20   0  652m 5872 2380 R 59.5  0.0   0:02.06 memtier_benchma                        
46542 work      20   0  652m 5872 2380 R 59.5  0.0   0:02.03 memtier_benchma                        
46541 work      20   0  652m 5872 2380 R 58.2  0.0   0:02.02 memtier_benchma                        
46543 work      20   0  652m 5872 2380 R 56.6  0.0   0:01.96 memtier_benchma                        
46544 work      20   0  652m 5872 2380 R 56.6  0.0   0:01.95 memtier_benchma  

启动 8 个 I/O 线程后 CPU 使用率达到了 800%,OPS 达到 34 万。单线程的情况下,CPU 使用率为 100%,OPS 为 12 万。使用了 8 倍的 CPU,换来了近 3 倍的 OPS 提升。

12 个 I/O 线程

配置如下:

io-threads 12
io-threads-do-reads yes

再增加 I/O 线程数量会是什么情况呢?看下图:

$ memtier_benchmark -p 11002 -n 10000 --key-maximum=10000 --ratio=1:1 --threads=8

ALL STATS
=========================================================================
Type         Ops/sec     Hits/sec   Misses/sec      Latency       KB/sec 
-------------------------------------------------------------------------
Sets       166235.80          ---          ---      1.42400     12320.70 
Gets       166235.80     88005.23     78230.57      1.42400      8909.17 
Waits           0.00          ---          ---      0.00000          --- 
Totals     332471.59     88005.23     78230.57      1.42400     21229.87

12 个 I/O 线程,CPU 使用率达到了 1200%,但是 OPS 相比于 8 个 I/O 线程却没有提高。可见,不能期望无限地增加 I/O 线程数量来提升 OPS。

io-threads-do-reads

前面压测中,均设置了 io-threads-do-reads yes,如果关闭此配置效果会如何呢?配置如下:

io-threads 8
io-threads-do-reads no

压测结果如下:

$ memtier_benchmark -p 11002 -n 10000 --key-maximum=10000 --ratio=1:1 --threads=8

ALL STATS
=========================================================================
Type         Ops/sec     Hits/sec   Misses/sec      Latency       KB/sec 
-------------------------------------------------------------------------
Sets       103461.28          ---          ---      1.91500      7668.12 
Gets       103461.28     54772.40     48688.88      1.91300      5544.86 
Waits           0.00          ---          ---      0.00000          --- 
Totals     206922.55     54772.40     48688.88      1.91400     13212.97

CPU 使用情况如下:

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND                               
45737 work      20   0  310m   9m 1552 R 100.0  0.0   0:06.07 io_thd_1                              
45738 work      20   0  310m   9m 1552 R 100.0  0.0   0:06.07 io_thd_2                              
45739 work      20   0  310m   9m 1552 R 100.0  0.0   0:06.07 io_thd_3                              
45740 work      20   0  310m   9m 1552 R 100.0  0.0   0:06.07 io_thd_4                              
45742 work      20   0  310m   9m 1552 R 100.0  0.0   0:06.07 io_thd_6                              
45743 work      20   0  310m   9m 1552 R 100.0  0.0   0:06.07 io_thd_7                              
45731 work      20   0  310m   9m 1552 R 99.6  0.0   0:06.10 redis-server                           
45741 work      20   0  310m   9m 1552 R 99.6  0.0   0:06.07 io_thd_5                               
45866 work      20   0  652m 6524 2380 R 53.6  0.0   0:03.20 memtier_benchma                        
45867 work      20   0  652m 6524 2380 R 52.9  0.0   0:03.17 memtier_benchma                        
45860 work      20   0  652m 6524 2380 R 52.0  0.0   0:03.19 memtier_benchma                        
45863 work      20   0  652m 6524 2380 R 51.6  0.0   0:03.18 memtier_benchma                        
45864 work      20   0  652m 6524 2380 R 50.3  0.0   0:03.09 memtier_benchma                        
45861 work      20   0  652m 6524 2380 R 48.7  0.0   0:02.97 memtier_benchma                        
45862 work      20   0  652m 6524 2380 R 48.7  0.0   0:02.98 memtier_benchma                        
45865 work      20   0  652m 6524 2380 R 48.3  0.0   0:02.96 memtier_benchma  

关闭了 io-threads-do-reads 之后,Redis 仅仅使用 I/O 线程来处理写操作,命令的读取和解析均由主线程执行。此时,OPS 降低到 20 万,CPU 使用率依然是 800%。而开启此选项后,8 个 I/O 线程,OPS 可以达到 34 万。

为什么 redis 要提供这个配置项呢,Redis 的配置文件中有如下注释:

io-threads-do-reads no
#
# Usually threading reads doesn't help much.
#
# NOTE 1: This configuration directive cannot be changed at runtime via
# CONFIG SET. Aso this feature currently does not work when SSL is
# enabled.
#
# NOTE 2: If you want to test the Redis speedup using redis-benchmark, make
# sure you also run the benchmark itself in threaded mode, using the
# --threads option to match the number of Redis threads, otherwise you'll not
# be able to notice the improvements.

注释中说 io-threads-do-reads 的作用不是很大,但上面的测试表明,开启此选项后,提示很明显。另外启动 SSL 后,不支持开启此选项,具体原因和 Redis 中 SSL 的实现有关。

Redis 的大部分应用场景下,是读多写少,请求内容往往比响应要小很多。因此,在真实场景下,多线程的优势体现在多线程写入上。为了验证这个想法,我将压测的数据由 32 字节调整到 1024 字节,依然使用 8 个 I/O 线程来执行压测。

开启 io-threads-do-reads 的压测结果如下:

$ memtier_benchmark -p 11002 -n 10000 --key-maximum=10000 --ratio=1:1 --threads=8 -d 1024

ALL STATS
=========================================================================
Type         Ops/sec     Hits/sec   Misses/sec      Latency       KB/sec 
-------------------------------------------------------------------------
Sets       129456.97          ---          ---      1.67900    135259.09 
Gets       129456.97     69570.18     59886.79      1.67600     58067.54 
Waits           0.00          ---          ---      0.00000          --- 
Totals     258913.94     69570.18     59886.79      1.67700    193326.64

关闭 io-threads-do-reads 的压测结果如下:

$ memtier_benchmark -p 11002 -n 10000 --key-maximum=10000 --ratio=1:1 --threads=8 -d 1024

ALL STATS
=========================================================================
Type         Ops/sec     Hits/sec   Misses/sec      Latency       KB/sec 
-------------------------------------------------------------------------
Sets       102822.34          ---          ---      1.93700    107430.73 
Gets       102822.34     55256.73     47565.61      1.93300     28905.74 
Waits           0.00          ---          ---      0.00000          --- 
Totals     205644.68     55256.73     47565.61      1.93500    136336.46

此时开启 io-threads-do-reads 选项后,性能提升了 25% 左右。而 value 大小为 32 字节时候,这一提升为 20 万 -> 34 万,高达 70%。

Redis 之所以提供 io-threads-do-reads 这个选项,我想是为了让 I/O 多线程和 SSL 兼容。

总结

Redis 6.0 引入多线程的网络模型,该模型可以有效提升 Redis 的 OPS,代价是需要消耗更多的 CPU 资源。这个多线程的实现,与常规的多线程网络模型有明显差异,看得出来这是 Redis 为了兼容现有功能而采用的折中解法。加入多线程后,Redis 的代码复杂度又提升了许多,而且整个模型算不上高效,Redis 已经有了很多的历史包袱,未来这一模型是否会被 Redis 社区改进呢?我感觉比较困难,不妨期待一下吧。

评论 (评论内容仅博主可见,不会公开显示)