缓存
缓存流程
缓存分类
-
本地缓存
在分布式环境中,本地缓存就会存在数据冗余和效率不高的问题,从而需要使用分布式缓存 -
分布式缓存
-
整合redis
添加依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
修改配置文件
spring: redis: host: 192.168.101.130 port: 6379
使用
@Autowired private StringRedisTemplate redisTemplate; public Map<String, List<CategoryEntity>> getCatelog2JSON() { String categoryData = redisTemplate.opsForValue().get("categoryData"); if (categoryData != null) { System.out.println("缓存命中了"); return JSONObject.parseObject(categoryData, Map.class); } System.out.println("查询数据库"); List<CategoryEntity> allCategoryEntity = this.list(); // 获取所有的一级分类的数据 List<CategoryEntity> levelOneOfCategory = this.getLevelOneOfCategory(); Map<String, List<CategoryEntity>> categoryMap = levelOneOfCategory.stream().map(levelOne -> { levelOne.setChildrens(getChildren(levelOne, allCategoryEntity)); return levelOne; }).collect(Collectors.toMap(levelOne -> levelOne.getCatId().toString(), levelOne -> levelOne.getChildrens())); redisTemplate.opsForValue().set("categoryData", JSONObject.toJSONString(categoryMap), 10, TimeUnit.MINUTES); return categoryMap; }
-
存在的问题
通过jmeter在50并发的情况下会发现控制台输出如下
这个结果并不是像我们期望的那样只查询一次数据库,因为在高并发的情况下,第一个请求从查询数据库到放入缓存的过程中,其他请求就不会命中缓存,那么也会去查询数据库,这就是缓存击穿。 -
缓存穿透/雪崩/击穿
- 缓存穿透
描述: 查询一个数据库一定不存在的数据,由于缓存是不命中,那么请求就会去查询数据库,但数据库也查询不到对应的记录,由于没有将查询的结果null写入缓存,那么后续的请求都会去查询数据库,从而失去了缓存的意义
解决方案: 将查询出的null写入缓存
实现:
- 缓存雪崩
描述: 缓存雪崩是指在我们设置缓存时不同key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩
解决方案: 在设置的过期时间基础上增加一个随机值
实现: - 缓存击穿
描述: 在大量的请求访问同一个key的缓存数据时,此时缓存数据不存在或者失效时,前面n个请求都没有命中缓存,从而导致这n个请求需要请求DB
解决方案: 加锁,大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去db
实现:
- 三者之间的区别:
击穿和穿透的区别在于,数据是否存在于数据库中;
击穿和雪崩的区别在于,是否是同一个key的数据失效
- 缓存穿透
-
本地锁可以解决单节点的缓存穿透问题,但在分布式环境下,是没有办法锁住其他节点的操作的,这种情况肯定是有问题的
针对本地锁的问题,我们需要通过分布式锁来解决,那么是不是意味着本身锁在分布式场景下就不需要了呢?
显然不是这样的,因为如果分布式环境下的每个节点不控制请求的数量,那么分布式锁的压力会非常大,这时我们需要本地锁来控制每个节点的同步,来降低分布式锁的压力,所以实际开发中我们都是本地锁和分布式锁结合使用的。
分布式锁
分布式锁的原理
分布式锁或者本地锁的本质其实是一样的,都是将并行的操作转换为了串行的操作
分布式锁的常用解决方案
- 数据库
数据库隔离性:唯一索引 - Zookeeper
临时有序节点 - Redis
setNx命令 - Redisson
Redis分布式锁的实现
- 基础代码
获取数据:
加锁:public Map<String, List<CategoryEntity>> getData() { String key = "categoryData"; String categoryData = redisTemplate.opsForValue().get(key); if (categoryData != null) { System.out.println("缓存命中了"); return JSONObject.parseObject(categoryData, Map.class); } System.out.println("查询数据库"); List<CategoryEntity> allCategoryEntity = this.list(); // 获取所有的一级分类的数据 List<CategoryEntity> levelOneOfCategory = this.getLevelOneOfCategory(); Map<String, List<CategoryEntity>> categoryMap = levelOneOfCategory.stream().map(levelOne -> { levelOne.setChildrens(getChildren(levelOne, allCategoryEntity)); return levelOne; }).collect(Collectors.toMap(levelOne -> levelOne.getCatId().toString(), levelOne -> levelOne.getChildrens())); //categoryMap = null; 模拟缓存穿透 if (categoryMap == null) { //数据库中也不存在,在缓存中也存一分,防止缓存穿透 redisTemplate.opsForValue().set(key, "{}", 5, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, JSONObject.toJSONString(categoryMap), Long.valueOf(new Random().nextInt(10)), TimeUnit.MINUTES); } return categoryMap; }
public Map<String, List<CategoryEntity>> getCatelog2JSONByRedisLock() { Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "lock"); if (lock) { //抢到锁则获取数据 Map<String, List<CategoryEntity>> data = getData(); //释放锁 redisTemplate.delete("lock"); return data; } else { //自旋获取锁 return getCatelog2JSONByRedisLock(); } }
- 上面的代码其实是存在问题的,如果在getData()方法中出现了异常,那么就不会删除key也就是不会释放锁,从而造成了死锁,针对这个问题我们可以在获取锁后加上一个过期时间
- 上面虽然解决了getData()中的异常造成死锁的问题,但是如果在执行expire之前就已经发生了异常,那么同样会出现死锁问题,所以需要获取锁和设置过期时间的操作能够保持原子性,可以在获取锁的同时指定过期时间
- 上面的代码看似很完美了,但是如果getData()执行的时间很长,超过了设置的30s的过期时间,那么就会存在业务代码还没执行完,锁已经释放了
比如线程A获取到了锁,执行getData()用了35秒,在30秒时锁已经释放了,被线程B获取到,那么在35秒时线程A还是会执行delete操作,这个时候删除的其实是线程B的锁,从而对线程B的数据处理造成数据不安全的问题
针对这个问题,我们可以查询锁的value通过UUID来区分,释放锁的时候判定当前锁的value是不是自己的,具体代码如下:
- 上面的代码查询key和删除key不是一个原子操作,那么就会出现查询出来key后key过期了,那么又会出现删除其他线程key的情况,那么针对这种情况就需要保证查询key和删除key是一个原子操作。
public Map<String, List<CategoryEntity>> getCatelog2JSONByRedisLock() { String uuid = UUID.randomUUID().toString(); //加锁,设置key的同时设置过期时间 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "lock" + uuid,30, TimeUnit.SECONDS); if (lock) { //设置过期时间 //redisTemplate.expire("lock", 30, TimeUnit.SECONDS); //抢到锁则获取数据 Map<String, List<CategoryEntity>> data; try { data = getData(); } finally { String srcipts = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end "; // 通过Redis的lua脚本实现 查询和删除操作的原子性 redisTemplate.execute(new DefaultRedisScript<Integer>(srcipts,Integer.class) ,Arrays.asList("lock"),uuid); } return data; } else { //自旋获取锁 return getCatelog2JSONByRedisLock(); } }
- 总结
redis实现分布式锁的过程非常繁琐并且很容易出错,对此可以使用更加高效的Redisson实现分布式锁
Redisson
- redisson整合
添加依赖
配置类<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.16.1</version> </dependency>
@Configuration public class MyRedisConfig { @Bean public RedissonClient redissonClient(){ Config config = new Config(); // 配置连接的信息 config.useSingleServer() .setAddress("redis://192.168.101.130:6379"); RedissonClient redissonClient = Redisson.create(config); return redissonClient; } }
- 可重入锁
/** * 1.锁会自动续期,如果业务时间超长,运行期间Redisson会自动给锁重新添加30s,不用担心业务时间,锁自动过去而造成的数据安全问题 * 2.加锁的业务只要执行完成, 那么就不会给当前的锁续期,即使我们不去主动的释放锁,锁在默认30s之后也会自动的删除 * @return */ @ResponseBody @GetMapping("/hello") public String hello(){ RLock myLock = redissonClient.getLock("myLock"); // 加锁 myLock.lock(); try { System.out.println("加锁成功...业务处理....." + Thread.currentThread().getName()); Thread.sleep(30000); }catch (Exception e){ }finally { System.out.println("释放锁成功..." + Thread.currentThread().getName()); // 释放锁 myLock.unlock(); } return "hello"; }
- 读写锁
@GetMapping("/writer") @ResponseBody public String writerValue(){ RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock"); // 加写锁 RLock rLock = readWriteLock.writeLock(); String s = null; rLock.lock(); // 加写锁 try { s = UUID.randomUUID().toString(); stringRedisTemplate.opsForValue().set("msg",s); Thread.sleep(30000); } catch (InterruptedException e) { e.printStackTrace(); } finally { rLock.unlock(); } return s; } @GetMapping("/reader") @ResponseBody public String readValue(){ RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock"); // 加读锁 RLock rLock = readWriteLock.readLock(); rLock.lock(); String s = null; try { s = stringRedisTemplate.opsForValue().get("msg"); }finally { rLock.unlock(); } return s; }
- 闭锁
@GetMapping("/lockDoor") @ResponseBody public String lockDoor(){ RCountDownLatch door = redissonClient.getCountDownLatch("door"); door.trySetCount(5); try { door.await(); // 等待数量降低到0 } catch (InterruptedException e) { e.printStackTrace(); } return "关门熄灯..."; } @GetMapping("/goHome/{id}") @ResponseBody public String goHome(@PathVariable Long id){ RCountDownLatch door = redissonClient.getCountDownLatch("door"); door.countDown(); // 递减的操作 return id + "下班走人"; }
- 信号量
@GetMapping("/park") @ResponseBody public String park(){ RSemaphore park = redissonClient.getSemaphore("park"); boolean b = true; try { // park.acquire(); // 获取信号 阻塞到获取成功 b = park.tryAcquire();// 返回获取成功还是失败 } catch (Exception e) { e.printStackTrace(); } return "停车是否成功:" + b; } @GetMapping("/release") @ResponseBody public String release(){ RSemaphore park = redissonClient.getSemaphore("park"); park.release(); return "释放了一个车位"; }
缓存一致性
针对于上的两种解决方案我们怎么选择?
- 缓存的所有数据我们都加上过期时间,数据过期之后主动触发更新操作
- 使用读写锁来处理,读读的操作是不相互影响的
无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
- 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可
- 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
- 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
- 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略)
总结:
- 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
- 我们不应该过度设计,增加系统的复杂性
- 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
SpringCache
-
基本使用
引入依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
添加配置
spring: cache: type: redis
放开缓存
启动类添加 @EnableCaching -
常用注解
@Cacheable:放入缓存
key:缓存的key
value:key的前缀
sync:是否同步,设置为true是可以防止缓存击穿@CacheEvict:清空缓存
@CachePut
@Caching
@CacheConfig
-
注解使用
@Cacheable@Cacheable(cacheNames = "category", key = "'info'") //spel表达式 //@Cacheable(cacheNames = "category", key = "root.methodName") //参数 id作为key //@Cacheable(cacheNames = "category", key = "#id") public JSONObject testCacheable(int id) { System.out.println("查询数据库了。。。。"); JSONObject info = new JSONObject(); info.put("name", "aaa"); info.put("age", 24); JSONObject address = new JSONObject(); address.put("provice", "浙江"); address.put("city", "杭州"); info.put("address", address); return info; }
(1) redis中存储的key的结构为:@Cacheable的 key-prefix + cacheNames::key,key-prefix在配置文件中指定
(2) cacheNames是数组,必须指定(否则报错),可以指定多个,对应的会在redis中生成多条数据;
(3) key的值为spel表达式,如果是字符串的话需要添加''
,key如果不指定,redis中key的结构为:cacheNames::SimpleKye[]
(4)sync默认为false,为true时会调用RedisCache的synchronized <T> T get(Object key, Callable<T> valueLoader)
,这种方式可以防止缓存击穿,类似于redis中使用本地同步锁@Cacheable
@Override @CacheEvict(cacheNames = "category", key = "'info'") public void testCacheEvict() { System.out.println("更新数据库了。。。。"); }
(1)删除redis中的key为key-prefix + cacheNames::key的数据
(2)删除所有key:allEntries=true
(3)删除多个key:@Caching( evict = { @CacheEvict(cacheNames = "category", key = "'info'"), @CacheEvict(cacheNames = "category", key = "'xxx'") })
-
总结
缓存穿透:查询一个null的数据。可以解决 cache-null-values=true
缓存击穿:大量并发进来同时查询一个正好过期的数据。解决方案:分布式锁 sync=true 本地锁
缓存雪崩:大量的key同一个时间点失效。解决方案:添加过期时间 time-to-live=60000 指定过期时间