Redis多级缓存架构、缓存设计、性能优化
- 多级缓存架构
- 缓存设计
- 缓存穿透
- 解决方法
- 缓存雪崩
- 解决方案
- 缓存击穿
- 解决方法
- 热点key重建
- 缓存与数据库双写不一致
- 解决方案
- 布隆过滤器
多级缓存架构
- Nginx层:Lua动态渲染模板
一些静态资源、例如HTML、CSS、JS、图片资源等都可以独立部署在一台服务器上、加载进Redis缓存中。用户请求经过Nginx时、判断是否为静态资源、是则直接从静态资源服务器里面获取、不用经过后端的Web层和Redis集群; - Web层缓存
Web层里面会有一些Ehcache缓存、可以使用HashMap、ConcurrentHashMap、ArrayList等数据结构缓存数据,当请求经过Nginx发送到Web服务器时、判断Web服务器缓存中是否已经缓存请求数据,是就从服务器的缓存中获取并返回、否则去Redis集群中查询 - Redis集群
如果前两层缓存都没有查找到数据,则将Web层服务器发送请求到Redis集群里取查询数据,如果查询到,就将数据返回,如果查询不到,返回null.
如果多级缓存都没有查找到数据,那就从MySQL数据库中查询,查到之后将数据放入Redis集群中。
缓存设计
缓存穿透
- 缓存穿透指查询一个根本不存在的数据,缓存层和存储层都不存在,通常处于容错的考虑,如果从存储层查询不到的数据则不写入缓存层;
- 缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。
- 原因有两个
- 自身业务代码或者数据出现问题
- 一些恶意攻击
例如:我们的用户ID一般都不可能是负数、如果一直查询ID=-1的数据,肯定查不到,如果同时发送大量请求(上万)会导致存储层压力瞬间变大。
解决方法
- 缓存空对象
Map<String,Object> DB=new HashMap<String,Object>();
@GetMapping("/getCache")
public Object get(String key){
String cache=stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(cache)){
//从数据库读取数据操作--假设DB为数据库
String dbVal= (String) DB.get(key);
stringRedisTemplate.opsForValue().set(key,dbVal);
if (StringUtils.isEmpty(dbVal)){
Boolean expire = stringRedisTemplate.expire(key,300,TimeUnit.SECONDS);
}
return dbVal;
}else {
//...正常业务逻辑
return cache;
}
}
- 布隆过滤器拦截
在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截,当收到一个对key请求时先用布隆过滤器验证是key否存在,如果存在在进入缓存层、存储层。可以使用bitmap做布隆过滤器。这种方法适用于数据命中不高、数据相对固定、实时性低的应用场景,代码维护较为复杂,但是缓存空间占用少 - 接口层进行进行数据校验
把一些逻辑上不可能存在的数据进行拦截。如果ID最小为0,那么-1就会带来缓存穿透问题,可以验证ID<0时,拦截掉。
缓存雪崩
由于缓存层承载着大量请求,有效保护了存储层,但是如果缓存层由于某些原因不可用(宕机)或者大量热点缓存key由于超时时间相同,在同一时间段失效,大量请求直接到达存储数据库,数据库承受不住导致系统雪崩。
解决方案
- 缓存的过期时间用随机值,尽量让不同的key的过期时间不同。
- 依赖隔离组件为使用后端限流熔断并降级。
比如使用Sentinel或Hystrix限流降级组件。 - 提前演练。
在项目上线前, 演练缓存层宕掉后, 应用以及后端的负载情况以及可能出现的问题, 在4此基础上做一些预案设定。 - 设置key永不过期
缓存击穿
指的是缓存中没有,但数据库中有的数据,(一般缓存时间到期),当前key是一个热点key(例如一个秒杀活动),并发量非常大。突然,该热点key失效,导致大量的线程透过缓存层到达数据库层,数据库承受不住,导致崩溃。
解决方法
- 分布式锁
只允许一个线程重建缓存,其他线程等待线程执行完,重新获取缓存数据。当key不存在,获取写锁、当key存在,获取写锁; - 设置key永不过期
不设置过期时间,不会出现key过期的问题。 - 接口限流,熔断与降级
重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些 服务 不可用时候,进行熔断,失败快速返回机制。
热点key重建
- 当前key是一个热点key(例如一个热门的娱乐新闻)失效,并发量非常大,有大量的线程来重建缓存,造成后端负载太大,甚至奔溃。
- 因为重建缓存不能再短时间内完成,可能是一个复杂计算,会有复杂的SQL,多次IO等;
- 这种情况下,要避免大量线程同时重建缓存
- 通过互斥锁,只允许一个线程重建缓存,其他线程等待线程执行完,重新获取缓存数据。
@GetMapping("/getProduct")
public Object getProduct(String key) throws InterruptedException {
String cache=stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(cache)){
String mutexKey="mutext:key:"+key;
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(mutexKey, "1", 180, TimeUnit.MILLISECONDS);
if (aBoolean){
cache= (String) DB.get(key);
stringRedisTemplate.opsForValue().set(key,cache,300,TimeUnit.SECONDS);
stringRedisTemplate.delete(mutexKey);
}else {
Thread.sleep(50);
getProduct(key);
}
}
return cache;
}
缓存与数据库双写不一致
在大并发下,同时操作数据库与缓存会存在数据不一致性问题。
线程1先写数据库、再准备写入缓存期间,线程也写了数据库、又更新了缓存;
这就造成redis写覆盖;
线程1写完数据后,删除缓存数据、然后线程3查询这条缓存数据,由于被删除了,查找不到,因此会从数据库中获取,获取到后,在准备更新缓存期间,线程2写了同一条记录,又删除了缓存,最后线程3Web层查出来的数据其实和数据库的不一致,如果把Web层查出来的数据stock更新缓存,那么得到的是脏数据。
解决方案
- 并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
- 如果不能容忍缓存数据不一致、可以通过读写锁保证并发读写或写写的时候是串行化的,读读操作相当于无锁;
- 用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
我们针对读多写少的情况加入缓存可以提高性能,如果写多读多又不能容忍数据不一致,那就没必要加缓存了,直接从Web层查询MySQL,不经过Redis,可以保证一致性;
放入缓存的数据应该是对实时性、一致性要求不高的数据。
布隆过滤器
对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。
- 布隆过滤器就是一个大型位数组和几个不一样的无偏hash函数。
- 向布隆过滤器添加key时、会使用多个hash函数进行Hash算出整数值,再对维数组进行取模得到一个位置,每个hash函数会得到一个不同的位置,再将这几个位置置为1,完成Add操作。
- 向布隆过滤器询问key是否存在时,跟add一样,通过hash算出几个位置,判断这些位置是不是都为1,只要有一个是0,则说明这个key不存在。如果都是1,则该key极有可能存在,因为这些位置为1,可能是其他的key存在所致。
如果这个数组比较稀疏,这个概率就很大,如果这个位数比较拥挤,这个概率就会降低。 - 这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
package com.redisson;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonBloomFilter {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
//构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);
//将zhuge插入到布隆过滤器中
bloomFilter.add("zhuge");
//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("guojia"));//false
System.out.println(bloomFilter.contains("baiqi"));//false
System.out.println(bloomFilter.contains("zhuge"));//true
}
}
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码:
//初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
//把所有数据存入布隆过滤器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
}
String get(String key) {
// 从布隆过滤器这一级缓存判断下key是否存在
Boolean exist = bloomFilter.contains(key);
if(!exist){
return "";
}
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
布隆过滤器不能删除数据,如果要删除得重新初始化数据