Redis 6.0多线程

前言

相信你一定不止一次见过Redis是单线程模式,不过说实话那只是个老版本,Redis 6.0在2019年底发布,其中引入的最重大的改变就是多线程IO。

问题概述

Redis 6.0之后的版本抛弃了单线程模型这一设计,原本使用单线程运行的Redis也开始选择性的使用多线程模型。

仔细想想这个问题,大致可以拆分为两个主要问题:

  1. 为什么Redis一开始选择单线程模型(单线程的好处)?
  2. 为什么Redis在6.0之后加入了多线程模型(在某些情况下单线程出现了缺点,多线程可以解决)?

就针对以上两个问题,简单的分析一下。

为什么Redis一开始使用程模型

不管是单线程还是多线程其目的都是为了提升Redis的开发效率,因为Redis是一个基于内存的数据库,还要处理大量的外部的网络请求,这就不可避免的要进行大量的IO。好在Redis使用了很多优秀的机制来保障它的高效率。那么Redis为什么要设计成单线程模式呢?

(1)IO多路复用

redis顶层设计

FD是一个文件描述符,表示当前文件出于可读、可写还是异常状态。使用I/O多路复用机制同时监控多个文件描述符的可读、可写状态。这里可以理解为具有多线程的特点。

一旦接收到网络请求就会在内存中快速处理,由于绝大多数操作都是纯内存操作,所以处理速度会非常的快。也就是说在单线程模式下,即使连接的网络请求很多,因为有IO多路复用,依然可以在高效的内存处理中忽略。

(2)可维护性高

多线程模型虽然在某些方面表现比较优异,但是多线程模型也带来了程序执行顺序的不确定性的问题,带来了并发读写的一系列问题。单线程模式下,可以很方便的进行调试和测试。

(3)基于内存,单线程状态下效率依然很高

多线程能够充分的利用CPU资源,但对于redis来说,由于基于内存速度那是相当的高,能达到在一秒内处理10万个用户请求,如果一秒10万还不能满足,那我们可以使用redis的分片技术交给不同的redis服务器。这样的做法避免了在同一个redis服务中引入大量的多线程操作。

而且基于内存,除非需要进行AOF备份,否则基本上不会涉及任何的I/O操作。这些数据的读写由于只发生在内存中,所以处理速度是非常快的,用多线程模型处理全部的外部请求可能不能一个很好的方案。

综上所述,我们可以总结一下,基于内存而且使用多路复用技术,单线程速度很快,有保证了多线程的特点。因此没有必要使用多线程。

为什么引入多线程模型

引入多线程模型说明redis在某些方便,单线程已经不具备优势。

因为读写网络的read/write系统调用redis执行期间占用大部分的CPU时间,如果把网络读写做成多线程的方式对性能会有很大的提升。

Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令依然是单线程。之所以这么设计是不想redis因为多线程而变得复杂,需要去控制key、lua、事务、lpush/lpop(redis语法:将一个或多个值插入到列表头部(左边)、移出并获取列表的第一个元素(左边)) 等并发操作。

Redis在最近的几个版本中加入了一些可以被其他线程异步处理的删除操作,也就是我们比较熟悉的UNLINK、FLUSHALL ASYNC 和 FLUSHDB ASYNC。我们为什么需要这些删除操作,而它们为什么需要多线程方式异步处理?

我们知道redis可以使用del命令删除一个元素,如果这个元素非常大,可能占据几十兆甚至几百兆,那么在短时间内是不能完成的,这样一来就需要多线程的异步支持。

Redis 6.0与老版性能对比评测

Redis 作者 Salvatore 在 RedisConf 2019 分享,其中一段展示了 Redis 6 引入的多线程 IO 特性对性能提升至少是一倍以上,接下来我们对于redis6.0代码进行剖析。

目前对于单线程的redis来说,性能瓶颈主要在与网络的IO消耗,优化主要有以下两个方向:

  1. 提高网络IO性能,例如,使用DPDK来替代内核网络栈的方式
  2. 使用多线程充分利用多核,例如,Memcached

协议栈优化的这种方式跟redis关系不大,多线程特性在社区也被反复提了很久后在redis6.0才加入了多线程模型,Salvatore 在自己的博客 An update about Redis developments in 2019 也有简单的说明。但跟 Memcached 这种从 IO 处理到数据访问多线程的实现模式有些差异。Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。(上面也有提到)

代码实现

多线程的IO读(请求)写(响应)在实现流程上是一样的,只是执行读还是执行写操作上的差异而已。同时这些IO线程在同一时刻全部是读或者写,不会部分读或者部分写的情况,所以下面以读流程作为例子。分析过程中的代码只是为了辅助理解,所以只会覆盖核心逻辑而不是全部细节。如果想完全理解细节,建议看完之后再次看一次源码实现。

加入多线程 IO 之后,整体的读流程如下:

  1. 主线程负责接收建连请求,读事件到来(收到请求)则放到一个全局等待读处理队列。
  2. 主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程,然后主线程忙等待(spinlock 的效果)状态
  3. IO 线程将请求数据读取并解析完成(这里只是读数据和解析并不执行)

  4. 主线程执行所有命令并清空整个请求等待读处理队列(执行部分串行)

上面的这个过程是完全无锁的,因为在 IO 线程处理的时主线程会等待全部的 IO 线程完成,所以不会出现 data race 的场景。

注意:如果对于代码实现没有兴趣的可以直接跳过下面内容,对了解 Redis 性能提升并没有伤害。

下面的代码分析和上面流程是对应的,当主线程收到请求的时候会回调 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 函数会直接返回不进行命令解析和执行。接着主线程在处理完读事件(注意是读事件不是读数据)之后将这些连接通过 RR 的方式分配给这些 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 线程对应需要处理的连接,然后主线程将这些连接通过 RR 的方式分配给这些 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 线程同一时刻只会执行读或者写。另外,心细的同学可能注意到处理线程会调用 readQueryFromClient 函数,而连接就是由这个回调函数加到队列的,那不就死循环了?这个的答案在 postponeClientRead 函数,已经加到等待处理队列的连接会被设置 CLIENT_PENDING_READ 标识。postponeClientRead 函数不会把连接再次加到队列,那么 readQueryFromClient 会继续执行读取和解析请求。readQueryFromClient 函数读取请求数据并调用 processInputBuffer 函数进行解析命令,processInputBuffer 会判断当前连接是否来自 IO 线程,如果是的话就只解析不执行命令,代码就不贴了。

大家去看 IOThreadMain 实现会发现这些 io 线程是没有任何 sleep 机制,在空闲状态也会导致每个线程的 CPU 跑到 100%,但简单 sleep 则会导致读写处理不及时而导致性能更差。Redis 当前的解决方式是通过在等待处理连接比较少的时候关闭这些 IO 线程。为什么不适用条件变量来控制呢?我也没想明白,后面可以到社区提问。

性能对比

Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 内存,主机型号 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 内存,主机型号 ecs.ic5.2xlarge

压测配置:

多线程 IO 版本刚合并到 unstable 分支一段时间,所以只能使用 unstable 分支来测试多线程 IO,单线程版本是 Redis 5.0.5。多线程 IO 版本需要新增以下配置:

io-threads 4 # 开启 4 个 IO 线程
io-threads-do-reads yes # 请求解析也是用 IO 线程

压测命令:

redis-benchmark -h 192.168.0.49 -a foobared -t set,get -n 1000000 -r 100000000 --threads 4 -d ${datasize} -c 256

从上面可以看到 GET/SET 命令在 4 线程 IO 时性能相比单线程是几乎是翻倍了。另外,这些数据只是为了简单验证多线程 IO 是否真正带来性能优化,并没有针对严谨的延时控制和不同并发的场景进行压测。数据仅供验证参考而不能作为线上指标,且只是目前的 unstble分支的性能,不排除后续发布的正式版本的性能会更好。

注意: Redis Benchmark 除了 unstable 分支之外都是单线程,对于多线程 IO 版本来说,压测发包性能会成为瓶颈,务必自己编译 unstable 分支的 redis-benchmark 来压测,并配置 --threads 开启多线程压测。另外,如果发现编译失败也莫慌,这是因为 Redis 用了 Atomic_ 特性,更新版本的编译工具才支持,比如 GCC 5.0 以上版本。

总结

Redis 6.0 将在性能、协议以及权限控制都会有很大的改进。Salvatore 今年全身心投入在优化 Redis 和集群的功能,特别值得期待。

Redis 选择使用单线程模型处理客户端的请求主要还是因为 CPU 不是 Redis 服务器的瓶颈,所以使用多线程模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率。

之前用单线程是因为基于内存速度快,而且多路复用有多路复用的作用,也就是足够了,现在引入是因为在某些操作要优化,因此引入了多线程。

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值