Skip to content

面试题 Redis

如何利用 Redis 实现一个分布式锁?

得分点 为什么要实现分布式锁、实现分布式锁的方式

标准回答

在分布式的环境下,会发生多个 server 并发修改同一个资源的情况,这种情况下,由于多个 server 是多个不同的 JRE 环境,而 Java 自带的锁局限于当前 JRE,所以 Java 自带的锁机制在这个场景下是无效的,那么就需要我们自己来实现一个分布式锁。

采用 Redis 实现分布式锁,我们可以在 Redis 中存一份代表锁的数据,数据格式通常使用字符串即可。

首先加锁的逻辑可以通过setnx key value来实现,但如果客户端忘记解锁,那么这种情况就很有可能造成死锁,但如果直接给锁增加过期时间即新增expire key seconds又会发生其他问题,即这两个命令并不是原子性的,那么如果第二步失败,依然无法避免死锁问题。考虑到如上问题,我们最终可以通过set...nx...命令,将加锁、过期命令编排到一起,把他们变成原子操作,这样就可以避免死锁。写法为set key value nx ex seconds

解锁就是将代表锁的那份数据删除,但不能用简单的del key,因为会出现一些问题。比如此时有进程 A,如果进程 A 在任务没有执行完毕时,锁被到期释放了。这种情况下进程 A 在任务完成后依然会尝试释放锁,因为它的代码逻辑规定它在任务结束后释放锁,但是它的锁早已经被释放过了,那这种情况它释放的就可能是其他线程的锁。为解决这种情况,我们可以在加锁时为 key 赋一个随机值,来充当进程的标识,进程要记住这个标识。当进程解锁的时候进行判断,是自己持有的锁才能释放,否则不能释放。另外判断,释放这两步需要保持原子性,否则如果第二步失败,就会造成死锁。而获取和删除命令不是原子的,这就需要采用 Lua 脚本,通过 Lua 脚本将两个命令编排在一起,而整个 Lua 脚本的执行是原子的。

综上所述,优化后的实现分布式锁命令如下:

  • 加锁
set key random-value nx ex seconds
  • 解锁
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end

加分回答 上述的分布式锁实现方式是建立在单节点之上的,它可能存在一些问题,比如有一种情况,进程 A 在主节点加锁成功,但主节点宕机了,那么从节点就会晋升为主节点。那如果此时另一个进程 B 在新的主节点上加锁成功而原主节点重启了,成为了从节点,系统中就会出现两把锁,这违背了锁的唯一性原则。 总之,就是在单个主节点的架构上实现分布式锁,是无法保证高可用的。

若要保证分布式锁的高可用,则可以采用多个节点的实现方案。这种方案有很多,而 Redis 的官方给出的建议是采用 RedLock 算法的实现方案。该算法基于多个 Redis 节点,它的基本逻辑如下:

这些节点相互独立,不存在主从复制或者集群协调机制;

  • 加锁:以相同的 KEY 向 N 个实例加锁,只要超过一半节点成功,则认定加锁成功;
  • 解锁:向所有的实例发送 DEL 命令,进行解锁; 我们可以自己实现该算法,也可以直接使用 Redisson 框架。

Redis 的持久化策略?

得分点 RDB、AOF

标准回答 Redis4.0 之后,Redis 有 RDB 持久化、AOF 持久化、RDB-AOF 混合持久化这三种持久化方式。

  • RDB 持久化是将当前进程数据以生成快照的方式保存到硬盘的过程,也是 Redis 默认的持久化机制。RDB 会创建一个经过压缩的二进制文件,这个文件以’.rdb‘结尾,内部存储了各个数据库的键值对等信息。RDB 持久化过程有手动触发和自动触发两种方式。手动触发是指通过 SAVE 或 BGSAVE 命令触发 RDB 持久化操作,创建“.rdb”文件;自动触发是指通过配置选项,让服务器在满足指定条件时自动执行 BGSAVE 命令。RDB 持久化的优点是其生成的紧凑压缩的二进制文件体积小,使用该文件恢复数据的速度非常快;缺点则是 BGSAVE 每次运行都要执行 fork 操作创建子进程,这属于重量级操作,不宜频繁执行,因此,RBD 没法做到实时的持久化。
  • AOF 以独立日志的方式记录了每次写入的命令,重启时再重新执行 AOF 文件中的命令来恢复数据。AOF 持久化的优点是与 RDB 持久化可能丢失大量的数据相比,AOF 持久化的安全性要高很多。通过使用 everysec 选项,用户可以将数据丢失的时间窗口限制在 1 秒之内。其缺点则是,AOF 文件存储的是协议文本,它的体积要比二进制格式的”.rdb”文件大很多。AOF 需要通过执行 AOF 文件中的命令来恢复数据库,其恢复速度比 RDB 慢很多。AOF 在进行重写时也需要创建子进程,在数据库体积较大时将占用大量资源,会导致服务器的短暂阻塞。AOF 解决了数据持久化的实时性,是目前 Redis 主流的持久化方式。
  • RDB-AOF 混合持久化模式是 Redis4.0 开始引入的,这种模式是基于 AOF 持久化构建而来的。用户可以通过配置文件中的“aof-use-rdb-preamble yes”配置项开启 AOF 混合持久化。Redis 服务器在执行 AOF 重写操作时,会像执行 BGSAVE 命令一样,根据数据库当前的状态生成相应的 RDB 数据,并将其写入 AOF 文件中;对于重写之后执行的 Redis 命令,则以协议文本的方式追加到 AOF 文件的末尾,即 RDB 数据之后。 通过使用 RDB-AOF 混合持久化,用户可以同时获得 RDB 持久化和 AOF 持久化的优点,服务器既可以通过 AOF 文件包含的 RDB 数据来实现快速的数据恢复操作,又可以通过 AOF 文件包含的 AOF 数据来将丢失数据的时间窗口限制在 1s 之内

加分回答

RDB 手动触发分别对应 save 和 bgsave 命令:

  • save 命令会一直阻塞当前 Redis 服务器到 RBD 过程完成为止,所以这种方式在操作内存比较大的实例时会造成长时间阻塞,因此线上环境不建议使用,该命令已经被废弃。
  • bgsave 命令会让 Redis 进程执行 fork 创建子进程,由子进程负责 RBD 持久化过程,完成后自动结束,因此只在 fork 阶段发生阻塞,一般阻塞的时间也不会很长。因此 Redis 内部所涉及的几乎所有 RDB 操作都采用了 bgsave 的方式。

除了执行命令手动触发之外,Redis 内部还存在自动触发 RDB 的持久化机制,例如以下场景:

  1. 使用 save 相关配置,如“save m n”。表示 m 秒内数据集存在 n 次修改 时,自动触发 bgsave。
  2. 如果从节点执行全量复制操作,主节点自动执行 bgsave 生成 RDB 文件并发送给从节点。
  3. 执行 debug reload 命令重新加载 Redis 时,也会自动触发 save 操作。
  4. 默认情况下执行 shutdown 命令时,如果没有开启 AOF 持久化功能则 自动执行 bgsave。

AOF 默认不开启,需要修改配置项来启用它:

appendonly yes # 启用AOF
appendfilename "appendonly.aof" # 设置文件名

AOF 以文本协议格式写入命令,如: *3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n 文本协议格式具有如下的优点:

  1. 文本协议具有很好的兼容性;
  2. 直接采用文本协议格式,可以避免二次处理的开销;
  3. 文本协议具有可读性,方便直接修改和处理。

AOF 持久化的文件同步机制: 为了提高程序的写入性能,现代操作系统会把针对硬盘的多次写操作优化为一次写操作。

  1. 当程序调用 write 对文件写入时,系统不会直接把书记写入硬盘,而是先将数据写入内存的缓冲区中;
  2. 当达到特定的时间周期或缓冲区写满时,系统才会执行 flush 操作,将缓冲区中的数据冲洗至硬盘中;

这种优化机制虽然提高了性能,但也给程序的写入操作带来了不确定性。

对于 AOF 这样的持久化功能来说,冲洗机制将直接影响 AOF 持久化的安全性;

为了消除上述机制的不确定性,Redis 向用户提供了 appendfsync 选项,来控制系统冲洗 AOF 的频率;

Linux 的 glibc 提供了 fsync 函数,可以将指定文件强制从缓冲区刷到硬盘,上述选项正是基于此函数。

详细的说说 Redis 的数据类型

得分点 Redis5 种数据结构

标准回答 Redis 主要提供了 5 种数据结构:字符串(string)、哈希(hash)、列表(list)、集合(set)、有序集合(zset)。Redis 还提供了 Bitmap、HyperLogLog、Geo 类型,但这些类型都是基于上述核心数据类型实现的。5.0 版本中,Redis 新增加了 Streams 数据类型,它是一个功能强大的、支持多播的、可持久化的消息队列。

  • string 可以存储字符串、数字和二进制数据,除了值可以是 String 以外,所有的键也可以是 string,string 最大可以存储大小为 2M 的数据。
  • list 保证数据线性有序且元素可重复,它支持 lpush、blpush、rpop、brpop 等操作,可以当作简单的消息队列使用,一个 list 最多可以存储 2^32-1 个元素
  • hash 的值本身也是一个键值对结构,最多能存储 2^32-1 个元素
  • set 是无序不可重复的,它支持多个 set 求交集、并集、差集,适合实现共同关注之类的需求,一个 set 最多可以存储 2^32-1 个元素
  • zset 是有序不可重复的,它通过给每个元素设置一个分数来作为排序的依据,一个 zset 最多可以存储 2^32-1 个元素。

加分回答 每种类型支持多个编码,每一种编码采取一个特殊的结构来实现 各类数据结构内部的编码及结构:

  • string:编码分为 int、raw、embstr;int 底层实现为 long,当数据为整数型并且可以用 long 类型表示时可以用 long 存储;embstr 底层实现为占一块内存的 SDS 结构,当数据为长度不超过 32 字节的字符串时,选择以此结构连续存储元数据和值;raw 底层实现为占两块内存的 SDS,用于存储长度超过 32 字节的字符串数据,此时会在两块内存中分别存储元数据和值。
  • list:编码分为 ziplist、linkedlist 和 quicklist(3.2 以前版本没有 quicklist)。ziplist 底层实现为压缩列表,当元素数量小于 2 且所有元素长度都小于 64 字节时,使用这种结构来存储;linkedlist 底层实现为双端链表,当数据不符合 ziplist 条件时,使用这种结构存储;3.2 版本之后 list 一般采用 quicklist 的快速列表结构来代替前两种。
  • hash:编码分为 ziplist、hashtable 两种,其中 ziplist 底层实现为压缩列表,当键值对数量小于 2,并且所有的键值长度都小于 64 字节时使用这种结构进行存储;hashtable 底层实现为字典,当不符合压缩列表存储条件时,使用字典进行存储。
  • set:编码分为 inset 和 hashtable,intset 底层实现为整数集合,当所有元素都是整数值且数量不超过 2 个时使用该结构存储,否则使用字典结构存储。
  • zset:编码分为 ziplist 和 skiplist,当元素数量小于 128,并且每个元素长度都小于 64 字节时,使用 ziplist 压缩列表结构存储,否则使用 skiplist 的字典+跳表的结构存储。

Redis 过期键的删除策略

详见《Redis 基础笔记》

I know where dry desert ends, green grass grows · MooN