缓存

缓存的基本思想

缓存的基本思想其实很简单,就是我们非常熟悉的空间换时间。它的确对系统的性能提升的性价比非常高。

其实,在使用缓存的时候,你发现缓存的思想实际在操作系统或者其他地方都被大量用到。 比如 CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。 再比如操作系统在页表方案基础之上引入了快表来加速虚拟地址到物理地址的转换。可以把快表理解为一种特殊的高速缓冲存储器(Cache)。

回归到业务系统来说:为了避免用户在请求数据的时候获取速度过于缓慢,所以在数据库之上增加了缓存这一层来弥补。

缓存特征

命中率

当某个请求能够通过访问缓存而得到响应时,称为缓存命中。

缓存命中率越高,缓存的利用率也就越高。

最大空间

缓存通常位于内存中,内存的空间通常比磁盘空间小的多,因此缓存的最大空间不可能非常大。

当缓存存放的数据量超过最大空间时,就需要淘汰部分数据来存放新到达的数据。

淘汰策略

  • FIFO(First In First Out):先进先出策略,在实时性的场景下,需要经常访问最新的数据,那么就可以使用 FIFO,使得最先进入的数据(最晚的数据)被淘汰。
  • LRU(Least Recently Used):最近最久未使用策略,优先淘汰最久未使用的数据,也就是上次被访问时间距离现在最久的数据。该策略可以保证内存中的数据都是热点数据,也就是经常被访问的数据,从而保证缓存命中率。
  • LFU(Least Frequently Used):最不经常使用策略,优先淘汰一段时间内使用次数最少的数据。

缓存的位置

浏览器

当 HTTP 响应允许进行缓存时,浏览器会将 HTML、CSS、JavaScript、图片等静态资源进行缓存。

ISP

网络服务提供商(ISP)是网络访问的第一跳,通过将数据缓存在 ISP 中能够大大提高用户的访问速度。

反向代理

反向代理位于服务器之前,请求与响应都需要经过反向代理。通过将数据缓存在反向代理,在用户请求反向代理时就可以直接使用缓存进行响应。

本地缓存

使用 Guava Cache 将数据缓存在服务器本地内存中,服务器代码可以直接读取本地内存中的缓存,速度非常快。

分布式缓存

使用 Redis、Memcache 等分布式缓存将数据缓存在分布式缓存系统中。

相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。不仅如此,服务器集群都可以访问分布式缓存,而本地缓存需要在服务器集群之间进行同步,实现难度和性能开销上都非常大。

数据库缓存

MySQL 等数据库管理系统具有自己的查询缓存机制来提高查询效率。

Java 内部的缓存

Java 为了优化空间,提高字符串、基本数据类型包装类的创建效率,设计了字符串常量池及 Byte、Short、Character、Integer、Long、Boolean 这六种包装类缓冲池。

CPU 多级缓存

CPU 为了解决运算速度与主存 IO 速度不匹配的问题,引入了多级缓存结构,同时使用 MESI 等缓存一致性协议来解决多核 CPU 缓存数据一致性的问题。

CDN

内容分发网络(Content distribution network,CDN)是一种互连的网络系统,它利用更靠近用户的服务器从而更快更可靠地将 HTML、CSS、JavaScript、音乐、图片、视频等静态资源分发给用户。

CDN 主要有以下优点:

  • 更快地将数据分发给用户;
  • 通过部署多台服务器,从而提高系统整体的带宽性能;
  • 多台服务器可以看成是一种冗余机制,从而具有高可用性。

缓存会出现什么问题?

缓存穿透

缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。

解决办法

最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。

缓存无效 key

如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。

布隆过滤器

将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

流程如下:

加入布隆过滤器后的缓存处理流程

但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。(具体原因看面试复习——数据结构第13题)

缓存空结果

另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

缓存雪崩

指的是由于数据没有被加载到缓存中,或者缓存数据在同一时间大面积失效(过期),又或者缓存服务器宕机,导致大量的请求都到达数据库。

在有缓存的系统中,系统非常依赖于缓存,缓存分担了很大一部分的数据请求。当发生缓存雪崩时,数据库无法处理这么大的请求,导致数据库崩溃。

解决方案:

  • 事前:尽量保证整个Redis集群的高可用性,发现机器宕机尽快补上,选择合适的内存淘汰策略
  • 事中:本地ehcache缓存+hystrix限流&降级,避免MySQL崩掉
  • 事后:利用redis持久化机制保存的数据尽快恢复缓存
  • 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以通过观察用户行为,合理设置缓存过期时间来实现;
  • 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用。
  • 也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩。

缓存一致性

缓存一致性要求数据更新的同时缓存数据也能够实时更新。

解决方案:

  • 在数据更新的同时立即去更新缓存;
  • 在读缓存之前先判断缓存是否是最新的,如果不是最新的先进行更新。

要保证缓存一致性需要付出很大的代价,缓存数据最好是那些对一致性要求不高的数据,允许缓存数据存在一些脏数据。

缓存 “无底洞” 现象

指的是为了满足业务要求添加了大量缓存节点,但是性能不但没有好转反而下降了的现象。

产生原因:缓存系统通常采用 hash 函数将 key 映射到对应的缓存节点,随着缓存节点数目的增加,键值分布到更多的节点上,导致客户端一次批量操作会涉及多次网络操作,这意味着批量操作的耗时会随着节点数目的增加而不断增大。此外,网络连接数变多,对节点的性能也有一定影响。

解决方案:

  • 优化批量数据操作命令;
  • 减少网络通信次数;
  • 降低接入成本,使用长连接 / 连接池,NIO 等。

缓存雪崩问题解决方案

缓存雪崩指的是缓存同一时间大面积的失效,所以后面的请求都会落到数据库上,造成数据库短时间内承受大量的请求而崩掉。

解决方案:

  • 事前:尽量保证整个Redis集群的高可用性,发现机器宕机尽快补上,选择合适的内存淘汰策略
  • 事中:本地ehcache缓存+hystrix限流&降级,避免MySQL崩掉
  • 事后:利用redis持久化机制保存的数据尽快恢复缓存

缓存雪崩解决方案

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
  2. 限流,避免同时处理大量的请求。

针对热点缓存失效的情况:

  1. 设置不同的失效时间比如随机设置缓存的失效时间。
  2. 缓存永不失效。

缓存穿透问题解决方案

缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。例如:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。

解决办法

入参校验

最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。

缓存无效 key

如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。

布隆过滤器

将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

流程如下:

加入布隆过滤器后的缓存处理流程

但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。(具体原因看面试复习——数据结构第13题)

缓存空结果

另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

缓存数据如何分布?

哈希分布

哈希分布就是将数据计算哈希值之后,按照哈希值分配到不同的节点上。例如有 N 个节点,数据的主键为 key,则将该数据分配的节点序号为:hash(key) % N

传统的哈希分布算法存在一个问题:当节点数量变化时,也就是 N 值变化,那么几乎所有的数据都需要重新分布,将导致大量的数据迁移。

顺序分布

将数据划分为多个连续的部分,按数据的 ID 或者时间分布到不同节点上。例如 User 表的 ID 范围为 1 ~ 7000,使用顺序分布可以将其划分成多个子表,对应的主键范围为 1 ~ 1000,1001 ~ 2000,…,6001 ~ 7000。

顺序分布相比于哈希分布的主要优点如下:

  • 能保持数据原有的顺序;
  • 并且能够准确控制每台服务器存储的数据量,从而使得存储空间的利用率最大。

一致性哈希

Distributed Hash Table(DHT) 是一种哈希分布方式,其目的是为了克服传统哈希分布在服务器节点数量变化时大量数据迁移的问题。

基本原理:将哈希空间 [0, 2n-1](一般是0 - 2^32) 看成一个哈希环,每个服务器节点都配置到哈希环上。每个数据对象通过哈希取模得到哈希值之后,存放到哈希环中顺时针方向第一个大于等于该哈希值的节点上。

一致性Hash图示

一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如新增一个节点 X,只需要将它前一个节点 C 上的数据重新进行分布即可,对于其他节点 A、B、D 都没有影响。(即只影响相邻节点,一旦有服务器挂了,只会丢失一部分缓存)

优点:加入和删除节点只影响哈希环中相邻的节点,对其他节点无影响。
缺点:

  • 加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。
  • 当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。
  • 普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。

上面描述的一致性哈希存在数据分布不均匀的问题,节点存储的数据量有可能会存在很大的不同。数据不均匀主要是因为节点在哈希环上分布的不均匀,这种情况在节点数量很少的情况下尤其明显。

解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得多,那么虚拟节点在哈希环上分布的均匀性就会比原来的真实节点好,从而使得数据分布也更加均匀。

如何保证缓存与数据库双写时的数据一致性?

只要用缓存,就可能会涉及到缓存与数据库双存储,双写,只要是双写,就一定会有数据一致性问题,如何解决呢?

一般来说,如果系统不是严格要求缓存+数据库必须一致性的话,缓存可以稍微跟数据库偶尔有不一致的情况。

另外,可以将读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况,但是串行化之后,就会导致系统的吞吐量大幅度降低,需要用比正常情况下多几倍的机器去支撑线上的一个请求。

解决这个问题的最经典的模式,就是 Cache Aside Pattern 数据库读写模式(旁路缓存模式)。

  1. 读的时候先读缓存,如果缓存不存在的话就读数据库,取出数据库后更新缓存;如果存在的话直接读取缓存的信息。
  2. 写的时候,先更新数据库,再删除缓存。

为什么是删除缓存而不是更新缓存?

很多时候复杂的缓存场景,缓存不是仅仅从数据库中取出来的值。可能是关联多张表的数据并通过计算才是缓存需要的值。并且,更新缓存的代价有时候很高。对于需要频繁写操作,而读操作很少的时候,每次进行数据库的修改,缓存也要随之更新,会造成系统吞吐的下降,但此时缓存并不会被频繁访问到,用到的缓存才去算缓存

删除缓存而不是更新缓存,是一种懒加载的思想,不是每次都重复更新缓存,只有用到的时候才去更新缓存,同时即使有大量的读请求,实际也就更新了一次,后面的请求不会重复读。

Cache Aside Pattern存在的问题

问题:先更新数据库,再删除缓存,如果删除缓存失败了,导致数据库中是新数据,缓存中是旧数据,就出现数据不一致的问题。

解决思路:先删除缓存,再更新数据库。

  • 缓存删除失败:如果缓存删除失败,那么就不会继续执行,数据库信息没有被修改,保持了数据的一致性;
  • 缓存删除成功,数据库更新失败:此时数据库里的是旧数据,缓存是空的,查询时发现缓存不存在,就查询数据库并更新缓存,数据保持一致。

问题:上面的方案存在不足,如果删除完缓存更新数据库时,如果一个请求过来查询数据,缓存不存在,就查询数据库的旧数据,更新旧数据到缓存中。随后数据更新完成,修改了数据库的数据,此时缓存和数据库的数据就会出现不一致了。高并发下会出现这种数据库 + 缓存不一致的情况。 如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

解决方案:采用双删除策略。写请求先删除缓存,再去更新数据库,等待一段时间后异步删除缓存。这样可以保证在读取错误数据时能及时被修正过来。

还有一种策略,就是:写请求先修改缓存为指定值,然后再去更新数据库,再更新缓存。读请求过来后,会先读缓存,判断是指定值后就进入循环读取状态,等到写请求更新缓存。如果循环超时就去数据库读取数据,更新缓存。

这种方案保证了读写的一致性,但由于读请求等待写请求的完成,会降低系统的吞吐量。

缓存读写模式/更新策略

Cache Aside Pattern(旁路缓存模式)

  1. 写:更新 DB,然后直接删除 cache 。
  2. 读:从 cache 中读取数据,读取到就直接返回,读取不到的话,就从 DB 中取数据返回,然后再把数据放到 cache 中。

Cache Aside Pattern 中服务端需要同时维系 DB 和 cache,并且是以 DB 的结果为准。另外,Cache Aside Pattern 有首次请求数据一定不在 cache 的问题,对于热点数据可以提前放入缓存中。

Cache Aside Pattern 是平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。

在写数据的过程中,可以先删除 cache ,后更新 DB 么?

这样是不行的!因为这样可能会造成数据库(DB)和缓存(Cache)数据不一致的问题。比如下面的流程:

  1. 请求 1 先把 cache 中的 A 数据删除
  2. 请求 2 从 DB 中读取数据
  3. 请求 1 再把 DB 中的 A 数据更新

这样就很有可能产生数据不一致性的问题。

在写数据的过程中,先更新 DB,后删除 cache 就没有问题了么?

理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多

但是这里也有可能产生数据不一致性的问题。这个过程可以简单描述为:

  1. 请求 1 想要读取数据 A,此时缓存中还没有,先从 DB 读数据 A
  2. 请求 2 写更新数据 A 到数据库并在 cache 中删除 A 数据
  3. 请求 1 将数据 A 写入 cache

这样就产生了数据不一致的情况。

Cache Aside Pattern 的缺陷

缺陷 1:首次请求数据一定不存在 cache 的问题

解决办法:可以将热点数据可以提前放入 cache 中。

缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。

解决办法:

  • 数据库和缓存数据强一致场景 :更新 DB 的时候同样更新 cache,不过这时候需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
  • 可以短暂地允许数据库和缓存数据不一致的场景 :更新 DB 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。

Read/Write Through Pattern(读写穿透)

Read/Write Through 套路是:服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。

  1. 写(Write Through):先查 cache,cache 中不存在,直接更新 DB。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)。
  2. 读(Read Through): 从 cache 中读取数据,读取到就直接返回 。读取不到的话,先从 DB 加载,写入到 cache 后返回响应。

Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。

和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。

Write Behind Pattern(异步缓存写入)

Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。

但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

Write Behind Pattern 下 DB 的写性能非常高,尤其适合一些数据经常变化的业务场景比如说一篇文章的点赞数量、阅读数量。 往常一篇文章被点赞 500 次的话,需要重复修改 500 次 DB,但是在 Write Behind Pattern 下可能只需要修改一次 DB 就可以了。

但是,这种模式同样也给 DB 和 Cache 一致性带来了新的考验,很多时候如果数据还没异步更新到 DB 的话,Cache 服务宕机就会导致数据丢失。

设计一个带有超时时间和容量的缓存

初始化一个队列,让过期时间最小的数据排在队列前,在清除过期数据时,只需查看缓存最近的过期数据,而不用扫描全部缓存。缓存可以存 HashMap,时间作为 key,缓存值作为 value。

然后执行定时任务,每次对队列头进行判断,如果已经过期,删除,并继续看下一个是否过期。

同时还可以执行惰性删除,每次读取一个数后,先检查一下过期没有,过期就删除。

当容量满了的时候,执行删除,如果数据都没过期,执行相应的淘汰策略。

参考内容

主要参考以来两篇博客以及相关博客推荐,因找的博客比较多,没注意记录,最后好多忘了在哪2333,如果有侵权,请及时联系我,非常抱歉。
https://github.com/Snailclimb/JavaGuide
https://github.com/CyC2018/CS-Notes
java实现带过期时间的缓存