缓存

介绍缓存 & 用redis作为mysql的缓存

Redis最主要的用途有三个方面:

1.作为数据库(内存数据库)

2.作为缓存。

3.消息队列。

其中作为数据库和缓存是比较常见的,其中作为缓存的情况则是最多的。

我们知道, 对于硬件的访问速度来说, 通常情况下:
CPU 寄存器 > 内存 > 硬盘 > ⽹络

缓存之所以能够有意义,是因为数据的访问大多遵循 “二八” 原则,即20%的数据能够满足80%的访问量。 这样我们就能把这20%的数据(高频访问的数据)放在缓存中, 就可以应付大多数场景,从而在整体上有明显的性能提升。

在⼀个⽹站中, 我们经常会使⽤关系型数据库 (⽐如 MySQL) 来存储数据.
关系型数据库虽然功能强⼤, 但是有⼀个很⼤的缺陷, 就是性能不⾼. (换⽽⾔之, 进⾏⼀次查询操作消耗的系统资源较多)

为什么说关系型数据库性能不⾼?
1. 数据库把数据存储在硬盘上, 硬盘的 IO 速度并不快. 尤其是随机访问.
2. 如果查询不能命中索引, 就需要进⾏表的遍历, 这就会⼤⼤增加硬盘 IO 次数.
3. 关系型数据库对于 SQL 的执⾏会做⼀系列的解析, 校验, 优化⼯作.
4. 如果是⼀些复杂查询, ⽐如联合查询, 需要进⾏笛卡尔积操作, 效率更是降低很多.
5. .....

因此, 如果访问数据库的并发量⽐较⾼, 对于数据库的压⼒是很⼤的, 很容易就会使数据库服务器宕机。 

为什么并发量⾼了就会宕机?
服务器每次处理⼀个请求, 都是需要消耗⼀定的硬件资源的. 所谓的硬件资源包括不限于 CPU,
内存, 硬盘, ⽹络带宽......

⼀个服务器的硬件资源本⾝是有限的. ⼀个请求消耗⼀份资源, 请求多了, ⾃然把资源就耗尽
了. 后续的请求没有资源可⽤, ⾃然就⽆法正确处理. 更严重的还会导致服务器程序的代码出现
崩溃。

如何让数据库能够承担更⼤的并发量呢? 核⼼思路主要是两个:
开源: 引⼊更多的机器, 部署更多的数据库实例, 构成数据库集群. (主从复制, 分库分表等...)
节流: 引⼊缓存, 使⽤其他的⽅式保存经常访问的热点数据, 从⽽降低直接访问数据库的请求数量

实际开发中,这两种方案一般都是搭配使用的。

Redis 就是⼀个⽤来作为数据库缓存的常⻅⽅案 

Redis 访问速度⽐ MySQL 快很多. 或者说处理同⼀个访问请求, Redis 消耗的系统资源⽐
MySQL 少很多. 因此 Redis 能⽀持的并发量更⼤.
Redis 数据在内存中, 访问内存⽐硬盘快很多.
Redis 只是⽀持简单的 key-value 存储, 不涉及复杂查询的那么多限制规则

客⼾端访问业务服务器, 发起查询请求.
业务服务器先查询 Redis, 看想要的数据是否在 Redis 中存在.
如果已经在 Redis 中存在了, 就直接返回. 此时不必访问 MySQL 了.

就像⼀个 "护盾" ⼀样, 把 MySQL 给罩住了。 

如果在 Redis 中不存在, 再查询 MySQL.
按照上述讨论的 "⼆⼋定律" , 只需要在 Redis 中放 20% 的热点数据, 就可以使 80% 的请求不再真正查询数据库了

当然, 实践中究竟是 "⼆⼋", 还是 "⼀九", 还是 "三七", 这个情况可能会根据业务场景的不同, 存在差
异. 但是⾄少绝⼤多数情况下, 使⽤缓存都能够⼤⼤提升整体的访问效率, 降低数据库的压⼒

缓存是⽤来加快 "读操作" 的速度的. 如果是 "写操作", 还是要⽼⽼实实写数据库, 缓存并不能
提⾼性能。

缓存更新策略 

1.定期生成

每隔⼀定的周期(⽐如⼀天/⼀周/⼀个⽉), 对于访问的数据频次进⾏统计. 挑选出访问频次最⾼的前 N%的数据,会将这些热点数据以日志的形式记录下来,然后加载到redis中。
大致流程:

定期生成策略的优点:

实现比较简单,过程比较可控,方便排查问题。

缺点:
实时性不够。比如对于某一些突发事件,有一些本来不是热词的内容成为了热词,此时就会给后面的数据库(比如mysql)带来不小的压力。

2.实时生成 

先给缓存设定容量上限(可以通过 Redis 配置⽂件的 maxmemory 参数设定).
接下来把⽤⼾每次查询:
如果在 Redis 查到了, 就直接返回.
如果 Redis 中不存在, 就从数据库查, 把查到的结果同时也写⼊ Redis.

如果缓存已经满了(达到上限), 就触发缓存淘汰策略, 把⼀些 "相对不那么热⻔" 的数据淘汰掉.
按照上述过程, 持续⼀段时间之后 Redis 内部的数据⾃然就是 "热⻔数据" 了,经过一段时间的“动态平衡”之后,redis中存储的大多都是热点数据了。

   当redis的内存达到上限时,如果继续插入数据,就会触发问题,在这里redis就引入了“内存淘汰策略”(经典面试题)。

  关于redis的内存上限不一定指机器的内存上限,在redis的配置文件中也可以配置redis最多使用多少内存。

 内存淘汰策略

重点 

FIFO (First In First Out) 先进先出:
把缓存中存在时间最久的 (也就是先来的数据) 淘汰掉

LRU (Least Recently Used) 淘汰最久未使⽤的
记录每个 key 的最近访问时间. 把最近访问时间最⽼的 key 淘汰掉

LFU (Least Frequently Used) 淘汰访问次数最少的
记录每个 key 最近⼀段时间的访问次数. 把访问次数最少的淘汰掉

Random 随机淘汰
从所有的 key 中抽取幸运⼉被随机淘汰掉

其中这个随机淘汰的策略显然是不太合理的,所以一般不会用到。

这些策略不仅仅局限于redis,其它缓存也可以按照这些策略展开。

另外这些策略我们可以自己实现出来,也可以直接使用redis内置的淘汰策略。 

 redis内置的淘汰策略:

volatile-lru 当内存不⾜以容纳新写⼊数据时,从设置了过期时间的key中使⽤LRU(最近最
少使⽤)算法进⾏淘汰
allkeys-lru 当内存不⾜以容纳新写⼊数据时,从所有key中使⽤LRU(最近最少使⽤)算法进
⾏淘汰.
volatile-lfu 4.0版本新增,当内存不⾜以容纳新写⼊数据时,在过期的key中,使⽤LFU算法
进⾏删除key.
allkeys-lfu 4.0版本新增,当内存不⾜以容纳新写⼊数据时,从所有key中使⽤LFU算法进⾏
淘汰.
volatile-random 当内存不⾜以容纳新写⼊数据时,从设置了过期时间的key中,随机淘汰数
据.
allkeys-random 当内存不⾜以容纳新写⼊数据时,从所有key中随机淘汰数据.
volatile-ttl 在设置了过期时间的key中,根据过期时间进⾏淘汰,越早过期的优先被淘汰.
(相当于 FIFO, 只不过是局限于过期的 key)
noeviction 默认策略,当内存不⾜以容纳新写⼊数据时,新写⼊操作会报错

整体来说 Redis 提供的策略和我们上述介绍的通⽤策略是基本⼀致的. 只不过 Redis 这⾥会针对 "过期key" 和 "全部 key" 做分别处理。

缓存预热, 缓存穿透, 缓存雪崩 和 缓存击穿 

这是重点内容,面试常考,工作常用。 

 缓存预热 (Cache preheating)

定期生成这种缓存更新策略是不涉及到预热的,预热主要是针对实时生成的。

使⽤ Redis 作为 MySQL 的缓存的时候, 当 Redis 刚刚启动, 或者 Redis ⼤批 key 失效之后, 此时由于Redis ⾃⾝相当于是空着的, 没啥缓存数据, 那么 MySQL 就可能直接被访问到, 从⽽造成较⼤的压⼒.因此就需要提前把热点数据准备好, 直接写⼊到 Redis 中. 使 Redis 可以尽快为 MySQL 撑起保护伞。

热点数据可以基于之前介绍的统计的⽅式⽣成即可. 这份热点数据不⼀定⾮得那么 "准确", 只要能帮助MySQL 抵挡⼤部分请求即可. 随着程序运⾏的推移, 缓存的热点数据会逐渐⾃动调整, 来更适应当前情况。

  

 缓存穿透 (Cache penetration)

访问的 key 在 Redis 和 数据库中都不存在. 此时这样的 key 不会被放到缓存上, 后续如果仍然在访问该key, 依然会访问到数据库.

这就会导致数据库承担的请求太多, 压⼒很⼤.
这种情况称为 缓存穿透.

缓存穿透产生的原因:

原因可能有⼏种:
业务设计不合理. ⽐如缺少必要的参数校验环节, 导致⾮法的 key 也被进⾏查询了.
开发/运维误操作. 不⼩⼼把部分数据从数据库上误删了.
⿊客恶意攻击。

其中业务设计不合理是比较典型的一种情况。

一些解决缓存穿透的方案:
通过改进业务/加强监控报警。这个是属于一种亡羊补牢的方案。

更靠谱一点的方法有(降低问题的严重性): 

1.先对查询的参数进行严格的合法性校验。比如要查询的key是一个qq邮箱,那么就需要先校验这个key是否符合qq邮箱的格式。

2.针对数据库上不存在的key,可以将这个信息存储到redis上,比如将这个key的value值设置成一个"",也就是空字符(或者其它表示非法值的值),这样就能避免后续频繁因为这个key访问到数据库。

3.可以引入一个布隆过滤器,通过hash + 位图(bitmap)的一个数据结构可以以较小的空间和时间开销,来判定一个key是否存在。

缓存雪崩 (Cache avalanche) 

短时间内⼤量的 key 在缓存上失效, 因此很多请求就都打到数据库上了,导致数据库压⼒骤增, 甚⾄直接宕机。

 可能产生的原因:
1.redis直接挂了。 redis宕机 或者 redis集群下大量节点宕机。

 2.redis没问题,但是有大量的key同时过期了。有可能在短时间内缓存内设置了大量过期时间相同的key导致的。

解决方法:

1.加强监控警报,提高redis集群的可用性。

 2.不给key设置过期时间 或者 在设置过期时间时加入一些随机因子。

缓存击穿 (Cache breakdown)

缓存击穿相当于缓存雪崩的特殊情况. 针对热点 key , 突然过期了, 导致⼤量的请求直接访问到数据库上, 甚⾄引起数据库宕机。

 这里热点的key访问频率高,造成的影响更大。

 解决方法:

1.基于统计的方式发现热点的key,并且设置永不过期。

2.进⾏必要的服务降级. 例如访问数据库的时候使⽤分布式锁, 限制同时请求数据库的并发数。

服务降级可以理解为手机的省电模式,就是关闭一些不那么必要的功能,保留核心功能。

分布式锁在这里就是限制访问数据库的频率,对数据库起到保护作用。 

分布式锁 

 什么是分布式锁?

 在学Linux的时候,我们接触到过线程安全问题

关于线程安全问题:因为多个线程并发执行的顺序是不确定的,也就是具备随机性,我们需要保证线程程序在任意执行顺序下执行逻辑都是OK的。最常见的方案就是加入互斥锁,但是互斥锁的本质只是在一个进程内生效的,而在分布式系统中,这都不是同一个进程的问题了,这都不同主机了,那么这种锁肯定就是无效的了。

在分布式系统的场景下(卖车票场景):

 java 的 synchronized 或者 C++ 的 std::mutex, 这样的锁都是只能在当前进程中⽣效, 在分布式的这种多个进程多个主机的场景下就⽆能为⼒了.
此时就需要使⽤到分布式锁了。

 所谓分布式锁,其实也是一个/一组 单独的服务器程序,专门给其它的服务器提供“加锁”的服务。

如上图,这样买票服务器在买票前,会先申请一个分布式锁,申请成功了,才能买票成功。

Redis是一种典型的可以用来实现分布式锁的方案(就是上图的Redis服务器), 但不是唯一的一种,业界也可能采用 mysql / zookeeper 这样的组件来实现分布式锁的效果。

这里看似很简单,但是有很多的细节,接下来就开始介绍。

引入setnx

这个申请锁的过程其实就是服务器向redis申请设置一个key - value,如果key不存在,那么就设置成功, 也就是申请锁成功,反之就是失败了。

释放锁就是将之前set的key给删掉即可。 

而我们之前学过一个setnx,刚好就能应付这个场景:
setnx,如果key存在那么就会出错返回;如果不存在那么就设置成功。

这里就会出现一个问题:
setnx确实可以实现 “加锁”的效果,解锁就是用del即可。但是如果某个服务器加锁成功了,执行后续的逻辑时程序崩溃了(或者服务器都没了),也就是还没有执行到解锁操作,这样就导致了后续其它的服务器申请锁时会一直失败(失败后是放弃还是阻塞就看具体实现了)。

引入过期时间 

为了解决上述因为服务器在执行逻辑时,程序崩溃没有执行解锁操作,导致后续其它服务器申请锁一直失败的问题,我们可以给设置的key加入过期时间。

此时需要用的命令就是 set ex nx了,setnx不能设置过期时间。

注意:不要使用

setnx
expire

 这样的方式来设置带有过期时间的key,因为redis对于多个命令是无法保证原子性的(这里的原子性指不能像mysql那样可以回滚的原子性),所以务必要使用 set ex nx的方式。

引入校验id

所谓加锁,就是给redis设置一个 key-value。

所谓解锁,就是将redis上的这个key-value删除掉。

然而有可能会出现一种情况:
服务器1加的锁,被服务器2解掉了。

虽然正常来说,服务器2不会故意执行这样的操作,但是保不齐代码中有bug,这样可能就给整个系统带来更严重的问题(比如超卖)。

所以为了解决这个问题,就需要引入一些校验机制:

1.给每个服务器加上一个编号id,用于标识自己的身份。

2.进行加锁的时候,设置key - value的时候,key表示要对哪个资源加锁(比如列车车次),value则是这个加锁的服务器的id。

这样后续解锁的时候,就可以进行一个身份校验了。

 引入lua脚本

到这里,我们知道解锁的时候大致分为两步:
1.先进行身份校验(向服务器查询id)。

2. 再进行del解锁。

这里顺便提醒一下,进行身份校验的工作一般也是在服务器上完成的。

可见这里的解锁操作并不是原则的,在一个服务器内部往往会有多个线程,也就是说同一个服务器,可能会有多个线程同时在解锁。

如上图,这样就可能会出现del被重复执行,也就是重复解锁,如果在两次解锁期间,有一个线程执行了一次加锁操作,那么这个锁可能立马就被解掉了。

这个问题归根结底还是因为get和del 不是原子操作导致的。

我们可以使用redis的事务解决上述问题,虽然redis的事务是弱原子性的,但是能避免插队。但是还有一个更好的解决方案:就是使用lua脚本。

 在redis的官方文档都说使用lua是事务的替代方案。

Lua 的语法类似于 JS, 是⼀个动态弱类型的语⾔. Lua 的解释器⼀般使⽤ C 语⾔实现. Lua 语法
简单精炼, 执⾏速度快, 解释器也⽐较轻量(Lua 解释器的可执⾏程序体积只有 200KB 左右).
因此 Lua 经常作为其他程序内部嵌⼊的脚本语⾔. Redis 本⾝就⽀持 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 服务器以原⼦的⽅式来执⾏

引⼊ watch dog (看⻔狗) 

  之前说过可以给key引入一个过期时间,这样就能防止因为服务器挂掉却没有及时释放锁的问题。

  然而这里仍然存在一个重要的问题,那就是当这个key过期以后,这个任务可能还没有执行完,这样就导致锁提前失效了。

这里更好的方式就是“动态续约”。我们可以为这个服务器专门搞一个线程,负责续约这个问题。

这个线程就称为 看门狗 。

假设我们给这个key初始设置1s后过期,当过期时间只有300ms(数值可以灵活调整)的时候,如果当前任务还没有完成,那么就把过期时间加上1s,如果还没完成就继续续约。

如果此时就算服务器崩溃了,那么也会在短时间内把锁释放掉。

 

引⼊ Redlock 算法  

即便已在这么多的方案的加持下,还是免不了一些极端的情况。

实践中的 Redis ⼀般是以集群的⽅式部署的 (⾄少是主从的形式, ⽽不是单机). 那么就可能出现以下⽐较极端的⼤冤种情况:

 

主节点和从节点之间的数据同步是有延迟的,假如redis收到了 set加锁的请求,但是主节点还没来得及同步给从节点,自己就挂了,即使从节点升级称为了主节点,但是刚刚加锁的数据还是不存在了。为了解决这个问题, Redis 的作者提出了 Redlock 算法。

 此时的redis就不是一组redis了,而是引入了多组redis,每组redis都包含了一个主节点和多个从节点,并且组和组之间的数据都是一致的,互相为一个“备份”的关系,目的就是为了备份数据(注意这里不是集群)。

加锁的时候, 按照⼀定的顺序, 写多个 master 节点. 在写锁的时候需要设定操作的 "超时时间". ⽐如
50ms. 即如果 setnx 操作超过了 50ms 还没有成功, 就视为加锁失败。
如果给某个节点加锁失败, 就⽴即再尝试下⼀个节点.
当加锁成功的节点数超过总节点数的⼀半, 才视为加锁成功

 

如上图有5组redis,其中有3组加锁成功了,那么就视为加锁成功。 

这样即便有些节点挂了,也不影响锁的正确性。 

同理, 释放锁的时候, 也需要把所有节点都进⾏解锁操作. (即使是之前超时的节点, 也要尝试解锁, 尽量保证逻辑严密)

 

 小总结:

这里介绍的只是一个简单的“互斥锁”。

这里对redis的学习就告一段落了,如果还想继续深入学习redis可以阅读redis的源码

 

 

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部