Home
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 设计模式
  • JavaSE
  • JVM
  • JUC
  • Netty
  • CPP
  • QT
  • UE
  • Go
  • Gin
  • Gorm
  • HTML
  • CSS
  • JavaScript
  • vue2
  • TypeScript
  • vue3
  • react
  • Spring
  • SpringMVC
  • Mybatis
  • SpringBoot
  • SpringSecurity
  • SpringCloud
  • Mysql
  • Redis
  • 消息中间件
  • RPC
  • 分布式锁
  • 分布式事务
  • 个人博客
  • 弹幕视频平台
  • API网关
  • 售票系统
  • 消息推送平台
  • SaaS短链接系统
  • Linux
  • Docker
  • Git
GitHub (opens new window)
Home
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 设计模式
  • JavaSE
  • JVM
  • JUC
  • Netty
  • CPP
  • QT
  • UE
  • Go
  • Gin
  • Gorm
  • HTML
  • CSS
  • JavaScript
  • vue2
  • TypeScript
  • vue3
  • react
  • Spring
  • SpringMVC
  • Mybatis
  • SpringBoot
  • SpringSecurity
  • SpringCloud
  • Mysql
  • Redis
  • 消息中间件
  • RPC
  • 分布式锁
  • 分布式事务
  • 个人博客
  • 弹幕视频平台
  • API网关
  • 售票系统
  • 消息推送平台
  • SaaS短链接系统
  • Linux
  • Docker
  • Git
GitHub (opens new window)
  • 基础篇
  • 数据类型篇
  • 持久化篇
  • 网络模型篇
  • 缓存篇
  • 高可用篇
  • 实战篇
    • 基于Redis实现登录功能
      • Session登录
      • Redis登录
    • 基于Redis实现缓存
      • 缓存商户数据
      • 缓存穿透
      • 缓存雪崩
      • 缓存击穿
    • 优惠券秒杀
  • 拾遗篇
  • Redisson
  • Redis
Nreal
2023-11-15
目录

实战篇

项目地址:https://github.com/hedsay/DianPing

# 基于Redis实现登录功能

本章重点:

  1. 判断密码格式工具类;
  2. MVC拦截器使用方式;
  3. 基于Session,Redis登录使用方式;

# Session登录

每台服务器只能保存一份 session 数据,不能服务器之间共享,如果采用其它方式共享,数据拷贝也会出现延迟,徒增服务器压力;

  1. 校验登录:客户端输入验证码与服务端session中的验证码校验;校验成功根据手机号查询用户,若不存在则创建并保存到数据库;无论是否存在将用户信息保存到session;

    先将验证码保存在session中;

    session有关API:setAttribute("key"),getAttribute("key");

    public Result login(LoginFormDTO loginForm, HttpSession session) {
        String phone = loginForm.getPhone();
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号码格式错误!");
        }
        Object cacheCode = session.getAttribute("code");
        String code = loginForm.getCode();
        if(cacheCode==null || !cacheCode.toString().equals(code)){
            return Result.fail("验证码错误!");
        }
        //select * from tb_user where phone = ?
        User user = query().eq("phone", phone).one();
        if(user == null){
            user = createUserWithPhone(phone);
        }
        //用户脱敏
        session.setAttribute("user", 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
  2. 拦截器与保存用户信息:客户端请求时会从cookie中携带sessionId,服务端根据sessionId从session中拿到用户信息;在Controllers前加一层拦截器,若没有session信息,进行拦截,有则将用户信息保存到threadLocal中放行;

    1. 本地线程工具类:

      public class UserHolder {
          private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
          public static void saveUser(UserDTO user){
              tl.set(user);
          }
          public static UserDTO getUser(){
              return tl.get();
          }
          public static void removeUser(){
              tl.remove();
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
    2. 拦截器:

      public class LoginInterceptor implements HandlerInterceptor {
          @Override
          public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
              HttpSession session = request.getSession();
              Object user = session.getAttribute("user");
              if(user == null){
                  response.setStatus(401);
                  return false;
              }
              //保存到ThreadLocal
              UserHolder.saveUser((UserDTO) user);
              return true;
          }
      
          @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
    3. 配置拦截器:

      @Configuration
      public class MvcConfig implements WebMvcConfigurer {
          @Override
          public void addInterceptors(InterceptorRegistry registry) {
              // 登录拦截器
              registry.addInterceptor(new LoginInterceptor())
                      .excludePathPatterns(
                              "/shop/**",
                              "/voucher/**",
                              "/shop-type/**",
                              "/upload/**",
                              "/blog/hot",
                              "/user/code",
                              "/user/login"
                      ).order(1);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17

# Redis登录

key用随机唯一 UUID 代替

  1. 校验登录:采用Hash数据结构保存用户信息,用户信息的id为long类型,存入redis需要转换为string类型,详细见代码;

    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
    String code = loginForm.getCode();
    if(cacheCode==null || !cacheCode.toString().equals(code)){
        return Result.fail("验证码错误!");
    }
    //select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();
    if(user == null){
        user = createUserWithPhone(phone);
    }
    //用户信息保存到redis 1.随机token作令牌 2.User对象转为HashMap存储
    String token = UUID.randomUUID().toString();
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    //id是long类型,存redis需要转换为string,要自定义序列化
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
         CopyOptions.create()
                    .setIgnoreNullValue(true)
                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
    stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.SECONDS);
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
  2. 刷新Token:防止用户在登录期token过期,再重新登录影响用户体验;设置两个拦截器,第一个只负责判断user对象是否存在,第二个拦截器只负责刷新token;

    //第一个拦截器
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (UserHolder.getUser() == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }
    
    
    
    //第二个拦截器
    public class RefreshTokenInterceptor implements HandlerInterceptor {
    
        //这能被spring容器注入,是因为MvcConfig已经将其注入了
        private StringRedisTemplate stringRedisTemplate;
    
        public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            //获取请求头中的token
            String token = request.getHeader("authorization");
            if(StrUtil.isBlank(token)){
                return true;//只负责刷新,没有也放行
            }
            String key = LOGIN_USER_KEY + token;
            Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
            if(userMap.isEmpty()) return true;
            //将hash数据转为UserDTO
            UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
            UserHolder.saveUser(userDTO);//保存到ThreadLocal
            stringRedisTemplate.expire(key,LOGIN_USER_TTL, TimeUnit.MINUTES);//刷新token
            return true;
        }
    
        @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
  3. 拦截器配置:

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            // 登录拦截器
            registry.addInterceptor(new LoginInterceptor())
                    .excludePathPatterns(
                            "/shop/**",
                            "/voucher/**",
                            "/shop-type/**",
                            "/upload/**",
                            "/blog/hot",
                            "/user/code",
                            "/user/login"
                    ).order(1);
            // token刷新的拦截器
            registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                    .addPathPatterns("/**").order(0);
        }
        
    }
    
    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

# 基于Redis实现缓存

# 缓存商户数据

利用 hutool工具包将 JSON转换为 Bean对象

商户查询:查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis;

public Result queryShopById(Long id) {
    String key = "cache:shop:"+id;
    //从redis查询
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    if(StrUtil.isNotBlank(shopJson)){
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    //不存在,数据库查询
    Shop shop = getById(id);
    if(shop == null){
        return Result.fail("商户不存在!");
    }
    //存在,写入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 缓存穿透

请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库;

常见解决方案:

  1. 缓存空对象

    public Result queryShopById(Long id) {
        String key = "cache:shop:"+id;
        //从redis查询
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //判断val不为空字符串
        if(StrUtil.isNotBlank(shopJson)){
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //既非不为空字符串,又不是空对象,只能是空字符串
        if(shopJson != null){
            return Result.fail("商户不存在!");
        }
        //不存在,数据库查询
        Shop shop = getById(id);
        if(shop == null){
            //缓存穿透:判断命中的是否是空值,""并不是null返回true
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("商户不存在!");
        }
        //存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        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
  2. 布隆过滤

  3. 做好热点参数的限流

# 缓存雪崩

同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力;

常见方案:

  1. 给不同的Key的过期时间添加随机值;
  2. 利用Redis集群提高服务的可用性;
  3. 给缓存业务添加降级限流策略;
  4. 给业务添加多级缓存;

# 缓存击穿

热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击;

常见方案:

  1. 互斥锁

    互斥锁可以使用 redis中的 setnx,key不存在才能写,存在不能写;(也可以ReentrantLock)

    线程1查询缓存未命中,且获取锁成功,就去查询数据库重建缓存,释放锁;

    其它线程缓存未命中,获取锁失败重试,直至命中缓存;

    public Shop queryWithMutex(Long id) throws InterruptedException {
        String key = "cache:shop:"+id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(shopJson)){
            return JSONUtil.toBean(shopJson,Shop.class);
        }
        if(shopJson != null){
            return null;
        }
        //缓存重构
        //获取互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            //获取锁失败
            while(!isLock){
                Thread.sleep(50);
            }
            //锁成功
            shop = getById(id);
            //模拟重建的延时
            Thread.sleep(200);
            if(shop == null){
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
                return null;
            }
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally{
            unlock(lockKey);
        }
        return shop;
    }
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    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
  2. 逻辑过期

    对象中添加过期字段,通过判断该字段来续期;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    public Shop queryWithLogicalExpire(Long id){
        String key = CACHE_SHOP_KEY + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(json)) {
            return null;
        }
        //将json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);//Object转Shop
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            //未过期,直接返回店铺信息
            return shop;
        }
        //过期重建缓存,获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        if(isLock) {
            //获取锁成功后,再检查次缓存是否过期,有可能别的线程重建缓存了,没过期无需重建
            //过程省略...
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    saveShop2Redis(id,20L);//暂停定为20s
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }finally {
                    unlock(lockKey);
                }
            });
        }
        return shop;
    }
    public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
        Shop shop = getById(id);
        //模拟重建缓存的时间
        Thread.sleep(200);
        //封装逻辑过期时间
        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
    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

TODO:封装Redis工具类

# 优惠券秒杀

高可用篇
拾遗篇

← 高可用篇 拾遗篇→

Theme by Vdoing | Copyright © 2021-2024
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式