本文已收录在Github,关注我,紧跟本系列专栏文章,咱们下篇再续!
- 🚀 魔都架构师 | 全网30W技术追随者
- 🔧 大厂分布式系统/数据中台实战专家
- 🏆 主导交易系统百万级流量调优 & 车联网平台架构
- 🧠 AIGC应用开发先行者 | 区块链落地实践者
- 🌍 以技术驱动创新,我们的征途是改变世界!
- 👉 实战干货:编程严选网
你觉得Redis的线程在CPU上运行,CPU快,自然Redis处理请求速度就这么快。
这是片面认识。CPU的多核架构及多CPU架构,会影响Redis性能。
1 CPU架构
一个CPU处理器中一般有多个运行核心,一个运行核心称为一个物理核,每个物理核都可运行应用程序。
每个物理核都拥有私有的一级缓存(Level 1 cache,简称L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称L2 cache)。
物理核的私有缓存
指缓存空间只能被当前的这个物理核使用,其他物理核无法对这个核的缓存空间进行数据存取。
CPU物理核的架构。

L1、L2缓存是每个物理核私有,所以,当数据或指令保存在L1、L2缓存时,物理核访问它们的延迟不超过10ns,很快。
若Redis把待运行指令或存取的数据保存在L1、L2缓存,不就能高速访问这些指令和数据。
但L1、L2缓存大小受限于处理器制造技术,只有KB级别,存不下太多数据。若L1、L2缓存中无所需数据,就要访问内存获取数据。而应用程序的访存延迟一般在百ns级,是访问L1、L2缓存延迟近10倍,不可避免对性能造成影响。
所以,不同物理核还会共享一个共同的三级缓存(Level 3 cache,L3 cache)。L3缓存能使用的存储资源较多,能达几MB~几十MB,应用程序就能缓存更多数据。
当L1、L2没有数据缓存,即可访问L3,尽量避免访问内存。
主流CPU处理器中的每个物理核通常都会运行两个超线程,也叫逻辑核。同一物理核的逻辑核会共享使用L1、L2缓存。
物理核和逻辑核,以及一级、二级缓存的关系。

超线程技术(Hyper-Threading):
- 每个物理核包含2个逻辑核(超线程),可以同时运行2个线程
- 同一物理核的两个超线程共享L1缓存(指令/数据缓存)和L2缓存
- 所有物理核共享L3缓存(三级共享缓存),容量最大但速度相对较慢
- 程序1和程序2运行在物理核1的两个超线程上,程序3和程序4运行在物理核2上
- 超线程可以提高CPU利用率,但共享缓存可能导致性能争用
性能提示:Redis单线程架构下,应避免多个Redis实例共享同一物理核,减少缓存争用。
主流服务器上,一个CPU处理器会有10~20多个物理核。
为提升服务器处理能力,服务器通常还有多个CPU处理器(多CPU Socket),每个处理器有自己的物理核(包括L1、L2缓存),L3缓存及连接的内存。
不同处理器通过总线连接。
多CPU Socket的架构,图中有两个Socket,每个Socket有两个物理核。

多CPU架构上,应用程序可在不同处理器运行。如Redis可先在Socket 1上运行一段时间,然后再被调度到Socket 2运行。
但若应用程序先在一个Socket上运行,并把数据保存到了内存,然后被调度到另一个Socket上运行,此时,应用程序再进行内存访问时,就需要访问之前Socket上连接的内存,这种访问属于远端内存访问。
相比于访问Socket直接连接的内存,远端内存访问会增加应用程序延迟。
多CPU架构下,一个应用程序访问所在Socket的本地内存和访问远端内存的延迟不一致,所以,把该架构称为非统一内存访问架构(Non-Uniform Memory Access,NUMA架构)。
CPU架构对应用程序运行的影响:
- L1、L2缓存中的指令和数据的访问速度很快,所以,充分利用L1、L2缓存,可以有效缩短应用程序的执行时间
- NUMA架构下,如果应用程序从一个Socket上调度到另一个Socket上,就可能会出现远端内存访问的情况,这会直接增加应用程序的执行时间。
2 CPU多核对Redis性能的影响
在一个CPU核上运行时,应用程序需记录自身使用的软硬件资源信息(例如栈指针、CPU核的寄存器值等),这些信息称为运行时信息。
应用程序访问最频繁的指令和数据还会被缓存到L1、L2缓存,以便提升执行速度。
但多核CPU下,一旦应用程序要在一个新CPU核上运行,则运行时信息就需重新加载到新CPU核。且新CPU核的L1、L2缓存也需要重新加载数据和指令,导致程序运行时间增加。
多核CPU环境下对Redis性能调优
当时,项目需求要对Redis的99%尾延迟进行优化,要求GET尾延迟小于300us,PUT尾延迟小于500us。
所有请求的处理延迟从小到大排个序,99%的请求延迟小于的值就是99%尾延迟。
如1000个请求,假设按请求延迟从小到大排,第991个请求的延迟实测值是1ms,而前990个请求的延迟都小于1ms,则此处99%尾延迟就是1ms。
刚开始,使用GET/PUT复杂度为O(1)的String类型进行数据存取,同时关闭RDB和AOF,且Redis实例没保存集合类型的其他数据,也就没有大K操作,避免了可能导致延迟增加的许多情况。
但即使这样,在一台有24个CPU核的服务器运行Redis实例,GET和PUT的99%尾延迟分别是504us、1175us,明显大于设定目标。
后来,仔细检测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核运行,则对Redis实例的请求处理时间影响更大。
每调度一次,一些请求就会受到运行时信息、指令和数据重新加载过程影响,导致某些请求延迟明显较高。至此,就明白案例中99%尾延迟的值为何降不下来。
所以,要避免Redis总在不同CPU核来回调度执行。
于是,尝试把Redis实例和CPU核绑定,让一个Redis实例固定运行在一个CPU核上。
可使用taskset命令把一个程序绑定在一个核上运行。
如执行下面命令,把Redis实例绑在了0号核上。其中,"-c"选项用于设置要绑定的核编号。
shell
taskset -c 0 ./redis-server
绑定后,测试发现,Redis实例的GET和PUT的99%尾延迟突然分别降到了260微秒和482微秒,达到期望。
绑核前后的Redis的99%尾延迟:
| 命令 | 未绑核运行Redis的99%尾延迟 | 绑核运行Redis的99%尾延迟 |
|---|---|---|
| GET | 504us | 260us |
| PUT | 1175us | 482us |
CPU多核下,通过绑定Redis实例和CPU核,可有效降低Redis的尾延迟。
绑核不仅对降低尾延迟有好处,同样也能降低平均延迟、提升吞吐率,进而提升Redis性能。
3 多CPU架构(NUMA架构)对Redis性能影响
很多人为提升Redis网络性能,把os的网络中断处理程序和CPU核绑定。这可以避免网络中断处理程序在不同核上来回调度执行,的确能有效提升Redis网络处理性能。
但网络中断程序是要和Redis实例进行网络数据交互,一旦把网络中断程序绑核后,就得注意Redis实例绑在哪个核,这关乎Redis访问网络数据的效率。
Redis实例和网络中断程序的数据交互
- 网络中断处理程序从网卡硬件读数据
- 把数据写入os内核维护的一块内存缓冲区
- 内核会通过epoll触发事件,通知Redis实例
- Redis实例再把数据从内核的内存缓冲区拷贝到自己的内存空间:

CPU NUMA架构下,当网络中断处理程序、Redis实例分别和CPU核绑定后,就可能:
若网络中断处理程序和Redis实例所绑CPU核不在同一CPU Socket,则Redis实例读网络数据时,就需跨CPU Socket访问内存,这就花费较多时间。

上图中:
- 网络中断处理程序绑在CPU Socket 1某核
- Redis实例绑在CPU Socket 2
此时,网络中断处理程序读取到的网络数据,被保存在CPU Socket 1的本地内存,当Redis实例要访问网络数据时,就需Socket 2通过总线把内存访问命令发送到 Socket 1,进行远程访问,时间开销较大。
和访问CPU Socket本地内存相比,跨CPU Socket的内存访问延迟增加了18%,这自然会导致Redis处理请求的延迟增加。
所以,为避免Redis跨CPU Socket访问网络数据,最好把网络中断程序和Redis实例绑在同一个CPU Socket,这样Redis实例就可以直接从本地内存读取网络数据

CPU NUMA架构下,对CPU核的编号规则,并非先把一个CPU Socket中的所有逻辑核编完,再对下一个CPU Socket中的逻辑核编码,而是先给每个CPU Socket中每个物理核的第一个逻辑核依次编号,再给每个CPU Socket中的物理核的第二个逻辑核依次编号。
假设2个CPU Socket,每个Socket上有6个物理核,每个物理核又有2个逻辑核,总共24个逻辑核。我们可以执行lscpu命令,查看到这些核的编号:
shell
lscpu
Architecture: x86_64
...
NUMA node0 CPU(s): 0-5,12-17
NUMA node1 CPU(s): 6-11,18-23
...
可见,NUMA node0的CPU核编号是0到5、12到17。其中,0到5是node0上的6个物理核中的第一个逻辑核的编号,12到17是相应物理核中的第二个逻辑核编号。NUMA node1的CPU核编号规则和node0一样。
所以绑核时,要注意,不能想当然地认为第一个Socket上的12个逻辑核的编号就是0到11。否则,网络中断程序和Redis实例就可能绑在了不同的CPU Socket上。
比如说,如果我们把网络中断程序和Redis实例分别绑到编号为1和7的CPU核上,此时,它们仍然是在2个CPU Socket上,Redis实例仍然需要跨Socket读取网络数据。
所以,你一定要注意NUMA架构下CPU核的编号方法,这样才不会绑错核。
- CPU多核的场景下,用taskset命令把Redis实例和一个核绑定,可以减少Redis实例在不同核上被来回调度执行的开销,避免较高的尾延迟
- 多CPU的NUMA架构下,如果你对网络中断程序做了绑核操作,建议同时把Redis实例和网络中断程序绑在同一个CPU Socket的不同核上,避免Redis跨Socket访问内存中的网络数据的时间开销。
4 绑核的风险和解决方案
Redis除了主线程以外,还有用于RDB生成和AOF重写的子进程,Redis的后台线程。
当我们把Redis实例绑到一个CPU逻辑核上时,就会导致子进程、后台线程和Redis主线程竞争CPU资源,一旦子进程或后台线程占用CPU时,主线程就会被阻塞,导致Redis请求延迟增加。
针对这种情况,我来给你介绍两种解决方案,分别是一个Redis实例对应绑一个物理核和优化Redis源码。
方案一:一个Redis实例对应绑一个物理核
在给Redis实例绑核时,我们不要把一个实例和一个逻辑核绑定,而要和一个物理核绑定,也就是说,把一个物理核的2个逻辑核都用上。
我们还是以刚才的NUMA架构为例,NUMA node0的CPU核编号是0到5、12到17。其中,编号0和12、1和13、2和14等都是表示一个物理核的2个逻辑核。所以,在绑核时,我们使用属于同一个物理核的2个逻辑核进行绑核操作。例如,我们执行下面的命令,就把Redis实例绑定到了逻辑核0和12上,而这两个核正好都属于物理核1。
taskset -c 0,12 ./redis-server
和只绑一个逻辑核相比,把Redis实例和物理核绑定,可以让主线程、子进程、后台线程共享使用2个逻辑核,可以在一定程度上缓解CPU资源竞争。但是,因为只用了2个逻辑核,它们相互之间的CPU竞争仍然还会存在。如果你还想进一步减少CPU竞争,我再给你介绍一种方案。
方案二:优化Redis源码
修改Redis源码,把子进程和后台线程绑到不同的CPU核上。
这是通过编程实现绑核的一个通用做法。
编程实现绑核时,要用到操作系统提供的1个数据结构cpu_set_t和3个函数CPU_ZERO、CPU_SET和sched_setaffinity
- cpu_set_t数据结构:是一个位图,每一位用来表示服务器上的一个CPU逻辑核。
CPU_ZERO函数:以cpu_set_t结构的位图为输入参数,把位图中所有的位设置为0 - CPU_SET函数:以CPU逻辑核编号和cpu_set_t位图为参数,把位图中和输入的逻辑核编号对应的位设置为1
- sched_setaffinity函数:以进程/线程ID号和cpu_set_t为参数,检查cpu_set_t中哪一位为1,就把输入的ID号所代表的进程/线程绑在对应的逻辑核上。
那么,怎么在编程时把这三个函数结合起来实现绑核呢?很简单,我们分四步走就行。
第一步:创建一个cpu_set_t结构的位图变量;
第二步:使用CPU_ZERO函数,把cpu_set_t结构的位图所有的位都设置为0;
第三步:根据要绑定的逻辑核编号,使用CPU_SET函数,把cpu_set_t结构的位图相应位设置为1;
第四步:使用sched_setaffinity函数,把程序绑定在cpu_set_t结构位图中为1的逻辑核上。
下面,我就具体介绍下,分别把后台线程、子进程绑到不同的核上的做法。
后台线程,为线程绑核:
c
//线程函数
void worker(int bind_cpu){
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&cpu_set); //位图变量所有位设置0
CPU_SET(bind_cpu, &cpuset); //根据输入的bind_cpu编号,把位图对应为设置为1
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在cpu_set_t结构位图中为1的逻辑核
//实际线程函数工作
}
int main(){
pthread_t pthread1
//把创建的pthread1绑在编号为3的逻辑核上
pthread_create(&pthread1, NULL, (void *)worker, 3);
}
对于Redis来说,它是在bio.c文件中的bioProcessBackgroundJobs函数中创建了后台线程。bioProcessBackgroundJobs函数类似于刚刚的例子中的worker函数,在这个函数中实现绑核四步操作,就可以把后台线程绑到和主线程不同的核上了。
和给线程绑核类似,当我们使用fork创建子进程时,也可以把刚刚说的四步操作实现在fork后的子进程代码中:
c
int main(){
//用fork创建一个子进程
pid_t p = fork();
if(p < 0){
printf(" fork error\n");
}
//子进程代码部分
else if(!p){
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&cpu_set); //位图变量所有位设置0
CPU_SET(3, &cpuset); //把位图的第3位设置为1
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在3号逻辑核
//实际子进程工作
exit(0);
}
...
}
生成RDB和AOF日志重写的子进程分别是如下两个文件实现:
- rdb.c文件:rdbSaveBackground函数
- aof.c文件:rewriteAppendOnlyFileBackground函数。
俩函数中都调用fork创建子进程,所以,可在子进程代码部分加上绑核的四步操作。
源码优化方案,既可实现Redis实例绑核,避免切换核带来的性能影响,还可让子进程、后台线程和主线程不在同一核运行,避免了它们之间的CPU资源竞争。相比taskset绑核,可进一步降低绑核风险。
总结
多核CPU架构下,Redis若在不同核上运行,就需频繁上下文切换,就会增加Redis执行时间,客户端也会观察到较高的尾延迟。建议Redis运行时,把实例和某核绑定,就能重复利用核上的L1、L2缓存,降低响应延迟。
为提升Redis的网络性能,有时还会把网络中断处理程序和CPU核绑定。此时,若服务器用NUMA架构,Redis实例一旦被调度到和中断处理程序不在同一个CPU Socket,就要跨CPU Socket访问网络数据,这就会降低Redis性能。建议将Redis实例和网络中断处理程序绑在同一个CPU Socket下的不同核上,这样可以提升Redis的运行性能。
虽然绑核可以帮助Redis降低请求执行时间,但是,除了主线程,Redis还有用于RDB和AOF重写的子进程,以及4.0版本之后提供的用于惰性删除的后台线程。当Redis实例和一个逻辑核绑定后,这些子进程和后台线程会和主线程竞争CPU资源,也会对Redis性能造成影响。所以,我给了你两个建议:
若不想修改Redis代码,可按一个Redis实例一个物理核方式进行绑定,这样,Redis的主线程、子进程和后台线程可以共享使用一个物理核上的两个逻辑核。
如果你很熟悉Redis的源码,就可以在源码中增加绑核操作,把子进程和后台线程绑到不同的核上,这样可以避免对主线程的CPU资源竞争。不过,如果你不熟悉Redis源码,也不用太担心,Redis 6.0出来后,可以支持CPU核绑定的配置操作了。
Redis低延迟是永恒追求目标,而多核CPU和NUMA架构已成为目前服务器主流配置,掌握绑核优化方案并应用实践吧!