Cache缓存
Cache缓存
1. 缓存
为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而 db 承担数据落盘工作。
哪些数据适合放入缓存?
- 即时性、数据一致性要求不高的
- 访问量大且更新频率不高的数据(读多,写少)
举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,买家需要 5 分钟才能看到新的商品一般还是可以接受的。
伪代码如下:
data = cache.load(id);//从缓存加载数据 |
注意:在开发中,凡是放入缓存中的数据我们都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程。避免业务崩溃导致的数据永久不一致问题。
1.1 使用 Redis 作为缓存
使用 redis
作为缓存可以大大减小对磁盘的压力,以及提升系统的性能,将那些读多写少且构建起来耗时的数据存入 redis
中作为缓存。
1.1.2 导入依赖
<dependency> |
因为 Redis 是一个操作内存的 nosql
,所以我们所作的缓存操作,无非就是将缓存数据存入 redis
。
- 对于查询出的结果,我们将其放入缓存中,并设置过期时间
- 对于修改的数据,我们可以先将其在数据库中修改,再将结果写入缓存;也可以修改数据库后,将缓存中的旧数据删除。来达到数据库数据与缓存中的数据的一致性。
2. 缓存失效问题
在学习缓存相关知识之前,先了解在大并发情况下的缓存失效问题;
2.1 缓存穿透
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的 null 写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
不存在的数据在数据库中查询对于数据库来说是会遍历全部数据然后返回NULL这对数据库的压力很大。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
解决方案
缓存空对象
- 优点: 实现简单,维护方便
- 缺点:
- 额外的内存消耗(一般设置过期时间,且过期时间较短)
- 可能造成短期的不一致(此时新增一条该key对应的数据,但是在TTL结束前,查询到的都是NULL 造成短期不一致)
布隆过滤器
优点:内存占用较少,没有多余key
缺点:
- 实现复杂
- 存在误判(利用hash原理,存在hash冲突,但是判断出为null,那就一定为null)
除此之外缓存穿透的解决方案还有:
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流(也可以减小数据库压力)
2.2 缓存雪崩
- 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效或者 Redis 服务宕机,请求全部转发到 DB,DB 瞬时压力过重雪崩。
解决方案:
- 给不同的key的TTL添加随机值(防止大量的缓存Key同时失效)
- 利用Redis集群提高服务的可用性(防止单机宕机的情况)
- 给缓存业务添加降级限流策略(待学)
- 给业务添加多级缓存
2.3 缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决:
互斥锁
- 大量请求访问,只有获取到锁的那个线程可以去查询数据库重建缓存数据。
- 缺点:加锁对性能有一定的影响。并且可能死锁
逻辑过期
- 设置逻辑过期时间而非TTL
- 获取互斥锁开启新线程,去查询数据库重建缓存数据。
两种方案的比较:
3. 加锁解决缓存击穿的问题
假设有百万请求同时访问同一个失效的热点key,加锁解决的流程如下。
- 查缓存,缓存未命中。
- 获取锁。
- 第一个获取锁成功;其他未获取到锁的线程循环阻塞等待。
- 获取锁成功的线程先检查缓存有没有被其他线程更新。
- 没有,查数据库构建缓存。
- 释放锁。
- 返回结果。
3.1 本地锁在分布式场景下的问题
在分布式服务集群的情况下,本地锁只能锁住当前实例对象,但是在集群的情况下,一个服务会部署在多个服务器的容器下,对于单个容器这个实例对象是唯一的,但是对于多节点集群的情况下,就不是了,所以会造成锁不住的情况。
本地锁,只能锁住当前线程,所以我们需要分布式锁。
4. 分布式锁
4.1 分布式锁原理与使用(Redis)
利用SET NX的互斥机制 和DEL的删除机制
为了防止Redis异常宕机的情况下锁的安全性问题(需要设置锁的过期时间)
获取锁:
互斥: 确保只能有一个线程获取锁
添加锁, NX是互斥 EX是设置超时时间
set lock thread1 NX EX 10
释放锁:
手动释放
超时释放: 获取锁时添加一个超时时间
释放锁, 删除即可
DEL key
4.2 锁的粒度
锁的名字。锁的粒度,越细越快。
锁的粒度:具体缓存的是某个数据,如11号商品;12号商品,而不是像粗粒度锁那样锁住所有商品而导致性能下降(根据具体数据上具体的锁)。
4.3 简单分布式锁的问题
在获取到分布式锁后,获取到锁的线程所在的服务器断电关机了,没有来得及释放锁,此时会造成其他线程永远获取不到锁。
解决方案:加入过期时间,最好是在设置锁的同时设置过期时间,防止分布操作失去原子性,导致在设置过期时间时服务器断电而导致同样的问题。
线程安全问题,业务堵塞导致锁的时间到期释放,第二个线程此时获取到锁,在执行业务中,一号线程业务阻塞完毕时释放锁就导致线程二的锁被释放了。
解决方案:为每个线程获取到的锁的值设置为不同的值,因为 Redis 是 key-value 型的,所以在 set nx 时可以通过设置不同的值来作为不同的线程的锁标识。
通过新增 UUID+ ThreadID 作为锁标识,在释放锁之前判断锁标识是否一致(同一个线程的锁标识一致,这样就不会出现业务阻塞导致释放了其他线程的锁的情况)
线程安全问题,在释放锁时,我们要根据线程的锁标识来判断是否一致来释放锁,防止出现线程安全问题,但是我们要先获取锁的值,才能判断是否一致来释放锁,而操作 Redis 是一个网络请求,中间存在网络延迟或者网络阻塞。如果线程1在锁将要过期的时候去请求锁的值,此时得到的值确实是当前线程的值,但是在回传值得过程中,锁过期了,线程2获取到了锁。此时回传的结果才到达线程1,线程1判断值确实相等,所以释放锁了,但是这个锁是线程2的锁。
解决方案:将比对锁标识和释放锁,作为一个原子操作,发送给 Redis。Redis 官方推荐使用
lua
脚本。释放锁业务的Lua脚本
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
4.4 基于Redis的简单分布式锁问题总结
基于SETNX实现的分布式锁存在下面的问题:
5. Redisson
Redis 官方并不推荐使用简单的方式来实现 redis
分布式锁,Redisson
是 Redis 官方推荐的 Java 分布式锁算法的实现。
Redisson
底层采用 Netty
作为网络通信框架。
5.1 概述
Redisson
是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet
, Set
, Multimap
, SortedSet
, Map
, List
, Queue
, BlockingQueue
, Deque
, BlockingDeque
, Semaphore
, Lock
, AtomicLong
, CountDownLatch
, Publish / Subscribe
, Bloom filter
, Remote service
, Spring cache
, Executor service
, Live Object service
, Scheduler service
) Redisson
提供了使用Redis的最简单和最便捷的方法。Redisson
的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
Redisson
也可以算作是一个操作 Redis 的客户端,提供了原子操作 Redis 的接口,提供了分布式锁、分布式对象、分布式集合等功能,底层使用了大量的 lua
脚本。
5.2 配置
<!-- https://mvnrepository.com/artifact/org.redisson/redisson --> |
或是
<!-- https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter --> |
spring 整合版省去了大量配置。
|
5.3 分布式锁
如果负责储存这个分布式锁的 Redisson
节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson
内部提供了一个监控锁的看门狗,它的作用是在Redisson
实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改 Config.lockWatchdogTimeout 来另行指定。
就是说,在锁还未被释放之前,Redisson
的看门狗机制会在到达一定阈值的时候对锁进行续期。如果存储这个分布式锁的 Redisson
节点宕机了,那么就不会延长了,超过时间后,释放锁。
5.3.1 可重入锁(Reentrant Lock)
基于Redis的Redisson分布式可重入锁RLock
Java对象实现了java.util.concurrent.locks.Lock
接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
RLock lock = redisson.getLock("anyLock"); |
另外 Redisson
还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了(但是这样就没有看门狗机制了)。
// 加锁以后10秒钟自动解锁 |
5.3.2 公平锁(Fair Lock)
它保证了当多个 Redisson
客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson
会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
总所周知公平锁的性能并不好。
RLock fairLock = redisson.getFairLock("anyLock"); |
手动指定过期时间。
// 10秒钟以后自动解锁 |
5.3.3 联锁(MultiLock)
基于Redis的 Redisson
分布式联锁RedissonMultiLock
对象可以将多个RLock
对象关联为一个联锁,每个RLock
对象实例可以来自于不同的 Redisson
实例。
RLock lock1 = redissonInstance1.getLock("lock1"); |
手动指定过期时间(但是这样就没有看门狗机制了)。
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3); |
5.3.4 红锁(RedLock)
基于Redis的 Redisson
红锁RedissonRedLock
对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock
对象关联为一个红锁,每个RLock
对象实例可以来自于不同的 Redisson
实例。
RLock lock1 = redissonInstance1.getLock("lock1"); |
手动指定过期时间(但是这样就没有看门狗机制了)。
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3); |
5.3.5 读写锁(ReadWriteLock)
基于Redis的 Redisson
分布式可重入读写锁RReadWriteLock
Java对象实现了java.util.concurrent.locks.ReadWriteLock
接口。其中读锁和写锁都继承了RLock接口。
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock"); |
手动指定过期时间(但是这样就没有看门狗机制了)。
// 10秒钟以后自动解锁 |
5.3.6 信号量(Semaphore)
基于Redis的 Redisson
的分布式信号量(Semaphore)Java对象RSemaphore
采用了与java.util.concurrent.Semaphore
相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
RSemaphore semaphore = redisson.getSemaphore("semaphore"); |
5.3.7 可过期性信号量(PermitExpirableSemaphore)
基于 Redis
的 Redisson
可过期性信号量(PermitExpirableSemaphore)是在RSemaphore
对象的基础上,为每个信号增加了一个过期时间。每个信号可以通过独立的ID来辨识,释放时只能通过提交这个ID才能释放。它提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
RPermitExpirableSemaphore semaphore = redisson.getPermitExpirableSemaphore("mySemaphore"); |
5.3.8 闭锁(CountDownLatch)
基于 Redisson
的 Redisson
分布式闭锁(CountDownLatch)Java对象RCountDownLatch
采用了与java.util.concurrent.CountDownLatch
相似的接口和用法。
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch"); |
6. 缓存数据一致性
6.1 保证一致性模式
缓存里面的数据如何和数据库保持一致
缓存数据一致性
6.1.1 双写模式
在更新数据库中的数据时,同时更新缓存。
大并发场景下的问题:
- 线程1在修改数据库后,由于卡顿等问题,导致在线程2修改数据库和写缓存之后执行写缓存。导致了出现了不一致脏数据问题。
但这是暂时性的脏数据问题,在数据库数据稳定,缓存过期以后,又能得到最新的正确的数据。
6.1.2 失效模式
在更新数据库中的数据时,同时删除缓存。
大并发场景下的问题:
- 线程1 首先对数据库进行修改,并且将缓存删除
- 然后 线程2 也对数据库进行修改(时间较长),但是还在修改数据库时,线程3 进行读请求
- 线程3,此时读缓存未命中,所以读数据库,但是此时线程2还完成对数据库的修改,所以线程3 得到的是线程1 修改的数据。
- 线程3再在线程2删缓存后,再将数据更新缓存,就出现了不一致脏数据得问题。
6.2 缓存数据一致性-解决方案
无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
- 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔―段时间触发读的主动更新即可。
- 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
- 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
- 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略);
总结:
- 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间, 保证每天拿到当前最新数据即可。
- 我们不应该过度设计,增加系统的复杂性 ·遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
改进方案
分布式读写锁。读数据等待写数据整个操作完成。
使用 cananl
Canal 会伪装成数据的一个租户,去监听 binlog
(数据库操作日志) 的变化,实时更新 redis
缓存。
缺点:引入了额外的中间件,需要额外地开发。
7. Spring Cache
官方文档:https://docs.spring.io/spring-framework/docs/5.3.25/reference/html/integration.html#cache
Spring从3.1开始定义了
org.springframework.cache.Cache
和org.springframework.cache.CacheManager
接口来统一不同的缓存技术;并支持使用JCache
(JSR-107)注解简化我们开发;Cache接口为缓存的组件规范定义,包含缓存的各种操作集合;Cache接口下Spring提供了各种
xxxCache
的实现;如RedisCache
,EhCacheCache
,ConcurrentMapCache
等;每次调用需要缓存功能的方法时,Spring会检查检查指定参数的指定的目标方法是否已经被调用过;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓存结果后返回给用户。下次调用直接从缓存中获取。
使用Spring缓存抽象时我们需要关注以下两点;
- 确定方法需要被缓存以及他们的缓存策略
- 从缓存中读取之前缓存存储的数据
7.1 基础概念
Spring Cache 通过 CacheManager
来管理缓存组件,CacheManager
为了方便管理为每一个 Cache 组件起了一个名字,Cache 组件就是真正操作缓存的组件。
Cache:缓存接口,定义缓存操作。实现有:RedisCache
、EhCacheCache
、ConcurrentMapCache
等。
CacheManager
:缓存管理器,管理各种缓存(Cache)组件。
7.2 注解
@Cacheable
: 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存。@CacheEvict
: 将数据从缓存删除。对应失效模式@CachePut
: 在不干扰方法执行的情况下更新缓存。对应双写模式@Caching
: 重新组合要应用于某个方法的多个缓存操作。@CacheConfig
: 在类级别共享一些常见的与缓存相关的设置。@EnableCaching
: 开启基于注解的缓存
7.3 配置
<dependency> |
配置缓存类型
spring: |
并且在主启动类上添加 @EnableCaching
开启缓存。
7.3.1 @Cacheable
配置
默认行为:
- 如果缓存中有,方法不调用
- key默认自动生成;缓存的名字::SimpleKey [] (自主生成的key值)
- 缓存的value值。默认使用
jdk
序列化方式,将序列化后的数据存到redis
- 默认缓存过期时间
ttl = -1
(不过期)
自定义:
每一个需要缓存的数据我们都来指定要放到那个名字的缓存。【缓存的分区(按照业务类型分)】
....指定生成的缓存使用的 key:key属性指定,接受一个
SpEL
表达式 SpEL 表达式参考//用方法名作为key
...指定缓存的数据存活时间:配置文件中修改
ttl
将数据保存为 json 格式
7.3.2 配置文件
spring: |
7.3.3 缓存自定义配置
|
7.3.4 @CacheEvict
,@CachePut
对于修改数据库后,为了保证缓存和数据库的数据一致性,我们可以使用双写模式和失效模式,Spring Cache 提供了两个注解来对应修改的两种方式。
@CacheEvict
:对应失效模式
@CachePut
:对应双写模式
//采用失效模式来保证缓存和数据库的数据一致性 |
对于数据库更新,可能会牵扯到许多缓存的更新,所以除了删除缓存的可能不止一个,对于这种情况我们可以采用如下两种方式。(@CacheEvict
,@CachePut
同理)
方式一:@Caching
@Caching(evict = { //通过这种配置可以同时指定操作多个,同时还可以组合 cacheable、put、evict @CacheEvict(value = {"category"},key = "'getLevel1Categorys'"), @CacheEvict(value = {"category"},key = "'getCatalogJson'") }) ......
方式二:删除同一个 `cacheNames` 下的所有缓存
* ```java
@CacheEvict(value = {"category"},allEntries = true) //清空该 cacheNames 下的所有缓存所以一般相关联的缓存可以放在同一个
cacheNames
下。
7.4 Spring Cache 总结
读模式
缓存穿透:查询一个 null 数据。解决,缓存空数据;
cache-null-values=true
缓存击穿:大量并发进来同时查询一个正好过期的数据。解决:加锁;Spring Cache 可以设置 @Cacheable sync属性为 true,这样在查数据是可以加锁避免缓存击穿。
- 不足:SpringCache的实现是使用本地锁的方式,而且是将查缓存的整个方法都上锁导致,即使缓存没有过期,获取缓存也是一个串行执行的过程,这样会大大影响性能。
缓存雪崩:大量的 key 同时过期。解决:加随机时间。加上过期时间(时间轴是分散的,所以加上固定的过期时间也可以保证缓存的过期时间是分散的)。
spring.cache.redis.time-to-live=30000
- 不足:还是应该使用随机过期时间的方式。
写模式(缓存与数据库一致):
- 读写加锁
- 引入 canal,感知到
Mysql
的更新去去更新缓存。 - 读多写多,直接去查数据库。
为了解决上述的问题:因为这里使用的 RedisCache
,可以考虑使用整合 Redisson
客户端来达到解决分布式锁得问题。并且只会在获取不到缓存的时候才会加锁去查数据。
总结:
常规数据(读多写少,及时性,一致性要求不高的数据);完全可以使用 Spring Cache;写模式(只要缓存的数据有过期时间就足够了)
特殊数据:特殊设计。