复盘女朋友面试4个月的Redis面试题

1,175 阅读21分钟

背景

上文提到最近会陆续开始对女朋友最近4个月的面试题复盘,没错,今天我又来复盘了,上文是Mysql专题(复盘女朋友面试4个月的Mysql面试题),本文将复盘另一个面试概率极高的Redis专题。

我记得我服务过的公司项目都有使用到Redis,特别是目前就职的电商公司,每个业务团队都有独立的Redis集群,根据不同的业务场景,Redis承担了不同的职责。

我们看看我女朋友被问到哪些Redis问题了!

image.png

Redis的面试题还是挺多的,和女朋友确认后,我把命中率最高的题目都标记了星号,有使用场景上的问题,有基础数据结构实现原理,还有高可用的架构原理,不得不说,在Redis专题想要完全征服面试官还是挺有难度的。

image.png

废话不多说,下面开始记录各个题目答题思路!

Redis使用问题

一、Redis在项目中的使用场景

这是一道开放题,面试官主要是了解我们有没有在实际项目中使用过Redis。我们可以根据项目的实际情况举例说明。我拿我负责过的电商优惠券系统来举例回答:

  • 分布式锁

在优惠券系统中,有比较核心的几个能力,发券,核销券,退还券,在这几个功能中都有使用分布式锁,通过分布式锁可以实现防止重复提交,比如发券接口,如果一个上游业务方连续重复调用两次请求,可以通过分布式锁对同一个幂等key过滤重复请求。

//同一个请求幂等key锁10秒,10秒内重复请求去除
boolean locked =  lock.tryLock(key, 10, TimeUnit.SECONDS);
if (locked) {
    //发券逻辑
} else {
    throw new RuntimeException("请勿重复提交");
}
  • 热点数据缓存

在优惠券系统中,有一个使用热点缓存非常经典的场景,那就是券模板信息,券模板信息在发券,咨询券,核券,回退券等核心链路中发挥了非常关键的作用,像大批量发券场景就会查询某些券模板信息,比如券门槛,券面额等信息。如果每次发券都查询数据库,那数据库一定会存在非常的压力,这样的肯定是非常不合理的。在我们优惠券系统中,通过caffeine+redis实现了多级缓存,缓解数据库的压力。

image.png

  • 计数器

在优惠券的后台管理系统中,一般有某个券模板发放量,使用量的统计展示,在我们优惠券系统中这个功能就是通过redis自增计数器功能来实现的。

redis在项目中使用的场景还是非常多的,我们只要结合自己的项目回答即可。

二、Redis有效期怎么设置

这个问题就是考察redis的最佳实践了,在实际项目中,我们一般有两个比较重要的准则:

1、尽量给每个key都设置有效期,避免不需要的key过多,占用内存资源

2、将同一个类型的redis key的有效期打散,避免批量缓存数据失效,导致穿透redis的问题,比如第一点里面,缓存券模板的过期时间就是不同随机因子打散的。

image.png

三、redission实现分布式锁的原理

这个题目需要对redission的源码有过阅读才能更好的回答,redission的源码阅读起来并不难,有时间还是可以去看一看的。

我们看看一下redission加锁后在redis中是怎么存储的?

image.png

没错,redission中分布式锁使用的是hash结构来存储的。看到这样就可以和面试官说redission底层使用了redis的hash结构来进行存储分布式锁信息。

hash key就是我们设置的锁key: dmb

hash 属性key xxxxx:85 xxxx是uuid, 85代表加锁成功的线程

hash value 2 代表加锁成功,重入了2次。

我们可以和面试官说我阅读过加锁解锁的源码:

image.png

上面这段代码是加锁的核心实现,底层通过lua脚本控制加锁逻辑,lua脚本是redis官方支持原子性执行多个redis命令的机制。整个加锁流程非常清晰简短,就是判断锁的key是否存在,不存在则加锁,否则判断锁是否属于当前线程的,是则加锁次数自增1,不是则返回锁的剩余时间。

接下来就是解锁的源码:

image.png

通过解锁源码看出,解锁会判断锁是不是被当前线程持有,如果是需要判断锁次数是否大于1,大于1需要加锁次数减1,如果减到了0则需要删除key,这样就释放锁成功了。

还有一个重要的知识点就是锁自动续期,也就是大家常说的看门狗设计。这个是在加锁核心逻辑的上层实现的。

image.png

image.png

看门狗的实现是通过netty的时间轮组件来实现定时续期功能的,定时任务里每隔3分之加锁时间就会尝试续期一次。

redis经典问题

一、redis缓存如何和数据库保持一致性?

这个问题我也记得被问过很多次,这个一般要具体的场景具体分析,如果是没有并发的业务场景下,我们很容易实现缓存和数据库的一致性,但是在高并发场景下就很难了,所以我们需要根据自身实际业务的场景选择最合适的方案,在我的实际项目中主要的场景都是使用的最终一致性的方案,比如上方说到的优惠券模板缓存信息,实际上就是满足最终一致性的。

回答这个问题,我们可以和面试官先讲下业界比较认可的方案,再举例讲下实际业务使用的方案。

  • 保证最终一致性Cache Design Pattern方案

缓存模式其实是计算机体系操作系统层面的内容,是解决cpu和内存数据一致性的方案。缓存模式有四种:

缓存模式更新缓存设计
Cache aside Pattern更新数据库时删除缓存,查询数据库加载缓存
Read Through Pattern更新数据库时不直接更新缓存,读数据时更新缓存
Write Through Pattern更新数据库时更新缓存
Write Behind Caching Pattern先同步更新缓存,再异步更新数据库

我讲下最常用的缓存模式,他是在更新缓存后删除缓存,在查询操作未命中缓存的时候再更新缓存。我们系统中也使用的是这种方案。

image.png

这个方案并不会完全保证更新缓存之后缓存数据一致,有极低的概率存在不一致,比如线程1查询缓存未命中后查询数据库,这个时候来了一个线程2更新数据库并删除缓存操作,这个时候线程1查到的旧数据就会写到缓存了,这样的弊端就是需要等候下次缓存时间到了才会更新为最新的数据。

image.png

为了解决上面的问题,在业界推出了缓存双删的方案。

image.png

这个方案可以最大程度避免上面提到的问题,延迟时间的设置需要考虑业务场景。

  • 强一致性方案

如果真的要求强一致方案,数据库更新的同时,redis也更新,那么我们就需要采用分布式事务了,比如二阶段提交,但是这样做的后果就是性能急剧下降,这就得看业务实时性重要还是性能以及用户体验重要了。在一般情况下,我们通常会和业务说明,选择最终一致性方案。

这个题目真的要结合自己的业务场景回答,或者结合面试官给的业务场景回答,不同的业务场景对数据一致性的要求也不一样。

二、讲一讲缓存穿透、缓存击穿、缓存雪崩

缓存穿透是指查询一些数据库中压根不存在的数据,导致每次查询都穿透到数据库了,这种也有很多成熟的方案解决,比如使用布隆过滤器将数据库存在的数据存储一份,每次查询缓存前都查询布隆过滤器是否存在,存在则执行后续查询逻辑,否则直接返回。

image.png

缓存击穿是指热点key失效,多个线程同时请求数据库,这个问题通常使用jvm锁和分布式锁就可以避免拦截大部分请求,如果是使用本机缓存比如caffiene的情况,它底层就是使用synchronized加锁使单机最多一个线程穿透到数据库。而如果是加载分布式缓存如redis我们需要使用分布式锁来控制只要一个进程可以操作数据库了。

拿加载redis来说:

image.png

缓存雪崩是指大量缓存key同时失效,导致批量请求打到数据库,这个时候db压力大,可能导致数据库扛不住,最终可能拖垮应用。这个问题通常都会将缓存设置的有效期打散,避免同时失效,或者通过定时任务提前预热缓存。

三、redis大key、热key如何发现解决?

这个问题其实挺好的,我们日常开发和巡检中关注比较多的点,可以考察面试者有没有实际解决热key和大key的生产问题。大key和热key对redis服务器的稳定性有很大的挑战,我们应该尽量避免大key的产生。

如果我们使用的云上的redis,比如腾讯云,他们提供了大key和热key监控,可以在发现热key的时候告警通知我们开发或者运维人员。

腾讯云官方对热key和大key有定义,以及有成熟的解决方案 www.tencentcloud.com/zh/document…

image.png

如果没有使用腾讯云等云上的redis,我们有哪些手段可以分析热key和大key呢?

  • 大key发现手段
  1. 在redis 4.0之后可以通过redis-cli --big-keys命令分析每种类型下最大的key。
  2. Redis自4.0起提供了MEMORY USAGE命令来帮助分析Key的内存占用
  3. 我们还可以通过redis的慢操作日志分析,查看操作key的耗时,可通过CONFIG SET slowlog-log-slower-than 100,控制大于多长时间才算慢。可以通过slowlog get命令查看慢日志。
  4. 使用redis-rdb-tools工具以定制化方式找出大Key
  • 大key解决手段
  1. 及时清理无效的数据,注意大key使用延迟删除(异步删除),unlink命令
  2. 压缩大value
  3. 对大key拆分,对多个key查询可以通过用mget批量查询
  4. 增强监控,对redis内存增长加强监控,及时发现减少大key的影响
  • 热key发现手段
  1. 我们可以在应用客户端记录监控每个key的访问次数
  2. Redis自4.0起提供了hotkeys参数来方便用户进行实例级的热Key分析功能,该参数能够返回所有Key的被访问次数
  3. Redis的monitor命令能够打印Redis中的所有请求,包括时间信息、Client信息、命令以及Key信息。
  • 热key解决手段
  1. 在架构上选择读写分离,分离读写压力
  2. 热key缓存在本地缓存,避免请求redis
  3. 阿里的Tair缓存代理框架能智能识别热key,并缓存在代理层,不会查询redis。

基础数据结构

一、讲下redis string底层实现的原理

这个题目需要对string类型的实现有所了解,否则不好开头回答,在string类型中,通过简单动态字符串SDS(simple Dynamic String)存储类型来实现。

我们可以脑海里回忆下SDS的结构体定义:

image.png

sds结构体其实包含装了c中的char数组结构,包含了数据长度和char数组,以及容量,一共定义了5种长度sds结构,redis存储stringl类型数据时会根据字符串长度决定使用哪个结构体存储。

从sds的结构定义可以看出sds比传统的char数组有以下优势:

  1. 根据len属性直接判断字符串长度,时间复杂度O(1),char数组O(n)
  2. 有不同长度的定义,可以动态扩容
  3. c中的char数组用\0作为结束符,不适合存储二进制数据,而sds不需要根据\0判断字符串是否结束,可以根据len属性判断

在redis中,不同的数据结构需要说原理的话,主要是插入,删除,更新,扩容,缩容等操作的流程机制,string数据结构的功能也无非是这几个。

image.png

上面就是string类型数据的插入和更新的主流程,值得一说的就是他的内存空间扩容策略惰性空间释放策略

  • 内存空间扩容机制

redis空间扩容.jpg

也就是在数据大小是1M之前扩容原空间2倍,在1M之后每次增加1M内存空间。

  • 惰性空间释放

惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来,并等待将来使用。

void sdsclear(sds s) {
 
    // 取出 sdshdr
    struct sdshdr *sh = (void*) (s-(sizeof(struct sdshdr)));
 
    // 剩余可用空间 加上需要释放的空间
    sh->free += sh->len;
    // 已使用的空间 设置为0
    sh->len = 0;
 
    // `将结束符放到最前面(相当于惰性地删除 buf 中的内容)`
    sh->buf[0] = '\0';
}

二、讲讲zset的底层数据结构

zset是一个有自动排序能力的数据结构。

每个元素有score,默认按score从小到大排序,score相同时,按照key的ascii码排序

redis底层使用了两种结构ziplist和skiplist来表示zset。根据zset的member数量选择不同的结构:

image.png

从上面可知,zset其实底层有两种数据结构支撑,skiplist只是其中一种结构,而面试官一般都喜欢问跳表结构。

跳表是由链表组成的,链表里的元素是一个数组结构,我们可以看一下redis中跳表的定义,比较清晰:

/* 跳表里的节点 */
typedef struct zskiplistNode {
    //元素内容
    sds ele;
    //分数
    double score;
    //后继指针
    struct zskiplistNode *backward;
    struct zskiplistLevel {
    //前驱指针
        struct zskiplistNode *forward;
    //间隔元素数量
        unsigned long span;
    } level[];
} zskiplistNode;

// 跳表结构
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    //大小
    unsigned long length;
    //总层数
    int level;
} zskiplist;

//zset 结构
typedef struct zset {
    //字典
    dict *dict;
    //跳表
    zskiplist *zsl;
} zset;

跳表结构比较好理解,就是经典的用空间换时间设计思想,将数据冗余在多层,最底层数据是最完整的,越往上层数据越少,这样设计就是为了查找快速。

每次插入数据都会从最上层开始查找插入位置。选择好了插入位置后会随机生成一个层级,根据是否是新层级构建跳表节点。

下面图可以表示redis中的跳表结构:

image.png

redis跳表的特点

  1. 用c语言翻译实现了William Pugh的平衡树的概率替代方案。
  2. 支持重复的score成员
  3. 支持分数排序,也支持数据排序
  4. 有一个后向指针,所以它是一个双向链表(回忆下B+树的叶子节点,是不是很相似?),后向指针仅在“级别 1”。这允许从尾到头遍历列表,对ZREVRANGE很有用

redis高可用方案

一、说一说redis的主从集群

虽然redis性能高,由于还是会存在单机故障,因此redis还是需要主从集群,从节点需要和主节点数据同步,这样既可以实现故障切换,还可以实现读写分离,提高redis可用性。

我之前的文章有详细分析过主从复制的原理,有需要的朋友可以去看看。如果是面试的时候可以和面试官直接说下面的重点。

redis主从集群主要涉及到主从复制的功能。复制数据又分为全量复制和增量复制。

在redis官方也有解释说明:redis.io/docs/manage…

全量复制:

master启动一个后台save进程去生产rdb文件,同时启动创建缓存用于缓存客户端发送的写命令,如果生产rdb文件完成,将rdb文件发送到slave,slave将rdb文件保存到磁盘,然后加载到内存。然后master将所有缓存的命令(和redis本身协议一致)发送到副本。

小结一下:全量同步:总体有两个数据,一个是rdb快照文件,一个生成快照过程中的客户端命令缓存。

image.png

增量复制:

增量复制过程是通过psync命令完成的,从节点会将复制id,上次同步的最大偏移量发送到主节点,主节点根据偏移量生成rdb文件,最后发送给从节点。

image.png

二、请你聊一聊哨兵模式sentinel

有了主从复制,为什么还需要哨兵模式呢?因为主从模式有他的短板,主从模式在节点故障的时候没法自动将从节点切换到主节点,而且客户端也无法感知新的主节点。这个时候哨兵模式就出现了,哨兵模式是给redis主从集群的节点增加了监控的节点,如果master出现故障,sentinel可以实现故障转移,并且通知客户端连接新的master。

哨兵部署架构:每个哨兵都会监听redis集群里的每个节点。 image.png

sentinel节点节点之间是通过redis的pub/sub机制实现通信的,这样可以指定哨兵有哪些节点,他们之间不会直接创建连接。 image.png

如果redis的master出现问题,那么sentinel会开始senntinel选举和redis的选举过程。

master故障需要sentinel集群确认master故障。包含主观下线和客观下线。

主观下线是sentinel单个节点发现master超过了指定时间没有回复ping命令。 image.png

客观下线是指有超过配置的法定哨兵数认为master主观下线后,确认master为客观下线。

sentine确认master主观下线后,会执行故障转移。首先会选择一个主sentinel完成故障转移。

先和面试官说重点:通过sentinel选举领导者。使用raft算法(状态共识算法)

每一个Sentinel节点都可以成为Leader,当一个Sentinel节点确认redis集群的主节点主观下线后,会请求其他Sentinel节点要求将自己选举为Leader。被请求的Sentinel节点如果没有同意过其他Sentinel节点的选举请求,则同意该请求(选举票数+1),否则不同意。

如果一个Sentinel节点获得的选举票数达到Leader最低票数(quorum和Sentinel节点数/2+1的最大值),则该Sentinel节点选举为Leader;否则重新进行选举。

Raft核心思想:先到先得,少数服从多数。

选举好主sentinel后,就是选择最新的redis master了。

选择master时候会考虑从节点(副本)下面几个情况:

  1. 与主机断开连接的时间。过滤发现与master服务器断开连接的时间超过配置的master超时时间down-after-milliseconds的副本slaves
  2. 副本优先级。 优先选择replica-priority低的。
  3. 如果优先级相同,已处理复制偏移量。越大越优先,这个更符合业务场景功能。
  4. 如果复制offset相同,就看运行ID。优先选择小的。

选择好master节点后,维护新集群

  1. sentinel节点,向新主节点发送,slave no one命令,让它成为独立节点
  2. sentinel节点,向其他从节点发送 slaveof ip port,跟随到主节点

到这里哨兵机制的原理就说清楚了。内容挺多,如果没有准备好,要讲清楚确实挺难。

image.png

三、请你聊一聊Cluster集群

为什么有了哨兵还需要Redis的Cluster集群方案呢?

哈哈哈,这个问题就是随着前面两个问题来的啊,如果理解了主从集群和哨兵,我们知道他们还是存在短板,主从集群没法主动选主master,哨兵没有做数据分片,这样redis单机性能瓶颈还是存在。如果需要提高redis的横向扩展能力,还想需要实现分片能力。Redis的Cluster方案就是redis官方提供的redis分片高可用架构。我现在的公司大部分核心业务也使用的是Cluster集群方案,这样可以把c端用户的读缓存压力分担到多个redis分片上。

Redis Cluster是使用虚拟槽实现的。总共创建16384个槽,每个redis节点负责一个区间段的虚拟槽。

master节点根据bitmap来保存slot关系,0代表不属于本节点,1代表属于当前节点。

image.png

为什么是16384个槽呢?为什么不是65535?

  1. 因为Redis每秒都需要发送心跳包,槽位信息也需要发送,如果槽位为65536,发送心跳信息的消息头达65536÷8÷1024=8kb,发送的心跳包过于庞大,因此16384够用。
  2. redis不建议超过1000个节点,如果节点过1000个,也会导致网络拥堵。16384
  3. Redis master节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,槽位数越小,压缩率越高

计算key属于哪个槽是通过CRC16算法以及取模得到的:

HASH_SLOT = CRC16(key) mod 16384

在redission客户端,客户端初始化和定时任务会从master拉取slot信息,并进行本地缓存。并且每次操作key都会计算slot。

if (key.hasArray()) {
end = CRC16.crc16(key.array(), key.position(), key.limit() - key.position()) % 16384;
return end;
}
//分配slot
end = CRC16.crc16(key) % 16384;

Redis持久化机制

redis有两种持久化机制,面试官一般都是问两者的区别,以及各自的优劣势。 rdb是定时生成快照文件,而aof是追加命令的机制。

一、RDB

RDB工作原理

通过fork一个子进程进行生成rdb文件,然后将老的rdb文件替换。

RDB特点

  1. rdb可以在指定时间和命令数之后生成快照文件,比如save 60 1000代表每60秒有1000个,如果发生断电,最近修改的数据会丢失。
  2. 他的大小比aof小,恢复速度比aof快。

二、AOF

AOF工作原理

和rdb一样,aof也会fork一个子进程,子进程通过写时复制机制,生成一个临时的aof文件,进行重写aof文件,同时把重写期间的操作的命令写入一个缓冲buffer区,等重写完后把buffer区的内容同步到aof文件,最后将临时文件替换成旧的aof文件。

AOF特点

  1. aof通过不断追加命令到文件完成持久化,
  2. 支持三种刷盘机制:每秒/每次写完新命令/不主动刷盘(依赖操作系统自己刷盘),丢失数据概率低。
  3. aof支持重写功能,但是它比rdb的文件大小会大一些,恢复速度比rdb要慢。

这波Redis的复盘到此就结束了,感觉每个题目都被问到过。

image.png