Appearance
面试题 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 的持久化机制,例如以下场景:
- 使用 save 相关配置,如“save m n”。表示 m 秒内数据集存在 n 次修改 时,自动触发 bgsave。
- 如果从节点执行全量复制操作,主节点自动执行 bgsave 生成 RDB 文件并发送给从节点。
- 执行 debug reload 命令重新加载 Redis 时,也会自动触发 save 操作。
- 默认情况下执行 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 文本协议格式具有如下的优点:
- 文本协议具有很好的兼容性;
- 直接采用文本协议格式,可以避免二次处理的开销;
- 文本协议具有可读性,方便直接修改和处理。
AOF 持久化的文件同步机制: 为了提高程序的写入性能,现代操作系统会把针对硬盘的多次写操作优化为一次写操作。
- 当程序调用 write 对文件写入时,系统不会直接把书记写入硬盘,而是先将数据写入内存的缓冲区中;
- 当达到特定的时间周期或缓冲区写满时,系统才会执行 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 基础笔记》