分布式常见场景解决方案——分布式缓存
1. 缓存的概念
1.1 外存
外储存器是指除计算机内存及 CPU 缓存以外的储存器,此类储存器一般断电后仍然能保存数据。
常见的外存储器有硬盘、软盘、光盘、U 盘等,一般的软件都是安装在外存中(windows系统指的是 CDEF 盘, Linux 系统指的是挂载点)。
1.2 内存
内存是计算机中重要的部件之一,它是与 CPU 进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。
内存(Memory)也被称为内存储器,其作用是用于暂时存放 CPU 中的运算数据,以及与硬盘等外部存储器交换的数据。只要计算机在运行中,CPU 就会把需要运算的数据调到内存中进行运算,当运算完成后 CPU 再将结果传送出来,内存的运行也决定了计算机的稳定运行,此类储存器一般断电后数据就会被清空。
1.3 缓存
缓存就是把一些外存上的数据保存到内存上而已,怎么保存到内存上呢,我们运行的所有程序,里面的变量值都是放在内存上的,所以说如果要想使一个值放到内存上,实质就是在获得这个变量之后,用一个生存期较长的变量存放你想存放的值,在 java中一些缓存一般都是通过 map 集合来做的。
广义的缓存是把一些慢存(较慢的外存)上的数据保存到快存(较快的存储)上,简单讲就是,如果某些资源或者数据会被频繁的使用,而这些资源或数据存储在系统外部,比如数据库、硬盘文件等,那么每次操作这些数据的时候都从数据库或者硬盘上去获取,速度会很慢,会造成性能问题(系统停工待料)。于是我们把这些数据冗余一份到快存里面,每次操作的时候,先到快存里面找,看有没有这些数据,如果有,那么就直接使用,如果没有那么就从硬盘获取它,并复制一份到快存中,下一次访问的时候就可以直接从快存中获取。从而节省大量的时间,可以看出,缓存是一种典型的空间换时间的方案。
生活中这样的例子处处可见,举例:
举例 1 --CPU--L1/L2--内存--磁盘
CPU 需要数据时先从 L1/L2 中读取,如果没有到内存中找,如果还没有会到磁盘上找。
举例 2 --Maven
用过 Maven 的朋友应该都知道,我们找依赖的时候,先从本机仓库找,再从本地服务器仓库找,最后到远程仓库服务器找。
举例 3 --京东仓储
还有如京东的物流为什么那么快?他们在各个地都有分仓库,如果该仓库有货物那么送货的速度是非常快的。
举例 4 --数据库中的索引
也是以空间换取时间,缓存也是以空间换取时间。
1.4 重要的指标
1.4.1 缓存命中率
缓存命中率表明缓存是否运行良好的。即【从缓存中读取数据的次数】与【总读取次数】的比率,命中率越高越好。
命中率 = 从缓存中读取次数 / (总读取次数[从缓存中读取次数 + 从慢速设备上读取的次数])
Miss 率 = 没有从缓存中读取的次数 / (总读取次数[从缓存中读取次数 + 从慢速设备上读取的次数]) = 1 - 命中率
命中率是一个非常重要的监控指标,如果做缓存一定要监控这个指标来看缓存是否工作良好。
1.4.2 移除策略
不同的移除策略实际上看的是不同的指标。即如果缓存满了,需要从缓存中移除数据,常见的有 LFU、LRU、FIFO等策略:
FIFO(First In First Out):先进先出算法,即先放入缓存的先被移除;
LRU(Least Recently Used):最久未使用算法,使用时间距离现在最久的那个被移除;
LFU(Least Frequently Used):最近最少使用算法,一定时间段内使用次数(频率)最少的那个被移除;
TTL(Time To Live ):存活期,即从缓存中创建时间点开始直到它到期的一个时间段(不管在这个时间段内有没有访问都将过期);
TTI(Time To Idle):空闲期,即一个数据多久没被访问将从缓存中移除的时间。
实际设计缓存时以上重要指标都应该考虑进去,当然根据实际需求可能有的指标并不会采用。
2. 缓存在Java中的实现
在 Java 中,我们一般对调用方法进行缓存控制,比如我调用"findUserById(Long id)",那么我应该在调用这个方法之前先从缓存中查找有没有,如果没有再掉该方法如从数据库加载用户,然后添加到缓存中,下次调用时将会从缓存中获取到数据。Java 中广泛使用的分布式缓存有Redis、MongoDB等。
2.1 缓存逻辑流程
流程图如下:
2.2 逻辑流程代码
@Override
public Provinces detail(String provinceid) {
Provinces provinces = null;
//在redis查询
provinces = (Provinces)redisTemplate.opsForValue().get(provinceid);
if (null != provinces){
// redisTemplate.expire(provinceid,20000, TimeUnit.MILLISECONDS);
System.out.println("缓存中得到数据");
return provinces;
}
provinces = super.detail(provinceid);
if (null != provinces){
redisTemplate.opsForValue().set(provinceid,provinces);//set缓存
redisTemplate.expire(provinceid,20000, TimeUnit.MILLISECONDS);//设置过期
}
return provinces;
}
3. 基于注解的 Cache
Spring 3.1 起,提供了基于注解的对 Cache 的支持。使用 Spring Cache 的好处:
基于注解,代码清爽简洁;
基于注解也可以实现复杂的逻辑;
可以对缓存进行回滚;
Spring Cache 并非具体的缓存技术,而是基于各种缓存产品(如 Guava、EhCache、Redis 等)共性进行的一层封装,结合 SpringBoot 的开箱即用的特性用起来会非常方便,因为 Spring Cache 通过注解隔离了具体的缓存产品,让用户更加专注于应用层面。
具体的底层缓存技术究竟采用了 Guava、EhCache 还是 Redis,只需要简单的配置就可以实现方便的切换。
3.1 设计理念
正如 Spring 框架的其它服务一样,Spring cache 首先是提供了一层抽象,核心抽象主要体现在两个接口上:
org.springframework.cache.Cache:代表缓存本身
org.springframework.cache.CacheManager:代表对缓存的处理和管理
抽象的意义在于屏蔽实现细节的差异和提供扩展性,Cache 的抽象解耦了缓存的使用和缓存的后端存储,方便后续更换存储。
3.2 使用 Spring Cache
分三步:
1. 声明缓存;
2. 开启 Spring 的 cache 功能;
3. 配置后端的存储。
3.2.1 声明缓存
@Cacheable("books")
public Book findBook(ISBN isbn) {...}
用法很简单,在方法上添加@cacheable 等注解,表示缓存该方法的结果。
当方法有被调用时,先检查 cache 中有没有针对该方法相同参数的调用发生过,如果有,从 cache 中查询并返回结果。如果没有,则执行具体的方法逻辑,并把结果缓存到 cache中。当然这一系列逻辑对于调用者来说都是透明的。
其它的缓存操作的注解包含如下(详细说明可参见官方文档):
@Cacheable triggers cache population
@CacheEvict triggers cache eviction
@CachePut updates the cache without interfering with the method execution
@Caching regroups multiple cache operations to be applied on a method
@CacheConfig shares some common cache-related settings at class-level
3.2.2 开启 Spring Cache 的支持
<cache:annotation-driven /> 或者使用注解@EnableCaching 的方式配置。
3.2.3 配置缓存后端存储
SpringCache 包本身提供了一个 manager 实现,用法如下:
@Bean
public CacheManager cacheManager() {
//jdk里,内存管理器
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(Collections.singletonList(new ConcurrentMapCache("province")));
return cacheManager;
}
更常用的,RedisCacheManager(来自于 Spring Data Redis 项目),用法如下:
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
return RedisCacheManager
.builder(connectionFactory)
.cacheDefaults(
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(20))) //缓存时间绝对过期时间20s
.transactionAware()
.build();
}
3.2.4 缓存 key 的生成
我们都知道缓存的存储方式一般是 key value 的方式,那么在 Spring cache 里,key是如何被设置的呢,在这里要引入 KeyGenerator,它负责 key 的生成策略,只需要按自己的业务,为 params 设计一套生成 key 的规则即可(简单地连接各个参数值):
@Bean
public KeyGenerator keyGenerator() {
return new KeyGenerator() {
@Override
public Object generate(Object target, Method method, Object... params) {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName());
sb.append(method.getName());
for (Object obj : params) {
sb.append(obj.toString());
}
return sb.toString();
}
};
}
3.3 注解风格说明
3.3.1 @CachePut 注解说明
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface CachePut {
@AliasFor("cacheNames")
String[] value() default {}; // 缓存的名字,可以把数据写到多个缓存
@AliasFor("value")
String[] cacheNames() default {};
String key() default ""; // 缓 存 key , 如 果 不 指 定 将 使 用 默 认 的 KeyGenerator 生成
String keyGenerator() default ""; // 默认的KeyGenerator
String cacheManager() default "";
String cacheResolver() default "";
String condition() default ""; // 满足缓存条件的数据才会放入缓存,condition 在调用方法之前和之后都会判断
String unless() default ""; // 用于否决缓存更新的,不像 condition,该表达只在方法执行之后判断,此时可以拿到返回值 result 进行判断了
}
@CachePut 注解使用
应用到写数据的方法上,如新增/修改方法,调用方法时会自动把相应的数据放入缓存。比如下面调用该方法时,会把 user.id 作为 key,返回值作为 value 放入缓存。
3.3.2 @CacheEvict 注解说明
public @interface CacheEvict {
String[] value(); //请参考@CachePut
String key() default ""; //请参考@CachePut
String condition() default ""; //请参考@CachePut
boolean allEntries() default false; //是否移除所有数据
boolean beforeInvocation() default false;//是调用方法之前移除/还是调用之后移除
}
@CacheEvict 注解使用
应用到移除数据的方法上,如删除方法,调用方法时会从缓存中移除相应的数据。
3.3.3 @Cacheable 注解说明
public @interface Cacheable {
@AliasFor("cacheNames")
String[] value() default {}; // 请参考@CachePut
@AliasFor("value")
String[] cacheNames() default {};
String key() default ""; // 请参考@CachePut
String keyGenerator() default ""; // 请参考@CachePut
String cacheManager() default "";
String cacheResolver() default "";
String condition() default ""; // 请参考@CachePut
String unless() default ""; // 请参考@CachePut
boolean sync() default false;
}
@Cacheable 注解使用
应用到读取数据的方法上,即可缓存的方法,如查找方法:先从缓存中读取,如果没有再调用方法获取数据,然后把数据添加到缓存中。
4. 缓存带来的一致性问题
读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
讨论一致性问题之前,先来看一个更新的操作顺序问题:先删除缓存,再更新数据库。
问题:同时有一个请求 A 进行更新操作,一个请求 B 进行查询操作。可能出现:
(1)请求 A 进行写操作(key = 1 value = 2),先删除缓存 key = 1 value = 1;
(2)请求 B 查询发现缓存不存在;
(3)请求 B 去数据库查询得到旧值 key = 1 value = 1;
(4)请求 B 将旧值写入缓存 key = 1 value = 1;
(5)请求 A 将新值写入数据库 key = 1 value = 2;
缓存中数据永远都是脏数据。
比较推荐操作顺序: 先删除缓存,再更新数据库,再删缓存(双删,第二次删可异步延时)。
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(500);
redis.delKey(key);
}
接下来,看一看缓存同步的一些方案
4.1 数据实时同步更新
更新数据库同时更新缓存,使用缓存工具类和或编码实现。
优点:数据实时同步更新,保持强一致性。
缺点:代码耦合,对业务代码有侵入性。
4.2 数据准实时更新
准一致性,更新数据库后,异步更新缓存,使用观察者模式/发布订阅/MQ 实现。
优点:数据同步有较短延迟 ,与业务解耦。
缺点:实现复杂,架构较重。
4.3 缓存失效机制
弱一致性,基于缓存本身的失效机制。
优点:实现简单,无须引入额外逻辑。
缺点:有一定延迟,存在缓存击穿/雪崩问题。
4.4 定时任务更新
最终一致性,采用任务调度框架,按照一定频率更新
优点:不影响正常业务。
缺点:不保证一致性,依赖定定时任务。
5. 缓存穿透、缓存击穿、缓存雪崩及解决方案
5.1 缓存击穿
缓存击穿是指缓存中没有但数据库中有数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
5.2 缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 down 机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:此时需要对数据库的查询操作,加锁 ---- lock (因考虑到是对同一个参数数值上一把锁,此处 synchronized 机制无法使用)
加锁的标准流程代码如下(一样解决击穿的问题):
public Provinces detail(String provinceid) {
// 1.从缓存中取数据
Cache.ValueWrapper valueWrapper = cm.getCache(CACHE_NAME).get(provinceid);
if (valueWrapper != null) {
logger.info("缓存中得到数据");
return (Provinces) (valueWrapper.get());
}
//2.加锁排队,阻塞式锁---100个线程走到这里---同一个sql的取同一把锁
doLock(provinceid);//32个省,最多只有32把锁,1000个线程
try{//第二个线程进来了
// 一次只有一个线程
//双重校验,不加也没关系,无非是多刷几次库
valueWrapper = cm.getCache(CACHE_NAME).get(provinceid);//第二个线程,能从缓存里拿到值?
if (valueWrapper != null) {
logger.info("缓存中得到数据");
return (Provinces) (valueWrapper.get());//第二个线程,这里返回
}
Provinces provinces = super.detail(provinceid);
// 3.从数据库查询的结果不为空,则把数据放入缓存中,方便下次查询
if (null != provinces){
cm.getCache(CACHE_NAME).put(provinceid, provinces);
}
return provinces;
}catch(Exception e){
return null;
}finally{
//4.解锁
releaseLock(provinceid);
}
}
5.3 缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为 id为“-1”的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决方案:使用布隆过滤器。
(1)布隆过滤器的使用方法,类似 java 的 SET 集合,只不过它能以更小的内存,存储更大的数据。
类似以下伪代码:
SET set = new HashSET(); //创建布隆过滤器
初始化加载业务数据
set.add(id)
查询query(id){
set.contain(id)==true ---》 去查数据库
}
(2)对应的生产代码,使用如下:
private BloomFilter<String> bf =null; //等效成一个set集合
@PostConstruct //对象创建后,自动调用本方法
public void init(){//在bean初始化完成后,实例化bloomFilter,并加载数据
List<Provinces> provinces = this.list();
//当成一个SET----- 占内存,比hashset占得小很多
bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), provinces.size());// 32个
for (Provinces p : provinces) {
bf.put(p.getProvinceid());
}
}
@Cacheable(value = "province")
public Provinces detail(String provinceid) {
//先判断布隆过滤器中是否存在该值,值存在才允许访问缓存和数据库
if(!bf.mightContain(provinceid)){
System.out.println("非法访问--------"+System.currentTimeMillis());
return null;
}
System.out.println("数据库中得到数据--------"+System.currentTimeMillis());
Provinces provinces = super.detail(provinceid);
return provinces;
}
@CacheEvict(value = "province",key = "#entity.provinceid")
public Provinces add(Provinces entity) {
super.add(entity);
bf.put(entity.getProvinceid());//新生成,加入过滤器
return entity;
}