一、什么是缓存穿透?
高并发场景下,如果某一个 key 被高并发访问,没有被命中,会尝试去从后端数据库中获取,这就导致了大量请求到达数据库,数据库可能因为扛不住压力而挂掉。
二、解决思路
通过加锁的方式,只允许一个请求访问数据库(Mysql),其他没抢到锁的请求返回空或者失败失败。
二、解决方案
几种方案的一步步分析改进:Atomic,ReentrantLock,ConcurrentHashMap,Guava Striped,Redisson
1. Java同步器
比如:synchronized
,reentrantLock
,AtomicBoolean
等
代码如下:
AtomicBoolean atomicBoolean = new AtomicBoolean(false);
public Integer testCacheLock(String bookId) {
Integer result = (Integer) redisUtil.get(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId);
//缓存不存在
if (result == null) {
//抢锁
if (!atomicBoolean.compareAndSet(false, true)) {
//没抢到锁
return null;
}
//抢到锁
try {
log.info("模拟查库");
result = 100;
//写缓存
redisUtil.set(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId, result);
} finally {
atomicBoolean.set(false);
}
}
log.info("模拟后续业务对result进行处理:{}", result);
return result;
}
优点:简单。Java层面的同步器,不需要引入第三方组件。
缺点:不能基于key控制锁的粒度,导致一个bookId失效,所有bookId都返回失败。
2. ConcurrentHashMap
通过map来存放key和对应的锁对象,可以实现基于key的加锁。
代码如下:
private Map<String, AtomicBoolean> locksMap = new ConcurrentHashMap<>();
public Integer testCacheLock(String bookId) {
Integer result = (Integer) redisUtil.get(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId);
//缓存不存在
if (result == null) {
//抢锁
AtomicBoolean atomicBoolean = locksMap.computeIfAbsent(bookId, key -> new AtomicBoolean(false));
if (!atomicBoolean.compareAndSet(false, true)) {
//没抢到锁
return null;
}
//抢到锁
try {
log.info("模拟查库");
result = 100;
//写缓存
redisUtil.set(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId, result);
} finally {
atomicBoolean.set(false);
}
}
log.info("模拟后续业务对result进行处理:{}", result);
return result;
}
优点:支持细粒度锁。
缺点:随着时间的推移,项目中的key越来越多,导致map占用的内存越来越大,这部分内存回收不掉。
轻则导致垃圾回收比较频繁,重则导致OOM。
优化思路:
- 弱引用方便垃圾回收
- 定时清理key,减少内存占用。
- 控制map的容量,LRU淘汰策略。
问题是还得自己造轮子,而Google已经有自己的实现:Guava Striped。
3. Guava Striped
原理是使用了ConcurrentHashMap + ReentrantLock,可以设定锁的数量。
对key进行hash获得一个下标,通过下标从map里取值。这样会导致不同的key可能会取到相同的锁。
先上代码吧:
private Striped<Lock> striped = Striped.lock(1024);
public Integer testCacheLock(String bookId) {
Integer result = (Integer) redisUtil.get(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId);
//缓存不存在
if (result == null) {
//抢锁
Lock lock = striped.get(bookId);
if (!lock.tryLock()) {//注意这里不能用lock.lock()
//没抢到锁
return null;
}
//抢到锁
try {
log.info("模拟查库");
result = 100;
//写缓存
redisUtil.set(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId, result);
} finally {
lock.unlock();
}
}
log.info("模拟后续业务对result进行处理:{}", result);
return result;
}
Striped 实现细粒度锁是基于它自己在 Striped Javadoc 中提出的一个真理,简单说来就以下三条:
- 相同的 key (hashCode()/equals()) 时, striped.get(key) 总会得到相同的锁实例
- 但是不同的 key 却可能调用 striped.get(key) 获得相同的锁实例
- 基于上一条,预建更多的锁实例数量能减低锁碰撞的可能性
优点:控制了锁的数量,避免内存占用太大。
缺点:
- 不同的key会取到相同的锁。
- 需要引入Guava,与hutool的很多功能都重复。
- Guava仓库的最新版本31.1中Striped类依然被标记为@Beta不稳定版本
- 也有文章提到使用Striped引起了死锁问题:使用guava Striped中的lock导致线程死锁的问题分析。分析一下我们的场景:锁的粒度比较小,取一次锁用完就释放,不会占着第一个锁再去获取第二个锁。所以倒不会引起死锁。
- 有关Striped的文章大都发布于几年前,近两年的文章也有,但是很少,热度不高。
4. 分布式锁Redisson
Redisson是什么?
Redisson与Jedis、Lettuce一样,都是Redis官方推荐的客户端。Jedis、Lettuce 的 API 更侧重对 Reids 数据库的 CRUD(增删改查),而 Redisson API 侧重于分布式开发。
分布式锁通过原生的redis setnx命令可以实现,但是需要考虑锁的续期问题,而Redisson已经实现好了。Redisson分布式锁正是目前主流的分布式锁实现方案。
public Integer testCacheLock(String bookId) {
Integer result = (Integer) redisUtil.get(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId);
//缓存不存在
if (result == null) {
//抢锁
RLock lock = redissonClient.getLock(bookId);
if (!lock.tryLock()) {//注意这里不能用lock.lock()
//没抢到锁
return null;
}
//抢到锁
try {
log.info("模拟查库");
result = 100;
//写缓存
redisUtil.set(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId, result);
} finally {
lock.unlock();
}
}
log.info("模拟后续业务对result进行处理:{}", result);
return result;
}
优点:把锁都放到redis里面,解决了jvm内存的占用问题。并且redis的锁默认有失效时间,不会占用太多redis的内存。
缺点:需要引入Redisson。
最佳实践
代码中一般存在很多缓存穿透的地方,需要编写大量的加锁解锁操作,对业务代码入侵太大。
推荐采用 注解+Spring AOP
的方式,使加锁解锁操作脱离出业务代码。
因为Aop切面的最小粒度是方法,所以这里把查库
和写缓存
的代码单独提取为一个方法,即reloadToRedis(String bookId),并为该方法打上注解:
public Integer testCacheLock(String bookId) {
Integer result = (Integer) redisUtil.get(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId);
//缓存不存在
if (result == null) {
//提取后的代码
result = testService.reloadToRedis(bookId);
}
log.info("模拟后续业务对result进行处理:{}", result);
return result;
}
reloadToRedis()内部:
@Override
@QueryLock(keyPre = "book_lock_")//重点
public Integer reloadToRedis(String bookId) {
log.info("模拟查库");
int result = 100;
//写缓存
redisUtil.set(EnumBookRedisKey.UPDATE_BOOK_DAYS, bookId, result);
return result;
}
注解类:
@Target({ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface QueryLock {
//前缀key,如book:cache:
String keyPre() default "";
//变化的key,如bookId
String queryParamKey() default "";
}
切面类:
protected Object handle(final ProceedingJoinPoint joinPoint) {
//取出QueryLock注解
QueryLock annotation = getAnnotationLimiter(joinPoint);
if (annotation == null) {
return null;
}
//根据注解配置取出key
String key = annotation.keyPre() + ServletUtil.getRequest().getParameter(annotation.queryParamKey());
//抢锁
RLock lock = redissonClient.getLock(key);
if (!lock.tryLock()) {//注意这里不能用lock.lock()
//没抢到锁
return null;
}
//抢到锁
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
log.error("", throwable);
if (throwable instanceof ServiceException) {
throw (ServiceException) throwable;
}
throw new ServiceException(EnumBaseError.SYSTEM_ERROR);
} finally {
lock.unlock();
}
}
ok,大功告成。
总结
综上,决定使用Redisson。