Redis ba'gu'wen

目录

Redis 八股文

数据类型

Redis 使用场景

  1. 数据缓存 (用户信息、商品数量、文章阅读数量)

  2. 消息推送 (站点的订阅)

  3. 队列 (削峰、解耦、异步)

  4. 排行榜 (积分排行)

  5. 社交网络 (共同好友、互踩、下拉刷新)

  6. 计数器 (商品库存,站点在线人数、文章阅读、点赞)

  7. 基数计算

  8. GEO 计算

Redis 功能特点

  1. 持久化
  2. 丰富的数据类型 (string、list、hash、set、zset、发布订阅等)
  3. 高可用方案 (哨兵、集群、主从)
  4. 事务
  5. 丰富的客户端
  6. 提供事务
  7. 消息发布订阅
  8. Geo
  9. HyperLogLog
  10. 事务

Redis 各种数据类型的底层数据结构

  1. string 底层数据结构为简单字符串。
  2. list 底层数据结构为 ziplist 和 linkedlist
  3. hash 底层数据结构为 ziplist 和 hashtable
  4. set 底层数据结构为 intset 和 hashtable
  5. sorted set 底层数据结构为 ziplist 和 skiplist

如何使用 Redis 实现队列功能

  1. 可以使用 list 实现普通队列,lpush 添加到队列,lpop 从队列中读取数据。
  2. 可以使用 zset 定期轮询数据,实现延迟队列。
  3. 可以使用发布订阅实现多个消费者队列。
  4. 可以使用 stream 实现队列。(推荐使用该方式实现)。

你是怎么用 Redis 做异步队列的

  1. 一般使用 list 结构作为队列,rpush 生产消息,lpop 消费消息。当 lpop 没有消息的时候,要适当 sleep 一会再重试。
  2. 如果对方追问可不可以不用 sleep 呢?
    • list 还有个指令叫 blpop,在没有消息的时候,它会阻塞住直到消息到来。
  3. 如果对方追问能不能生产一次消费多次呢?
    • 使用 pub/sub 主题订阅者模式,可以实现 1:N 的消息队列。
  4. 如果对方追问 pub/sub 有什么缺点?
    • 在消费者下线的情况下,生产的消息会丢失,可以使用 Redis6 增加的 stream 数据类型,也可以使用专业的消息队列如 rabbitmq 等
  5. 如果对方追问 redis 如何实现延时队列
    • 使用 sortedset,拿时间戳作为 score,消息内容作为 key 调用 zadd 来生产消息,消费者用 zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。

使用 Redis Stream 做队列,比 list,zset 和发布订阅有什么区别

  1. list 可以使用 lpush 向队列中添加数据,lpop 可以向队列中读取数据。list 作为消息队列无法实现一个消息多个消费者。如果出现消息处理失败,需要手动回滚消息。
  2. zset 在添加数据时,需要添加一个分值,可以根据该分值对数据进行排序,实现延迟消息队列的功能。消息是否消费需要额外的处理。
  3. 发布订阅可以实现多个消费者功能,但是发布订阅无法实现数据持久化,容易导致数据丢失。并且开启一个订阅者无法获取到之前的数据。
  4. stream 借鉴了常用的 MQ 服务,添加一个消息就会产生一个消息 ID,每一个消息 ID 下可以对应多个消费组,每一个消费组下可以对应多个消费者。可以实现多个消费者功能,同时支持 ack 机制,减少数据的丢失情况。也是支持数据值持久化和主从复制功能。

设计一个网站每日、每月和每天的 PV、UV 该怎么设计

实现这样的功能,如果只是统计一个汇总数据,推荐使用 HyperLogLog 数据类型。

Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的。

在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64^ 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。

如何使用 Redis 实现附近距离检索功能

实现距离检索,可以使用 Redis 中的 GEO 数据类型

GEO 主要用于存储地理位置信息,并对存储的信息进行操作,该功能在 Redis 3.2 版本新增。

但是 GEO 适合精度不是很高的场景。

由于 GEO 是在内存中进行计算,具备计算速度快的特点。

如何使用 Redis 实现一个分布式锁功能

使用 Redis 实现分布式锁,可以使用 set key value + expire ttl 实现,但是这两个命令分开执行不是一个原子操作,因此推荐使用 set key vale nx ttl,该命令属于原子操作。

使用 Redis 解决秒杀超卖,该选择什么数据类型,为什么选择该数据类型

  1. 在秒杀场景下,超卖是一个非常严重的问题。常规的逻辑是先查询库存在减少库存。但在秒杀场景中,无法保证减少库存的过程中有其他的请求读取了未减少的库存数据。
  2. 由于 Redis 是单线程的执行,同一时刻只有一个线程进行操作。因此可以使用 Redis 来实现秒杀减少库存。
  3. 在 Redis 的数据类型中,可以使用 lpush,decr 命令实现秒杀减少库存。该命令属于原子操作。

具体的步骤是

  1. 在系统初始化时,将商品的库存数量加载到Redis缓存中
  2. 接收到秒杀请求时,在Redis中进行预减库存,当 Redis 中的库存不足时,直接返回秒杀失败,否则继续进行第 3 步;
  3. 将请求放入异步队列中,返回正在排队中;
  4. 服务端异步队列将请求出队,出队成功的请求可以生成秒杀订单,减少数据库库存,返回秒杀订单详情。
  5. 用户在客户端申请秒杀请求后,进行轮询,查看是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。

如何使用 Redis 实现系统用户签到功能

  1. 使用 Redis 实现用户签到可以使用 bitmap 实现。bitmap 底层数据存储的是 1 否者 0,占用内存小。
  2. Redis 提供的数据类型 BitMap(位图),每个 bit 位对应 0 和 1 两个状态。虽然内部还是采用 String 类型存储,但 Redis 提供了一些指令用于直接操作 BitMap,可以把它看作一个 bit 数组,数组的下标就是偏移量。
  3. 它的优点是内存开销小,效率高且操作简单,很适合用于签到这类场景。
  4. 缺点在于位计算和位表示数值的局限。如果要用位来做业务数据记录,就不要在意 value 的值。

如何使用 Redis 实现一个积分排行功能

  1. 使用 Redis 实现积分排行,可以使用 zset 数据类型。
  2. zset 在添加数据时,需要添加一个分值,将积分作为分值,值作为用户 ID,根据该分值对数据进行排序。

Redis 如何解决事务之间的冲突

  1. 使用 watch 监听 key 变化,当 key 发生变化,事务中的所有操作都会被取消。
  2. 使用乐观锁,通过版本号实现。
  3. 使用悲观锁,每次开启事务时,都添加一个锁,事务执行结束之后释放锁。

悲观锁

  • 悲观锁 (Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人拿到这个数据就会 block 直到它拿到锁。传统的关系型数据库里面就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。

乐观锁

  • 乐观锁 (Optimistic Lock),顾名思义,就是很乐观,每次去那数据的时候都认为别人不会修改,所以不会上锁;但是在修改的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。redis 就是使用这种 check-and-set 机制实现 事务的。

watch 监听

  • 在执行 multi 之前,先执行 watch key1 [key2 ...],可以监视一个或者多个 key,若在事务的 exec 命令之前,这些 key 对应的值被其他命令所改动了,那么事务中所有命令都将被打断,即事务所有操作将被取消执行。

Redis 事务的三大特性

  1. 事务中的所有命令都会序列化、按顺序地执行,事务在执行过程中,不会被其他客户端发送来的命令请求所打断。
  2. 队列中的命令没有提交 (exec) 之前,都不会实际被执行,因为事务提交前任何指令都不会被实际执行。
  3. 事务中如果有一条命令执行失败,后续的命令仍然会被执行,没有回滚。
    • 如果在组队阶段,有 1 个失败了,后面都不会成功;
    • 如果在组队阶段成功了,在执行阶段有命令失败,就这条失败,其他的命令则正常执行,不保证都成功或都失败。

持久化

Redis 持久化都有哪些方式

  1. 快照全量备份的方式 (RDB),使用 bgsave 或者 save 命令
    • bgsave 是通过 fork 一个子进程,异步持久化;
    • save 使用同步阻塞的模式进行持久化。
  2. 增量日志快照的方式 (AOF),将 Redis 写命令写入到缓冲区,然后在将缓冲区的命令写入到磁盘中。

使用 AOF 方式做持久化,会遇到什么问题?如何解决?

  1. AOF 记录的是 Redis 的写操作命令,当命令数量多时,就会导致文件过大。同时有些缓存数据本身应该是过期了,但对应的写命令还是被保留在文件中。这就出现 AOF 文件过大的问题。
  2. 针对这种情况,Redis 采用了重写机制,定期 fork 一个子进程对 AOF 文件进行重写,用来减少文件体积并剔除一些过期的命令。
  3. AOF 重写可以通过自动方式和手动的方式触发,手动可以使用 bgrewriteaof 和自动通过配置文件体积大小时触发。

什么是写时复制技术

  1. Redis 使用 AOF 做持久化时,会做重写操作,此时用到了写时复制技术。
  2. 在触发重写时,主进程会 fork 一个子进程,该子进程来负责做重写。在 fork 之后,子进程和主进程会共享物理内存地址,当有新的操作发生时,会单独复制一块内存空间用作重写操作。

AOF 持久化会保证数据的不丢失吗

  1. 采用 AOF 持久化,首先写的命令是放在缓冲区中,通过同步策略持久化到磁盘中。可以通过 appendfsync 配置进行操作。具体可配置的值有:

    • always:命令写入到 aof_buf 缓冲区中之后立即调用系统的 fsync 操作同步到 aof 文件中,fsync 完成后线程返回。
    • everysec:命令写入到 aof_buf 缓冲区后每隔一秒调用系统的 write 操作,write 完成后线程返回。
    • no:命令写入 aof_bug 缓冲区后调用系统 write 操作,不对 aof 文件做 fsync 同步,同步硬盘操作由系统操作完成,时间一般最长为 30s。
  2. fork 出来的子进程在做文件重写后,父进程此时会将就的重写文件替换掉。在这个过程中,父进程是一个阻塞的过程,不接受客户端的写命令。这个过程中容易导致数据的丢失。

RDB 和 AOF 做持久化的区别

RDB 优缺点

  • 优点

    • 文件实现的数据快照,全量备份,便于数据的传输。
      • 比如我们需要把 A 服务器上的备份文件传输到 B 服务器上面,直接将 rdb 文件拷贝即可。
    • 文件采用压缩的二进制文件,当重启服务时加载数据文件,比 aof 方式更快(aof 是重新去执行一次命令)
  • 缺点

    • rbd 采用加密的二进制格式存储文件,由于 Redis 各个版本之间的兼容性问题也导致 rdb 由版本兼容问题导致无法再其他的 Redis 版本中使用。
    • 实时性差,并不是完全的实时同步,容易造成数据的不完整性
      • 因为 rdb 并不是实时备份,当某个时间段 Redis 服务出现异常,内存数据丢失,这段时间的数据是无法恢复的,因此易导致数据的丢失。
    • 可读性差
      • 由于文件内容采用二进制加密处理,我们无法直接读取,不能修改文件内容
      • 但一般情况下是不会去查看或修改持久化的内容

AOF 优缺点

  • 优点

    • 多种文件写入(fsync)策略,数据实时保存,数据完整性强;即使丢失某些数据,制定好策略最多也是一秒内的数据丢失
    • 可读性强,由于使用的是文本协议格式来存储的数据,可有直接查看操作的命令,同时也可以手动改写命令。
  • 缺点

    • 文件体积过大,加载速度比 rbd
    • 由于 aof 记录的是 redis 操作的日志,一些无效的,可简化的操作也会被记录下来,造成 aof 文件过大;但该方式可以通过文件重写策略进行优化.

主从复制

Redis 的同步机制

主从同步。

  • 第一次同步时,主节点做一次 bgsave,并同时将后续修改操作记录到内存 buffer

  • 待完成后将 rdb 文件全量同步到复制节点,复制节点接受完成后将 rdb 镜像加载到内存。

  • 加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。

如何防止 Redis 脑裂导致数据库丢失情况

什么是脑裂

所谓的脑裂,就是指在主从集群中,同时有两个主节点,它们都能接收写请求。

而脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。而且,严重的话,脑裂会进一步导致数据丢失。

脑裂发生原因
  1. 确认是不是数据同步出现了问题。

    • 在主从集群中发生数据丢失,最常见的原因就是主库的数据还没有同步到从库,结果主库发生了故障,等从库升级为主库后,未同步的数据就丢失了。
    • 如果是这种情况的数据丢失,我们可以通过比对主从库上的复制进度差值来进行判断,也就是计算master_repl_offsetslave_repl_offset 的差值。
      • 如果从库上的 slave_repl_offset 小于原主库的 master_repl_offset,那么,我们就可以认定数据丢失是由数据同步未完成导致的。
  2. 排查客户端的操作日志,发现脑裂现象

    • 在排查客户端的操作日志时,我们发现,在主从切换后的一段时间内,有一个客户端仍然在和原主库通信,并没有和升级的新主库进行交互。
    • 这就相当于主从集群中同时有了两个主库。根据这个迹象,我们就想到了在分布式主从集群发生故障时会出现的一个问题:脑裂。
    • 但是,不同客户端同时给两个主库发送数据写操作,按道理来说,只会导致新数据会分布在不同的主库上,并不会造成数据丢失。那么,为什么我们的数据仍然丢失了呢?
  3. 发现是原主库假故障导致的脑裂。

    • 我们是采用哨兵机制进行主从切换的,当主从切换发生时,一定是有超过预设数量(quorum 配置项)的哨兵实例和主库的心跳都超时了,才会把主库判断为客观下线,然后,哨兵开始执行切换操作。哨兵切换完成后,客户端会和新主库进行通信,发送请求操作。
    • 但是,在切换过程中,既然客户端仍然和原主库通信,这就表明,原主库并没有真的发生故障(例如主库进程挂掉)
为什么脑裂会导致数据丢失

主从切换后,从库一旦升级为新主库,哨兵就会让原主库执行 slave of 命令,和新主库重新进行全量同步。

而在全量同步执行的最后阶段,原主库需要清空本地的数据,加载新主库发送的 RDB 文件,这样一来,原主库在主从切换期间保存的新写数据就丢失了。

解决方案

Redis 已经提供了两个配置项来限制主库的请求处理,分别是 min-slaves-to-writemin-slaves-max-lag

  • min-slaves-to-write:这个配置项设置了主库能进行数据同步的最少从库数量;

  • min-slaves-max-lag:这个配置项设置了主从库间进行数据复制时,从库给主库发送 ACK 消息的最大延迟(以秒为单位)。

我们可以把 min-slaves-to-writemin-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的请求了。

即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-writemin-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端请求,客户端也就不能在原主库中写入新数据了。

内存管理

Redis 存储大 key 有什么优化的解决方案

  1. 应用层对存储的数据进行压缩,在存储到 Redis 中,从 Redis 中获取数据后再解压数据。
  2. 可以拆分存储内容,将大 key 中的存储信息进行拆分。例如一个存储一个很大的对象,可以将对象的方法和属性给拆分开进行存储,这样检索的时候也会很快。也可以采用数据切片处理。
  3. 制定合理的内存淘汰策略,例如 lru、lfu 等内存淘汰策略方案。
  4. 上面几种方案如不能解决,也可以使用集群、扩容等操作。进行横向扩展。

针对大 key,一般会出现两种情况。一种是数据检索慢,另外一种是内存占用大。因此优化的策略可以从这两个方面入手。

Redis 的数据过期策略有哪些

过期策略是指数据在过期之后,还会占用这内容,这时候 Redis 是如何处理的?分别有下面三种方式:

定时策略

Redis 在对设置了过期时间的 key,在创建时都会增加一个定时器。定时器定时去处理该 key。

  • 优点:保证内存被尽快释放,减少无效的缓存暂用内存。

  • 缺点:若过期 key 很多,删除这些 key 会占用很多的 CPU 时间,在 CPU 时间紧张的情况下,CPU 不能把所有的时间用来做要紧的事儿,还需要去花时间删除这些 key。定时器的创建耗时,若为每一个设置过期时间的 key 创建一个定时器(将会有大量的定时器产生),性能影响严重。

一般来说不会选择该策略模式。

惰性策略

在客户端向 Redis 读数据时,Redis 会检测该 key 是否过期,过期了就返回空值。

  • 优点:删除操作只发生在从数据库取出 key 的时候发生,而且只删除当前 key,所以对 CPU 时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的 key 了)。

  • 缺点:若大量的 key 在超出超时时间后,很久一段时间内,都没有被获取过,此时的无效缓存是永久暂用在内存中的,那么可能发生内存泄露(无用的垃圾占用了大量的内存)。

定期策略

Redis 会定期去检测设置了过期时间的 key,当该 key 已经失效了,则会从内存中剔除;如果未失效,则不作任何处理。

该方式不是去遍历所有的 ky,而是随机抽取一些 key 做过期检测

  • 优点:通过限制删除操作的时长和频率,来缓解定时策略、惰性策略的缺点

  • 缺点

    • 在内存友好方面,不如"定时删除",因为是随机遍历一些 key,因此存在部分 key 过期,但遍历 key 时,没有被遍历到,过期的 key 仍在内存中。
    • 在 CPU 时间友好方面,不如"惰性删除",定期删除也会暂用 CPU 性能消耗。
  • 难点:合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)(这个要根据服务器运行情况来定了)

Redis 的数据淘汰策略

淘汰策略主要是针对数据一直存在内存中,导致内存无法接纳新的数据。重点是了解 lru 算法、lfu 算法。

  1. volatile-lru 当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key。
  2. allkeys-lru 当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
  3. volatile-lfu 当内存不足以容纳新写入数据时,在过期密集的键中,使用 LFU 算法进行删除 key。
  4. allkeys-lfu 当内存不足以容纳新写入数据时,使用 LFU 算法移除所有的 key。
  5. volatile-random 当内存不足以容纳新写入数据时,在设置了过期的键中,随机删除一个 key。
  6. allkeys-random 当内存不足以容纳新写入数据时,随机删除一个或者多个 key。
  7. volatile-ttl 当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。
  8. noeviction 当内存不足以容纳新写入数据时,新写入操作会报错。(默认的方式)

什么是缓存穿透、击穿、雪崩,如该如何解决这几个问题

缓存穿透

缓存穿透是指,请求数据库或者缓存都不存在的数据,导致每一个请求都访问数据库。

  1. 可以针对请求参数过滤,减少无效的请求。

  2. 缓存内容设置为 null,并制定一个合理的过期时间。

  3. 第 2 点中的方案会浪费无效的内存,可以使用布隆过滤器解决。示例方案

缓存击穿

缓存击穿是指,请求某一个热点数据在不存在,导致大量请求访问数据库。

  1. 设置数据缓存时间永不过期,可以根据物理过期时间和逻辑过期时间来控制。
  2. 可以将热点数据通过多种方式缓存,Redis 不存在还可以通过其他的缓存方式读取。

缓存雪崩

缓存雪崩是指,某一个时刻请求的缓存大面积失效,导致大量请求访问数据库。有可能是缓存过期时间设置比较集中导致。

  1. 缓存的时间均匀分布,避免缓存时间过于集中。
  2. 针对热点数据可以不用设置过期时间,可以根据物理过期时间和逻辑过期时间来控制。
  3. 多级缓存,一级缓存和二级缓存都设置不同的缓存时间。

什么是缓存预热,如何做缓存预热,什么是服务降级,如何做服务降级?

缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。

如果不进行预热,那么 Redis 初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。

  1. 数据量不大的时候,工程启动的时候进行加载缓存动作。
  2. 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新。
  3. 数据量太大的时候,优先保证热点数据进行提前加载到缓存。

缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。

降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。在项目实战中通常会将部分热点数据缓存到服务的内存中,这样一旦缓存出现异常,可以直接使用服务的内存数据,从而避免数据库遭受巨大压力。

原理

Redis 为什么读写数据快

  1. 数据的读写都是基于内存操作。

  2. IO 多路复用

  3. 单线程模式。

    • Redis 的瓶颈不在线程,不在获取 CPU 的资源,所以如果使用多线程就会带来多余的资源占用。比如上下文切换、资源竞争、锁的操作。
    • 上下文的切换:上下文其实不难理解,它就是 CPU 寄存器程序计数器。主要的作用就是存放没有被分配到资源的线程。
      • 多线程操作的时候,总有线程获取到资源,也总有线程需要等待获取资源,这个时候,等待获取资源的线程就需要被挂起,也就是我们的寄存。这个时候我们的上下文就产生了,当我们的上下文再次被唤起,得到资源的时候,就是我们上下文的切换。
    • 竞争资源:竞争资源相对来说比较好理解,CPU 对上下文的切换其实就是一种资源分批,但是在切换之前,到底切换到哪一个上下文,就是资源竞争的开始。在 redis 中由于是单线程的,所以所有的操作都不会涉及到资源的竞争。
    • 锁的消耗:对于多线程的情况来讲,不能回避的就是锁的问题。如果说多线程操作出现并发,有可能导致数据不一致,或者操作达不到预期的效果。这个时候我们就需要锁来解决这些问题。当我们的线程很多的时候,就需要不断的加锁,释放锁,该操作就会消耗掉我们很多的时间。

线程模型

Redis 是单线程吗?

Redis 单线程指的是「接收客户端请求 -> 解析请求 -> 进行数据读写等操作 -> 发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。
    • 例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。
    • 因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大 key

之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

https://markdown-1303167219.cos.ap-shanghai.myqcloud.com/image-20220907160719297.png

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:

  • BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
  • BIO_AOF_FSYNC,AOF 刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
  • BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;

Redis 单线程模式是怎样的?

Redis 6.0 版本之前的单线模式如下图:

https://markdown-1303167219.cos.ap-shanghai.myqcloud.com/redis%E5%8D%95%E7%BA%BF%E7%A8%8B%E6%A8%A1%E5%9E%8B.drawio.webp

图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情:

  • 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 一个服务端 socket
  • 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket;
  • 然后,将调用 epoll_ctl() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。

初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:

  • 首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
  • 接着,调用 epoll_wait 函数等待事件的到来:
    • 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;
    • 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
    • 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。

Redis 采用单线程为什么还这么快?

官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,如下图所示:

image-20220907161202011

之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:

  • Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  • Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  • Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

Redis 6.0 之后为什么引入了多线程?

虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理但是对于命令的执行,Redis 仍然使用单线程来处理。

Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。

Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。

1
2
//读请求也使用io多线程
io-threads-do-reads yes 

同时, redis.conf 配置文件中提供了 IO 多线程个数的配置项。

1
2
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4 

关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会创建 6 个线程:

  • Redis-server : Redis 的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4 - 1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。