缓存:热点key重建优化
Posted JavaPub
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了缓存:热点key重建优化相关的知识,希望对你有一定的参考价值。
本篇是实战经验,大概阅读3分钟
前言
现在是 2021 辛丑年 叄月,大家都知道我最近在做一套JavaPub系列面试题,前面已经更新过一部分,在接下来还会持续更新,欢迎大家分享、关注
序言
再高大上的框架,也需要扎实的基础才能玩转,高频面试问题更是基础中的高频实战要点。
适合阅读人群
Java 学习者和爱好者,有一定工作经验的技术人,准面试官等。
阅读建议
本教程是系列教程,包含 Java 基础,JVM,容器,多线程,反射,异常,网络,对象拷贝,JavaWeb,设计模式,Spring-Spring MVC,Spring Boot / Spring Cloud,Mybatis / Hibernate,Kafka,RocketMQ,Zookeeper,mysql,Redis,Elasticsearch,Lucene
微信搜:JavaPub,阅读全套系列面试题教程
热点key重建优化
开发人员使用**“缓存+过期时间“的策略既可以加速数据读写,又保证数据的定时更新,这种模式基本满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害(也就是缓存击穿**):
-
当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。 -
重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。
缓存击穿:缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
在缓存失效的瞬间,有大量线程来重建缓存(如下图所示),造成后端负载加大,甚至可能会让应用奔溃。
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:
-
减少重建缓存的次数。 -
数据尽可能一致。 -
较少的潜在危险。
常见解决方案
互斥锁(mutex key)
此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如下图所示。
下面代码使用 Redis 的 setnx
命令实现上述功能:
String get(String key) {
// 从Redis中获取数据
String value = redis.get(key);
// 如果value为空,则开始重构缓存
if(value == null) {
// 只允许一个线程重构缓存,使用nx,并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if(redis.set(mutexKey, "1", "ex 180", "nx")) {
// 从数据源获取数据
value = db.get(key);
// 回写Redis,并设置过期时间
redis.setex(key, timeout, value);
// 删除key_mutex
redis.delete(mutex_key);
}
// 其他线程休息50毫秒后重试
else {
Thread.sleep(50);
get(key);
}
}
return value;
}
-
从 Redis 获取数据,如果值不为空,则直接返回值;否则执行下面 2 和 3 步骤。 -
如果 set(nx和ex) 结果为 true,说明此时没有其他线程重建缓存,那么当前线程执行缓存构建逻辑。 -
如果 set(nx和ex) 结果为 false,说明此时已经有其他线程正在执行构建缓存的工作,那么当前线程将休息指定时间(例如这里是50毫秒,取决于构建缓存的速度)后,重新执行函数,直到获取到数据。
永远不过期
“永远不过期”包含两层意思:
-
从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。 -
从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存
整个过程如下图所示。
从实战看,此方法有效地杜绝了热点 key 产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。下面代码使用 Redis 进行模拟:
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
// 逻辑过期时间
long logicTimeout = v.getLogicTimeout();
// 如果逻辑过期时间小于当前时间,开始后台构建
if(v.logicTimeout <= System.currentTimeMillis()) {
String mutexKey = "mutex:key:" + key;
if(redis.set(mutexKey, "1", "ex 180", "nx")) {
// 重构缓存
threadPool.execute(new Runnable(){
public void run() {
String dbValue = db.get(key);
redis.set(key, dbvalue, newLogicTimeout);
redis.delete(mutexKey);
}
});
}
}
return value;
}
作为一个并发量较大的应用,在使用缓存时有三个目标:
-
加快用户访问速度,提高用户体验。 -
降低后端负载,减少潜在的风险,保证系统平稳。 -
保证数据“尽可能”及时更新。
下面将按照这三个维度对上述两种解决方案进行分析。
-
互斥锁(mutex key):这种方案思路比较简单,但是存在一定的隐患,如果构建过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好的降低后端存储负载,并在一致性上做的比较好。 -
“永远不过期”:这种方案由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。
两种解决方法对比如下表所示。
解决方案 | 优点 | 缺点 |
---|---|---|
简单分布式锁 | 思路简单 保证一致性 | 代码复杂度增大 存在死锁的风险 存在线程池阻塞的风险 |
永远不过期 | 基本杜绝热点key问题 | 不保证一致性 逻辑过期时间增加代码维护成本和内存成本 |
我是JavaPub,下期见。
以上是关于缓存:热点key重建优化的主要内容,如果未能解决你的问题,请参考以下文章