一、redis应用分析
1.为什么需要redis
主要解决的是性能和并发,还可以使用redis实现分布式锁等。
1.性能
如下图所示,我们在碰到需要执行耗时特别久,且结果不频繁变动的SQL,就特别适合将运行结果放入缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应。
比如商城的首页,各种类目的商品信息,各种推荐信息,如果走数据库查询,并且查完了可能接下来好几个小时都没什么变化,那每次请求都走到数据库里,就有点不大合适了。
这时,我们把查到的结果放到扔到缓存里面,下次再来查询,不走数据库,直接走缓存查询,算上网络消耗可能 10ms 左右就能响应结果了,性能瞬间提升 50 倍。
这就是说,对于一些需要复杂操作耗时查出来的结果,且确定后面不怎么变化,但是有很多读请求,那么直接将查询出来的结果放在缓存中,后面直接读缓存就好了。
2.并发
如下图所示,在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问数据库。
MySQL 或者 Oracle 这种关系型数据库压根就不是用来玩并发的,虽然也可以支撑一定的并发,单机 8C16G 的 MySQL 优化基本上极限能撑到 900 左右的 TPS , QPS 极限能撑到 9000 左右。别看这个数字不小,请注意是极限情况,这个情况下 CPU 全都已经爆表,整个服务已经处于不健康的状态。
这时业务场景如果 1s 有 1w 的请求过来,使用一个 MySQL 单机肯定直接崩掉,但是如果使用 Redis 缓存,把大量的热点数据放在缓存,因为是走内存的操作,单机轻松支撑几万甚至于几十万的访问。单机的并发承载量是 MySQL 的几十倍。
2.redis使用场景
redis使用场景大多数是数据一致性要求不高的,而双写一致性问题,下面的章节会重点写。
1.分布式锁
2.分布式ID
1.生成分布式ID
3.实时计数
如对学习时长,做题量,接口调用量等实时计数。
4.分布式session
其他
1.做缓存抗并发:
冷热隔离:即热点数据和并发量不大的数据
4.队列
Reids在内存存储引擎领域的一大优点是提供 list 和 set 结构,这使得Redis能作为一个很好的消息队列平台来使用。
5.发布/订阅????
可参考:为什么分布式一定要有redis? https://www.cnblogs.com/bigben0123/p/9115597.html
2.缓存设计
1.一些术语
1.缓存命中率
缓存命中率是从缓存中读取数据的次数与总读取次数的比率,命中率越高使用越好。缓存命中率 = 从缓存中读取次数 / {总读取次数 (从缓存中读取次数 +从慢设备上读取次数) }
这个指标非常重要,直接影响到系统性能,也可以通过该指标来衡量缓存是否运行良好;
2.缓存的设计
1.Cache Aside Pattern
1.设计思路
这是最常用最常用的pattern了。其具体逻辑如下:
- 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
代码实现
/**
* 获取商品详情信息
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo(Long id) {
PmsProductParam productInfo = null;
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (productInfo != null) {
return productInfo;
}
productInfo = portalProductDao.getProductInfo(id);
if (null == productInfo) {
return null;
}
FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id);
if (!ObjectUtils.isEmpty(promotion)) {
productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount());
productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit());
productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice());
productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId());
productInfo.setFlashPromotionEndDate(promotion.getEndDate());
productInfo.setFlashPromotionStartDate(promotion.getStartDate());
productInfo.setFlashPromotionStatus(promotion.getStatus());
}
redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS);
return productInfo;
}
注意,我们的更新是先更新数据库,成功后,让缓存失效。那么,这种方式是否可以没有文章前面提到过的那个问题呢?我们可以脑补一下。
一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。
这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。
2.Cache Aside Pattern方案存在的问题
1.Cache Aside同样在高并发下存在数据不一致的问题
那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
详细场景和解决方案,见上面的前言部分。
2.存在缓存穿透问题
可以用压测工具测试一下上面的方案
可见系统的响应时间还是很长,那是因为在1s内大量的线程还是从数据库里面取的数据,直到数据库里面的数据加载到缓存后,因为数据库读取的数据相比比较慢。
解决方案:
1.去数据库加载数据时只让一个线程去加载,其他线程处于等待状态。
2.热点数据进行缓存预热
2.Read/Write Through Pattern
我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。
1.Read Through
Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。
2.Write Through
Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)
自己备注:文章见《左耳听风专栏》这中策略实际中参考价值不大,因为会存在数据库和缓存的一致性问题,除非把热点数据全部放缓存中,所有的增删改查都在缓存中操作,数据库只是用来坐备份,这样能保证热点数据的高一致性。
3.Write Behind Caching
1.设计思路
Write Behind 又叫 Write Back。一些了解 Linux 操作系统内核的同学对 write back 应该非常熟悉,这不就是 Linux 文件系统的 page cache 算法吗?是的,你看基础知识全都是相通的。所以,基础很重要,我已经说过不止一次了。
Write Back 套路就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的 I/O 操作飞快无比(因为直接操作内存嘛)。因为异步,Write Back 还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道 Unix/Linux 非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间、空间换时间一个道理。有时候,强一致性和高性能,高可用和高性能是有冲突的。软件设计从来都是 trade-off(取舍)。
另外,Write Back 实现逻辑比较复杂,因为它需要 track 有哪些数据是被更新了的,需要刷到持久层上。操作系统的 Write Back 会在仅当这个 Cache 需要失效的时候,才会把它真正持久起来。比如,内存不够了,或是进程退出了等情况,这又叫 lazy write。
在 Wikipedia 上有一张 Write Back 的流程图,基本逻辑可以在下图中看到。
2.适用场景
1.因为读写都是在内存中完成,适用于少数热点数据的高并发读写
3.Redis布隆过滤器
布隆过滤器(Bloom Filter)是 Redis 4.0 版本提供的新功能,它被作为插件加载到 Redis 服务器中,给 Redis 提供强大的去重功能。
相比于 Set 集合的去重功能而言,布隆过滤器在空间上能节省 90% 以上,但是它的不足之处是去重率大约在 99% 左右,也就是说有 1% 左右的误判率,这种误差是由布隆过滤器的自身结构决定的。俗话说“鱼与熊掌不可兼得”,如果想要节省空间,就需要牺牲 1% 的误判率,而且这种误判率,在处理海量数据时,几乎可以忽略。
1.应用场景
布隆过滤器是 Redis 的高级功能,虽然这种结构的去重率并不完全精确,但和其他结构一样都有特定的应用场景,比如当处理海量数据时,就可以使用布隆过滤器实现去重。
下面举两个简单的例子:
1) 示例:
百度爬虫系统每天会面临海量的 URL 数据,我们希望它每次只爬取最新的页面,而对于没有更新过的页面则不爬取,因策爬虫系统必须对已经抓取过的 URL 去重,否则会严重影响执行效率。但是如果使用一个 set(集合)去装载这些 URL 地址,那么将造成资源空间的严重浪费。
2) 示例:
垃圾邮件过滤功能也采用了布隆过滤器。虽然在过滤的过程中,布隆过滤器会存在一定的误判,但比较于牺牲宝贵的性能和空间来说,这一点误判是微不足道的。
2.工作原理
布隆过滤器(Bloom Filter)是一个高空间利用率的概率性数据结构,由二进制向量(即位数组)和一系列随机映射函数(即哈希函数)两部分组成。
布隆过滤器使用exists()
来判断某个元素是否存在于自身结构中。
当布隆过滤器判定某个值存在时,其实这个值只是有可能存在;当它说某个值不存在时,那这个值肯定不存在,这个误判概率大约在 1% 左右。
1.工作流程-添加元素
布隆过滤器主要由位数组和一系列 hash 函数构成,其中位数组的初始状态都为 0。
下面对布隆过滤器工作流程做简单描述,如下图所示:
图1:布隆过滤器原理
当使用布隆过滤器添加 key 时,会使用不同的 hash 函数对 key 存储的元素值进行哈希计算,从而会得到多个哈希值。根据哈希值计算出一个整数索引值,将该索引值与位数组长度做取余运算,最终得到一个位数组位置,并将该位置的值变为 1。每个 hash 函数都会计算出一个不同的位置,然后把数组中与之对应的位置变为 1。通过上述过程就完成了元素添加(add)操作。
2.工作流程-判定元素是否存在
当我们需要判断一个元素是否存时,其流程如下:首先对给定元素再次执行哈希计算,得到与添加元素时相同的位数组位置,判断所得位置是否都为 1,如果其中有一个为 0,那么说明元素不存在,若都为 1,则说明元素有可能存在。
3.为什么是可能“存在”
您可能会问,为什么是有可能存在?其实原因很简单,那些被置为 1 的位置也可能是由于其他元素的操作而改变的。比如,元素1 和 元素2,这两个元素同时将一个位置变为了 1(图1所示)。在这种情况下,我们就不能判定“元素 1”一定存在,这是布隆过滤器存在误判的根本原因。
4.关于内存空间思考
因为布隆过滤器只需要存对应key无需存value,所以占用的内存不会太大。然后存储容器是redis,自带分布式特性,可支持分布式场景
5.全量和增量同步
全量同步:可以基于服务启动时自动加载数据库数据到布隆过滤器。
增量同步:基于数据库等增删改操作后,可以发布异步通知如mq来更新布隆过滤器数据。
3.布隆过滤器(基于redis)基本命令
4.布隆过滤器代码实现
1.存储
@Slf4j
@Configuration
public class BloomFilterConfig implements InitializingBean{
@Autowired
private PmsProductService productService;
@Autowired
private RedisTemplate template;
@Bean
public BloomFilterHelper<String> initBloomFilterHelper() {
return new BloomFilterHelper<>((Funnel<String>) (from, into) -> into.putString(from, Charsets.UTF_8)
.putString(from, Charsets.UTF_8), 1000000, 0.01);
}
/**
* 布隆过滤器bean注入
* @return
*/
@Bean
public BloomRedisService bloomRedisService(){
BloomRedisService bloomRedisService = new BloomRedisService();
bloomRedisService.setBloomFilterHelper(initBloomFilterHelper());
bloomRedisService.setRedisTemplate(template);
return bloomRedisService;
}
@Override
public void afterPropertiesSet() throws Exception {
List<Long> list = productService.getAllProductId();
log.info("加载产品到布隆过滤器当中,size:{}",list.size());
if(!CollectionUtils.isEmpty(list)){
list.stream().forEach(item->{
//LocalBloomFilter.put(item);
bloomRedisService().addByBloomFilter(RedisKeyPrefixConst.PRODUCT_REDIS_BLOOM_FILTER,item+"");
});
}
}
}
2.拦截
@Slf4j
public class BloomFilterInterceptor implements HandlerInterceptor {
@Autowired
private BloomRedisService bloomRedisService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String currentUrl = request.getRequestURI();
PathMatcher matcher = new AntPathMatcher();
//解析出pathvariable
Map<String, String> pathVariable = matcher.extractUriTemplateVariables("/pms/productInfo/{id}", currentUrl);
//布隆过滤器存储在redis中
if(bloomRedisService.includeByBloomFilter(RedisKeyPrefixConst.PRODUCT_REDIS_BLOOM_FILTER,pathVariable.get("id"))){
return true;
}
/**
* 存储在本地jvm布隆过滤器中
*/
/*if(LocalBloomFilter.match(pathVariable.get("id"))){
return true;
}*/
/*
* 不在本地布隆过滤器当中,直接返回验证失败
* 设置响应头
*/
response.setHeader("Content-Type","application/json");
response.setCharacterEncoding("UTF-8");
String result = new ObjectMapper().writeValueAsString(CommonResult.validateFailed("产品不存在!"));
response.getWriter().print(result);
return false;
}
}
二、redis和数据库双写一致性问题
分析:一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。答这个问题,先明白一个前提。就是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。另外,我们所做的方案其实从根本上来说,只能说降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据,不能放缓存。
回答:首先,采取正确更新策略,先更新数据库,再删缓存。
其次,讨论一些删缓存失败的问题。这种可能性极低,可以先加监控捕获删除失败的日志,然后在人工介入去删缓存吧,不建议在业务中写代码增加系统复杂度。如果硬要在系统中实现,可提供一个补偿措施即可,例如利用设置缓存失效时间(一般再秒级)或者消息队列的重试机制。
删除缓存失败的补偿方案参考:
对删除缓存进行重试,数据的一致性要求越高,我越是重试得快。(个人理解当删除缓存失败后,应该还要为当前key值设置标记,当下一次对当前key值有查询请求时,直接读数据库,直到删除失败的标记消失为止)
定期全量更新,简单地说,就是我定期把缓存全部清掉,然后再全量加载。
给所有的缓存一个失效期。
第三种方案可以说是一个大杀器,任何不一致,都可以靠失效期解决,失效期越短,数据一致性越高。但是失效期越短,查数据库就会越频繁。因此失效期应该根据业务来定。
将不一致分为三种情况:
-
数据库有数据,缓存没有数据;
-
数据库有数据,缓存也有数据,数据不相等;
-
数据库没有数据,缓存有数据。
1.读写策略
1 读请求
不要求强一致性的读请求,走redis,要求强一致性的直接从mysql读取。
读操作优先读取redis,不存在的话就去访问MySQL,并把读到的数据写回Redis中;
2 写请求
数据首先都写到数据库,然后把缓存里对应的数据失效掉(删掉)。
为什么不先删除缓存,然后再更新数据库,而后续的操作会把数据再装载的缓存中。
- 试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
为什么不先写redis再写mysql
- 先写redis再写mysql的弊端,如果数据库写入失败事务回滚会造成redis中存在脏数据
2.缓存更新策略
1.前言
1.为何是删缓存而不是更新缓存?
这么做引发的问题是,如果A,B两个线程同时做数据更新,A先更新了数据库,B后更新数据库,则此时数据库里逻辑上存的是B的数据。
但要考虑两种情况:
1.A在更新缓存前卡住了(如网络原因),如果在更新缓存的时候,是B先更新了缓存,而A后更新了缓存,则缓存里是A的数据。这样缓存和数据库的数据也不一致。
2.A更新成功了,B更新失败了。则缓存里是A的数据。这样缓存和数据库的数据也不一致。
针对更新失败的解决方案:
方案一:采用补偿策略,更新失败后缓存delete。如果更新失败,那么再对缓存进行删除,让读请求重新回源来保证数据一致性。
方案二:“日志记录后手动操作”。更新失败会记入日志,手动操作【人工操作,不灵活】。
方案三:“key存入MQ重新消费set一次”。借用MQ中间件,将有问题的key放入MQ,重新消费进行更新缓存【借用第三方组件,耗费大】。
2.删缓存一定能保证双写一致性吗?
线程1和2为分别写数据,线程3为读数据。理论上缓存最后应该是线程2的数据,但实际有可能出现
出现的原因如下:
线程1执行完成后,此时缓存为空。这时线程2去更新数据库因为比较耗时还没有写完,另外一个线程3请求来查询数据,发现缓存里没有,就去数据库里查此时查到的为10,然后准备回写缓存前,此时线程2更新数据库完成了并删了缓存,线程3继续回写完缓存后,此时缓存的值为10。
但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作(否则就会读到写之后的数据或者读会被锁住,那就不存在一致性问题),而又要晚于写操作更新缓存(线程2写操作如果很慢,那线程3读的操作早就回写缓存了,那就不存在一致性问题,如果线程2写操作很快,那那线程3读的时候大概率会读到线程2数据库写操作之后的值了,那就不存在一致性问题),所有的这些条件都具备的概率基本并不大。
解决方案1:
遇到这种情况,可以用队列的去解决这个问题,创建一个先进先出的阻塞队列如ArrayBlockingQueue,当有数据更新请求时,先把它丢到队列里去,当更新完后在从队列里去除,如果在更新的过程中,遇到以上场景,先去缓存里看下有没有数据,如果没有,可以先去队列里看是否有相同商品ID在做更新,如果有也把查询的请求发送到队列的末端里去,然后同步等待缓存更新完成。
这里有一个优化点,如果发现队列里有一个查询请求了,那么就不要放新的查询操作进去了,用一个while(true)循环去查询缓存,循环个200MS左右,如果缓存里还没有则直接取数据库的旧数据(不要更新缓存),一般情况下是可以取到的。
当然这种排队的策略对性能是有一定的影响的。
解决方案2:
利用Redisson的读写锁机制,在操作缓存时,线程3上了读锁之后,线程2就需要等待线程3写缓存释放之后才能去抢锁并删缓存。
注意:此方案报保证抢锁的顺序性,不能让写锁线程2一直阻塞,则会一直读到老的数据。
解决方案3:
自己想的:写操作阻塞一切,当要进行刷新缓存时,所有读操作都不可用直接返回缓存的空值,直到刷新完成。在刷新缓存区间,如果数据库值发生了变更,又会将循环上述刷新缓存操作,系统吞吐量要将会大大降低,不推荐使用。
3.高并发选型
思想准备:没有一种方案能完全满足一致性和高吞量,除非只用缓存,但成本很大。
1.并发不高的情况
即只有偶尔某一个时刻,可能并发请求稍微大一些。可以使用Cache Aside Pattern
读: 读redis->没有,只让一个线程读mysql->把mysql数据写回redis,有的话直接从redis中取;
新增写: 写mysql->成功,再写redis
更新写:更新mysql中的数据->成功,则删缓存
2.并发高的情况
1.Cache Aside Pattern
高并发情况下,为何不建议用Cache Aside Pattern模式?
我自己认为Cache Aside Pattern在进行数据更新后,直接让缓存失效,下次如果对当前key有大量的读请求,就会有缓存击穿的风险,及时使用了加锁解决缓存击穿问题,这样也会降低系统的吞吐量,但数据的一致性保证相对较高。
2.Write Behind Caching Pattern
即读写都在缓存中,最后在某一个负载低的时间段,异步备份到数据库中。
这样一致性是一致性最高,吞吐量最高,实际上很多大厂它就是这么干的。
但这种对缓存的容量要求有一点高
3.Read/Write Through思想
当然针对于Cache Aside Pattern模式更新数据带来的缓存击穿问题,可以使用Read/Write Through Pattern思想来解决缓存击穿问题
方案一
先写缓存后写数据库
读: 读redis->没有,只让一个线程读mysql->把mysql数据写回redis。读redis->有,有的话直接从redis中取;
写:异步化,先写入redis的缓存,就直接返回,然后异步写mysql.
异步写mysql可以如放到先进先出的阻塞队列将数据保存到mysql,可以做到多次更新,一次保存,如果写mysql失败,可以将对应进行删缓存,但这种方案会存在一定程度的数据不一致问题且会产生脏数据,有点像Cache Aside和Write Behind Caching 的融合版
方案二
先写数据库后写缓存
读: 读redis->没有,只让一个线程读mysql->把mysql数据写回redis。读redis->有,有的话直接从redis中取;
写:异步化,先写入mysql,就直接返回,然后异步刷新缓存。
异步刷新缓存对数据一致性的保证要比方案一要高一些,但在异步区间也会因为读到旧数据而存在数据不一致的问题,且吞吐量要低于方案一,所以这种方式比较鸡肋。
参考:
1.Redis怎么保持缓存与数据库一致性? https://blog.csdn.net/belalds/article/details/82078009
2.Redis 如何保持和MySQL数据一致 https://blog.csdn.net/thousa_ho/article/details/78900563
三、缓存服务治理
1 缓存穿透
缓存穿透是指缓存和数据库中都没有的数据。
导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
如黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常。
解决方案:
1.接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
2.采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力
3.从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。
2. 缓存击穿
1.定义
缓存击穿一般指缓存中没有但数据库中有的数据。
指的是并发量很高的 KEY,在该 KEY 失效的瞬间有很多请求同时去请求数据库,刷新缓存。
例如我们有一个业务 KEY,该 KEY 的并发请求量为 10000。当该 KEY 失效的时候,就会有 1 万个线程会去请求数据库更新缓存。这个时候如果没有采取适当的措施,那么数据库很可能崩溃。
与缓存雪崩的区别:在于这里针对某一key缓存,前者则是很多key。
与缓存穿透的区别:在于数据库中是有对应key值的,前者数据库是没有对应值的。
2.解决方案
1.使用互斥锁
当从缓存获取的是null值时,对当前key加锁,其他相同key的线程 处于等待现象,必须等待第一个构建完缓存之后,释放锁,其他线程才能通过该key访问数据;那其他线程就直接从缓存里面取数据,不会造成数据库的读写性能的缺陷;
原理如下图:
如果是单机,可以用synchronized或者lock来处理,如果是分布式环境可以用分布式锁就可以了(分布式锁,可以用memcache的add, redis的setnx, zookeeper的添加节点操作)。
分布式版源码如下:
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
// 3 min timeout to avoid mutex holder crash
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); //重试
}
} else {
return value;
}
}
2."提前"使用互斥锁(mutex key)
在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。
然后再从数据库加载数据并设置到cache中。
3.“热点数据永远不过期”
这里的“永远不过期”包含两层意思:
- 物理”不过期:从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题。同时如果发现需要刷新缓存时,采用更新缓存而不是删缓存的策略。
- “逻辑”不过期:从功能上看,我们把过期时间存在key对应的value里,如果发现要过期了,开启一个线程进行异步刷新。也可以通过一个后台定时任务异步进行缓存的刷新。
其实现原理如下:
源码如下:
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (v.timeout <= System.currentTimeMillis()) {
// 异步更新后台异常执行
threadPool.execute(new Runnable() {
public void run() {
String keyMutex = "mutex:" + key;
if (redis.setnx(keyMutex, "1")) {
// 3 min timeout to avoid mutex holder crash
redis.expire(keyMutex, 3 * 60);
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(keyMutex);
}
}
});
}
return value;
}
从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
4.资源保护
采用netflix的hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。
具体策略:
当缓存失效后,可以通过熔断机制直接返回默认值或错误信息,避免请求直接访问数据库。这种方式可以在一定程度上对抗缓存击穿,但需要根据业务场景合理设置熔断策略。
可以在降级策略里让有限的流量刷新缓存,其他流量返回空值或者默认值。
四种解决方案:没有最佳只有最合适
3. 缓存雪崩
1.即缓存同一时间大面积的失效,这个时候又来了一波请求,结果请求都怼到数据库上,从而导致数据库连接异常。
2.redis集群挂了,请求都到了数据库。
解决办法:
1.给缓存的失效时间,加上一个随机值,避免集体失效。即在设置过期时间时,让所配置的过期时间在乘以一个随机值
2.使用互斥锁,但是该方案吞吐量明显下降了。即流量消峰
3.缓存预热。
4.服务熔断降级
使用 Hystrix进行限流 & 降级 ,比如一秒来了5000个请求,我们可以设置假设只能有一秒 2000个请求能通过这个组件,那么其他剩余的 3000 请求就会走限流逻辑。然后去调用我们自己开发的降级组件(降级),比如设置的一些默认值呀之类的。以此来保护最后的 MySQL 不会被大量的请求给打死。
因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作,这里可以采用降级的方式返回一个默认值或者空值
4.双缓存(实践中很少使用)。
我们有两个缓存,缓存A和缓存B。缓存A的失效时间为20分钟,缓存B不设失效时间。自己做缓存预热操作。
然后细分以下几个小点
1)从缓存A读数据库,有则直接返回
2)A没有数据,直接从B读数据,直接返回,并且异步启动一个更新线程。
3)更新线程同时更新缓存A和缓存B。
但这样会增大内存空间。缓存B可以使用lru算法来兼容空间问题。
4.缓存预热
冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。
1、缓存预热定义
缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
2、解决方案
1)直接写个缓存刷新页面,上线时手工操作下。
2)数据量不大,可以在项目启动的时候自动进行加载。
3)定时刷新缓存。
项目启动时实现缓存预热
package com.heima.item.config;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import com.heima.item.service.IItemService;
import com.heima.item.service.IItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class RedisHandler implements InitializingBean {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private IItemService itemService;
@Autowired
private IItemStockService stockService;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void afterPropertiesSet() throws Exception {
// 初始化缓存
// 1.查询商品信息
List<Item> itemList = itemService.list();
// 2.放入缓存
for (Item item : itemList) {
// 2.1.item序列化为JSON
String json = MAPPER.writeValueAsString(item);
// 2.2.存入redis
redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
}
// 3.查询商品库存信息
List<ItemStock> stockList = stockService.list();
// 4.放入缓存
for (ItemStock stock : stockList) {
// 2.1.item序列化为JSON
String json = MAPPER.writeValueAsString(stock);
// 2.2.存入redis
redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
}
}
}
这里我们利用InitializingBean接口来实现,因为InitializingBean可以在对象被Spring创建并且成员变量全部注入后执行。
四、多级缓存
1.为何需要多级缓存
传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库。
如图:
存在下面的问题:
•请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈
•Redis缓存失效时,会对数据库产生冲击
2.多级缓存
多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:
- 浏览器访问静态资源时,优先读取浏览器本地缓存
- 访问非静态资源(ajax查询数据)时,访问服务端
- 请求到达Nginx后,优先读取Nginx本地缓存
- 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)
- 如果Redis查询未命中,则查询Tomcat
- 请求进入Tomcat后,优先查询JVM进程缓存
- 如果JVM进程缓存未命中,则查询数据库
在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了。
因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理,如图:
另外,我们的Tomcat服务将来也会部署为集群模式:
可见,多级缓存的关键有两个:
-
一个是在nginx中编写业务,实现nginx本地缓存、Redis、Tomcat的查询
-
另一个就是在Tomcat中实现JVM进程缓存
其中Nginx编程则会用到OpenResty框架结合Lua这样的语言。
JVM进程缓存则用Caffeine框架进行实现
3.二级缓存
其实大部分业务用不到三级以上的缓存架构,大多使用jvm+redis二级缓存架构来进行实现缓存
4.多级缓存数据一致性
缓存数据同步的常见方式有三种:
设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新
- 优势:简单、方便
- 缺点:时效性差,缓存过期之前可能不一致
- 场景:更新频率较低,时效性要求低的业务
同步双写:在修改数据库的同时,直接修改缓存
- 优势:时效性强,缓存与数据库强一致
- 缺点:有代码侵入,耦合度高;
- 场景:对一致性、时效性要求较高的缓存数据
异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
- 优势:低耦合,可以同时通知多个缓存服务
- 缺点:时效性一般,可能存在中间不一致状态
- 场景:时效性要求一般,有多个服务需要同步
异步实现又可以基于MQ或者Canal来实现
1.基于MQ的异步通知
- 商品服务完成对数据的修改后,只需要发送一条消息到MQ中。
- 缓存服务监听MQ消息,然后完成对缓存的更新
2.基于Canal的通知
- 商品服务完成商品修改后,业务直接结束,没有任何代码侵入
- Canal监听MySQL变化,当发现变化后,立即通知缓存服务
- 缓存服务接收到canal通知,更新缓存