第一章 缓存实战笔记
一、缓存的概念
1、外存
外储存器是指除计算机内存及CPU缓存以外的储存器,此类储存器一般断电后仍然能保存数据。常见的外存储器有硬盘、软盘、光盘、U 盘等,一般的软件都是安装在外存中(windows系统指的是CDEF盘, Linux系统指的是挂载点)。
2、内存
内存是计算机中重要的部件之一,它是与CPU进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。内存(Memory)也被称为内存储器,其作用是用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。只要计算机在运行中,CPU就会把需要运算的数据调到内存中进行运算,当运算完成后CPU再将结果传送出来,内存的运行也决定了计算机的稳定运行,此类储存器一般断电后数据就会被清空。
3、缓存
缓存就是把一些外存上的数据保存到内存上而已,怎么保存到内存上呢,我们运行的所有程序,里面的变量值都是放在内存上的,所以说如果要想使一个值放到内存上,实质就是在获得这个变量之后,用一个生存期较长的变量存放你想存放的值,在java中一些缓存一般都是通过map集合来做的。
广义的缓存是把一些慢存(较慢的外存)上的数据保存到快存(较快的存储)上,简单讲就是,如果某些资源或者数据会被频繁的使用,而这些资源或数据存储在系统外部,比如数据库、硬盘文件等,那么每次操作这些数据的时候都从数据库或者硬盘上去获取,速度会很慢,会造成性能问题(系统停工待料)。于是我们把这些数据冗余一份到快存里面,每次操作的时候,先到快存里面找,看有没有这些数据,如果有,那么就直接使用,如果没有那么就获取它,并复制一份到快存中,下一次访问的时候就可以直接从快存中获取。从而节省大量的时间,可以看出,缓存是一种典型的空间换时间的方案。生活中这样的例子处处可见,举例:
举例1:CPU--L1/L2--内存--磁盘
CPU需要数据时先从L1/L2中读取,如果没有到内存中找,如果还没有会到磁盘上找。
举例2:Maven
还有如用过Maven的朋友都应该知道,我们找依赖的时候,先从本机仓库找,再从本地服务器仓库找,最后到远程仓库服务器找。
举例3:京东仓储
还有如京东的物流为什么那么快?他们在各个地都有分仓库,如果该仓库有货物那么送货的速度是非常快的。
举例4 :数据库中的索引,也是以空间换取时间,缓存也是以空间换取时间。
4、重要的指标
4.1、什么样的数据适合缓存?
1)访问频率高
2)更改频率低
3)一致性要求不高
4.2 缓存命中率
表明缓存是否运行良好的,即从缓存中读取数据的次数与总读取次数的比率,命中率越高越高:
命中率 = 从缓存中读取次数 / (总读取次数[从缓存中读取次数 + 从慢速设备上读取的次数])
Miss率 = 没有从缓存中读取的次数 / (总读取次数[从缓存中读取次数 + 从慢速设备 上读取的次数])
这是一个非常重要的监控指标,如果做缓存一定要健康这个指标来看缓存是否工作良好。
4.3 移除策略
不同的移除策略实际上看的是不同的指标,即如果缓存满了,从缓存中移除数据的策略。
常见的有:LFU、LRU、FIFO:
FIFO(First In First Out):先进先出算法,即先放入缓存的先被移除;
LRU(Least Recently Used):最近最少使用,使用时间距离现在最久的那个被移除;
LFU(Least Frequently Used):最不经常使用,一定时间段内使用次数(频率)最少的那个被移除。
TTL(Time To Live):存活期,即从缓存中创建时间点开始直到它到期的一个时间段(不管在这个时间段内有没有访问都将过期)。
TTI(Time To Idle):空闲期,即一个数据多久没被访问将从缓存中移除的时间。
实际设计缓存时以上重要指标都应该考虑进去,当然根据实际需求可能有的指标并不会采用进设计去。
5、缓存在java中的实现
在Java中,我们一般对调用方法进行缓存控制,比如我调用findUserById(Long id),那么我应该在调用这个方法之前先从缓存中查找有没有,如果没有再掉该方法如从数据库加载用户,然后添加到缓存中,下次调用时将会从缓存中获取到数据。Java中广泛使用的分布式缓存Redis。
5.1、缓存逻辑流程
流程图如下:
逻辑流程代码
@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;
}
5.2、缓存---更新的陷阱
由于写数据花费的时间一般是比读数据要长一些,比如当我们有一个写请求过来时,先将缓存数据删除,然后再数据局对数据进行更新的过程中正好有一个读请求过来,这个时候由于缓存中没有数据所以直接去数据库中查询,此时很有可能那到的数据是未修改完成的数据,这种情况就是缓存的更新陷阱。
二、基于注解的Cache
Spring 3.1起,提供了基于注解的对Cache的支持。使用Spring Cache的好处:
- 基于注解,代码清爽简洁;
- 基于注解也可以实现复杂的逻辑;
- 可以对缓存进行回滚;
Spring Cache并非具体的缓存技术,而是基于各种缓存产品(如Guava、EhCache、Redis等)共性进行的一层封装,结合SpringBoot的开箱即用的特性用起来会非常方便,因为Spring Cache通过注解隔离了具体的缓存产品,让用户更加专注于应用层面。具体的底层缓存技术究竟采用了Guava、EhCache还是Redis,只需要简单的配置就可以实现方便的切换。
1、设计理念
正如Spring框架的其它服务一样,Spring cache先是提供了一层抽象,核心抽象主要体现在两个接口上:
org.springframework.cache.Cache:代表缓存本身;
org.springframework.cache.CacheManager:代表对缓存的处理和管理抽象的意义在于屏蔽实现细节的差异和提供扩展性,Cache 的抽象解耦了缓存的使用和缓存的后端存储,方便后续更换存储。
2、使用Spring Cache
使用spring cache分三步:声明缓存,开启Spring的cache功能,配置后端的存储。
2.1、Springcache的用法
缓存声明:
@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。
2.2、开启Spring Cache的支持
<cache:annotation-driven /> 或者使用注解@EnableCaching的方式
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 RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory,
ResourceLoader resourceLoader) {
RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(determineConfiguration(resourceLoader.getClassLoader()));
List<String> cacheNames = this.cacheProperties.getCacheNames();
if (!cacheNames.isEmpty()) {
builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
}
return this.customizerInvoker.customize(builder.build());
}
2.4、缓存key的生成
我们都知道缓存的存储方式一般是key value的方式,那么在Spring cache里,key是如何被设置的呢,在这里要引入KeyGenerator,它负责key的生成策略,只需要按自己的业务,为params设计一套生成key的规则即可(简单地连接各个参数值):
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
//key的生成,springcache的内容,跟具体实现缓存器无关
//自定义本项目内的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();
}
};
}
2.5、Spring表达式--- SpEl
SpEl表达式可基于上下文并通过使用缓存抽象,提供与root独享相关联的缓存特定的内置参数:
3、注解风格说明
3.1、@CachePut 注解说明
package org.springframework.cache.annotation;
public @interface CachePut {
//TODO 缓存的名字,可以把数据写到多个缓存
@AliasFor("cacheNames")
String[] value() default {};
@AliasFor("value")
String[] cacheNames() default {};
//TODO 缓存key,如果不指定将使用默认的KeyGenerator生成,后边介绍
String key() default "";
String keyGenerator() default "";
String cacheManager() default "";
String cacheResolver() default "";
//TODO 满足缓存条件的数据才会放入缓存,condition 在调用方法之前和之后都会判断
String condition() default "";
//TODO 用于否决缓存更新的,不像 condition,该表达只在方法执行之后判断,此时可以拿到返回值 result 进行判断了
String unless() default "";
}
@CachePut注解使用:
应用到写数据的方法上,如新增/修改方法,调用方法时会自动把相应的数据放入缓存。比如下面调用该方法时,会把user.id作为key,返回值作为value放入缓存。
3.2、@CacheEvict 注解说明
@CacheEvictpublic @interface CacheEvict {
String[] value(); //请参考
@CachePut String key() default ""; //请参考
@CachePut String condition() default ""; //请参考
@CachePut boolean allEntries() default false; //是否移除所有数据
//是调用方法之前移除/还是调用之后 移除
boolean beforeInvocation() default false;
}
@CacheEvict注解使用:
应用到移除数据的方法上,如删除方法,调用方法时会从缓存中移除相应的数据。
3.3、@Cacheable 注解说明
public @interface Cacheable {
String[] value(); //请参考@CachePut
String key() default ""; //请参考@CachePut
String condition() default "";//请参考@CachePut
String unless() default ""; //请参考@CachePut
}
@Cacheable注解使用:
应用到读取数据的方法上,即可缓存的方法,如查找方法:先从缓存中读取,如果没有再调用方法获取数据,然后把数据添加到缓存中。
三、缓存带来的一致性问题
一致性问题读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存(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);
}
接下来,看一看缓存同步的一些方案,见下图:
1、数据实时同步更新
更新数据库同时更新缓存,使用缓存工具类和或编码实现。
优点:数据实时同步更新,保持强一致性
缺点:代码耦合,对业务代码有侵入性
2、数据准实时更新
准一致性,更新数据库后,异步更新缓存,使用观察者模式/发布订阅/MQ 实现;
优点:数据同步有较短延迟 ,与业务解耦
缺点:实现复杂,架构较重
3、缓存失效机制
弱一致性,基于缓存本身的失效机制
优点:实现简单,无须引入额外逻辑
缺点:有一定延迟,存在缓存击穿/雪崩问题
4、定时任务更新
最终一致性,采用任务调度框架,按照一定频率更新
优点:不影响正常业务
缺点:不保证一致性,依赖定时任务
5、缓存过期与一致性问题对比
四、缓存穿透、击穿、雪崩及解决方案
1、缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于 并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力 瞬间增大,造成过大压力。
2、缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压 力过大甚至 down 机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩 是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:
1)加锁、限流:对某个key只允许一个线程查询数据和写缓存。
2)缓存预热:常用值,使用http接口预热错峰加载。
3)改被动失效为主动失效:什么时候失效,我说了算。
此时需要对数据库的查询操作,加锁lock (因考虑到是对同一个参数数值上一把锁,此处synchronized 机制无法使用),加锁的标准流程代码如下(一样解决击穿的问题ProvincesServiceImpl3):
public Provinces detail(String provinceid) {
// TODO 1.从缓存中取数据
Cache.ValueWrapper valueWrapper = cm.getCache(CACHE_NAME).get(provinceid);
if (valueWrapper != null) {
logger.info("缓存中得到数据");
return (Provinces) (valueWrapper.get());
}
//TODO 2.加锁排队,阻塞式锁---100个线程走到这里---同一个sql的取同一把锁
doLock(provinceid);//32个省,最多只有32把锁,1000个线程
try{
//TODO 第二个线程进来了,一次只有一个线程,双重校验,不加也没关系,无非是多刷几次库
valueWrapper = cm.getCache(CACHE_NAME).get(provinceid);//第二个线程,能从缓存里拿到值?
if (valueWrapper != null) {
logger.info("缓存中得到数据");
return (Provinces) (valueWrapper.get());//第二个线程,这里返回
}
Provinces provinces = super.detail(provinceid);
// TODO 3.从数据库查询的结果不为空,则把数据放入缓存中,方便下次查询
if (null != provinces){
cm.getCache(CACHE_NAME).put(provinceid, provinces);
}
return provinces;
}catch(Exception e){
return null;
}finally{//4.解锁
releaseLock(provinceid);
}
}
private void releaseLock(String userCode) {
ReentrantLock oldLock = (ReentrantLock) locks.get(userCode);
if(oldLock !=null && oldLock.isHeldByCurrentThread()){
oldLock.unlock();
}
}
// 给一个搜索条件,对应一个锁
private void doLock(String lockcode) {
//TODO provinceid有不同的值,参数多样化
//TODO provinceid相同的,加一个锁,不是同一个key,不能用同一个锁
ReentrantLock newLock = new ReentrantLock();//创建一个锁
//TODO 若已存在,则newLock直接丢弃
Lock oldLock = locks.putIfAbsent(lockcode, newLock);
if(oldLock == null){
newLock.lock();
}else{
oldLock.lock();
}
}
3、缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为 id 为“-1”的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导 致数据库压力过大。
解决方案:使用布隆过滤器(不存在的数据,直接拦截)
1)布隆过滤器的使用方法
类似java的SET集合,只不过它能以更小的内存,存储更大的数据,类似以下伪代码:
SET set = new HashSET(); //创建布隆过滤器 初始化加载业务数据
set.add(id)
查询:query(id){set.contain(id)==true ---》 去查数据库 }
2)对应的生产代码,使用如下:
/**
* 缓存穿透,使用布隆过滤器BloomFilter
*/
//@Service("provincesService")
public class ProvincesServiceImpl4 extends ProvincesServiceImpl implements ProvincesService{
//TODO 布隆过滤器,等效成一个set集合
private BloomFilter<String> bf =null;
@PostConstruct //对象创建后,自动调用本方法
public void init(){//在bean初始化完成后,实例化bloomFilter,并加载数据
List<Provinces> provinces = this.list();
//TODO 当成一个SET----- 占内存,比hashset占得小很多
bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), provinces.size());// 指定大小32个
for (Provinces p : provinces) {
bf.put(p.getProvinceid()); // 将ID放入过滤器
}
}
@Cacheable(value = "province")
public Provinces detail(String provinceid) {
//TODO 先判断布隆过滤器中是否存在该值,值存在才允许访问缓存和数据库
if(!bf.mightContain(provinceid)){
System.out.println("非法访问--------"+System.currentTimeMillis());
return null;
}
System.out.println("数据库中得到数据--------"+System.currentTimeMillis());
Provinces provinces = super.detail(provinceid);
return provinces;
}
3)布隆过滤器算法原理: