通用互斥锁和逻辑过期方案解决redis缓存击穿
缓存击穿
有时候,我们在访问热点数据时。比如:我们在某个商城购买某个热门商品。
为了保证访问速度,通常情况下,商城系统会把商品信息放到缓存中。但如果某个时刻,该商品到了过期时间失效了。
此时,如果有大量的用户请求同一个商品,但该商品在缓存中失效了,一下子这些用户请求都直接打到数据库,可能会造成瞬间数据库压力过大,而直接挂掉。
这种情况通常有下面几种解决方案:
- 如果业务允许,将key值设置为永不过期。
- 使用互斥锁,保证在缓存失效仅有一个线程访问数据库,降低了在同一时刻打在数据库上的请求,防止数据库打死。当然加锁会导致系统的性能变差,但能在一定程度上保证用户访问的数据不为过期的旧数据。
- 使用逻辑过期
- 在将数据缓存到redis中添加一个逻辑过期时间,并将缓存的key值设置为永不过期。
- 拿到缓存时获取到逻辑过期时间比较当前时间
- 过期:获取互斥锁
- 成功:返回缓存数据并开启一个异步线程更新缓存数据,更新完成后释放锁。
- 失败:说明已经有其它请求开启异步线程更新缓存,直接返回缓存数据。
- 未过期:返回缓存数据
- 过期:获取互斥锁
方案一:互斥锁方案
代码逻辑
- 从数据库拿到缓存数据
- 命中直接返回数据,若缓存是""则返回null解决缓存击穿。
- 尝试获取锁并重建缓存
- 获取互斥锁成功
- 从数据库中获取数据,并重建缓存。(若数据库同样不存在该数据则缓存控制解决缓存击穿)。
- 获取互斥锁失败
- 递归调用方法,直到缓存重建成功
- 获取互斥锁成功
方法签名
因为需要考虑到通用性,我们需要使用Java泛型来设计方法,我们用泛型R来表示缓存的类型。方法的签名如下:
public <R> R queryWithMutex(String key,Class<R> type, Supplier<R> dbSupplier, Long time, TimeUnit unit)
- R:表示缓存的类型
- key:获取缓存需要缓存的key
- type :返回值类型(class)
- dbSupplier:存储数据库中的数据(函数式接口)
- time:缓存有效期
- unit:缓存有效期时间单位
代码
/**
* 互斥锁解决缓存击穿
* @param key 缓存的key
* @param type 返回值类型(class)
* @param dbSupplier 获取数据库的数据
* @param time 过期时间
* @param unit 时间单位
* @param <R> 返回数据类型
* @return
*/
public <R> R queryWithMutex(String key,Class<R> type, Supplier<R> dbSupplier, Long time, TimeUnit unit){
String CacheStr = getCache(key);
// 缓存为空说明数据库和缓存中都不存在该数据(缓存穿透)
if("".equals(CacheStr)){
log.info("缓存穿透~~~");
return null;
}
//缓存命中直接将数据返回
if(CacheStr != null) {
log.info("走了缓存~~~");
return JSONUtil.toBean(CacheStr,type);
}
//互斥锁重建缓存
R dbData = null;
SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate,key.substring(key.indexOf(":")));
try {
//已经有线程开始重构,其余线程等待
if(!simpleRedisLock.tryLock(20l)){
log.info("我没拿到锁,我需要等。。。" + Thread.currentThread().getId());
Thread.sleep(500);
return queryWithMutex(key,type,dbSupplier,time,unit);
}
log.info("我拿到锁,进行缓存重建,{},{}",key,Thread.currentThread().getId());
dbData = dbSupplier.get();
Thread.sleep(15000);
//缓存穿透
if(dbData == null){
set(key, "",CACHE_NULL_TTL,TimeUnit.MINUTES );
return null;
}
set(key,dbData,time,unit);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
simpleRedisLock.unlock();
}
return dbData;
}
调用方法
该方法作用是根据id返回对应的文章详情。
@Override
public Result detailArticle(Long id,Boolean isEdit) {
ArticleVo articleVo = cacheClient.queryWithMutex(
RedisConstants.CACHE_Article_KEY + id,
ArticleVo.class,
() -> ArticleToArticleVo(lambdaQuery().eq(Article::getId, id).one(), true),
RedisConstants.CACHE_Article_TTL,
TimeUnit.MINUTES
);
if(articleVo == null) return Result.fail(ARTICLE_NOT_EXIST.getCode(),ARTICLE_NOT_EXIST.getMsg());
return Result.success(articleVo);
}
其中需要注意的点就是Supplier<R> dbSupplier
参数需要传入一个() -> x
的lambda表达式或者方法引用,其中返回的x即为需要进行缓存的数据,并且需要将操作数据库的操作放入lambda表达式中。
测试
我们在重建缓存时使线程sleep了15s来模拟缓存重建,并记录相关日志以测试互斥锁是否生效。我们使用postman
发送两个请求获取文章id为1的数据:
控制台:
2023-10-11T15:00:48.402+08:00 INFO 17040 --- [nio-8888-exec-2] com.duan.blog.utils.CacheClient : 我拿到锁,进行缓存重建,cache:article:1,35
JDBC Connection [HikariProxyConnection@1604163156 wrapping com.mysql.cj.jdbc.ConnectionImpl@66995400] will not be managed by Spring
==> Preparing: SELECT id,title,summary,comment_counts,view_counts,author_id,body_id,category_id,weight,create_date FROM tb_article WHERE (id = ?)
==> Parameters: 1(Long)
<== Columns: id, title, summary, comment_counts, view_counts, author_id, body_id, category_id, weight, create_date
.......
对数据库的操作
可以看到线程id为35的线程获取到了锁并进行了缓存重建。
2023-10-11T15:00:48.970+08:00 INFO 17040 --- [nio-8888-exec-5] com.duan.blog.utils.CacheClient : 我没拿到锁,我需要等。。。38
2023-10-11T15:00:49.487+08:00 INFO 17040 --- [nio-8888-exec-5] com.duan.blog.utils.CacheClient : 我没拿到锁,我需要等。。。38
2023-10-11T15:00:49.998+08:00 INFO 17040 --- [nio-8888-exec-5] com.duan.blog.utils.CacheClient : 我没拿到锁,我需要等。。。38
2023-10-11T15:00:50.519+08:00 INFO 17040 --- [nio-8888-exec-5] com.duan.blog.utils.CacheClient : 我没拿到锁,我需要等。。。38
2023-10-11T15:00:51.037+08:00 INFO 17040 --- [nio-8888-exec-5] com.duan.blog.utils.CacheClient : 我没拿到锁,我需要等。。。38
2023-10-11T15:00:51.551+08:00 INFO 17040 --- [nio-8888-exec-5] com.duan.blog.utils.CacheClient : 我没拿到锁,我需要等。。。38
.........
重复
可以看到第二个线程id为38的未获取到锁而进行等待
2023-10-11T15:01:03.863+08:00 INFO 17040 --- [nio-8888-exec-5] com.duan.blog.utils.CacheClient : 走了缓存~~~
2023-10-11T15:01:37.334+08:00 INFO 17040 --- [nio-8888-exec-3] com.duan.blog.utils.CacheClient : 走了缓存~~~
2023-10-11T15:01:56.831+08:00 INFO 17040 --- [nio-8888-exec-6] com.duan.blog.utils.CacheClient : 走了缓存~~~
2023-10-11T15:01:59.639+08:00 INFO 17040 --- [nio-8888-exec-7] com.duan.blog.utils.CacheClient : 走了缓存~~~
线程id35重建缓存成功之后另一个等待的线程就获取到了缓存中的数据而未去访问数据库,并且在后续访问中都从缓存中拿到了数据。因此测试后发现该互斥锁方案生效,是一种解决缓存击穿的方案。
方案二:逻辑过期
代码逻辑
- 提前对需要缓存的数据进行预热,存入逻辑过期时间。
- 根据key拿到缓存后判断逻辑过期时间是否过期
- 过期则获取互斥锁
- 成功:开启异步线程更新缓存,并将过期数据返回
- 失败:直接返回过期数据
- 未过期则返回缓存数据
- 过期则获取互斥锁
方法签名
public <R> R queryWithLogicalExpire(String key,Class<R> type, Supplier<R> dbSupplier,
Long expire, TimeUnit unit)
- R:表示缓存的类型
- key:获取缓存需要缓存的key
- type :返回值类型(class)
- dbSupplier:存储数据库中的数据(函数式接口)
- time:缓存有效期
- unit:缓存有效期时间单位
代码
缓存预热:
@Test
public void preCache(){
cacheClient.setWithLogicalExpire(
RedisConstants.CACHE_Article_KEY + 1,
articleService.lambdaQuery().eq(Article::getId, 1).one(),
30l,
TimeUnit.SECONDS);
}
/**
* 逻辑过期方案解决缓存击穿
* @param key 缓存的key
* @param type 返回值类型(class)
* @param dbSupplier 获取数据库的数据
* @param expire 过期时间
* @param unit 时间单位
* @param <R> 返回数据类型
* @return
*/
@Resource
ThreadService threadService;
public <R> R queryWithLogicalExpire(String key,Class<R> type, Supplier<R> dbSupplier, Long expire, TimeUnit unit){
RedisData redisData = getCache(key,RedisData.class);
//判断数据是否过期
R cache = BeanUtil.toBean(redisData.getData(), type);
if(redisData.getExpireTime().isAfter(LocalDateTime.now())) return cache;
log.info("缓存过期");
SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate,key.substring(key.indexOf(":")));
log.info("我拿到锁,开始异步线程重构缓存");
if(simpleRedisLock.tryLock(10l)){
try {
threadService.rebuildCache(
new RedisData(LocalDateTime.now().plusSeconds(unit.toSeconds(expire)),dbSupplier.get()),
key,stringRedisTemplate);
}catch (Exception e){
throw new RuntimeException();
}finally {
simpleRedisLock.unlock();
}
}
return cache;
}
@Override
public <R> void rebuildCache(R dbData,String cacheKey, StringRedisTemplate stringRedisTemplate) {
stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(dbData));
log.info("重建缓存成功。。。。"+Thread.currentThread().getId());
}
其中rebuildCache
方法是由异步线程池来执行的。
调用方法
该方法作用是根据id返回对应的文章详情。
@Override
public Result detailArticle(Long id,Boolean isEdit) {
Article articleVo = cacheClient.queryWithLogicalExpire(
RedisConstants.CACHE_Article_KEY + id,
Article.class,
() -> lambdaQuery().eq(Article::getId, id).one(),
RedisConstants.CACHE_Article_TTL,
TimeUnit.MINUTES
);
if(articleVo == null) return Result.fail(ARTICLE_NOT_EXIST.getCode(),ARTICLE_NOT_EXIST.getMsg());
return Result.success(articleVo);
}
其中需要注意的点就是Supplier<R> dbSupplier
参数需要传入一个() -> x
的lambda表达式或者方法引用,其中返回的x即为需要进行缓存的数据,并且需要将操作数据库的操作放入lambda表达式中。
测试
- 先测试缓存未过期的情况:
Result(success=true, code=200, msg=success, data=Article(id=1, title=springboot介绍以及入门案例, summary=通过Spring Boot实现的服务,只需要依靠一个Java类,把它打包成jar,并通过`java -jar`命令就可以运行起来。
这一切相较于传统Spring应用来说,已经变得非常的轻便、简单。, commentCounts=1, viewCounts=178, authorId=1, bodyId=1, categoryId=2, weight=0, createDate=1695280032000))
可以看到请求并未访问数据库而是从缓存拿到数据。
- 缓存过期的情况:
2023-10-12T15:43:31.334+08:00 INFO 13792 --- [ main] com.duan.blog.utils.CacheClient : 缓存过期
2023-10-12T15:43:31.338+08:00 INFO 13792 --- [ main] com.duan.blog.utils.CacheClient : 我拿到锁,开始异步线程重构缓存
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@41736818] was not registered for synchronization because synchronization is not active
2023-10-12T15:43:31.387+08:00 WARN 13792 --- [ main] c.b.m.c.t.support.ReflectLambdaMeta : Unable to make field private final java.lang.Class java.lang.invoke.SerializedLambda.capturingClass accessible: module java.base does not "opens java.lang.invoke" to unnamed module @6cd8737
2023-10-12T15:43:31.399+08:00 INFO 13792 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2023-10-12T15:43:31.529+08:00 INFO 13792 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@5fa3df
2023-10-12T15:43:31.530+08:00 INFO 13792 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
JDBC Connection [HikariProxyConnection@2061363062 wrapping com.mysql.cj.jdbc.ConnectionImpl@5fa3df] will not be managed by Spring
==> Preparing: SELECT id,title,summary,comment_counts,view_counts,author_id,body_id,category_id,weight,create_date FROM tb_article WHERE (id = ?)
==> Parameters: 1(Long)
.....数据库操作
2023-10-12T15:43:31.603+08:00 INFO 13792 --- [ main] c.d.blog.Service.impl.ThreadServiceImpl : 重建缓存成功。。。。1
可以看到缓存过期后我们成功重建了缓存并刷新了逻辑过期时间。
下面使用postman同时发送两个情况测试缓存过期的情况,并手动更改数据库数据与缓存数据不一致:
请求一获取的数据:
{
"success": true,
"code": 200,
"msg": "success",
"data": {
"id": "1",
"title": "springboot介绍以及入门案例",
"summary": "通过Spring Boot实现的服务,只需要依靠一个Java类,把它打包成jar,并通过`java -jar`命令就可以运行起来。\r\n\r\n这一切相较于传统Spring应用来说,已经变得非常的轻便、简单。",
"commentCounts": 1,
"viewCounts": 178, //缓存不一致
"authorId": "1",
"bodyId": "1",
"categoryId": "2",
"weight": 0,
"createDate": "1695280032000"
}
}
请求二获取的数据:
{
"success": true,
"code": 200,
"msg": "success",
"data": {
"id": "1",
"title": "springboot介绍以及入门案例",
"summary": "通过Spring Boot实现的服务,只需要依靠一个Java类,把它打包成jar,并通过`java -jar`命令就可以运行起来。\r\n\r\n这一切相较于传统Spring应用来说,已经变得非常的轻便、简单。",
"commentCounts": 1,
"viewCounts": 180, 与数据库相同
"authorId": "1",
"bodyId": "1",
"categoryId": "2",
"weight": 0,
"createDate": "1695280032000"
}
}
从这我们可以看出,缓存过期与缓存重建成功之间的请求获取到的数据是与数据库不一致的。
总结
**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据。