Redis 面试问题

当前版本5.0 稳定版,项目使用 5.0   C语言写的

 

问题 答案

Redis 基本数据结构

 

参考:

https://mp.weixin.qq.com/s/gRtiSNDCuS0c8nF_Q8Tv9A

https://mp.weixin.qq.com/s/TR8oe7c1SlOrk78untXdOA

string动态字符串,是可以修改的字符串,类似于 Java 的 ArrayList,分配冗余空间的方式来减少内存的频繁分配,当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M

 

长度大于32字节,对象的编码设置为 raw,动态字符串,两次内存分配

长度小于32字节,对象将使用embstr 编码,只读不能修改,一次内存分配

都使用 redisObject 结构和 sdshdr 结构来保存字符串

Redis 面试问题_第1张图片

list

list : 相当于 Java 语言里面的 LinkedList,注意它是双向链表而不是数组。插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。Redis 的列表结构常用来做异步队列使用。

Redis 面试问题_第2张图片

列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。当数据量比较多的时候才会改成 quicklist(快速列表)是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。

 

列表对象保存的所有字符串元素的长度都小于 64 字节;列表对象保存的元素数量数量小于 512 个;使用 ziplist 编码。

hash

hash : 相当于 Java 语言里面的 HashMap,它是无序字典。Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为 Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis 为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。Redis 的字典默认的 hash 函数是 siphash

 

扩容条件:正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (dict_can_resize),但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。

 

渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个 hash 结构,然后在后续的定时任务中以及 hash 操作指令中,循序渐进地将旧 hash 的内容一点点迁移到新的 hash 结构中。当搬迁完成了,就会使用新的hash结构取而代之。当 hash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。

 

缩容 Redis的hash结构不但有扩容还有缩容,从这一点出发,它要比Java的HashMap要厉害一些,Java的HashMap只有扩容。缩容的原理和扩容是一致的,只不过新的数组大小要比旧数组小一倍。缩容的条件是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave。

 

保存的所有键值对的键和值的字符串长度都小于64字节;保存的键值对数量小于512个;使用 ziplist 编码。

Redis 面试问题_第3张图片

set

set : 相当于 Java 语言里面的 HashSet,字典中所有的 value 都是一个值NULL,有去重功能

Redis 面试问题_第4张图片

zset

zset (有序列表): 它类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权重。它的内部实现用的是一种叫做「跳跃列表」的数据结构。

Redis 面试问题_第5张图片

位图、HyperLogLog、布隆过滤器

位图: 普通的字符串,也就是 byte 数组,setbit getbit bitcount bitpos

 

HyperLogLog :

用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存, 就可以计算接近 2^64 个不同元素的基数。

应用:统计网站每个网页每天的 UV 数据

相关命令:pfadd 和 pfcount,根据字面意义很好理解,一个是增加计数,一个是获取计数。pfmerge,用于将多个 pf 计数值累加在一起形成一个新的 pf 值。

问题:pf 的内存占用为什么是 12k?

算法中使用了 1024 个桶进行独立计数,不过在 Redis 的 HyperLogLog 实现中用到的是 16384 个桶,也就是 2^14,每个桶的 maxbits 需要 6 个 bits 来存储,最大可以表示 maxbits=63,于是总共占用内存就是2^14 * 6 / 8 = 12k字节。

 

布隆过滤器:大型的位数组和几个不一样的无偏 hash 函数

布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。

应用:新闻客户端推荐系统实现推送去重

相关命令:bf.add 添加元素,bf.exists 查询元素是否存在。如果想要一次添加多个,就需要用到 bf.madd 指令。同样如果需要一次查询多个元素是否存在,就需要用到 bf.mexists 指令。

如何从海量的 key 中找出满足特定前缀的 key 列表来?

keys:用来列出所有满足特定正则字符串规则的 key 缺点: 没有 offset、limit 参数;遍历算法复杂度O(n) 导致Redis服务卡顿

 

scan:复杂度O(n),通过游标分步进行,不会阻塞线程;返回游标为零即为结束;返回结果可能会有重复,limit 只是说扫描的行数非返回结果的行数

redis 与 memcache 选择

Redis 面试问题_第6张图片

支持数据结构: redis支持复杂数据结构,memcache只支持简单数据结构

支持集群模式:redis天然支持 cluster 模式,memcache依靠客户端实现

持久化:redis支持数据持久化到磁盘,memcache把数据全都存在内存中

线程模型:Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的非阻塞IO复用模型

为啥 redis 单线程模型也能效率这么高?
  • 纯内存操作。

  • 核心是基于非阻塞的 IO 多路复用机制。

  • C 语言实现,一般来说,C 语言实现的程序“距离”操作系统更近,执行速度相对会更快。

  • 单线程反而避免了多线程的频繁上下文切换问题,预防了多线程可能产生的竞争问题。

Redis 过期策略

过期的 key 集合

redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个字典来删除到期的 key。

定时扫描策略:

Redis 默认会每隔100ms过期扫描,过期扫描不会遍历过期字典中所有的 key,而是采用了一种简单的贪心策略

  1. 从过期字典中随机 20 个 key;

  2. 删除这 20 个 key 中已经过期的 key;

  3. 如果过期的 key 比率超过 1/4,那就重复步骤 1;

同时,为了保证过期扫描不会出现循环过度,导致线程卡死现象,算法还增加了扫描时间的上限,默认不会超过 25ms

注:如果有大批量的 key 过期,要给过期时间设置一个随机范围,而不能全部在同一时间过期。

从库的过期策略:从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。会出现主从数据的不一致。

惰性策略:

所谓惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期了就立即删除

Redis 内存淘汰机制

问题:如果定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期 key 堆积在内存里,导致 redis 内存块耗尽了,咋整?

maxmemory(最大使用内存): 超出即需内存淘汰

maxmemory-policy:内存淘汰策略

noeviction 不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略

volatile-lru 尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。

volatile-ttl 跟上面一样,除了淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。

volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key。

allkeys-lru 区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。

allkeys-random 跟上面一样,不过淘汰的策略是随机的 key。

 

LRU 算法 : 双向链表 + 字典 实现

近似 LRU 算法(redis采用):给每个 key 增加了一个额外的小字段,这个字段的长度是 24 个 bit,也就是最后一次被访问的时间戳。它的处理方式只有懒惰处理。当 Redis 执行写操作时,发现内存超出 maxmemory,就会执行一次 LRU 淘汰算法。这个算法也很简单,就是随机采样出 5(可以配置) 个 key,然后淘汰掉最旧的 key,如果淘汰后内存还是超出 maxmemory,那就继续随机采样淘汰,直到内存低于 maxmemory 为止。maxmemory_samples :每次采样的个数,默认是5.

Redis 持久化有哪几种方式

RDB:

一次全量备份,快照是内存数据的二进制序列化形式,在存储上非常紧凑,调用 glibc 的函数fork产生一个子进程,采用COW(Copy On Write) 机制来实现快照持久化,可以让 redis 保持高性能

开启命令 :save m n (表示m秒内数据集存在n次修改时)

手动触发: save 阻塞当前Redis服务器 bgsave 异步进行快照操作

 

AOF:

内存数据修改的指令记录文本,快照文件更大。收到客户端修改指令后,先存到AOF日志(磁盘),然后再执行指令。每隔 1s 左右执行一次 fsync 操作,周期 1s 是可以配置的。

AOF 重写(fork子进程)条件: 调用bgRewriteAof 指令、大于最小的重写值及大于指定增量

通常 Redis 的主节点是不会进行持久化操作,持久化操作主要在从节点进行

Redis 主从架构,主从复制(异步)

 

主负责写,从节点负责读

主从架构 -> 读写分离 -> 水平扩容支撑读高并发

心跳
master 默认每隔 10秒 发送一次 heartbeat

slave node 每隔 1秒 发送一个 heartbeat

参考:

https://github.com/doocs/advanced-java/blob/master/docs/high-concurrency/redis-master-slave.md

主从复制原理:

Redis 面试问题_第7张图片

Redis 面试问题_第8张图片

全量复制:

master生成RDB文件发送给slave(默认60s完成),之后新增数据写命令缓存在内存中(默认大小256M)

slave清空旧数据加载RDB

 

增量复制:

master 直接从自己的 backlog 中获取部分丢失的数据,发送给 slave node,默认 backlog 就是 1MB

slave 发送的 psync 中的 offset 来从 backlog 中获取数据的

Redis 哨兵实现高可用

Redis Sentinel 集群看成是一个 ZooKeeper 集群

主要功能:主节点存活检测主从运行情况检测自动故障转移主从切换

主观下线:就一个哨兵如果自己觉得一个 master 宕机了,那么就是主观宕机

客观下线:如果 quorum 数量的哨兵都觉得一个 master 宕机了,那么就是客观宕机

Sentinel通过 cmd 连接给 Redis 发送命令,通过 pub/sub 连接到 Redis 实例上的其他 Sentinel 实例。(每秒一次的频率)

 

Redis 面试问题_第9张图片

故障转移:选点的依据依次是:网络连接正常->5秒内回复过INFO命令->10*down-after-milliseconds内与主连接过的->从服务器优先级->复制偏移量->运行id较小的

哨兵机制是有缺点的:

  1.主从服务器的数据要经常进行主从复制,这样造成性能下降。

  2.主从切换过程,服务不可用

Redis Cluster (稳定版本5.0.4 3.0之后支持集群)

遇到 单机内存并发流量 等瓶颈时,可以采用 Cluster 架构方案达到 负载均衡 的目的。

端口号:6379

节点间通信端口号:16379

Redis Cluster 是去中心化的,采用 gossip 协议维护集群元数据。gossip 协议包含多种消息,包含 ping,pong,meet,fail 等等。

 

定槽位:集群有固定的16384 (2^14)的 slots,slot位置 CRC16(KEY)%16384 ,或者key中嵌入tag指定slot。客户端连接集群时会得到一份集群slot信息,当客户端要查找某个 key 时,可以直接定位到目标节点。

 

槽位迁移:Redis迁移的单位是槽,Redis 一个槽一个槽进行迁移。槽在原节点的状态为migrating,在目标节点的状态为importing从源节点获取内容 => 存到目标节点 => 从源节点删除内容。迁移过程是同步的,节点的主线程会处于阻塞状态,直到key被成功删除。

 

槽位感知moved 是用来纠正槽位的(客户端访问了错误的节点,刷新槽位关系表);asking 用来临时纠正槽位(客户端访问了正在迁移的节点,不刷新槽位关系表)

 

redis cluster 高可用:判断节点宕机(主观宕机、客观宕机)、从节点过滤、从节点选举

功能限制:key分布在不同节点时,批量操作及事务不支持;不能将一个很大的键值对映射到不同的节点;

Redis 实现分布式锁

分布式锁在分布式环境下,锁定全局唯一公共资源,表现为:

  • 请求串行化

  • 互斥性

分布式锁的目的如下:

  • 解决业务层幂等性

  • 解决 MQ 消费端多次接受同一消息

  • 确保串行|隔离级别

  • 多台机器同时执行定时任务

1. 防止用户重复下单 共享资源进行上锁的对象 : 【用户id】
2. 订单生成后发送MQ给消费者进行积分的添加 寻找上锁的对象 :【订单id】
3. 用户已经创建订单,准备对订单进行支付,同时商家在对这个订单进行改价 寻找上锁对象 : 【订单id】

Redlock: https://blog.csdn.net/chen_kkw/article/details/81276068

实现1:占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。

问题:如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。

 

实现2:我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。

问题:setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。

 

实现3:Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,彻底解决了分布式锁的乱象。

> set lock true ex 5 nx
OK
... do something critical ...
> del lock

问题:超时问题。Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。 方案:set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key

 

Redlock:顺序地在 所有实例上申请锁,使用相同的 key 和 random value,一半以上获取到锁 申请成功;申请失败释放锁;

问题:其中一个redis重启(同步持久化或者启动后的Redis一段时间不可用待锁过期)

分布式锁问题

1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
2、高可用的获取锁与释放锁;
3、高性能的获取锁与释放锁;
4、具备可重入特性;
5、具备锁失效机制,防止死锁;
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

参考:

https://blog.csdn.net/wuzhiwei549/article/details/80692278

基于数据库实现排他锁:

数据库表字段: 自增id、锁方法名method_nam(唯一键)、锁状态state(1分配 2未分配)、更新时间update_time、版本号version

获取锁:插入一行 成功 释放锁:删除一行

问题及解决方案: 性能问题数据库单点(主从);解锁失败无失效时间(定时任务清理);插入失败即报错非阻塞(加个while循环);非重入(表中加机器及线程信息)

 

基于redis实现:

SET resource_name my_random_value NX PX 30000

问题及解决方案:锁时间不可控超时(释放时检测唯一key;锁续租,循环延长到期时间);主从同步不及时问题,redis AP模型(Redlock 算法来保证)

 

基于 Zookeeper: ZK 和客户端的心跳进行维持

创建临时有序节点,开启事件监听

问题及解决方案:客户端挂了(直接释放锁);业务线程死循环一直持有锁不放(业务方处理或者监控锁异常);业务方GC导致心跳断开(GC完之后检查所状态,重试)

Redis 实现消息队列 和 延时队列

消息队列list(列表) 数据结构常用来作为异步消息队列使用,用blpop/brpop替代前面的lpop/rpop,阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。

 

延时队列:可以通过 Redis 的 zset(有序列表) 来实现。我们将消息序列化成一个字符串作为 zset 的value,这个消息的到期处理时间作为score,然后用多个线程轮询 zset 获取到期的任务进行处理

Redis 管道、事务

管道:客户端通过改变了读写的顺序带来的性能的巨大提升,

 

事务:multi(开始)/exec(提交)/discard(丢弃) redis 不支持事务回滚

所有的指令在 exec 之前不执行,而是缓存在服务器的一个事务队列中,服务器一旦收到 exec 指令,才开执行整个事务队列,执行完毕后一次性返回所有指令的运行结果。因为 Redis 的单线程特性,它不用担心自己在执行队列的时候被其它指令打搅,可以保证他们能得到的「原子性」执行。 discard 指令,用于丢弃事务缓存队列中的所有指令,在 exec 执行之前。

注:事务在遇到指令执行失败后,后面的指令还继续执行。

 

watch机制:它就是一种乐观锁,watch 会在事务开始之前盯住 1 个或多个关键变量,当事务执行时,也就是服务器收到了 exec 指令要顺序执行缓存的事务队列时,Redis 会检查关键变量自 watch 之后,是否被修改了 (包括当前事务所在的客户端)。如果关键变量被人动过了,exec 指令就会返回 null 回复告知客户端事务执行失败,这个时候客户端一般会选择重试。

guava cache

加载:LoadingCache是附带CacheLoader构建而成的缓存实现。创建自己的CacheLoader通常只需要简单地实现V load(K key) throws Exception方法。

 

回收

基于容量的回收(超过设定值,尝试回收最近没有使用或总体上很少使用的缓存项);

定时回收:

  • expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。

  • expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。

基于引用的回收

 

显式清除:个别清除、批量清除、清除所有缓存项

清理什么时候发生? 不会自动清理,访问的时候判断;人工清理

刷新:异步刷新,提供读

Redis & Tair

 

Tair:

https://www.oschina.net/p/tair

Redis 适用 需要使用复杂数据结构(map, set),map/set中元素很多(1000以上) 延迟敏感服务

不适用 数据量超过600GB(数据太多,全内存太浪费资源) 需要多语言客户端支持

 

Tair 适用 不能容忍数据丢失 ,数据量大,内存放不下的服务

不适用 使用复杂数据结构(map/set),map/set中元素很多(1000以上)

   

 

你可能感兴趣的:(分布式架构,面试题,redis,面试题)