来看看缓存

入门

什么是缓存?

缓存时对原始数据的一个复制的副本,我们一般将数据缓存起来以便于备用,常常用来加速读取

缓存有什么用?

缓存一般用来提升读取的速度,优化系统的吞吐量
以我们的java和mysql之间进行交互为例子,我们从mysql中读取数据,需要进行IO,在这里先忽略mysql自己的Buffer pool中的缓存,把mysql的每次读取都看成一次IO,那么当我们多次查询同一个数据的时候,需要进行N次IO,IO性能有限,所以我们引入本地缓存或者分布式缓存,不再从磁盘上去进行IO,而是从内存中读取,内存读取比IO普遍要快,对于相同的请求,能有效减少IO次数,减少带来的资源消耗,同时加快读取速度,提高系统的吞吐量

然后来讲讲常见的缓存算法

基础

缓存算法

  • LRU:最近最少使用
    - LRU:最近最少使用
    - LRU K:最近使用过K次
    - Two queues:两个队列,一个是FIFO队列,一个是LRU队列
    - Multi Queues:根据访问频率将数据划分成多个队列,不同的队列有不同的访问优先级,防止冷数据将热数据顶出
  • LFU:根据数据的历史访问频率来淘汰数据
    - LFU:根据数据的访问频率淘汰数据
    - LFU *:只淘汰访问过一次的数据
    - LFU-Aging:除了访问次数以外,还需要考虑访问时间
    - Window-LFU:记录过去的一段时间窗口内的访问历史
  • FIFO:先进先出
    - FIFO:先进先出
    - Second Chance:如果被淘汰的数据之前被访问过,给予第二次机会
    - Clock:通过一个环形队列,避免数据在FIFO队列中移动

我这边介绍一下LRU算法和LRU-K算法,因为两个都有较多的实现,方便结合例子讲解,

常用的缓存算法

LRU算法

LRU算法会根据数据的历史访问记录来进行淘汰数据,如果数据最近被访问过,那么将来被访问的概率也很高
下面来看看它的实现思路和步骤

  • 1)新数据插入到链表的头部,因为是最近就用过
  • 2)每当数据被访问过,因为是最近用过,将数据放到链表的头部
  • 3)当链表满的时候,将尾部的数据丢弃
    主要就是看是不是操作过,我们的链表头部用来存放最新的数据,尾部放老数据,然后操作过就会放到头部
    我们可以利用JAVA中的LinkedHashMap来简单实现这个LRU缓存

LRU简单描述

这个算法简单但是实用性很高,大部分的缓存场景都能使用这个算法,但是,在特定的场景下,热数据可能会被冷数据顶出LRU缓存。
以mysql为例,mysql为了加速查询,并不会每次查询都去进行IO操作,而是会使用自己内部的Buffer Pool,用来存放我们最近访问过的数据,LRU结构的节点是Mysql中的页,那么问题来了,现在我们多次查询了A数据,然后A数据已经是最热的了,放在队头,然后假设容量是N,如果此时使用的是我们原来的LRU算法来淘汰页面,那么如果这个时候假设我们不管是有意还是无意进行了一次全表扫描,而且这个数据量大于了我们的容量N,会发生什么结果呢?
我们的热缓存A被顶出了缓存,而且整个LRU缓存中存放的是我们的全表扫描的页,然后之后我们去查询A的时候又得去进行IO
这样的代价很大,我们的热数据被顶出去了,然后同时大量的请求来查询这些热缓存,需要继续一一的进行IO,然后回写Buffer Pool,加载我们的页,进行缓存重建,这样性能大大降低
有什么解决办法呢?可以将LRU改成LRU-K

LRU-K算法

热数据队列:负责存放我们的热数据,也就是真正访问的多的缓存
冷数据队列:防止我们的全表扫描导致出现的热数据污染问题
LRU-K算法基本和我们的LRU一致,但是会多了一个冷数据队列,然后K的意义是最近使用的次数,只有当我们的访问次数大于了K以后,才会加入到热数据队列,否则一开始是加入到我们的冷数据队列中的,只有访问量大于了K,才会升级到我们的热数据队列中
这样就能保证我们的热缓存数据不会被冷数据给污染,我们的mysql就是采用的LRU-1的算法来防止冷热数据污染,它的K是1,代表如果全全表扫描一次的数据是不会加入到热缓存中去的,从而保证不会出现数据污染
在这里插入图片描述

在mysql官网中提供的冷热链表的模型图,这个冷热链表可以理解为LRU-1,

1)3/8的list信息是作为old list,这些信息是被驱逐的对象。

2)list的中点就是我们所谓的old list头部和new list尾部的连接点,相当于一个界限。

3)新数据的读入首先会插入到old list的头部。

4)如果是old list的数据被访问到了,这个页信息就会变成new list,变成young page,就会将数据页信息移动到new sublist的头部。

5)在数据库的buffer pool里面,不管是new sublist还是old sublist的数据如果不会被访问到,最后都会被移动到list的尾部作为牺牲者。
在这里插入图片描述

数据一致性

概述

在大型系统中,我们通常会依靠到分布式缓存中间件,例如redis之类的,但是当我们接入了分布式缓存后,就需要注意我们的分布式缓存和我们的数据库之间存在的数据一致性问题了,例如,当我们不同的缓存读写策略可能会影响到缓存和数据库中的数据,例如缓存中的是脏数据,数据库的是新数据,两份数据不一样。当然不止分布式缓存和数据库数据之间会出现一致性问题,当我们接入了越多的中间件,系统变得越复杂,出现数据一致性问题的概率会上升,在这里,我们以分布式缓存+数据库为例,先来了解三种数据一致性的方案

1、强一致性

强一致性又叫做线性一致性,即,是同步的或者说是原子性的,举个最简单的例子
我们线程A,线程B都要对同一份数据进行修改,在不做任何情况的操作下,我们可能会出现一下的情况,线程A修改了数据库,然后拿着自己的数据准备修改缓存,但是自己此时时间片到了,之后轮到我们的线程B来进行执行,线程B同时执行完了数据库修改和缓存修改,结束了自己的线程,然后又到了A,A此时会覆盖B的缓存数据,然后就出现了数据库和缓存之间数据不一致的情况,数据库里的数据是B,缓存中的数据是A
如何用强一致性去解决呢?最简单的方法就是加锁
我们在操作数据库和缓存的时候需要获取到锁,获取到锁以后才能去进行更新,例如

	加锁
	写数据库
	写缓存
	解锁

这样的话就能保证不会出现数据库和缓存之间出现不一致的问题,但是,这个如何在分布式环境下使用?
将本地锁改成分布式锁,例如Redisson中的红锁
如果线程A的数据比B的要老,但是线程B先抢占到了锁,先更新完了数据,怎么办?
我们增加一个版本号,可以是全局时间戳,也可以是全局的一个自增序列号,以新版本的数据为基准,判断需不需要继续更新数据
如果数据比自己的版本号小,可以更新,如果数据的版本号比自己的要新,选择不更新,然后丢弃数据,将新数据返回

优点:能有效保证对实时性要求高的数据一致性的要求
缺点:需要加锁,尽管能依靠降低锁粒度来尽量减少锁冲突的出现,但是频繁的加锁,释放锁还是会带来一定的性能影响

2、弱一致性

弱一致性在个人感觉像是异步,系统并不保证进程或者线程的访问都会返回最新的值,在写入数据成功后,不承诺立即可以读到,也不承诺一定会读到,甚至可能会出现数据丢失的情况或者读到旧数据的情况
我们强一致性去做比较,当我们在进行一个数据存储的操作,向一个中间件发起请求,此时分别有主和从的中间件
当我们对主进行写入成功后,强一致性会等到我们的主库向从库写入成功后返回,而弱一致性写入主库后就返回,不管从数据中有没有写入成功,这里就体现了一个异步的过程,异步能有效保证速度,但是不能保证数据准确和一定。

3、最终一致性

这个是用的特别广泛的一种一致性方案了,大部分情况下,缓存和数据库之间并不需要保证强一致性,所以可以利用补偿的机制,保证最终一定能达到数据间的一致性
例子1:FaceBook的案例,更新数据库后,将需要删除的key交给一个McSqueal,这个McSqueal主要是负责读取commitLog,然后解析我们的Sql之后,广播到我们的MC集群,通过这样一种异步删除缓存的模式,当有下一次读取请求的时候,如果没有数据,需要从数据库里查询数据同时更新到缓存集群
例子2:京东的话是采用canal这个中间件,canal是一个通过将自己伪装成Mysql从节点,然后不断去请求mysql主节点的binlog的一个中间件,我们依靠canal订阅我们的mysql的binlog,然后监听到数据变更后,负责更新我们的缓存

上面两种其实原理都差不多,都是订阅mysql,不过一个是订阅binlog一个是订阅commitlog,然后去让资源同步组件来进行同步资源,同步组件保证最终一致性,用户线程就不需要进行同步的资源开销。

注意对于更新的时候可能会遇到的问题

  • 1、更新数据的时候需要利用版本号或者时间戳来进行判断,因为是分布式的,可能会出现网路抖动的问题,导致请求顺序不对,然后新数据被老数据覆盖
  • 2、可以将更新请求散列到多个队列,然后每个队列对应一个单线程,负责不断的自旋然后更新数据,这样能不需要锁的帮助下实现线性

上面介绍了数据一致性的问题,下面来谈谈缓存更新需要用到的几种常见策略

缓冲使用策略

一、Cache-Aside

这种使用策略就是在业务代码中管理维护缓存,某些缓存中间件没有关联缓存和存储的逻辑,就只能依靠业务代码来进行实现了
首先是读场景,先从缓存中获取数据,然后如果没有命中,就准备查询存储层,并将查到的数据放入到缓存,
然后是写场景,先将数据写入到存储层,写入数据后将数据同步写入缓存层,
优点:能利用数据库比较成熟的高可用机制,当写入成功后,就可以进行缓存数据更新,写入失败,就会进行重试

二、Cashe-As-SoR

SoR是记录系统,就是存储原始数据的系统,这个策略其实就是将缓存当成Sor来使用,我们的业务代码只对Cahce操作,然后对于同步数据库的操作在Cache中实现,也就是说,我们只需要对Cahce进行API操作,对于同步数据的操作交给我们的Cache实现,和上面的旁路缓存不一样,具体实现是下面的read-Through(穿透读),write-Through(穿透写),Write-Behind(后写入)三种实现

三、Read-Through

穿透读,在这个策略下,我们的业务代码在调用get方法获取数据的时候,如果有返回,就先访问缓存,如果没有,就从数据库加载,然后放入缓存,和旁路的逻辑基本一致,只不过查询数据库的操作都封装到了Cache中去了,我们只需要访问Cache,之后的操作交给我们的Cahce来进行

四、Write-Through

穿透写,在这个策略下,我们的业务代码先调用cache的写操作,然后实际上由我们的Cache进行更新缓存和存储数据的操作,完成后返回

五、Write-Behind

后写入,在这个模式下,业务代码只更新完我们的缓存,对于更新数据的操作,需要由我们的Cache的落盘机制决定,最终一致性

六、Refresh-Ahead

业务代码访问数据时,仅调用cache的get操作。通过设定数据的过期时间在数据过期时,自动从数据库重新加载数据的模式,进行续命,此模式相较于 Read-Through 模式的好处是性能高, 坏处是可能获取到非数据库的最新数据。在此对数据精度有一定容忍性的场景合适使用。

缓存类型?

注意,我下面的例子是以我个人做的一个系统为例,在我的系统中涉及到的缓存点,可能和你们的见解不一致,不会很准确,其实还会有像浏览器缓存,客户端缓存等等

  • CDN缓存
  • 反向代理缓存
  • 本地缓存
  • 分布式缓存

我们分别来看看这四个缓存

CDN:内容分发网络,用户端优化的常见手段就是CDN,我们依靠CDN来缓存静态资源,
反向代理缓存:反向代理位于应用服务器机房,处理所有对 Web 服务器的请求。以nginx为例,在我们的nginx中设置缓存,因为我们的请求会比较早的砸中我们的反向代理,如果能在反向代理层就返回,能减少很多其他系统的资源的消耗,也能直接返回我们的静态模板,但是缺点很明显,缓存内存不能设置太大,能设置的缓存大小有限,
本地缓存:在系统本地的缓存,常见的有我们的ecache,guava cache,优点是在应用内,访问不需要过多的网络开销,而且在本地,能自由的管控,速度和其他相比,速度应该是最快的,因为没有过多的网络消耗,但是缺点是占用应用内存,同时不易扩展,能使用的内存有限,同时,会有GC时被清理的风险,受到JVM制约,同时,不能被其他应用节点共享,如果不加处理,可能缓存不命中概率大,所以,一般需要进行一些路由操作,保证本地缓存能有用武之地

分布式缓存:远程的缓存,常见的像redis,mc这一类的,以redis为例,redis自身高性能,支持集群,能够水平扩展以及自动故障转移等功能,这些优秀的高可用机制能解决我们的本地缓存内存大小有限,不易扩展等缺点,能让所有应用共享缓存数据,共同读取缓存

上面是我们的一些基本的概念,然后看完概念后,我们来详细展开讲讲

分布式缓存

概述

分布式缓存是和我们的本地应用相互分离的组件,是一个独立的组件,不受我们的本地应用控制,相互隔离,同时可以让多个应用共享
一般用来存放我们经过复杂运算后得出的数据,以及频繁访问的热点数据,减轻数据库的压力
目前流行的开源中间件有Redis,Memcached
分布式缓存和本地缓存相比有哪些优点?

  • 分布式缓存独立于我们的本地应用,不需要占用我们的JVM内存,但是本地缓存会占用内存
  • 分布式缓存支持水平扩展,我们只需要进行横向扩展就能大幅提升内存容量,但是本地缓存受到系统限制,需要进行纵向扩展
  • 分布式缓存不会受到GC影响,本地缓存会受到GC影响,例如STW导致活动线程被暂停,无法提供服务
  • 分布式缓存自带高可用机制,例如redis的哨兵,集群等,本地缓存只有自己本地拥有

分布式缓存可以说是提升系统吞吐量的好帮手,是重点所在,我们常见的分布式缓存有Redis和MC,接下来会介绍分布式缓存架构的进化,会以redis作为例子,先不加入限流

首先是单机架构

单机架构宕机了怎么办

当我们只有一台redis机器的时候,当我们的请求过热,导致我们的Redis缓存中间件挂了,然后我们的请求全部都去查询数据库了,导致数据库负载高,甚至直接宕机
我们可以采取热备的方式,利用Redis提供的主从复制模型,增加一台或者多台Redis的备用机,同步我们的父节点的数据,我们也可以负载我们的读请求,降低我们的主Redis节点的压力。当我们的Redis挂掉后,切换到我们的备用Redis,然后等待我们的Redis加载RDB或者AOF重新启动,切换从节点这个热备来作为主节点

主从模式下,如果我们的缓存数据太多了怎么办?

我们可以采取集群的模式,进行水平的扩展,和水平扩展相对立的就是我们的纵向扩展,纵向扩展需要硬件的支持,提供更大内存的硬件,所以我们能选择水平扩展的方式,降低成本,我们可以设立多个主节点,然后每个主节点都有一定量的从结点,然后利用哨兵机制来集群健康状况的探测,实现故障转移,这样,我们扩展了内存大小,同时,每台主节点都能进行写操作,也能提升集群的负载能力

集群模式下,如果出现突发事件,导致某一个节点或者一个缓存的请求特别热,系统出现了瓶颈怎么办?—— L1-Main-HA级缓存

我们假设现在有一个特别热点的数据或者事件,然后很多人都来请求这个,然后假设在我们的集群Cluster模式下,然后因为是集群,数据利用哈希槽分散到了各个主节点上,然后读取的时候读取的是我们的对应的节点上的数据,然后也就是类似于一主多从,然后因为请求很多,导致我们的这个主节点和从节点都宕机了或者出现了其他问题,同时影响了这个节点上存储的其他的缓存数据,然后去读取到了DB,导致系统瓶颈的出现
我们可以增加一层L1缓存层,专门用来存储被探测到的热点事件,我们可以来看看微博的 Main-HA-L1 架构的演进
一开始采取的是哈希分片存储在MC中间件中,然后出现了节点宕机,请求穿透到DB去了
然后在哈希分片的基础上,增加了一个HA层,当我们的Main层出现了节点宕机,请求穿透到HA层,不会穿透到我们的DB层
然后是L1缓存层,因为微博上经常会出现一些热点新闻,然后被推送到的人有概率点进去,然后瞬间的请求骤增,导致我们的系统出现瓶颈

下面是微博的L1-Main-HA的缓存结构
在这里插入图片描述
引入L1层,L1层主要用来存放我们的热点缓存,然后当请求访问到了我们的L1层并获取到数据后,L1层可以有多个,进行负载均衡,降低读压力,直接返回即可,热点的请求由我们的L1消费,大大减少Main层的负载,每个L1大概是Main层的N分之一,这部分需要存储相对较热的数据,我们可以创建一个缓存探测服务,当需要修改数据的时候,对L1和其他的缓存进行多写来达到我们的数据一致,读的话依赖于多组L1缓存,读负载均衡,然后返回,降低流量峰值

上面这个是对我们的分布式缓存架构演进的一些描述,然后来看看我们在分布式缓存中常常会遇到的一些问题

分布式缓存中容易遇到的问题
缓存穿透

假设现在我们的缓存A现在已经失效了,然后一堆请求想要来查询我们的缓存A,然后都没有查询到,然后都去查询了数据库,导致我们的数据库的负载一下被拉高

解决方案:
1、对结果为空的数据也进行缓存,优点:简单,便利。缺点:可能会占用大量内存
2、利用布隆过滤器,对结果进行多次散列,然后查看我们的bitmap下标处是不是为特定数据,没有的话代表一定没有其他人重建缓存,但是三个都有的话也可能有误差,不过忽略不计,优点:省内存,计算高效。

缓存雪崩

缓存雪崩是由于缓存大批量在一个时间断内失效后,导致的系统性能下降的情况,因为缓存失效后,系统需要重建缓存,大量的缓存需要重建,然后这些请求会先去进行计算或者查询数据库,导致的系统吞吐量下降,大量线程也被用于去重建缓存,然后可能会导致资源紧张

解决
1、设置缓存生存时间随机值
2、利用后台线程去进行更新缓存,而不是业务线程,业务线程发现缓存失效后,发送消息给后台先去更新缓存

缓存击穿

请求过于热点,大量的请求去查询一个缓存,然后导致我们的缓存服务挂了,甚至之后影响到了我们的DB,
解决办法,我们从事前避免,事中处理,事后恢复三个方便来讲

事前避免
1、多级缓存,依靠多级缓存机制,快速进行读取缓存并返回,缩短调用链,增大系统吞吐量,这个之后会讲
2、分布式缓存高可用,像我们上面的介绍的L1-HA-Main缓存架构,同时,配置上一些高可用机制,例如自动故障转移,快速恢复,保证缓存服务能提供服务
3、设计缓存探测服务,利用缓存探测提前预知到我们的热点缓存,并且加载到我们的多级缓存中去,进行缓存预热,同时对这些热点数据需要降级为轮询
4、多机房部署

事中处理
1、限流,减少请求突发的流量
2、降级,当发现某个请求特别热点的时候,可以利用降级机制修改负载均衡策略,例如由一开始的一致性哈希转为轮询,将每台机器的流量打散,负载到每个机器上,此时每台机器上都需要存储一份热点数据,降低一开始的热点查询导致的节点宕机问题。
事后处理
1、此时假设分布式缓存已经挂了,我们需要立即进行快速恢复,然后继续运行
2、利用熔断和降级机制以及访问资源隔离,因为此时分布式缓存已经挂了,此时去访问的话,如果不加处理,大概率会引起我们的超时问题,但是因为超时判断是需要时间的,我们的线程会一直在请求,然后资源被占用,所以,当触发了超时,或者一定程度的异常,不再去查询分布式缓存,改为去查询本地的一些数据,并且拼装成兜底数据作为结果返回,这个兜底数可能不全甚至不是期望的数据,但是至少能不影响到业务资源,例如线程资源等,因为前面大概率触发的超时,会导致线程资源一直请求,然后导致这一块的线程都耗尽了,如果没有资源隔离,系统可能会响应不了请求,或者直接卡死

缓存大Key和大value问题

很容易出现的问题,当用户操作了程序后,输入的数据太大了,如果进行了查询,并存储到缓存中去了,如果没有进行处理,每次查询的时候就会占用大量的带宽资源,导致网络堵塞等问题

解决方案:
1、使用多线程的缓存中间件,例如MC。
2、MC会把数据给切片,然后利用多线程去查询数据,最后聚合,我们也可以仿照,将数据进行一定程度上的切分存储,然后再根据特定规则进行聚合。
3、将数据进行压缩,进行压缩后执行插入,缺点是繁琐,而且很浪费性能
4、限制大小。

更新缓存与原子性

在我们的分布式环境下,多个应用同时操作一份缓存数据时很常见的,这就有很大可能会出现更新导致的数据不一致,以及脏数据等问题
例如:现在有AB两个线程,A的数据比B旧,但是因为网络波动等原因,导致缓存中间件先收到了B的请求,然后执行,在这之后收到了A的请求,然后执行完成后,将数据A覆盖了B的数据,导致缓存中的数据是一份旧数据

解决办法:
1、利用时间戳或者版本号,在更新的时候判断版本号谁更新一点
2、利用canal订阅数据库的binlog,然后异步更新我们的redis
3、将更新的请求根据一个唯一的参数同时利用一致性Hash路由到多个队列中,然后每个队列对应一个线程,作用类似于排队,但是多个队列多个线程,可以提升效率,注意,这个需要在进入本地应用前也进行路由,确保同一个唯一ID一定会路由到同一个本地应用。
4、加分布式锁

热点缓存

在某一时间段突然流量暴增的key,例如出现的热点新闻,然后一堆人查询,就像上面的缓存击穿一样,缓存击穿也是因为热点缓存没有处理好然后出现的事故,其实两者一样,但是在这里会说的更详细一点,之后会出一个小章节来介绍这个缓存探测的具体实现的

解决方案:
1、像上面的缓存击穿一样,建立实时缓存热点探测服务,负责探测我们的热点,然后推送到多级缓存中去
2、多级缓存,多级缓存中包含了我们的反向代理缓存,分布式缓存,本地缓存,举个例子,现在有一个请求请求访问,我们先砸到反向代理上,先去查询我们的反向代理的缓存,然后去查询本地缓存或者分布式缓存,然后再去查找分布式缓存或者本地缓存,这两个其实可以反着来,需要根据业务来看,如果对网络资源紧张,可能就需要访问我们的本地缓存,找到直接返回,不会进行额外的远程访问的资源消耗,如果对内存紧张,可能需要先去访问分布式缓存。

接入了上述方案后,当更新缓存的时候,我们需要多写,反向代理缓存,分布式缓存,本地缓存等都需要进行更新,读的话是多层读

缓存命中率

命中率就是看我们的缓存是否查询到了,就一般普遍理性而论,缓存失效时间设置的越大,可能命中率会有效提升。然后讲讲命中率除了这种方法之外的办法----负载均衡策略

假设现在有ABCD四台本地缓存,同时他们的缓存大小都为1,只能存储一个key,以本地缓存为例,方便讲解,假设IP地址分别为a,b,c,d,这里用字母来表示,方便书写。

现在我们采取随机访问的形式
我们请求key四次,假设现在请求运气好,都分散在了四台机器上,现在四台机器上都有了一份缓存,
然后又来了请求key共2次,请求key2共2次,因为是随机访问两次,key2分别砸中了A,B。key砸中了A,C,现在会出现什么样的结果呢?key2执行缓存查询,剔除掉A中原来的key缓存,然后存储key2缓存,然后B更新成key2,然后key又来请求A,把刚刚存储的,而且还没使用的key2缓存给剔除去了,然后设置成key
上面的流程中,我们的A机器,先后设置了Key,然后key没被访问,被剔除了,插入Key2,然后key2没被访问但又被剔除,key插入
可以看到,我们上面的缓存A和没用一样,因为查询都没有查到,还反复进行了更新操作,这个就是典型的缓存命中率带来的一个问题,浪费内存,同时没有起到作用,典型的占着茅坑不拉屎

有什么解决办法呢?

我们注意上面的请求特点,和接口以及key值有关,然后主要是想要他们请求和key值最好是能负载均衡访问到同一台机器上,这样就能保证不会有浪费情况,变相的能够支持存储更多的数据。

如何让同一个接口以及同一个参数的请求负载到同一台机器上去呢?
我们可以使用Hash,我们的HashMap使用Hash算法将同一个对象的hashcode会定位到同一个下标中,我们可以将接口+请求参数构建成一个字符串,然后对这个字符串进行Hash,我们把所有的机器看作一个数组,定位到同一台机器上

如果此时一台机器加了一台或者挂了一台怎么办?
此时挂了以后,我们的请求Hash后还是得到的挂掉的那个下标,但是不是原来的机器了,怎么解决?
将Hash算法替换成一致性Hash算法,一致性Hash算法用于解决分布式缓存系统中的节点选择和在增删服务器后,节点减少带来的数据缓存的消失与重新分配问题,主要的特点是环状,主要的特点就是Hash环,我们的请求可以构建成一个Hash环,按照顺时针记录hash和请求
当我们的服务挂了A时,我们只需要将A的请求交给A后面的B处理,当我们需要增加服务器C时,我们只需要在Hash环上划一块范围,然后交给C,这样就可以实现动态的扩容和缩容,同时可以增加虚拟节点,来让请求更均匀

这样,替换成一致性Hash算法后,当请求来了,每个请求都会访问到自己的那一台机器上,然后不会造成内存浪费,也能进行动态的扩容与缩容

缓存维度化与增量缓存

对于数据应该按照维度和作用进行维度化,这样可以分离存储,进行更有效的存储和使用。

缓存和业务之间的关系

缓存的数据类型
KV结构

用的很多的缓存结构,这种KV结构类型的数据的特点是直接取出来就行,不需要进行其他的额外的计算
我们读的话进行一致性Hash路由,没有缓存就到一层去,写的话需要根据是不是热点数据来进行写,毕竟一部分不是热点数据,不需要加那么多,采取多组L1,提升读取性能,降低峰值流量

集合类结构

集合类数据的话多见于像关注,群组,粉丝,收藏等业务,数据结构特点是部分修改,分页获取,联动计算。

例如:
1、我不关注你了,那么你的集合中需要删除掉我,这就涉及到部分修改。
2、获取一个人的粉丝,这个人可能有几万粉丝,但是不需要一次性全部查询出来,我们需要进行分页获取。
3、联动计算的话例如共同关注,获取你和我两个人的并集等。
这些操作和KV相比,都要复杂一点

这些场景下需要利用缓存中间件进行Hash分片存储,例如Redis,Redis有着比MC更丰富的类型,同时支持水平扩展,我们可以利用这些特性进行水平扩充,然后为了提升读性能,需要多组机器,进行负载均衡。

业务类型
判断是否存在

这种业务的话主要是判断是不是已经存在了,例如有没有点赞等。
特点是:每条记录非常小,但是总数据量巨大
现在发表帖子,有的帖子的点赞量是1000,有的是10000,但是大部分是0,这个0需不需要进行存储?
如果存了,如果很多记录的情况下,我们这个判重需要每个人都进行存储,总数据量大,开销有点多

利用Redis的话,单条KV在65bytes,因为Redis为了支持多种编码实现,以及其他的操作,增加了大量的指针,但是对于业务来说,这些可能是根本不需要的,我们只需要1个bit就能判断,相比之下差距太大

然后来看看微博的解决方案
微博自研的Phantom
先看看优点,每条KV只占用1.2bytes,存在1%的误判
主要思想是采用把共享内存分段分配,最终使用的内存只用 120G 就可以。
1G=85 8993 4592 bit(85亿数据)
120G=1 0307 9215 1040 bit(万亿级)

算法就是利用布隆过滤器,对每个key进行N次哈希,然后判断这些下标是不是都是1,不是的话代表一定没有点赞,都是1的话代表可能有这条数据。
总结,可能有误差,但是可以容忍,浪费空间少
在这里插入图片描述

计数

来看看计数的业务特点,单条key可能有多个计数,例如一条帖子有点赞量,有浏览量,评论数等各种数字,对于计数来说,这个value一般预估不会超过全球的总人口,70来亿,一般存储数字来说,2-8个字节就够了,当我们请求一个帖子的时候,要查询多个计数,查询的场景耗费大
使用MC的话,如果大量计数为0,存储的话浪费空间,不存储的话不清楚究竟是0还是说单纯的被淘汰了
使用Redis的话,存在访问性能,毕竟是单线程的,虽然支持读写扩展,但是终归有限

来看看微博的解决办法:
微博自研了Counter Service

  • 支持多列,按照bit分配
  • 双层Hash寻址
  • 冷热分离,热数据放入LRU,冷数据放入SSD硬盘
  • 落地RDB+AOF
  • 全增量复制
    如果使用MC,当计数超过容量,就会导致一些计数的剔除,宕机或者重启后就没有了
    很多计数为0,存储0都需要占用大量内存,不存储的话就会去DB中查询,降低性能
    如果采用Redis,当数据量多了以后,内存浪费非常严重,Redis存储一个KV最小需要65byte,但是我们只需要记一个数据,差不多12字节就够了,而且进行冷热分离,热数据存在内存里,冷数据如果重新变热,就把它放到 LRU 里去。

在这里插入图片描述上面是存储架构,上面是内存区,下面是SSD,在内存中会预先分配成N个桶,每个桶工具IOID的指针序列,画出一定范围
当我们的一个需要修改的请求过来了,先去寻找桶,这个桶里面如果有数据的话,直接进行操作,如果有新的计数需求,需要进行插入,内存不够,会将一个小的桶复制到SSD中,留着新的位置放在最上面供新的请求来使用
如果超过了4个字节,通过Aux dict进行存放。
对于存在于SSD的桶,然后会有一个IndAux作为索引,指向SSD中的数据,类似于一个索引

看到了上面的根据业务和数据类型的介绍,可以看到我们的业务中使用的分布式缓存可能很乱,有些场景可能使用MC更好,有些可能使用Redis更好,但是如果一个系统可能同时接入了两个不同的缓存中间件,会不会显得太臃肿了,同时多级L1缓存是不是要我们自己在自己的业务代码中接入,接入了这个L1缓存后,路由怎么办,业务代码中添加?我们可不可以将缓存给集中起来,对外提供一个单独的接口,屏蔽上述的麻烦问题,然后调用者只管调用就行了,然后像具体的路由,选择等交给我们的这一个中间代理层来实现,同时,依靠这个中间代理来进行缓存资源监控报警,水平扩展,配置拉取与修改,服务治理,自动路由等功能

项目介绍

我的项目是一个商城系统,个人从几年以前开始写的,然后一直在更新,参考了别人的结构,然后在自己的项目中进行了实现,主要是采用了反向代理缓存+分布式缓存+本地缓存的三级缓存架构,其实实际上可能不止三层,因为分布式缓存是算的一层,但是其中有很多地方是有自己的缓存的,但是主体上分成三层,

三级缓存架构

反向代理缓存

我这边使用的是nginx作为我的项目反向代理的web服务器,因为反向代理能设置的缓存有限,所以一般是用于存储热点key,以及秒杀的商品信息,缓存大小设置的是64M,会有缓存探测服务去更新这个反向代理缓存。
在这里插入图片描述
首先来看DNS负载均衡和LVS负载均衡
DNS大家应该有了解过,这个是一个域名转化协议,一般用于域名转化成IP地址,因为IP地址不方便记忆,所以推出域名,每台主机都有自己的一个IP,我们可以通过DNS解析,解析到距离自己最近的机房结点,降低远距离通信带来的网络带宽消耗
LVS的话是一个Linux虚拟服务器,在Linux内核中实现了基于IP的数据请求负载均衡调度方案终端互联网用户从外部访问公司的外部负载均衡服务器,终端用户的Web请求会发送给LVS调度器,调度器根据自己预设的算法决定将该请求发送给后端的某台Web服务器,比如,轮询算法可以将外部的请求平均分发给后端的所有服务器,终端用户访问LVS调度器虽然会被转发到后端真实的服务器,但如果真实服务器连接的是相同的存储,提供的服务也是相同的服务,最终用户不管是访问哪台真实服务器,得到的服务内容都是一样的,整个集群对用户而言都是透明的。最后根据LVS工作模式的不同,真实服务器会选择不同的方式将用户需要的数据发送到终端用户

这里因为我是主攻后端的,对这些东西掌握有限,所以不会做特别深入的了解,大家可以理解成一层负载均衡

然后就到我们的Nginx了,我这里的Nginx总共设计了两层,一层用于流量分发,一层用于传统的应用服务器,流量分发很容易理解,将流量按照特定规则进行分发到不同的应用服务器上,就是负载均衡

对于普通请求,默认规则是进行一致性hash,一致性hash之前也简单的介绍了,简单理解就是hash的升级版,同一个请求同一个参数会达到同一台主机,然后支持动态缩容与扩容,当我们在流量分发层判断出我们的请求并不是热点的请求,执行一致性Hash算法,我们将同一个请求分发到同一台机器上,可能这个应用层的nginx会有这个请求的缓存,找到了就能够直接返回了,就不需要再经过反向代理去访问我们的应用了,缩短调用链

对于热点请求,规则就需要修改成进行轮询了,当我们在流量分发层判断了我们的请求是一个热点的,我们就将负载策略修改成我们的轮询,降低原本这个接口服务器的压力,分散到各个服务器上,保证应用不会崩掉

我这里是依靠的lua脚本做的,利用ngx.req.get_uri_args()方法获取到我们的uri的请求参数集,然后拿到每个请求参数,然后合成我们的key,查询这个key是不是热点缓存,是的话就利用轮询机制,构建HTTP请求负载到机器上,不是的话就利用一致性hash获取到我们的机器然后构建HTTP请求,注意,这个脚本的执行需要在nginx中进行配置,定义接口的执行脚本是这一个lua脚本

然后是到我们的应用层nginx,我们的应用层nginx在对应到接口后,执行Lua脚本,会获取到uri参数,然后从我们的热点缓存中获取,直接返回,如果没有的话就会执行到我们的本地应用上去,本地应用会先去访问分布式缓存

分布式缓存

为了屏蔽不同缓存中间件的差异,这边会抽出一个Proxy的代理层,统一对外接口,我们只需要访问这个Proxy就行了,Proxy中会包含监控,配置管理,扩容缩容,路由,缓存池等。这个Proxy之后也会单独进行讲解,现在先将分布式缓存看成一个由L1层+分布式缓存集群组成的就行了

PS:虽然说L1级缓存是应该包括在分布式缓存的,但是下面的介绍中,如果没有指出包含L1级缓存,就代表没有,因为分布式缓存可能会有MC,Redis等等缓存中间件,描述较为麻烦,就统称分布式缓存,然后用到L1会指出

然后本地应用上会先去访问我们的分布式缓存来承受离散请求,然后如果我们的分布式缓存MISS,就会去查询我们的本地缓存做一个数据兜底,然后看能不能直接返回,然后去查询数据库,进行带时间戳的回写分布式缓存。

缓存分为较热缓存,热缓存,热点缓存。这几种缓存会在缓存探测服务中被发现然后分别推送到我们的三级缓存中去

较热缓存就是单纯的被查询过的,我们只需要分布式缓存中进行海量的存储

然后热缓存的话就是比冷缓存要访问的多一点,并且达到了一定次数的缓存,会在我们的LRU-K队列(之后会介绍)中存储的缓存数据,然后我们需要分布式缓存支持,L1级缓存支持,本地应用缓存支持。

然后我们的热点缓存访问频率特别高,甚至是我们的LRU-K队列中被查询次数的N倍的数据,比其他热数据要热的多得多,需要我们的分布式缓存,本地缓存,反向代理缓存,L1缓存,代理Proxy缓存支持。
在这里插入图片描述

缓存探测服务

在我们的项目中,可能有的商品访问量是0,有的是10,说不定有的是1万甚至10万百万,老热数据无法靠自己来推断,如果不进行区分,都放入三级缓存,就会频繁的发生修改,我们需要我们的三级缓存有针对性,针对某一类缓存,例如较热缓存,热缓存,热点缓存需要放入的三级缓存的范围是不同的,我们就需要程序来帮忙查找我们的热点缓存了,并且在这个缓存探测中查询出来后放入到我们的其他的缓存服务中去,做缓存预热

首先分析服务特点:

  • 1、需要记录所有查询接口请求,计算数据量大。
  • 2、可以容忍数据丢失。
  • 3、尽量做到实时推送。

首先是到哪里去获取数据?
我们从用户请求开始,请求会在浏览器上开始,然后开始DNS解析,LVS负载,然后达到我们的web服务器nginx,然后进行两层ngxin之后,达到了我们的本地应用

在上面我们的那几个阶段是可控的?nginx和本地应用
如果在本地应用添加,会占用本地应用的资源,然后我们选择在nginx层进行我们的发送请求信息
因为请求量很大,所以系统不可能一下子能全部计算完成,我们需要有一个能临时存储我们的请求信息的中间件,我们可以采取吞吐量高的kafka
然后对于实时计算来说,我们可以选用storm

整体流程

然后来看我们的逻辑,我们的nginx收到了请求后,除了会去进行请求我们的服务外,还需要单独发送一条消息给我们的kafka,然后我们的基于storm集群的缓存探测服务就会去实时的拉取我们的kafka消息,然后在进行完计算后,需要进行统计后,将我们统计好的热点缓存与热缓存都推送到对应的范围中去,我们可以利用Zookeeper,将需要进行重建的缓存就推送到znode上,我们的proxy去监听我们的znode,然后当znode进行了更改,就调用自己的接口,推送到我们的nginx以及我们的分布式缓存,L1级缓存中去。

如何计算热点缓存,热缓存?—— 热点数据探测

我们在上面学习了缓存淘汰算法,例如FIFO,LRU,LFU。三种缓存算法都可以用来淘汰我们的缓存,反之,我们没被淘汰掉的就是我们需要的缓存数据,我这边是采用LRU的变种,LRU-K来进行计算的
先来说LRU的思路,我们在这里采用的是storm,一个大数据的实时计算框架

当我们的spout作为kafka的消费者,接收到日志后,发送给一个用于解析的ParseBolt,在解析完成之后,发送给我们的用于计算热点缓存的HotCountBolt,在这个HotCountBolt中,会存在两个队列,一个用于记录我们的访问列表history,一个用于存放超过了K次的LRUMap–hot。
当我们在访问一个数据的时候,我们会首先在访问列表history中记录自己的访问次数与唯一ID,然后当之后访问的时候,会将访问次数加1然后判断是否大于了K,是的话加入到我们的hot中,然后继续按照LRUMap的方法进行操作,淘汰掉最近最少使用的数据,然后在我们的hot中的数据都是我们的热数据,然后按照一定规则:例如在hot中的topK的数据都是热点数据,或者说是hot后50%中的数据平均值的几倍的数据把他看成我们的热点缓存,我这里倾向后者。当记录完成后,我们把这些数据推送到我们的zookeeper中,同时可以利用HTTP请求或者监听znode结点等操作,反向推送给nginx和L1级缓存,然后在里面添加数据

热点数据访问量降下去了,但是占用着内存怎么办?—— 热点缓存消失的实时感知

当我们的热点数据被放入到我们的反向代理缓存中去后,我们可以设置超时时间,也可以不设置超时时间,如果没有设置超时时间,我们就需要一个热点数据消失的实时感知系统
在我们的统计热点数据结果出来之后,我们应该将上一次结果的id存储起来,然后和这一次的结果进行比较,我们就需要判断两个集合的并集,求出并集后,反向推送给没在这个并集里面的数据,然后将没有在这个里面的缓存给删除掉,这样就能删除掉反向代理中的缓存,注意,这边只需要对反向代理缓存中的数据进行删除,因为像L1层,他是支持水平扩展的,能大量增加内存空间,但是反向代理能用的有限。

Cache Proxy

我们可以看到,我们的分布式缓存随着业务规模的增长,我们单种的缓存中间件可能会开始变得不适用我们的业务场景,我们可能需要扩展这个缓存中间件来达到我们的期望,例如
1、在使用MC的时候,需要更丰富的数据类型来支持我们的集合类型数据
2、在使用Redis,不好解决我们的大Value问题
3、在面对热点请求的时候,继续使用一主多从导致缓存服务器宕机,加入了L1级缓存,但是代码变得臃肿

虽然说不止上面的问题,但是已经是典型了
我们可以考虑引入一个缓存层的Cache Proxy,隐藏我们的缓存查询逻辑,然后对外提供公共的API,用来获取,删除,更新等操作,我们只需要进行第三方接口调用就行了
那么需要哪些功能呢?

  • 路由
  • 渗透读
  • 缓存监控
  • 扩容缩容
  • 本地缓存
  • 连接池
自动路由

对于我们查询一个缓存,可能这个缓存是一个热缓存,我们首先需要去进行查询L1级缓存,然后去查询我们的分布式缓存中间件,我们之间是以集群的形式存在,我们需要自动路由,去查询某一台缓存,对于L1级缓存应该是轮询,然后对于缓存中间件来说,应该是需要进行一致性Hash,因为这个时候L1级缓存没有,代表可能不是我们的热数据或者热点数据,就需要进行一致性Hash。

渗透读

因为我们的上层接口调用者只会调用一个API,然后内部封装细节,需要包含有MISS的情况,需要自动去查询另外的一个缓存

扩容缩容支持

我们根据业务需求可能会增加机器或者因为负载原因,导致机器宕机,我们需要进行扩容缩容,采取一致性Hash的办法+水平扩容的办法,让我们的请求能够继续被路由到原来的机器上,不会因为扩容缩容导致我们的请求被路由到另一台上或者请求无效

本地缓存

因为我们的接口调用者都会经过这个Cache Proxy来进行查询分布式缓存,我们也可以增加缓冲池来减少远程调用,
1、我们可以利用我们的WeakReference来做一个缓存,当GC的时候会自动进行回收
2、缓存回收,有几种方案,我采用的是1+2方案

  • 对于设置了过期的key,在插入前计算过期的时间,然后利用多级反馈队列,根据时间跨度,放入到对应的根据时间排序的跳表中,我们只需要判断跳表的队头结点是不是过期了,然后修改头结点,将队头元素设为null,帮助GC回收
  • 利用懒的思想,当用户去取数据的时候判断是否过期,然后过期的话删除缓存,同时移除掉我们的跳表中的元素
  • 也可以开一个后台线程去不断扫描,浪费多,可能扫不到多少过期数据
连接池

我们需要连接池来进行复用连接,减少连接的创建

缓存监控

这个基本上需要依靠中间件自己本身支持,例如redis可以使用我们的info命令去查看我们的已使用内存等等信息,我们可以利用一个定时线程Collector去不断的收集我们的内存数据,同时,利用一个Sender线程去发送我们的数据到我们的MonitorManager中的一个FIFO的定长双端链表CahceMessageQueue,同时MonitorManager中会调用服务去进行存储数据,进行持久化,帮助我们之后分析内存占用率,此外,还有我们的缓存MISS占比等。

项目架构简介图

先说清楚,因为我用的画图软件是要付费才能解锁无限制画图的,所以这边很多详细设计都没有画进去,只能画多少算多少,这边的话还有对于应用集群,实时性要求的缓存同步方案,缓存探测设计详细,热点缓存的分发降级等等等等没有画出来,喜欢的小伙伴可以来私我来了解这个的设计详细,或者之后会挑个时间来补全这一块内容

在这里插入图片描述

画图画的不是很好,有些细节画不出来,将就着看看

上面是对缓存的一些介绍,之后会出一些设计的详细细节出来。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值