16-20
16 | 异步机制:如何避免单线程模型的阻塞?
Redis
实例的阻塞点
Redis
实例交互的对象以及交互时会发生的操作- 客户端: 网络IO、键值对增删改查操作、数据库操作
- 磁盘: 生成RDB快照、记录AOF日志、AOF日志重写
- 主从节点: 主库生成/传输RDB文件、从库接收RDB文件/清空数据库/加载RDB文件
- 切片集群实例: 向其他实例传输哈希槽信息、数据迁移
引起阻塞的操作
1. 和客户端交互时的阻塞点- 网络IO:
Redis
使用了IO多路复用机制
,避免了主线程一直处于等待网络链接或者请求到来的状态,因此网络IO不是导致Redis阻塞的原因 - 键值对增删改查操作: 这部分是
Redis
和客户端交互的主要部分,也是Redis主线程
执行的主要任务,因此,复杂度高的增删改查操作肯定会阻塞Redis
。
判断复杂度的最基本标准: 看操作的复杂度是否为O(N)- 第一个阻塞点-集合全量查询和聚合操作:
Redis
中涉及集合的操作复杂度通常为 O(N),例如集合元素全量查询操作:HGETALL
、SMEMBERS
,以及集合的聚合统计操作: 求教、并、差集 - 第二个阻塞点-
bigkey
的删除操作:
删除操作的本质就是要释放键值对占用的内存空间。为了更加高效地管理内存空间,在应用程序释放内存时,操作系统要把释放掉的内存块插入一个空闲内存块的链表,以便进行管理和再分配。这个过程本身就需要一定的时间,而且会阻塞当前释放内存的应用程序,因此如果一下子释放大量内存,空闲内存块链表的操作时间就会增加,相应的造成Redis主线程
的阻塞
大量释放内存最典型的场景就是删除包含了大量元素的集合(bigkey删除
) - 第三个阻塞点-清空数据库: 原因与同
bigkey删除
- 第一个阻塞点-集合全量查询和聚合操作:
2. 和磁盘交互时的阻塞点
- 为了避免磁盘IO导致阻塞,Redis采用子进程的方式生成
RDB快照文件
以及执行AOF日志重写操作
,这两个操作由子进程负责执行,慢速的磁盘IO就不会阻塞主线程了 - 第四个阻塞点-AOF日志同步写: Redis直接记录日志时会根据不同的写回策略对数据做落盘处理,一个同步写磁盘的操作耗时大概
1-2ms
,如果有大量的写操作需要记录在AOF日志
中并同步写回的话就会阻塞主线程
3. 主从节点交互时的阻塞点
- 主从集群中,主库需要生成
RDB文件
并传输给从库,主从复制的过程中,创建和传输RDB文件
都是由子进程完成的,不会阻塞主线程。但是对于从库来说,在接收RDB文件
后需要使用FLUSHDB
命令清空当前数据库,于是有可能遇到第三个阻塞点的情况 - 第五个阻塞点-加载RDB文件: 在从库清空数据库后需要把
RDB文件
加载到内存中,这个过程的快慢和RDB文件
的大小密切相关,RDB文件
越大,加载过程越慢
4. 切片集群实例交互时的阻塞点
- 部署切片集群时,每个
Redis实例
上分配的哈希槽信息需要在不同实例间进行传输,当进行负载均衡或者有实例增减时,数据会在不同的实例间进行迁移,但是哈希槽的信息量不大,而数据迁移是渐进式执行的,所以这两类操作一般对Redis主线程
阻塞风险不大 - 使用
Redis Cluster
方案,并且同时迁移的是bigkey
的话就会造成主线程的阻塞,因为Redis Cluster
使用了同步迁移
五个阻塞点
- 集合全量查询和聚合操作
- bigkey删除
- 清空数据库
- AOF日志同步写
- 从库加载RDB文件
- 在主线程中执行这类操作必然会导致主线程长时间无法服务其他请求,为了避免阻塞式操作,
Redis
提供了异步线程操作,所谓异步线程,就是指Redis会启动一些子线程,然后把任务交给这些子线程在后台完成,因此可以避免阻塞主线程
- 网络IO:
可以异步执行的阻塞点
- 异步执行对操作的要求: 如果一个操作能被异步执行,就意味着他不是
Redis主线程
关键路径上的操作(客户端发请求发送给Redis后,等待Redis返回数据结果的操作)
非关键路径操作:- 对Redis来说,读操作的典型的关键路径操作,因为客户端发送读操作后就会等待读取的数据返回以便进行后续的数据处理,而第一个阻塞点
集合全量查询和聚合操作
都涉及到了读操作,因此不能进行异步操作 - 删除操作并不需要给客户端返回具体数据结果,不算关键路径操作,因此第二、三个阻塞点
bigkey删除
和清空数据库
可以进行异步操作 - 为了保证数据可靠性,Redis实例需要保证AOF日志中的
操作记录
已经落盘
,这个操作虽然需要实例等待,但是并不会返回具体数据结果给实例,因此可以启动子线程执行AOF日志的同步写,而不用让主线程等待 - 从库想要对客户端提供数据存取服务,就必须把
RDB文件
加载完成,因此这个操作也属于关键路径操作,必须由从库主线程执行
- 对Redis来说,读操作的典型的关键路径操作,因为客户端发送读操作后就会等待读取的数据返回以便进行后续的数据处理,而第一个阻塞点
异步的子线程机制
- Redis异步子线程执行机制:
Redis主线程启动后,使用操作系统提供的pthread_create函数
创建三个子线程,分别负责AOF日志写操作
、键值对删除
以及文件关闭的异步执行
主线程通过一个链表形式的任务队列和子线程进行交互,当收到键值对删除和清空数据库操作时,主线程会把这些操作封装成一个任务放入任务队列中,然后给客户端返回一个完成信息表明操作已完成,此时实际上操作还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对并释放内存空间,这种异步删除称为惰性删除(lazy free)
,此时的删除、清空操作并不会阻塞主线程,避免了对主线程的性能影响
与惰性删除相似,当AOF日志
配制成everysec
选项后,主线程会把AOF写日志操作封装成一个任务,也放到任务队列中,后台子线程读取任务后开始自行写入AOF日志,主线程不用一直等待AOF日志写完 - 异步的键值对删除和数据库清空操作是
Redis 4.0
后提供的功能,Redis提供两个新的命令来执行这两个操作:- 键值对删除: 当集合类型中有大量元素(百万级别以上)需要删除时,建议使用
UNLINK
命令 - 清空数据库: 可以在
FLUSHDB
和FLUSHALL
命令后加上ASYNC
参数,以异步的方式清空数据库
- 键值对删除: 当集合类型中有大量元素(百万级别以上)需要删除时,建议使用
无法异步时的阻塞点优化
- 当
Redis
版本为4.0之前
时,可以使用集合类型提供的SCAN
命令读取数据然后再进行删除,因为 SCAN 命令可以每次只读取一小部分数据并进行删除,可以避免一次性删除大量 key 导致的主线程阻塞 - 集合全量查询和聚合操作: 可以使用 SCAN 命令分批读取数据,在客户端进行聚合计算
- 从库加载RDB文件: 把主库数据量大小控制在
2-4 GB
左右,以保证RDB文件能以较快的速度加载
思考: 写操作是否在关键路径上
- 客户端是否需要确认写入完成
- 读写一致性: 写完马上读取,是否会造成数据脏读,能否保证数据准确性
17 | 为什么CPU结构也会影响Redis的性能?
主流CPU架构
- 一个CPU处理器中有多个运行核心,一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核拥有私有的
一级缓存(Level 1 cache,称为 L1 cache)
,包括一级指令缓存
和一级数据缓存
,以及私有的二级缓存(Level 2 cache,称为 L2 cache)
- 私有缓存: 指缓存空间只能被当前这个物理核使用,其他物理核无法对这个核的缓存空间进行数据存取
- 三级缓存(Level 3 cache,简称 L3 cache): 不同物理核共享的的缓存空间,一般比较大,能达到
几MB-几十MB
,能让应用程序缓存更多的数据,当L1、L2中没有数据缓存时可以访问L3,尽量避免访问内存 - 逻辑核: 主流的CPU处理器中,每个物理核通常会运行两个超线程,也叫逻辑核,同一个物理核的两个逻辑核会共享使用L1、L2缓存
- 关系图:
- 主流服务器上,一个CPU处理器会有
10-20+
个物理核,同时为了提升服务器的处理能力,服务器上通常还会有都个CPU处理器(也称为多CPU Socket),每个处理器有自己的物理核(包括L1、L2缓存)、L3缓存以及连接的内存,不同处理器间通过总线连接
多CPU Socket架构:
在多CPU架构上,应用程序可以在不同的处理器上运行
远端内存访问: 应用程序先在一个 Socket 上运行,并且把数据保存到内存,然后被调度到另一个 Socket 上运行,此时应用程序再进行内存访问,就需要访问之前 Socket 上连接的内存。比起访问和 Socket 直接连接的内存,远端内存访问会增加应用程序的延迟
非统一内存访问架构(Non-Uniform Memory Access,NUMA架构): 在多CPU架构下,一个应用程序访问所在 Socket 的本地内存和访问远端内存的延迟并不一致 - CPU架构对应用程序运行的影响
- L1、L2缓存中的指令和数据的访问速度很快,因此充分利用L1、L2缓存可以有效缩短应用程序的执行时间
- 在NUMA架构下,如果应用程序从一个 Socket 上调度到另一个 Socket 上,就可能出现远端内存访问的情况,会直接增加应用程序的执行时间
CPU多核对Redis性能的影响
- 运行时信息: 在一个CPU核上运行时,应用程序需要记录自身使用的软硬件资源信息(例如栈指针、CPU核的寄存器值等),这些信息称为运行时信息,应用程序访问最频繁的指令和数据会被缓存到L1、L2上,以便提升运行速度,在多核CPU的场景下,一旦应用程序需要在一个新的CPU核上运行,运行时数据需要重新加载到新的CPU核上,新的CPU核上的L1、L2缓存也需要重新加载数据和指令,会导致程序的运行时间增加
- 尾延迟: 把所有请求的处理延迟从小到大排序,99%的请求延迟小于的之就是99%尾延迟,例子: 1000个请求,排序后第991个请求的延迟是1ms,而前990请求的延迟都小于1ms,99%尾延迟就是1ms
- CPU多核场景下Redis实例被频繁调度到不同CPU核上运行时,会发生
context switch(上下文切换)
,Redis主线程的运行时数据需要被重新加载到另一个核上,Redis实例需要等待这个重新加载的过程完成后才能开始处理请求 - 使用 taskset 命令可以把一个程序绑定在一个核上运行,在CPU多核场景下把Redis实例和CPU核绑定,可以有效降低尾延迟,同样也能降低平均延迟、提升吞吐率,进而提升Redis性能
- 尾延迟对比:
CPU的NUMA架构对Redis性能的影响
- Redis实例和网络中断程序的数据交互: 网络中断处理程序从网卡硬件中读取数据并写入操作系统内核维护的一块内存缓冲区,内核通过
epoll机制
触发事件通知Redis实例,Redis实例再把数据从内核的内存缓冲区拷贝到自己的内存空间 - 在CPU的NUMA架构下,当网络中断处理程序、Redis实例分别与CPU核绑定后,会有一个潜在风险: 当网络中断处理程序和Redis实例各自绑定的CPU核不在同一个 CPU Socket 上时,Redis实例读取网络数据需要跨CPU Socket访问内存,花费较多时间
解决方案: 把网络中断处理程序和Redis实例绑定在同一个CPU Socket上,这样Redis实例就能直接从本地内存读取网络数据 - NUMA架构下CPU核的编码规则: 先给每个 CPU Socket 中每个物理核的第一个逻辑核一次编号,再给每个 CPU Socket 中物理核的第二个逻辑核依次编号
绑核的风险和解决方案
- 风险: Redis 中除了主线程之外,还有用于 RDB 生成和 AOF 重写的子进程,当把 Redis 实例绑定到一个CPU逻辑核上时,会导致子进程、后台线程和Redis主线程竞争CPU资源,一旦子进程或后台线程占用CPU,主线程就会阻塞,导致Redis请求延迟增加
- 解决方案:
- 一个Redis实例对应绑定一个物理核: 不与逻辑核绑定,和物理核绑定,可以使用物理核上的两个逻辑核,使主线程、子进程、后台线程共享使用两个逻辑核,在一定程度上缓解CPU资源竞争
2 优化Redis源码: 修改Redis源码,把子进程和后台线程绑定到不同的CPU核上,可以实现Redis实例绑核,避免切换核带来的性能影响,还可以避免子进程、后台线程和Redis主线程的CPU资源竞争,相比于使用 taskset 绑核的操作性能风险更低
- 一个Redis实例对应绑定一个物理核: 不与逻辑核绑定,和物理核绑定,可以使用物理核上的两个逻辑核,使主线程、子进程、后台线程共享使用两个逻辑核,在一定程度上缓解CPU资源竞争
18 | 波动的响应延迟:如何应对变慢的Redis?(上)
- Redis变慢的连锁反应: 事务性操作中,需要保证事务的原子性,此时如果 Redis 延迟增加,就会拖慢整个事务的执行,进而可能导致其他操作中占用的资源无法释放,进而导致其他服务的请求被阻塞
判断Redis是否真的变慢
- 判断 Redis 是否变慢的方法:
- 查看 Redis 的响应延迟
- 基于当前环境下的
Redis基线性能
做判断
基线性能: 一个系统在低压力、无干扰下的基本性能,这个性能只由当前的软硬件配置决定
从 2.8.7 版本开始,Redis-cli 命令提供了 -intrinsic-latency 选项,可以用来监测和统计测试期间的最大延迟,这个延迟可以作为Redis的基线延迟
将基线延迟和 Redis 运行时的延迟结合起来,再进一步判断 Redis 性能是否变慢,Redis运行时延迟是其基线延迟的两倍及以上时,就可以认定 Redis 变慢了
如何应对Redis变慢
- 影响Redis性能的三大要素: Redis自身的操作特性、文件系统和操作系统
Redis自身操作特性的影响
- 慢查询指令
指在 Redis 中执行速度慢的指令,会导致 Redis 延迟增加,与命令操作的复杂度有关(官方文档中对每个命令的复杂度都有介绍)
- 发现 Redis 性能变慢时,可以通过
Redis日志
或者latency monitor
工具查询变慢的请求 - 处理方式:
(1) 使用其他高效命令代替
(2) 当需要执行排序、交集、并集操作时,可以在客户端完成,而非使用SORT、SUNION、SINTER
这类命令,以免拖慢Redis实例 KEYS
命令是一个比较容易忽略的慢查询命令,因为需要遍历存储的键值对
,所以操作延时高,一般不建议用于生产环境中
- 过期key操作
- 过期key的自动删除机制:
Redis 用来回收内存空间的常用机制,应用广泛,本身就会引起Redis操作阻塞,导致性能变慢
默认情况下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(20 * 10
)个过期的key会被删除- Redis 一旦触发清除过期key的第二条算法,就会一直删除以释放内存空间,删除操作是阻塞的(在 Redis 4.0 版本后可以使用异步线程机制来减少阻塞影响),因此一旦触发该条件,就会造成Redis无法正常服务其他键值操作,进一步引起其他操作的延迟增加
- 第二条算法触发条件: 频繁使用带有相同时间参数的 EXPIREAT 命令设置过期 Key,这就会导致同一时间内有大量key同时过期
19 | 波动的响应延迟:如何应对变慢的Redis?(下)
影响性能的其他机制: 文件系统和操作系统
- Redis 会持久化保存数据到磁盘,这个过程要依赖文件系统来完成,因此文件系统将数据写回磁盘的机制会直接影响到
Redis持久化
的效率,在持久化的过程中,Redis仍然接收其他请求,因此持久化的效率又会影响到Redis处理请求的性能 - Redis 是内存数据库,内存操作非常频繁,所以操作系统的内存机制会直接影响到 Redis 的处理效率,比如当 Redis 的内存不够用时,操作系统会启动
swap
机制,这会直接拖慢 Redis
文件系统: AOF模式
AOF日志
提供了三种日志写回策略:no
、everysec
、always
,这三种写回策略依赖文件系统的两个系统调用
完成:write
和fsync
everysec
: Redis 允许丢失1秒的操作记录,因此Redis主线程不需要确保每个操作记录日志都写回磁盘。并且 fsync 的执行时间很长,如果在Redis主线程中执行fsync很容易阻塞主线程,因此Redis会使用后台的子线程异步完成 fsync 的操作always
: Redis需要确保每个操作日志写回磁盘,如果使用后台子线程异步完成,Redis主线程就无法及时知道每个操作是否已完成,这就不符合always策略的要求,因此不使用后台子线程执行
- 在使用 AOF日志 时为了避免日志文件不断增大,redis会执行 AOF重写,生成体量缩小的 AOF日志文件,AOF重写 本身需要的时间很长,容易阻塞主线程,因此使用后台子线程完成 AOF重写
- 潜在风险点: AOF重写 会对磁盘进行
大量IO操作
,同时 fsync 需要等数据写到磁盘后才能返回,因此当 AOF重写 的压力较大时,会导致 fsync 被阻塞,虽然 fsync 由后台子线程执行,但是主线程会监控 fsync 的执行进度,因此当主线程发现上一次 fsync 还没执行完的时候就会阻塞,如果后台子线程执行的 fsync 频繁阻塞的话(比如AOF重写 占用了大量的磁盘IO带宽
),主线程也会阻塞,导致Redis性能下降 - appendfsync配置项
no-appendfsync-on-rewrite
: 设置为yes时,表示在AOF重写时不进行 fsync 操作
,即 Redis实例 把写命令写入内存后不调用后台线程进行 fsync操作就可以直接返回,此时如果实例发生宕机就会导致数据丢失
操作系统: swap(潜在瓶颈)
- 内存 swap: 操作系统里将内存数据在内存和磁盘间
来回换入和换出的机制
,涉及到磁盘的读写,一旦触发 swap,无论是被换入数据的进程还是被换出数据的进程,性能都会受到慢速磁盘读写的影响 - 正常情况下Redis的操作是直接通过访问内存完成的,一旦触发 swap,Redis的请求操作就需要等待磁盘数据读写完成,与 AOF日志文件读写使用 fsync 不同,swap 触发后影响的是
Redis主IO线程
,这会极大增加 Redis 的响应时间 - 触发原因: 主要是物理机器内存不足
- Redis实例自身使用大量内存导致物理机器可能内存不足
- 和 Redis实例 在同一台机器上运行的其他进程在进行大量的文件读写操作,文件读写本身会占用系统内存,导致分配给 Redis实例 的内存两变少,进而触发 Redis 发生 swap
- 解决方案: 增加机器内存(
加钱
)或者使用Redis集群(== 加机器 == 加钱
)
操作系统: 内存大页
内存大页机制: Linux内核 从而
2.6.38
开始支持的,该机制支持2MB
大小的内存页分配(常规的内存页分配按4KB
的粒度执行)使用内存大页给 Redis 带来的影响:
- 优势: 内存分配方面的收益,分配相同内存量时可以减少分配次数
- 劣势: Redis 为了提供数据可靠性保证,需要对数据进行持久化保存,这个写入过程由额外的线程执行,此时 Redis主线程 仍然可以接收客户端写请求,写请求可能会修改正在进行持久化的数据,在这个过程中 Redis 会采用
写时复制机制
: 一旦有数据要被修改,Redis并不会直接修改内存中的数据,而是将数据拷贝一份然后再进行修改,如果使用内存大页,即使客户端只修改 100B 的数据,Redis也需要拷贝 2MB 的大页,使用常规内存页机制则只需要拷贝 4KB
因此当客户端请求修改或者新写入数据较多时,内存大页将导致大量拷贝,影响Redis正常的访问内存操作,最终导致性能下降
Redis 性能变慢的 CheckList
- 获取 Redis实例 当前环境下的
基线性能
- 是否使用
慢查询命令
- 是否对过期key设置了
相同的过期时间
- 是否存在
bigkey
- Redis AOF 配置级别
- Redis实例 的内存使用是否过大,是否发生 swap
- Redis实例 的运行环境中是否使用了
内存大页机制
- 是否运行了 Redis主从集群(主从复制时,从库加载
RDB文件过大
导致阻塞) - 是否使用
多核CPU
或者NUMA架构
运行 Redis实例
20 | 删除数据后,为什么内存占用率还是很高?
- 删除数据后内存占用率还是很高: 当数据删除后,Redis 释放的内存空间会由内存分配器管理,不会立即返回给操作系统,操作系统仍然会记录着给 Redis 分配了大量内存
- 潜在风险点: Redis 释放的内存空间可能并不是连续的,这些不连续的内存空间很有可能处于一种闲置的状态,导致虽然有内存空间,但是Redis却无法用来保存数据,导致Redis 实际保存内存量的减少和 Redis运行机器成本回报率的降低
内存碎片:
- 操作系统中剩余的非连续内存空间称为内存碎片
内存碎片的形成
内因: 内存分配器的策略
- 内存分配器一般
按照固定大小
来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配 ,这就决定了操作系统无法做到按需分配
- Redis 可以使用 libc、jemalloc、tcmalloc多种内存分配器来分配内存,默认使用jemalloc
- jemalloc: 按照一系列固定的大小划分内存空间(例如: 8字节、16字节、32字节、...、2KB、4KB、8KB等),当程序申请的内存最接近某个固定值时,jemalloc 就会给它分配相应大小的空间
这样的分配方式本身是为了减少分配次数,但如果Redis每次向分配器申请的内存空间大小不一致,就有形成碎片的风险(这正好来源于 Redis 的外因)
外因: 键值对大小不一样和删改操作
- Redis 通常作为共用的缓存系统或键值数据库对外提供服务,不同业务应用的数据都有可能保存在Redis中,这就会带来不同大小的键值对,这样一来,Redis 申请内存空间分配时本身就会有大小不一的空间需求
- Redis 中的键值对会被
修改和删除
,会导致空间的扩容和释放。具体来说就是,如果修改后的键值对变大或者变小了,就需要占用额外的空间或者释放不用的空间,而删除的键值对则不再需要内存空间,此时就会释放形成空闲空间