Redis 面试题整理

Redis基础

单线程的redis为什么这么快

  1. 纯内存操作
  2. 单线程操作,避免了频繁的上下文切换
  3. 采用了非阻塞I/O多路复用机制

Redis 优点

  1. 速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)
  2. 支持丰富数据类型,支持string,list,set,sorted set,hash
  3. 支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行
  4. 丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除

Redis 为什么是单线程的

官方FAQ表示,因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。

既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了(毕竟采用多线程会有很多麻烦!)Redis利用队列技术将并发访问变为串行访问
1)绝大部分请求是纯粹的内存操作(非常快速)

2)采用单线程,避免了不必要的上下文切换和竞争条件

3)非阻塞IO优点采用了非阻塞I/O多路复用机制

redis的数据类型

  1. String
    这个其实没啥好说的,最常规的set/get操作,value可以是String也可以是数字。一般做一些复杂的计数功能的缓存。

  2. hash
    这里value存放的是结构化的对象,比较方便的就是操作其中的某个字段。

  3. list
    使用List的数据结构,可以做简单的消息队列的功能。另外还有一个就是,可以利用lrange命令,做基于redis的分页功能,性能极佳,用户体验好。本人还用一个场景,很合适—取行情信息。就也是个生产者和消费者的场景。LIST可以很好的完成排队,先进先出的原则。

  4. set
    因为set堆放的是一堆不重复值的集合。所以可以做全局去重的功能。为什么不用JVM自带的Set进行去重?因为我们的系统一般都是集群部署,使用JVM自带的Set,比较麻烦,难道为了一个做一个全局去重,再起一个公共服务,太麻烦了。
    另外,就是利用交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。

  5. sorted set
    sorted set多了一个权重参数score,集合中的元素能够按score进行排列。可以做排行榜应用,取TOP N操作。

  6. bit arrays

    简单的位映射

  7. hyperloglogs

    概率数据结构

Redis数据类型实现

左边是 Redis 3.0版本的,右边是现在 Redis 7.0 版本的。

image-20230507173135110

string 内部实现

String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。 SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串:

  • SDS 不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。
  • SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。
  • Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。

List 内部实现

List 类型的底层数据结构是由双向链表或压缩列表实现的:

  • 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

Hash内部实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

Set 内部实现

Set 类型的底层数据结构是由哈希表或整数集合实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

Zset 内部实现

Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表skiplist作为 Zset 类型的底层数据结构;

redis 的 skiplist(组合了hashskipList

  • hash用来存储value到score的映射,这样就可以在O(1)时间内找到value对应的分数;
  • skipList按照从小到大的顺序存储分数;
  • skipList每个元素的值都是[score,value]对

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Redis 内部结构

  • dict: 本质上是为了解决算法中的查找问题(Searching)是一个用于维护key和value映射关系的数据结构,与很多语言中的Map或dictionary类似。 本质上是为了解决算法中的查找问题(Searching)
  • sds: sds就等同于char * 它可以存储任意二进制数据,不能像C语言字符串那样以字符’\0’来标识字符串的结 束,因此它必然有个长度字段。
  • skiplist: (跳跃表) 跳表是一种实现起来很简单,单层多指针的链表,它查找效率很高,堪比优化过的二叉平衡树,且比平衡树的实现容易。skipListNode的level根据幂次定律确定。
  • quicklist: 实际上是 zipList 和 linkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。
  • ziplist: 压缩表 ziplist是一个编码后的列表,是由一系列特殊编码的连续内存块组成的顺序型数据结构,

Redis 原子性

对于Redis而言,命令的原子性指的是:一个操作的不可以再分,操作要么执行,要么不执行。

Redis的操作之所以是原子性的,是因为Redis是单线程的。

Redis本身提供的所有API都是原子操作,Redis中的事务其实是要保证批量操作的原子性。

Redis持久化

Redis 持久化机制

Redis是一个支持持久化的内存数据库,通过持久化机制把内存中的数据同步到硬盘文件来保证数据持久化。当Redis重启后通过把硬盘文件重新加载到内存,就能达到恢复数据的目的。

持久化两种方式

  • RDB: 是Redis默认的持久化方式,按照一定的时间周期策略把内存的数据以快照的形式保存到硬盘的二进制文件。即Snapshot快照存储,对应产生的数据文件为dump.rdb,通过配置文件中的save参数来定义快照的周期。( 快照可以是其所表示的数据的一个副本,也可以是数据的一个复制品。)
  • AOF:Redis会将每一个收到的写命令都通过Write函数追加到文件最后,类似于MySQL的binlog。当Redis重启是会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容。
    当两种方式同时开启时,数据恢复Redis会优先选择AOF恢复。

AOF 日志

在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:

image-20230507181051892

AOF 写入过程

image-20230507181146011

  1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
  2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
  3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。

AOF 写回策略

Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。

redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

  • Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
  • Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

深入到源码后,你就会发现这三种策略只是在控制 fsync() 函数的调用时机。

当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。

image-20230507181312015

如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是永不执行 fsync() 函数

AOF重写机制

OF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。

如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。

所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

AOF 后台重写

写入 AOF 日志的操作虽然是在主进程完成的,因为它写入的内容不多,所以一般不太影响命令的操作。

但是在触发 AOF 重写时,比如当 AOF 文件大于 64M 时,就会对 AOF 文件进行重写,这时是需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后将其写入到新的 AOF 文件,重写完后,就把现在的 AOF 文件替换掉。

这个过程其实是很耗时的,所以重写的操作不能放在主进程里。

所以,Redis 的重写 AOF 过程是由后台子进程 *bgrewriteaof* 来完成的,这么做可以达到两个好处:

  • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
  • 子进程带有主进程的数据副本(数据副本怎么产生的后面会说),这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

子进程是怎么拥有主进程一样的数据副本的呢?

主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。

image-20230507181545125

这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读

不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(*Copy On Write*)」。

image-20230507181719242

写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

当然,操作系统复制父进程页表的时候,父进程也是阻塞中的,不过页表的大小相比实际的物理内存小很多,所以通常复制页表的过程是比较快的。

不过,如果父进程的内存数据非常大,那自然页表也会很大,这时父进程在通过 fork 创建子进程的时候,阻塞的时间也越久。

所以,有两个阶段会导致阻塞父进程:

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
  • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;

触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。

但是子进程重写过程中,主进程依然可以正常处理命令。

如果此时主进程修改了已经存在 key-value,就会发生写时复制,注意这里只会复制主进程修改的物理内存数据,没修改物理内存还是与子进程共享的

所以如果这个阶段修改的是一个 bigkey,也就是数据量比较大的 key-value 的时候,这时复制的物理内存数据的过程就会比较耗时,有阻塞主进程的风险。

还有个问题,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?

为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

image-20230507182206701

也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

  • 执行客户端发来的命令;
  • 将执行后的写命令追加到 「AOF 缓冲区」;
  • 将执行后的写命令追加到 「AOF 重写缓冲区」;

当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。

主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:

  • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
  • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

信号函数执行完后,主进程就可以继续像往常一样处理命令了。

在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。

RDB持久化实现方式

Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

RDB 文件的加载工作是在服务器启动时自动执行的,Redis 并没有提供专门用于加载 RDB 文件的命令。

Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:

1
2
3
save 900 1
save 300 10
save 60 10000

别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。

只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:

  • 900 秒之内,对数据库进行了至少 1 次修改;
  • 300 秒之内,对数据库进行了至少 10 次修改;
  • 60 秒之内,对数据库进行了至少 10000 次修改。

这里提一点,Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。

所以可以认为,执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。

RDB的写入也应用到了COPY ON WRITE 技术,和AOF进行重写的时候原理很相似。

RDB 和AOF 合体

尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握:

  • 如果频率太低,两次快照间一旦服务器发生宕机,就可能会比较多的数据丢失;
  • 如果频率太高,频繁写入磁盘和创建子进程会带来额外的性能开销。

那有没有什么方法不仅有 RDB 恢复速度快的优点和,又有 AOF 丢失数据少的优点呢?

当然有,那就是将 RDB 和 AOF 合体使用,这个方法是在 Redis 4.0 提出的,该方法叫混合使用 AOF 日志和内存快照,也叫混合持久化。

如果想要开启混合持久化功能,可以在 Redis 配置文件将下面这个配置项设置成 yes:

1
aof-use-rdb-preamble yes

混合持久化工作在 AOF 日志重写过程

当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

image-20230507182808943

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失

两种持久化方式区别

AOF文件比RDB更新频率高,优先使用AOF还原数据。

AOF比RDB更安全也更大

RDB性能比AOF好

如果两个都配了优先加载AOF

Redis实现分布式锁

Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系Redis中可以使用SETNX命令实现分布式锁。
将 key 的值设为 value ,当且仅当 key 不存在。 若给定的 key 已经存在,则 SETNX 不做任何动作

解锁:使用 del key 命令就能释放锁
解决死锁:
1)通过Redis中expire()给锁设定最大持有时间,如果超过,则Redis来帮我们释放锁。
2 ) 使用 setnx key “当前系统时间+锁持有的时间”和getset key “当前系统时间+锁持有的时间”组合的命令就可以实现。

Redis 常见线上问题

缓存雪崩

问题解释

缓存雪崩我们可以简单的理解为:由于原有缓存失效,新缓存未到期间

例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期,所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机。从而形成一系列连锁反应,造成整个系统崩溃。

*解决办法 *

大多数系统设计者考虑用加锁( 最多的解决方案)或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。还有一个简单方案就时讲缓存失效时间分散开。

缓存穿透

问题解释

缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。

这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。

这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中率问题。

解决办法

最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。通过这个直接设置的默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库,这种办法最简单粗暴。

缓存击穿

问题解释

指一个key非常热点,大并发集中对这个key进行访问,当这个key在失效的瞬间,仍然持续的大并发访问就穿破缓存,转而直接请求数据库。

解决方案

在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。

缓存预热

问题解释

缓存预热这个应该是一个比较常见的概念,相信很多小伙伴都应该可以很容易的理解,缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。

这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

解决思路
1、直接写个缓存刷新页面,上线时手工操作下;
2、数据量不大,可以在项目启动的时候自动进行加载;
3、定时刷新缓存;

缓存降级

问题描述

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。

系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。

降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

降级思路

以参考日志级别设置预案:
(1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
(2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
(3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
(4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。

redis 事物和pipeline

redis 的pipeline

pipeline出现的背景

redis客户端执行一条命令分4个过程:

发送命令-〉命令排队-〉命令执行-〉返回结果

这个过程称为Round trip time(简称RTT, 往返时间),mget mset有效节约了RTT,但大部分命令(如hgetall,并没有mhgetall)不支持批量操作,需要消耗N次RTT ,这个时候需要pipeline来解决这个问题

pepeline的性能

1、未使用pipeline执行N条命令

1592038125957

2、使用了pipeline执行N条命令

1592038134671

原生批操作和pipeline对比

1、原生批命令是原子性,pipeline是非原子性
(原子性概念:一个事务是一个不可分割的最小工作单位,要么都成功要么都失败。原子操作是指你的一个业务逻辑必须是不可拆分的. 处理一件事情要么都成功,要么都失败,原子不可拆分)

2、原生批命令一命令多个key, 但pipeline支持多命令(存在事务),非原子性
3、原生批命令是服务端实现,而pipeline需要服务端与客户端共同完成

Redis事务

pipeline是多条命令的组合,为了保证它的原子性,redis提供了简单的事务。

1、redis的简单事务

一组需要一起执行的命令放到multi和exec两个命令之间,其中multi代表事务开始,exec代表事务结束。
1592038145769

2、停止事务discard

1592038156883

redis的过期策略、内存淘汰机制

redis 的过期策略

redis采用的是 定期删除 + 惰性删除 策略。

为什么不用定时删除策略?

定时删除,用一个定时器来负责监视key,过期则自动删除。虽然内存及时释放,但是十分消耗CPU资源。

在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略.

定期删除+惰性删除是如何工作的呢?

定期删除,redis默认每个100ms检查,是否有过期的key,有过期key则删除。

需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis岂不是卡死)。

因此,如果只采用定期删除策略,会导致很多key到时间没有删除。

于是,惰性删除派上用场。也就是说在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间那么是否过期了?如果过期了此时就会删除。

redis 内存淘汰机制

采用定期删除+惰性删除就没其他问题了么?

不是的,如果定期删除没删除key。然后你也没即时去请求key,也就是说惰性删除也没生效。这样,redis的内存会越来越高。那么就应该采用内存淘汰机制。

在redis.conf中有一行配置

maxmemory-policy volatile-lru

该配置就是配内存淘汰策略的:

  • volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰

  • volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰

  • volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰

  • allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰

  • allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰

  • no-enviction(驱逐):禁止驱逐数据,新写入操作会报错

    ps:如果没有设置 expire 的key, 不满足先决条件(prerequisites); 那么 volatile-lru, volatile-random 和 volatile-ttl 策略的行为, 和 noeviction(不删除) 基本上一致。

Redis 并发问题

同时有多个子系统去set一个key。这个时候要注意什么呢?

不推荐使用redis的事务机制。因为我们的生产环境,基本都是redis集群环境,做了数据分片操作。

你一个事务中有涉及到多个key操作的时候,这多个key不一定都存储在同一个redis-server上。因此,redis的事务机制,十分鸡肋。

解决办法

(1)如果对这个key操作,不要求顺序: 准备一个分布式锁,大家去抢锁,抢到锁就做set操作即可
(2)如果对这个key操作,要求顺序: 分布式锁+时间戳。 假设这会系统B先抢到锁,将key1设置为{valueB 3:05}。接下来系统A抢到锁,发现自己的valueA的时间戳早于缓存中的时间戳,那就不做set操作了。以此类推。
(3) 利用队列,将set方法变成串行访问也可以

redis遇到高并发,如果保证读写key的一致性

对redis的操作都是具有原子性的,是线程安全的操作,你不用考虑并发问题,redis内部已经帮你处理好并发的问题了。

Redis 常见性能问题和解决方案?

(1) Master 最好不要做任何持久化工作,如 RDB 内存快照和 AOF 日志文件
(2) 如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次
(3) 为了主从复制的速度和连接的稳定性, Master 和 Slave 最好在同一个局域网内
(4) 尽量避免在压力很大的主库上增加从库
(5) 主从复制不要用图状结构,用单向链表结构更为稳定,即: Master <- Slave1 <- Slave2 <- Slave3…

Redis 主从数据同步过程

主从复制的方式

从节点复制主节点的数据后,就相当于给主从节点备份了,所谓的有备无患就是这个意思。那么主从复制的原理是怎么样的?

其实主要就是三种复制方式:持续复制、全量复制、部分复制。

持续复制

当有客户端的写命令请求到主节点后,主节点会做两件事:命令传播和将写命令写入到复制积压缓冲区。

原理图如下:
image-20220607191010777

  • 命令传播:将写命令持续发送给所有从服务器,保持主从数据一致。这个就可以理解为持续复制了。
  • 复制积压缓冲区:其实就是一个有界队列,保存着最近传播的写命令,而队列里面的每个字节都有一个偏移量标识。复制积压缓冲区的作用和原理在部分复制的时候再细讲。

全量复制

用于主从节点第一次复制的场景。
image-20220607191159560

总结全量复制的步骤

  1. 从节点给主节点发送命令;

  2. 主节点回复从节点,要开始全量复制了;

  3. 从节点保存主节点信息;

  4. 主节点开始生成 RDB 快照文件;

  5. RDB 文件发给从节点,主节点发送 RDB 文件;

  6. 发送缓冲区数据给客户端;

  7. 从节点清空旧数据;

  8. 从节点加载 RDB 文件;

  9. 从节点执行 AOF 操作。

部分复制

这个可以理解为增量更新,比如和第三方系统对接时,如果第三方有数据更新,定期进行增量更新就可以了。

而 Redis 主从的部分复制就是指当主从之间的网络故障等原因造成持续复制中断了,当从节点再次连上主节点后,主节点就补发数据给从节点,避免了全量复制的过高开销。补发数据的来源就是复制积压缓冲的数据。

原理图如下所示:
image-20220607191547923

部分复制总共分为六步:

(1)当主节点之间失联后,如果时间超过了 repl-timeout 时间,主节点就认为从节点发生故障了,中断连接。

(2)主节点其实一直都在把客户端写命令放入复制积压缓冲区,所以即使断连了,主节点还是会保留断连期间的命令,但因为队列是固定的,当写命令太多时,就会导致部分命令被覆盖了。

(3)主从节点恢复连接。

(4)从节点发送 psync 命令给主节点,带有 runId 和 offset 参数,runId 是上一次复制时保存的主节点的 runId值,offset 是从节点的复制偏移量。

(5)主节点接收到从节点的命令后,先判断传过来的 runId 是否和自己匹配,如果不匹配,则进行全量复制;如果 runId 匹配,则响应 CONTINUE,告诉从节点,可以进行部分复制了。我要把复制积压缓冲区的数据发给你了哦,请准备好接收。

(6)主节点根据子节点发送的偏移量,将复制积压缓冲区的数据发送给子节点。

那复制积压缓冲区到底是怎么来根据偏移量来计算要发送哪些缓存数据的呢?我们接着往下看。

积压缓冲区

复制积压缓冲区的特点:

固定长度的队列。

最近传播的写命令,默认为 1 MB 大小,可调节大小。

队列中的每个字节都有对应的复制偏移量进行标识。如下图所示,每一个字节对应一个偏移量。

image-20220607192640931

复制积压缓冲区索引

从节点重新连上主节点后,会发送 psync 命令,携带着偏移量 offset。比如 offset = 125,然后主节点拿着这个 125 去复制积压缓冲区找,125 正好在里面,然后就会执行部分复制的操作,将 125 以后的缓冲数据发送给从节点。

image-20220607192702918

偏移量在复制积压缓冲区的作用

如果 offset =10,主节点拿着这个 10 去复制积压缓冲区找,发现队列中最早的 offset 是 100,所以 100 之前的字节都被覆盖了,那么子节点就不能通过复制积压缓冲区拿到完整数据,所以只能通过全量复制的方式来同步。这个时候主节点就会发送一个 +FULLRESYNC的命令给子节点,告诉子节点,兄弟,你来得太晚了,只能使用全量同步的方式了。

image-20220607192712645

Redis Cluster 模式

Redis Cluster 集群介绍

一、概述

在高并发的系统中当我们需要从海量数据中快速找到所需符合要求的数据, 我们可以按照某种规则对海量的数据进行划分,将其分散存储在多个redis服务节点中,从而通过实现数据分片来降低redis服务加点的压力。

架构图

image-20220608105455866

在这个图中, 每一个蓝色的圈都代表着一个redis的服务器节点。他们任何两个节点之间都是相互连通的。客户端可以与任何一个节点相连接,然后就可以访问集群中的任何一个节点, 对其进行存取和其他操作

二、redis集群的特点

cluster模式的优点

  1. 将数据自动切分到多个节点的能力
  2. 当集群中的一部分节点失效或者无法进行通讯时,仍然可以继续处理命令请求的能力,拥有自动故障转移的能力。

节点间内部通讯机制

基础通讯原理

在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息

Redis 集群采用 Gossip(流言)协议,Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播

  1. 集群中的每个节点都会单独开辟一个 TCP 通道,用于节点之间彼此通信,通信端口号在基础端口上加10000。
  2. 每个节点在固定周期内通过特定的规则选择介个节点发送ping消息。
  3. 接收到ping消息的节点用pong消息做为响应。

集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。

当节点出故障、新节点加入、主从角色变化、槽信息变更等事件发生时,通过不断的 ping/pong 消息通信,经过一段时间后所有的节点都会知道整个集群全部节点的最新状态,从而达到集群状态同步的目的。

gossip协议

gossip协议包含多种消息,包括ping,pong,meet,fail,等等

meet: 某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信

redis-trib.rb add-node

其实内部就是发送了一个gossip meet消息,给新加入的节点,通知那个节点去加入我们的集群

ping: 每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据

每个节点每秒都会频繁发送ping给其他的集群,ping,频繁的互相之间交换数据,互相进行元数据的更新

pong: 返回ping和meet,包含自己的状态和其他信息,也可以用于信息广播和更新

fail: 某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了

ping消息深入

ping很频繁,而且要携带一些元数据,所以可能会加重网络负担

每个节点每秒会执行10次ping,每次会选择5个最久没有通信的其他节点

当然如果发现某个节点通信延时达到了cluster_node_timeout / 2,那么立即发送ping,避免数据交换延时过长,落后的时间太长了

比如说,两个节点之间都10分钟没有交换数据了,那么整个集群处于严重的元数据不一致的情况,就会有问题

所以cluster_node_timeout可以调节,如果调节比较大,那么会降低发送的频率

每次ping,一个是带上自己节点的信息,还有就是带上1/10其他节点的信息,发送出去,进行数据交换

至少包含3个其他节点的信息,最多包含总节点-2个其他节点的信息

基于重定向的客户端

(1)请求重定向

客户端可能会挑选任意一个redis实例去发送命令,每个redis实例接收到命令,都会计算key对应的hash slot

如果在本地就在本地处理,否则返回moved给客户端,让客户端进行重定向

cluster keyslot mykey,可以查看一个key对应的hash slot是什么

用redis-cli的时候,可以加入-c参数,支持自动的请求重定向,redis-cli接收到moved之后,会自动重定向到对应的节点执行命令

(2)计算hash slot

计算hash slot的算法,就是根据key计算CRC16值,然后对16384取模,拿到对应的hash slot

用hash tag可以手动指定key对应的slot,同一个hash tag下的key,都会在一个hash slot中,比如set mykey1:{100}和set mykey2:{100}

(3)hash slot查找

节点间通过gossip协议进行数据交换,就知道每个hash slot在哪个节点上

smart jedis

(1)什么是smart jedis

基于重定向的客户端,很消耗网络IO,因为大部分情况下,可能都会出现一次请求重定向,才能找到正确的节点

所以大部分的客户端,比如java redis客户端,就是jedis,都是smart的

本地维护一份hashslot -> node的映射表,缓存,大部分情况下,直接走本地缓存就可以找到hashslot -> node,不需要通过节点进行moved重定向

(2)JedisCluster的工作原理

在JedisCluster初始化的时候,就会随机选择一个node,初始化hashslot -> node映射表,同时为每个节点创建一个JedisPool连接池

每次基于JedisCluster执行操作,首先JedisCluster都会在本地计算key的hashslot,然后在本地映射表找到对应的节点

如果那个node正好还是持有那个hashslot,那么就ok; 如果说进行了reshard这样的操作,可能hashslot已经不在那个node上了,就会返回moved

如果JedisCluter API发现对应的节点返回moved,那么利用该节点的元数据,更新本地的hashslot -> node映射表缓存

重复上面几个步骤,直到找到对应的节点,如果重试超过5次,那么就报错,JedisClusterMaxRedirectionException

jedis老版本,可能会出现在集群某个节点故障还没完成自动切换恢复时,频繁更新hash slot,频繁ping节点检查活跃,导致大量网络IO开销

jedis最新版本,对于这些过度的hash slot更新和ping,都进行了优化,避免了类似问题

(3)hashslot迁移和ask重定向

如果hash slot正在迁移,那么会返回ask重定向给jedis

jedis接收到ask重定向之后,会重新定位到目标节点去执行,但是因为ask发生在hash slot迁移过程中,所以JedisCluster API收到ask是不会更新hashslot本地缓存

已经可以确定说,hashslot已经迁移完了,moved是会更新本地hashslot->node映射表缓存的

Redis Cluster 集群伸缩

Redis 集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容。

image-20220608112400731

槽和数据与节点的对应关系

当主节点分别维护自己负责的槽和对应的数据,如果希望加入1个节点实现集群扩容时,需要通过相关命令把一部分槽和数据迁移给新节点

image-20220608112925942

上面图里的每个节点把一部分槽和数据迁移到新的节点6385,每个节点负责的槽和数据相比之前变少了从而达到了集群扩容的目的,集群伸缩=槽和数据在节点之间的移动。

扩容操作

扩容是分布式存储最常见的需求,redis集群扩容操作可分为如下步骤:

  1. 准备新节点
  2. 接入集群
  3. 迁移槽和数据

加入集群后需要为新节点迁移槽和相关数据,槽在迁移过程中集群可以正常提供读写服务,迁移过程是集群扩容最核心的环节,下面详细讲解。

  1. 槽是 Redis 集群管理数据的基本单位,首先需要为新节点制定槽的迁移计划,确定原有节点的哪些槽需要迁移到新节点。迁移计划需要确保每个节点负责相似数量的槽,从而保证各节点的数据均匀,比如之前是三个节点,现在是四个节点,把节点槽分布在四个节点上。

    image-20220608113121888
  2. 槽迁移计划确定后开始逐个把槽内数据从源节点迁移到目标节点

image-20220608113352006

数据迁移过程是逐个槽进行的

流程说明:

1)对目标节点发送导入命令,让目标节点准备导入槽的数据。

2)对源节点发送导出命令,让源节点准备迁出槽的数据。

3)源节点循环执行迁移命令,将槽跟数据迁移到目标节点。

image-20220608113424727

数据分步

数据分步理论

分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上, 没个几点负责整体数据的一个子集

image-20220608123435366

数据分步通常有哈希分区和顺序分区两种方式,由于 Redis Cluster 采用 哈希分区规则,这里重点讨论 哈希分区

常见的 哈希分区 规则有几种,下面分别介绍:

节点取余分区

使用特定的数据,如redis 的键或用户ID,再根据节点数量N使用公式: hash(key) % N 计算出哈希值,用来决定数据映射到哪一个节点上。

image-20220608123716895
  • 优点

这种方式的突出优点是 简单性,常用于 数据库分库分表规则。一般采用 预分区 的方式,提前根据 数据量 规划好 分区数,比如划分为 5121024 张表,保证可支撑未来一段时间的 数据容量,再根据 负载情况 迁移到其他 数据库 中。扩容时通常采用 翻倍扩容,避免 数据映射 全部被 打乱,导致 全量迁移 的情况。

  • 缺点

节点数量 变化时,如 扩容收缩 节点,数据节点 映射关系 需要重新计算,会导致数据的 重新迁移

一致性哈希分区

一致性哈希可以很好的解决稳定性的问题,可以将所有的存储节点排列在首尾相接的hash环上,没个key在hash后会顺时针找到临近的存储节点存放。而当有节点加入或者退出时,仅影响改节点在hash环上顺时针相邻的后续节点。

优点: 加入和删除节点只影响哈希还中顺时针方向的相邻节点,对其他节点无影响。

缺点: 加减节点会造成哈希环中部分数据无法命中。当使用少量节点时,节点变化将大范围影响哈希环中数据映射,不适合少量数据节点的分布式方案。普通的一致性哈希分区在增减节点时需要增加一倍或减去一般节点才能保证数据的负载和均衡。

虚拟槽分区

Redis Cluster 采用 虚拟槽分区,所有的 根据 哈希函数 映射到 0~16383 整数槽内,计算公式:slot = CRC16(key)& 16383。每个节点负责维护一部分槽以及槽所映射的 键值数据,如图所示:

image-20220608134348677

redis虚拟槽分区的特点

  • 解耦数据和节点之间的关系, 简化了节点扩容和收缩的难度
  • 节点自身 维护槽的 映射关系,不需要 客户端 或者 代理服务 维护 槽分区元数据
  • 支持 节点 之间的 映射查询,用于 数据路由在线伸缩 等场景。

Redis 集群的功能限制

Redis 集群相对 单机 在功能上存在一些限制,需要 开发人员 提前了解,在使用时做好规避。

  • key批量操作 支持有限。

类似 msetmget 操作,目前只支持对具有相同 slot 值的 key 执行 批量操作。对于 映射为不同 slot 值的 key 由于执行 mgetmget 等操作可能存在于多个节点上,因此不被支持。

  • key事务操作 支持有限。

只支持 key同一节点上事务操作,当多个 key 分布在 不同 的节点上时 无法 使用事务功能。

  • key 作为 数据分区 的最小粒度

不能将一个 大的键值 对象如 hashlist 等映射到 不同的节点

  • 不支持 多数据库空间

单机 下的 Redis 可以支持 16 个数据库(db0 ~ db15),集群模式 下只能使用 一个 数据库空间,即 db0

  • 复制结构 只支持一层

从节点 只能复制 主节点,不支持 嵌套树状复制 结构。

这种结构很容易 添加 或者 删除 节点。如果 增加 一个节点 6,就需要从节点 1 ~ 5 获得部分 分配到节点 6 上。如果想 移除 节点 1,需要将节点 1 中的 移到节点 2 ~ 5 上,然后将 没有任何槽 的节点 1 从集群中 移除 即可。

由于从一个节点将 哈希槽 移动到另一个节点并不会 停止服务,所以无论 添加删除 或者 改变 某个节点的 哈希槽的数量 都不会造成 集群不可用 的状态.

Redus Cluster 故障转移与恢复

Redis集群中的节点分为主节点(master)和从节点(slave),主节点主要负责处理槽,从节点则用于复制某个主节点数据,并在被复制的主节点下线时,代替主节点处理后续的命令请求。

针对节点下线有两种状态:

  1. 主观下线:当节点A向节点B发送了一条PING消息时,节点B没有在规定的时间内(设置的cluster-node-timeout参数)返回PONG消息,那么节点A会将节点B标记为主观下线状态。

    这里的主观下线只是节点A主观的认为节点B下线了,有可能是因为节点A和节点B之间的网络断了,但是其他节点依然可以和节点B通讯,所以主观下线并不一定是节点B真的就下线了。

  2. 客观下线:由于节点A与集群内的其他节点仍然保持通讯,因此节点B的下线消息也通过Gossip协议传遍了集群内的其他节点。

    当集群内半数以上的节点都认为节点B主观下线了,那么节点B就会被认为客观下线了,同时将节点B标记为客观下线的节点会向集群中发送一条FAIL消息,所有收到这条消息的节点会立即将节点B标记为客观下线。

如果一个节点被认为客观下线了,那么就需要从它的从节点当中选出一个节点来代替它成为主节点。选举过程如下:

  1. 从节点FAILOVER_AUTH_REQUEST::当从节点发现自己正在复制的主节点被标记为客观下线时,从节点会向集群中发送一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息的具有投票权的主节点向这个从节点投票
  2. 主节点进行ACK:如果一个主节点具有投票权,并且未投票给其他从节点,那么这个主节点会向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持该从节点成为新的主节点
  3. 票数统计:每个从节点都会接收返回的CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并会进行统计自己得到了多少主节点的支持
  4. 选出主节点:每个具有投票权的主节点只能投一次票,当一个从节点获得了一半以上的主节点的支持票时,那么这个节点就会成为新的主节点,
  5. 重复执行:如果没有任何一个从节点获取大于半数的投票,那么将进行新的选举,直到选出新的主节点为止。
  6. 接受槽的指派:新的主节点产生后,它会撤销所有对已下线的主节点的槽指派,并将这些槽指派给自己。
  7. 广播Leader信息:新的主节点会向集群中广播一条PONG消息,让集群中的其他节点直到这个从节点已经成为了新的主节点,并且接管了原先主节点的所有槽。
  8. 结束:新的主节点负责接收和自己处理的槽相关的指令,至此故障转移结束。

redis 多线程与后台线程

redis 单线程 + 后台线程

再次强调:我们经常听说的 redis 单线程模型(上图),其实仅仅指的是对客户端的请求处理过程,另外还有一些工作由部分特殊的独立线程来完成。

在 redis 6.0 以前,完整的 redis 线程模型是 主线程(1个)+ 后台线程(三个),我画了一张图,你可以看下:

image-20230110110144015

三个后台线程分别处理:

  • close_file:关闭 AOF、RDB 等过程中产生的大临时文件
  • aof_fsync:将追加至 AOF 文件的数据刷盘(一般情况下 write 调用之后,数据被写入内核缓冲区,通过 fsync 调用才将内核缓冲区的数据写入磁盘)
  • lazy_free:惰性释放大对象
    这三个线程有一个共同特点,都是用来处理耗时长的操作,也印证了我们常说的,专业的人做专业的事。

redis 多线程 + 后台线程
咱们继续将时钟往后拨到 redis6.0 版本,此版本出现了一种新的 IO 线程,也称为「多线程」。

我同样也画了张图,你可以看下:

image-20230110110301288

我们先思考下,引进 IO 线程解决了哪些问题?

在之前系列文章中,我们提到过,通常情况下,redis 性能在于网络和内存,而不是 CPU。针对 网络,一般是处理速度较慢的问题;针对内存,一般是指物理空间的限制。

所以到这,你应该很清楚了,究竟哪个模块需要引入多线程来处理?

对,就是网络模块,因此,引入的这些线程也叫 IO线程;由于主线程也会处理网络模块的工作,主线程习惯上也叫做主IO线程。

网络模块有接收连接、IO读(包括数据解析)、IO写等操作,其中,主线程负责接收新连接,然后分发到 IO线程进行处理(主线程也参与)。

默认情况下,只针对写操作启用IO线程,如果读操作也需要的话,需要在配置文件中进行配置

值得注意的是,命令处理仍然是单线程执行。

配置:

redis 默认情况下不会开启多线程处理,官方也建议,除非性能达到瓶颈,否则没必要开启多线程。

开启多线程:配置 io-thread 即可。io-thread = 1 表示只使用主 IO 线程 io-threads 4

开启之后,默认写操作会通过多线程来处理,而读操作则不会。

如果读操作也想要开启多线程,则需要配置:io-threads-do-reads yes

总结

本文从 redis 架构演进开始讲起,从单线程模型 => 单线程 + 后台线程 => 多线程 + 后台线程 演进。

每一次演进,都是为了解决某一类特殊问题;后台线程的出现,解决了一些耗时长的重操作。同样,多线程的出现,解决了网络模块的性能瓶颈。

大Key问题如何处理

什么是大key

一般而言,下面这两种情况被称为大 key:

  • String 类型的值大于 10 KB;
  • Hash、List、Set、ZSet 类型的元素的个数超过 5000个;

大 key 会带来以下四种影响:

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。

如何找到大key

使用 RdbTools 工具查找大 key

  • 使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。

    比如,下面这条命令,将大于 10 kb 的 key 输出到一个表格文件。

    1
    rdb dump.rdb -c memory --bytes 10240 -f redis.csv

如何删除大key

删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。

释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。

所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

因此,删除大 key 这一个动作,我们要小心。具体要怎么做呢?这里给出两种方法:

  • 分批次删除
  • 异步删除(Redis 4.0版本以上)

分批次删除

对于删除大 Hash,使用 hscan 命令,每次获取 100 个字段,再用 hdel 命令,每次删除 1 个字段。

对于删除大 List,通过 ltrim 命令,每次删除少量元素。

对于删除大 Set,使用 sscan 命令,每次扫描集合中 100 个元素,再用 srem 命令每次删除一个键。

对于删除大 ZSet,使用 zremrangebyrank 命令,每次删除 top 100个元素。

异步删除

从 Redis 4.0 版本开始,可以采用异步删除法,用 unlink 命令代替 del 来删除

这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。

除了主动调用 unlink 命令实现异步删除之外,我们还可以通过配置参数,达到某些条件的时候自动进行异步删除。

主要有 4 种场景,默认都是关闭的:

1
2
3
4
lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
noslave-lazy-flush no

它们代表的含义如下:

  • lazyfree-lazy-eviction:表示当 Redis 运行内存超过 maxmeory 时,是否开启 lazy free 机制删除;
  • lazyfree-lazy-expire:表示设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除;
  • lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除;
  • slave-lazy-flush:针对 slave (从节点) 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free 机制删除。

建议开启其中的 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del 等配置,这样就可以有效的提高主线程的执行效率。

刘小恺(Kyle) wechat
如有疑问可联系博主