Redis 网络模型
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 社区改进呢?我感觉比较困难,不妨期待一下吧。