主从同步原理
作为判断依据:
-
Replication Id
:简称replid
,是数据集的标记,replid一致则是同一数据集。每个master
都有唯一的replid
,slave
则会继承master
节点的replid
-
offset
:偏移量,随着记录在repl_baklog
中的数据增多而逐渐增大。slave
完成同步时也会记录当前同步的offset
。如果slave
的offset
小于master
的offset
,说明slave
数据落后于master
,需要更新。
全量同步
主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点;
如何判断是第一次建立连接?
当搭建redis主从关系时,会在master节点创建一个新的replid,其他slave节点的replid与其保持一致,当slave
节点请求增量同步,master节点会判断该slave节点的replid与自己是否一致,不一致就可以认为是第一次连接,master会执行bgsave命令把所有数据保存到一个RDB文件当中,slave节点接收到该文件,slave就清空自己本地原有的数据,然会执行加载RDB文件实现全量同步。
增量同步
全量同步需要先做RDB,然后将RDB文件通过网络传输个slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步。
什么是增量同步?就是只更新slave与master存在差异的部分数据,根据偏移量差值来同步数据
repl_baklog原理
这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。
repl_baklog
中会记录Redis处理过的命令及offset
,包括master当前的offset
,和slave已经拷贝到的offset
:
slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。
如果master继续写入新数据,master的offset
就会覆盖repl_baklog
中旧的数据,直到将slave现在的offset
也覆盖:
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset
都没有了,无法完成增量同步了。只能做全量同步。
repl_baklog
大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于repl_baklog
做增量同步,只能再次全量同步。
主从同步优化
可以从以下几个方面来优化Redis主从就集群:
-
在master中配置
repl-diskless-sync yes
启用无磁盘复制,避免全量同步时的磁盘IO。 -
Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
-
适当提高
repl_baklog
的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步 -
限制一个master上的slave节点数量,如果实在是太多slave,则可以采用
主-从-从
链式结构,减少master压力
总结(常见问题)
简述全量同步和增量同步区别?
-
全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
-
增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave
什么时候执行全量同步?
-
slave节点第一次连接master节点时
-
slave节点断开时间太久,repl_baklog中的offset已经被覆盖时
什么时候执行增量同步?
-
slave节点断开又恢复,并且在
repl_baklog
中能找到offset时
Redis哨兵
保证redis集群的高可用性
哨兵工作原理
Redis提供了哨兵
(Sentinel
)机制来监控主从集群监控状态,确保集群的高可用性。
哨兵作用
哨兵的作用如下:
-
状态监控:
Sentinel
会不断检查您的master
和slave
是否按预期工作 -
故障恢复(failover):如果
master
故障,Sentinel
会将一个slave
提升为master
。当故障实例恢复后会成为slave
-
状态通知:
Sentinel
充当Redis
客户端的服务发现来源,当集群发生failover
时,会将最新集群信息推送给Redis
的客户端
状态监控(如何知道某个节点宕机)
Sentinel
基于心跳机制监测服务状态,每隔1秒向集群的每个节点发送ping命令,并通过实例的响应结果来做出判断:
-
主观下线(sdown):如果某sentinel节点发现某Redis节点未在规定时间响应,则认为该节点主观下线。
-
客观下线(odown):若超过指定数量(通过
quorum
设置)的sentinel都认为该节点主观下线,则该节点客观下线。quorum值最好超过Sentinel节点数量的一半,Sentinel节点数量至少3台。
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
-
首先会判断slave节点与master节点断开时间长短,如果超过
down-after-milliseconds * 10
则会排除该slave节点 -
然后判断slave节点的
slave-priority
值,越小优先级越高,如果是0则永不参与选举(默认都是1)。 -
如果
slave-prority
一样,则判断slave节点的offset
值,越大说明数据越新,优先级越高 -
最后是判断slave节点的
run_id
大小,越小优先级越高(通过info server可以查看run_id
)。
选举leader
首先,Sentinel集群要选出一个执行failover
的Sentinel节点,可以成为leader
。要成为leader
要满足两个条件:
-
最先获得超过半数的投票
-
获得的投票数不小于
quorum
值
而sentinel投票的原则有两条:
-
优先投票给目前得票最多的
-
如果目前没有任何节点的票,就投给自己
比如有3个sentinel节点,s1
、s2
、s3
,假如s2
先投票:
-
此时发现没有任何人在投票,那就投给自己。
s2
得1票 -
接着
s1
和s3
开始投票,发现目前s2
票最多,于是也投给s2
,s2
得3票 -
s2
称为leader
,开始故障转移
第一个确认master客观下线的人会立刻发起投票,一定会成为leader。
failover(如何让一个从节点代替宕机的master节点)
假如master发生故障,slave1当选。则故障转移的流程如下:
1)sentinel
给备选的slave1
节点发送slaveof no one
命令,让该节点成为master
2)sentinel
给所有其它slave
发送slaveof 192.168.150.101 7002
命令,让这些节点成为新master
,也就是7002
的slave
节点,开始从新的master
上同步数据。
3)最后,当故障节点恢复后会接收到哨兵信号,执行slaveof 192.168.150.101 7002
命令
总结(常见问题)
Sentinel的三个作用是什么?
-
集群监控
-
故障恢复
-
状态通知
Sentinel如何判断一个redis实例是否健康?
-
每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线(
sdown
) -
如果大多数sentinel都认为实例主观下线,则判定服务客观下线(
odown
)
故障转移步骤有哪些?
-
首先要在
sentinel
中选出一个leader
,由leader执行failover
-
选定一个
slave
作为新的master
,执行slaveof noone
,切换到master模式 -
然后让所有节点都执行
slaveof
新master -
修改故障节点配置,添加
slaveof
新master
sentinel选举leader的依据是什么?
-
票数超过sentinel节点数量1半
-
票数超过quorum数量
-
一般情况下最先发起failover的节点会当选
sentinel从slave中选取master的依据是什么?
-
首先会判断slave节点与master节点断开时间长短,如果超过
down-after-milliseconds
* 10
则会排除该slave节点 -
然后判断slave节点的
slave-priority
值,越小优先级越高,如果是0则永不参与选举(默认都是1)。 -
如果
slave-prority
一样,则判断slave节点的offset
值,越大说明数据越新,优先级越高 -
最后是判断slave节点的
run_id
大小,越小优先级越高(通过info server可以查看run_id
)。
散列插槽
数据要分片存储到不同的Redis节点,肯定需要有分片的依据,这样下次查询的时候才能知道去哪个节点查询。很多数据分片都会采用一致性hash算法。而Redis则是利用散列插槽(hash slot
)的方式实现数据分片。
在Redis集群中,共有16384个hash slots
,集群中的每一个master节点都会分配一定数量的hash slots
。
当我们读写数据时,Redis基于CRC16
算法对key
做hash
运算,得到的结果与16384
取余,就计算出了这个key
的slot
值。然后到slot
所在的Redis节点执行读写操作。
不过hash slot
的计算也分两种情况:
-
当
key
中包含{}
时,根据{}
之间的字符串计算hash slot
-
当
key
中不包含{}
时,则根据整个key
字符串计算hash slot
总结(常见问题)
Redis分片集群如何判断某个key应该在哪个实例?
-
将16384个插槽分配到不同的实例
-
根据key计算哈希值,对16384取余
-
余数作为插槽,寻找插槽所在实例即可
如何将同一类数据固定的保存在同一个Redis实例?
-
Redis计算key的插槽值时会判断key中是否包含
{}
,如果有则基于{}
内的字符计算插槽 -
数据的key中可以加入
{类型}
,例如key都以{typeId}
为前缀,这样同类型数据计算的插槽一定相同
Redis数据结构
我们常用的Redis数据类型有5种,分别是:
-
String
-
List
-
Set
-
SortedSet
-
Hash
还有一些高级数据类型,比如Bitmap、HyperLogLog、GEO等,其底层都是基于上述5种基本数据类型。因此在Redis的源码中,其实只有5种数据类型。
RedisObject
不管是任何一种数据类型,最终都会封装为RedisObject格式,它是一种结构体,C语言中的一种结构,可以理解为Java中的类。
结构大概是这样的:
属性中的encoding
就是当前对象底层采用的数据结构或编码方式,可选的有11种之多:
编号 | 编码方式 | 说明 |
---|---|---|
0 | OBJ_ENCODING_RAW | raw编码动态字符串 |
1 | OBJ_ENCODING_INT | long类型的整数的字符串 |
2 | OBJ_ENCODING_HT | hash表(也叫dict) |
3 | OBJ_ENCODING_ZIPMAP | 已废弃 |
4 | OBJ_ENCODING_LINKEDLIST | 双端链表 |
5 | OBJ_ENCODING_ZIPLIST | 压缩列表 |
6 | OBJ_ENCODING_INTSET | 整数集合 |
7 | OBJ_ENCODING_SKIPLIST | 跳表 |
8 | OBJ_ENCODING_EMBSTR | embstr编码的动态字符串 |
9 | OBJ_ENCODING_QUICKLIST | 快速列表 |
10 | OBJ_ENCODING_STREAM | Stream流 |
11 | OBJ_ENCODING_LISTPACK | 紧凑列表 |
Redis中的5种不同的数据类型采用的底层数据结构和编码方式如下:
数据类型 | 编码方式 |
---|---|
STRING |
|
LIST |
|
SET |
|
ZSET |
|
HASH |
|
SkipList(跳表)
SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
-
元素按照升序排列存储
-
节点可能包含多个指针,指针跨度不同。
传统链表只有指向前后元素的指针,因此只能顺序依次访问。如果查找的元素在链表中间,查询的效率会比较低。而SkipList则不同,它内部包含跨度不同的多级指针,可以让我们跳跃查找链表中间的元素,效率非常高。
跳表的特点:
多层链表:跳表由多层链表构成,每一层都是前一层的索引,可以快速地跳过一些元素。
随机化:在创建新节点时,会随机决定该节点在跳表中的层数,这增加了跳表的灵活性和性能。
有序:跳表中的元素是有序的,可以进行快速的顺序访问。
动态:跳表可以动态地添加和删除元素,而不需要重新构建整个结构。
跳表的操作:
查找:从顶层开始,逐层向下查找,直到找到目标元素或到达底层。查找的时间复杂度是O(log n),其中n是跳表中元素的数量。
插入:首先在底层进行插入操作,然后根据随机化过程决定是否在更高层创建索引节点。插入操作的时间复杂度也是O(log n)。
删除:首先查找要删除的元素,然后从所有层中删除该元素的节点。删除操作的时间复杂度同样是O(log n)。
SortedSet
面试题:Redis的SortedSet
底层的数据结构是怎样的?
答:SortedSet是有序集合,底层的存储的每个数据都包含element和score两个值。score是得分,element则是字符串值。SortedSet会根据每个element的score值排序,形成有序集合。
它支持的操作很多,比如:
-
根据element查询score值
-
按照score值升序或降序查询element
要实现根据element查询对应的score值,就必须实现element与score之间的键值映射。SortedSet底层是基于HashTable来实现的。
要实现对score值排序,并且查询效率还高,就需要有一种高效的有序数据结构,SortedSet是基于跳表实现的。
加分项:因为SortedSet底层需要用到两种数据结构,对内存占用比较高。因此Redis底层会对SortedSet中的元素大小做判断。如果元素大小小于128且每个元素都小于64字节,SortedSet底层会采用ZipList,也就是压缩列表来代替HashTable和SkipList
不过,ZipList
存在连锁更新问题,因此而在Redis7.0版本以后,ZipList
又被替换为Listpack(紧凑列表)。
Redis内存回收
当内存达到上限,就无法存储更多数据了。因此,Redis内部会有两套内存回收的策略:
-
内存过期策略
-
内存淘汰策略
内存过期处理
存入Redis中的数据可以配置过期时间,到期后再次访问会发现这些数据都不存在了,也就是被过期清理了。
Redis中通过expire
命令可以给KEY设置TTL
(过期时间)
过期策略
Redis不管有多少种数据类型,本质是一个KEY-VALUE
的键值型数据库,而这种键值映射底层正式基于HashTable来实现的,在Redis中叫做Dict.
面试题:Redis如何判断KEY是否过期呢?
答:在Redis中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。
Redis是何时删除过期KEY的呢?
Redis并不会在KEY过期时立刻删除KEY,因为要实现这样的效果就必须给每一个过期的KEY设置时钟,并监控这些KEY的过期状态。无论对CPU还是内存都会带来极大的负担。
Redis的过期KEY删除策略有两种:
-
惰性删除
-
周期删除
惰性删除,顾明思议就是过期后不会立刻删除。那在什么时候删除呢?
Redis会在每次访问KEY的时候判断当前KEY有没有设置过期时间,如果有,过期时间是否已经到期。
周期删除:顾明思议是通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。
执行周期有两种:
-
SLOW模式:Redis会设置一个定时任务
serverCron()
,按照server.hz
的频率来执行过期key清理 -
FAST模式:Redis的每个事件循环前执行过期key清理(事件循环就是NIO事件处理的循环)。
SLOW模式规则:
-
① 执行频率受
server.hz
影响,默认为10,即每秒执行10次,每个执行周期100ms。 -
② 执行清理耗时不超过一次执行周期的25%,即25ms.
-
③ 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
-
④ 如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束
FAST模式规则(过期key比例小于10%不执行):
-
① 执行频率受
beforeSleep()
调用频率影响,但两次FAST模式间隔不低于2ms -
② 执行清理耗时不超过1ms
-
③ 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
-
④ 如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束
内存淘汰策略
对于某些特别依赖于Redis的项目而言,仅仅依靠过期KEY清理是不够的,内存可能很快就达到上限。因此Redis允许设置内存告警阈值,当内存使用达到阈值时就会主动挑选部分KEY删除以释放更多内存。这叫做内存淘汰机制。
淘汰策略
Redis支持8种不同的内存淘汰策略:
-
noeviction
: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。 -
volatile
-ttl
: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰 -
allkeys
-random
:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选 -
volatile-random
:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。 -
allkeys-lru
: 对全体key,基于LRU算法进行淘汰 -
volatile-lru
: 对设置了TTL的key,基于LRU算法进行淘汰 -
allkeys-lfu
: 对全体key,基于LFU算法进行淘汰 -
volatile-lfu
: 对设置了TTL的key,基于LFI算法进行淘汰
比较容易混淆的有两个算法:
-
LRU(
L
east
R
ecently
U
sed
),最近最久未使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。 -
LFU(
L
east
F
requently
U
sed
),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
逻辑访问次数
Redis的逻辑访问次数算法了,LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:
-
① 生成
[0,1)
之间的随机数R
-
② 计算
1/(旧次数 * lfu_log_factor + 1)
,记录为P
,lfu_log_factor
默认为10 -
③ 如果
R
<P
,则计数器+1
,且最大不超过255 -
④ 访问次数会随时间衰减,距离上一次访问时间每隔
lfu_decay_time
分钟(默认1) ,计数器-1
Redis采取的是抽样法,即每次抽样一定数量(maxmemory_smples
)的key,然后基于内存策略做排序,找出淘汰优先级最高的,删除这个key。这就导致Redis的算法并不是真正的LRU,而是一种基于抽样的近似LRU算法。
总结(常见问题)
面试题:Redis如何判断KEY是否过期呢?
答:在Redis中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。
面试题:Redis何时删除过期KEY?如何删除?
答:Redis的过期KEY处理有两种策略,分别是惰性删除和周期删除。
惰性删除是指在每次用户访问某个KEY时,判断KEY的过期时间:如果过期则删除;如果未过期则忽略。
周期删除有两种模式:
-
SLOW模式:通过一个定时任务,定期的抽样部分带有TTL的KEY,判断其是否过期。默认情况下定时任务的执行频率是每秒10次,但每次执行不能超过25毫秒。如果执行抽样后发现时间还有剩余,并且过期KEY的比例较高,则会多次抽样。
-
FAST模式:在Redis每次处理NIO事件之前,都会抽样部分带有TTL的KEY,判断是否过期,因此执行频率较高。但是每次执行时长不能超过1ms,如果时间充足并且过期KEY比例过高,也会多次抽样
面试题:当Redis内存不足时会怎么做?
答:这取决于配置的内存淘汰策略,Redis支持很多种内存淘汰策略,例如LRU、LFU、Random. 但默认的策略是直接拒绝新的写入请求。而如果设置了其它策略,则会在每次执行命令后判断占用内存是否达到阈值。如果达到阈值则会基于配置的淘汰策略尝试进行内存淘汰,直到占用内存小于阈值为止。
面试题:那你能聊聊LRU和LFU吗?
答:LRU
是最近最久未使用。Redis的Key都是RedisObject,当启用LRU算法后,Redis会在Key的头信息中使用24个bit记录每个key的最近一次使用的时间lru
。每次需要内存淘汰时,就会抽样一部分KEY,找出其中空闲时间最长的,也就是now - lru
结果最大的,然后将其删除。如果内存依然不足,就重复这个过程。
由于采用了抽样来计算,这种算法只能说是一种近似LRU算法。因此在Redis4.0以后又引入了LFU
算法,这种算法是统计最近最少使用,也就是按key的访问频率来统计。当启用LFU算法后,Redis会在key的头信息中使用24bit记录最近一次使用时间和逻辑访问频率。其中高16位是以分钟为单位的最近访问时间,后8位是逻辑访问次数。与LFU类似,每次需要内存淘汰时,就会抽样一部分KEY,找出其中逻辑访问次数最小的,将其淘汰。
面试题:逻辑访问次数是如何计算的?
答:由于记录访问次数的只有8bit
,即便是无符号数,最大值只有255,不可能记录真实的访问次数。因此Redis统计的其实是逻辑访问次数。这其中有一个计算公式,会根据当前的访问次数做计算,结果要么是次数+1
,要么是次数不变。但随着当前访问次数越大,+1
的概率也会越低,并且最大值不超过255.
除此以外,逻辑访问次数还有一个衰减周期,默认为1分钟,即每隔1分钟逻辑访问次数会-1
。这样逻辑访问次数就能基本反映出一个key
的访问热度了。
缓存问题
Redis经常被用作缓存,而缓存在使用的过程中存在很多问题需要解决。例如:
-
缓存的数据一致性问题
-
缓存击穿
-
缓存穿透
-
缓存雪崩
缓存一致性
缓存的通用模型有三种:
-
Cache Aside
:有缓存调用者自己维护数据库与缓存的一致性。即:-
查询时:命中则直接返回,未命中则查询数据库并写入缓存
-
更新时:更新数据库并删除缓存,查询时自然会更新缓存
-
-
Read/Write Through
:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:-
查询时:命中则直接返回,未命中则查询数据库并写入缓存
-
更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据库
-
-
Write Behind Cahing
:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库
目前企业中使用最多的就是Cache Aside
模式
综上,添加缓存的目的是为了提高系统性能,而你要付出的代价就是缓存与数据库的强一致性。如果你要求数据库与缓存的强一致,那就需要加锁避免并行读写。但这就降低了性能,与缓存的目标背道而驰。
因此不管任何缓存同步方案最终的目的都是尽可能保证最终一致性,降低发生不一致的概率。我们采用先更新数据库再删除缓存的方案,已经将这种概率降到足够低,目的已经达到了。
同时我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。
缓存穿透
开启很多线程频繁的访问一个数据库中也不存在的数据。由于缓存不可能生效,那么所有的请求都访问数据库,可能就会导致数据库因过高的压力而宕机。
解决这个问题有两种思路:
-
缓存空值
-
布隆过滤器
缓存空值
简单来说,就是当我们发现请求的数据即不存在与缓存,也不存在与数据库时,将空值缓存到Redis,避免频繁查询数据库。实现思路如下:
优点:
-
实现简单,维护方便
缺点:
-
额外的内存消耗
布隆过滤器
布隆过滤是一种数据统计的算法,用于检索一个元素是否存在一个集合中。
布隆过滤首先需要一个很长的bit数组,默认数组中每一位都是0.
然后还需要K
个hash
函数,将元素基于这些hash函数做运算的结果映射到bit数组的不同位置,并将这些位置置为1。
此时,我们要判断元素是否存在,只需要再次基于K
个hash
函数做运算, 得到K
个角标,判断每个角标的位置是不是1:
-
只要全是1,就证明元素存在
-
任意位置为0,就证明元素一定不存在
布隆过滤器的判断存在误差:
-
当布隆过滤器认为元素不存在时,它肯定不存在
-
当布隆过滤器认为元素存在时,它可能存在,也可能不存在
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
常见的解决方案有:
-
给不同的Key的TTL添加随机值,这样KEY的过期时间不同,不会大量KEY同时过期
-
利用Redis集群提高服务的可用性,避免缓存服务宕机
-
给缓存业务添加降级限流策略
-
给业务添加多级缓存,比如先查询本地缓存,本地缓存未命中再查询Redis,Redis未命中再查询数据库。即便Redis宕机,也还有本地缓存可以抗压力(比如浏览器级别缓存、nginx级别缓存、jvm本地缓存等,然后就是redis缓存和数据库)
缓存击穿(热点Key问题)
就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
-
互斥锁:给重建缓存逻辑加锁,避免多线程同时指向
-
逻辑过期:热点key不要设置过期时间,在活动结束后手动删除。
基于互斥锁的方案如图:
逻辑过期的思路如图:
逻辑时间过期,就是说缓存数据还在,只需要重置逻辑过期时间就行.如果每次都要查数据库重建缓存数据,那这个逻辑时间就没有意义了 。
总结(常见问题)
面试题:如何保证缓存的双写一致性?
答:缓存的双写一致性很难保证强一致,只能尽可能降低不一致的概率,确保最终一致。我们项目中采用的是Cache Aside
模式。简单来说,就是在更新数据库之后删除缓存;在查询时先查询缓存,如果未命中则查询数据库并写入缓存。同时我们会给缓存设置过期时间作为兜底方案,如果真的出现了不一致的情况,也可以通过缓存过期来保证最终一致。
追问:为什么不采用延迟双删机制?
答:延迟双删的第一次删除并没有实际意义,第二次采用延迟删除主要是解决数据库主从同步的延迟问题,我认为这是数据库主从的一致性问题,与缓存同步无关。既然主节点数据已经更新,Redis的缓存理应更新。而且延迟双删会增加缓存业务复杂度,也没能完全避免缓存一致性问题,投入回报比太低。
面试题:如何解决缓存穿透问题?
答:缓存穿透也可以说是穿透攻击,具体来说是因为请求访问到了数据库不存在的值,这样缓存无法命中,必然访问数据库。如果高并发的访问这样的接口,会给数据库带来巨大压力。
我们项目中都是基于布隆过滤器来解决缓存穿透问题的,当缓存未命中时基于布隆过滤器判断数据是否存在。如果不存在则不去访问数据库。
当然,也可以使用缓存空值的方式解决,不过这种方案比较浪费内存。
面试题:如何解决缓存雪崩问题?
答:缓存雪崩的常见原因有两个,第一是因为大量key同时过期。针对问这个题我们可以可以给缓存key设置不同的TTL值,避免key同时过期。
第二个原因是Redis宕机导致缓存不可用。针对这个问题我们可以利用集群提高Redis的可用性。也可以添加多级缓存,当Redis宕机时还有本地缓存可用。
面试题:如何解决缓存击穿问题?
答:缓存击穿往往是由热点Key引起的,当热点Key过期时,大量请求涌入同时查询,发现缓存未命中都会去访问数据库,导致数据库压力激增。解决这个问题的主要思路就是避免多线程并发去重建缓存,因此方案有两种。
第一种是基于互斥锁,当发现缓存未命中时需要先获取互斥锁,再重建缓存,缓存重建完成释放锁。这样就可以保证缓存重建同一时刻只会有一个线程执行。不过这种做法会导致缓存重建时性能下降严重。
第二种是基于逻辑过期,也就是不给热点Key设置过期时间,而是给数据添加一个过期时间的字段。这样热点Key就不会过期,缓存中永远有数据。
查询到数据时基于其中的过期时间判断key是否过期,如果过期开启独立新线程异步的重建缓存,而查询请求先返回旧数据即可。当然,这个过程也要加互斥锁,但由于重建缓存是异步的,而且获取锁失败也无需等待,而是返回旧数据,这样性能几乎不受影响。
需要注意的是,无论是采用哪种方式,在获取互斥锁后一定要再次判断缓存是否命中,做dubbo check. 因为当你获取锁成功时,可能是在你之前有其它线程已经重建缓存了。