1.缓存击穿解释
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和前面缓存雪崩的区别在于这里针对某一key缓存,前者则是一堆key在同一时刻失效。
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
结合上一章Redis学习之1招雪崩自己的系统附送N招解决雪崩大礼包 对雪崩的理解,我通过画图对比。
可以通过图中差异得出,击穿在于同一个key集中查询,当key失效,所有查询压力就直接下去DB,最后应该最差情况导致数据库挂掉,但是一般我们会用连接池限制得情况下,会导致数据库连接过大,业务系统直接gg,无法从数据库获取查询,同时影响其他db操作。
2.缓存击穿模拟
使用前面Redis+SpringBoot+SpringCache基础项目搭建 作为基础项目进行模拟。
具体思路:
- 设置数据库连接池最大数量为5,增大数据库表数据量(增加连接占用时间)。
- 创建key1-8888数据持久化到数据库。
- 设置缓存过期时间为1s。
- 创建线程池,多线程针对查询。
@Test
public void testQueryIdWithBreakDown(){
String key = "8888";
//存进缓存
redisTemplate.opsForValue().set(key,"value", Duration.ofSeconds(1));
ExecutorService es = Executors.newFixedThreadPool(10);
int loop= 1;
//睡个觉等过期
try {
Thread.sleep(1000);
}catch (Exception e){
e.printStackTrace();
}
//开始了疯狂查询,其实并不是太疯狂,结合DB最大连接数才5 我们放10条线程攻击就很容易gg了
for (int i = 0; i < 10; i++) {
es.execute(() -> {
for (int k = 0; k < loop; k++) {
if(!redisTemplate.hasKey(key)){
//数据查询
Assert.assertNotNull(userService.queryByIdWithTest(Integer.parseInt(key)));
//放进缓存
redisTemplate.opsForValue().set(key,"value", Duration.ofSeconds(1));
}
}
});
}
}
控制台结果:
`java.sql.SQLNonTransientConnectionException: Data source rejected establishment of connection, message from server: "Too many connections"
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:110) ~[mysql-connector-java-8.0.19.jar:8.0.19]
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97) ~[mysql-connector-java-8.0.19.jar:8.0.19]
at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122) ~[mysql-connector-java-8.0.19.jar:8.0.19]
at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:836) ~[mysql-connector-java-8.0.19.jar:8.0.19]
at com.mysql.cj.jdbc.ConnectionImpl.<init>(ConnectionImpl.java:456) ~[mysql-connector-java-8.0.19.jar:8.0.19]
at com.mysql.cj.jdbc.ConnectionImpl.getInstance(ConnectionImpl.java:246) ~[mysql-connector-java-8.0.19.jar:8.0.19]
at com.mysql.cj.jdbc.NonRegisteringDriver.connect(NonRegisteringDriver.java:197) ~[mysql-connector-java-8.0.19.jar:8.0.19]
at com.alibaba.druid.filter.FilterChainImpl.connection_connect(FilterChainImpl.java:149) ~[druid-1.1.9.jar:1.1.9]
at com.alibaba.druid.filter.stat.StatFilter.connection_connect(StatFilter.java:218) ~[druid-1.1.9.jar:1.1.9]
at com.alibaba.druid.filter.FilterChainImpl.connection_connect(FilterChainImpl.java:143) ~[druid-1.1.9.jar:1.1.9]
at com.alibaba.druid.pool.DruidAbstractDataSource.createPhysicalConnection(DruidAbstractDataSource.java:1515) ~[druid-1.1.9.jar:1.1.9]
at com.alibaba.druid.pool.DruidAbstractDataSource.createPhysicalConnection(DruidAbstractDataSource.java:1578) ~[druid-1.1.9.jar:1.1.9]
at com.alibaba.druid.pool.DruidDataSource$CreateConnectionThread.run(DruidDataSource.java:2466) ~[druid-1.1.9.jar:1.1.9]`
很明显数据库连接数超了,导致gg,以上就是简单对击穿进行模拟。而在实际情况中,缓存击穿的现象很常见,比如微博某个热点话题导致宕机,就很有可能在缓存层已经撑不住了,对这样的“话题”我们可以认为是热点key,从缓存击穿的解决方案上,我们可以沿用雪崩那套解决Redis学习之1招雪崩自己的系统附送N招解决雪崩大礼包,因为击穿跟雪崩的差别就是热点key跟一大堆或者全部key的差别而已。
3.缓存击穿解决方案
3.1 通过逐级降低访问的流量的方式保持业务系统的稳定性
与雪崩同样的,再面对大范围的访问,先想到的还是“倒三角”模型,通过逐级降低访问的流量的方式保持业务系统的稳定性。
3.2 使用互斥锁
使用互斥锁的原理就是就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。
具体可以参考:Redis学习之1招雪崩自己的系统附送N招解决雪崩大礼包
3.3 异步构建缓存
异步构建缓存就是将过期时间存放在value里面或者其他地方,然后发现过期后异步更新缓存,但是很有可能导致一致性问题。
下面我们来简单实现一下
这里我采用的方案是在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;
}
3.4 热点标注
其实按照现在大多数业务系统都会进行热点统计监控的话,很容易得出热点集合,对热点集合进行“永不过期”处理也是一种方案。
4. 总结
我觉得缓存击穿是缓存雪崩的一种特殊形式,但是其实又是最常见的一种问题,在经过模拟场景后,通过代码形式,结合程序执行返回,更好地理解缓存击穿是怎么产生的,在理解产生之后,针对现象进行分析获取缓存击穿的解决方案,虽然上面列举几个解决方法,但是并都不是一劳永逸的方案,需要结合具体的场景采取具体的解决方案,以上只是给与思路参考。