在本章内容里,我希望大家还是要先看看【前置知识】的内容。按照我的大纲设计,我是想先给大家抛出一些大家比较陌生的,关于【Redis缓存问题以及缓存方案】的一些名词概念,再然后在正文【课程内容】里面给大家使用源码案例,然后优化演进的方式,逐步、尽可能地将【前置知识】中提到的这些内容给大家结合案例解释一下,帮助大家加深理解印象。
另外,说实在对于这个推演的过程理解还是有点门槛的,对于没有【并发意识】的同学来说,真的有点难度。我只能说我尽可能地,简单地给大伙讲解一下。
对了,我在推演过程中用到了【Redis分布式锁】,非常推荐大家看看我前面的文章《【Redis专题】大厂生产级Redis高并发分布式锁实战》,没有【并发意识】和【分布式锁】经验的同学墙裂建议看看,获取能得到那么一些灵感吧。
听说,Redis、数据库、JVM级别扛并发能力如下:
层级 | Redis | Mysql | JVM |
---|---|---|---|
扛并发能力 | Redis官宣单个节点10W+ | 主流说法数千到数万之间 | 百万级 |
解释一下:
所以,你知道为什么要学,或者要用Redis了吗?
在说缓存问题跟缓存方案之前,需要跟大家声明一句:没有绝对完美的方案,只有相对可靠的方案。别钻牛角尖哦同学们
Q1:什么是缓存击穿?
答:也叫缓存失效。击穿,是指击穿了缓存,使得请求直达数据库,进而造成了数据库瞬间压力过大甚至挂掉。
记忆点:缓存中没有但是数据库有的数据
Q2:导致缓存击穿可能的原因是什么?
答:大量已存在的key同时失效。比如在电商场景下, 以前存在的做法是:批量上架一些热点商品到缓存中,由于批量操作,所以过期时间设置的一样。所以,在某些情况下造成:热点数据同时到达过期时间,接着大量用户进来。
Q3:缓存击穿如何解决?
答:批量添加缓存时,分散缓存过期时间(设置为一个时间段内的不同时间),避免相同时间段大量缓存失效。
给一个伪代码示例:
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
//***设置一个过期时间(300到600之间的一个随机数)***
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
注意上面的过期时间。
Q1:什么是缓存穿透?
答:缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上造成数据库短时间内承受大量请求而崩掉。
记忆点:缓存和数据库中都没有的数据
Q2:导致缓存穿透可能的原因是什么?
答:通常处于容错的考虑,存储层找不到数据不会写入都缓存层。正常情况是没问题的,可能导致缓存穿透的的情况如下:
while
操作,反复获取一个不存在的数据(实际上是新增功能,上线时忘了准备一些初始化数据),导致死循环CPU超负荷运行,直接把系统搞炸了。(属于是自己玩自己了)Q3:缓存穿透如何解决?
答:有如下几点:
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
// 缓存空对象
cache.set(key, new TargetObject());
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
(注意:不同于网上其他版本,这里的建议是缓存一个空对象。为啥?说不出很好的理由。只能说,这算是一种比较好的规范。对于某些变量,给个有意义的初始值远比Null安全的多)
对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀.
向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组长度比较大,存在概率就会很大,如果这个位数组长度比较小,存在概率就会降低。(PS:总结一下:布隆过滤器认为这个key不存在那就一定不存在;布隆过滤器认为key存在,却不一定真的存在,只是极有可能存在。记住这一点,你基本上算是理解了布隆过滤器!!!)
这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
(PS:重要的知识点说三遍)
(PS:重要的知识点说三遍)
(PS:重要的知识点说三遍)
布隆过滤器认为这个key不存在那就一定不存在;布隆过滤器认为key存在,却不一定真的存在,只是极有可能存在
在项目中使用Redisson实现布隆过滤器,引入依赖:
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.6.5version>
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);
//将test插入到布隆过滤器中
bloomFilter.add("test");
//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("test_1"));//false
System.out.println(bloomFilter.contains("test_2"));//false
System.out.println(bloomFilter.contains("test"));//true
}
}
但是正常来说,使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,这样才能起到过滤作用。布隆过滤器缓存过滤伪代码:(注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据)
// 第一步:初始化布隆过滤器
// 第一步:初始化布隆过滤器
// 第一步:初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
bloomFilter.tryInit(100000000L,0.03); //初始化布隆过滤器:预计元素为100000000L,误差率为3%
// 第二步:预先把所有数据存入布隆过滤器
// 第二步:预先把所有数据存入布隆过滤器
// 第二步:预先把所有数据存入布隆过滤器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
}
// 第三步:真正的使用场景了。Redis查询数据
// 第三步:真正的使用场景了。Redis查询数据
// 第三步:真正的使用场景了。Redis查询数据
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.set(key, new TargetObject());
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
Q1:什么是缓存雪崩?
答:缓存雪崩指的是缓存层支撑不住或宕掉后,流量会像奔逃的野牛一样,打向后端存储层,然后一层一层的滚雪球导致各个层宕机。由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降),于是大量请求都会打到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。
记忆点:缓存层撑不住,大量流量打向服务层,导致整体拒绝服务,或者级联宕机
Q2:导致缓存雪崩可能的原因是什么?
答:缓存层没有起到缓存作用,或者缓存作用没有达到预期。比如:
Q3:缓存雪崩如何解决?
答:有如下几种方式:
基本介绍
查询数据时先查找缓存,如果有,则延长缓存时间并返回;如果没有,再去查找数据库,将查询的数据再写到缓存,同时设置过期时间。如果是静态热点数据,可以不设置缓存失效时间。
对应的伪代码:
public Target getTartget(String id) {
String key = getRedisKey(id);
String json = redisUtil.get(key);
if (StrUtil.isNotEmpty(json)) {
// 延长缓存时间
redisUtil.expire(key, 新的缓存时间);
return JSONObject.parse(json, Target.class);
}
Target target = dbUtil.get(id);
if (target != null) {
// 这里根据是否为热点数据,可以考虑是否要设置过期时间
redisUtil.set(key, JSONObject.toJSONString(target), 缓存时间);
}
return target;
}
基本介绍
在服务降级时,根据冷热数据做不同的处理。这个方案其实我们在上面【缓存雪崩】讲过大致的过程了。但是对于冷热数据需要大家自己根据业务去区分。这里给一点思路:
比如服务降级,我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据(例如电商商品属性,用户信息等)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取
基本介绍
比如在redis前再加一级缓存JVM,一般是通过map存储数据。可以类似redis方案更新缓存,也可以使用redis的发布订阅功能、MQ、canal来实现与数据库的同步。也可以单独部署热点缓存系统,监测到热点数据主动同步到分布式系统中。
值得注意的是,因为多引入了一级缓存,那在分布式的情况下,势必需要通过通信的方式更新新的一级缓存(就像Redis集群使用gossip协议更新节点一样)。通信就势必存在时间消耗,进而导致:短期的数据不一致性。所以建议大家使用多级缓存时,就不要就觉绝对一致了,否则会增加更大的维护成本。
基本介绍
热点重建缓存时,通过双检锁重建缓存:先查询,不存在需要重建缓存,重建缓存逻辑加入分布式锁,仅有一个请求能重建缓存,重建完成后,后面的请求都能获取到数据了。
对应的伪代码:
public Target getTartget(String id) {
String key = getRedisKey(id);
String json = redisUtil.get(key);
if (StrUtil.isNotEmpty(json)) {
// 延长缓存时间
redisUtil.expire(key, 新的缓存时间);
return JSONObject.parse(json, Target.class);
}
// 获取分布式锁
RLock rLock = redisson.getLock(lockKey);
rLock.lock();
try {
// 由于我们在外层已经设置了分布式锁,所以我们知道的
// 只要有一个去查库,然后设置到缓存,其他的没必要再去查库了
// 通过双重检查,我们把原本n个查库的请求,转变成了1个查库,n-1查缓存
// 再次检查缓存
if (StrUtil.isNotEmpty(json)) {
// 延长缓存时间
redisUtil.expire(key, 新的缓存时间);
return JSONObject.parse(json, Target.class);
}
Target target = dbUtil.get(id);
if (target != null) {
// 这里根据是否为热点数据,可以考虑是否要设置过期时间
redisUtil.set(key, JSONObject.toJSONString(target), 缓存时间);
}
} finaly {
rLock.unlock();
}
return target;
}
通过上面的例子我们可以看到,通过【缓存预热】,或者说【双检索】思想,我们把原本n
个查库的请求,转变成了1
个查库,n-1
个查缓存
同学们有没有一种感觉,目前我们Java技术栈,出现的各种中间件也好,框架也好,似乎都是为了【电商场景】服务的。太难了…
public class ProductService {
@Autowired
private ProductDao productDao;
@Autowired
private RedisUtil redisUtil;
@Autowired
private Redisson redisson;
public static final Integer PRODUCT_CACHE_TIMEOUT = 60 * 60 * 24;
@Transactional
public Product create(Product product) {
Product productResult = productDao.create(product);
redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), JSON.toJSONString(productResult), PRODUCT_CACHE_TIMEOUT , TimeUnit.SECONDS);
return productResult;
}
@Transactional
public Product update(Product product) {
Product productResult = productDao.update(product);
redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), JSON.toJSONString(productResult), PRODUCT_CACHE_TIMEOUT , TimeUnit.SECONDS);
}
public Product get(Long productId) throws InterruptedException {
Product product = null;
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
// 先从缓存拿数据,有就直接返回
product = getProductFromCache(productCacheKey);
if (product != null) {
return product;
}
product = productDao.get(productId);
if (product != null) {
redisUtil.set(productCacheKey, JSON.toJSONString(product), PRODUCT_CACHE_TIMEOUT, TimeUnit.SECONDS);
}
return product;
}
}
上面的例子很简单,就是CRUD而已。我想,大家对于Redis缓存,很多人的使用都止于上述几个get
、create
、update
方法的使用了吧?反正博主我就是,啊哈哈
如果你也是这么写的话,我想问问大伙儿,你们有没有思考过这里面会存在什么问题没有?可以好好拿笔写一下。尽可能地写出来,也对自己是一种锻炼了(大家也是看过我【前置知识】的各种缓存问题跟方案介绍了,多少能写一点吧)。我个人能看到的问题有如下:(百万流量下)
create
方法的缓存时间是固定的,在批量插入场景下,可能会出现批量缓存同时过期,造成【缓存击穿(失效)】现象get
方法在成功拿到缓存数据返回之后,没有延长缓存时间。有可能出现【热点数据】,或者最近使用过的数据缓存过期,造成【缓存击穿(失效)】现象get
方法在没有拿到缓存,去查询数据库的时候,可能存在大量请求同时请求数据库,数据库压力暴增get
方法对于Redis跟数据库都不存在的数据没有防备,可能会造成【缓存穿透】现象好了,问题已经发现了,那让我们一起学习一下,在大厂里面,是如何写这些代码的!
首先,还是希望大家时刻对背景有个简单的印象:
然后再附上一张,JD的主页:
你们看上面吧,我们前面有简单分析过,像这种电商首页的数据,正常来说一定会缓存到Redis里面的,而且首页数据是超热数据,所以添加缓存的时候甚至不会设置过期时间。
上面那个版本
我们前面有大致介绍过【冷热分离】,这里重点在【冷】、【热】数据的区分。
感谢我东哥的文章《一线大厂Redis高并发缓存架构实战与性能优化》