Redis 面试

Redis 面试

Redis 简介

什么是 Redis

【问题】

  • 什么是 Redis?
  • Redis 有什么功能和特性?

【解答】

什么是 Redis:

Redis 是一个开源的“内存”数据库。由于,Redis 的读写操作都是在内存中完成,因此其读写速度非常快

  • 高性能 - 由于,Redis 的读写操作都是在内存中完成,因此性能极高。
  • 高并发 - Redis 单机 QPS 能达到 10w+,将近是 Mysql 的 10 倍。

Redis 常被用于缓存,消息队列、分布式锁等场景

Redis 的功能和特性:

  • Redis 支持多种数据类型。如:String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理空间)、Stream(流)。

  • Redis 的读写采用“单线程”模型,因此,其操作天然就具有原子性

  • Redis 支持两种持久化策略:RDB 和 AOF。

  • Redis 有多种高可用方案:主从复制模式、哨兵模式、集群模式。

  • Redis 支持很多丰富的特性,如:事务Lua 脚本发布订阅过期删除内存淘汰等等。

图来自 https://architecturenotes.co/redis/

Redis 有哪些应用场景

【问题】

  • Redis 有哪些应用场景?

【解答】

  • 缓存 - 将热点数据放到内存中,设置内存的最大使用量以及过期淘汰策略来保证缓存的命中率。
  • 计数器 - Redis 这种内存数据库能支持计数器频繁的读写操作。
  • 应用限流 - 限制一个网站访问流量。
  • 消息队列 - 使用 List 数据类型,它是双向链表。
  • 查找表 - 使用 HASH 数据类型。
  • 聚合运算 - 使用 SET 类型,例如求两个用户的共同好友。
  • 排行榜 - 使用 ZSET 数据类型。
  • 分布式 Session - 多个应用服务器的 Session 都存储到 Redis 中来保证 Session 的一致性。
  • 分布式锁 - 除了可以使用 SETNX 实现分布式锁之外,还可以使用官方提供的 RedLock 分布式锁实现。

Redis vs. Memcached

【问题】

  • Redis 和 Memcached 有什么相同点?
  • Redis 和 Memcached 有什么差异?
  • 分布式缓存技术选型,选 Redis 还是 Memcached,为什么?

【解答】

Redis 与 Memcached 的共性

  1. 都是内存数据库,因此性能都很高
  2. 都有过期策略。

因为以上两点,所以常被作为缓存使用。

Redis 与 Memcached 的差异

Redis Memcached
数据类型 支持多种数据类型:String、Hash、List、Set、ZSet 等 只支持 String 类型
持久化 支持两种持久化策略:RDB 和 AOF 不支持持久化,一旦重启或宕机就会丢失数据
分布式 支持分布式 本身不支持分布式,只能通过在客户端使用像一致性哈希这样的分布式算法来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点
线程模型 读写采用单线程+IO 多路复用。因此存储小数据时比 Memcached 性能更高 采用多线程+IO 多路复用。在 100k 以上的数据中,Memcached 性能要高于 Redis
其他功能 支持发布订阅模型、Lua 脚本、事务等功能 不支持

通过以上分析,可以看出,Redis 在很多方面都占有优势。因此,绝大多数情况下,优先选择 Redis 作为分布式缓存。

参考:《脚踏两只船的困惑 - Memcached 与 Redis》

Redis 为什么快

【问题】

  • Redis 有多快?
  • Redis 为什么这么快?

【解答】

根据 Redis 官方 Benchmark 文档的描述,Redis 单机 QPS 能达到 10w+。

Redis 官方 Benchmark QPS 图

Redis 是单线程模型(Redis 6.0 已经支持多线程模型),为什么还能有这么高的并发?

  • Redis 读写基于内存
  • IO 多路复用 + 读写单线程模型
    • IO 多路复用是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
    • 单线程模型避免了由于并发而产生的线程切换、锁竞争等开销。
    • 由于,Redis 读写基于内存,性能很高,所以 CPU 并不是制约 Redis 性能表现的瓶颈所在。更多情况下是受到内存大小和网络 I/O 的限制,所以 Redis 核心网络模型使用单线程并没有什么问题。
  • 高效的数据结构

图来自 Why is redis so fast?

Redis 数据类型

Redis 支持哪些数据类型

【问题】

  • Redis 支持哪些数据类型?

【解答】

  • Redis 支持五种基本数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
  • 随着 Redis 版本升级,又陆续支持以下数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。

Redis 各数据类型的应用场景

【问题】

Redis 各数据类型有哪些应用场景?

【解答】

  • String(字符串) - 缓存对象、分布式 Session、分布式锁、计数器、限流器、分布式 ID 等。

  • Hash(哈希) - 缓存对象、购物车等。

  • List(列表) - 消息队列

  • Set(集合) - 聚合计算(并集、交集、差集),如点赞、共同关注、抽奖活动等。

  • Zset(有序集合) - 排序场景,如排行榜、电话和姓名排序等。

  • BitMap(2.2 版新增) - 二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;

  • HyperLogLog(2.8 版新增) - 海量数据基数统计的场景,比如百万级网页 UV 计数等;

  • GEO(3.2 版新增) - 存储地理位置信息的场景,比如滴滴叫车;

  • Stream(5.0 版新增) - 消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。

Redis 基本数据类型的底层实现

  • String 类型 - String 类型的底层数据结构是 SDS。SDS 是 Redis 针对字符串类型的优化,具有以下特性:
    • 常数复杂度获取字符串长度
    • 杜绝缓冲区溢出
    • 减少修改字符串长度时所需的内存重分配次数
  • List 类型 - 列表对象的编码可以是 ziplist 或者 linkedlist 。当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist 编码;否则,使用 linkedlist 编码。
    • 列表对象保存的所有字符串元素的长度都小于 64 字节;
    • 列表对象保存的元素数量小于 512 个;
  • Hash 类型 - 哈希对象的编码可以是 ziplist 或者 hashtable 。当哈希对象同时满足以下两个条件时, 使用 ziplist 编码;否则,使用 hashtable 编码。
    • 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
    • 哈希对象保存的键值对数量小于 512 个;
  • Set 类型 - 集合对象的编码可以是 intset 或者 hashtable 。当集合对象可以同时满足以下两个条件时,集合对象使用 intset 编码;否则,使用 hashtable 编码。
    • 集合对象保存的所有元素都是整数值;
    • 集合对象保存的元素数量不超过 512 个;
  • Zset 类型 - 有序集合的编码可以是 ziplist 或者 skiplist 。当有序集合对象可以同时满足以下两个条件时,有序集合对象使用 ziplist 编码;否则,使用 skiplist 编码。
    • 有序集合保存的元素数量小于 128 个;
    • 有序集合保存的所有元素成员的长度都小于 64 字节;

Redis 过期删除和内存淘汰

Redis 过期删除策略

【问题】

  • Redis 的过期删除策略是什么?

【解答】

Redis 采用的过期策略是:定期删除+惰性删除

  • 定时删除 - 在设置 key 的过期时间的同时,创建一个定时器,让定时器在 key 的过期时间来临时,立即执行 key 的删除操作。
    • 优点 - 保证过期 key 被尽可能快的删除,释放内存。
    • 缺点 - 如果过期 key 较多,可能会占用相当一部分的 CPU,从而影响服务器的吞吐量和响应时延
  • 惰性删除 - 放任 key 过期不管,但是每次访问 key 时,都检查 key 是否过期,如果过期的话,就删除该 key ;如果没有过期,就返回该 key。
    • 优点 - 占用 CPU 最少。程序只会在读写键时,对当前键进行过期检查,因此不会有额外的 CPU 开销。
    • 缺点 - 过期的 key 可能因为没有被访问,而一直无法释放,造成内存的浪费,有内存泄漏的风险
  • 定期删除 - 每隔一段时间,程序就对数据库进行一次检查,删除里面的过期 key 。至于要删除多少过期 key ,以及要检查多少个数据库,则由算法决定。定期删除是前两种策略的一种折中方案。定期删除策略的难点是删除操作执行的时长和频率。
    • 执行太频或执行时间过长,就会出现和定时删除相同的问题;
    • 执行太少或执行时间过短,就会出现和惰性删除相同的问题;

持久化时,对过期键会如何处理

RDB 持久化

  • RDB 文件生成阶段 - 从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,过期的键“不会”被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。
  • RDB 加载阶段 - RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况:
    • 如果 Redis 是“主服务器”运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键“不会”被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响;
    • 如果 Redis 是“从服务器”运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。

AOF 持久化

  • AOF 文件写入阶段 - 当 Redis 以 AOF 模式持久化时,如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值
  • AOF 重写阶段 - 执行 AOF 重写时,会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中,因此不会对 AOF 重写造成任何影响。

主从复制时,对过期键会如何处理

当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。

从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。

Redis 内存淘汰策略

【问题】

  • Redis 内存不足时,怎么办?
  • Redis 有哪些内存淘汰策略?
  • 如何选择内存淘汰策略?

【解答】

(1)Redis 内存淘汰要点

  • 失效时间 - 作为一种定期清理无效数据的重要机制,在 Redis 提供的诸多命令中,EXPIREEXPIREATPEXPIREPEXPIREAT 以及 SETEXPSETEX 均可以用来设置一条键值对的失效时间。而一条键值对一旦被关联了失效时间就会在到期后自动删除(或者说变得无法访问更为准确)。
  • 最大缓存 - Redis 允许通过 maxmemory 参数来设置内存最大值。当内存达设定的阀值,就会触发内存淘汰
  • 内存淘汰 - 内存淘汰是为了更好的利用内存——清理部分缓存,以此换取内存的利用率,即尽量保证 Redis 缓存中存储的是热点数据。

(2)Redis 内存淘汰策略

  • 不淘汰

    • noeviction - 当内存使用达到阈值的时候,所有引起申请内存的命令会报错。这是 Redis 默认的策略。
  • 在过期键中进行淘汰

    • volatile-random - 在设置了过期时间的键空间中,随机移除某个 key。

    • volatile-ttl - 在设置了过期时间的键空间中,具有更早过期时间的 key 优先移除。

    • volatile-lru - 在设置了过期时间的键空间中,优先移除最近未使用的 key。

    • volatile-lfu (Redis 4.0 新增)- 淘汰所有设置了过期时间的键值中,最少使用的键值。

  • 在所有键中进行淘汰

    • allkeys-lru - 在主键空间中,优先移除最近未使用的 key。
    • allkeys-random - 在主键空间中,随机移除某个 key。
    • allkeys-lfu (Redis 4.0 新增) - 淘汰整个键值中最少使用的键值。

(3)如何选择内存淘汰策略

  • 如果数据呈现幂等分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用 allkeys-lruallkeys-lfu
  • 如果数据呈现平均分布,也就是所有的数据访问频率都相同,则使用 allkeys-random
  • 若 Redis 既用于缓存,也用于持久化存储时,适用 volatile-lruvolatile-lfuvolatile-random。但是,这种情况下,也可以部署两个 Redis 集群来达到同样目的。
  • 为 key 设置过期时间实际上会消耗更多的内存。因此,如果条件允许,建议使用 allkeys-lruallkeys-lfu,从而更高效的使用内存。

Redis 持久化

Redis 如何保证数据不丢失

【问题】

  • Redis 如何保证数据不丢失?
  • Redis 有几种持久化方式?

【解答】

为了追求性能,Redis 的读写都是在内存中完成的。一旦重启,内存中的数据就会清空,为了保证数据不丢失,Redis 支持持久化机制。

Redis 有三种持久化方式

  • RDB 快照
  • AOF 日志
  • 混合持久化

AOF 的实现原理

【问题】

  • AOF 的实现原理是什么?
  • 为什么先执行命令,再把数据写入日志呢?

【解答】

Redis 命令请求会先保存到 AOF 缓冲区,再定期写入并同步到 AOF 文件

AOF 的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

  • 命令追加 - 当 Redis 服务器开启 AOF 功能时,服务器在执行完一个写命令后,会以 Redis 命令协议格式将被执行的写命令追加到 AOF 缓冲区的末尾。
  • 文件写入文件同步
    • Redis 的服务器进程就是一个事件循环,这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复。而时间事件则负责执行想 serverCron 这样的定时运行的函数。
    • 因为服务器在处理文件事件时可能会执行写命令,这些写命令会被追加到 AOF 缓冲区,服务器每次结束事件循环前,都会根据 appendfsync 选项来判断 AOF 缓冲区内容是否需要写入和同步到 AOF 文件中。

先执行命令,再把数据写入 AOF 日志有两个好处:

  • 避免额外的检查开销
  • 不会阻塞当前写操作命令的执行

当然,这样做也会有弊端:

  • 数据可能会丢失:
  • 可能阻塞其他操作:

AOF 的回写策略有几种

Redis 命令请求会先保存到 AOF 缓冲区,再定期写入并同步到 AOF 文件

appendfsync 不同选项决定了不同的持久化行为:

  • always - 将 AOF 缓冲区中所有内容写入并同步到 AOF 文件。这种方式是最数据最安全的,但也是性能最差的。
  • no - 将 AOF 缓冲区所有内容写入到 AOF 文件,但并不对 AOF 文件进行同步,何时同步由操作系统决定。这种方式是数据最不安全的,一旦出现故障,未来得及同步的所有数据都会丢失。
  • everysec - appendfsync 默认选项。将 AOF 缓冲区所有内容写入到 AOF 文件,如果上次同步 AOF 文件的时间距离现在超过一秒钟,那么再次对 AOF 文件进行同步,这个同步操作是有一个线程专门负责执行的。这张方式是前面两种的这种方案——性能足够好,且即使出现故障,仅丢失一秒钟内的数据。

appendfsync 选项的不同值对 AOF 持久化功能的安全性、以及 Redis 服务器的性能有很大的影响。

AOF 重写机制

【问题】

  • AOF 日志过大时,怎么办?
  • AOF 重写流程是怎样的?
  • AOF 重写时,可以处理请求吗?

【解答】

当 AOF 日志过大时,恢复过程就会很久。为了避免此问题,Redis 提供了 AOF 重写机制,即 AOF 日志大小超过所设阈值后,启动 AOF 重写,压缩 AOF 文件。

AOF 重写机制是,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到新的 AOF 日志中,等到全部记录完成后,就使用新的 AOF 日志替换现有的 AOF 日志。

作为一种辅助性功能,显然 Redis 并不想在 AOF 重写时阻塞 Redis 服务接收其他命令。因此,Redis 决定通过 BGREWRITEAOF 命令创建一个子进程,然后由子进程负责对 AOF 文件进行重写,这与 BGSAVE 原理类似。

  • 在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区。当 AOF 重写子进程开始工作后,Redis 每执行完一个写命令,会同时将这个命令发送给 AOF 缓冲区和 AOF 重写缓冲区。
  • 由于彼此不是在同一个进程中工作,AOF 重写不影响 AOF 写入和同步。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。
  • 最后,服务器用新的 AOF 文件替换就的 AOF 文件,以此来完成 AOF 重写操作。

BGREWRITEAOF 流程

RDB 的实现原理

【问题】

  • RDB 的实现原理是什么?
  • 生成 RDB 快照时,Redis 可以响应请求吗?

【解答】

BGSAVE 命令会“派生”(fork)一个子进程,由子进程负责创建 RDB 文件,服务器进程继续处理命令请求,所以该命令“不会阻塞”服务器

BGSAVE 流程

为什么会有混合持久化?

RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。

AOF 优点是丢失数据少,但是数据恢复不快。

为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

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

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

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

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

混合持久化优点:

  • 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

混合持久化缺点:

  • AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
  • 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

Redis 高可用

【问题】

Redis 如何保证高可用?

Redis 主从复制

【问题】

  • Redis 复制的工作原理?Redis 旧版复制和新版复制有何不同?
  • Redis 主从节点间如何复制数据?
  • Redis 的数据一致性是强一致性吗?

【解答】

(1)旧版复制基于 SYNC 命令实现。分为同步(sync)和命令传播(command propagate)两个操作。这种方式存在缺陷:不能高效处理断线重连后的复制情况。

(2)新版复制基于 PSYNC 命令实现。同步操作分为了两块:

  • 完整重同步(full resychronization) 用于初次复制;
  • 部分重同步(partial resychronization) 用于断线后重复制。
    • 主从服务器的复制偏移量(replication offset)
    • 主服务器的复制积压缓冲区(replication backlog)
    • 服务器的运行 ID

(3)Redis 集群主从节点复制的工作流程:

  • 步骤 1. 设置主从服务器
  • 步骤 2. 主从服务器建立 TCP 连接。
  • 步骤 3. 发送 PING 检查通信状态。
  • 步骤 4. 身份验证。
  • 步骤 5. 发送端口信息。
  • 步骤 6. 同步。
  • 步骤 7. 命令传播。

(4)由于主从复制是异步的,具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。

Redis 哨兵

【问题】

  • Redis 哨兵的功能?
  • Redis 哨兵的原理?
  • Redis 哨兵如何选举 Leader?
  • Redis 如何实现故障转移?

【解答】

(1)Redis 主从复制模式无法自动故障转移,也就是说,一旦主服务器宕机,需要手动恢复。为了解决此问题,Redis 增加了哨兵模式(Sentinel)。

(2)由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

img

Redis 集群

当 Redis 数据量超出单机的极限,就需要通过分区技术来分而治之。

Redis 采用的分区策略是:使用虚拟哈希槽来映射节点和数据。在 Redis Cluster 中,为整个集群分配 16384 个哈希槽。每个节点都会被分配一定的哈希槽,这个过程可以是自动分配,也可以是手动分配。任何一个槽没有被分配,那么集群处于下线状态。

当客户端向服务端发起读写请求时,先要根据 key 计算其所属的哈希槽(计算公式:CRC16(KEY) mod 16384),然后获取该哈希槽所属的节点。这样,就完成了寻址过程。

Redis 脑裂

什么是脑裂

分布式系统的脑裂问题(Split-Brain Problem)是一个严重的一致性问题,通常发生在分布式系统中的节点之间失去通信或部分通信时。这个问题的名称源自脑裂的比喻,就像一个分布式系统被分成多个部分的”脑”,每个部分独立运行,而没有协调一致的方式。

脑裂问题通常发生在以下情况下:

  1. 网络分区:当分布式系统中的网络发生问题,导致节点之间无法互相通信或只能部分通信时。这可能是由于网络故障、硬件故障、防火墙配置问题等原因引起的。
  2. 节点故障:当分布式系统的某个节点崩溃或出现故障,但其他节点无法确定该节点的状态,可能导致脑裂问题。

脑裂问题的典型情况是,在网络分区或节点故障后,分布式系统的一部分节点认为另一部分节点已经不可用,因此开始采取某种措施,比如选举新的领袖或切换到备份模式。然而,在某些情况下,网络分区可能会解除,或者节点故障可能会自行修复,导致系统中存在多个独立运行的子系统,每个子系统都认为自己是正确的。

这种情况下,脑裂问题可能导致以下问题:

  1. 数据不一致性:不同子系统可能具有不同的数据状态,这可能会导致数据不一致性和冲突。
  2. 资源冲突:如果不同的子系统尝试访问相同的资源,可能会发生资源冲突和竞争条件。
  3. 性能问题:系统中的资源可能被多次分配,从而浪费了资源并降低了性能。

为了解决脑裂问题,分布式系统通常需要采用一些机制,如投票算法、选举协议、心跳检测等,以确保在出现网络分区或节点故障时,系统能够正确地识别和处理问题,并维护一致性。这些机制可以帮助系统中的节点协同工作,避免脑裂问题的发生。然而,脑裂问题是分布式系统设计和管理中的复杂挑战之一,需要细致的规划和测试来确保系统的可靠性和稳定性。

Redis 中的脑裂问题是如何产生的

在 Redis 主从架构中,部署方式一般是“一主多从”,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程 A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。

这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在“从节点”中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— 脑裂出现了

然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题

总结一句话就是:由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。

如何解决 Redis 中的脑裂问题

当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。

在 Redis 的配置文件中有两个参数我们可以设置:

  • min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
  • min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。

我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。

这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。

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

等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。

再来举个例子。

假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。

同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。

这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。

Redis 线程模型

Redis 真的只有单线程吗?

Redis 并非真的只有单线程。

  • Redis 的主要工作包括接收客户端请求、解析请求和进行数据读写等操作,是由单线程来执行的,这也是常说 Redis 是单线程程序的原因。
  • Redis 还启动了 3 个线程来执行文件关闭AOF 同步写惰性删除等操作。

Redis 单线程模式是怎样的?

Redis 单线程模式指的是其核心网络模型为单线程模式。这个模式为 IO 多路复用+单线程读写请求,其中,IO 多路复用使得 Redis 可以同时处理多个客户端连接。

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

参考:[Redis 为什么快](#Redis 为什么快)

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

随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。

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

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

Redis 事务

【问题】

  • Redis 的并发竞争问题是什么?如何解决这个问题?
  • Redis 支持事务吗?
  • Redis 事务是严格意义的事务吗?Redis 为什么不支持回滚。
  • Redis 事务如何工作?
  • 了解 Redis 事务中的 CAS 行为吗?

【解答】

Redis 提供的不是严格的事务,Redis 只保证串行执行命令,并且能保证全部执行,但是执行命令失败时并不会回滚,而是会继续执行下去

Redis 不支持回滚的理由:

  • Redis 命令只会因为错误的语法而失败,或是命令用在了错误类型的键上面。
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

MULTIEXECDISCARDWATCH 是 Redis 事务相关的命令。

Redis 有天然解决这个并发竞争问题的类 CAS 乐观锁方案:每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。

Redis 管道

【问题】

  • 除了事务,还有其他批量执行 Redis 命令的方式吗?

【解答】

Redis 是一种基于 C/S 模型以及请求/响应协议的 TCP 服务。Redis 支持管道技术。管道技术允许请求以异步方式发送,即旧请求的应答还未返回的情况下,允许发送新请求。这种方式可以大大提高传输效率。使用管道发送命令时,Redis Server 会将部分请求放到缓存队列中(占用内存),执行完毕后一次性发送结果。如果需要发送大量的命令,会占用大量的内存,因此应该按照合理数量分批次的处理。

Redis 应用

缓存设计

【问题】

如何避免缓存雪崩、缓存击穿、缓存穿透?

有哪些更新缓存策略?采用那种策略比较好?

如何保证缓存一致性?

有哪些常见的内存淘汰算法

LRU 算法的原理是什么

LFU 算法的原理是什么

分布式锁

大 Key 处理

什么是大 Key

大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。

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

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

大 Key 的影响

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

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

如何找到大 Key

1、redis-cli –bigkeys 查找大 key

可以通过 redis-cli –bigkeys 命令查找大 key:

1
redis-cli -h 127.0.0.1 -p6379 -a "password" -- bigkeys

使用的时候注意事项:

  • 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;
  • 如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。

该方式的不足之处:

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

2、使用 SCAN 命令查找大 key

使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。

对于 String 类型,可以直接使用 STRLEN 命令获取字符串的长度,也就是占用的内存空间字节数。

对于集合类型来说,有两种方法可以获得它占用的内存大小:

  • 如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List 类型:LLEN 命令;Hash 类型:HLEN 命令;Set 类型:SCARD 命令;Sorted Set 类型:ZCARD 命令;
  • 如果不能提前知道写入集合的元素大小,可以使用 MEMORY USAGE 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。

3、使用 RdbTools 工具查找大 key

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

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

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

如何删除大 Key

如果大 Key 过大,删除时间过长,会阻塞 Redis 主线程,导致主线程无法及时响应其他请求。因此,删除大 Key 时需要考虑分批、异步处理。

1、分批次删除

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

Python 代码:

1
2
3
4
5
6
7
8
9
10
def del_large_hash():
r = redis.StrictRedis(host='redis-host1', port=6379)
large_hash_key ="xxx" #要删除的大hash键名
cursor = '0'
while cursor != 0:
# 使用 hscan 命令,每次获取 100 个字段
cursor, data = r.hscan(large_hash_key, cursor=cursor, count=100)
for item in data.items():
# 再用 hdel 命令,每次删除1个字段
r.hdel(large_hash_key, item[0])

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

Python 代码:

1
2
3
4
5
6
def del_large_list():
r = redis.StrictRedis(host='redis-host1', port=6379)
large_list_key = 'xxx' #要删除的大list的键名
while r.llen(large_list_key)>0:
#每次只删除最右100个元素
r.ltrim(large_list_key, 0, -101)

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

Python 代码:

1
2
3
4
5
6
7
8
9
10
def del_large_set():
r = redis.StrictRedis(host='redis-host1', port=6379)
large_set_key = 'xxx' # 要删除的大set的键名
cursor = '0'
while cursor != 0:
# 使用 sscan 命令,每次扫描集合中 100 个元素
cursor, data = r.sscan(large_set_key, cursor=cursor, count=100)
for item in data:
# 再用 srem 命令每次删除一个键
r.srem(large_size_key, item)

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

Python 代码:

1
2
3
4
5
6
def del_large_sortedset():
r = redis.StrictRedis(host='large_sortedset_key', port=6379)
large_sortedset_key='xxx'
while r.zcard(large_sortedset_key)>0:
# 使用 zremrangebyrank 命令,每次删除 top 100个元素
r.zremrangebyrank(large_sortedset_key,0,99)

2、异步删除

从 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
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 等配置,这样就可以有效的提高主线程的执行效率。

参考资料