缓存使用
为了系统性能提升,我们一般都会将部分数据放入缓存中,加速访问,而db承担数据落盘工作。
那些数据适合放入缓存
- 及时性,数据一致性要求不高的
- 访问量大而且更新频率不高的数据(读多,写少)
例如:电商应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,卖家需要5分钟后才能看到新的商品一般还是可以接受的。
User user = cache.load(id)//从缓存加载数据
if(Objects.isEntity(user)){//缓存里面没有数据
user = mysql.load(id)//去数据库里面取数据
cache.put(user);//保存到缓存里面去
return user;
}else{
//有缓存的情况下
return user;
}
SpringBoot整合Redis
- 导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 注入
@Autowired
private StringRedisTemplate redisTemplate;
- 简单使用
public Map<Long, List<Catelog2Vo>> getCatalogJson(){
//1,加入缓存逻辑
String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
if (StringUtils.isEmpty(CatalogJson)){
Map<Long, List<Catelog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
redisTemplate.opsForValue().set("methods:getCatalogJson", JSONObject.toJSONString(catalogJsonFromDb));
return catalogJsonFromDb;
}
Map<Long, List<Catelog2Vo>> map = JSON.parseObject(CatalogJson,new TypeReference<Map<Long, List<Catelog2Vo>>>(){});
return map;
}
缓存失效问题
缓存穿透
指查询一个不存在的数据,由于缓存无法命中,从而去查询数据库,但是数据库也没有记录,所以就不会将这个结果写入缓存,这将导致这个不存在的数据每次请求都要到缓存层去查询,从而失去了缓存的意义。
- 理由不存在的数据进行攻击,数据库瞬间压力增大,最总导致崩溃
- 可以给null结果进行缓存,并加入短暂的过期时间,这样就可以抵挡住先请求,减轻数据库压力。
缓存击穿
对于一些设置了过期时间的key,如果这些key看你会在某些时间点被高并发的访问,时一种非常“热点”的数据
如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落到db,我们成为缓存击穿
- 解决方法:加锁,大量并发下只让一个人去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,就不用去db查询了。
缓存雪崩
缓存雪崩是指在我们设置缓存时key采用了相同的过期时间,导致环境在某一时刻同时失效,请求全部转发到DB,DB瞬时间压力过大导致崩溃
- 解决方法:原有的失效时间基础上增加一个随机值,例如1-5分钟随机,这样每一个缓存过期时间重复率就会降低,就很难引起集体失效的事件。
总结
- 穿透:原因:高并发下一百万人同时去查询一个不存在的数据,那么redis中没有,就全部去查询mysql了,但是mysql里面也没有,那么一百万人同时查询就全部去查询db了,导致数据库崩溃
- 穿透:解决方法:第一个人去查询redis 的时候没有数据,再去查询mysql,但是这个时候mysql也没数据,那么就吧这个null存redis,并设置一个短暂的过期时间。
- 雪崩:原因:同一有大量的key过期,但是这个时候并发很大,一万个不同key同时过期,同时又有很多人查询这一万个key,导致全部都先去db里面查询然后在同步到redis,但是db一次扛不住一万次查询,导致宕机
- 雪崩:解决方法:在设置key过期时间的时候,将设置原有的过期时间+随机时间,这样每个key 的过期时间都不一样,就不会出现大面积的key到期,大量请求全部查询db,导致宕机。
- 穿透:原因:某个key在高并发下被很多人同时访问,这个时候key突然到过期时间了,导致这么多人同时并行请求接口,全部去查询db了,导致db宕机
- 穿透:解决方法:加锁,先让一个人去查询,然后把查询的结果保存到redis里面,然后在让其他人来访问。
并发锁
本地锁
- 使用synchronized把当前对象锁住,那么就只能一次性有一条线程可以进入。
- 优点:简单
- 缺点:在集群多台实例下失效,因为每个实例都有一个本地锁
@Override
public String getCatalogJson() {
String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
if (StringUtils.isEmpty(CatalogJson)) {
System.out.println("没有命中缓存.............");
String catalogJsonFromDb = getCatalogJsonFromDb();
redisTemplate.opsForValue().set("methods:getCatalogJson", JSONObject.toJSONString(catalogJsonFromDb));
return catalogJsonFromDb;
}
System.out.println("命中缓存");
return CatalogJson;
}
public synchronized String getCatalogJsonFromDb() {
synchronized (this) {
String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
if (!StringUtils.isEmpty(CatalogJson)) {
System.out.println("直接返回");
return CatalogJson;
}
String msg = db.list();
System.out.println("查询数据库");
return msg;
}
}
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("命中缓存");
System.out.println("命中缓存");
System.out.println("直接返回");
System.out.println("直接返回");
- 为什么我们添加了锁,还是会有很多线程同时去查询db呢?
- 因为这里代码写的有问题,锁的粒度很小,导致,数据还没有放到缓存中去,其他线程就进入到锁里面,所以才会出现多次查询数据库,导致锁失效。
- 锁的时序性问题,就是当前1线程和2线程同时请求进来,1线程强到锁了,去查询缓存没有,再去查询数据库,方法结束释放锁,在把结果放到缓存,但是这个时候,1线程释放锁后,数据还在存放缓存中的时候,2线程里面就拿到锁了,就去查询缓存,因为1线程的数据还没有成功放到缓存中,所以是没有的,然后2线程继续查询数据库查询完成方法结束,释放锁。
- 解决方法:增加锁的粒度,将结果放入缓存操作也放到锁里面。
- 将缓存存放操作放到锁里,这样就避免了锁的时序问题。
@Override
public String getCatalogJson() {
String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
if (StringUtils.isEmpty(CatalogJson)) {
System.out.println("没有命中缓存.............");
String catalogJsonFromDb = getCatalogJsonFromDb();
return catalogJsonFromDb;
}
System.out.println("命中缓存");
return CatalogJson;
}
public synchronized String getCatalogJsonFromDb() {
synchronized (this) {
String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
if (!StringUtils.isEmpty(CatalogJson)) {
System.out.println("直接返回");
return CatalogJson;
}
String msg = db.list();
System.out.println("查询数据库");
//将存入缓存操作也放到锁里面
redisTemplate.opsForValue().set("methods:getCatalogJson", JSONObject.toJSONString(msg));
return msg;
}
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("查询数据库");
System.out.println("直接返回");
System.out.println("直接返回");
System.out.println("直接返回");
System.out.println("直接返回");
System.out.println("命中缓存");
System.out.println("命中缓存");
System.out.println("命中缓存");
System.out.println("命中缓存");
分布式锁
- 优点:多台实例集群下,保证每个机器都可以锁住
- 缺点:麻烦,需要中间件实现
使用redis实现分布式锁
public Map<Long, List<Catelog2Vo>> getCatalogJsonFromCache() {
String uuid = UUID.randomUUID().toString();
//上锁
Boolean look = redisTemplate.opsForValue().setIfAbsent("look", uuid, 300, TimeUnit.SECONDS);
Map<Long, List<Catelog2Vo>> categoryListFromDb=null;
if (look) {//上锁成功
//执行业务代码
categoryListFromDb = getCategoryListFromDb();
//解锁
String lua = "if redis.call('get', KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<Long>(lua, Long.class), Arrays.asList("look"), uuid);
return categoryListFromDb;
} else {//没有抢到锁就自旋重试
try {
Thread.sleep(100);
System.out.println("重试,自旋");
categoryListFromDb=getCatalogJsonFromCache();
} catch (Exception e) {
}
}
return categoryListFromDb;
}
使用redisson框架实现
- 使用redisson可以实现更高级的功能
- redisson实现了JUC中的Look方法,使得我们使用redisson更简单
- redsson里面有看门狗机制,我们加锁后不需要指定过期时间,由看门狗来自动检测是否我们业务执行完成,原理:(加锁的时候会默认指定一个30s的过期时间,当执行到过期时间的三分之一的时候这个锁还没有被删掉,那么看门狗会给这个锁续期,直到这个业务执行完成,释放锁)
- 导包
<!-- 分布式锁-->
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
- 实例化bean
package com.atguigu.gulimall.product.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.cluster.ClusterConnectionManager;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedisSonConfig {
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(4);
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
Lock可重入锁
@Autowired
private RedissonClient redissonClient;
@GetMapping("/hello")
public String hello() {
//获取一把锁,只需要锁的名字一样,那么就是同一把锁
RLock lock = redissonClient.getLock("my-lock");
//加锁
lock.lock();//阻塞式等待,默认加的锁过期时间是30秒
//自动续期,如果业务超长时间,运行期间会自动给锁续上新的30s,不用担心业务时间长,锁自动过期被删掉
try {
System.out.println("加锁成功...执行业务"+Thread.currentThread().getId());
Thread.sleep(30000);
}catch (Exception e){
e.printStackTrace();
}finally {
//解锁,加锁这个解锁代码没有执行,也不会出现死锁问题。
lock.unlock();
System.out.println("成功解锁"+Thread.currentThread().getId());
}
return "hello";
}
RedWriteLock读写锁
- 读写锁指的是,可以同时读,但是不能编写边读
- 在写操作还没有释放锁的时候,读会一直阻塞状态
- 写操作完成后,读才会拿到锁进行读取操作
- 保证一读取到的是最新的数据,修改期间,写锁是一个互斥锁,读锁是一个共享锁。
- 写锁没释放就读必须等待
- 读+读,相当于无所,并发读,只会在redis中记录好,所以当前的读锁,他们会同时加锁成功。
- 写+读,等待写锁释放
- 写+写,阻塞方式
- 读+写,有读锁,写也需要等待。
@GetMapping("/write")
@ResponseBody
public String write() {
//获取一把锁,只需要锁的名字一样,那么就是同一把锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
//加锁
RLock rLock = readWriteLock.writeLock();
String s = UUID.randomUUID().toString();
try {
rLock.lock();
Thread.sleep(30000);
stringRedisTemplate.opsForValue().set("red-write-lock", s);
} catch (Exception e) {
e.printStackTrace();
} finally {
//解锁,加锁这个解锁代码没有执行,也不会出现死锁问题。
rLock.unlock();
System.out.println("成功解锁" + Thread.currentThread().getId());
}
return "成功";
}
@GetMapping("/read")
@ResponseBody
public String read() {
//获取一把锁,只需要锁的名字一样,那么就是同一把锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
//加锁
RLock rLock = readWriteLock.readLock();
String s="";
try {
rLock.unlock();
s = stringRedisTemplate.opsForValue().get("red-write-lock");
} catch (Exception e) {
e.printStackTrace();
} finally {
//解锁,加锁这个解锁代码没有执行,也不会出现死锁问题。
rLock.unlock();
System.out.println("成功解锁" + Thread.currentThread().getId());
}
return s;
}
Semaphore信号量
- 信号量类似于地下车库,车库位置固定那么多,停一个车少一个车位,开走一辆车多一个车位
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
//获取信号量锁
RSemaphore park = redissonClient.getSemaphore("park");
//取一个信号量,信号量-1,如果没有信号量了就会一直在这里等待。
park.acquire();
return "ok";
}
@GetMapping("/go")
@ResponseBody
public String go() throws InterruptedException {
//获取信号量锁
RSemaphore park = redissonClient.getSemaphore("park");
//释放一个信号量,信号量+1
park.release();
return "ok";
}
CountDownLatch闭锁
- 闭锁类似于学校锁大门,当全部班级人走完后,才可以锁学校大门,如果其中有一个班没有走,那么就不能锁大门。
@GetMapping("/lockdoor")
@ResponseBody
public String lockdoor() throws InterruptedException {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.trySetCount(5);//假设5个班,
door.await();//当班级没有走完,就会一直在这里阻塞住
return "全部班级走完,锁学校大门";
}
@GetMapping("/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id) throws InterruptedException {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.countDown();//走一个班就执行一次。
return id+"班走了";
}
缓存一致性
当我们修改了某条数据,但是缓存里面没有修改,导致缓存不一致,有有两种解决问题
- 双写模式:当修改的时候写入数据库同时写入缓存
- 失效模式:当修改的时候写入数据库,删掉缓存,下次请求进来获取最新的数据写入缓存。
注意:明面上看着都没有问题,但是在高并发下都会有几率出现问题
解决方案
无论是双写模式还是失效模式,都会导致缓存不一致的问题,既多个实例同时更新会出事 怎么办?
- 如果是用户维度数据(订单数据,用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据上加上过期时间,每隔一段时间触发读的主动更新即可。
- 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅mysql的binlog方式
- 缓存数据+过期时间也足够解决大部分对于缓存的要求。
- 通过分布式(读写锁)保证并发读写,写写的时候按顺序排好队,读读无所谓,所以适合使用读写锁(业务不关心脏数据,运行临时脏数据可忽略);
总结
- 我们能放入缓存的数据根本就不应该是实时性,一致性要求超高的,所以缓存数据的适合加上过期时间,保证每天拿到当前最新数据即可。
- 我们不应该过度设计,增加系统的复杂性
- 遇到实时性,一致性要求高的数据,就应该查数据库,即使慢点。
canal原理
SpringCache
- 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
- 配置缓存中间件,type:选择缓存中间件,time-to-live:缓存过期时间
spring:
cache:
type: redis #设置缓存中间件
redis:
time-to-live: 360000 #key获取时间
use-key-prefix: true #是否开启前缀
cache-null-values: true #是否缓存空值,解决缓存穿透问题
key-prefix: CACHE_ #统一前缀
定义JSON存储
package com.atguigu.gulimall.product.config;
import com.alibaba.fastjson.support.spring.FastJsonRedisSerializer;
import com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@EnableCaching
public class MyCacheConfig {
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()));
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
注解使用
Cacheable
获取结果并保存到缓存,下次请求来直接读取缓存返回
- category:缓存名称(分区)
@Cacheable({"category"})
@Override
public List<CategoryEntity> getLave1CateGorys() {
System.out.println("getLave1CateGorys");
// long timeMillis = System.currentTimeMillis();
List<CategoryEntity> categoryEntityList = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
// System.out.println("耗费时间:"+(System.currentTimeMillis()-timeMillis));
return categoryEntityList;
}
CacheEvict
触发将数据从缓存中删除
@CacheEvict(value = "category",key = "'getLave1CateGorys'")
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}
- 一次删除多个缓存
@Caching(evict = {
@CacheEvict(value = "category",key = "'getCatalogJson'"),
@CacheEvict(value = "category",key = "'getLave1CateGorys'")
})
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}
- 一次性删除多个缓存
@CacheEvict(value = "category",allEntries = true)
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}
CachePut
双写模式,吧当前返回的值,在缓存中在存一份。
@CacheEvict(value = "category",allEntries = true)
@CachePut
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}
注意:
- 存储同一个类型的数据,都可以指定成一个分区,分区名默认就是缓存的前缀。