String 数据结构详解

String 类型并不是适用于所有场合的,它有一个明显的短板,就是它保存数据时所消耗的内存空间较多。集合类型有非常节省内存空间的底层实现结构,但是,集合类型保存的数据模式,是一个键对应一系列值,并不适合直接保存单值的键值对。

为什么 String 类型内存开销大?

SDS 和 INT

除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。

当你保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节的 Long 类型整数,这种保存方式通常也叫作 int 编码 方式。但是,当你保存的数据中包含字符时,String 类型就会用简单动态字符串(SimpleDynamic String,SDS)结构体来保存:

  • buf:字节数组,保存实际数据。为了表示字节数组的结束,Redis 会自动在数组最后加一个“\0”,这就会额外占用 1 个字节的开销。

  • len:占 4 个字节,表示 buf 的已用长度。

  • alloc:也占个 4 字节,表示 buf 的实际分配长度,一般大于 len。

可以看到,在 SDS 中,buf 保存实际数据,而 len 和 alloc 本身其实是 SDS 结构体的额外开销。

RedisObject

对于 String 类型来说,除了 SDS 的额外开销,还有一个来自于 RedisObject 结构体的开销。因为 Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体
数据类型的实际数据所在,例如指向 String 类型的 SDS 结构所在的内存地址,具体如下图:

avatar

为了节省内存空间,Redis 还对 Long 类型整数和 SDS 的内存布局做了专门的设计:

  1. 当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。(也就是没有 SDS 数据结构,直接就是 INT(8B) 类型)

  2. 当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。

  3. 当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式。

全局哈希表(DictEntry)

Redis 会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节,如下图所示:

avatar

  • 内存分配库 jemalloc

jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。举个例子。如果你申请 6 字节空间,jemalloc 实际会分配 8 字节空间;如果你申请 24 字节空间,jemalloc 则会分配 32 字节。所以,在我们刚刚说的场景里,dictEntry 结构就占用了 32 字节。

压缩列表(ziplist)数据结构详解

Redis 有一种底层数据结构,叫压缩列表(ziplist),这是一种非常节省内存的结构。压缩列表的构成。表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量,以及列表中的 entry 个数。压缩列表尾还有一个 zlend,表示列表结束。如下图:

avatar

压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据。每个 entry 的元数据包括下面几部分:

  • prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。

  • len:表示自身长度,4 字节;

  • encoding:表示编码方式,1 字节;

  • content:保存实际数据。

这些 entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销。当你用 String 类型时,一个键值对就有一个 dictEntry,要用 32 字节空间。但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。

Hash 类型底层结构什么时候使用压缩列表?

Redis Hash 类型的两种底层实现结构,分别是压缩列表和哈希表。什么时候使用哈希表呢?其实,Hash类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。这两个阈值分别对应以下两个配置项:

  • hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。

  • hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。

如果我们往 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。

注意:不管是使用Hash还是Sorted Set,当采用ziplist方式存储时,虽然可以节省内存空间,但是在查询指定元素时,都要遍历整个ziplist,找到指定的元素。所以使用ziplist方式存储时,虽然可以利用CPU高速缓存,但也不适合存储过多的数据(hash-max-ziplist-entries和zset-max-ziplist-entries不宜设置过大),否则查询性能就会下降比较厉害。整体来说,这样的方案就是时间换空间,我们需要权衡使用。

当使用ziplist存储时,我们尽量存储int数据,ziplist在设计时每个entry都进行了优化,针对要存储的数据,会尽量选择占用内存小的方式存储(整数比字符串在存储时占用内存更小),这也有利于我们节省Redis的内存。还有,因为ziplist是每个元素紧凑排列,而且每个元素存储了上一个元素的长度,所以当修改其中一个元素超过一定大小时,会引发多个元素的级联调整(前面一个元素发生大的变动,后面的元素都要重新排列位置,重新分配内存),这也会引发性能问题,需要注意。

扩展类型(Bitmap, HyperLogLog, GEO)

Bitmap

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态。你可以把 Bitmap 看作是一个 bit 数组。

Bitmap 提供了 GETBIT/SETBIT 操作,使用一个偏移值 offset 对 bit 数组的某一个 bit 位进行读和写。不过,需要注意的是,Bitmap 的偏移量是从 0 开始算的,也就是说 offset的最小值是 0。当使用 SETBIT 对一个 bit 位进行写操作时,这个 bit 位会被设置为 1。Bitmap 还提供了 BITCOUNT 操作,用来统计这个 bit 数组中所有“1”的个数。

  • 用位数组来表示各元素是否出现,每个元素对应一位,所需的总内存为n bit。能大大减少内存占用且位操作迅速。

  • 如果要统计1亿个数据的基数值,大约需要内存100000000/8/1024/1024 ≈ 12M,内存减少占用的效果显著。然而统计一个对象的基数值需要12M,如果统计10000个对象,就需要将近120G,同样不能广泛用于大数据场景。

使用示例:

1
2
3
4
5
# 记录用户ID为3000的用户在202008月份的打卡情况
SETBIT uid:sign:3000:202008 2 1
SETBIT uid:sign:3000:202008 92 1
GETBIT uid:sign:3000:202008 2
BITCOUNT uid:sign:3000:202008

基数统计(HyperLogLog)

HyperLogLog 是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。在 Redis 中,每个 HyperLogLog 只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。你看,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。

HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。这也就意味着,你使用HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。

使用示例:

1
2
PFADD page1:uv user1 user2 user3 user4 user5
PFCOUNT page1:uv

GEO

GEO 类型的底层数据结构就是用 Sorted Set 来实现的。

GeoHash 的编码方法

为了能高效地对经纬度进行比较,Redis 采用了业界广泛使用的 GeoHash 编码方法,这个方法的基本原理就是“二分区间,区间编码”。当我们要对一组经纬度进行 GeoHash 编码时,我们要先对经度和纬度分别编码,然后再把经纬度各自的编码组合成一个最终编码。

首先,我们来看下经度和纬度的单独编码过程。

对于一个地理位置信息来说,它的经度范围是[-180,180]。GeoHash 编码会把一个经度值编码成一个 N 位的二进制值,我们来对经度范围[-180,180]做 N 次的二分区操作,其中 N可以自定义。在进行第一次二分区时,经度范围[-180,180]会被分成两个子区间:[-180,0) 和[0,180](我称之为左、右分区)。此时,我们可以查看一下要编码的经度值落在了左分区还是右分区。如果是落在左分区,我们就用 0 表示;如果落在右分区,就用 1 表示。这样一来,每做完一次二分区,我们就可以得到 1 位编码值。

然后,我们再对经度值所属的分区再做一次二分区,同时再次查看经度值落在了二分区后的左分区还是右分区,按照刚才的规则再做 1 位编码。当做完 N 次的二分区后,经度值就可以用一个 N bit 的数来表示了。

对纬度的编码方式,和对经度的一样,只是纬度的范围是[-90,90]。

当一组经纬度值都编完码后,我们再把它们的各自编码值组合在一起,组合的规则是:最终编码值的偶数位上依次是经度的编码值,奇数位上依次是纬度的编码值,其中,偶数位从 0 开始,奇数位从 1 开始。

到这里,我们就知道了,GEO 类型是把经纬度所在的区间编码作为 Sorted Set 中元素的权重分数,把和经纬度相关的车辆 ID 作为 Sorted Set 中元素本身的值保存下来,这样相邻经纬度的查询就可以通过编码值的大小范围查询来实现了。

使用方法:

  • GEOADD 命令:用于把一组经纬度信息和相对应的一个 ID 记录到 GEO 类型集合中;

  • GEORADIUS 命令:会根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。当然,我们可以自己定义这个范围。

使用示例:

1
2
3
4
# 添加车辆位置信息
GEOADD cars:locations 116.034579 39.030452 33
# 查询指定地点5KM内的车辆,限制10个
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10

如果需要,我们可以二次开发,开发自定义的数据类型。

Redis异步机制

Redis 实例有哪些阻塞点?

和 Redis 实例交互的对象,以及交互时会发生的操作大致有如下几种:

  • 客户端:网络 IO,键值对增删改查操作,数据库操作;

  • 磁盘:生成 RDB 快照,记录 AOF 日志,AOF 日志重写;

  • 主从节点:主库生成、传输 RDB 文件,从库接收 RDB 文件、清空数据库、加载 RDB文件;

  • 切片集群实例:向其他实例传输哈希槽信息,数据迁移。

avatar

和客户端交互时的阻塞点

网络 IO 有时候会比较慢,但是 Redis 使用了 IO 多路复用机制,避免了主线程一直处在等待网络连接或请求到来的状态,所以,网络 IO 不是导致 Redis 阻塞的因素。

键值对的增删改查操作是 Redis 和客户端交互的主要部分,也是 Redis 主线程执行的主要任务。所以,复杂度高的增删改查操作肯定会阻塞 Redis。

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

  2. 集合自身的删除操作同样也有潜在的阻塞风险。删除操作的本质是要释放键值对占用的内存空间。释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序,所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞。(什么时候会释放大量内存,其实就是在删除大量键值对数据的时候,最典型的就是删除包含了大量元素的集合,也称为 bigkey 删除。)

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

和磁盘交互时的阻塞点

Redis 开发者早已认识到磁盘 IO 会带来阻塞,所以就把 Redis 进一步设计为采用子进程的方式生成 RDB 快照文件,以及执行 AOF 日志重写操作。这样一来,这两个操作由子进程负责执行,慢速的磁盘 IO 就不会阻塞主线程了。但是,Redis 直接记录 AOF 日志时,会根据不同的写回策略对数据做落盘保存。一个同步写磁盘的操作的耗时大约是 1~2ms,如果有大量的写操作需要记录在 AOF 日志中,并同步写回的话,就会阻塞主线程了。这就得到了 Redis 的第四个阻塞点了:AOF 日志同步写

主从节点交互时的阻塞点

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

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

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

最后,当我们部署 Redis 切片集群时,每个 Redis 实例上分配的哈希槽信息需要在不同实例间进行传递,同时,当需要进行负载均衡或者有实例增删时,数据会在不同的实例间进行迁移。不过,哈希槽的信息量不大,而数据迁移是渐进式执行的,所以,一般来说,这两类操作对 Redis 主线程的阻塞风险不大。不过,如果你使用了 Redis Cluster 方案,而且同时正好迁移的是 bigkey 的话,就会造成主线程的阻塞,因为 Redis Cluster 使用了同步迁移(后面章节会具体分析)。

综上所述总结就是有五点:

  1. 集合全量查询和聚合操作;(关键路径)

  2. bigkey 删除;(非关键路径)

  3. 清空数据库;(非关键路径)

  4. AOF 日志同步写;(非关键路径)

  5. 从库加载 RDB 文件。(关键路径)

如果在主线程中执行这些操作,必然会导致主线程长时间无法服务其他请求。为了避免阻塞式操作,Redis 提供了异步线程机制。所谓的异步线程机制,就是指,Redis 会启动一些子线程,然后把一些任务交给这些子线程,让它们在后台完成,而不再由主线程来执行这些任务。使用异步线程机制执行操作,可以避免阻塞主线程。

所谓关键路径就是必须要执行完成才能进行下一步,或者需要执行完成返回给客户端。非关键路径既不影响主流程,可以异步的来执行,在接收到指令后直接给客户端返回成功,然后后台异步执行。

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

异步的子线程机制

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

主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)。此时,删除或清空操作不会阻塞主线程,这就避免了对主线程的性能影响。

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

avatar

异步的键值对删除和数据库清空操作是 Redis 4.0 后提供的功能,Redis 也提供了新的命令来执行这两个操作:

  • 键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用 UNLINK 命令。

  • 清空数据库:可以在 FLUSHDB 和 FLUSHALL 命令后加上 ASYNC 选项,这样就可以让后台子线程异步地清空数据库,如下所示:

1
2
FLUSHDB ASYNC
FLUSHALL AYSNC

总结

本节我们学习了 Redis 实例运行时的 4 大类交互对象:客户端、磁盘、主从库实例、切片集群实例。基于这 4 大类交互对象,我们梳理了会导致 Redis 性能受损的 5 大阻塞点,包括集合全量查询和聚合操作、bigkey 删除、清空数据库、AOF 日志同步写,以及从库加载 RDB 文件。

在这 5 大阻塞点中,bigkey 删除、清空数据库、AOF 日志同步写不属于关键路径操作,可以使用异步子线程机制来完成。Redis 在运行时会创建三个子线程,主线程会通过一个任务队列和三个子线程进行交互。子线程会根据任务的具体类型,来执行相应的异步操作。

不过,异步删除操作是 Redis 4.0 以后才有的功能,如果你使用的是 4.0 之前的版本,当你遇到 bigkey 删除时,我给你个小建议:先使用集合类型提供的 SCAN 命令读取数据,然后再进行删除。因为用 SCAN 命令可以每次只读取一部分数据并进行删除,这样可以避免一次性删除大量 key 给主线程带来的阻塞。例如,对于 Hash 类型的 bigkey 删除,你可以使用 HSCAN 命令,每次从 Hash 集合中获取一部分键值对(例如 200 个),再使用 HDEL 删除这些键值对,这样就可以把删除压力分摊到多次操作中,那么,每次删除操作的耗时就不会太长,也就不会阻塞主线程了。

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

  • 集合全量查询和聚合操作:可以使用 SCAN 命令,分批读取数据,再在客户端进行聚合计算;

  • 从库加载 RDB 文件:把主库的数据量大小控制在 2~4GB 左右,以保证 RDB 文件能以较快的速度加载。

CPU结构对Redis性能的影响

主流的 CPU 架构

一个 CPU 处理器中一般有多个运行核心,我们把一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核都拥有私有的一级缓存(Level 1 cache,简称 L1cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称 L2 cache)。物理核的私有缓存:它其实是指缓存空间只能被当前的这个物理核使用,其他的物理核无法对这个核的缓存空间进行数据存取。当数据或指令保存在 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 缓存。具体如下图:

avatar

在主流的服务器上,一个 CPU 处理器会有 10 到 20 多个物理核。同时,为了提升服务器的处理能力,服务器上通常还会有多个 CPU 处理器(也称为多 CPU Socket),每个处理器有自己的物理核(包括 L1、L2 缓存),L3 缓存,以及连接的内存,同时,不同处理器间通过总线连接。下图显示的就是多 CPU Socket 的架构:

avatar

我们也把这个架构称为非统一内存访问架构(Non-Uniform MemoryAccess,NUMA 架构)。

在多 CPU 架构上,应用程序可以在不同的处理器上运行。如果应用程序先在一个 Socket 上运行,并且把数据保存到了内存,然后被调度到另一个 Socket 上运行,此时,应用程序再进行内存访问时,就需要访问之前 Socket 上连接的内存,这种访问属于远端内存访问。和访问 Socket 直接连接的内存相比,远端内存访问会增加应用程序的延迟。

CPU 架构对应用程序运行的影响

  1. L1、L2 缓存中的指令和数据的访问速度很快,所以,充分利用 L1、L2 缓存,可以有效缩短应用程序的执行时间;

  2. 在 NUMA 架构下,如果应用程序从一个 Socket 上调度到另一个 Socket 上,就可能会出现远端内存访问的情况,这会直接增加应用程序的执行时间。

总结

在多核 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 性能造成影响。所以,我给了你两个建议:

  1. 如果你不想修改 Redis 代码,可以把按一个 Redis 实例一个物理核方式进行绑定,这样,Redis 的主线程、子进程和后台线程可以共享使用一个物理核上的两个逻辑核。

  2. 如果你很熟悉 Redis 的源码,就可以在源码中增加绑核操作,把子进程和后台线程绑到不同的核上,这样可以避免对主线程的 CPU 资源竞争。不过,如果你不熟悉 Redis 源码,也不用太担心,Redis 6.0 出来后,可以支持 CPU 核绑定的配置操作了。

响应延迟问题排查

在 Redis 的实际部署应用中,有一个非常严重的问题,那就是 Redis 突然变慢了。

查看 Redis 的响应延迟

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

从 2.8.7 版本开始,redis-cli 命令提供了 –intrinsic-latency 选项,可以用来监测和统计测试期间内的最大延迟,这个延迟可以作为 Redis 的基线性能。其中,测试时长可以用–intrinsic-latency 选项的参数来指定。需要注意的是,基线性能和当前的操作系统、硬件配置相关。因此,我们可以把它和Redis 运行时的延迟结合起来,再进一步判断 Redis 性能是否变慢了。一般来说,你要把运行时延迟和基线性能进行对比,如果你观察到的 Redis 运行时延迟是其基线性能的 2 倍及以上,就可以认定 Redis 变慢了。

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

如何应对 Redis 变慢?

Redis 自身操作特性的影响

慢查询命令

慢查询命令,就是指在 Redis 中执行速度慢的命令,这会导致 Redis 延迟增加。Redis 提供的命令操作很多,并不是所有命令都慢,这和命令操作的复杂度有关。所以,我们必须要知道 Redis 的不同命令的复杂度。

比如说,Value 类型为 String 时,GET/SET 操作主要就是操作 Redis 的哈希表索引。这个操作复杂度基本是固定的,即 O(1)。但是,当 Value 类型为 Set 时,SORT、SUNION/SMEMBERS 操作复杂度分别为 O(N+M*log(M)) 和 O(N)。其中,N 为 Set 中的元素个数,M 为 SORT 操作返回的元素个数。这个复杂度就增加了很多。Redis 官方文档中对每个命令的复杂度都有介绍,当你需要了解某个命令的复杂度时,可以直接查询。

那该怎么应对这个问题呢?当你发现 Redis 性能变慢时,可以通过 Redis 日志,或者是 latency monitor 工具,查询变慢的请求,根据请求对应的具体命令以及官方文档,确认下是否采用了复杂度高的慢查询命令。如果的确有大量的慢查询命令,有两种处理方式:

  1. 用其他高效命令代替。比如说,如果你需要返回一个 SET 中的所有成员时,不要使用SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。

  2. 当需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。

keys影响(scan讲解)

还有一个比较容易忽略的慢查询命令,就是 KEYS。它用于返回和输入模式匹配的所有key,因为 KEYS 命令需要遍历存储的键值对,所以操作延时高。所以,KEYS 命令一般不被建议用于生产环境中。

如果想要获取整个实例的所有key,建议使用SCAN命令代替。客户端通过执行SCAN $cursor COUNT $count可以得到一批key以及下一个游标$cursor,然后把这个$cursor当作SCAN的参数,再次执行,以此往复,直到返回的$cursor为0时,就把整个实例中的所有key遍历出来了。

scan重复

关于SCAN讨论最多的问题就是,Redis在做Rehash时,会不会漏key或返回重复的key。

在使用SCAN命令时,不会漏key,但可能会得到重复的key,这主要和Redis的Rehash机制有关。Redis的所有key存在一个全局的哈希表中,如果存入的key慢慢变多,在达到一定阈值后,为了避免哈希冲突导致查询效率降低,这个哈希表会进行扩容。与之对应的,key数量逐渐变少时,这个哈希表会缩容以节省空间。

1、为什么不会漏key?Redis在SCAN遍历全局哈希表时,采用高位进位法的方式遍历哈希桶(可网上查询图例,一看就明白),当哈希表扩容后,通过这种算法遍历,旧哈希表中的数据映射到新哈希表,依旧会保留原来的先后顺序,这样就可以保证遍历时不会遗漏也不会重复。

2、为什么SCAN会得到重复的key?这个情况主要发生在哈希表缩容。已经遍历过的哈希桶在缩容时,会映射到新哈希表没有遍历到的位置,所以继续遍历就会对同一个key返回多次。

SCAN是遍历整个实例的所有key,另外Redis针对Hash/Set/Sorted Set也提供了HSCAN/SSCAN/ZSCAN命令,用于遍历一个key中的所有元素,建议在获取一个bigkey的所有数据时使用,避免发生阻塞风险。

但是使用HSCAN/SSCAN/ZSCAN命令,返回的元素数量与执行SCAN逻辑可能不同。执行SCAN $cursor COUNT $count时一次最多返回count个数的key,数量不会超过count。但Hash/Set/Sorted Set元素数量比较少时,底层会采用intset/ziplist方式存储,如果以这种方式存储,在执行HSCAN/SSCAN/ZSCAN命令时,会无视count参数,直接把所有元素一次性返回,也就是说,得到的元素数量是会大于count参数的。当底层转为哈希表或跳表存储时,才会真正使用发count参数,最多返回count个元素。

过期 key 操作

我们来看过期 key 的自动删除机制。它是 Redis 用来回收内存空间的常用机制,应用广泛,本身就会引起 Redis 操作阻塞,导致性能变慢,所以,你必须要知道该机制对性能的影响。Redis 键值对的 key 可以设置过期时间。默认情况下,Redis 每 100 毫秒会删除一些过期key,具体的算法如下:

  1. 采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的key 全部删除;

  2. 如果超过 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 和 EXPIRE 的过期时间参数。其次,如果一批 key 的确是同时过期,你还可以在EXPIREAT 和 EXPIRE 的过期时间参数上,加上一个一定大小范围内的随机数,这样,既保证了 key 在一个邻近时间范围内被删除,又避免了同时过期造成的压力。

文件系统影响

前面说过,为了保证数据可靠性,Redis 会采用 AOF 日志或 RDB 快照。其中,AOF日志提供了三种日志写回策略:no、everysec、always。这三种写回策略依赖文件系统的两个系统调用完成,也就是 write 和 fsync。write 只要把日志记录写到内核缓冲区,就可以返回了,并不需要等待日志实际写回到磁盘;而 fsync 需要把日志记录写回到磁盘后才能返回,时间较长。下面这张表展示了三种写回策略所执行的系统调用。

avatar

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

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

  2. 而对于 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 性能变慢。

排查和解决方法

你可以检查下 Redis 配置文件中的 appendfsync 配置项,该配置项的取值表明了Redis 实例使用的是哪种 AOF 日志写回策略。如果 AOF 写回策略使用了 everysec 或 always 配置,请先确认下业务方对数据可靠性的要求,明确是否需要每一秒或每一个操作都记日志。如果业务应用对延迟非常敏感,但同时允许一定量的数据丢失,那么,可以把配置项 noappendfsync-on-rewrite 设置为 yes,如下所示:

1
no-appendfsync-on-rewrite yes

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

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

操作系统影响

swap影响

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

Redis 是内存数据库,内存使用量大,如果没有控制好内存的使用量,或者和其他内存需求大的应用一起运行了,就可能受到 swap 的影响,而导致性能变慢。通常,触发 swap 的原因主要是物理机器内存不足,对于 Redis 而言,有两种常见的情况:

  1. Redis 实例自身使用了大量的内存,导致物理机器的可用内存不足;

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

内存大页影响

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

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

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

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

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

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

小结

为了方便你应用,我给你梳理了一个包含 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 内存碎片

在使用 Redis 时,我们经常会遇到这样一个问题:明明做了数据删除,数据量已经不大了,为什么使用 top 命令查看时,还会发现 Redis 占用了很多内存呢?实际上,这是因为,当数据删除后,Redis 释放的内存空间会由内存分配器管理,并不会立即返回给操作系统。所以,操作系统仍然会记录着给 Redis 分配了大量内存。

但是,这往往会伴随一个潜在的风险点:Redis 释放的内存空间可能并不是连续的,那么,这些不连续的内存空间很有可能处于一种闲置的状态。这就会导致一个问题:虽然有空闲空间,Redis 却无法用来保存数据,不仅会减少 Redis 能够实际保存的数据量,还会降低 Redis 运行机器的成本回报率。这就是内存碎片产生的影响,虽然操作系统的剩余内存空间总量足够,但是,应用申请的是一块连续地址空间的 N 字节,但在剩余的内存空间中,没有大小为 N 字节的连续空间了,那么,这些剩余空间就是内存碎片。

内存碎片形成原因

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

内存分配器的分配策略

内存分配器的分配策略就决定了操作系统无法做到“按需分配”。这是因为,内存分配器一般是按固定大小来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配。Redis 可以使用 libc、jemalloc、tcmalloc 多种内存分配器来分配内存,默认使用jemalloc。jemalloc 的分配策略之一,是按照一系列固定的大小划分内存空间,例如 8 字节、16 字节、32 字节、48 字节,…, 2KB、4KB、8KB 等。当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间。这样的分配方式本身是为了减少分配次数。例如,Redis 申请一个 20 字节的空间保存数据,jemalloc 就会分配 32 字节,此时,如果应用还要写入 10 字节的数据,Redis 就不用再向操作系统申请空间了,因为刚才分配的 32 字节已经够用了,这就避免了一次分配操作。但是,如果 Redis 每次向分配器申请的内存空间大小不一样,这种分配方式就会有形成碎片的风险,而这正好来源于 Redis 的外因了。

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

内存分配器只能按固定大小分配内存,所以,分配的内存空间一般都会比申请的空间大一些,不会完全一致,这本身就会造成一定的碎片,降低内存空间存储效率。比如说,应用 A 保存 6 字节数据,jemalloc 按分配策略分配 8 字节。如果应用 A 不再保存新数据,那么,这里多出来的 2 字节空间就是内存碎片了。

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

大量内存碎片的存在,会造成 Redis 的内存实际利用率变低,接下来,我们就要来解决这个问题了。

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

Redis 自身提供了 INFO 命令,可以用来查询内存使用的详细信息,命令如下:

1
2
3
4
5
6
7
8
9
10
INFO memory
# Memory
# 操作系统实际分配给 Redis 的物理内存空间(包含内存碎片)
used_memory:1073741736
used_memory_human:1024.00M
# used_memory 是 Redis 为了保存数据实际申请使用的空间
used_memory_rss:1997159792
used_memory_rss_human:1.86G
# 内存碎片率。used_memory_rss 和 used_memory 相除的结果
mem_fragmentation_ratio:1.86
  • mem_fragmentation_ratio 大于 1 但小于 1.5。这种情况是合理的。这是因为,刚才介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由 Redis 负载决定,也无法限制。所以,存在内存碎片也是正常的。

  • mem_fragmentation_ratio 大于 1.5 。这表明内存碎片率已经超过了 50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了。

如何清理内存碎片?

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

  1. 如果 Redis 中的数据没有持久化,那么,数据就会丢失;

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

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

内存碎片清理,简单来说,就是搬家让位,合并空间。当有数据把一块连续的内存空间分割成好几块不连续的空间时,操作系统就会把数据拷贝到别处。此时,数据拷贝需要能把这些数据原来占用的空间都空出来,把原本不连续的内存空间变成连续的空间。否则,如果数据拷贝后,并没有形成连续的内存空间,这就不能算是清理了。(有点像JVM新生代空间清理)

需要注意的是:碎片清理是有代价的,操作系统需要把多份数据拷贝到新位置,把原有空间释放出来,这会带来时间开销。因为 Redis 是单线程,在数据拷贝时,Redis 只能等着,这就导致 Redis 无法及时处理请求,性能就会降低。而且,有的时候,数据拷贝还需要注意顺序,这种对顺序性的要求,会进一步增加 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 而言,是个潜在的风险。如果你在实践过程中遇到 Redis 性能变慢,记得通过日志看下是否正在进行碎片清理。如果 Redis 的确正在清理碎片,那么,我建议你调小 active-defrag-cycle-max 的值,以减轻对正常请求处理的影响。

如果 mem_fragmentation_ratio 小于 1 了,Redis 的内存使用是什么情况呢?会对 Redis 的性能和内存空间利用率造成什么影响?

mem_fragmentation_ratio小于1,说明used_memory_rss小于了used_memory,这意味着操作系统分配给Redis进程的物理内存,要小于Redis实际存储数据的内存,也就是说Redis没有足够的物理内存可以使用了,这会导致Redis一部分内存数据会被换到Swap中,之后当Redis访问Swap中的数据时,延迟会变大,性能下降。

Redis缓冲区

缓冲区的功能其实很简单,主要就是用一块内存空间来暂时存放命令数据,以免出现因为数据和命令的处理速度慢于发送速度而导致的数据丢失和性能问题。但因为缓冲区的内存空间有限,如果往里面写入数据的速度持续地大于从里面读取数据的速度,就会导致缓冲区需要越来越多的内存来暂存数据。当缓冲区占用的内存超出了设定的上限阈值时,就会出现缓冲区溢出。

客户端输入和输出缓冲区

为了避免客户端和服务器端的请求发送和处理速度不匹配,服务器端给每个连接的客户端都设置了一个输入缓冲区和输出缓冲区,我们称之为客户端输入缓冲区和输出缓冲区。如下图:

avatar

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

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

  1. 写入了 bigkey,比如一下子写入了多个百万级别的集合类型数据;

  2. 服务器端处理请求的速度过慢,例如,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 mu...

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

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

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

缓冲区调大

Redis 的客户端输入缓冲区大小的上限阈值,在代码中就设定为了 1GB。也就是说,Redis服务器端允许为每个客户端最多暂存 1GB 的命令和数据。1GB 的大小,对于一般的生产环境已经是比较合适的了。一方面,这个大小对于处理绝大部分客户端的请求已经够用了; 所以:Redis 并没有提供参数让我们调节客户端输入缓冲区的大小。那我们就只能从数据命令的发送和处理速度入手,也就是前面提到的避免客户端写入 bigkey,以及避免 Redis 主线程阻塞。

输出缓冲区溢出

Redis 为每个客户端设置的输出缓冲区也包括两部分:一部分,是一个大小为 16KB的固定缓冲空间,用来暂存 OK 响应和出错信息;另一部分,是一个可以动态增加的缓冲空间,用来暂存大小可变的响应结果。那什么情况下会发生输出缓冲区溢出呢? 总结有三种:

  1. 服务器端返回 bigkey 的大量结果;

  2. 执行了 MONITOR 命令;

  3. 缓冲区大小设置得不合理。

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

  1. 设置缓冲区大小的上限阈值;

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

对于和 Redis 实例进行交互的应用程序来说,主要使用两类客户端和 Redis 服务器端交互,分别是常规和 Redis 服务器端进行读写命令交互的普通客户端,以及订阅了 Redis 频道的订阅客户端。当我们给普通客户端设置缓冲区大小时,通常可以在 Redis 配置文件中进行这样的设置:

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

其中,normal 表示当前设置的是普通客户端,第 1 个 0 设置的是缓冲区大小限制,第 2个 0 和第 3 个 0 分别表示缓冲区持续写入量限制和持续写入时间限制。对于普通客户端来说,它每发送完一个请求,会等到请求结果返回后,再发送下一个请求,这种发送方式称为阻塞式发送。在这种情况下,如果不是读取体量特别大的 bigkey,服务器端的输出缓冲区一般不会被阻塞的。

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

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

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

总结下如何应对输出缓冲区溢出:

  1. 避免 bigkey 操作返回大量数据结果;

  2. 避免在线上环境中持续使用 MONITOR 命令。

  3. 使用 client-output-buffer-limit 设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限。

主从集群中的缓冲区

主从集群间的数据复制包括全量复制和增量复制两种。全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。无论在哪种形式的复制中,为了保证主从节点的数据一致,都会用到缓冲区。但是,这两种复制场景下的缓冲区,在溢出影响和大小设置方面并不一样。

复制缓冲区的溢出问题

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

avatar

所以,如果在全量复制时,从节点接收和加载 RDB 较慢,同时主节点接收到了大量的写命令,写命令在复制缓冲区中就会越积越多,最终导致溢出。复制缓冲区一旦发生溢出,主节点也会直接关闭和从节点进行复制操作的连接,导致全量复制失败。那如何避免复制缓冲区发生溢出呢?

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

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

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

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

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

avatar

复制积压缓冲区就是我们之前学的 repl_backlog_buffer。首先,复制积压缓冲区是一个大小有限的环形缓冲区。当主节点把复制积压缓冲区写满后,会覆盖缓冲区中的旧命令数据。如果从节点还没有同步这些旧命令数据,就会造成主从节点间重新开始执行全量复制。其次,为了应对复制积压缓冲区的溢出问题,我们可以调整复制积压缓冲区的大小,也就是设置 repl_backlog_size 这个参数的值。(具体看主从同步那节)

小结

这节课,我们一起学习了 Redis 中使用的缓冲区。使用缓冲区以后,当命令数据的接收方处理速度跟不上发送方的发送速度时,缓冲区可以避免命令数据的丢失。

按照缓冲区的用途,例如是用于客户端通信还是用于主从节点复制,我把缓冲区分成了客户端的输入和输出缓冲区,以及主从集群中主节点上的复制缓冲区和复制积压缓冲区。这样学习的好处是,你可以很清楚 Redis 中到底有哪些地方使用了缓冲区,那么在排查问题的时候,就可以快速找到方向——从客户端和服务器端的通信过程以及主从节点的复制过程中分析原因。

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

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

  • 缓冲区溢出导致命令数据丢失:主节点上的复制积压缓冲区属于环形缓冲区,一旦发生溢出,新写入的命令数据就会覆盖旧的命令数据,导致旧命令数据的丢失,进而导致主从节点重新进行全量复制。

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

  • 针对命令数据发送过快过大的问题,对于普通客户端来说可以避免 bigkey,而对于复制缓冲区来说,就是避免过大的 RDB 文件。

  • 针对命令数据处理较慢的问题,解决方案就是减少 Redis 主线程上的阻塞操作,例如使用异步的删除操作。

  • 针对缓冲区空间过小的问题,解决方案就是使用 client-output-buffer-limit 配置项设置合理的输出缓冲区、复制缓冲区和复制积压缓冲区大小。当然,我们不要忘了,输入缓冲区的大小默认是固定的,我们无法通过配置来修改它,除非直接去修改 Redis 源码。

应用程序和Redis实例交互时,应用程序中使用的客户端需要使用缓冲区吗?如果使用的话,对Redis的性能和内存使用有什么影响?

  1. 客户端和服务端交互,一般都会制定一个交互协议,客户端给服务端发数据时,都会按照这个协议把数据拼装好,然后写到客户端buffer中,客户端再一次性把buffer数据写到操作系统的网络缓冲区中,最后由操作系统发送给服务端。这样服务端就能从网络缓冲区中读取到一整块数据,然后按照协议解析数据即可。使用buffer发送数据会比一个个发送数据到服务端效率要高很多。

  2. 客户端还可以使用Pipeline批量发送命令到服务端,以提高访问性能。不使用Pipeline时,客户端是发送一个命令、读取一次结果。而使用Pipeline时,客户端先把一批命令暂存到buffer中,然后一次性把buffer中的命令发送到服务端,服务端处理多个命令后批量返回结果,这样做的好处是可以减少来回网络IO的次数,降低延迟,提高访问性能。当然,Redis服务端的buffer内存也会相应增长,可以控制好Pipeline命令的数量防止buffer超限。

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

Redis慢操作

Redis慢日志

Redis 的慢查询日志记录了执行时间超过一定阈值的命令操作。当我们发现 Redis 响应变慢、请求延迟增加时,就可以在慢查询日志中进行查找,确定究竟是哪些命令执行时间很长。在使用慢查询日志前,我们需要设置两个参数:

  • slowlog-log-slower-than:这个参数表示,慢查询日志对执行时间大于多少微秒的命令进行记录。

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

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

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

1
2
3
4
5
6
7
8
SLOWLOG GET 1
1) 1) (integer) 33 //每条日志的唯一ID编号
2) (integer) 1600990583 //命令执行时的时间戳
3) (integer) 20906 //命令执行的时长,单位是微秒
4) 1) "keys" //具体的执行命令和参数
2) "abc*"
5) "127.0.0.1:54793" //客户端的IP和端口号
6) "" //客户端的名称,此处为空

有了慢查询日志后,我们就可以快速确认,究竟是哪些命令的执行时间比较长,然后可以反馈给业务部门,让业务开发人员避免在应用 Redis 的过程中使用这些命令,或是减少操作的数据量,从而降低命令的执行复杂度。

latency monitor 监控工具

Redis 从 2.8.13 版本开始,还提供了 latency monitor 监控工具,这个工具可以用来监控 Redis 运行过程中的峰值延迟情况。和慢查询日志的设置相类似,要使用 latency monitor,首先要设置命令执行时长的阈值。当一个命令的实际执行时长超过该阈值时,就会被 latency monitor 监控到。比如,我们可以把 latency monitor 监控的命令执行时长阈值设为 1000 微秒,如下所示:

1
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 //最大的超过阈值的延迟

Redis Bigkey 排查

在应用 Redis 时,我们要尽量避免 bigkey 的使用,这是因为,Redis 主线程在操作bigkey 时,会被阻塞。那么,一旦业务应用中使用了 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. 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大。

所以,如果我们想统计每个数据类型中占用内存最多的前 N 个 bigkey,可以自己开发一个程序,来进行统计。

这里提供一个基本的开发思路:使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。接下来,对于 String 类型,可以直接使用 STRLEN 命令获取字符串的长度,也就是占用的内存空间字节数。对于集合类型来说,有两种方法可以获得它占用的内存大小,如果你能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。如果你不能提前知道写入集合的元素大小,可以使用 MEMORY USAGE 命令(需要 Redis4.0 及以上版本),查询一个键值对占用的内存空间。例如,执行以下命令,可以获得 key为 user:info 这个集合类型占用的内存空间大小。

1
2
MEMORY USAGE user:info
(integer) 315663239

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

最后更新: 2021年04月23日 17:23

原始链接: https://jjw-story.github.io/2021/03/16/Redis-实践一/

× 请我吃糖~
打赏二维码