缓存穿透解决方案和最佳实践(注解+AOP)

一、什么是缓存穿透?

高并发场景下,如果某一个 key 被高并发访问,没有被命中,会尝试去从后端数据库中获取,这就导致了大量请求到达数据库,数据库可能因为扛不住压力而挂掉。

二、解决思路

通过加锁的方式,只允许一个请求访问数据库(Mysql),其他没抢到锁的请求返回空或者失败失败。

二、解决方案

几种方案的一步步分析改进:Atomic,ReentrantLock,ConcurrentHashMap,Guava Striped,Redisson

1. Java同步器

比如:synchronizedreentrantLockAtomicBoolean

代码如下:

    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。

优化思路

  1. 弱引用方便垃圾回收
  2. 定时清理key,减少内存占用。
  3. 控制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 中提出的一个真理,简单说来就以下三条:

  1. 相同的 key (hashCode()/equals()) 时, striped.get(key) 总会得到相同的锁实例
  2. 但是不同的 key 却可能调用 striped.get(key) 获得相同的锁实例
  3. 基于上一条,预建更多的锁实例数量能减低锁碰撞的可能性

优点:控制了锁的数量,避免内存占用太大。

缺点

  1. 不同的key会取到相同的锁。
  2. 需要引入Guava,与hutool的很多功能都重复。
  3. Guava仓库的最新版本31.1中Striped类依然被标记为@Beta不稳定版本
  4. 也有文章提到使用Striped引起了死锁问题:使用guava Striped中的lock导致线程死锁的问题分析。分析一下我们的场景:锁的粒度比较小,取一次锁用完就释放,不会占着第一个锁再去获取第二个锁。所以倒不会引起死锁。
  5. 有关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。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
要通过自定义注解AOP来实现Spring Security配置指定接口不需要Token才能访问,可以按照以下步骤进行操作: 1. 创建一个自定义注解,例如`@NoTokenRequired`,用于标识不需要Token的接口。 ```java @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoTokenRequired { } ``` 2. 创建一个切面类,用于拦截带有`@NoTokenRequired`注解的方法,并跳过Spring Security的Token验证。 ```java @Aspect @Component public class TokenValidationAspect { @Before("@annotation(com.example.NoTokenRequired)") public void skipTokenValidation(JoinPoint joinPoint) { // 跳过Spring Security的Token验证逻辑 SecurityContextHolder.getContext().setAuthentication(null); } } ``` 3. 配置Spring Security,将AOP切面类添加到Spring Security的配置中。 ```java @Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private TokenValidationAspect tokenValidationAspect; @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() // 配置需要Token验证的接口 .anyRequest().authenticated() .and() .csrf().disable(); // 将AOP切面类添加到Spring Security的配置中 http.addFilterBefore(tokenValidationAspect, UsernamePasswordAuthenticationFilter.class); } } ``` 4. 在需要不需要Token验证的接口上,添加`@NoTokenRequired`注解。 ```java @RestController public class ExampleController { @NoTokenRequired @GetMapping("/example") public String example() { return "This API does not require Token"; } } ``` 这样配置之后,带有`@NoTokenRequired`注解的接口将不会进行Spring Security的Token验证,即可在没有Token的情况下访问该接口。其他接口仍然需要进行Token验证。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值