应用缓存

一、应用缓存的定义、分类和应用场景(是什么)

1.1 定义

定义:是一种以空间换取时间为手段,以提升响应速度为目标的技术方案。通常情况下使用可快速存取的介质去替代慢速存取的介质或者复杂计算的内容。举例:使用Redis缓存人员数据,避免从数据库直接查询,提升整体的QPS。

空间换时间:多创建一份数据副本(空间),请请求可以更快(时间)的拿到想要的数据。举例:16的平方是的多少?不需要算也知道是256,因为在脑子里面已经将其记录下来,无需在计算。

1.2 为啥用缓存?解决什么问题?同时带来什么问题?

为什么要用缓存?
目的:保护数据库,提升性能。没有缓存(中间件)之前,使用比较多的数据库是 MySQL ,QPS在几千到一万左右。高于这个阈值就会出现超时、甚至宕机的问题。如果想提升性能,一般需要靠:sql性能优化、分库分表、持久成缓存等技术去做、本地缓存。
缓存(中间件)的出现刚好的可以解决这部分问题。同时在使用缓存也会带来一些”副作用“,参见后面部分。

总结:大部分情况下,缓存是用来保护数据库,相当于数据库找了个”干爹“,替自己抗压,避免被打死。

1.3 缓存的分类和应用场景

1.3.1 按照生命周期划分:临时性缓存、永久性缓存

临时缓存 永久缓存
场景 适用于数据变化频繁,且缓存数据的有效性要求不高的场景。临时缓存通常用于存储短暂的数据,如临时计算结果、用户会话信息、页面片段等。 适用于数据变化较少,且缓存数据的有效性要求高的场景。永久缓存通常用于存储稳定的数据,如静态配置信息、静态页面内容等。
特点 缓存数据的生命周期较短,可以根据需要设置合适的过期时间。一般是在查询的流程进行设置缓存。 缓存数据的生命周期较长,可以长期存储,不需要频繁更新。数据变化时需要手动或定时刷新缓存。缓存数据一般是固定的,不会频繁添加或删除。可以使用持久化的缓存存储,如数据库、文件系统、分布式缓存等。

1.3.2 按照类型划分:堆缓存、堆外缓存、磁盘缓存、分布式缓存。

缓存类型 特点 应用场景 技术选型
堆缓存 是由JVM管理的,在JVM中可以直接去以引用的形式去读取,所以读写的速度会特别高。而且JVM会负责其内容的回收与清理,使用起来比较“省心” - 小型应用程序 ,需要频繁访问和更新的数据 Guava LocalCache、Caffeine
堆外缓存 是在内存中划定了一块独立的存储区域,然后可以将这部分内存当做“磁盘”进行使用。需要使用方自行维护数据的清理,读写前需要序列化与反序列化操作,但可以省去GC的影响 - 大规模缓存数据 - 需要高存储容量 - 对垃圾回收开销敏感 Caffeine EhCache
磁盘缓存 将数据存储在磁盘上 - 长期保存数据 需要持久化存储 数据量较大 EhCache
分布式缓存 将数据分布在多个节点上 - 大规模分布式系统 需要共享和管理缓存数据 需要高可用性和扩展性 Redis、Memcached
多级缓存 本地缓存 + 分布式缓存

1.3.3 按照一致性划分:强一致性、弱一致性(可能一直错了)、最终一致性(最后能更正)

强一致性 弱一致性 最终一致性
区别 这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大 短期看不一致,长期看也不一致。不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态 短期看不一致,过了某个时间窗口一定会一致。这里之所以将最终一致性单独提出来,是因为它是业界在大型分布式系统的数据一致性上比较推崇的模型

1.4 缓存算法

1.4.1 最近最少使用(Least Recently Used, LRU)算法

根据数据项的访问历史,淘汰最近最少被使用的数据项。当缓存空间不足时,会优先淘汰最近最少使用的数据,以保留热门数据。LRU算法可以使用双向链表和哈希表实现。

举例:假设缓存中有5个数据项,按访问时间顺序依次为A、B、C、D、E。当新的数据项F需要缓存时,由于缓存空间不足,需要淘汰一个数据项。根据LRU算法,最近最少使用的数据项是A,所以A会被淘汰,而F会被缓存。

1.4.2 先进先出(First-In-First-Out, FIFO)算法

按照数据项被缓存的先后顺序进行淘汰。当缓存空间不足时,淘汰最早被缓存的数据项。

举例:假设缓存中有5个数据项,依次被缓存的顺序为A、B、C、D、E。当新的数据项F需要缓存时,由于缓存空间不足,需要淘汰一个数据项。根据FIFO算法,最早被缓存的数据项是A,所以A会被淘汰,而F会被缓存。

1.4.3 最不经常使用(Least Frequently Used, LFU)算法

根据数据项的访问次数,淘汰访问次数最少的数据项。当缓存空间不足时,会优先淘汰访问次数最少的数据。

举例:假设缓存中有5个数据项,访问次数分别为3、2、5、1、4。当新的数据项需要缓存时,由于缓存空间不足,需要淘汰一个数据项。根据LFU算法,访问次数最少的数据项是1,所以1会被淘汰,而新的数据项会被缓存。

1.4.4 最近使用次数最少(Least Frequently Used, LFU)算法

根据数据项最近的使用次数,淘汰使用次数最少的数据项。与LFU算法不同的是,LFU-K算法考虑了时间因素。

举例:假设缓存中有5个数据项,最近的使用次数分别为3、2、5、1、4。当新的数据项需要缓存时,由于缓存空间不足,需要淘汰一个数据项。根据LFU-K算法,使用次数最少且时间最久远的数据项会被淘汰。

二、如何使用缓存?三种模式和设计细节(怎么用)

2.1、如何使用缓存?三种模式

选择缓存策略?
缓存策略取决于数据和数据访问模式。换句话说,数据是如何写和读的。

一、Cache-Aside读流程
Cache-Aside Pattern 的读请求流程如下:
读的时候,先读缓存,缓存命中的话,直接返回数据
缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。

Cache-Aside Pattern 的写请求流程如下:
更新的时候,先更新数据库,然后再删除缓存 。
特点:lazy计算,以db数据为准
适用场景:更强一致性,更新db后直接删除cache,可以大幅降低chche和db数据不一致的概率

二、Read-Through/Write-Through(读写穿透-重点是直接跟缓存应用交互)
Read/Write-Through 模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层 完成的。

三、Write-behind (异步缓存写入-重点是异步)
Write-behind 跟Read-Through/Write-Through有相似的地方,都是由Cache Provider 来负责缓存和数据库的读写。它们又有个很大的不同:Read/Write-Through 是同步更新缓存和数据的,Write-Behind 则是只更新缓存,不直接更新数据库,通过批量异步 的方式来更新数据库。

2.2、Key设计细节

2.2.1 命名规范:

使用清晰、有意义的命名,以便于理解和维护。
选择简洁的键名,以减少存储空间和网络传输成本。
避免使用特殊字符和空格,以免引起解析和使用上的问题。

正例:

  • user:123:email - 表示用户 ID 为 123 的用户的邮箱地址。
  • product:789:price - 表示商品 ID 为 789 的商品的价格。
  • session:abc123 - 表示会话 ID 为 abc123 的会话信息。
  • counter:page_views - 表示页面浏览计数器的键。

反例:

  • usr:123:eml - 使用缩写和无明确含义的命名。
  • product-789_price - 使用特殊字符和混合命名风格。

2.2.2 命名空间:避免重复替换

使用命名空间对键进行分组和分类,以避免键名冲突和混乱。
例如,可以使用前缀来表示键的类别,如 "user:id"、"product:id"。

可以一个应用一个命名空间,然后按照模块或者类型 往下分类。

2.2.3 键(key)的结构:

根据数据模型和访问模式,设计键的层次结构。
例如,使用散列(Hash)来存储用户信息时,可以使用 user:id 作为键名,散列字段存储用户的属性。

2.2.4 键的过期时间:

对于具有临时性或有时效性的数据,可以设置键的过期时间,以避免数据过期后仍然存储在 Redis 中。
例如,可以使用 SETEX 命令设置键的过期时间。

2.2.5 键的长度和性能:

长键名可能会占用更多的存储空间和网络带宽,影响性能。
如果键名的长度超过一定限制,可以考虑使用哈希(Hash)来存储键和值,减少存储空间和网络传输。

三、 缓存带来的问题和解决方案(常见问题和方案)

3.1 不一致性问题

一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,也就是说如果你的系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。

串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。

读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,然后再删除缓存。

3.2 大key问题

如何判定为大key?对于Key和String类型的Value,最大不超过512MB,这个限制显然没有太大参考意义,建议:在几KB到几十KB之间。
危害?redis的基础假设是每个操作都很快,所以设计成单线程处理;所以如果有大key,基础设计就不成立了,会阻塞;查询突然很慢,qps降低;数据倾斜,部分redis分片节点存储占用很高

3.2.1 业务拆分法

将key的含义更细粒度化,避免大key出现。将大key拆分为多个小的key,将数据分散存储在不同的小key中。这样可以减少单个key的大小,提高读写性能。
例子:假设需要存储用户的个人信息,包括姓名、年龄、地址等,原本可以存储在一个大key中,如"user:123",现在可以将它拆分为多个小key存储,如"user:name:123"、"user:age:123"、"user:address:123"等。

// 存储用户信息到不同的键中
Jedis jedis = new Jedis("localhost");

// 存储用户姓名
jedis.set("user:name:123", "John");
// 存储用户年龄
jedis.set("user:age:123", "25");
// 存储用户地址
jedis.set("user:address:123", "123 Main Street");

// 获取用户信息
String name = jedis.get("user:name:123");
String age = jedis.get("user:age:123");
String address = jedis.get("user:address:123");

System.out.println("Name: " + name); // Output: John
System.out.println("Age: " + age); // Output: 25
System.out.println("Address: " + address); // Output: 123 Main Street

// todo 形成可理解的 https://blog.csdn.net/jiagoubaiduren/article/details/122142884

3.2.2 数据压缩法

压缩:对大key的压缩。相当于用cpu资源来降低网络io,其中google提出的snappy算法较常用。

POM
<dependency>
    <groupId>org.xerial.snappy</groupId>
    <artifactId>snappy-java</artifactId>
    <version>1.1.8.4</version>
</dependency>

SnappyUtil.java
import org.xerial.snappy.Snappy;
import java.io.IOException;

public class SnappyUtil {

  public static byte[] compress(byte[] input) throws IOException {
      return Snappy.compress(input);
  }

  public static byte[] uncompress(byte[] compressed) throws IOException {
      return Snappy.uncompress(compressed);
  }
}

3.3 缓存雪崩

定义:是指缓存由于某些原因无法提供服务( 例如,缓存挂掉了,或者集中过期 ),所有请求全部达到 DB 中,导致 DB 负荷大增,最终挂掉的情况。

如何解决:预防和解决缓存雪崩的问题,可以从以下多个方面进行共同着手。
缓存高可用:通过搭建缓存的高可用,避免缓存挂掉导致无法提供服务的情况,从而降低出现缓存雪崩的情况。假设我们使用 Redis 作为缓存,则可以使用 Redis Sentinel 或 Redis Cluster 实现高可用。
本地缓存:如果使用本地缓存时,即使分布式缓存挂了,也可以将 DB 查询到的结果缓存到本地,避免后续请求全部到达 DB 中。如果我们使用 JVM ,则可以使用 Ehcache、Guava Cache 实现本地缓存的功能。

当然,引入本地缓存也会有相应的问题,例如说:
本地缓存的实时性怎么保证?
方案一,可以引入消息队列。在数据更新时,发布数据更新的消息;而进>程中有相应的消费者消费该消息,从而更新本地缓存。
方案二,设置较短的过期时间,请求时从 DB 重新拉取。
方案三,手动过期。

请求 DB 限流: 通过限制 DB 的每秒请求数,避免把 DB 也打挂了。如果我们使用 Java ,则可以使用 Guava RateLimiter、Sentinel、Hystrix 实现限流的功能。这样至少能有两个好处:
可能有一部分用户,还可以使用,系统还没死透。
未来缓存服务恢复后,系统立即就已经恢复,无需再处理 DB 也挂掉的情况。
提前演练:在项目上线前,演练缓存宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。

3.4 缓存穿透

定义:是指查询一个一定不存在的数据,由于缓存是不命中时被动写,并且处于容错考虑,如果从 DB 查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,失去了缓存的意义.

方案一,缓存空对象。
当从 DB 查询数据为空,我们仍然将这个空结果进行缓存,具体的值需要使用特殊的标识,能和真正缓存的数据区分开。另外,需要设置较短的过期时间,一般建议不要超过 5 分钟。

方案二,BloomFilter 布隆过滤器。
在缓存服务的基础上,构建 BloomFilter 数据结构,在 BloomFilter 中存储对应的 KEY 是否存在,如果存在,说明该 KEY 对应的值不为空。
这两个方案,各有其优缺点。

缓存空对象 BloomFilter布隆过滤器
适用场景 1、数据命中不高 2、保证一致性 1、数据命中不高, 2、数据相对固定、实时性低
维护成本 1、代码维护简单 2、需要过多的缓存空间 3、数据不一致 1、代码维护复杂,2、缓存空间占用小
实际情况下,使用方案二比较多。因为,相比方案一来说,更加节省内容,对缓存的负荷更小。

3.5 缓存击穿

定义:是指某个极度“热点”数据在某个时间点过期时,恰好在这个时间点对这个 KEY 有大量的并发请求过来,这些请求发现缓存过期一般都会从 DB 加载数据并回设到缓存,但是这个时候大并发的请求可能会瞬间 DB 压垮。
举例:对于一些设置了过期时间的 KEY ,如果这些 KEY 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑这个问题。

解决方案:

使用互斥锁 手动过期 主从缓存
方法 请求发现缓存不存在后,去查询 DB 前,使用分布式锁,保证有且只有一个线程去查询 DB ,并更新到缓存。 缓存上从不设置过期时间,功能上将过期时间存在 KEY 对应的 VALUE 里。流程如下:获取缓存。通过 VALUE 的过期时间,判断是否过期。如果未过期,则直接返回;如果已过期,继续往下执行。通过一个后台的异步线程进行缓存的构建,也就是“手动”过期。通过后台的异步线程,保证有且只有一个线程去查询 DB。同时,虽然 VALUE 已经过期,还是直接返回。通过这样的方式,保证服务的可用性,虽然损失了一定的时效性。选择 分主备,查不到查备份的
优点 1、思路简单 2、保证一致性 1、性价最佳,用户无需等待
缺点 1、代码复杂度增大 2、存在死锁的风险 1、无法保证缓存一致性

三者质检的差别

雪崩 穿透 击穿
key的数量 很多key 0 某1个或者几个

相关文章:

  1. Guava、ehcache的例子 git@github.com:jinhuicheng/heap-outside-cache.git
  2. 缓存三种模式:https://juejin.cn/post/7116281192738979876
  3. 缓存相关知识点:https://blog.csdn.net/qq_40374604/article/details/128123061
  4. 代码:git@github.com:xiaogangfan/spring-boot-redis-guava-caffeine-cache.git
  5. 如何发现热key:https://blog.csdn.net/qq_41956014/article/details/127751591

作者微信:

posted @ 2023-07-19 19:11  范晓刚  阅读(105)  评论(0编辑  收藏  举报