文章目录
- [1. 缓存](#1. 缓存)
-
- [1.1 Redis作为缓存](#1.1 Redis作为缓存)
- [1.2 缓存更新、淘汰策略](#1.2 缓存更新、淘汰策略)
- [1.3 缓存预热、缓存穿透、缓存雪崩和缓存击穿](#1.3 缓存预热、缓存穿透、缓存雪崩和缓存击穿)
-
- [1.3.1 缓存预热(preheating)](#1.3.1 缓存预热(preheating))
- [1.3.2 缓存穿透(penetration)](#1.3.2 缓存穿透(penetration))
- [1.3.3 缓存雪崩(avalanche)](#1.3.3 缓存雪崩(avalanche))
- [1.3.4 缓存击穿(breakdown)](#1.3.4 缓存击穿(breakdown))
- [2. 分布式锁](#2. 分布式锁)
-
- [2.1 什么是分布式锁?](#2.1 什么是分布式锁?)
- [2.2 分布式锁的基础实现](#2.2 分布式锁的基础实现)
- [2.3 引入过期时间(防止"死锁"问题)](#2.3 引入过期时间(防止“死锁”问题))
- [2.4 引入校验id(防止锁误删问题)](#2.4 引入校验id(防止锁误删问题))
- [2.5 引入lua(解决原子性问题)](#2.5 引入lua(解决原子性问题))
- [2.6 引入watchdog(解决过期时间不足问题)](#2.6 引入watchdog(解决过期时间不足问题))
- [2.7 引入Redlock算法(防止redis挂了)](#2.7 引入Redlock算法(防止redis挂了))

1. 缓存
Redis最主要的三个功能
- 存储数据(内存数据库)
- 缓存
- 消息队列
1.1 Redis作为缓存
在⼀个网站中,我们经常会使⽤关系型数据库(比如MySQL)来存储数据;关系型数据库虽然功能强大,但是有⼀个很大的缺陷,就是性能不高(换言之,进⾏⼀次查询操作消耗的系统资源较多)
硬件的访问速度通常是如下情况下:CPU寄存器 > 内存 > 硬盘 > ⽹络
为什么说关系型数据库性能不⾼?
- 数据库把数据存储在硬盘上,硬盘的IO速度并不快,尤其是随机访问.
- 如果查询不能命中索引,就需要进⾏表的遍历,这就会大大增加硬盘IO次数.
- 关系型数据库对于SQL的执行会做⼀系列的解析、校验、优化⼯作.
- 如果是⼀些复杂查询,⽐如联合查询,需要进⾏笛卡尔积操作,效率更是降低很多.
因此,如果访问数据库的并发量⽐较⾼,对于数据库的压⼒是很⼤的,很容易就会使数据库服务器宕机
如何让数据库能够承担更⼤的并发量呢?核心思路主要是两个:
- 开源:引⼊更多的机器,部署更多的数据库实例,构成数据库集群.(主从复制,分库分表等...)
- 节流:引⼊缓存,使⽤其他的⽅式保存经常访问的热点数据,从⽽降低直接访问数据库的请求数量
Redis 就是⼀个⽤来作为数据库缓存的常⻅⽅案,Redis访问速度⽐MySQL快很多,或者说处理同⼀个访问请求,Redis消耗的系统资源⽐MySQL少很多,因此Redis能⽀持的并发量更⼤.
- Redis数据在内存中,访问内存⽐硬盘快很多.
- Redis只是⽀持简单的key-value存储,不涉及复杂查询的那么多限制规则.

1.2 缓存更新、淘汰策略
redis作为缓存,一般存储的热点数据,那么如何知道哪些数据是热点数据呢?
- 定期生成
每隔⼀定的周期(⽐如⼀天/⼀周/⼀个⽉),对于访问的数据频次进⾏统计,挑选出访问频次最⾼的前N%的数据
这种做法实时性较低.对于⼀些突然情况应对的并不好
- 实时生成
先给缓存设定容量上限(可以通过Redis配置⽂件的maxmemory 参数设定).
接下来把用户每次查询:
- 如果在Redis查到了,就直接返回.
- 如果Redis中不存在,就从数据库查,把查到的结果同时也写⼊Redis.
如果缓存已经满了(达到上限),就触发缓存淘汰策略,把⼀些"相对不那么热门"的数据淘汰掉。照上述过程,持续⼀段时间之后Redis内部的数据⾃然就是"热门数据"了.
通⽤的淘汰策略主要有以下⼏种:
- FIFO (First In First Out) 先进先出:把缓存中存在时间最久的(也就是先来的数据)淘汰掉
- LRU (LeastRecentlyUsed)淘汰最久未使⽤的:记录每个key的最近访问时间,把最近访问时间最⽼的key淘汰掉
- LFU (LeastFrequently Used)淘汰访问次数最少的:记录每个key最近⼀段时间的访问次数,把访问次数最少的淘汰掉
- Random随机淘汰:从所有的key中抽取幸运⼉被随机淘汰掉
这⾥的淘汰策略,我们可以自己实现,当然Redis也提供了内置的淘汰策略,也可以供我们直接使用,下面罗列了一部分:
整体来说Redis提供的策略和我们上述介绍的通⽤策略是基本⼀致的,只不过Redis这⾥会针对"过期key" 和"全部key"做分别处理.
1.3 缓存预热、缓存穿透、缓存雪崩和缓存击穿
1.3.1 缓存预热(preheating)
使⽤Redis作为MySQL的缓存的时候,当Redis刚刚启动,或者Redis⼤批key失效之后,此时由于Redis 自身相当于是空着的,没啥缓存数据,那么MySQL就可能直接被访问到,从⽽造成较⼤的压⼒
因此就需要提前把热点数据准备好,直接写⼊到Redis中,使Redis可以尽快为MySQL撑起保护伞
热点数据可以基于之前介绍的统计的⽅式⽣成即可,这份热点数据不⼀定非得那么"准确",只要能帮助MySQL抵挡大部分请求即可,随着程序运⾏的推移,缓存的热点数据会逐渐自动调整,来更适应当前情况
1.3.2 缓存穿透(penetration)
访问的key在Redis和数据库中都不存在,此时这样的key不会被放到缓存上,后续如果仍然在访问该key, 依然会访问到数据库,这就会导致数据库承担的请求太多,压⼒很⼤,这种情况称为缓存穿透
为何产⽣?原因可能有几种:
- 业务设计不合理,比如缺少必要的参数校验环节,导致非法的key也被进⾏查询了
- 开发/运维误操作,不小心把部分数据从数据库上误删了
- ⿊客恶意攻击
如何解决?
- 针对要查询的参数进⾏严格的合法性校验
- ⽐如要查询的key是⽤⼾的⼿机号,那么就需要校验当前key 是否满⾜⼀个合法的⼿机号的格式
- 针对数据库上也不存在的key,也存储到Redis中
- ⽐如value就随便设成⼀个"",避免后续频繁访问数据库.
- 使⽤布隆过滤器先判定key是否存在,再真正查询
1.3.3 缓存雪崩(avalanche)
短时间内大量的key在缓存上失效,导致数据库压力骤增,甚⾄直接宕机,这种情况叫做缓存雪崩
本来Redis是MySQL的⼀个护盾,帮MySQL抵挡了很多外部的压⼒,⼀旦护盾突然失效了,MySQL⾃⾝承担的压⼒骤增,就可能直接崩溃.
产⽣大规模key失效,可能性主要有两种:
- Redis挂了.
- Redis上的大量的key同时过期
为啥会出现⼤量的key同时过期?这种可能是短时间内在Redis上缓存了⼤量的key,并且设定了相同的过期时间.
如何解决?
- 部署⾼可⽤的Redis集群,并且完善监控报警体系.
- 不给key设置过期时间或者设置过期时间的时候添加随机时间因⼦
1.3.4 缓存击穿(breakdown)
相当于缓存雪崩的特殊情况,针对热点key突然过期了,导致⼤量的请求直接访问到数据库上,甚至引起数据库宕机
如何解决?
- 基于统计的方式发现热点key,并设置永不过期.
- 进行必要的服务降级
- 例如访问数据库的时候使用分布式锁,限制同时请求数据库的并发数
2. 分布式锁
2.1 什么是分布式锁?
在⼀个分布式的系统中,也会涉及到多个节点访问同⼀个公共资源的情况,此时就需要通过锁来做互斥控制,避免出现类似于"线程安全"的问题。
而java的synchronized或者C++的std::mutex,这样的锁都是只能在当前进程中⽣效;在分布式的这种多个进程多个主机的场景下就⽆能为力了,此时就需要使用到分布式锁
分布式锁本质上就是使⽤⼀个公共的服务器,来记录加锁状态.
这个公共的服务器可以是Redis,也可以是其他组件(⽐如MySQL或者ZooKeeper等),还可以 是我们自己写的⼀个服务
2.2 分布式锁的基础实现
思路非常简单,本质上就是通过⼀个键值对来标识锁的状态
举个例⼦:考虑买票的场景,现在⻋站提供了若⼲个⻋次,每个⻋次的票数都是固定的。现在存在多个服务器节点,都可能需要处理这个买票的逻辑:先查询指定⻋次的余票,如果余票>0,则设置余票值-=1

显然上述的场景是存在"线程安全"问题的,需要使用锁来控制。
所谓的分布式锁,就是一个/一组单的服务器程序,来给其它服务器提供"加锁"这样的服务。
redis是一种典型的可以用来实现分布式锁的方案,但不是唯一一种

在买票服务器进行买票操作的过程中,就需要先进行加锁
- 加锁:往redis中设置一个特殊的key-value,完成买票操作,再把这个key-value删除掉
- 使用 set nx命令设置,del命令删除
- 其它服务器也想执行买票操作时,也去redis上尝试设置key-value,设置失败,则认为加锁失败(是阻塞/放弃,看具体的策略)
- 此时,就可以解决线程安全的问题了
刚才买票场景,使用 mysql 的事务也可以批量执行 査询 + 修改 操作,但是分布式系统中,要访问的共享资源不一定是 mysq! 也可能是其他的存储介质没有事务,也可能是执行一段特定的操作,是通过统一的服务器完成执行动作
2.3 引入过期时间(防止"死锁"问题)
当服务器1加锁之后,开始处理买票的过程中,如果服务器1意外宕机了,就会导致解锁操作(删除该key) 不能执行,就可能引起其他服务器始终无法获取到锁的情况
为了解决这个问题,可以在设置key的同时引入过期时间,即这个锁最多持有多久,就应该被释放(使用set ex nx
命令)
注意:
此处的过期时间只能使⽤上述⼀个命令的⽅式设置 ,如果分开多个操作,⽐如setnx之后,再来⼀个单独的expire,由于Redis的多个指令之间不存在关联,并且即使使⽤了事务也不能保证这两个操作都⼀定成功,因此就可能出现setnx成功,但是expire失败的情况,此时仍然会出现⽆法正确释放锁的问题.
2.4 引入校验id(防止锁误删问题)
对于Redis中写⼊的加锁键值对,其他的节点也是可以删除的.
比如服务器1写⼊⼀个
"001":1
这样的键值对,服务器2是完全可以把"001"
给删除掉的。当然,服务器2不会进⾏这样的"恶意删除"操作,不过不能保证因为⼀些bug导致服务器2把锁误删除。
为了解决上述问题,我们可以引⼊⼀个校验id。
比如可以把设置的键值对的值,不再是简单的设为⼀个1,⽽是设成服务器的编号,形如"001":"服务器1"
。
这样就可以在删除key(解锁)的时候,先校验当前删除key的服务器是否是当初加锁的服务器,如果是,才能真正删除;不是,则不能删除
在解锁的时候,要先进行id的校验,再执行del操作,但是这两步不是原子的,就可能出现问题
2.5 引入lua(解决原子性问题)
为了使解锁操作原子,可以使⽤Redis的Lua脚本功能
使⽤Lua脚本完成上述解锁功能
lua
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
上述代码可以编写成⼀个.lua后缀的⽂件,由redis-cli 或者redis-plus-plus 或者jedis 等客⼾端加载,并发送给Redis服务器,由Redis服务器来执⾏这段逻辑;⼀个lua脚本会被Redis服务器以原⼦的⽅式来执⾏.
2.6 引入watchdog(解决过期时间不足问题)
上述⽅案仍然存在⼀个重要问题,当我们设置了key过期时间之后(⽐如10s),仍然存在⼀定的可能性,当任务还没执⾏完,key就先过期了,这就导致锁提前失效。
- 把这个过期时间设置的⾜够⻓,⽐如30s,是否能解决这个问题呢?很明显,设置多⻓时间合适,是⽆⽌境的,即使设置再⻓,也不能完全保证就没有提前失效的情况。
- 如果设置的太⻓了,万⼀对应的服务器挂了,此时其他服务器也不能及时的获取到锁
- 因此相⽐于设置⼀个固定的⻓时间,不如动态的调整时间更合适
所谓watchdog(看门狗),本质上是加锁的服务器上的一个单独的线程,通过这个线程来对锁过期时间进⾏"续约".
这样就不担心锁提前失效的问题了,而且另一方面,如果该服务器挂了,看门狗线程也就随之挂了,此时无人续约,这个key⾃然就可以迅速过期,让其他服务器能够获取到锁了
2.7 引入Redlock算法(防止redis挂了)
实践中的Redis⼀般是以集群的⽅式部署的(至少是主从的形式,⽽不是单机),那么就可能出现以下⽐较极端的大冤种情况:
- 服务器1向master节点进行加锁操作,这个写⼊key的过程刚刚完成,master挂了,slave节点升级成了新的master节点。
- 但是由于刚才写⼊的这个key尚未来得及同步给slave呢,此时就相当于服务器1的加锁操作形同虚设了,服务器2仍然可以进⾏加锁(即给新的master写⼊key,因为新的master不包含刚才的key)
为了解决这个问题,Redis的作者提出了Redlock算法
- 我们引入一组Redis节点 ,其中每⼀组Redis节点都包含⼀个主节点和若⼲从节点,并且组和组之间存储的数据都是⼀致的,相互之间是"备份"关系(而并非是数据集合的⼀部分,这点有别于redis cluster).
- 加锁的时候,按照⼀定的顺序,写多个master节点,在写锁的时候需要设定操作的"超时时间",⽐如50ms,即如果setnx操作超过了50ms还没有成功,就视为加锁失败.
- 如果给某个节点加锁失败,就⽴即再尝试下⼀个节点,当加锁成功的节点数超过总节点数的⼀半,才视为加锁成功.

这样的话,即使有某些节点挂了,也不影响锁的正确性
同理,释放锁的时候,也需要把所有节点都进⾏解锁操作(即使是之前超时的节点,也要尝试解锁,尽量保证逻辑严密)
简而言之,Redlock算法的核心就是:加锁操作不能只写给⼀个Redis节点,而要写个多个!
分布式系统中任何⼀个节点都是不可靠的,最终的加锁成功结论是"少数服从多数的";由于⼀个分布式系统不⾄于⼤部分节点都同时出现故障,因此这样的可靠性要⽐单个节点来说靠谱不少
