可能很多人都认为 Redis 和 CPU 的关系简单,Redis 的线程在 CPU 上运行,CPU 快 Reids 处理请求的速度也很快。
其实,这种认知是片面的,CPU 的多核架构
及多 CPU 结构
,也会影响到 Redis 的性能。如果不了解 CPU 对 Redis 的影响,那么在进行 Redis 调优时,可能会遗漏一些调优方法,不能把 Redis 的性能发挥到极致。
一个 CPU 处理器中,一般有多个运行核心
,我们把一个运行核心
称为一个物理核
,每个物理核
都可以运行应用程序。每个物理核
都拥有私有的一级缓存(Level 1 cache,简称 L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存。
这里提到了一个概念,就是物理核的私有缓存。它其实是指缓存空间只能被当前这个物理核试验,其他的物理核无法对这个核的缓存空间进行数据存取。
因为 L1 和 L2 缓存是每个物理核私有的,所以,当数据或指令保存在 L1、L2 缓存时,物理核访问它们的速度非常块,不超过 10 纳秒。如果 Redis 把要运行的指令或存取的数据保存在 L1 和 L2 缓存的话,就能高速地访问这些指令和数据。
不过,由于技术限制,L1 和 L2 缓存一般只有 KB 级别,存不下太多的数据。如果 L1、L2 缓存中,没有所需的数据,应用程序就要访问内存来获取数据。而应用程序的访问内存延迟一般在百纳秒级别,是访问 L1、L2 缓存的延迟的近 10 倍,不可避免地会对性能造成影响。
所以,不同的物理核还会共享一个共同的三级缓存(Level 3 cache,简称 L3 cache)
。L3 缓存能够使用的存储资源比较多,一般比较大,能达到几 MB 到几十 MB ,这就能让应用程序缓存更多的数据。当 L1、L2 缓存中没有数据缓存时,可以访问 L3,尽可能避免访问内存。
另外,目前主流的 CPU 处理器中,每个物理核通常都会运行两个超线程,也叫作逻辑核。同一个物理核的逻辑核会共享使用 L1、L2 缓存。
在主流的服务器上,一个 CPU 处理器,会有 10 到 20 多个物理核。同时,为了提升服务器的处理能力,一个服务器上通常还会有多个 CPU 处理器(也称为多 CPU Socket
),每个处理器有自己的物理核(包括 L1、L2 缓存),L3 缓存,以及连接的内存,同时,不同的处理器间通过总线连接。
在多 CPU 架构上,应用程序可以运行在不同的处理器上。在上图中,Redis 可以先在 Cpu Socket1
上运行一段时间,然后再被调度到 Cpu Socket2
上运行。
这里有个场景:如果应用程序现在一个 Socket 上运行,并且把数据保存到了内存,然后被调度到另一个 Socket 运行,此时,应用程序再进行内存访问时,就需要访问之前 Socket 上连接的内存,这种访问属于远端内存访问。和访问 Socket 直接连接的内存相比,远端内存访问会增加应用程序的延迟。
在多 CPU 架构下,一个应用程序访问所在 Socket 本地内存和访问远端内存的延迟并不一致,所以,我们也把这个架构称为非统一内存访问架构(Non-Uniform Memory Access,NUMA 架构)。
现在,我们知道了主流的 CPU 多核架构和多 CPU 架构,总结下 CPU 架构对应用程序运行影响:
接下来,我们了解下 CPU 多核是如何影响 Redis 性能的。
在一个CPU 核删高云翔时,应用程序需要记录自身使用的软硬件资源信息(如栈指针、CPU 核的寄存器值等),我们把这些信息称为运行时信息。同时,应用程序访问最频繁的指令和数据还会被缓存到 L1、L2 缓存上,以便提升执行速度。
但是在多核 CPU 场景下,一旦应用程序需要在一个新的 CPU 核上运行,那么,运行时信息就需要重新加载到新的 CPU 核上。而且,新的 CPU 核的 L1、L2 缓存也需要重新加载指令和数据,这会导致程序运行时间增加。
有个曾经在多核 CPU 场景下调优的案例,可以帮助我们理解到多核 CPU 对 Redis 性能的影响。
需求是要对 Redis 99% 的尾延迟进行优化,要求 GET 尾延迟小于 300 微秒,PUT 尾延迟小于 500 微妙。
尾延迟解释
我们把所有请求的处理延迟从小到大排序,99% 的请求延迟小于的值就是 99% 尾延迟。 比如说我们有 1000 个请求,假设按从小到大排序后,第 991 个请求的延迟实测值是 1ms,而前 990 个请求的延迟都小于 1ms,所以,这里的 99% 尾延迟就是 1ms。
我们使用 String类型(复杂度为 O(1)) 的 GET/PUT 进行数据存取,同时关闭 AOF 和 RDB,而且 Redis 中没有保存集合类型的数据,也就没有 bigkey 操作,避免了可能导致延迟增加的许多情况。
但是,即使这样,我们在一台有 24 个 CPU 核的服务器上运行 Redis 实例,GET 和 PUT 的 99% 的尾延迟分别是 504 微妙 和 1175 微妙,明显大于设定的目标。后来,我们仔细检测了 Redis 实例运行时的服务器 CPU 的状态值,这才发现, CPU 的 context switch
次数比较多。
context switch
是指上下文切换,这里的上下文就是线程的运行时信息。在 CPU 多核环境中,一个线程先在一个 CPU 核上运行,之后又切换到另一个 CPU 核上运行,这是就会发生context switch
。
当 context switch
发送后,Redis 主线程的运行时信息需要被重新加载到另一个 CPU 核上,而且,此时,另一个 CPU 核上的 L1、L2 缓存中,并没有 Redis 实例之前运行时频繁访问的指令和数据,所以,这些指令和数据都需要从 L3 缓存,甚至是内存中加载。这个加载过程需要花费一定时间。而且,Redis 实例需要等待这个重新记载的过程完成后,才能开始处理请求,所以,这也会导致一些请求的处理时间增加。
如果在 CPU 多核场景下,Redis 实例被频繁地调度到不同的 CPU 核上运行的话,那么,对 Reids 实例的情趣处理时间影响就更大了。每调度一次,一些请求就会收到运行时信息、指令和数据重新过程的影响,这就会导致某些请求的延迟明显高于其他请求。分析到这里,我们就知道了刚刚的例子中 99% 的尾延迟时钟降不下来的原因了。
所以,我们要避免 Redis 总是在不同 CPU 核上来回调度执行。我们可以使用 taskset
命令把一个程序绑定在一个核上运行。
比如,下面的命令就把 Redis 绑定在了 0 号 核上,其中,“-c” 选项用于设置要绑定的核编号。
taskset -c 0 ./redis-server
我自己本地进行了测试, 使用的命令如下所示
taskset -c 0 /home/chenjian/redis-6.2.12/src/redis-server /home/chenjian/redis-6.2.12/redis.conf
此外,若要查询 Redis 进程与哪些核绑定,可以使用taskset -pc pid
命令查看
绑定后,进行了测试,发现 Redis 实例的 GET 和 PUT 的 99% 尾延迟一下子就分别讲到了 260 微妙和 482 微妙,达到了预期的目标。
可以看到,在 CPU 多核的环境下,通过绑定 Redis 实例和 CPU 核,可以有效降低 Redis 尾延迟。当然,绑核不仅对降低尾延迟有好处,同样也能降低平均延迟、提升吞吐率,进而提升 Redis 性能。
在实际应用 Redis 时,经常会有一种做法,为了提升 Redis 的网络性能,把操作系统网络中断处理程序
和 CPU 核绑定。这个做法可以避免网络中断处理程序
在不同核上来回调度执行,有效提升 Redis 的网络处理性能。
但是,网络中断处理程序是要和 Redis 实例进行网络数据交互的,一旦把网络中断处理程序绑核后,我们就需要注意 Redis 实例是绑在哪个核了,这会关系到 Redis 访问网络数据的效率高低。
先看下 Redis 实例和网络中断处理程序的数据交互:
网卡
中读取数据,并把数据写入到内核的缓冲区
。epoll 机制
触发事件,通知 Redis 实例
那么,在 CPU 的 NUMA 架构下,当网络终端处理程序、Redis 实例分别和 CPU 核绑定后,就会有一个潜在的风险: 如果网络中断处理程序和 Redis 实例各种所绑定的 CPU 核不在同一个 CPU Socket 上,那么 Redis 实例读取网络数据时,就需要跨 CPU Socket 访问内存,这个过程会花费较多时间。
上图中,可以看到,网络中断处理程序绑定在了 CPU Socket 1
的某个核上,而 Redis 实例则被绑定在了 CPU Socket 2
的某个核上。此时,网络中断处理程序读取到的网络数据,被保存在 CPU Socket 1
的本地内存中,而 Redis 要访问网络数据时,就需要 CPU Socket 2
通过总线把内存访问命令发送到 CPU Socket 1
上,进行远程访问,时间开销比较大。
为了避免 Redis 跨 CPU Socket
访问网络数据,我们最好把网络中断处理程序和 Redis 实例绑在同一个 CPU Socket
上,这样一来,Redis 实例就可以直接从本地内存读取网络数据了。
不过,需要注意的是,在 CPU 的 NUMA 架构
下,对 CPU 核的编号规则,并不是先把一个 CPU Socket
中的所有逻辑核编完,再对下一个 CPU Socket
中的逻辑核编码,而是先给每个 CPU Socket
中的每个物理核的第一个逻辑核依次编号,在给每个 CPU Socket
中的物理核的第二个逻辑核依次编号。
举个例子:假设有 2 个 CPU Socket
,每个 CPU Socket
上有 6 个物理核,每个物理核又有 2 个逻辑核,总共 24 个逻辑核。我们可以执行 lscpu
命令,查看到这些核的编号:
lscpu
Architecture: x86_64 ...
NUMA node0 CPU(s): 0-5,12-17
NUMA node1 CPU(s): 6-11,18-23
...
所以在绑核时,我们一定要注意,不能想当然的任务第一个 Socket 上的 12 个逻辑核的编号就是 0-11。否则,网络中断处理程序和 Redis 实例分别绑定到编号为 1 和 7 的 CPU 核上,此时,它们仍然是在 2 个 CPU Socket 上,Redis 实例仍然需要跨 Socket 读取网络数据。
所以,你一定要注意 NUMA 架构下 CPU 核的编号方法,这样才不会绑错核。
简单总结下刚刚的内容。在CPU 多核场景下,用 taskset 命令把 Redis 实例和一个核绑定,可以减少 Redis 实例在不同的核上来回调度执行的开销,避免较高的尾延迟;在多 CPU 的 NUMA 架构下,如果你对网络中断处理程序做了绑核操作,建议你同时把 Redis 实例和网络中断处理程序绑在同一个 CPU Socket 的不同核上,这样可以避免 Redis 跨 Socket 访问内存中的网络数据的时间开销。
当我们把 Redis 实例绑到一个 CPU 逻辑核上时,就会导致子进程、后台线程和 Redis 主线程竞争 CPU 资源,一旦子进程或后台线程占用 CPU 时,主线程就会被阻塞,导致 Redis 请求延迟增加。
在给 Redis 绑核时,我们不要把一个实例和一个逻辑核绑定,而要和一个物理核绑定,也就是说,把一个物理核的 2 个逻辑都用上。
还是以刚才的 NUMA 架构为例, NUMA node0 的CPU 核编号是 0-5、12-17。其中 1 和额 12、1 和 13、2 和 14 都表示一个物理核的 2 个逻辑核。所以,在绑核时,我们使用属于同一个物理核的 2 个逻辑核进行绑定操作。例如,我们执行下面的命令,就把 Redis 实例绑定到了 逻辑核 0 和 12 上,而这两个核正好都属于物理核 1。
taskset -c 0,12 ./redis-server
和只绑一个核相比,把 Redis 实例和物理核绑定,可以让主线程、子进程、后台线程共享使用 2 个逻辑核,可以在一定程序上环节 CPU 的资源竞争。但是,因为只是用了 2 个逻辑核,他们之间的 CPU 竞争仍然会存在。
这个方案就是通过修改 Redis 源码,把子进程和后台线程绑定到不同的 CPU 核上。
如果你对 Redis 的源码不太熟悉也没关系,因为这是通过编程实现绑核的一个通用做法。学会了这个方案,你可以在熟悉了源码之后把它用上。
通过编程实现绑核时,需要用到操作系统提供的 1 个数据结构 cpu_set_t
和 3 个函数 CPU_ZERO
、CPU_SET
和 sched_setaffinity
。
那么,怎么在编程时,把这个三个函数结合起来实现绑核呢?分四步走就行:
下面具体介绍下,分别把后台线程、子进程绑到不同的核上的做法。
先说后台线程。为了让你更好地理解编程实现绑核,你可以看下这段代码示例:
// 线程函数
void worker(int bind_cpu) {
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&cpuset); //位图变量所有位设置为0
CPU_SET(bind_cpu, &cpuset); //绑程序绑定在 cpu_set_t 结构位图
sche_setaffinity(0, sizeof(cpuset), &cpuset); // 把程序绑定在 cpu_set_t 结构位图
//实际线程函数工作
}
int main() {
pthread_t pthread1;
// 把线程的 pthread1 绑在编号为 3 的逻辑核上
pthread_create(&pthread1, NULL, (void *)worker, 3)
}
对于 Redis 来说,它是在 bio.c 文件文件中的 bioProcessBackgroundJobs 函数中创建了后台线程。bioProcessBackgroundJobs 函数类似于刚刚的例子中的 worker 函数,在这个函数中实现绑核四步操作,就可以把后台线程绑定到主线程不同的核上了。
和给线程绑核类似,当我们使用 fork 创建子进程时,也可以把刚刚说的四步操作实现在 fork 后的子进程代码中,示例如下:
int main() {
//用 fork 创建一个子进程
pit_t p = fork();
if(p < 0) {
printf(" fork error\n");
}
//子进程代码部分
else if() {
cupt_set_t cpuset; //创建位图变量
CPU_ZERO(&cpuset); //位图变量所有位设置为0
CPU_SET(3, &cpuset); //把位图的第 3 位设置为1
sche_setaffinity(0, sizeof(cpuset), &cpuset); // 把程序绑定在 3 号逻辑核
//实际子进程工作
exit(0);
}
...
}
对于 Redis 来说,生成 RDB 和 AOF 日志重写的子进程分别是下面两个文件的函数中实现的。
这两个函数中都调用了 fork 创建子进程,所以,我们可以在子进程代码部分加上绑核的四步操作。
使用源码的优化方案,我们既可以实现 Redis 实例绑核,避免切换核带来的性能影响,还可以让子进程、后台线程和主线程在不同一个核上运行,避免了它们之间的 CPU 资源竞争。相比使用 taskset 绑核来说,这个方案可以进一步降低绑核的风险。