面试系列 - Redis
简介
Remote DIctionary Server(Redis) 是一个基于内存的key-value存储系统。
它通常被称为数据结构服务器,因为值(value)可以是 字符串(String), 哈希(Hash), 列表(list), 集合(sets) 和 有序集合(sorted sets)等类型。
几种类型
字符串(String)
哈希(Hash)
列表(List). 消息队列(Lpop 头出 Rpop 尾出 LRange等操作)
集合(Set)
有序集合(Sorted Set)
底层数据结构
String
字符串对象的底层实现可以是int、raw、embstr(上面的表对应有名称介绍)。embstr编码是通过调用一次内存分配函数来分配一块连续的空间,而raw需要调用两次。
get:sdsrange—O(n)
set:sdscpy—O(n)
create:sdsnew—O(1)
len:sdslen—O(1)
int编码字符串对象和embstr编码字符串对象在一定条件下会转化为raw编码字符串对象。embstr:<=39字节的字符串。int:8个字节的长整型。raw:大于39个字节的字符串。
List
List对象的底层实现是quicklist(快速列表,是ziplist 压缩列表 和linkedlist 双端链表 的组合)。Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。
Redis的linkedlist双端链表有以下特性:节点带有prev、next指针、head指针和tail指针,获取前置节点、后置节点、表头节点和表尾节点的复杂度都是O(1)。len属性获取节点数量也为O(1)。
rpush: listAddNodeHead —O(1)
lpush: listAddNodeTail —O(1)
push:listInsertNode —O(1)
index : listIndex —O(N)
pop:ListFirst/listLast —O(1)
llen:listLength —O(N)
LinkedList
与双端链表相比,压缩列表可以节省内存空间,但是进行修改或增删操作时,复杂度较高;因此当节点数量较少时,可以使用压缩列表;但是节点数量多时,还是使用双端链表划算。
ziplist(压缩列表)
当一个列表键只包含少量列表项,且是小整数值或长度比较短的字符串时,那么redis就使用ziplist(压缩列表)来做列表键的底层实现。
ziplist是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块(而不是像双端链表一样每个节点是指针)组成的顺序型数据结构;具体结构相对比较复杂,有兴趣读者可以看 Redis 哈希结构内存模型剖析。在新版本中list链表使用 quicklist 代替了 ziplist和 linkedlist:
quickList 是 zipList 和 linkedList 的混合体。它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。因为链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。
quicklist 默认的压缩深度是 0,也就是不压缩。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。为了进一步节约空间,Redis 还会对 ziplist 进行压缩存储,使用 LZF 算法压缩。
Hash
Hash对象的底层实现可以是ziplist(压缩列表)或者hashtable(字典或者也叫哈希表)。
hashtable哈希表可以实现O(1)复杂度的读写操作,因此效率很高。
Hash对象只有同时满足下面两个条件时,才会使用ziplist(压缩列表):1.哈希中元素数量小于512个;2.哈希中所有键值对的键和值字符串长度都小于64字节。
这个结构类似于JDK7以前的HashMap,当有两个或以上的键被分配到哈希数组的同一个索引上时,会产生哈希冲突。Redis也使用链地址法来解决键冲突。即每个哈希表节点都有一个next指针,多个哈希表节点用next指针构成一个单项链表,链地址法就是将相同hash值的对象组织成一个链表放在hash值对应的槽位。
Redis中的字典使用hashtable作为底层实现的话,每个字典会带有两个哈希表,一个平时使用,另一个仅在rehash(重新散列)时使用。随着对哈希表的操作,键会逐渐增多或减少。为了让哈希表的负载因子维持在一个合理范围内,Redis会对哈希表的大小进行扩展或收缩(rehash),也就是将ht【0】里面所有的键值对分多次、渐进式的rehash到ht【1】里。
Set
Set集合对象的底层实现可以是intset(整数集合)或者hashtable(字典或者也叫哈希表)。
sadd:intsetAdd—O(1)
smembers:intsetGetO(1)—O(N)
srem:intsetRemove—O(N)
slen:intsetlen —O(1)
intset(整数集合)当一个集合只含有整数,并且元素不多时会使用intset(整数集合)作为Set集合对象的底层实现。
intset底层实现为有序,无重复数组保存集合元素。 intset这个结构里的整数数组的类型可以是16位的,32位的,64位的。如果数组里所有的整数都是16位长度的,如果新加入一个32位的整数,那么整个16的数组将升级成一个32位的数组。升级可以提升intset的灵活性,又可以节约内存,但不可逆。
zset
ZSet有序集合对象底层实现可以是ziplist(压缩列表)或者skiplist(跳跃表)。
zadd—zslinsert—平均O(logN), 最坏O(N)
zrem—zsldelete—平均O(logN), 最坏O(N)
zrank–zslGetRank—平均O(logN), 最坏O(N)
当一个有序集合的元素数量比较多或者成员是比较长的字符串时,Redis就使用skiplist(跳跃表)作为ZSet对象的底层实现。
skiplist的查找时间复杂度是LogN,可以和平衡二叉树相当,但实现起来又比它简单。跳跃表(skiplist)是一种有序数据结构,它通过在某个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
每种类型的适用场景
redis分布式锁(死锁 & 非死锁)
redis的分布式锁就是分布式服务获取相同(key)的value,如果获取到的不为null,就表示有别的服务器在用到这个锁。
下面是redis分布式锁的伪代码:
线程不安全的分布式锁
public class getRedisLok(String key){
try{
String value = redis.get(key);
if(value == null){
redis.set(key,curretnTimeM + expireTime);
redis.expire(50000);
//业务逻辑
}else{
if(value < curretnTimeM){
redis.set(key,curretnTimeM + expireTime);
redis.expire(50000);
//业务逻辑
}
}
}catch(Exception e){
//释放锁
redis.del(key);
}finally{
redis.del(key);
}
}
job:
public class freeRedisLock(){
//锁的时间超过了最终的超时时间
if(redis.get(key) < curretnTimeM + lastExpireTime){
redis.del(key);
}
}
不安全的点:
1、获取不到锁。set和expire不是一个原子操作。万一一个线程执行到set命令之后挂起,没有设置过期时间,那另一个线程就永远获取不到锁(除非超时自动释放)
2、有可能会造成覆盖。第一个线程set之后挂起,第二个线程在超时过期之后重新set之后,第一个锁还可以继续获取到执行
3、超时自动释放可能会释放正在执行的锁(除非每次加锁做相应的计数处理(只能释放自己set的锁)
线程安全的分布式锁
用 set(String key, String value, String nxxx, String expx, long time) 命令
实现一次性set + expire操作。让加锁的过程变成原子性
redis持久化方案(aof & rdb)
rdb是全部的redis结构文件 适合恢复所有的redis信息
aof是短期内的日志文件 适合短期内的数据补全 但是日志比较大,恢复慢
一般aof可以在每次set的时候就写入日志,但是在高访问量的情况下这样会导致耗时久,所以可以设置1s就执行一起日志的写入,如果数据丢失的话最多也就丢失1s的数据
如果机器断电需要重新恢复redis数据(从磁盘恢复到内存中)
rdb + aof
布隆过滤器
Bloom Filter是一个适用于 海量数据需要重查 & 缓存穿透(恶意攻击)这些场景的一种工具。
它的优点是计算速度极快,可以快速的判断需要查找的值是否在缓存里存在。但是存在一定的误判率以及删除比较困难。
布隆过滤器的原理
当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时我们再通过同样的K个散列函数找到这K个点,然后看看这些点是不是都是1就(大约)知道集合中有没有它了。如果这些点有任何一个0,则被检元素一定不在。
这就是布隆过滤器的基本思想。
Bloom Filter跟单哈希函数Bit-Map不同之处在于:Bloom Filter使用了k个哈希函数,每个字符串跟k个bit对应。从而降低了冲突的概率。
Bloom Filter的缺点
bloom filter之所以能做到在时间和空间上的效率比较高,是因为牺牲了判断的准确率、删除的便利性
1、存在误判,可能要查到的元素并没有在容器中,但是hash之后得到的k个位置上值都是1。如果bloom filter中存储的是黑名单,那么可以通过建立一个白名单来存储可能会误判的元素。
2、困难。一个放入容器的元素映射到bit数组的k个位置上是1,删除的时候不能简单的直接置为0,可能会影响其他元素的判断。可以采用Counting Bloom Filter
Bloom Filter 实现
在使用bloom filter时,绕不过的两点是预估数据量n以及期望的误判率fpp,
在实现bloom filter时,绕不过的两点就是hash函数的选取以及bit数组的大小。
对于一个确定的场景,我们预估要存的数据量为n,期望的误判率为fpp,然后需要计算我们需要的Bit数组的大小m,以及hash函数的个数k,并选择hash函数
缓存击穿、雪崩、穿透
雪崩
大量的热点数据在一瞬间全部过期,导致数据库查询瞬间增大而崩掉
解决方案:
1、热点数据在存储的时候,使用固定值+随机数的过期方法存储,不要让大量数据在同一时间过期
2、设置热点数据不过期
穿透
请求的数据缓存不存在,大量请求数据库。一般都是恶意请求
1、添加网关层校验,恶意请求处理
2、增加入参校验
3、布隆过滤器。redis快速返回
缓存击穿
大量请求某一热点数据,在过期的瞬间数据库被打穿
1、热点数据不要过期
2、增加互斥锁
redis哨兵模式
1、监控master/slave是否正常运行
2、消息通知
r如果某台机器宕机,会通知到管理员
3、故障转移
如果master宕机了,会自动推举新的master
4、配置
如果master宕机了,通知新的master机器到各个client
redis主从同步
一台slave机器启动,会发送一个命令给master,如果是第一次启动,会开启全量复制模式。master会开启一个线程生成rdb文件,并发给slave机器。与此同时还会把新添加的记录下来,等待slave处理完rdb文件后再发给slave补全数据
内存淘汰机制
redis的过期策略
定期删除+ 惰性删除
定期删除:redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除
定期删除可能会导致很多过期 key 到了时间并没有被删除掉,那咋整呢?所以就是惰性删除了。这就是说,在你获取某个 key 的时候,redis 会检查一下 ,这个 key 如果设置了过期时间那么是否过期了?如果过期了此时就会删除,不会给你返回任何东西。
惰性删除:获取 key 的时候,如果此时 key 已经过期,就删除,不会返回任何东西。
但是实际上这还是有问题的,如果定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期 key 堆积在内存里,导致 redis 内存块耗尽了,咋整?
答案是:走内存淘汰机制。
redis的内存淘汰机制
1、FIFO 淘汰最早数据
2、LRU 剔除最近最少使用
3、LFU 剔除最近使用频率最低的数据
redis 内存淘汰机制有以下几个:
noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错,这个一般没人用吧,实在是太恶心了。
allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key,这个一般没人用吧,为啥要随机,肯定是把最近最少使用的 key 给干掉啊。
volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。
volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。
项目中使用(address项目)
具体画图描述 + 使用到到一些redis结构类型