Redis 6

Redis 6

介绍 Redis 6 的几个关键新特性。

多线程处理

在 Redis 6.0 中,非常受关注的第一个新特性就是多线程。这是因为,Redis一直被大家熟知的就是它的单线程架构,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF重写),但是,从网络IO处理到实际的读写命令处理,都是由单个线程完成的

随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络IO的处理上,也就是说,单个主线程处理网单个主线程处理网络请求的速度跟不上底层网络硬件的速度络请求的速度跟不上底层网络硬件的速度。

为了应对这个问题,一般有两种方法。

第一种方法是,用用戶态网络协议栈(例如DPDK)取代内核网络协议栈,让网络请求的处理不用在内核里执行,直接在用戶态完成处理就行。

对于高性能的Redis来说,避免频繁让内核进行网络请求处理,可以很好地提升请求处理效率。但是,这个方法要求在Redis的整体架构中,添加对用戶态网络协议栈的支持,需要修改Redis源码中和网络相关的部分(例如修改所有的网络收发请求函数),这会带来很多开发工作量。而且新增代码还可能引入新Bug,导致系统不稳定。所以,Redis 6.0中并没有采用这个方法。

第二种方法就是采用多个IO线程来处理网络请求,提高网络请求处理的并行度。Redis 6.0就是采用的这种方法。

但是,Redis的多IO线程只是用来处理网络请求的,对于读写命令,Redis仍然使用单线程来处理。这是因为,Redis处理请求时,网络处理经常是瓶颈,通过多个IO线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证Lua脚本、事务的原子性,额外开发多线程互斥机制了。这样一来,Redis线程模型实现就简单了。

Multiple Reactors

目前 Linux 平台上主流的高性能网络库/框架中,大都采用 Reactor 模式,比如 netty、libevent、libuv、POE(Perl)、Twisted(Python)等。

Reactor 模式本质上指的是使用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。

单 Reactor 模式,引入多线程之后会进化为 Multi-Reactors 模式,基本工作模式如下:

区别于单 Reactor 模式,这种模式不再是单线程的事件循环,而是有多个线程(Sub Reactors)各自维护一个独立的事件循环,由 Main Reactor 负责接收新连接并分发给 Sub Reactors 去独立处理,最后 Sub Reactors 回写响应给客户端。

Multiple Reactors 模式通常也可以等同于 Master-Workers 模式,比如 Nginx 和 Memcached 等就是采用这种多线程模型,虽然不同的项目实现细节略有区别,但总体来说模式是一致的。

Redis 网络多线程设计思路

Redis 虽然也实现了多线程,但是却不是标准的 Multi-Reactors/Master-Workers 模式:

I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终还是要在主线程上完成

Redis 多线程加锁

Redis 的多线程模式下,似乎并没有对数据进行锁保护,事实上 Redis 的多线程模型是全程无锁(Lock-free)的,这是通过原子操作+交错访问来实现的,主线程和 I/O 线程之间共享的变量有三个:io_threads_pending 计数器、io_threads_op I/O 标识符和 io_threads_list 线程本地任务队列。

io_threads_pending 是原子变量,不需要加锁保护,io_threads_opio_threads_list 这两个变量则是通过控制主线程I/O 线程交错访问来规避共享数据竞争问题:I/O 线程启动之后会通过忙轮询和锁休眠等待主线程的信号,在这之前它不会去访问自己的本地任务队列 io_threads_list[id],而主线程会在分配完所有任务到各个 I/O 线程的本地队列之后才去唤醒 I/O 线程开始工作,并且主线程之后在 I/O 线程运行期间只会访问自己的本地任务队列 io_threads_list[0] 而不会再去访问 I/O 线程的本地队列,这也就保证了主线程永远会在 I/O 线程之前访问 io_threads_list 并且之后不再访问,保证了交错访问。io_threads_op 同理,主线程会在唤醒 I/O 线程之前先设置好 io_threads_op 的值,并且在 I/O 线程运行期间不会再去访问这个变量。

Redis 开启网络多线程

Redis 6.0中,多线程机制默认是关闭的,如果需要使用多线程功能,需要在 redis.conf 中完成两个设置。

  • 1.设置io-thread-do-reads配置项为yes,表示启用多线程。
io-threads-do-reads yes
  • 2.设置线程个数。一般来说,线程个数要小于Redis实例所在机器的CPU核个数,例如,对于一个8核的机器来说,Redis官方建议配置6个IO线程。
io-threads  6

如果你在实际应用中,发现Redis实例的CPU开销不大,吞吐量却没有提升,可以考虑使用Redis 6.0的多线程机制,加速网络处理,进而提升实例的吞吐量

Redis 网络多线程模型缺陷

标准的 Multi-Reactors/Master-Workers 模式下,Sub Reactors/Workers 会完成 网络读 -> 数据解析 -> 命令执行 -> 网络写 整套流程,Main Reactor/Master 只负责分派任务,而在 Redis 的多线程方案中,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,却没有真正去执行命令,所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

Redis 之所以如此设计它的多线程网络模型,我认为主要的原因是为了保持兼容性,因为以前 Redis 是单线程的,所有的客户端命令都是在单线程的事件循环里执行的,也因此 Redis 里所有的数据结构都是非线程安全的,现在引入多线程,如果按照标准的 Multi-Reactors/Master-Workers 模式来实现,则所有内置的数据结构都必须重构成线程安全的,这个工作量无疑是巨大且麻烦的。

所以,在我看来,Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能。

其次,目前 Redis 的多线程模型中,主线程和 I/O 线程的通信过于简单粗暴:忙轮询和锁,因为通过自旋忙轮询进行等待,导致 Redis 在启动的时候以及运行期间偶尔会有短暂的 CPU 空转引起的高占用率,而且这个通信机制的最终实现看起来非常不直观和不简洁,希望后面 Redis 能对目前的方案加以改进。

客户端缓存

和之前的版本相比,Redis 6.0新增了一个重要的特性,就是实现了服务端协助的客戶端缓存功能,也称为跟踪(Tracking)功能。有了这个功能,业务应用中的Redis客戶端就可以把读取的数据缓存在业务应用本地了,应用就可以直接在本地快速读取数据了。

如果数据被修改了或是失效了,如何通知客戶端对缓存的数据做失效处理?

6.0实现的Tracking功能实现了两种模式,来解决这个问题。

第一种模式是普通模式

在这个模式下,实例会在服务端记录客戶端读取过的key,并监测key是否有修改。一旦key的值发生变化,服务端会给客戶端发送invalidate消息,通知客戶端缓存失效了。

在使用普通模式时,有一点你需要注意一下,服务端对于记录的key只会报告一次invalidate消息,也就是说,服务端在给客戶端发送过一次invalidate消息后,如果key再被修改,此时,服务端就不会再次给客戶端发送invalidate消息。

只有当客戶端再次执行读命令时,服务端才会再次监测被读取的key,并在key修改时发送invalidate消息。这样设计的考虑是节省有限的内存空间。毕竟,如果客戶端不再访问这个key了,而服务端仍然记录key的修改情况,就会浪费内存资源

我们可以通过执行下面的命令,打开或关闭普通模式下的Tracking功能。

CLIENT TRACKING ON|OFF

第二种模式是广播模式

在这个模式下,服务端会给客戶端广播所有key的失效情况,不过,这样做了之后,如果 key 被频繁修改,服务端会发送大量的失效广播消息,这就会消耗大量的网络带宽资源。

所以,在实际应用时,我们会让客戶端注册希望跟踪的key的前缀,当带有注册前缀的key被修改时,服务端会把失效消息广播给所有注册的客戶端。和普通模式不同,在广播模式下,即使客戶端还没有读取过和普通模式不同,在广播模式下,即使客戶端还没有读取过key,但只要它注册了要跟踪的key,服务端都会把key失效消息通知给这个客戶端key,但只要它注册了要跟踪的key,服务端都会把key失效消息通知给这个客戶端。

我给你举个例子,带你看一下客戶端如何使用广播模式接收key失效消息。当我们在客戶端执行下面的命令后,如果服务端更新了user:id:1003这个key,那么,客戶端就会收到invalidate消息。

CLIENT TRACKING ON BCAST PREFIX user

这种监测带有前缀的key的广播模式,和我们对key的命名规范非常匹配。我们在实际应用时,会给同一业务下的key设置相同的业务名前缀,所以,我们就可以非常方便地使用广播模式。

不过,刚才介绍的普通模式和广播模式,需要客戶端使用RESP 3协议,RESP 3协议是6.0新启用的通信协议。

权限控制

在Redis 6.0 版本之前,要想实现实例的安全访问,只能通过设置密码来控制,例如,客戶端连接实例前需要输入密码。

此外,对于一些高⻛险的命令(例如KEYSFLUSHDBFLUSHALL等),在Redis 6.0 之前,我们也只能通过rename-command来重新命名这些命令,避免客戶端直接调用。

Redis 6.0 提供了更加细粒度的访问权限控制,这主要有两方面的体现。

首先,6.0版本支持创建不同用戶来使用Redis。在6.0版本前,所有客戶端可以使用同一个密码进行登录使用,但是没有用戶的概念,而在6.0中,我们可以使用ACL SETUSER命令创建用戶。例如,我们可以执行下面的命令,创建并启用一个用戶normaluser,把它的密码设置为 “abc”:

ACL SETUSER normaluser on > abc

另外,6.0版本还支持以用戶为粒度设置命令操作的访问权限。我把具体操作列在了下表中,你可以看下,其中,加号(+)和减号(-)就分别表示给用戶赋予或撤销命令的调用权限。

设置用戶normaluser只能调用Hash类型的命令操作,而不能调用String类型的命令操作,我们可以执行如下命令:

ACL SETUSER normaluser [email protected] [email protected]

除了设置某个命令或某类命令的访问控制权限,6.0版本还支持以key为粒度设置访问权限。

具体的做法是使用波浪号“~”和key的前缀来表示控制访问的key。例如,我们执行下面命令,就可以设置用戶normaluser只能对以“user:”为前缀的key进行命令操作:

ACL SETUSER normaluser ~user:* [email protected]

RESP 3 协议

在RESP 2中,客戶端和服务器端的通信内容都是以字节数组形式进行编码的,客戶端需要根据操作的命令或是数据类型自行对传输的数据进行解码,增加了客戶端开发复杂度。

而RESP 3直接支持多种数据类型的区分编码,包括空值、浮点数、布尔值、有序的字典集合、无序的集合等。

所谓区分编码,就是指直接通过不同的开头字符,区分不同的数据类型,这样一来,客戶端就可以直接通过判断传递消息的开头字符,来实现数据转换操作了,提升了客戶端的效率。除此之外,RESP 3协议还可以支持客戶端以普通模式广播模式实现客戶端缓存。

参考