16-21节 如何避免Redis性能变慢

16-异步机制:如何避免单线程模型的阻塞?

影响Redis性能的5大方面的潜在因素,分别是:

  • Redis内部的阻塞式操作;
  • CPU核和NUMA架构的影响;
  • Redis关键系统配置;
  • Redis内存碎片;
  • Redis缓冲区。

Redis实例有哪些阻塞点?

Redis实例在运行时,要和许多对象进行交互,这些不同的交互就会涉及不同的操作,下面我们来看看和Redis实例交互的对象,以及交互时会发生的操作。

  • 客户端:网络IO,键值对增删改查操作,数据库操作;
  • 磁盘:生成RDB快照,记录AOF日志,AOF日志重写;
  • 主从节点:主库生成、传输RDB文件,从库接收RDB文件、清空数据库、加载RDB文件;
  • 切片集群实例:向其他实例传输哈希槽信息,数据迁移。

1.和客户端交互时的阻塞点

复杂度高【看操作的复杂度是否为O(N)】的增删改查操作肯定会阻塞Redis。
Redis中涉及集合的操作复杂度通常为O(N),我们要在使用时重视起来。例如集合元素全量查询操作HGETALL、SMEMBERS,以及集合的聚合统计操作,例如求交、并和差集。这些操作可以作为Redis的第一个阻塞点:集合全量查询和聚合操作。

【删除(释放内存)也会容易阻塞主线程】在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序,所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成Redis主线程的阻塞。
bigkey删除操作就是Redis的第二个阻塞点。

在Redis的数据库级别操作中,清空数据库(例如FLUSHDB和FLUSHALL操作)必然也是一个潜在的阻塞风险,因为它涉及到删除和释放所有的键值对。所以,这就是Redis的第三个阻塞点:清空数据库。

2.和磁盘交互时的阻塞点

Redis直接记录AOF日志时,会根据不同的写回策略对数据做落盘保存。一个同步写磁盘的操作的耗时大约是1~2ms,如果有大量的写操作需要记录在AOF日志中,并同步写回的话,就会阻塞主线程了。这就得到了Redis的第四个阻塞点了:AOF日志同步写。

3.主从节点交互时的阻塞点

在主从集群中,主库需要生成RDB文件,并传输给从库。主库在复制的过程中,创建和传输RDB文件都是由子进程来完成的,不会阻塞主线程。但是,对于从库来说,它在接收了RDB文件后,需要使用FLUSHDB命令清空当前数据库,这就正好撞上了刚才我们分析的第三个阻塞点。

此外,从库在清空当前数据库后,还需要把RDB文件加载到内存,这个过程的快慢和RDB文件的大小密切相关,RDB文件越大,加载过程越慢,所以,加载RDB文件就成为了Redis的第五个阻塞点。

4.切片集群实例交互时的阻塞点

最后,当我们部署Redis切片集群时,每个Redis实例上分配的哈希槽信息需要在不同实例间进行传递,同时,当需要进行负载均衡或者有实例增删时,数据会在不同的实例间进行迁移。不过,哈希槽的信息量不大,而数据迁移是渐进式执行的,所以,一般来说,这两类操作对Redis主线程的阻塞风险不大。

不过,如果你使用了Redis Cluster方案,而且同时正好迁移的是bigkey的话,就会造成主线程的阻塞,因为Redis Cluster使用了同步迁移。我将在第33讲中向你介绍不同切片集群方案对数据迁移造成的阻塞的解决方法,这里你只需要知道,当没有bigkey时,切片集群的各实例在进行交互时不会阻塞主线程,就可以了。

我们来总结下刚刚找到的五个阻塞点:

  • 集合全量查询和聚合操作;
  • bigkey删除;
  • 清空数据库;
  • AOF日志同步写;
  • 从库加载RDB文件。

为了避免阻塞式操作,Redis提供了异步线程机制。所谓的异步线程机制,就是指,Redis会启动一些子线程,然后把一些任务交给这些子线程,让它们在后台完成,而不再由主线程来执行这些任务。

哪些阻塞点可以异步执行?

(看是返回OK就可以,还是需要返回具体的数据/提供服务,例如对于Redis来说,读操作是典型的关键路径操作

对于Redis的五大阻塞点来说,除了“集合全量查询和聚合操作”和“从库加载RDB文件”,其他三个阻塞点涉及的操作都不在关键路径上,所以,我们可以使用Redis的异步子线程机制来实现bigkey删除,清空数据库,以及AOF日志同步写。

异步的子线程机制

Redis主线程启动后,会使用操作系统提供的pthread_create函数创建3个子线程,分别由它们负责AOF日志写操作、键值对删除以及文件关闭的异步执行。

主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。

但实际上,这个时候删除还没有执行,【等到后台子线程从任务队列中读取任务后,才开始实际删除键值对】,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)。此时,删除或清空操作不会阻塞主线程,这就避免了对主线程的性能影响。

和惰性删除类似,当AOF日志配置成everysec选项后,主线程会把AOF写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入AOF日志,这样主线程就不用一直等待AOF日志写完了。

有个地方需要你注意一下,异步的键值对删除和数据库清空操作是Redis 4.0后提供的功能,Redis也提供了新的命令来执行这两个操作。

  • 键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用UNLINK命令。
  • 清空数据库:可以在FLUSHDB和FLUSHALL命令后加上ASYNC选项,这样就可以让后台子线程异步地清空数据库

小结

异步删除操作是Redis 4.0以后才有的功能,如果你使用的是4.0之前的版本,当你遇到bigkey删除时,我给你个小建议:先使用集合类型提供的SCAN命令读取数据,然后再进行删除。
对于Hash类型的bigkey删除,你可以使用HSCAN命令,每次从Hash集合中获取一部分键值对(例如200个),再使用HDEL删除这些键值对

集合全量查询和聚合操作、从库加载RDB文件是在关键路径上,无法使用异步操作来完成。对于这两个阻塞点,我也给你两个小建议。

  • 集合全量查询和聚合操作:可以使用SCAN命令,分批读取数据,再在客户端进行聚合计算;
  • 从库加载RDB文件:把主库的数据量大小控制在2~4GB左右,以保证RDB文件能以较快的速度加载

课后问题

17-为什么CPU结构也会影响Redis的性能?

一个CPU处理器中一般有多个运行核心,我们把一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核都拥有私有的一级缓存(Level 1 cache,简称L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称L2 cache)。

不同的物理核还会共享一个共同的三级缓存(Level 3 cache,简称为L3 cache)。L3缓存能够使用的存储资源比较多,所以一般比较大,能达到几MB到几十MB

现在主流的CPU处理器中,每个物理核通常都会运行两个超线程,也叫作逻辑核。同一个物理核的逻辑核会共享使用L1、L2缓存。

在主流的服务器上,一个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多核架构和多CPU架构,我们来简单总结下CPU架构对应用程序运行的影响。

  • L1、L2缓存中的指令和数据的访问速度很快,所以,充分利用L1、L2缓存,可以有效缩短应用程序的执行时间;
  • 在NUMA架构下,如果应用程序从一个Socket上调度到另一个Socket上,就可能会出现远端内存访问的情况,这会直接增加应用程序的执行时间。

CPU多核对Redis性能的影响

在一个CPU核上运行时,应用程序需要记录自身使用的软硬件资源信息(例如栈指针、CPU核的寄存器值等),我们把这些信息称为运行时信息。同时,应用程序访问最频繁的指令和数据还会被缓存到L1、L2缓存上,以便提升执行速度。

但是,在多核CPU的场景下,一旦应用程序需要在一个新的CPU核上运行,那么,运行时信息就需要重新加载到新的CPU核上。而且,新的CPU核的L1、L2缓存也需要重新加载数据和指令,这会导致程序的运行时间增加。
如果在CPU多核场景下,Redis实例被频繁调度到不同CPU核上运行的话,那么,对Redis实例的请求处理时间影响就更大了。每调度一次,一些请求就会受到运行时信息、指令和数据重新加载过程的影响,这就会导致某些请求的延迟明显高于其他请求。 分析到这里,我们就知道了刚刚的例子中99%尾延迟的值始终降不下来的原因。

所以,我们要避免Redis总是在不同CPU核上来回调度执行。于是,我们尝试着把Redis实例和CPU核绑定了,让一个Redis实例固定运行在一个CPU核上。我们可以使用taskset命令把一个程序绑定在一个核上运行。

CPU的NUMA架构对Redis性能的影响

在实际应用Redis时,我经常看到一种做法,为了提升Redis的网络性能,把操作系统的网络中断处理程序和CPU核绑定。这个做法可以避免网络中断处理程序在不同核上来回调度执行,的确能有效提升Redis的网络处理性能。

但是,网络中断程序是要和Redis实例进行网络数据交互的,一旦把网络中断程序绑核后,我们就需要注意Redis实例是绑在哪个核上了,这会关系到Redis访问网络数据的效率高低。

我们先来看下Redis实例和网络中断程序的数据交互:网络中断处理程序从网卡硬件中读取数据,并把数据写入到操作系统内核维护的一块内存缓冲区。内核会通过epoll机制触发事件,通知Redis实例,Redis实例再把数据从内核的内存缓冲区拷贝到自己的内存空间,如下图所示:

那么,在CPU的NUMA架构下,当网络中断处理程序、Redis实例分别和CPU核绑定后,就会有一个潜在的风险:如果网络中断处理程序和Redis实例各自所绑的CPU核不在同一个CPU Socket上,那么,Redis实例读取网络数据时,就需要跨CPU Socket访问内存,这个过程会花费较多时间。 如下图

为了避免Redis跨CPU Socket访问网络数据,我们最好把网络中断程序和Redis实例绑在同一个CPU Socket上,这样一来,Redis实例就可以直接从本地内存读取网络数据了,如下图所示:

不过,需要注意的是,在CPU的NUMA架构下,对CPU核的编号规则,并不是先把一个CPU Socket中的所有逻辑核编完,再对下一个CPU Socket中的逻辑核编码,而是先给每个CPU Socket中每个物理核的第一个逻辑核依次编号,再给每个CPU Socket中的物理核的第二个逻辑核依次编号。

所以,你一定要注意NUMA架构下CPU核的编号方法,这样才不会绑错核。

不过,“硬币都是有两面的”,绑核也存在一定的风险。接下来,我们就来了解下它的潜在风险点和解决方案。

绑核的风险和解决方案

Redis除了主线程以外,还有用于RDB生成和AOF重写的子进程(可以回顾看下第4讲和第5讲)。此外,我们还在第16讲学习了Redis的后台线程。

当我们把Redis实例绑到一个CPU逻辑核上时,就会导致子进程、后台线程和Redis主线程竞争CPU资源,一旦子进程或后台线程占用CPU时,主线程就会被阻塞,导致Redis请求延迟增加。

针对这种情况,我来给你介绍两种解决方案,分别是一个Redis实例对应绑一个物理核和优化Redis源码。

方案一:一个Redis实例对应绑一个物理核

在给Redis实例绑核时,我们不要把一个实例和一个逻辑核绑定,而要和一个物理核绑定,也就是说,把一个物理核的2个逻辑核都用上。

1
taskset -c 0,12 ./redis-server

和只绑一个逻辑核相比,把Redis实例和物理核绑定,可以让主线程、子进程、后台线程共享使用2个逻辑核,可以在一定程度上缓解CPU资源竞争。但是,因为只用了2个逻辑核,它们相互之间的CPU竞争仍然还会存在。如果你还想进一步减少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的逻辑核上。
    下面,我就具体介绍下,分别把后台线程、子进程绑到不同的核上的做法。
    先说后台线程。为了让你更好地理解编程实现绑核,你可以看下这段示例代码,它实现了为线程绑核的操作:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //线程函数
    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后的子进程代码中,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
}
...
}

对于Redis来说,生成RDB和AOF日志重写的子进程分别是下面两个文件的函数中实现的。

  • rdb.c文件:rdbSaveBackground函数;
  • aof.c文件:rewriteAppendOnlyFileBackground函数。

这两个函数中都调用了fork创建子进程,所以,我们可以在子进程代码部分加上绑核的四步操作。

使用源码优化方案,我们既可以实现Redis实例绑核,避免切换核带来的性能影响,还可以让子进程、后台线程和主线程不在同一个核上运行,避免了它们之间的CPU资源竞争。相比使用taskset绑核来说,这个方案可以进一步降低绑核的风险。

课后问题


另外,在切片集群中,不同实例间通过网络进行消息通信和数据迁移,并不会使用共享内存空间进行跨实例的数据访问。所以,即使把不同的实例部署到不同的Socket上,它们之间也不会发生跨Socket内存的访问,不会受跨Socket内存访问的负面影响。

18-波动的响应延迟:如何应对变慢的Redis?(上)

应用服务器(App Server)要完成一个事务性操作,包括在MySQL上执行一个写事务,在Redis上插入一个标记位,并通过一个第三方服务给用户发送一条完成消息。

这三个操作都需要保证事务原子性,所以,如果此时Redis的延迟增加,就会拖累App Server端整个事务的执行。这个事务一直完成不了,又会导致MySQL上写事务占用的资源无法释放,进而导致访问MySQL的其他请求被阻塞。很明显,Redis变慢会带来严重的连锁反应。

Redis真的变慢了吗?
在实际解决问题之前,我们首先要弄清楚,如何判断Redis是不是真的变慢了。

一个最直接的方法,就是查看Redis的响应延迟
【延迟“毛刺”】这种方法是看Redis延迟的绝对值,但是,在不同的软硬件环境下,Redis本身的绝对性能并不相同。

第二个方法了,也就是基于当前环境下的Redis基线性能做判断。所谓的基线性能呢,也就是一个系统在低压力、无干扰下的基本性能,这个性能只由当前的软硬件配置决定。

从2.8.7版本开始,redis-cli命令提供了–intrinsic-latency选项,可以用来监测和统计测试期间内的最大延迟,这个延迟可以作为Redis的基线性能。其中,测试时长可以用–intrinsic-latency选项的参数来指定。

举个例子,比如说,我们运行下面的命令,该命令会打印120秒内监测到的最大延迟。

1
2
3
4
5
6
7
8
9
./redis-cli --intrinsic-latency 120
Max latency so far: 17 microseconds.
Max latency so far: 44 microseconds.
Max latency so far: 94 microseconds.
Max latency so far: 110 microseconds.
Max latency so far: 119 microseconds.

36481658 total runs (avg latency: 3.2893 microseconds / 3289.32 nanoseconds per run).
Worst run took 36x longer than the average latency.

不过,我们通常是通过客户端和网络访问Redis服务,为了避免网络对基线性能的影响,刚刚说的这个命令需要在服务器端直接运行,这也就是说,我们只考虑服务器端软硬件环境的影响。

如果你想了解网络对Redis性能的影响,一个简单的方法是用iPerf这样的工具,测量从Redis客户端到服务器端的网络延迟。如果这个延迟有几十毫秒甚至是几百毫秒,就说明,Redis运行的网络环境中很可能有大流量的其他应用程序在运行,导致网络拥塞了。这个时候,你就需要协调网络运维,调整网络的流量分配了。

如何应对Redis变慢?

这是我们在第一节课画的Redis架构图。你可以重点关注下我在图上新增的红色模块,也就是Redis自身的操作特性、文件系统和操作系统,它们是影响Redis性能的三大要素。


这节课我先给你介绍Redis的自身操作特性的影响,下节课我们再重点研究操作系统和文件系统的影响。

Redis自身操作特性的影响

首先,我们来学习下Redis提供的键值对命令操作对延迟性能的影响。我重点介绍两类关键操作:慢查询命令和过期key操作。

1.慢查询命令

Redis提供的命令操作很多,并不是所有命令都慢,这和命令操作的复杂度有关。

当Value类型为Set时,SORT、SUNION/SMEMBERS操作复杂度分别为O(N+M*log(M))和O(N)。其中,N为Set中的元素个数,M为SORT操作返回的元素个数。
当你发现Redis性能变慢时,可以通过Redis日志,或者是latency monitor工具,查询变慢的请求,根据请求对应的具体命令以及官方文档,确认下是否采用了复杂度高的慢查询命令。

如果的确有大量的慢查询命令,有两种处理方式:

  1. 用其他高效命令代替。 比如说,如果你需要返回一个SET中的所有成员时,不要使用SMEMBERS命令,而是要使用SSCAN多次迭代返回,避免一次返回大量数据,造成线程阻塞。
  2. 当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用SORT、SUNION、SINTER这些命令,以免拖慢Redis实例。

当然,如果业务逻辑就是要求使用慢查询命令,那你得考虑采用性能更好的CPU,更快地完成查询命令,避免慢查询的影响。

还有一个比较容易忽略的慢查询命令,就是KEYS。它用于返回和输入模式匹配的所有key,例如,以下命令返回所有包含“name”字符串的keys。

1
2
3
redis> KEYS *name*
1) "lastname"
2) "firstname"

因为KEYS命令需要遍历存储的键值对,所以操作延时高。如果你不了解它的实现而使用了它,就会导致Redis性能变慢。所以,KEYS命令一般不被建议用于生产环境中。

2.过期key操作

接下来,我们来看过期key的自动删除机制。它是Redis用来回收内存空间的常用机制,应用广泛,本身就会引起Redis操作阻塞,导致性能变慢,所以,你必须要知道该机制对性能的影响。

Redis键值对的key可以设置过期时间。默认情况下,Redis每100毫秒会删除一些过期key,具体的算法如下:

采样ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP个数的key,并将其中过期的key全部删除;
如果超过25%的key过期了,则重复删除的过程,直到过期key的比例降至25%以下。
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP是Redis的一个参数,默认是20,那么,一秒内基本有200个过期key会被删除。这一策略对清除过期key、释放内存空间很有帮助。如果每秒钟删除200个过期key,并不会对Redis造成太大影响。

但是,如果触发了上面这个算法的第二条,Redis就会一直删除以释放内存空间。注意,删除操作是阻塞的(Redis 4.0后可以用异步线程机制来减少阻塞影响)。所以,一旦该条件触发,Redis的线程就会一直执行删除,这样一来,就没办法正常服务其他的键值操作了,就会进一步引起其他键值操作的延迟增加,Redis就会变慢。

那么,算法的第二条是怎么被触发的呢?其中一个重要来源,就是频繁使用带有相同时间参数的EXPIREAT命令设置过期key,这就会导致,在同一秒内有大量的key同时过期。

现在,我就要给出第二条排查建议和解决方法了。

你要检查业务代码在使用EXPIREAT命令设置key过期时间时,是否使用了相同的UNIX时间戳,有没有使用EXPIRE命令给批量的key设置相同的过期秒数。因为,这都会造成大量key在同一时间过期,导致性能变慢。

遇到这种情况时,【千万不要嫌麻烦】,你首先要根据实际业务的使用需求,决定EXPIREAT和EXPIRE的过期时间参数。其次,如果一批key的确是同时过期,你还可以在EXPIREAT和EXPIRE的过期时间参数上,加上一个一定大小范围内的随机数,这样,既保证了key在一个邻近时间范围内被删除,又避免了同时过期造成的压力。

小结

重点介绍了判断Redis变慢的方法,一个是看响应延迟,一个是看基线性能。同时,我还给了你两种排查和解决Redis变慢这个问题的方法:

  • 从慢查询命令开始排查,并且根据业务需求替换慢查询命令;
  • 排查过期key的时间设置,并根据实际使用需求,设置不同的过期时间。

课后问题

19-波动的响应延迟:如何应对变慢的Redis?(下)如果上节课的方法不管用,那就说明,你要关注影响性能的其他机制了,也就是文件系统和操作系统。

Redis会持久化保存数据到磁盘,这个过程要依赖文件系统来完成,所以,文件系统将数据写回磁盘的机制,会直接影响到Redis持久化的效率。而且,在持久化的过程中,Redis也还在接收其他请求,持久化的效率高低又会影响到Redis处理请求的性能。

另一方面,Redis是内存数据库,内存操作非常频繁,所以,操作系统的内存机制会直接影响到Redis的处理效率。比如说,如果Redis的内存不够用了,操作系统会启动swap机制,这就会直接拖慢Redis。

那么,接下来,我再从这两个层面,继续给你介绍,如何进一步解决Redis变慢的问题。

文件系统:AOF模式

为了保证数据可靠性,Redis会采用AOF日志或RDB快照。其中,AOF日志提供了三种日志写回策略:no、everysec、always。这三种写回策略依赖文件系统的两个系统调用完成,也就是write和fsync。

write只要把日志记录写到内核缓冲区,就可以返回了,并不需要等待日志实际写回到磁盘;而fsync需要把日志记录写回到磁盘后才能返回,时间较长。下面这张表展示了三种写回策略所执行的系统调用。

当写回策略配置为everysec和always时,Redis需要调用fsync把日志写回磁盘。但是,这两种写回策略的具体执行情况还不太一样。

在使用everysec时,Redis允许丢失一秒的操作记录,所以,Redis主线程并不需要确保每个操作记录日志都写回磁盘。而且,fsync的执行时间很长,如果是在Redis主线程中执行fsync,就容易阻塞主线程。所以,当写回策略配置为everysec时,Redis会使用后台的子线程异步完成fsync的操作

而对于always策略来说,Redis需要确保每个操作记录日志都写回磁盘,如果用后台子线程异步完成,主线程就无法及时地知道每个操作是否已经完成了,这就不符合always策略的要求了。所以,always策略并不使用后台子线程来执行

另外,在使用AOF日志时,为了避免日志文件不断增大,Redis会执行AOF重写,生成体量缩小的新的AOF日志文件。AOF重写本身需要的时间很长,也容易阻塞Redis主线程,所以,Redis使用子进程来进行AOF重写。

但是,这里有一个潜在的风险点:AOF重写会对磁盘进行大量IO操作,同时,fsync又需要等到数据写到磁盘后才能返回,所以,当AOF重写的压力比较大时,就会导致fsync被阻塞。虽然fsync是由后台子线程负责执行的,但是,【主线程会监控fsync的执行进度】

当主线程使用后台子线程执行了一次fsync,需要再次把新接收的操作记录写回磁盘时,如果主线程发现上一次的fsync还没有执行完,那么它就会阻塞。所以,如果后台子线程执行的fsync频繁阻塞的话(比如AOF重写占用了大量的磁盘IO带宽),主线程也会阻塞,导致Redis性能变慢。
如果AOF写回策略使用了everysec或always配置,请先确认下业务方对数据可靠性的要求,明确是否需要每一秒或每一个操作都记日志。有的业务方不了解Redis AOF机制,很可能就直接使用数据可靠性最高等级的always配置了。其实,在有些场景中(例如Redis用于缓存),数据丢了还可以从后端数据库中获取,并不需要很高的数据可靠性。

如果业务应用对延迟非常敏感,但同时允许一定量的数据丢失,那么,可以把配置项no-appendfsync-on-rewrite设置为yes,如下所示:

no-appendfsync-on-rewrite yes
这个配置项设置为yes时,表示在AOF重写时,不进行fsync操作。也就是说,Redis实例把写命令写到内存后,不调用后台线程进行fsync操作,就可以直接返回了。当然,如果此时实例发生宕机,就会导致数据丢失。反之,如果这个配置项设置为no(也是默认配置),在AOF重写时,Redis实例仍然会调用后台线程进行fsync操作,这就会给实例带来阻塞。

如果的确需要高性能,同时也需要高可靠数据保证,我建议你考虑采用高速的固态硬盘作为AOF日志的写入设备。

高速固态盘的带宽和并发度比传统的机械硬盘的要高出10倍及以上。在AOF重写和fsync后台线程同时执行时,固态硬盘可以提供较为充足的磁盘IO资源,让AOF重写和fsync后台线程的磁盘IO资源竞争减少,从而降低对Redis的性能影响。

操作系统:swap

内存swap是操作系统里将内存数据在内存和磁盘间来回换入和换出的机制,涉及到磁盘的读写,所以,一旦触发swap,无论是被换入数据的进程,还是被换出数据的进程,其性能都会受到慢速磁盘读写的影响。

正常情况下,Redis的操作是直接通过访问内存就能完成,一旦swap被触发了,Redis的请求操作需要等到磁盘数据读写完成才行。而且,和我刚才说的AOF日志文件读写使用fsync线程不同,swap触发后影响的是Redis主IO线程,这会极大地增加Redis的响应时间。

通常,触发swap的原因主要是物理机器内存不足,对于Redis而言,有两种常见的情况:

  • Redis实例自身使用了大量的内存,导致物理机器的可用内存不足;
  • 和Redis实例在同一台机器上运行的其他进程,在进行大量的文件读写操作。文件读写本身会占用系统内存,这会导致分配给Redis实例的内存量变少,进而触发Redis发生swap。

针对这个问题,我也给你提供一个解决思路:增加机器的内存或者使用Redis集群。

操作系统本身会在后台记录每个进程的swap使用情况,即有多少数据量发生了swap。你可以先通过下面的命令查看Redis的进程号,这里是5332。

1
2
$ redis-cli info | grep process_id
process_id: 5332

然后,进入Redis所在机器的/proc目录下的该进程目录中:

1
$ cd /proc/5332

最后,运行下面的命令,查看该Redis进程的使用情况。在这儿,我只截取了部分结果:

1
2
3
4
5
6
7
8
9
10
11
$cat smaps | egrep '^(Swap|Size)'
Size: 584 kB
Swap: 0 kB
Size: 4 kB
Swap: 4 kB
Size: 4 kB
Swap: 0 kB
Size: 462044 kB
Swap: 462008 kB
Size: 21392 kB
Swap: 0 kB

你可以看到有很多Size行,有的很小,就是4KB,而有的很大,例如462044KB。不同内存块被换出到磁盘上的大小也不一样

这里有个重要的地方,我得提醒你一下,当出现百MB,甚至GB级别的swap大小时,就表明,此时,Redis实例的内存压力很大,很有可能会变慢。所以,swap的大小是排查Redis性能变慢是否由swap引起的重要指标。

一旦发生内存swap,最直接的解决方法就是增加机器内存。如果该实例在一个Redis切片集群中,可以增加Redis集群的实例个数,来分摊每个实例服务的数据量,进而减少每个实例所需的内存量。

当然,如果Redis实例和其他操作大量文件的程序(例如数据分析程序)共享机器,你可以将Redis实例迁移到单独的机器上运行,以满足它的内存需求量。如果该实例正好是Redis主从集群中的主库,而从库的内存很大,也可以考虑进行主从切换,把大内存的从库变成主库,由它来处理客户端请求。

操作系统:内存大页

除了内存swap,还有一个和内存相关的因素,即内存大页机制(Transparent Huge Page, THP),也会影响Redis性能。

Linux内核从2.6.38开始支持内存大页机制,该机制支持2MB大小的内存页分配,而常规的内存页分配是按4KB的粒度来执行的。

很多人都觉得:“Redis是内存数据库,内存大页不正好可以满足Redis的需求吗?而且在分配相同的内存量时,内存大页还能减少分配次数,不也是对Redis友好吗?”

其实,系统的设计通常是一个取舍过程,我们称之为trade-off。很多机制通常都是优势和劣势并存的。Redis使用内存大页就是一个典型的例子。

虽然内存大页可以给Redis带来内存分配方面的收益,但是,不要忘了,Redis为了提供数据可靠性保证,需要将数据做持久化保存。这个写入过程由额外的线程执行,所以,此时,Redis主线程仍然可以接收客户端写请求。客户端的写请求可能会修改正在进行持久化的数据。在这一过程中,Redis就会采用【写时复制】机制,也就是说,一旦有数据要被修改,Redis并不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。

如果采用了内存大页,那么,即使客户端请求只修改100B的数据,Redis也需要拷贝2MB的大页。相反,如果是常规内存页机制,只用拷贝4KB。两者相比,你可以看到,当客户端请求修改或新写入数据较多时,内存大页机制将导致大量的拷贝,这就会影响Redis正常的访存操作,最终导致性能变慢。

那该怎么办呢?很简单,关闭内存大页,就行了。

首先,我们要先排查下内存大页。方法是:在Redis实例运行的机器上执行如下命令:

1
cat /sys/kernel/mm/transparent_hugepage/enabled

如果执行结果是always,就表明内存大页机制被启动了;如果是never,就表示,内存大页机制被禁止。

在实际生产环境中部署时,我建议你不要使用内存大页机制,操作也很简单,只需要执行下面的命令就可以了:

1
echo never /sys/kernel/mm/transparent_hugepage/enabled

小结

这节课,我从文件系统和操作系统两个维度,给你介绍了应对Redis变慢的方法。

为了方便你应用,我给你梳理了一个包含9个检查点的Checklist,希望你在遇到Redis性能变慢时,按照这些步骤逐一检查,高效地解决问题。

  1. 获取Redis实例在当前环境下的基线性能。
  2. 是否用了慢查询命令?如果是的话,就使用其他命令替代慢查询命令,或者把聚合计算命令放在客户端做。
  3. 是否对过期key设置了相同的过期时间?对于批量删除的key,可以在每个key的过期时间上加一个随机数,避免同时删除。
  4. 是否存在bigkey? 对于bigkey的删除操作,如果你的Redis是4.0及以上的版本,可以直接利用异步线程机制减少主线程阻塞;如果是Redis 4.0以前的版本,可以使用SCAN命令迭代删除;对于bigkey的集合查询和聚合操作,可以使用SCAN命令在客户端完成。
  5. Redis AOF配置级别是什么?业务层面是否的确需要这一可靠性级别?如果我们需要高性能,同时也允许数据丢失,可以将配置项no-appendfsync-on-rewrite设置为yes,避免AOF重写和fsync竞争磁盘IO资源,导致Redis延迟增加。当然, 如果既需要高性能又需要高可靠性,最好使用高速固态盘作为AOF日志的写入盘。
  6. Redis实例的内存使用是否过大?发生swap了吗?如果是的话,就增加机器内存,或者是使用Redis集群,分摊单机Redis的键值对数量和内存压力。同时,要避免出现Redis和其他内存需求大的应用共享机器的情况。
  7. 在Redis实例的运行环境中,是否启用了透明大页机制?如果是的话,直接关闭内存大页机制就行了。
  8. 是否运行了Redis主从集群?如果是的话,把主库实例的数据量大小控制在2~4GB,以免主从复制时,从库因加载大的RDB文件而阻塞。
  9. 是否使用了多核CPU或NUMA架构的机器运行Redis实例?使用多核CPU时,可以给Redis实例绑定物理核;使用NUMA架构时,注意把Redis实例和网络中断处理程序运行在同一个CPU Socket上。

课后提问

你遇到过Redis变慢的情况吗?如果有的话,你是怎么解决的呢?

20-删除数据后,为什么内存占用率还是很高?

这节课,我就和你聊聊Redis的内存空间存储效率问题,探索一下,为什么数据已经删除了,但内存却闲置着没有用,以及相应的解决方案。

什么是内存碎片?

应用申请的是一块连续地址空间的N字节,但在剩余的内存空间中,没有大小为N字节的连续空间了,那么,这些剩余空间就是内存碎片(比如上图中的“空闲2字节”和“空闲1字节”,就是这样的碎片)。

内存碎片是如何形成的?

其实,内存碎片的形成有内因和外因两个层面的原因。简单来说,内因是操作系统的内存分配机制,外因是Redis的负载特征。

内因:内存分配器的分配策略

内存分配器的分配策略就决定了操作系统无法做到“按需分配”。这是因为,内存分配器一般是按固定大小来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配。

Redis可以使用libc、jemalloc、tcmalloc多种内存分配器来分配内存,默认使用jemalloc。接下来,我就以jemalloc为例,来具体解释一下。其他分配器也存在类似的问题。

jemalloc的分配策略之一,是按照一系列固定的大小划分内存空间,例如8字节、16字节、32字节、48字节,…, 2KB、4KB、8KB等。当程序申请的内存最接近某个固定值时,jemalloc会给它分配相应大小的空间。

这样的分配方式本身是为了减少分配次数。例如,Redis申请一个20字节的空间保存数据,jemalloc就会分配32字节,此时,如果应用还要写入10字节的数据,Redis就不用再向操作系统申请空间了,因为刚才分配的32字节已经够用了,这就避免了一次分配操作。

但是,如果Redis每次向分配器申请的内存空间大小不一样,这种分配方式就会有形成碎片的风险,而这正好来源于Redis的外因了。

外因:键值对大小不一样和删改操作

第一个外因:不同业务应用的数据都可能保存在Redis中,这就会带来不同大小的键值对。这样一来,Redis申请内存空间分配时,本身就会有大小不一的空间需求。

第二个外因是,这些键值对会被修改和删除,这会导致空间的扩容和释放。具体来说,一方面,如果修改后的键值对变大或变小了,就需要占用额外的空间或者释放不用的空间。另一方面,删除的键值对就不再需要内存空间了,此时,就会把空间释放出来,形成空闲空间。

如何判断是否有内存碎片?

为了让用户能监控到实时的内存使用情况,Redis自身提供了INFO命令,可以用来查询内存使用的详细信息,命令如下:

1
INFO memory

这里有一个mem_fragmentation_ratio的指标,它表示的就是Redis当前的内存碎片率。那么,这个碎片率是怎么计算的呢?其实,就是上面的命令中的两个指标used_memory_rss和used_memory相除的结果。

mem_fragmentation_ratio = used_memory_rss/ used_memory

used_memory_rss是操作系统实际分配给Redis的物理内存空间,里面就包含了碎片;而used_memory是Redis为了保存数据实际申请使用的空间。

我简单举个例子。例如,Redis申请使用了100字节(used_memory),操作系统实际分配了128字节(used_memory_rss),此时,mem_fragmentation_ratio就是1.28。

那么,知道了这个指标,我们该如何使用呢?在这儿,我提供一些经验阈值:

  • mem_fragmentation_ratio 大于1但小于1.5。这种情况是合理的。这是因为,刚才我介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由Redis负载决定,也无法限制。所以,存在内存碎片也是正常的。
  • mem_fragmentation_ratio 大于 1.5 。这表明内存碎片率已经超过了50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了。

如何清理内存碎片?

当Redis发生内存碎片后,一个“简单粗暴”的方法就是重启Redis实例。当然,这并不是一个“优雅”的方法,毕竟,重启Redis会带来两个后果:

如果Redis中的数据没有持久化,那么,数据就会丢失;
即使Redis数据持久化了,我们还需要通过AOF或RDB进行恢复,恢复时长取决于AOF或RDB的大小,如果只有一个Redis实例,恢复阶段无法提供服务。

幸运的是,从4.0-RC3版本以后,Redis自身提供了一种内存碎片自动清理的方法,我们先来看这个方法的基本机制。

内存碎片清理,简单来说,就是“搬家让位,合并空间”。

不过,需要注意的是:碎片清理是有代价的,操作系统需要把多份数据拷贝到新位置,把原有空间释放出来,这会带来时间开销。因为Redis是单线程,在数据拷贝时,Redis只能等着,这就导致Redis无法及时处理请求,性能就会降低。而且,有的时候,数据拷贝还需要注意顺序,就像刚刚说的清理内存碎片的例子,操作系统需要先拷贝D,并释放D的空间后,才能拷贝B。这种对顺序性的要求,会进一步增加Redis的等待时间,导致性能降低。

那么,有什么办法可以尽量缓解这个问题吗?这就要提到,Redis专门为自动内存碎片清理功机制设置的参数了。我们可以通过设置参数,来控制碎片清理的开始和结束时机,以及占用的CPU比例,从而减少碎片清理对Redis本身请求处理的性能影响。

首先,Redis需要启用自动内存碎片清理,可以把activedefrag配置项设置为yes,命令如下:

1
config set activedefrag yes

这个命令只是启用了自动清理功能,但是,具体什么时候清理,会受到下面这两个参数的控制。这两个参数分别设置了触发内存清理的一个条件,如果同时满足这两个条件,就开始清理。在清理的过程中,只要有一个条件不满足了,就停止自动清理。

  • active-defrag-ignore-bytes 100mb:表示内存碎片的字节数达到100MB时,开始清理;
  • active-defrag-threshold-lower 10:表示内存碎片空间占操作系统分配给Redis的总空间比例达到10%时,开始清理。

为了尽可能减少碎片清理对Redis正常请求处理的影响,自动内存碎片清理功能在执行时,还会监控清理操作占用的CPU时间,而且还设置了两个参数,分别用于控制清理操作占用的CPU时间比例的上、下限,既保证清理工作能正常进行,又避免了降低Redis性能。这两个参数具体如下:

  • active-defrag-cycle-min 25: 表示自动清理过程所用CPU时间的比例不低于25%,保证清理能正常开展;
  • active-defrag-cycle-max 75:表示自动清理过程所用CPU时间的比例不高于75%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞Redis,导致响应延迟升高。

自动内存碎片清理机制在控制碎片清理启停的时机上,既考虑了碎片的空间占比、对Redis内存使用效率的影响,还考虑了清理机制本身的CPU时间占比、对Redis性能的影响。而且,清理机制还提供了4个参数,让我们可以根据实际应用中的数据量需求和性能要求灵活使用,建议你在实践中好好地把这个机制用起来。

小结

这节课,我和你一起了解了Redis的内存空间效率问题,这里面的一个关键技术点就是要识别和处理内存碎片。简单来说,就是“三个一”:

  • info memory命令是一个好工具,可以帮助你查看碎片率的情况;
  • 碎片率阈值是一个好经验,可以帮忙你有效地判断是否要进行碎片清理了;
  • 内存碎片自动清理是一个好方法,可以避免因为碎片导致Redis的内存实际利用率降低,提升成本收益率。
  • 内存碎片并不可怕,我们要做的就是了解它,重视它,并借用高效的方法解决它。

最后,我再给你提供一个小贴士:内存碎片自动清理涉及内存拷贝,这对Redis而言,是个潜在的风险。如果你在实践过程中遇到Redis性能变慢,记得通过日志看下是否正在进行碎片清理。如果Redis的确正在清理碎片,那么,我建议你调小active-defrag-cycle-max的值,以减轻对正常请求处理的影响。

课后问题

21-缓冲区:一个可能引发“惨案”的地方

直接给缓冲区的大小设置上限(不可以)——>随着累积的数据越来越多,缓冲区占用内存空间越来越大,一旦耗尽了Redis实例所在机器的可用内存,就会导致Redis实例崩溃。

我们知道,Redis是典型的client-server架构,所有的操作命令都需要通过客户端发送给服务器端。==> 缓冲区在Redis中的一个主要应用场景,就是在客户端和服务器端之间进行通信时,用来暂存客户端发送的命令数据,或者是服务器端返回给客户端的数据结果。此外,缓冲区的另一个主要应用场景,是在主从节点间进行数据同步时,用来暂存主节点接收的写命令和数据。

这节课,我们就分别聊聊服务器端和客户端、主从集群间的缓冲区溢出问题,以及应对方案。

客户端输入和输出缓冲区


下面,我们就分别学习下输入缓冲区和输出缓冲区发生溢出的情况,以及相应的应对方案。

如何应对输入缓冲区溢出?

我们前面已经分析过了,输入缓冲区就是用来暂存客户端发送的请求命令的,所以可能导致溢出的情况主要是下面两种:

  • 写入了bigkey,比如一下子写入了多个百万级别的集合类型数据;
  • 服务器端处理请求的速度过慢,例如,Redis主线程出现了间歇性阻塞,无法及时处理正常发送的请求,导致客户端发送的请求在缓冲区越积越多。
    接下来,我们就从如何查看输入缓冲区的内存使用情况,以及如何避免溢出这两个问题出发,来继续学习吧。

要查看和服务器端相连的每个客户端对输入缓冲区的使用情况,我们可以使用CLIENT LIST命令:

1
2
CLIENT LIST
id=5 addr=127.0.0.1:50487 fd=9 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client

CLIENT命令返回的信息虽然很多,但我们只需要重点关注两类信息就可以了。

一类是与服务器端连接的客户端的信息。这个案例展示的是一个客户端的输入缓冲区情况,如果有多个客户端,输出结果中的addr会显示不同客户端的IP和端口号。

另一类是与输入缓冲区相关的三个参数:

  • cmd,表示客户端最新执行的命令。这个例子中执行的是CLIENT命令。
  • qbuf,表示输入缓冲区已经使用的大小。这个例子中的CLIENT命令已使用了26字节大小的缓冲区。
  • qbuf-free,表示输入缓冲区尚未使用的大小。这个例子中的CLIENT命令还可以使用32742字节的缓冲区。qbuf和qbuf-free的总和就是,Redis服务器端当前为已连接的这个客户端分配的缓冲区总大小。这个例子中总共分配了 26 + 32742 = 32768字节,也就是32KB的缓冲区。

有了CLIENT LIST命令,我们就可以通过输出结果来判断客户端输入缓冲区的内存占用情况了。如果qbuf很大,而同时qbuf-free很小,就要引起注意了,因为这时候输入缓冲区已经占用了很多内存,而且没有什么空闲空间了。此时,客户端再写入大量命令的话,就会引起客户端输入缓冲区溢出,Redis的处理办法就是把客户端连接关闭,结果就是业务程序无法进行数据存取了。

通常情况下,Redis服务器端不止服务一个客户端,当多个客户端连接占用的内存总量,超过了Redis的maxmemory配置项时(例如4GB),就会触发Redis进行数据淘汰。一旦数据被淘汰出Redis,再要访问这部分数据,就需要去后端数据库读取,这就降低了业务应用的访问性能。此外,更糟糕的是,如果使用多个客户端,导致Redis内存占用过大,也会导致内存溢出(out-of-memory)问题,进而会引起Redis崩溃,给业务应用造成严重影响。

所以,我们必须得想办法避免输入缓冲区溢出。我们可以从两个角度去考虑如何避免,一是把缓冲区调大,二是从数据命令的发送和处理速度入手。

我们先看看,到底有没有办法通过参数调整输入缓冲区的大小呢?答案是没有。

Redis的客户端输入缓冲区大小的上限阈值,在代码中就设定为了1GB。 也就是说,Redis服务器端允许为每个客户端最多暂存1GB的命令和数据。1GB的大小,对于一般的生产环境已经是比较合适的了。一方面,这个大小对于处理绝大部分客户端的请求已经够用了;另一方面,如果再大的话,Redis就有可能因为客户端占用了过多的内存资源而崩溃。

所以,Redis并没有提供参数让我们调节客户端输入缓冲区的大小。如果要避免输入缓冲区溢出,那我们就只能从数据命令的发送和处理速度入手,也就是前面提到的避免客户端写入bigkey,以及避免Redis主线程阻塞。

如何应对输出缓冲区溢出?

Redis的输出缓冲区暂存的是Redis主线程要返回给客户端的数据。一般来说,主线程返回给客户端的数据,既有简单且大小固定的OK响应(例如,执行SET命令)或报错信息,也有大小不固定的、包含具体数据的执行结果(例如,执行HGET命令)。

因此,Redis为每个客户端设置的输出缓冲区也包括两部分:一部分,是一个大小为16KB的固定缓冲空间,用来暂存OK响应和出错信息;另一部分,是一个可以动态增加的缓冲空间,用来暂存大小可变的响应结果。

那什么情况下会发生输出缓冲区溢出呢? 我为你总结了三种:

  • 服务器端返回bigkey的大量结果;
  • 执行了MONITOR命令;
  • 缓冲区大小设置得不合理。

其中,bigkey原本就会占用大量的内存空间,所以服务器端返回的结果包含bigkey,必然会影响输出缓冲区。接下来,我们就重点看下,执行MONITOR命令和设置缓冲区大小这两种情况吧。

MONITOR命令是用来监测Redis执行的。执行这个命令之后,就会持续输出监测到的各个命令操作,如下所示:

1
2
3
4
MONITOR
OK
1600617456.437129 [0 127.0.0.1:50487] "COMMAND"
1600617477.289667 [0 127.0.0.1:50487] "info" "memory"

到这里,你有没有看出什么问题呢?MONITOR的输出结果会持续占用输出缓冲区,并越占越多,最后的结果就是发生溢出。所以,我要给你一个小建议:MONITOR命令主要用在调试环境中,不要在线上生产环境中持续使用MONITOR。 当然,如果在线上环境中偶尔使用MONITOR检查Redis的命令执行情况,是没问题的。

接下来,我们看下输出缓冲区大小设置的问题。和输入缓冲区不同,我们可以通过client-output-buffer-limit配置项,来设置缓冲区的大小。具体设置的内容包括两方面:

  • 设置缓冲区大小的上限阈值;
  • 设置输出缓冲区持续写入数据的数量上限阈值,和持续写入数据的时间的上限阈值。

在具体使用client-output-buffer-limit来设置缓冲区大小的时候,我们需要先区分下客户端的类型。

对于和Redis实例进行交互的应用程序来说,主要使用两类客户端和Redis服务器端交互,分别是常规和Redis服务器端进行读写命令交互的普通客户端,以及订阅了Redis频道的订阅客户端。此外,在Redis主从集群中,主节点上也有一类客户端(从节点客户端)用来和从节点进行数据同步,我会在介绍主从集群中的缓冲区时,向你具体介绍。

当我们给普通客户端设置缓冲区大小时,通常可以在Redis配置文件中进行这样的设置:

1
client-output-buffer-limit normal 0 0 0

其中,normal表示当前设置的是普通客户端,第1个0设置的是缓冲区大小限制,第2个0和第3个0分别表示缓冲区持续写入量限制和持续写入时间限制。

对于普通客户端来说,它每发送完一个请求,会等到请求结果返回后,再发送下一个请求,这种发送方式称为阻塞式发送。在这种情况下,如果不是读取体量特别大的bigkey,服务器端的输出缓冲区一般不会被阻塞的。

所以,我们通常把普通客户端的缓冲区大小限制,以及持续写入量限制、持续写入时间限制都设置为0,也就是不做限制。

对于订阅客户端来说,一旦订阅的Redis频道有消息了,服务器端都会通过输出缓冲区把消息发给客户端。所以,订阅客户端和服务器间的消息发送方式,不属于阻塞式发送。不过,如果频道消息较多的话,也会占用较多的输出缓冲区空间。

因此,我们会给订阅客户端设置缓冲区大小限制、缓冲区持续写入量限制,以及持续写入时间限制,可以在Redis配置文件中这样设置:

1
client-output-buffer-limit pubsub 8mb 2mb 60

其中,pubsub参数表示当前是对订阅客户端进行设置;8mb表示输出缓冲区的大小上限为8MB,一旦实际占用的缓冲区大小要超过8MB,服务器端就会直接关闭客户端的连接;2mb和60表示,如果连续60秒内对输出缓冲区的写入量超过2MB的话,服务器端也会关闭客户端连接。

好了,我们来总结下如何应对输出缓冲区溢出:

  • 避免bigkey操作返回大量数据结果;
  • 避免在线上环境中持续使用MONITOR命令。
  • 使用client-output-buffer-limit设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限。

主从集群中的缓冲区

主从集群间的数据复制包括全量复制和增量复制两种
无论在哪种形式的复制中,为了保证主从节点的数据一致,都会用到缓冲区。但是,这两种复制场景下的缓冲区,在溢出影响和大小设置方面并不一样。

复制缓冲区的溢出问题

在全量复制过程中,主节点在向从节点传输RDB文件的同时,会继续接收客户端发送的写命令请求。这些写命令就会先保存在复制缓冲区中,等RDB文件传输完成后,再发送给从节点去执行。主节点上会为每个从节点都维护一个复制缓冲区,来保证主从节点间的数据同步。

所以,如果在全量复制时,从节点接收和加载RDB较慢,同时主节点接收到了大量的写命令,写命令在复制缓冲区中就会越积越多,最终导致溢出。

其实,主节点上的复制缓冲区,本质上也是一个用于和从节点连接的客户端(我们称之为从节点客户端),使用的输出缓冲区。复制缓冲区一旦发生溢出,主节点也会直接关闭和从节点进行复制操作的连接,导致全量复制失败。那如何避免复制缓冲区发生溢出呢?

一方面,我们可以控制主节点保存的数据量大小。按通常的使用经验,我们会把主节点的数据量控制在2~4GB,这样可以让全量同步执行得更快些,避免复制缓冲区累积过多命令。

另一方面,我们可以使用client-output-buffer-limit配置项,来设置合理的复制缓冲区大小。设置的依据,就是主节点的数据量大小、主节点的写负载压力和主节点本身的内存大小。

我们通过一个具体的例子,来学习下具体怎么设置。在主节点执行如下命令:

1
config set client-output-buffer-limit slave 512mb 128mb 60

其中,slave参数表明该配置项是针对复制缓冲区的。512mb代表将缓冲区大小的上限设置为512MB;128mb和60代表的设置是,如果连续60秒内的写入量超过128MB的话,也会触发缓冲区溢出。

我们再继续看看这个设置对我们有啥用。假设一条写命令数据是1KB,那么,复制缓冲区可以累积512K条(512MB/1KB = 512K)写命令。同时,主节点在全量复制期间,可以承受的写命令速率上限是2000条/s(128MB/1KB/60 约等于2000)。

这样一来,我们就得到了一种方法:在实际应用中设置复制缓冲区的大小时,可以根据写命令数据的大小和应用的实际负载情况(也就是写命令速率),来粗略估计缓冲区中会累积的写命令数据量;然后,再和所设置的复制缓冲区大小进行比较,判断设置的缓冲区大小是否足够支撑累积的写命令数据量。

关于复制缓冲区,我们还会遇到一个问题。主节点上复制缓冲区的内存开销,会是每个从节点客户端输出缓冲区占用内存的总和。如果集群中的从节点数非常多的话,主节点的内存开销就会非常大。所以,我们还必须得控制和主节点连接的从节点个数,不要使用大规模的主从集群。

好了,我们先总结一下这部分的内容。为了避免复制缓冲区累积过多命令造成溢出,引发全量复制失败,我们可以

  • 控制主节点保存的数据量大小,
  • 并设置合理的复制缓冲区大小。
  • 同时,我们需要控制从节点的数量,来避免主节点中复制缓冲区占用过多内存的问题。

复制积压缓冲区的溢出问题

接下来,我们再来看下增量复制时使用的缓冲区,这个缓冲区称为复制积压缓冲区(repl_backlog_buffer)。

主节点在把接收到的写命令同步给从节点时,同时会把这些写命令写入复制积压缓冲区。一旦从节点发生网络闪断,再次和主节点恢复连接后,从节点就会从复制积压缓冲区中,读取断连期间主节点接收到的写命令,进而进行增量同步,如下图所示:

【影响】首先,复制积压缓冲区是一个大小有限的环形缓冲区。当主节点把复制积压缓冲区写满后,会覆盖缓冲区中的旧命令数据。如果从节点还没有同步这些旧命令数据,就会造成主从节点间重新开始执行全量复制。
【如何调整】我们可以调整复制积压缓冲区的大小,也就是设置repl_backlog_size这个参数的值。具体的调整依据,你可以再看下第6讲中提供的repl_backlog_size大小的计算依据。

小结

从缓冲区溢出对Redis的影响的角度,我再把这四个缓冲区分成两类做个总结。

  • 缓冲区溢出导致网络连接关闭:普通客户端、订阅客户端,以及从节点客户端,它们使用的缓冲区,本质上都是Redis客户端和服务器端之间,或是主从节点之间为了传输命令数据而维护的。这些缓冲区一旦发生溢出,处理机制都是直接把客户端和服务器端的连接,或是主从节点间的连接关闭。网络连接关闭造成的直接影响,就是业务程序无法读写Redis,或者是主从节点全量同步失败,需要重新执行。
  • 缓冲区溢出导致命令数据丢失:主节点上的复制积压缓冲区属于环形缓冲区,一旦发生溢出,新写入的命令数据就会覆盖旧的命令数据,导致旧命令数据的丢失,进而导致主从节点重新进行全量复制。

从本质上看,缓冲区溢出,无非就是三个原因:命令数据发送过快过大;命令数据处理较慢;缓冲区空间过小。明白了这个,我们就可以有针对性地拿出应对策略了。

  • 针对命令数据发送过快过大的问题,对于普通客户端来说可以避免bigkey,而对于复制缓冲区来说,就是避免过大的RDB文件。
  • 针对命令数据处理较慢的问题,解决方案就是减少Redis主线程上的阻塞操作,例如使用异步的删除操作。
  • 针对缓冲区空间过小的问题,解决方案就是使用client-output-buffer-limit配置项设置合理的输出缓冲区、复制缓冲区和复制积压缓冲区大小。当然,我们不要忘了,输入缓冲区的大小默认是固定的,我们无法通过配置来修改它,除非直接去修改Redis源码。

课后问题


补充:另一方面,在应用Redis主从集群时,主从节点进行故障切换是需要一定时间的,此时,主节点无法服务外来请求。如果客户端有缓冲区暂存请求,那么,客户端仍然可以正常接收业务应用的请求,这就可以避免直接给应用返回无法服务的错误。

22-第11~21讲课后思考题答案及常见问题答疑

第13讲

问题:你在日常的实践过程中,还用过Redis的其他数据类型吗?

答案:除了我们课程上介绍的5大基本数据类型,以及HyperLogLog、Bitmap、GEO,Redis还有一种数据类型,叫作布隆过滤器。它的查询效率很高,经常会用在缓存场景中,可以用来判断数据是否存在缓存中。

第14讲

问题:在用Sorted Set保存时间序列数据时,如果把时间戳作为score,把实际的数据作为member,这样保存数据有没有潜在的风险?另外,如果你是Redis的开发维护者,你会把聚合计算也设计为Sorted Set的一个内在功能吗?

Sorted Set和Set一样,都会对集合中的元素进行去重,也就是说,如果我们往集合中插入的member值,和之前已经存在的member值一样,那么,原来member的score就会被新写入的member的score覆盖。相同member的值,在Sorted Set中只会保留一个。
对于时间序列数据来说,这种去重的特性是会带来数据丢失风险的。

关于是否把聚合计算作为Sorted Set的内在功能,考虑到Redis的读写功能是由单线程执行,在进行数据读写时,本身就会消耗较多的CPU资源,如果再在Sorted Set中实现聚合计算,就会进一步增加CPU的资源消耗,影响到Redis的正常数据读取。所以,如果我是Redis的开发维护者,除非对Redis的线程模型做修改,比如说在Redis中使用额外的线程池做聚合计算,否则,我不会把聚合计算作为Redis的内在功能实现的。

第15讲

问题:如果一个生产者发送给消息队列的消息,需要被多个消费者进行读取和处理(例如,一个消息是一条从业务系统采集的数据,既要被消费者1读取并进行实时计算,也要被消费者2读取并留存到分布式文件系统HDFS中,以便后续进行历史查询),你会使用Redis的什么数据类型来解决这个问题呢?

答案:有同学提到,可以使用Streams数据类型的消费组,同时消费生产者的数据,这是可以的。但是,有个地方需要注意,如果只是使用一个消费组的话,【消费组内的多个消费者在消费消息时是互斥的】,换句话说,在一个消费组内,一个消息只能被一个消费者消费。我们希望消息既要被消费者1读取,也要被消费者2读取,是一个多消费者的需求。所以,如果使用消费组模式,需要让消费者1和消费者2属于不同的消费组,这样它们就能同时消费了。

另外,Redis基于字典和链表数据结构,实现了发布和订阅功能,这个功能可以实现一个消息被多个消费者消费使用,可以满足问题中的场景需求。

第16讲

问题:Redis的写操作(例如SET、HSET、SADD等)是在关键路径上吗?

答案:Redis本身是内存数据库,所以,写操作都需要在内存上完成执行后才能返回,这就意味着,如果这些写操作处理的是大数据集,例如1万个数据,那么,主线程需要等这1万个数据都写完,才能继续执行后面的命令。所以说,Redis的写操作也是在关键路径上的。

这个问题是希望你把面向内存和面向磁盘的写操作区分开。当一个写操作需要把数据写到磁盘时,一般来说,写操作只要把数据写到操作系统的内核缓冲区就行。不过,如果我们执行了同步写操作,那就必须要等到数据写回磁盘。所以,面向磁盘的写操作一般不会在关键路径上。

我看到有同学说,根据写操作命令的返回值来决定是否在关键路径上,如果返回值是OK,或者客户端不关心是否写成功,那么,此时的写操作就不算在关键路径上。

这个思路不错,不过,需要注意的是,客户端经常会阻塞等待发送的命令返回结果,在上一个命令还没有返回结果前,客户端会一直等待,直到返回结果后,才会发送下一个命令。此时,即使我们不关心返回结果,客户端也要等到写操作执行完成才行。所以,在不关心写操作返回结果的场景下,可以对Redis客户端做异步改造。具体点说,就是使用异步线程发送这些不关心返回结果的命令,而不是在Redis客户端中等待这些命令的结果。

代表性问题

接下来,我就再重点解释一下,如何排查慢查询命令,以及如何排查bigkey。

问题1:如何使用慢查询日志和latency monitor排查执行慢的操作?

在使用慢查询日志前,我们需要设置两个参数。

  • slowlog-log-slower-than:这个参数表示,慢查询日志对执行时间大于多少微秒的命令进行记录。
  • slowlog-max-len:这个参数表示,慢查询日志最多能记录多少条命令记录。慢查询日志的底层实现是一个具有预定大小的先进先出队列,一旦记录的命令数量超过了队列长度,最先记录的命令操作就会被删除。这个值默认是128。但是,如果慢查询命令较多的话,日志里就存不下了;如果这个值太大了,又会占用一定的内存空间。所以,一般建议设置为1000左右,这样既可以多记录些慢查询命令,方便排查,也可以避免内存开销。

设置好参数后,慢查询日志就会把执行时间超过slowlog-log-slower-than阈值的命令操作记录在日志中。

我们可以使用SLOWLOG GET命令,来查看慢查询日志中记录的命令操作,例如,我们执行如下命令,可以查看最近的一条慢查询的日志信息。

除了慢查询日志以外,Redis从2.8.13版本开始,还提供了latency monitor监控工具,这个工具可以用来监控Redis运行过程中的峰值延迟情况。

和慢查询日志的设置相类似,要使用latency monitor,首先要设置命令执行时长的阈值。当一个命令的实际执行时长超过该阈值时,就会被latency monitor监控到。比如,我们可以把latency monitor监控的命令执行时长阈值设为1000微秒,如下所示:

config set latency-monitor-threshold 1000
设置好了latency monitor的参数后,我们可以使用latency latest命令,查看最新和最大的超过阈值的延迟情况,如下所示:

1
2
3
4
5
latency latest
1) 1) "command"
2) (integer) 1600991500 //命令执行的时间戳
3) (integer) 2500 //最近的超过阈值的延迟
4) (integer) 10100 //最大的超过阈值的延迟

问题2:如何排查Redis的bigkey?

Redis可以在执行redis-cli命令时带上 –bigkeys 选项,进而对整个数据库中的键值对大小情况进行统计分析,比如说,统计每种数据类型的键值对个数以及平均大小。此外,这个命令执行后,会输出每种数据类型中最大的bigkey的信息,对于String类型来说,会输出最大bigkey的字节长度,对于集合类型来说,会输出最大bigkey的元素个数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
./redis-cli  --bigkeys

-------- summary -------
Sampled 32 keys in the keyspace!
Total key length in bytes is 184 (avg len 5.75)

//统计每种数据类型中元素个数最多的bigkey
Biggest list found 'product1' has 8 items
Biggest hash found 'dtemp' has 5 fields
Biggest string found 'page2' has 28 bytes
Biggest stream found 'mqstream' has 4 entries
Biggest set found 'userid' has 5 members
Biggest zset found 'device:temperature' has 6 members

//统计每种数据类型的总键值个数,占所有键值个数的比例,以及平均大小
4 lists with 15 items (12.50% of keys, avg size 3.75)
5 hashs with 14 fields (15.62% of keys, avg size 2.80)
10 strings with 68 bytes (31.25% of keys, avg size 6.80)
1 streams with 4 entries (03.12% of keys, avg size 4.00)
7 sets with 19 members (21.88% of keys, avg size 2.71)
5 zsets with 17 members (15.62% of keys, avg size 3.40)

不过,在使用–bigkeys选项时,有一个地方需要注意一下。这个工具是通过扫描数据库来查找bigkey的,所以,在执行的过程中,会对Redis实例的性能产生影响。如果你在使用主从集群,我建议你在从节点上执行该命令。因为主节点上执行时,会阻塞主节点。如果没有从节点,那么,我给你两个小建议:

  • 第一个建议是,在Redis实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;
  • 第二个建议是,可以使用-i参数控制扫描间隔,避免长时间扫描降低Redis实例的性能。例如,我们执行如下命令时,redis-cli会每扫描100次暂停100毫秒(0.1秒)。
1
./redis-cli  --bigkeys -i 0.1

当然,使用Redis自带的–bigkeys选项排查bigkey,有两个不足的地方:

  1. 这个方法只能返回每种类型中最大的那个bigkey,无法得到大小排在前N位的bigkey;
  2. 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大。

我给你提供一个基本的开发思路:使用SCAN命令对数据库扫描,然后用TYPE命令获取返回的每一个key的类型。接下来,对于String类型,可以直接使用STRLEN命令获取字符串的长度,也就是占用的内存空间字节数。

如果你能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。

  • List类型:LLEN命令;
  • Hash类型:HLEN命令;
  • Set类型:SCARD命令;
  • Sorted Set类型:ZCARD命令;

如果你不能提前知道写入集合的元素大小,可以使用MEMORY USAGE命令(需要Redis 4.0及以上版本),查询一个键值对占用的内存空间。例如,执行以下命令,可以获得key为user:info这个集合类型占用的内存空间大小。

1
2
MEMORY USAGE user:info
(integer) 315663239

这样一来,你就可以在开发的程序中,把每一种数据类型中的占用内存空间大小排在前 N 位的key统计出来,这也就是每个数据类型中的前N个bigkey。