四,Redis典型应用——缓存
Redis最主要的三个用途:
存储数据(内存数据库)
缓存
消息队列
1,使用redis作为数据库的缓存
在一个网站中,通常会使用关系型数据库(如MySQL)来存储数据,关系型数据库虽然强大,但是有一个很大的缺陷,就是性能不高。(换言之 ,进行一次 查询操作消耗的系统资源较多)。
为什么说关系型数据库的性能不高?
数据库是把数据存储在硬盘上的,硬盘的IO速度并不快,尤其是随机访问。
如果查询不能命中索引,就需要进行表的遍历,这就会大大增加 IO的次数。
关系型数据库对SQL的操作会进行一系列的解析,校验,优化工作。
如果是一些复杂查询,比如联合查询,需要进行笛卡尔积的操作,效率更是降低很多。
因为MySQL等数据库 ,效率比较低,所以承担的并发量就有限了,一旦请求量多了,数据库的压力就很大,甚至很容易就宕机了。对于服务器的每一个请求,都要消耗一定的硬件资源(CPU,内存,硬盘,网络带宽等等),任意一种资源的消耗超出了机器能提供的上限,机器就很容易出故障。
如何提高MySQL能承担的并发量?
开源:引入更多的机器,构成数据集群。
节流:引入缓存,将一些热点数据保存到缓存中。后续在查询数据的时候,如果数据库中已经存在了,就不再访问MySQL了。
2,如何知道redis中应该存储哪些数据?
也就是怎么获得热点数据。
这涉及到缓存的两种更新策略:1,定期生成 2,实时生成
1,定期生成
将访问的数据 ,以日志的 形式记录下来。接下来就可以针对这些日志进行统计了,统计这一天/一周/一个月,数据出现的频率,然后再按照降序排序,取出前20%的数据数据,这些数据 就是热点数据。
优点:上述过程,实际上实现起来比较简单,过程更可控,缓存中的数据是比较扶额和预期的,方便排查问题。
缺点:实时性不够。如果出现一些突发事件,有些本来不是热词的内容成了热词,这就可能会给后面的数据库带来较大的压力。
2,实时生成
如果在redis中查到了数据,就直接返回。
如果没有查到,就从数据库查,同时把查到的数据写入redis中。
这里就会有一个问题,如果不停的向redis中写入数据,就会使redis的内存占用越来越高,逐渐达到内存上限。
此时如果继续向redis中写入数据,就会出现问题,为了解决这个问题,redis就引入了"内存淘汰策略"。
经典面试题:
FIFO(First In First Out)先进先出:把缓存中存在时间最久的(也就是先来的数据)淘汰掉。
LRU(Least Recently Used)淘汰最近未使用的:记录每个key的最近访问时间,把最近访问时间最老的key淘汰掉
LFU(Least Frequently Used)淘汰访问次数最少的:记录每个key最近一段时间的访问次数,把访问次数最少的淘汰掉。
Random 随机淘汰:从所有的key中随机抽取一个淘汰掉。
3,缓存预热,缓存穿透,缓存雪崩和缓存击穿
缓存预热(Cache preheating)
缓存中的数据,有两种更新策略:1,定期生成 2,实时生成
如果使定期生成,就不涉及到预热。
如果是实时生成,在redis服务首次接入之后,服务器里是没有数据的,此时客户端的所有请求就都会打给MySQL,如果请求量太多,可能就会导致MySQL服务挂了。随着时间的推移,reids中的数据越来越多,MySQl承担的压力也就越来越小了。
缓存预热,就是为了解决上述问题。把定期生成和实时生成相结合,先通过离线的方式,通过一些统计的途径,先找到一批热点数据,导入到redis中。此时导入的这批热点数据就能帮MySQL分担一些压力了。随着时间的推移,使用新的热点数据来淘汰旧的热点数据。
在刚开始架构演进的时候,没有缓存,此时要加入缓存,就要进行缓存预热。还有当服务器进行重启的时候,我们要保证重启之后缓存中是否有数据以及 这里的数据 是否是热点数据,这也涉及到缓存预热。
缓存穿透(Cache penetration)
在一次查询的过程中,如果要查询的某个key,在redis中没有,在MySQL中也没有。也就意味着此时这个key是不会被放到redis中,那么下次访问依然会访问数据库,这就会导致数据库承担的请求太多,压力很大。这种情况称为缓存穿透。
出现这种情况可能的原因:
业务设计不合理:比如缺少必要的参数检验环节,导致非法到的key也被进行查询了。
开发/运维误操作:不小心把部分数据从数据库中删除。
黑客恶意攻击。
解决方案:
如果发现这个key,在redis和MySQL中都不存在在,仍然写入redis,将value设成一个非法值(比如"")。再应用层程序可以检查出这是一个非法的key。
还可以引入布隆过滤器,每次查询redis/MySQL之前,都先判断一下key是否在布隆过滤器上存在。布隆过滤器本质是结合了hash+bitmap,以较小的空间开销,以较快的访问速度,实现针对key是否存在的判定。
缓存雪崩(Cache avalanche)
由于在短时间内,redis上大规模的key失效,导致缓存命中率陡然下降,并且MySQL压力迅速上升,甚至导致MySQL直接宕机。
可能的原因:
redis直接挂了,redis宕机/redis集群模式下很多节点宕机。(这是最主要的)
redis正常工作,但是可能之前短时间内设置了很多key给redis,并且设置的过期时间是相同的。在给redis里设置key作为缓存的时候,有的时候为了考虑时效性,就会设置过期时间(和redis的内存淘汰机制是配合使用的)。
解决方法:
加强监控报警,加强redis集群可用性的保证。
不给key设置过期时间,或者在设置过期时间的时候,添加随机因子(避免同一时刻过期 )。
缓存击穿(Cache breakdown)
相当于缓存雪崩的特殊情况,针对热点key,突然过期了,导致大量的请求访问到数据库上,导致数据库宕机了。
解决方案:
基于统计的方式发现热点key,并设置为永不过期。这种方案往往需要服务器做出较大的调整。比如把当前访问哪些key的日志记录下来,接到一个消息队列中,再通过一些计算,将结果再返回给我们的服务器。
进行必要的降级服务。例如访问数据库的时候,使用分布式锁,限制同时请求数据库的并发数。
五,分布式锁
在一个分布式系统中,会涉及到多个节点访问同一个公共资源的问题,此时就需要通过 锁 来做互斥控制,避免出现类似于 线程安全的问题。而C++中的std::mutex,这样的锁只能在当前进程中生效。
而在分布式系统中,是有很多进程的(每个服务器,都是独立的进程)。因此,之前的锁就难以对现在分布式系统中的多个进程之前产生制约。分布式系统中,多个进程之间的执行顺序也是不确定的。
此时就需要引入"分布式锁",来解决上述 问题。
所谓的分布式锁,也是一个/一组单独的服务器程序,给其他的服务器提供"加锁"这样的服务。redis是一种典型的可以是实现分布式锁的方案,但不是唯一的一种。
买票服务器在进行买票的过程中,就需要先加锁,就是往redis上尝试设置一个特殊的key-value,完成买票后,就会把这个key-value删掉。其他服务器在买票的过程中,也会去尝试设置这个key-value,如果发现key-value已经存在,就认为加锁失败(是放弃还是阻塞,就看具体的实现策略了)。
这个加锁过程其实就对标redis中的一个命令setnx key val,这个命令如果key不存在才会设置,如果key存在就会执行出错,同时解锁过程也对标redis中的del key命令。
1,引入过期时间
问题1:某个服务器加锁成功了(setnx成功),如果该服务器执行后续逻辑的过程中,程序崩溃了,此时还没有执行到解锁操作。这种情况就会导致redis上的key无人删除,也就导致其他服务器无法获取到锁了。
解决办法:在加锁过程中,给这个key设置一个过期时间,set ex nx这样的命令来完成设置,时间到了,redis服务器会自动删除这个key,这是其他服务器就可以获取到锁了。
注意:在设置过期时间的时候,智能使用set nx ex这样的方式设置,不能使用set nx ,exprie这两个命令来设置。因为redis上多个命令之间,是无法保证原子性的,此时就可能出现,这两个命令,一个执行成功,一个执行失败。相比之下,使用一条命令设置,是更加稳妥的。
2,引入校验id
问题2:所谓的加锁,就是给redis上设置一个key-val,所谓的解锁,就是给redis上的key-val删除掉。锁,就可以认为是redis上的一个普通键值对。可能会出现服务器1执行了加锁,而服务器2误执行了解锁。因此就可能给我们的系统带来严重的问题。(比如票数超卖)
为了解决这个问题,就引入了校验机制。
给服务器编号,每个服务器都有自己的身份标识。
进行加锁的时候,设置key-val。key对应着服务器要访问的资源,val表示服务器的编号。
在解锁的时候,先查询这个锁对应的服务器编号,然后判定这个编号和执行解锁的服务器的编号是否一致,如果是,才能真正执行del;否则,执行失败。
3,引入lua脚本
对于问题2,我们引入了校验id,但是还存在问题。就是在解锁的时候,需要两步操作,先获取到key对应的val,在执行del,此处是两步操作(不是原子的),就可能会出现问题。
一个服务器内部,也可能是多线程的,此时,就可能服务器A的两个线程都在执行解锁操作,首先进行id校验,都通过了,然后开始执行del命令,del就会被重复执行。
这看起来没有什么问题,但是如果此时一个线程执行完了del,又有一个服务器B来进行加锁(set nx ex),加锁成功,之后服务器A的另一个线程执行del,就会把服务器B的锁给解掉。
归根节点,是因为get 和 del这两个命令不是原子的,此时可以引入事务,将这两个操作打包成一个事务,使在执行get 和 del之间不会执行其他操作(避免插队)。
使用事务,能解决上述问题,但是在实践中,往往推荐使用更好的方案——lua脚本。
redis执行lua脚本的过程 ,也是原子的,相当于执行一条命令一样。
在redis官方文档中,也明确说明了,lua就属于事务的替代方案。
4,引入看门狗(Watch Dog)
在前面提到过,服务器在进行加锁的时候,要给key设置一个过期时间。
这个过期时间,如果设置的太短,就可能在服务器的业务逻辑还未执行完,锁就释放了。
如果设置的太长,也会导致"锁释放不及时"的问题。
这里更好的方式是"动态续约"。
初始情况下,设置一个过期时间(比如设置1s),就提前在还剩300ms的时候(不一定是300ms,数值可以灵活调整),如果当前任务还未执行完,就把过期时间再续上1s。等到时间又快到了,任务还未执行完,就再续。
这样设置也有一个好处:如果服务器中途崩溃了,也就没人续约了,此时,锁就可以再较短的时间内被释放。
服务器进行"动态续约"往往是需要有一个专门的线程来完成这个事情,这个线程就叫做"看门狗"。
5,redlock算法
使用redis作为分布式锁,redis本身是有可能挂了的。
要想保证redis的高可用,可以使用主从复制,哨兵,集群模式等方案。这里使用哨兵机制最合适。
进行加锁操作,就是把key设置到设置到主节点上,如果主节点挂了,有哨兵节点会把从节点升级为主节点,进一步保证刚才的锁可用。
但是主节点和从节点的数据同步是有延迟的,可能主节点收到了加锁的请求(set nx ex),还没来得及推送给从节点,主节点就挂了。即使从节点升级成为了主节点,但是刚才加锁的对应的数据是不存在的。
此时就需要使用 redlock算法。(redis作者给出的一种方案)核心思想:冗余,少数服从多数。
此时加锁,就是按照一定的顺序,针对这些redis都进行加锁操作。如果某个主节点挂了(加不上锁),没关系,继续给下一个主节点加锁。如果加锁成功的主节点个数超过总结点总数的一半,就视为加锁成功。同理,进行解锁的时候,每个主节点都会进行一遍解锁。