Redis面试题
一、缓存雪崩
1.1什么是缓存雪崩?
首先为什么要使用缓存(Redis):
优点:
- 提高性能:缓存查询速度比数据库查询速度快(内存vs硬盘)
- 提高并发能力:缓存分担了部分请求。支持更高的并发
Now 如果缓存宕机了,就意味着所有的请求就都要跑去数据库了。
Redis不可能把所有的数据都缓存起来(内存昂贵且有限);所以Redis需要对数据设置过期时间,并采取采用的是惰性删除+定期删除两种策略对过期键删除。
如果缓存数据设置的时间是相同的,并且Redis恰好将在这部分数据全部删光了。这就导致了这段时间内,这些缓存同时失效,全部请求到数据库中。
这就是缓存雪崩:
Redis挂掉了,请求全部走数据库。
对缓存数据设置相同的过期时间,导致某时间内缓存失效,请求全部数据库走数据。
1.2如何解决缓存雪崩
解决方法:
在缓存的时候给过期时间加上一个随机值,这样就会大幅的减少缓存在同一时间过期。
思路:
事发前:实现Redis高可用(主从架构+sentinel或者Redis Cluster),尽量避免Redis失效这种情况。
事发中:万一Redis失效了,我们可以设置本地缓存(ehcache)+限流(hystrix),尽量避免我们的数据库失效。
事发后:Redis持久化,重启后自动回复缓存数据。
二、缓存穿透
2.1什么是缓存穿透?
我们有一张数据库表,ID都是从1开始的(正数):
但是当数据库出现错误时,每次请求的ID都是负数。这就导致缓存就没用了,请求全部都找到数据库去了,但数据库也没有负值,所以每次都返回空出去。
缓存穿透是指查询一个一定不存在的数据。由于缓存不命中,并且处于容错的考虑,如果从数据库查询不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,书去了缓存的意义。
这就是缓存穿透:
请求的数据在缓存中大量不命中,导致请求走数据库。
缓存穿透如果发生了,也可能把我们的数据库搞垮,导致整个服务器瘫痪!
2.1如何解决缓存穿透
解决缓存穿透的方案有两种:
由于请求的参数是不合法的(每次拦截请求不存在的参数),于是我们可以使用布隆过滤器或者压缩提前拦截,不合法就不让这个请求到数据库层。
当我们从数据库找不到的时候,我们也将这个空对象设置到缓存里边去。下次请求的时候能,就可以从缓存里边获取了。(这种情况会将空对象设置一个较短的过期时间)
三、缓存与数据库双写一致
3.1对于读操作,流程是这样的
缓存穿透提到:如果从数据库查不到数据则不写入缓存。
读流程:如果我们的数据在缓存里有,那么就直接读取缓存的。
如果缓存里没有我们想要的数据,我们会先去查数据库,然后将数据库查出来的数据写到缓存中。最后将数据返回给请求
3.2什么是缓存与数据库双写一致问题?
如果仅仅是查询的话,缓存的数据和数据库的数据是没问题的。但是当我们要更新的时候,各种情况很肯能就造成数据库和缓存不一致了。
不一致指的是:数据库与缓存的数据不一致。
从理论上说,只要我们设置了键的过期时间,我们就能保证缓存和数据库的数据最终是一致的。因为只要缓存数据过期了,就会被删除。随后读的时候,因为缓存里没有,就可以查数据库的数据,然后将数据库查出来的数据写入到缓存中。
除了设置过期时间,我们还需要做更多的措施来尽量避免数据库与缓存处于不一致的情况发生。
3.3对于更新操作
一般来说,执行更新操作时,我们会有两种选择:
先操作数据库,再操作缓存,
先操作缓存,再操作数据;
首先,要明确的是,无论我们选择哪个,我们都希望这两个操作要么同时成功,要么同时失败。所以,这会演变成一个分布式事务的问题。
3.3.1操作缓存
操作缓存的两种方案:
更新缓存 删除缓存
多数情况下采取删除缓存:
高并发环境下,无论是先操作数据库还是后操作数据库,如果加上更新缓存,那就更加容易导致数据库与缓存数据不一致的问题。
如果每次更新数据库,都要更新缓存(指的是频繁更新的情况),会耗费一定的性能,倒不如直接删掉,等再次读取时,缓存里没有,那我到数据库里找,在数据库找到在写到缓存里(体现懒加载)
3.3.2先更新数据库,再删除缓存
正常的情况是这样的:
先操作数据库,成功;
再删除缓存,也成功;
如果原子性被破坏了:
第一步成功(操作数据库),第二步失败(删除缓存),会导致数据库里是新数据,而缓存里是旧数据。
如果第一步(操作数据库)就失败了,我们可以直接返回错误(Exception),不会出现数据不一致。
如果在高并发的场景下,出现数据库与缓存数据不一致的概率特别低,也不是没有:
缓存刚好失效
线程A查询数据库,得一个旧值
线程B将新值写入数据库
线程B删除缓存
线程A将查到的旧值写入缓存
要达成上述情况,还是说一句概率特别低:
因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
对于这种策略,其实是一种设计模式:Cache Aside Pattern
删除缓存失败的解决思路:
将需要删除的key发送到消息队列中
自己消费消息,获得需要删除的key
不断重试删除操作,直到成功
3.3.3先删除缓存,再更新数据库
正常情况是这样的:
先删除缓存,成功;
再更新数据库,也成功;
如果原子性被破坏了:
第一步成功(删除缓存),第二步失败(更新数据库),数据库和缓存的数据还是一致的。
如果第一步(删除缓存)就失败了,我们可以直接返回错误(Exception),数据库和缓存的数据还是一致的。
看起来是很美好,但是我们在并发场景下分析一下,就知道还是有问题的了:
线程A删除了缓存
线程B查询,发现缓存已不存在
线程B去数据库查询得到旧值
线程B将旧值写入缓存
线程A将新值写入数据库
所以也会导致数据库和缓存不一致的问题。
并发下解决数据库与缓存不一致的思路:
将删除缓存、修改数据库、读取缓存等的操作积压到队列里边,实现串行化。
3.4对比两种策略
我们可以发现,两种策略各自有优缺点:
先删除缓存,再更新数据库
在高并发下表现不如意,在原子性被破坏时表现优异
先更新数据库,再删除缓存(Cache Aside Pattern设计模式)
在高并发下表现优异,在原子性被破坏时表现不如意
3.5其他保障数据一致的方案与资料
可以用databus或者阿里的canal监听binlog进行更新
Redis原理
redis单点吞吐量
单点TPS达到8万/秒,QPS达到10万/秒。
redis的5中存储类型string、list、set、map(hash)、stored-set
redis的string类型
- 能表达3中类型:字符串、整数和浮点数。根据场景相互间自动转型,并且根据需要选取底层的承载方式
- value内部以int、sds作为结构存储。int存放整型数据,sds存放字节/字符串和浮点型数据
- sds内部结构:
- 用buf数组存储字符串的内容,但数组的长度会大于所存储内容的长度。会有一格专门存放”\0”(C标准库)作为结尾,还有预留多几个空的(即free区域),当append字符串的长度小于free区域,则sds不会重新申请内存,直接使用free区域
- 扩容:当对字符串的操作完成后预期的串长度小于1M时,扩容后的buf数组大小=预期长度*2+1;若大于1M,则buf总是会预留出1M的free空间
- value对象通常具有两个内存部分:redisObject部分和redisObject的ptr指向的sds部分。创建value对象时,通常需要为redisObject和sds申请两次内存。单对于短小的字符串,可以把两者连续存放,所以可以一次性把两者的内存一起申请了
redis的list类型
- list类型的value对象内部以linkedlist或ziplist承载。当list的元素个数和单个元素的长度较小时,redis会采用ziplist实现以减少内存占用,否则采用linkedlist结构
- linkedlist内部实现是双向链表。在list中定义了头尾元素指针和列表的长度,是的pop/push操作、llen操作的复杂度为O(1)。由于是链表,lindex类的操作复杂度仍然是O(N)
- ziplist的内部结构
l 所有内容被放置在连续的内存中。其中zlbytes表示ziplist的总长度,zltail指向最末元素,zllen表示元素个数,entry表示元素自身内容,zlend作为ziplist定界符
l rpush、rpop、llen,复杂度为O(1);lpush/pop操作由于涉及全列表元素的移动,复杂度为O(N)
redis的hash类型
- hash又叫map。map内部的key和value不能再嵌套map了,只能是string类型:整型、浮点型和字符串
- hash主要由hashtable和zplist两种方式实现,对于数据库量较小的map,采用ziplist实现
- hashtable内部结构
l 主要分三层,自底向上分别是dictEntry、distht、dist
l distEntry:管理一个key-value对,同时保留同一个桶中相邻元素的指针,一次维护哈希桶的内部连
l distht:维护哈希表的所有桶链
l dictL:当dictht需要扩容时,用于管理dictht的迁移
l 哈希表的核心结构是dictht,他的table字段维护着hash桶,他是一个数组,每个元素指向桶的第一个元素(distEntry)
l set值的流程:先通过Murmurhash算法求出key的hash值,再对桶的个数取模,得到key对应的桶,再进入桶中,遍历全部的entry,判定是否已有相同的key,如果没有,则将新key对应的键值对插入到桶头,并且更新distht的used数量,used表示哈数表中已经存了多少元素,由于每次插入都要遍历hash桶中的全部entry,所以当桶中entry很多时,性能会现行下降。
l 扩容:通过负载因子判定是否需要增加桶数。负载因子=哈希表中已有元素/哈希桶数的比值,有两个阈值,小于1一定不扩容;大于5一定扩容。扩容时新的桶数目是现有桶数的2n倍
l 缩容:负载因子的阈值是0.1
l 扩/缩容通过新建哈希表的方式实现。即扩容时,会并存连两个哈希表,一个是原表,一个是目标表。通过将原表的桶逐步迁移到目标表的,以数据迁移的方式实现扩容,迁移完成后目标表覆盖原表。迁移过程中,首先发明访问表,如果发现key对应的原表桶已完成迁移,则重新访问目标表,否则在原表中操作。
l Redis是单线程处理请求,迁移和访问的请求在相同线程内进行,所以不会存在并发性问题。
ziplist内部结构
- 和list的ziplist实现类似。不同的是,map对应的ziplist的entry个数总是2的整数倍,奇数存放key,偶数存放value
- ziplist实现下,由哈希遍历变成了链表的顺序遍历,复杂度变成O(N)
redis的set类型
- set以intset或以hashtable来存储。hashtable中的value永远为null,当set中只包含整数型的元素时,则采用intset
- intset的内部结构
l 核心元素是一个字节数组,从小到大有序存放着set的元素
l 由于元素有序排列,所以set的获取操作采用二分查找方式实现,复杂度O(log(N))。进行插入时,首先通过二分查找得到本次插入的位置,再对元素进行扩容,再将预计插入位置之后的所有元素向右移动一个位置,最后插入元素,插入复杂度为O(N)。删除类似
redis的sorted-set类型
- 类似map是一个key-value对,但是有序的。value是一个浮点数,称为score,内部是按照score从小到大排序
- 内部结构以ziplist或skiplist+hashtable来实现
redis客户端与服务器的交互模式
串行的请求/响应模式
- 每一次请求的发送都依赖于上一次请求的相应结果完全接收,同一个连接的每秒吞吐量低
- redis对单个请求的处理时间通常比局域网的延迟小一个数量级,所以串行模式下,单链接的大部分时间都处于网络等待
- 适用于批量的独立写入操作。即可将请求数据批量发送到服务器,再批量地从服务器连接的字节流中一次读取每个响应数据,减少了网络延迟,所以单连接吞吐量较串行会提高一个数量级
- 客户端通过和redis服务器两阶段的交互做到批量命令原子执行的事务效果:入队操作(即服务器端先将客户端发送过来的连接对象暂存在请求队列中)和执行阶段(依次执行请求队列中的所有请求)
- 一个连接的请求在执行批量请求的过程中,不会执行其他客户端的请求
- redis的事务不是一致的,没有回滚机制。如果中途失败,则返回错误信息,但已经成功执行的命令不会回滚
- 事务里面有可能会带有读操作作为条件,由于批量请求只会先入队列,再批量一起执行,所以一般读操作不会跟批量写请求一起执行,这时候就有可能会导致批量写之前和之后读到的数据不一致,这种可以通过乐观锁的可串行化来解决,redis通过watch机制实现乐观锁。
- 发布端和订阅者通过channel关联
- channel的订阅关系,维护在reids实例级别,独立于redisDB的key-value体系。所有的channel都由一个map维护,键是channel的名字,value是它所有订阅者client的指针链表
双工的请求/相应模式(pipeline)
原子化的批量请求/响应模式(事务)
发布/订阅模式
脚本化的批量执行(脚本模式)
redis通过watch机制实现乐观锁流程
- 将本次事务涉及的所有key注册为观察模式
- 执行只读操作
- 根据只读操作的结果组装写操作命令并发送到服务器端入队
- 发送原子化的批量执行命令EXEC试图执行连接的请求队列中的命令
- 如果前面注册为观察模式的key中有一个货多个,在EXEC之前被修改过,则EXEC将直接失败,拒绝执行;否则顺序执行请求队列中的所有请求
- redis没有原生的悲观锁或者快照实现,但可通过乐观锁绕过。一旦两次读到的操作不一样,watch机制触发,拒绝了后续的EXEC执行
- redis主要提供了两种持久化机制:RDB和AOF;
- RDB
redis的持久化机制
- 默认开启,会按照配置的指定时间将内存中的数据快照到磁盘中,创建一个dump.rdb文件,redis启动时再恢复到内存中。
- redis会单独创建fork()一个子进程,将当前父进程的数据库数据复制到子进程的内存中,然后由子进程写入到临时文件中,持久化的过程结束了,再用这个临时文件替换上次的快照文件,然后子进程退出,内存释放。
- 需要注意的是,每次快照持久化都会将主进程的数据库数据复制一遍,导致内存开销加倍,若此时内存不足,则会阻塞服务器运行,直到复制结束释放内存;都会将内存数据完整写入磁盘一次,所以如果数据量大的话,而且写操作频繁,必然会引起大量的磁盘I/O操作,严重影响性能,并且最后一次持久化后的数据可能会丢失;
- AOF
l 以日志的形式记录每个写操作(读操作不记录),只需追加文件但不可以改写文件,redis启动时会根据日志从头到尾全部执行一遍以完成数据的恢复工作。包括flushDB也会执行。
l 主要有两种方式触发:有写操作就写、每秒定时写(也会丢数据)。
l 因为AOF采用追加的方式,所以文件会越来越大,针对这个问题,新增了重写机制,就是当日志文件大到一定程度的时候,会fork出一条新进程来遍历进程内存中的数据,每条记录对应一条set语句,写到临时文件中,然后再替换到旧的日志文件(类似rdb的操作方式)。默认触发是当aof文件大小是上次重写后大小的一倍且文件大于64M时触发;
当aof与rdb并存时
当两种方式同时开启时,数据恢复redis会优先选择AOF恢复。一般情况下,只要使用默认开启的RDB即可,因为相对于AOF,RDB便于进行数据库备份,并且恢复数据集的速度也要快很多。
开启持久化缓存机制,对性能会有一定的影响,特别是当设置的内存满了的时候,更是下降到几百reqs/s。所以如果只是用来做缓存的话,可以关掉持久化。
redis内存分析的设计思路
- 主要有3种方式可以实现
- keys命令:获取到所有的key,再根据key获取所有的内容。缺点是如果key数量特别多,则会导致redis卡住影响业务
- aof:通过aof文件获取到所有数据。缺点是有一些redis实例写入频繁,不适合开启aof,并且文件可能特别大,传输、解析效率差
- rdb:使用bgsave获取rdb文件,然后解析。缺点是bgsave在fork子进程时有可能会卡住主进程。当对于其他两种,在低峰期在从节点做bgsave获取rdb文件,相对安全可靠。
设计思路:
- 在访问低峰期时根据redis获取rdb文件
- 解析rdb文件
- 根据相对应的数据结构及内容,估算内容消耗等
- 统计并生成报表
- 基础的数据类型:sds、dict、intset、zipmap、adlist、quicklist、skiplist
- 举例:以key为hello,value为world,类型是string,它的内存使用:
- 一个dictEntry的消耗(有2个指针,一个int64的内存消耗),RedisDB就是一个大dict,每对kv都是其中的一个entry;
- 一个robj的消耗(有1指针,一个int,以及几个使用位域的字段共消耗4字节),robj是为了在同一个dict内能够存储不同类型的value,而使用的一个通用的数据结构,全名是RedisObject;
- 存储key的sds消耗(存储header以及字符串长度+1的空间,header长度根据字符串长度不同也会有所不同),sds是Redis中存储字符串使用的数据结构;
- 存储过期时间消耗(也是存储为一个dictEntry,时间戳为int64);
- 存储value的sds消耗,根据数据结构不同而不同;
- 前四项基本是存储任何一个key都需要消耗的,最后一项根据value的数据结构不同而不同;
- redis3以后,节点之间提供了完整的sharding(分片)、replication(主备感知能力)、failover(故障转移)的特性
- 配置一致性:每个节点(Node)内部都保存了集群的配置信息,存储在clusterState中,通过引入自增的epoch变量来使得集群配置在各个节点间保持一致
- sharding数据分片
- 将所有数据划分为16384个分片(slot),每个节点会对应一部分slot,每个key都会根据分布算法映射到16384个slot中的一个,分布算法为slotId=crc16(key)%16384
- 当一个client访问的key不在对应节点的slots中,redis会返回给client一个moved命令,告知其正确的路由信息从而重新发起请求。client会根据每次请求来缓存本地的路由缓存信息,以便下次请求直接能够路由到正确的节点
- 分片迁移:分片迁移的触发和过程控制由外部系统完成,redis只提供迁移过程中需要的原语支持。主要包含两种:一种是节点迁移状态设置,即迁移钱标记源、目标节点;另一种是key迁移的原子化命令
- 故障发现:节点间两两通过TCP保持连接,周期性进行PING、PONG交互,若对方的PONG响应超时未收到,则将其置为PFAIL状态,并传播给其他节点
- 故障确认:当集群中有一半以上的节点对某一个PFAIL状态进行了确认,则将起改为FAIL状态,确认其故障
- slave选举:当有一个master挂掉了,则其slave重新竞选出一个新的master。主要根据各个slave最后一次同步master信息的时间,越新表示slave的数据越新,竞选的优先级越高,就更有可能选中。竞选成功之后将消息传播给其他节点。
redis内存估算
redis集群(redis cluster)
failover故障转移
用redis实现分布式锁
- 主要使用的命令:
- setnx key val。当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
- expire key timeout。为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
- delete key。删除锁
实现思想:
- 使用setnx加锁,如果返回1,则说明加锁成功,并设置超时时间,避免系统挂了,锁没法释放。在finally中delete删除锁释放。
- 如果需要设置超时等待时间,则可以加个while循环,在获取不到锁的情况下,进行循环获取锁,超时了则退出。