• 【Redis实战】击穿+雪崩+穿透


    架构

    image.png

    短信登录

    基于session实现登录

    流程图

    image.png

    代码实现
    @Slf4j
    @Service
    public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    
        /**
         * session用户key
         */
        public static final String USER_CONSTANT = "user";
    
        @Override
        public Result sendCode(String phone, HttpSession session) {
            //校验手机号码
            boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
            if (phoneInvalid) {
                return Result.fail("手机号码格式错误!");
            }
            //生成6位数的验证码
            String code = RandomUtil.randomNumbers(6);
            session.setAttribute("code", code);
            //发送验证码
            log.info("send code success,code={}", code);
            return Result.ok();
        }
    
        @Override
        public Result login(LoginFormDTO loginForm, HttpSession session) {
            //校验手机号码
            if (Objects.isNull(loginForm)) {
                return Result.fail("参数为空!");
            }
            String phone = loginForm.getPhone();
            if (RegexUtils.isPhoneInvalid(phone)) {
                return Result.fail("手机号码格式错误!");
            }
            //验证码校验
            String code = (String) session.getAttribute("code");
            if (StringUtils.isBlank(code) || !StringUtils.equals(code, loginForm.getCode())) {
                return Result.fail("验证码错误!");
            }
            LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(User::getPhone, phone);
            User user = getOne(wrapper);
            if (!Objects.nonNull(user)) {
                //注册新用户
                user = getNewUserByPhone(phone);
                save(user);
            }
            session.setAttribute(USER_CONSTANT, BeanUtil.copyProperties(user, UserDTO.class));
            return Result.ok();
        }
    
        /**
         * 根据手机号码创建新用户
         *
         * @param phone 手机号码
         * @return
         */
        private User getNewUserByPhone(String phone) {
            User user = new User();
            user.setCreateTime(LocalDateTime.now());
            user.setPhone(phone);
            user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
            user.setUpdateTime(LocalDateTime.now());
            return user;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    集群session共享问题

    image.png

    session数据拷贝可以解决这个问题,但是多台tomcat之间存储相同的数据会浪费内存空间,拷贝会有数据延迟。
    session每个浏览器有不同的code,tomcat里保存里很多code。

    基于Redis实现session登录

    验证码流程图

    image.png

    代码实现
        public Result sendCode(String phone, HttpSession session) {
            //校验手机号码
            boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
            if (phoneInvalid) {
                return Result.fail("手机号码格式错误!");
            }
            //生成6位数的验证码
            String code = RandomUtil.randomNumbers(6);
            //保存验证码到redis
            stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.SECONDS);
            //发送验证码
            log.info("send code success,code={}", code);
            return Result.ok();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    校验流程图

    image.png

    代码实现

    登录

        public Result login(LoginFormDTO loginForm, HttpSession session) {
            //校验手机号码
            if (Objects.isNull(loginForm)) {
                return Result.fail("参数为空!");
            }
            String phone = loginForm.getPhone();
            if (RegexUtils.isPhoneInvalid(phone)) {
                return Result.fail("手机号码格式错误!");
            }
            //验证码校验
            String code = (String) session.getAttribute("code");
            if (StringUtils.isBlank(code) || !StringUtils.equals(code, loginForm.getCode())) {
                return Result.fail("验证码错误!");
            }
            LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(User::getPhone, phone);
            User user = getOne(wrapper);
            if (!Objects.nonNull(user)) {
                //注册新用户
                user = getNewUserByPhone(phone);
                save(user);
            }
            session.setAttribute(USER_CONSTANT, BeanUtil.copyProperties(user, UserDTO.class));
            return Result.ok();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    登录拦截器

    import cn.hutool.core.bean.BeanUtil;
    import cn.hutool.http.HttpStatus;
    import com.hmdp.dto.UserDTO;
    import com.hmdp.utils.UserHolder;
    import org.apache.commons.lang3.StringUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    import org.springframework.util.CollectionUtils;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.util.Map;
    import java.util.concurrent.TimeUnit;
    
    import static com.hmdp.utils.RedisConstants.*;
    
    /**
     * 登录拦截器
     *
     * @author zhangzengxiu
     * @date 2023/10/6
     */
    @Component
    public class LoginInterceptor implements HandlerInterceptor {
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            //获取请求头的token
            String token = request.getHeader("authorization");
            if (StringUtils.isBlank(token)) {
                response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
                return false;
            }
            //获取redis中的token
            String tokenKey = LOGIN_USER_KEY + token;
            Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
            if (CollectionUtils.isEmpty(map)) {
                //未授权
                response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
                return false;
            }
            UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
            //用户信息保存到ThreadLocal中
            UserHolder.saveUser(userDTO);
            //刷新token有效期
            stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
            return true;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    拦截器的操作方式
    方式一

    拦截器

    public class LoginInterceptor implements HandlerInterceptor {
    
        private StringRedisTemplate stringRedisTemplate;
    
        /**
         * 这个LoginInterceptor是new出来的,所以不能使用Spring注入Bean
         */
        public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
           this.stringRedisTemplate = stringRedisTemplate;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    使用拦截器

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        
        /**
         * 添加拦截器
         *
         * @param registry
         */
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            InterceptorRegistration registration = registry.addInterceptor(new LoginInterceptor(stringRedisTemplate));
            registration.excludePathPatterns("/user/code");
            registration.excludePathPatterns("/user/login");
            registration.excludePathPatterns("/blog/hot");
            registration.excludePathPatterns("/shop/**");
            registration.excludePathPatterns("/shop-type/**");
            registration.excludePathPatterns("/voucher/**");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    方式二

    拦截器:配置为Spring的组件

    @Component
    public class LoginInterceptor implements HandlerInterceptor {
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    注册拦截器:依赖注入使用即可

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
    
        @Autowired
        private LoginInterceptor loginInterceptor;
    
        /**
         * 添加拦截器
         *
         * @param registry
         */
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            InterceptorRegistration registration = registry.addInterceptor(loginInterceptor);
            registration.excludePathPatterns("/user/code");
            registration.excludePathPatterns("/user/login");
            registration.excludePathPatterns("/blog/hot");
            registration.excludePathPatterns("/shop/**");
            registration.excludePathPatterns("/shop-type/**");
            registration.excludePathPatterns("/voucher/**");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    Redis实现session共享

    image.png

    拦截器优化

    当前存在的问题:
    如果用户访问不需要登录鉴权的接口,token就不会刷新,token可能会过期。
    image.png
    token刷新拦截器

    import cn.hutool.core.bean.BeanUtil;
    import com.hmdp.dto.UserDTO;
    import com.hmdp.utils.UserHolder;
    import org.apache.commons.lang3.StringUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    import org.springframework.util.CollectionUtils;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.util.Map;
    import java.util.concurrent.TimeUnit;
    
    import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
    import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;
    
    /**
     * @author zhangzengxiu
     * @date 2023/10/6
     */
    @Component
    public class RefreshTokenInterceptor implements HandlerInterceptor {
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            //获取请求头的token
            String token = request.getHeader("authorization");
            if (StringUtils.isBlank(token)) {
                return true;
            }
            //获取redis中的token
            String tokenKey = LOGIN_USER_KEY + token;
            Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(tokenKey);
            if (CollectionUtils.isEmpty(map)) {
                //未授权
                return true;
            }
            UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
            //用户信息保存到ThreadLocal中
            UserHolder.saveUser(userDTO);
            //刷新token有效期
            stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
            return true;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    登录拦截器

    import cn.hutool.http.HttpStatus;
    import com.hmdp.dto.UserDTO;
    import com.hmdp.utils.UserHolder;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.util.Objects;
    
    /**
     * 登录拦截器
     *
     * @author zhangzengxiu
     * @date 2023/10/6
     */
    @Component
    public class LoginInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            UserDTO userDTO = UserHolder.getUser();
            if (Objects.isNull(userDTO)) {
                response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
                return false;
            }
            return true;
        }
    
        /**
         * 后置拦截器
         * 销毁用户信息,防止内存泄露
         *
         * @param request
         * @param response
         * @param handler
         * @param ex
         * @throws Exception
         */
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            UserHolder.removeUser();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    商户查询

    缓存

    缓存就是数据交换的缓冲区称作Cache,是存储数据的临时地方,一般读写性能比较高。

    CPU缓存

    计算机构造:CPU+内存+磁盘
    CPU要做数据计算必须先从内存或者硬盘读取到数据,然后放到寄存器才可以运算。计算机性能受限
    CPU会把经常需要读写的数据放到CPU缓存中,这样做高速运算的时候,就不需要每次从内存或者磁盘中进行数据读取,再进行运算,而是直接从缓存中获取数据进行运算。
    这样可以充分释放CPU的运算能力。CPU缓存越大,可存储的数据越多,处理的性能越高。

    web应用开发过程中的缓存

    image.png

    优缺点

    image.png

    缓存作用模型

    image.png

    优化商户缓存流程

    image.png
    代码实现

    @Service
    public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public Result queryShopById(Long id) {
            if (Objects.isNull(id) || id < 0) {
                return Result.fail("非法商户!");
            }
            //从redis中查询缓存信息
            String shopCacheKey = CACHE_SHOP_KEY + id;
            String shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
            if (StringUtils.isNotBlank(shopJson)) {
                Shop shop = JSONUtil.toBean(shopJson, Shop.class);
                return Result.ok(shop);
            }
            //未命中缓存查询数据库
            Shop shop = getById(id);
            if (Objects.isNull(shop)) {
                return Result.fail("商户不存在!");
            }
            //缓存商户信息
            stringRedisTemplate.opsForValue().set(shopCacheKey, JSONUtil.toJsonStr(shop));
            return Result.ok(shop);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    缓存更新策略

    缓存一致性问题
    image.png
    image.png

    业务场景

    • 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
    • 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存

    主动更新策略

    image.png
    image.png

    • 01
      • 维护成本高,需要手动编写代码实现
    • 02
      • 可能没现成的,需要单独维护
    • 03
      • 一致性差:还没异步去更新DB,其他线程去查询了数据库
      • 可靠性差:还没将数据更新到DB,Redis服务挂了,数据丢失了
    手动维护

    image.png

    先删除缓存再操作DB
    正常情况

    image.png

    异常情况

    数据不一致情况
    image.png

    先操作DB再删除缓存(使用)
    正常情况

    image.png

    异常情况

    出现的可能性相对较低,加超时时间作为兜底!!!
    出现的条件:

    • 两条线程并行执行
    • 线程1执行时,缓存刚好失效
    • 查询数据库后写缓存是微秒级别的
      • 这时刚好另一条线程来进更新了数据库并且删除了缓存,可能性很低

    image.png

    总结

    image.png
    image.png

    最佳实践方案

    image.png
    业务代码实现
    image.png

    设置超时时间

    image.png
    image.png

    缓存穿透

    缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求会全部打到数据库中。

    解决方案

    image.png

    缓存空对象

    image.png

    image.png

    布隆过滤器

    并不是100%准确,有风险
    image.png
    image.png

    业务代码

    image.png
    解决方案:
    image.png

    总结

    image.png

    缓存雪崩

    缓存雪崩是指同意时段大量的缓存key同时失效或者redis宕机,导致大量请求到达数数据库,带来巨大压力。
    image.png

    未命中

    image.png

    服务宕机

    image.png

    解决方案

    • 给不同的key的TTL添加随机值。(缓存预热过期都时间一样)
    • 利用Redis集群提高服务的可用性
    • 给缓存业务添加服务降级、限流(如:快速失败,拒绝服务等)
    • 给业务添加多级缓存

    缓存击穿(热点key)

    缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务复杂的key突然失效,无数的请求在瞬间给业务数据库带来巨大的冲击。
    image.png
    image.png

    image.png

    解决方案

    互斥锁

    image.png
    性能差,阻塞

    逻辑过期

    不设置过期时间,永不过期,做活动的时候才会去添加
    image.png

    VS

    image.png

    互斥锁牺牲了可用性,保证了一致性:CP
    逻辑过期牺牲了一致性,保证了可用性:AP

    互斥锁解决缓存击穿问题

    image.png

    setnx

    setnx只有第一个可以操作成功,其他的都会失败。
    可以设置有效期作为兜底
    有效期设置为业务执行时间的10-20倍
    image.png

    代码实现
    获取锁
        /**
         * 尝试获取锁
         *
         * @param key
         * @return
         */
        private boolean tryLock(String key) {
            //setnx
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
            //自动拆箱 防止NPE
            return BooleanUtil.isTrue(flag);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    释放锁
        /**
         * 释放锁
         *
         * @param key
         */
        private void unLock(String key) {
            stringRedisTemplate.delete(key);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    业务代码

    image.png

        /**
         * 互斥锁解决缓存缓存击穿问题
         *
         * @param id
         * @return
         */
        private Shop queryShopByMutex(Long id) {
            //从redis中查询缓存信息
            String shopCacheKey = CACHE_SHOP_KEY + id;
            String shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
            if (StringUtils.isNotBlank(shopJson)) {
                return getShopFromCache(shopJson);
            }
            Shop shop = null;
            String lockKey = "lock:shop:" + id;
            try {
                //获取互斥锁
                boolean isLock = tryLock(lockKey);
                if (!isLock) {
                    //获取锁失败,休眠 重试
                    TimeUnit.MILLISECONDS.sleep(50);
                    //一直重试 会有性能问题
                    return queryShopByMutex(id);
                }
                //获取锁成功,再次查询缓存是否存在,Double Check
                shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
                if (StringUtils.isNotBlank(shopJson)) {
                    return getShopFromCache(shopJson);
                }
                //未命中缓存查询数据库
                shop = getById(id);
                //模拟重建延时200ms
                TimeUnit.MILLISECONDS.sleep(200);
                if (Objects.isNull(shop)) {
                    //缓存空值 缓存2min
                    stringRedisTemplate.opsForValue().set(shopCacheKey, NULL_VAL, CACHE_NULL_TTL, TimeUnit.MINUTES);
                    return null;
                }
                //缓存商户信息,添加过期时间 30分钟
                stringRedisTemplate.opsForValue().set(shopCacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
            } catch (InterruptedException e) {
                throw new RuntimeException();
            } finally {
                //释放互斥锁
                unLock(lockKey);
            }
            return shop;
        }
    
        /**
         * 从缓存中获取shop信息
         *
         * @param shopJson
         * @return
         */
        private Shop getShopFromCache(String shopJson) {
            if (StringUtils.equals(NULL_VAL, shopJson)) {
                //空值
                return null;
            }
            return JSONUtil.toBean(shopJson, Shop.class);
        }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    模拟并发请求

    image.png
    线程组 QPS=200
    image.png

    逻辑过期解决缓存击穿问题

    业务流程图

    image.png

    缓存预热
        /**
         * 模拟缓存预热
         *
         * @param id
         * @param expireSeconds 过期时间
         */
        public void saveShopToRedis(Long id, long expireSeconds) {
            if (Objects.isNull(id)) {
                return;
            }
            Shop shop = getById(id);
            RedisData redisData = new RedisData();
            redisData.setData(shop);
            redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
            //未设置过期时间
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
        }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
        @Autowired
        private ShopServiceImpl shopService;
    
        /**
         * 单测:缓存预热
         */
        @Test
        public void saveShopToRedis() {
            shopService.saveShopToRedis(1L, 10L);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    逻辑过期时间
    image.png
    业务代码实现

        /**
         * 缓存重建线程池
         */
        private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    
    	/**
         * 查询商户信息
         * 逻辑过期解决缓存击穿问题
         *
         * @param id
         
         * @return
         */
        public Shop queryShopByLogicExpire(Long id) {
            if (Objects.isNull(id) || id < 0) {
                return null;
            }
            RedisData redisData = getRedisDataFromCache(id);
            JSONObject data = (JSONObject) redisData.getData();
            Shop shop = JSONUtil.toBean(data, Shop.class);
            //是否过期
            if (!cacheIsExpire(redisData)) {
                //未过期
                return shop;
            }
            //过期
            String lockKey = LOCK_SHOP_KEY + id;
            //获取互斥锁
            if (!tryLock(lockKey)) {
                //获取互斥锁失败,返回已经过期的商户信息
                return shop;
            }
            //获取锁成功
            redisData = getRedisDataFromCache(id);
            //Double Check 再次查看缓存是否过期
            if (!cacheIsExpire(redisData)) {
                //没过期,无需重建缓存
                return JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
            }
            //开启独立线程进行缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    this.saveShopToRedis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
            return shop;
        }
    
        /**
         * 缓存是否过期
         *
         * @param redisData
         * @return
         */
        private boolean cacheIsExpire(RedisData redisData) {
            //是否过期 Double check
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
                //未过期
                return false;
            }
            return true;
        }
    
        private RedisData getRedisDataFromCache(Long id) {
            String shopCacheKey = CACHE_SHOP_KEY + id;
            String shopJson = stringRedisTemplate.opsForValue().get(shopCacheKey);
            if (StringUtils.isBlank(shopJson)) {
                //不存在 直接返回
                return null;
            }
            return JSONUtil.toBean(shopJson, RedisData.class);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    压测

    jmeter压测100QPS
    查看运行结果
    前面会返回旧数据
    image.png
    后面会返回新数据
    image.png
    数据会有短暂不一致的问题,但是保证了可用性。
    image.png

    封装缓存工具

    image.png
    代码

    import cn.hutool.core.util.BooleanUtil;
    import cn.hutool.json.JSONObject;
    import cn.hutool.json.JSONUtil;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.lang3.StringUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    
    import java.time.LocalDateTime;
    import java.util.Objects;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.TimeUnit;
    import java.util.function.Function;
    
    import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
    
    /**
     * @author zhangzengxiu
     * @date 2023/10/7
     */
    @Slf4j
    @Component
    public class CacheClient {
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        /**
         * 缓存不存在的数据
         */
        public static final String NULL_VAL = "-1";
    
        /**
         * 锁key前缀
         */
        private static final String LOCK_KEY = "lock:";
        /**
         * 缓存重建线程池
         */
        private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    
        /**
         * 设置缓存
         *
         * @param key
         * @param value
         * @param expireTime
         * @param timeUnit
         */
        public void set(String key, Object value, long expireTime, TimeUnit timeUnit) {
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), expireTime, timeUnit);
        }
    
        /**
         * 逻辑过期时间
         *
         * @param key
         * @param value
         * @param expireTime
         * @param timeUnit
         */
        public void setLogicExpire(String key, Object value, long expireTime, TimeUnit timeUnit) {
            RedisData redisData = new RedisData();
            redisData.setData(value);
            redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
        }
    
        /**
         * 解决缓存穿透问题
         *
         * @param id
         * @param keyPrefix
         * @param type
         * @param function
         * @param expireTime
         * @param timeUnit
         * @param 
         * @param 
         * @return
         */
        public <R, ID> R queryWithPassThrough(ID id, String keyPrefix, Class<R> type, Function<ID, R> function, long expireTime, TimeUnit timeUnit) {
            if (Objects.isNull(id)) {
                return null;
            }
            //从redis中查询缓存信息
            String cacheKey = keyPrefix + id;
            String json = stringRedisTemplate.opsForValue().get(cacheKey);
            if (StringUtils.isNotBlank(json)) {
                if (StringUtils.equals(NULL_VAL, json)) {
                    //空值
                    return null;
                }
                return JSONUtil.toBean(json, type);
            }
            //未命中缓存查询数据库
            R res = function.apply(id);
            if (Objects.isNull(res)) {
                //缓存空值 缓存2min
                this.set(cacheKey, NULL_VAL, 2L, TimeUnit.MINUTES);
                return null;
            }
            //缓存添加过期时间
            this.set(cacheKey, JSONUtil.toJsonStr(res), expireTime, timeUnit);
            return res;
        }
    
        /**
         * 逻辑过期解决缓存击穿问题
         *
         * @param id
         * @return
         */
        public <R, ID> R queryByLogicExpire(String keyPrefix, ID id, Class<R> type, long expireTime, TimeUnit unit, Function<ID, R> function) {
            if (Objects.isNull(id)) {
                return null;
            }
            String cacheKey = keyPrefix + id;
            RedisData redisData = getRedisDataFromCache(cacheKey);
            JSONObject data = (JSONObject) redisData.getData();
            R res = JSONUtil.toBean(data, type);
            //是否过期
            if (!cacheIsExpire(redisData)) {
                //未过期
                return res;
            }
            //过期
            String lockKey = LOCK_KEY + id;
            //获取互斥锁
            if (!tryLock(lockKey)) {
                //获取互斥锁失败,返回已经过期的信息
                return res;
            }
            //获取锁成功
            redisData = getRedisDataFromCache(cacheKey);
            //Double Check 再次查看缓存是否过期
            if (!cacheIsExpire(redisData)) {
                //没过期,无需重建缓存
                return JSONUtil.toBean((JSONObject) redisData.getData(), type);
            }
            //开启独立线程进行缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //查询DB
                    R r = function.apply(id);
                    //写入redis
                    this.setLogicExpire(lockKey, r, expireTime, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
            return res;
        }
    
        /**
         * 缓存是否过期
         *
         * @param redisData
         * @return
         */
        private boolean cacheIsExpire(RedisData redisData) {
            //是否过期 Double check
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
                //未过期
                return false;
            }
            return true;
        }
    
        /**
         * 从缓存中获取RedisData
         *
         * @param cacheKey
         * @return
         */
        private RedisData getRedisDataFromCache(String cacheKey) {
            String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
            if (StringUtils.isBlank(shopJson)) {
                //不存在 直接返回
                return null;
            }
            return JSONUtil.toBean(shopJson, RedisData.class);
        }
    
        /**
         * 尝试获取锁
         *
         * @param key
         * @return
         */
        private boolean tryLock(String key) {
            //setnx
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
            //自动拆箱 防止NPE
            return BooleanUtil.isTrue(flag);
        }
    
        /**
         * 释放锁
         *
         * @param key
         */
        private void unLock(String key) {
            stringRedisTemplate.delete(key);
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212

    使用方式

        @Override
        public Result queryShopById(Long id) {
            //获取店铺信息 缓存穿透
            //Shop shop = queryShopByPassThrough(id);
            //使用工具类实现
            Shop shop = cacheClient.queryWithPassThrough(id, CACHE_SHOP_KEY, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
            //互斥锁 缓存击穿
            //Shop shop = queryShopByMutex(id);
            //逻辑过期时间 解决缓存击穿问题
            //Shop shop = queryShopByLogicExpire(id);
            Shop shop = cacheClient.queryByLogicExpire(CACHE_SHOP_KEY, id, Shop.class, CACHE_SHOP_TTL, TimeUnit.MINUTES, this::getById);
            if (Objects.isNull(shop)) {
                return Result.fail("商户不存在!");
            }
            return Result.ok(shop);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
  • 相关阅读:
    Netty-bytebuf
    力扣--76. 最小覆盖子串
    COCO格式json切分为labelme可识别json
    TCP为什么需要3次握手?
    猿创征文|Java之static关键字的应用【工具类、代码块和单例】
    工业级wifi路由器 功能 接口参数
    GO 语言的函数??
    YOLOFastestv2 训练自己的数据集---辛酸仨小时
    LeetCode·每日一题·775.全局倒置与局部倒置·模拟
    【Java Web】会话管理
  • 原文地址:https://blog.csdn.net/zhangzengxiu/article/details/133654831