黑马Redis6实战篇

文章目录

1.概述

image-20230701192254202

说明:

​ 本项目仅在此展示服务层的功能

2.短信登录

笔记小结:

  1. Redis命令:

    • 在发送短信验证码功能中,使用了Redis的String命令集的set方法完成验证码保存

    • 在短信验证码登录、注册功能中,使用了Redis的Hash命令集的putAll方法完成了登录用户的信息保存

    • 在校验验证登录状态功能中,使用了Reids的Hash命令集的entries对登录用户信息的非空校验以及身份保存

  2. 功能实现疑难点:

    • 在Hutool工具中,进行Bean类型转换Map类型的BeanUtil、beanToMap方法,其中一个属性使用了CopyOptions.create().ignoreNullValue().setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())进行参数类型的转换

2.1常规Session实现登录

2.1.1概述

image-20230620190838544

说明:

​ 通过Session实现登录,分为发送如上步骤。发送短信验证码、短信验证码登录与注册、校验登录状态通过将验证码和用户保存在Session域中,实现会话的管理等操作

2.1.2基本用例

步骤一:发送验证码

  • 添加UserServiceImpl类的sendCode方法
/**
     * @param phone   手机号
     * @param session session域
     * @return Result风格结果
     */
@Override
public Result sendCode(String phone, HttpSession session) {
    
    
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
    
    
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }

    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4.保存验证码、手机号到session
    session.setAttribute("code", code);
    session.setAttribute("phone", phone);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{},", code);
    return Result.ok();
}

步骤二:实现登录

  • 添加UserServiceImpl类的login方法
/**
     * @param loginForm 封装登录用户的DTO
     * @param session   session域
     * @return eoken
     */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    
    

    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone) || ObjectUtil.notEqual(phone, session.getAttribute("phone").toString())) {
    
    
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号错误!");
    }
    // 3.校验验证码
    String code = loginForm.getCode();
    if (RegexUtils.isCodeInvalid(code) || ObjectUtil.notEqual(code, session.getAttribute("code").toString())) {
    
    
        // 4.如果不符合,返回错误信息
        return Result.fail("验证码错误!");
    }
    // 5.判断用户是否存在
    LambdaQueryWrapper<User> lambdaQuery = new LambdaQueryWrapper<>();
    lambdaQuery.eq(User::getPhone, phone);
    User user = userMapper.selectOne(lambdaQuery);
    // 6.用户不存在,创建用户,保存到数据库
    if (ObjectUtil.isNull(user)) {
    
    
        user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtil.randomString(5));
        userMapper.insert(user);
    }
    // 7.保存用户到session
    // 此处保存用户session信息时,使用Hutool工具的拷贝字节流的方式将属性值存入UserDTO类中,防止过多的用户信息发送给前端,造成安全问题
    session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
    return Result.ok();
}

补充:

​ DTO是什么意思,参考网址:(43条消息) Java深入了解DTO及如何使用DTO_dto是什么_visant的博客-CSDN博客

补充:

  • LoginFormDTO
@Data
public class LoginFormDTO {
     
     
    private String phone;
    private String code;
    private String password;
}

步骤三:校验登录状态

1.创建LoginInterceptor拦截器

/*
* 定义拦登录拦截器并实现逻辑,在拦截器中,记录用户身份信息
*/
public class LoginInterceptor implements HandlerInterceptor {
    
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    

        // 1.获取session
        HttpSession session = request.getSession();
        // 2.获取session中的用户
        UserDTO user = (UserDTO) session.getAttribute("user");
        // 3.判断用户是否存在
        boolean result = ObjectUtil.isNull(user);
        // 4.不存在,拦截
        if (result) {
    
    
            // 返回401状态码
            response.setStatus(401);
            return false;
        }
        // 5.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(user);
        // 6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    
    
        // 当拦截器完成之后,将内存中ThreadLocal(对线程内)里面保存的用户信息清除,释放内存空间,避免浪费
        UserHolder.removeUser();
    }
}

2.创建MvcConfig配置文件

/*
* 创建配置类,并注册登录拦截器
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    
    
        // 配置相应的放行逻辑
        registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/voucher/**",
                "/shop-type/**",
                "/upload/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        );
    }

    @Bean
    LoginInterceptor getLoginInterceptor() {
    
    
        return new LoginInterceptor();
    }
}

2.1.3总结

image-20230621091917804

说明:

​ 根据系统架构,当需要通过tomcat搭建集群,如此实现会出现session共享问题的出现。同一个tomcat与另一个tomcat之间,并不能共享session域

2.2Redis实现共享Session登录

2.2.1概述

image-20230621093235161

说明:

​ 保存验证码信息到Redis中,而不再是Session域中,便于解决多台Tomcat服务器访问Redis服务解决Session共享域问题

image-20230621092755862

说明:

​ 此处存入Redis的Token不建议直接使用手机号来作为Key,因为Token将来是会返回给前端,若用手机号会造成信息泄露的风险

2.2.2基本用例

说明:

  • 实现思路:

    ​ 将发送短信验证码时,将验证码存入Redis。校验验证码时,从Redis中取出并校验。登录成功后,将Token存入Redis,当校验用户身份时,从Redis取出并校验

步骤一:导入依赖

1.修改Pom.xml文件,添加如下依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
    <version>5.1.47</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.1</version>
</dependency>
<!--hutool-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

说明:

​ Hutool是一个小而全的Java工具类库,通过静态方法封装,降低相关API的学习成本,提高工作效率,使Java拥有函数式语言般的优雅,让Java语言也可以“甜甜的”。

步骤二:编写Pom.xml配置文件

server:
  port: 8081
spring:
  application:
    name: hmdp
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/db1?useSSL=false&serverTimezone=UTC
    username: root
    password: qweasdzxc
  redis:
    host: 10.13.164.55
    port: 6379
    password: qweasdzxc
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s
  jackson:
    default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
  type-aliases-package: com.hmdp.entity # 别名扫描包
logging:
  level:
    com.hmdp: debug

步骤三:封装Result风格结果集

1.在dto包下创建Result类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    
    
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
    
    
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
    
    
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
    
    
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
    
    
        return new Result(false, errorMsg, null, null);
    }
}

说明:

​ 封装Result结果集,将控制层结果进行统一处理与返回

步骤四:创建工具类

1.创建系统常量类

  • util包下创建系统常量SystemConstants
public class SystemConstants {
    
    
    public static final String IMAGE_UPLOAD_DIR = "D:\\lesson\\nginx-1.18.0\\html\\hmdp\\imgs\\";
    public static final String USER_NICK_NAME_PREFIX = "user_";
    public static final int DEFAULT_PAGE_SIZE = 5;
    public static final int MAX_PAGE_SIZE = 10;
}

2.创建校验工具类

2.1创建常用正则表达式

  • util包下创建格式校验RegexPatterns
public abstract class RegexPatterns {
    
    
    /**
     * 手机号正则
     */
    public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
    /**
     * 邮箱正则
     */
    public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    /**
     * 密码正则。4~32位的字母、数字、下划线
     */
    public static final String PASSWORD_REGEX = "^\\w{4,32}$";
    /**
     * 验证码正则, 6位数字或字母
     */
    public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";
}

2.2创建校验规则

  • util包下创建参数校验RegexUtils
public class RegexUtils {
    
    
    /**
     * 是否是无效手机格式
     *
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone) {
    
    
        return mismatch(phone, RegexPatterns.PHONE_REGEX);
    }

    /**
     * 是否是无效邮箱格式
     *
     * @param email 要校验的邮箱
     * @return true:符合,false:不符合
     */
    public static boolean isEmailInvalid(String email) {
    
    
        return mismatch(email, RegexPatterns.EMAIL_REGEX);
    }

    /**
     * 是否是无效验证码格式
     *
     * @param code 要校验的验证码
     * @return true:符合,false:不符合
     */
    public static boolean isCodeInvalid(String code) {
    
    
        return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
    }

    // 校验是否不符合正则格式
    private static boolean mismatch(String str, String regex) {
    
    
        if (StrUtil.isBlank(str)) {
    
    
            return true;
        }
        return !str.matches(regex);
    }
}

步骤五:实现发送短信验证码业务

1.在UserServiceImpl实现类中创建发送sendCode方法

@Autowired
StringRedisTemplate stringRedisTemplate; //利用StringRedisTemplate实现对Redis的操作
@Autowired
UserMapper userMapper;

@Override
public Result sendCode(String phone, HttpSession session) {
    
    
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
    
    
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }

    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4.保存验证码到redis
    stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{},", code);

    return Result.ok();
}

步骤六:实现登录业务

1.在UserServiceImpl类中创建发送login方法

@Autowired
UserMapper userMapper;

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    
    

    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
    
    
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.从Redis获取校验验证码
    String verCode = loginForm.getCode();
    String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    if (RegexUtils.isCodeInvalid(verCode) || ObjectUtil.notEqual(code, verCode)) {
    
    
        // 4.如果不符合,返回错误信息
        return Result.fail("验证码错误!");
    }
    // 5.判断用户是否存在
    LambdaQueryWrapper<User> lambdaQuery = new LambdaQueryWrapper<>();
    lambdaQuery.eq(User::getPhone, phone);
    User user = userMapper.selectOne(lambdaQuery);
    // 6.用户不存在,创建用户,保存到数据库
    if (ObjectUtil.isNull(user)) {
    
    
        user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtil.randomString(5));
        userMapper.insert(user);
    }
    // 7.保存用户到redis
    // 7.1使用Hutool的UUID方法随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2将User对象转换为HashMap,便于存储Redis
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    // 此处通过Hutool工具在将Bean数据转换为Map
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().ignoreNullValue()
                        .setFieldValueEditor(new BiFunction<String, Object, Object>() {
    
    
                            @Override
                            public Object apply(String fieldName, Object fieldValue) {
    
    
                                return fieldValue.toString(); // 因为UserDTO实体类的ID为Long类型,不能直接存入Redis,需要转换为其余类型
                            }
                        }));
    // 7.3利用Redis的hash方式,存储用户信息
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4设置有效期
    stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8.返回结果
    return Result.ok(token); //此处需要将token返回给前端,前端添加到请求头的authorization中
}

注意:

  • 设置Token的有效期,便于清理Redis的数据存储,也为了提高数据安全性

  • 需要将Bean变成Map的方法里的类型设置为String

    image-20230621113911283

说明:

​ 此处设置Token的有效期,也就是意味着每次登录的Token时间过期,就会强制让用户退出,而重新登录。因此,此时需要找地方设置刷新Token的时间。让用户在30分钟内处于活跃状态就延长Token的有效期时间即可。请继续查看步骤以下步骤

补充:

  • UserDTO
@Data
public class UserDTO {
     
     
    private Long id;
    private String nickName;
    private String icon;
}

步骤七:校验登录状态

image-20230621190241757

说明:

​ 添加新的拦截器,拦截所有路径,对所有访问所有路径下的资源进行Redis中的Session域刷新时间的处理

1.添加RefreshTokenInterceptor拦截器

public class RefreshTokenInterceptor implements HandlerInterceptor {
    
    
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    

        // 1.获取请求头中的Token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
    
    
            return true;
        }
        // 2.基于Token获取Redis中的用户
        String tokenKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
    
    
            // 4.不存在则放行,交给LoginInterceptor拦截器进行处理
            return true;
        }
        // 5.将查询到的Hash数据转换为UserDTO对象(便于存储到ThreadLocal中)-Hutool(BeanUtil.fillBeanWithMap)
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新Token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    
    
        // 当执行完Controller内的方法后,对线程内的用户信息进行删除
        UserHolder.removeUser();
    }
}

说明:

​ 此处需要刷新Token的有效期,让用户处于在线状态

2.修改LoginInterceptor拦截器

public class LoginInterceptor implements HandlerInterceptor {
    
    

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    
        // 1.判断是否需要拦截用户
        if (ObjectUtil.isNull(UserHolder.getUser())) {
    
    
            // 没有,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 放行
        return true;
    }
}

3.修改MvcConfig配置类

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    
    
        // 	设置多个拦截器的先后顺序,让拦截器的执行时机变得有序
        registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/voucher/**",
                "/shop-type/**",
                "/upload/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        ).order(1);
        registry.addInterceptor(getRefreshTokenInterceptor()).addPathPatterns("/**").order(0);
    }

    @Bean
    LoginInterceptor getLoginInterceptor() {
    
    
        return new LoginInterceptor();
    }

    @Bean
    RefreshTokenInterceptor getRefreshTokenInterceptor() {
    
    
        return new RefreshTokenInterceptor();
    }
}

2.2.3总结

​ 当通过Redis来记录并刷新用户的登录状态,可以便于集群中Tomcat对Redis的操作信息进行共享,从而解决了Session保存、Session校验问题

3.商户查询缓存

笔记小结:

  1. Redis命令:

    • 在添加缓存的功能中,使用了Redis的String命令集的setget方法完成商户数据的缓存与查询,提高系统响应速度

    • 在缓存更新策略功能中,实现超时剔除时使用了Redis的Stirng命令集的set方法使用了超时过期的属性。实现主动更新时,使用了Redis的delete命令移除Key

    • 在缓存穿透功能中,使用了Redis的String命令集的set方法,利用技巧保存空值,来解决缓存穿透

    • 在缓存击穿功能中,使用了Redis的String命令集的setIfAbsent方法,实现了互斥锁的加锁

  2. 功能实现疑难点:

    • 实现缓存更新策略功能时,使用超时剔除,实现较低的一致性需求。使用主动更新,进行先删除数据库再删除缓存的流程,实现较高的一致性需求
    • 实现缓存穿透功能中,实现互斥锁方案时,使用双重检验锁,进行二次检查,避免重复加载数据
    • 实现缓存穿透功能中,实现逻辑过期方案时,巧用RedisData类,在不修改原类的情况下进行商品类的成员属性额外添加。利用Executors.newFixedThreadPool方法进行多线程池子的创建,以及任务提交
    • 在Hutool工具中,使用StrUtil.isNotBlank方法,完成商铺信息的存在判断。使用RandomUtil.randomLong方法,添加随机值,解决缓存雪崩。使用JSONUtil.toBean方法,将对象反序列化。使用JSONUtil.toJsonStr方法,将对象序列化

3.1添加缓存

3.1.1概述

含义:

缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。

image-20230621194023079

作用:

image-20230621193443492

应用场景:

image-20230621193412204

补充:

​ 缓存可以给我们带来很多优点,但是更容易出现缓存击穿、缓存雪崩等问题

image-20230621194247699

说明:

  • 当未使用缓存进行数据缓冲时,客户端想要获取数据,会直接通过服务端查询数据库进行获得。这种方式,当客户端的请求达到高并发时,服务端的性能就会越来越下降。主要是因为磁盘的读写速度,因为读写磁盘次数很频繁,因此会影响服务端的性能
  • 当客户端想要获取数据时请求服务端,服务端首先通过Redis进行数据的获取,可大大减少数据库的读写次数,从而大大的提高了服务端的性能。主要是因为磁盘的读写速度,因为读写磁盘次数大大减少,因此会影响服务端的性能

3.1.2基本用例

说明:

image-20230621200400553

  • 实现思路:

    ​ 客户端发送请求,先从Redis中获取数据,若命中则返回,未命中则查询数据库。若数据库存在则写入Redis,并返回数据,不存在则直接返回报错

  • 添加ShopServiceImpl类中的queryById方法
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.不存在,根据Id查询数据库
    Shop shop = getById(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        // 5.不存在,返回错误
        return Result.fail("店铺不存在!");
    }
    // 6.存在,写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
    // 7.返回结果
    return Result.ok(shop);
}

3.2缓存更新策略

3.2.1概述

​ 对于缓存更新策略,根据一致性的需求分为以下三种方式

image-20230622102806076

说明:在生活场景中

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

image-20230622104827187

说明:

  • Cache Aside Pattern,通过编码的方式来对数据库进行同时更新缓存,需要认为的控制
  • Read/write Through Pattern,这样的服务代码开发成本太高
  • Write Behind Caching Pattern,将对数据的大量操作存储在缓存中,等到一定时间后再对数据库进行操作。假如缓存宕机,则会造成数据丢失
  • Cache Aside Pattern

image-20230622105417568

说明:

  • 先删缓存和后删缓存区别?

image-20230622105617516

  • 先删除缓存,再操作数据库出现异常记录较大。因为对操作系统数据库的读写速度大于对缓存的读写速度

image-20230622105806349

  • 先操作数据库,再删除缓存出现异常记录小。因为对缓存的读写速度小于对操作系统数据库的读写速度

3.2.2实现超时剔除

说明:

  • 实现思路:

    ​ 修改商品实现类中的业务逻辑,根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

  • 修改ShopServiceImpl类中的queryById方法
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.不存在,根据Id查询数据库
    Shop shop = getById(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        // 5.不存在,返回错误
        return Result.fail("店铺不存在!");
    }
    // 6.存在,写入Redis
    // 为从Redis查询商品设置了商品的过期时间,实现缓存更新策略中的超时剔除的功能
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7.返回结果
    return Result.ok(shop);
}

3.2.3实现主动更新

说明:

  • 实现思路:

    ​ 修改商品实现类中的业务逻辑,根据id修改店铺时,先修改数据库,再删除缓存

  • 添加ShopServiceImpl类中的update方法
@Override
@Transactional// 因此本项目为单体项目,因此添加事务注解,即可实现事务的同步
public Result update(Shop shop) {
    
    
    Long id = shop.getId();
    if (ObjectUtil.isNull(id)) {
    
    
        return Result.fail("商铺Id不能为空");
    }
    // 1.修改数据库
    updateById(shop);
    // 2.删除缓存
    String key = CACHE_SHOP_KEY + id;
    stringRedisTemplate.delete(key);
    return Result.ok();
}

3.3缓存穿透

3.3.1概述

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,给数据库带来巨大压力

说明:

​ 如果非法用户一直想后台发送垃圾请求,就会造成服务异常甚至崩溃

解决缓存穿透,有如下常用两种方式:

缓存空对象

image-20230622114646999

说明:

  • 优点:实现简单,维护方便
  • 缺点:额外的内存消耗、可能造成短期的不一致(若插入了真实的数据,但结果被Redis已经缓存,则会出现数据不一致)

布隆过滤

image-20230622114953415

说明:

  • 优点:内存占用较少,没有多余key
  • 缺点:实现复杂、存在误判可能(因为算出来的Hash值可能相同,就会误以为不存在的数据显示已存在)

补充:此外还有如下解决方式:

  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

3.3.2实现缓存NULL

image-20230622142149276

说明:

  • 实现思路:

    ​ 若在Redis中进行命中的未null值,则直接返回错误信息、当将查询不存在的ID在Redis中进行null值的缓存,当缓存完毕后返回错误信息。这样下一次再次查询时就会减少查询数据库的次数

  • 修改ShopServiceImpl类的queryById方法
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    // StrUtil.isNotBlank方法会忽略null、空、换行符
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.判断命中是否为Null值--此处用缓存Null值方案解决缓存穿透
    if (ObjectUtil.isNotNull(shopJson)) {
    
     // 不是null值,就为空
        return Result.fail("店铺不存在!");
    }

    // 5.未命中,根据Id查询数据库
    Shop shop = getById(id);
    // 5.1判断数据库中数据是否存在
    if (ObjectUtil.isNull(shop)) {
    
     
        // 5.2将未命中的数据进行空值写入Redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("店铺不存在!");
    }
    // 5.3命中,写入Redis--此处用超时剔除,解决缓存中的更新策略、用Key的TTL随机值,解决缓存雪崩
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 6.返回结果
    return Result.ok(shop);
}

3.5缓存雪崩

3.5.1概述

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

image-20230622144038605

说明:缓存雪崩常用的解决方案

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

3.5.2实现不同Key的TTL添加随机值方案

说明:

  • 实现思路:

    ​ 为Key的过期时间添加随机值

  • 修改ShopServiceImpl类的queryById方法
/**
     * @param id 商铺的ID
     * @return 商铺
     */
@Override
public Result queryById(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    
        // 3.存在,则直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 4.判断命中是否未Null值
    if (ObjectUtil.isNull(shopJson)) {
    
    
        return Result.fail("店铺不存在!");
    }
    // 5.不存在,根据Id查询数据库
    Shop shop = getById(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        // 6.将空值写入Redis
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 不存在,返回错误
        return Result.fail("店铺不存在!");
    }
    // 7.存在,写入Redis-为商品增加随机的TTL值 
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL + RandomUtil.randomLong(10), TimeUnit.MINUTES);
    // 8.返回结果
    return Result.ok(shop);
}

说明:

​ 此种方式解决缓存雪崩问题相对简单,随机增加TTL值即可。更高级的解决方式请继续往下看

3.6缓存击穿(重点)

3.6.1概述

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

image-20230622150941250

说明:

  • 在高并发的情况下,此时大量的线程未命中数据。且因为查询数据库业务的时间比较长,就会导致等待数据库业务查询时间过长。
  • 若此时有大量的请求未命中数据,就会很多线程去访问数据库,给数据库带来巨大的冲击

常见的解决方案,有如下两种:

image-20230622151457542

说明:

  • 此种方案的解决方式是通过互斥锁的方式进行解决。也就是当一个线程在访问数据库时进行加锁,访问完毕再进行释放锁,此时其余线程若想访问数据库就不得不等到锁释放完毕后,再进行访问。减少了数据库的压力
  • 通过互斥锁的方式来解决,实际上会影响服务器的性能。因为服务一直处于等待状态,所以获取数据缓慢

image-20230622152013120

说明:

  • 此种方案的解决方式是通过采用逻辑过期的方式进行解决。也就是当一个线程访问Redis中的数据,给Redis加上一个逻辑过期的字段。若有线程发现该数据逻辑上已过期,则开辟新的线程拿到锁去获取数据。旧的线程则返回旧的数据

image-20230622152905992

说明:

  • 互斥锁与逻辑过期解决方案各有优缺点

3.6.2实现互斥锁方案

image-20230622154314649

说明:

  • 实现思路:

    ​ 通过synchronized或者lock自带的锁不能够满足我们实现的业务逻辑,当线程拿不到锁时就等待一定的时间。我们需要通过自定义互斥锁的方式来实现自定义锁的逻辑。

步骤一:添加锁

  • 添加ShopServiceImpl中的tryLock方法
/**
     * @param key Redis中的键
     * @return 加锁是否成功
     */
private Boolean tryLock(String key) {
    
    
    // 设置锁的过期时间,防止死锁
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "l", LOCK_SHOP_TTL, TimeUnit.MINUTES);
    /*
        注意:此处通过setIfAbsent方法,返回的结果的类型为boolean类型而不是Boolean类型。
              Boolean是boolean的包装类,因此JDK17会进行拆箱。
              拆箱可能会出现空指针异常,因此这里借用Hutool工具进行判别
        * */
    return BooleanUtil.isTrue(result);
}

步骤二:释放锁

  • 添加ShopServiceImpl中的unLock方法
/**
     * @param key Redis中的键
     */
private void unLock(String key) {
    
    
    stringRedisTemplate.delete(key);
}

步骤三:添加双重检查锁机制

  • 修改ShopServiceImpl中的queryById方法
@Override
public Result queryById(Long id) {
    
    
    // 互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);
    if (ObjectUtil.isNull(shop)) {
    
    
        return Result.fail("店铺不存在!");
    }
    // 7.返回结果
    return Result.ok(shop);
}


/**
     * 互斥锁解决缓存击穿
     *
     * @param id 店铺的Id信息
     * @return 店铺信息
     */
public Shop queryWithMutex(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    
    // StrUtil.isNotBlank方法会忽略null、空、换行符
        // 3.存在,则直接返回
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    // 判断命中是否为Null值--此处用缓存Null值方案解决缓存穿透
    if (ObjectUtil.isNotNull(shopJson)) {
    
     // 不是null值,就为空
        return null;
    }

    // 4.实现缓存重建
    // 4.1获取互斥锁
    String lockKey = "lock:shop" + id;
    Shop shop = null;
    try {
    
    
        Boolean isLock = tryLock(lockKey);
        // 4.2判断是否获取成功
        if (!isLock) {
    
    
            // 4.3失败,则休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }
        Thread.sleep(200);
        // 4.5成功,再次检测Redis缓存是否存在
        String shopJsons = stringRedisTemplate.opsForValue().get(key); //此处实现了双重检验锁
        if (StrUtil.isNotBlank(shopJsons)) {
    
    
            return JSONUtil.toBean(shopJsons, Shop.class);
        }
        // 4.6根据Id查询数据库
        shop = getById(id);
        if (ObjectUtil.isNull(shop)) {
    
    
            // 将空值写入Redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 5.不存在,返回错误
            return null;
        }
        // 6.存在,写入Redis--此处用超时剔除,解决缓存中的更新策略、用Key的TTL随机值,解决缓存雪崩
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL + RandomUtil.randomLong(10), TimeUnit.MINUTES);
    } catch (InterruptedException e) {
    
    
        throw new RuntimeException(e);
    } finally {
    
    
        // 7.释放互斥锁
        unLock(lockKey);
    }
    // 8.返回结果
    return shop;
}

说明:在Redis中,为什么进行双重检查锁的时候,会进行第二次查看缓存的操作?

  • 在使用双重检查锁(Double-Checked Locking)来实现对Redis缓存的访问时,第二次查看缓存的操作是为了确保在获取锁后,其他线程没有在此期间已经更新了缓存。
  • 双重检查锁是一种常用的多线程并发控制技术,它可以在保证线程安全的前提下减少锁的使用次数,提高性能。在使用双重检查锁时,通常会先进行一次非同步的判断,如果缓存中存在需要的数据,则直接返回结果,避免获取锁的开销。但是,由于多线程的并发执行,可能存在以下情况:
    1. 线程A首先检查缓存,发现缓存为空,于是获取锁并开始加载数据到缓存。
    2. 此时,线程B也执行了第一次检查,发现缓存为空,于是也尝试获取锁。
    3. 线程B在获取锁之前,线程A已经完成了数据加载,并释放了锁。
    4. 线程B获取到了锁,但不知道线程A已经加载了数据到缓存,于是继续进行加载数据的操作。
  • 为了避免线程B重复加载数据,第二次查看缓存的操作是必要的。在第二次查看缓存时,线程B再次检查缓存,如果发现缓存不为空,则说明在获取锁的过程中,其他线程已经加载了数据到缓存,此时线程B可以直接使用缓存中的数据,避免重复加载数据。

3.6.3实现逻辑过期方式方案

image-20230623070800550

说明:

  • 实现思路:
    • 核心点是,旧的线程发现数据已过期依旧会返回旧数据,但与此同时会开启新的线程去更新数据

步骤一:逻辑过期时间

  • 创建RedisData
/**
 * 用于封装Shop类,在Shop类现有的成员属性上添加新的成员属性(expireTime)
 */
@Data
public class RedisData<T> {
    
    
    private LocalDateTime expireTime;
    private T data; // 此数据类型定义为泛型,便于封装其余需要实现逻辑过期的类
}

说明:

  • 为Shop类添加逻辑过期时间

步骤二:热点商品保存

  • 添加ShopServiceImplshopSave2Redis方法
/**
     * 保存热点商品到Redis
     *
     * @param id            商铺Id
     * @param expireSeconds 过期时间
     */
public void shopSave2Redis(Long id, Long expireSeconds) {
    
    
    // 1.查询商品
    Shop shop = getById(id);
    // 2.封装逻辑过期时间
    RedisData<Shop> redisData = new RedisData<>();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 3.添加缓存
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

说明:

​ 添加热点商品保存方法,将商品保存至Redis

补充:

  • 在任一测试类中,通过模拟商品管理后台,进行热点商品的添加
@Autowired
ShopServiceImpl shopService;

/**
     * 通过模拟商品管理后台,添加热点商品
     */
@Test
public void testSave() {
     
     
    shopService.shopSave2Redis(1L, 10L);
}

步骤三:添加逻辑过期

1.修改ShopServiceImpl类中的queryById方法

@Override
    public Result queryById(Long id) {
    
    
        // 缓存Null值解决缓存穿透
        // Shop shop = queryWithPassThrough(id);
        // 互斥锁解决缓存击穿
        // Shop shop = queryWithMutex(id);
        // 逻辑过期方式解决缓存击穿
        Shop shop = queryWithLogicExpire(id);
        if (ObjectUtil.isNull(shop)) {
    
    
            return Result.fail("店铺不存在!");
        }
        // 7.返回结果
        return Result.ok(shop);
    }

2.创建线程池

/**
     * 创建线程池
     */
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

说明:

  • 创建线程池,让额外的线程能够查询并更新数据

3.实现逻辑过期方式,在ShopServiceImpl类中添加queryWithLogicExpire方法

/**
     * 逻辑过期方式解决缓存击穿
     *
     * @param id 店铺的Id信息
     * @return 店铺信息
     */
public Shop queryWithLogicExpire(Long id) {
    
    
    // 1.从Redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
    // 2.判断是否存在
    if (StrUtil.isBlank(shopJson)) {
    
    
        // 3.未命中,直接返回空
        return null;
    }
    // 4.命中,需要先把Json序列化为对象
    RedisData<Shop> redisData = JSONUtil.toBean(shopJson, new TypeReference<RedisData<Shop>>() {
    
    
    }.getType(), false);
    Shop shop = redisData.getData();
    // 5.检查缓存过期时间
    if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
        // 5.1未过期,直接返回旧数据
        return shop;
    }
    // 5.2已过期,缓存重建
    // 6.缓存重建
    // 6.1获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Boolean isLock = tryLock(lockKey);
    if (isLock) {
    
    
        // 6.2获取互斥锁成功,再次检查缓存过期时间
        String result = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(result)) {
    
    
            RedisData<Shop> redisData2 = JSONUtil.toBean(result, new TypeReference<RedisData<Shop>>() {
    
    
            }.getType(), false);
            Shop shop2 = redisData2.getData();
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
                return shop2;
            }
        }
        // 6.3开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
    
    
            try {
    
    
                // 6.3.1重建锁
                this.shopSave2Redis(1L, 20L);// 这里便于测试设置时长为20秒。实际情况建议30分钟查一次
            } catch (Exception e) {
    
    
                throw new RuntimeException(e);
            } finally {
    
    
                // 6.3.2释放锁
                unLock(lockKey);
            }
        });
    }
    // 6.4获取互斥锁失败,直接返回过期数据
    return shop;
}

3.7缓存工具封装

说明:

​ 此工具类借助Hutool工具、泛型等进行编写。此工具类经过调式、验证,准确无误

  • 工具类使用示例
// 缓存Null值解决缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class,this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期方式解决缓存击穿
Shop shop = cacheClient.queryWithLogicExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

说明:

  • 使用工具类,需要传入存入Redis中的Key以及所需要查询数据库返回值的类型
  • 创建工具CacheClient类
@Slf4j
@Component
@AllArgsConstructor
public class CacheClient {
    
    
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    /**
     * 创建线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 加锁
     *
     * @param key Redis中的键
     * @return 加锁是否成功
     */
    private Boolean tryLock(String key) {
    
    
        // 设置锁的过期时间,防止死锁
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "l", LOCK_SHOP_TTL, TimeUnit.MINUTES);
        /*
        注意:此处通过setIfAbsent方法,返回的结果的类型为boolean类型而不是Boolean类型。
              Boolean是boolean的包装类,因此JDK17会进行拆箱。
              拆箱可能会出现空指针异常,因此这里借用Hutool工具进行判别
        * */
        return BooleanUtil.isTrue(result);
    }

    /**
     * 解锁
     *
     * @param key Redis中的键
     */
    private void unLock(String key) {
    
    
        stringRedisTemplate.delete(key);
    }

    /**
     * 设置存储在Redis中的键,并指定Redis中的过期时间
     *
     * @param key   键
     * @param value 值
     * @param time  时间
     * @param unit  时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
    
    
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 设置存储在Redis中的键,并指定对象的逻辑过期时间
     *
     * @param key   键
     * @param value 值
     * @param time  过期时间
     * @param unit  时间单位
     */
    public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit) {
    
    
        RedisData<Object> redisData = new RedisData<>();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); // 此处设置RedisData对象的值
    }

    /**
     * 缓存Null值解决缓存穿透
     *
     * @param keyPrefix  键的前缀
     * @param id         存储键的前缀以及查询数据库的ID
     * @param type       存储数据的类型
     * @param dbFallback 获取数据库数据的逻辑
     * @param time       过期时间
     * @param unit       时间单位
     * @param <R>        返回值类型
     * @param <ID>       id类型
     * @return 存储数据类的对象
     */
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type,
                                          Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    
    
        // 1.从Redis查询缓存数据
        String key = keyPrefix + id;
        String strJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
        // 2.判断数据是否存在
        if (StrUtil.isNotBlank(strJson)) {
    
    // StrUtil.isNotBlank方法会忽略null、空、换行符
            // 3.存在,则直接返回
            return JSONUtil.toBean(strJson, type);
        }
        // 4.不存在,则进一步判断
        // 4.1判断命中是否为Null值--此处实现了缓存Null值方案(不是null值,就为空),缓解了缓存穿透问题的影响
        if (ObjectUtil.isNotNull(strJson)) {
    
    
            return null;
        }
        // 4.2查询数据库,获得返回值数据
        R r = dbFallback.apply(id);
        // 5.判断数据是否存在
        if (ObjectUtil.isNull(r)) {
    
    
            // 5.1不存在
            // 将空值写入Redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回空
            return null;
        }
        // 5.2存在
        // 写入Redis--此处实现了超时剔除功能,缓解了缓存中的更新策略的影响、实现了Key的TTL随机值,缓解了缓存雪崩问题的影响
        this.set(key, JSONUtil.toJsonStr(r), time + RandomUtil.randomLong(5), unit);
        // 6.返回结果
        return r;
    }

    /**
     * 逻辑过期方式解决缓存击穿
     *
     * @param keyPrefix  键的前缀
     * @param id         存储键的前缀以及查询数据库的ID
     * @param type       存储数据的类型
     * @param dbFallback 获取数据库数据的逻辑
     * @param time       过期时间
     * @param unit       时间单位
     * @param <R>        返回值类型
     * @param <ID>       id类型
     * @return 存储数据类的对象
     */

    public <R, ID> R queryWithLogicExpire(String keyPrefix, ID id, Class<R> type,
                                          Function<ID, R> dbFallback, Long time, TimeUnit unit) {
    
    
        // 1.从Redis查询商铺缓存
        String key = keyPrefix + id;
        String jsonStr = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
        // 2.判断数据是否命中
        if (StrUtil.isBlank(jsonStr)) {
    
    
            // 2.1未命中,直接返回空
            return null;
        }
        // 2.2.命中,需要先把Json序列化为对象
        RedisData<R> redisData = JSONUtil.toBean(jsonStr, new TypeReference<RedisData<R>>() {
    
    
        }.getType(), false);

        R r = redisData.getData(); // 注意,此时因实用Hutool工具进行类型转换,返回的R类型为JSONObject
        R bean = JSONUtil.toBean((JSONObject) r, type);
        // 3.检查缓存过期时间
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
            // 3.1未过期,直接返回旧数据
            return bean;
        }
        // 3.2已过期,缓存重建
        // 4.缓存重建
        // 4.1获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        Boolean isLock = tryLock(lockKey);
        if (isLock) {
    
    
            // 4.2获取互斥锁成功,再次检查缓存过期时间
            String jsonStr2 = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(jsonStr2)) {
    
    
                RedisData<R> redisData2 = JSONUtil.toBean(jsonStr2, new TypeReference<RedisData<R>>() {
    
    
                }.getType(), false);
                R r2 = redisData2.getData();
                if (redisData2.getExpireTime().isAfter(LocalDateTime.now())) {
    
    
                    return JSONUtil.toBean((JSONObject) r2, type);
                }
            }
            // 6.3开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
    
    
                try {
    
    
                    // 6.3.1重建锁
                    // 查询商品
                    R r3 = dbFallback.apply(id); // 此处可用debug进行调试 log.debug("我成功执行");
                    // 添加缓存
                    this.setWithLogicExpire(key, r3, time, unit);

                } catch (Exception e) {
    
    
                    throw new RuntimeException(e);
                } finally {
    
    
                    // 6.3.2释放锁
                    unLock(lockKey);
                }
            });
        }
        // 6.4获取互斥锁失败,直接返回过期数据
        return bean;
    }
}

4.优惠券秒杀

笔记小结:

  1. Redis命令:

    • 在实现全局唯一ID的功能中,使用了Redis中的String命令,通过increment方法,实现了生成ID的个数统计
    • 在实现分布式锁的功能中,使用了Redis中的String命令,通过setIfAbsent方法,实现了分布式锁的获取
    • 在实现Redis优化秒杀的功能中,使用了Redis中的String命令,通过set方法,实现了热卖商品的添加
  2. 功能实现疑难点:

    • 实现缓存更新策略功能时,使用超时剔除,实现较低的一致性需求。使用主动更新,进行先删除数据库再删除缓存的流程,实现较高的一致性需求
    • 在解决库存超卖问题时,利用MyBatis-Plus工具中的eq方法,巧妙的实现了CAS方式来解决
    • 在实现一人一单功能时,巧妙利用IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();API获取了Spring框架代理的对象。以及synchronized锁的巧妙利用。利用了toString方法的intern()方法确保了加锁对象的唯一
    • 在实现分布式锁的功能中,巧妙利用Thread.currentThread().getId()API获取当前线程的ID作为获取锁的键。修改释放锁的逻辑,巧妙进行释放锁前判断释放的锁是否为自己的锁,防止锁的误删
    • 在实现Redis优化秒杀的功能中,编写了Lua脚本保证操作原子性完成优惠券的下单功能。使用stringRedisTemplate.execute方法执行Lua脚本。以及阻塞队列的使用,异步处理订单的功能。详细请查看本小节
    • 在使用Redssion工具时,对可重入锁原理、可重试原理、超时续约原理、主从一致问题的原理性的理解与运用
    • 在MyBatis-Plus工具中,使用了update()方法,以及联合使用.setSql方法进行SQL语句的补充执行

4.1概述

​ 优惠券秒杀是一种促销活动方式,通过限时限量地提供优惠券,吸引用户参与抢购,从而达到营销和销售的目的。下面是一种简单的优惠券秒杀的实现思路:

  1. 准备优惠券:创建一批优惠券,并设置其数量和有效期限。
  2. 展示活动信息:在前端页面展示秒杀活动的信息,包括优惠券的折扣、原价、秒杀价、活动开始时间和结束时间等。
  3. 用户参与秒杀:用户在活动开始时间前进入秒杀页面,等待秒杀开始。秒杀开始后,用户可以点击秒杀按钮进行抢购。
  4. 验证库存:在用户点击秒杀按钮时,先验证优惠券的库存是否足够。如果库存不足,提示秒杀结束或者已抢光。
  5. 下单处理:如果库存足够,生成订单并扣减优惠券的库存。可以使用数据库事务来确保下单和库存扣减的一致性。
  6. 订单支付:用户支付订单金额,完成交易流程。
  7. 完成秒杀:秒杀活动结束后,统计活动数据,如参与人数、成功下单数量、优惠券使用情况等。

​ 在实际开发中,还需要考虑并发访问和高并发场景下的性能优化,例如限制用户的秒杀频率、使用分布式缓存和消息队列等技术手段来提高系统的并发处理能力。

​ 需要注意的是,优惠券秒杀是一种特殊的促销活动,需要综合考虑业务需求、系统设计和性能调优等方面因素,以确保系统的稳定性和用户体验。

4.2全局唯一ID

4.2.1概述

每个店铺都可以发布优惠券:

image-20230623165251479

说明:

​ 当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题。例如:1.id的规律性太明显、2.受单表数据量的限制

​ 全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性

image-20230623165350978

说明:

  • 唯一性,全局唯一,不会出现不同
  • 高性能,能在很短的时间内生成所需要的ID
  • 高可用,能够方便的实现主从复制等高级操作
  • 递增性,能够符合一定的规律。安全性,不会被用户轻易猜到

4.2.2基本用例

image-20230623165721873

说明:ID的组成部分

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年(31 位表示的时间戳可以表示的最大值是 2^31 - 1约等于69年)
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

注意:

​ 生成全局唯一ID需要满足五个特性,确保系统性能

步骤一:自定义ID生成工具

  • 创建工具RedisIdWorker
@Component
public class RedisIdWorker {
    
    
    @Resource
    StringRedisTemplate stringRedisTemplate;

    private final static long BEGIN_TIMESTAMP = 1076630400L; //秒级时间戳有10位,指定日期

    public long nextId(String keyPrefix) {
    
    
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowTimeStamp = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowTimeStamp - BEGIN_TIMESTAMP;
        // 2.生成序列号
        // 2.1获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yy:MM:dd"));
        // 2.2自增长
        long count = stringRedisTemplate.opsForValue().increment("inc:" + keyPrefix + date); // 若所操作的键不存在,使用Increment可以自动的创建该键
        // 3.拼接并返回
        return timeStamp << 32 | count;
    }
}

补充:

​ 在 Redis 中,自增操作使用的数据类型是有符号的 64 位整数(signed 64-bit integer),也就是 int64 类型。因此,自增操作的上限是 9223372036854775807(2^63 - 1)。因此建议不同的Key使用不同的键

补充:

  • 当使用了<<或者是|运算符时,Java会自动将二进制转换为十进制,例如1076630400L
01000000 00101100 00010011 10000000
  • 当二进制数向左移动32位时,会向右侧补0
01000000 00101100 00010011 10000000 00000000 00000000 00000000 00000000

步骤二:测试

  • 新建Test
private static final ExecutorService es = Executors.newFixedThreadPool(500);

@Test
public void testSize() throws InterruptedException {
    
    
    CountDownLatch latch = new CountDownLatch(300); // 创建一个计数器,初始值为300

    Runnable task = () -> {
    
     // 定义一个任务,生成唯一ID并打印
        for (int i = 0; i < 100; i++) {
    
    
            long code = redisIdWorker.nextId("code"); // 生成唯一ID
            System.out.println(code); // 打印ID
        }
        latch.countDown(); // 任务执行完毕,计数器减一
    };

    long begin = System.currentTimeMillis(); // 记录开始时间
    for (int i = 0; i < 300; i++) {
    
    
        es.submit(task); // 提交任务到线程池执行
    }
    latch.await(); // 等待计数器归零,即等待所有任务执行完毕
    long end = System.currentTimeMillis(); // 记录结束时间
    System.out.println(end - begin); // 打印任务执行时间
}

4.3优惠券秒杀下单

4.3.1概述

image-20230624123849468

说明:

  • 此后台为Knife4j,详细实现过程请查看日志
  • 需要通过后台先添加限时抢购的优惠券,优惠券添加成功后再进行实现操作

4.3.2基本用例

image-20230624150127895

说明:

​ 实现思路:判断时间异常、判断库存、操作库存、创建订单、返回订单ID,实现基本的优惠券的下单功能

步骤一:实现优惠券秒杀下单逻辑

  • 修改VoucherOrderServiceImpl类中的seckillVoucher方法
@Resource
private ISeckillVoucherService seckillVoucherService;

@Autowired
RedisIdWorker redisIdWorker;

@Override
@Transactional // 设置到多张表的操作,需要添加事务
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 订单信息
     */
public Result seckillVoucher(Long voucherId) {
    
    
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    
    
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    
    
        // 已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
    
    
        return Result.fail("库存不足");
    }
    // 5.扣减库存
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1").eq("voucher_id", voucherId).update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2设置用户ID
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 6.4保存订单
    save(voucherOrder);
    // 7.返回订单id
    return Result.ok(orderId);
}

4.3.3总结

image-20230624161411042

说明:

​ 此时,查看订单库存stock。发现库存已经变为负数,说明出现库存超卖,此问题解决请看下节

4.4库存超卖问题

4.4.1概述

image-20230624150838257

说明:

​ 正常情况下,当线程1发现库存大于零时,扣减库存。紧接着,线程2再次判断库存,发现库存依旧大于零,于是正常报错

image-20230624153347904

说明:

​ 当非正常情况下,当线程1查询库存正常之后,有其余的线程接着来进行查询,发现库存正常,它们依次进行库存扣减,就会出现超卖问题

  • 解决超卖的问题可以进行加锁,加锁分为悲观锁与乐观锁

image-20230624153728046

说明:

  • 由于乐观锁的修改方式是让线程串行执行,再商品查询缓存时就已经演示过,此处不再演示
  • 乐观锁的修改方式,会在程序进行数据执行前检查是否有人进行数据的检查,看是否有人更新
  • 乐观锁的执行分为两种方式

image-20230624154652753

说明:

​ 每次执行SQL语句的修改前,查询版本是否跟查询到的版本号一致,若一致则失败

image-20230624154755365

说明:

​ 既然查询版本号可以发现数据有无修改,那么查询库存也可以发现是否数据有无修改,如此进行库存查询

补充:超卖问题解决

  • 悲观锁:添加同步锁,让线程串行执行
    • 优点:简单粗暴
    • 缺点:性能一般
  • 乐观锁:不加锁,在更新时判断是否有其它线程在修改
    • 优点:性能好
    • 缺点:存在成功率低的问题

4.4.2实现CAS(Compare And Switch)方法

说明:

  • 实现思路:

    ​ 加锁,实现乐观锁,让更新数据时同时检查是否还有库存,若有则秒杀成功

步骤一:新增CAS查询校验

  • 修改VoucherOrderServiceImplseckillVoucher方法
@Override
@Transactional // 设置到多张表的操作,需要添加事务
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
public Result seckillVoucher(Long voucherId) {
    
    
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    
    
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    
    
        // 已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
    
    
        return Result.fail("库存不足");
    }
    // 5.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2设置用户ID
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 6.4保存订单
    save(voucherOrder);
    // 7.返回订单id
    return Result.ok(orderId);
}

说明:

  • 扣减库存时,判别库存大于0而不是与查询库存时的数量对等进行比较。
  • 通过对等进行比较的方式,会让系统觉得有库存安全问题,从而导致停止库存的购买

4.4.3总结

​ 此时,会出现一个人购买多单的情况。若想要实现一人一单功能,请查看下节

4.5一人一单功能(难点)

4.5.1概述

​ 同一用户,对同一个优惠券只能添加一单

4.5.2 基本用例

image-20230624170153264

说明:

  • 实现思路:

    ​ 当库存充足时,根据用户ID和优惠券的ID判断订单是否存在,防止出现一人多单的情况

步骤一:添加依赖

  • 修改pom.xml文件
<!--aspectjweaver-使用API获取动态代理对象时会用到此依赖的底层源码-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

步骤二:实现一人一单功能

  • 修改VoucherOrderServiceImpl类的seckillVoucher方法并添加createVoucherOrder方法
@Override
public Result seckillVoucher(Long voucherId) {
    
    
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    
    
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    
    
        // 已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
    
    
        return Result.fail("库存不足");
    }
    /*
        补充:在此处加锁,而不是在createVoucherOrder方法内加锁。此时,代码的逻辑变为事务执行完成之后再进行锁的释放,保证事务的成功提交。若在方法内加锁,代码的逻辑变为锁已经释放了,但是事务还没有执行完成,依旧会造成线程安全问题。
         * */
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
    
     
        /*
    1.使用用户的ID作为锁对象是为了缩小加锁的范围,只为访问此方法的用户加锁。实现了不同用户加不同的锁,保证不同用户之间的并发性能。
   2.toString()方法,会在底层new一个新的对象进行加锁,因此添加intern方法,使得率先寻找常量池中的字符串地址值,确保了加锁对象唯一。这样不同的对象就会加不同的锁 */
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
        return proxy.createVoucherOrder(voucherId); // 用代理对象来调用此createVoucherOrder函数,因为此代理对象由Spring进行创建,因此该函数可被Spring进行管理。而不是用原生对象来调用此createVoucherOrder函数,例如this.createVoucherOrder。用原生对象来调用此函数,不能够触发@Transactional注解的功能
    }
}
/*
@Transactional此注解的生效,是因为Spring对当前VoucherOrderServiceImpl类做了动态代理,从而拿到了VoucherOrderServiceImpl类的代理对象createVoucherOrder方法。因此用代理对象来做的动态代理,所以才能够实现事务管理的功能
*/
@Transactional
public Result createVoucherOrder(Long voucherId) {
    
    
    // 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
    // 5.1获取用户ID
    Long userId = UserHolder.getUser().getId();

    // 5.2判断此订单是否存在
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
    
    
        // 该用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }
    // 6.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 7.2设置用户ID
    voucherOrder.setUserId(userId);
    // 7.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 7.4保存订单
    save(voucherOrder);
    // 8.返回订单id
    return Result.ok(orderId);
}

4.5.3总结

image-20230624204559297

说明:

  • 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了

  • 通过Nginx制作了负载均衡后,在不同的JVM中,使用的不同的锁监视器,因此单纯的通过悲观锁来对同一用户加锁会出现并发安全的问题。

  • 若需要解决不同的锁监视器,请查看下一小节

4.6分布式锁

4.6.1概述

image-20230625113036348

说明:

​ 分布式锁:满足分布式系统或集群模式下多进程可见且互斥的锁

·分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

image-20230625113640601

说明:

  • MySQL数据库都支持事务机制,当写入数据时,会自动的上锁,实现了互斥。MySQL支持主从模式。性能比Redis稍微差一点
  • Redis使用Setnx实现互斥,唯一不足的方式就是安全性不够高超时时,容易出现死锁
  • Zookeeper运用了内部节点的机制,利用唯一性和有序性来实现。性能有很强的一致性,因此主从机制会让性能变得更差一些

基于Redis的分布式锁

image-20230625112829451

说明:

  • 实现分布式锁的时候,只需要将锁监视器让每个JVM能够进行看到即可

  • 实现分布式锁时需要实现的两个基本方法,获取锁以及释放锁

  • 获取锁分为互斥的方式,确保只能有一个线程获取锁。以及非阻塞的方式,尝试一次,成功返回true,失败返回false

  • # 添加锁,NX是互斥、EX是设置超时时间
    SET lock thread1 NX EX 10 # 同时设置超时与判断,确保操作的原子性
    
  • 释放锁分为手动释放以及超时释放的方式,获取锁时添加一个超时时间

    # 释放锁,删除即可
    DEL key
    

4.6.2实现Redis分布式锁初级版本

image-20230625124912779

说明:

  • 实现思路:

    ​ 首先建立分布式的锁。业务开始前,先尝试从Redis中获取锁,获取成功则执行业务,否则失败

步骤一:创建锁

  • 创建锁ILock接口
/**
 * 锁的操作方式,获取锁,释放锁
 */
public interface ILock {
    
    
    /**
     * 获取锁
     *
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功;false代表获取锁失败
     */
    boolean tryLock(Long timeoutSec);

    /**
     * 释放锁
     */
    void unLock();
}

说明:

​ 定义锁的接口,实现基本的操作规范,获得锁,释放锁

步骤二:分布式锁的实现类

  • 创建SimpleRedisLock类,并实现锁ILock接口
public class SimpleRedisLock implements ILock {
    
    

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "lock:";

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
    
    
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }


    @Override
    public boolean tryLock(Long timeoutSec) {
    
    
        long threadID = Thread.currentThread().getId(); //获取线程的ID作为锁值
        Boolean success = stringRedisTemplate.opsForValue() // 设置锁的超时时间,防止锁的释放
                .setIfAbsent(KEY_PREFIX + name, String.valueOf(threadID), timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //因为返回结果为包装类型,拆箱涉及到可能空指针的影响,这里判断一下
    }

    @Override
    public void unLock() {
    
    
        stringRedisTemplate.delete(KEY_PREFIX + name);

    }
}

说明:

​ 根据定义锁的接口,实现基本的操作,获得锁,释放锁

步骤三:修改业务逻辑

  • 修改VoucherOrderServiceImpl类中seckillVoucher方法与createVoucherOrder方法
@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
StringRedisTemplate stringRedisTemplate;

@Autowired
RedisIdWorker redisIdWorker;

@Override
public Result seckillVoucher(Long voucherId) {
    
    
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    
    
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    
    
        // 已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
    
    
        return Result.fail("库存不足");
    }
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象
    SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate); //拼接用户的ID,为每个用户添加自己的锁
    // 获取锁
    boolean success = simpleRedisLock.tryLock(10L);
    if (BooleanUtil.isFalse(success)) {
    
    
        // 获取锁失败,返回错误或重试
        return Result.fail("不允许重复下单");
    }

    try {
    
    
        // 获取代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); //通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
        return proxy.createVoucherOrder(voucherId); //用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
    } finally {
    
    
        // 释放锁
        simpleRedisLock.unLock();
    }
}

@Transactional
public Result createVoucherOrder(Long voucherId) {
    
    
    // 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
    // 5.1获取用户ID
    Long userId = UserHolder.getUser().getId();

    // 5.2判断此订单是否存在
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
    
    
        // 该用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }
    // 6.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        return Result.fail("库存不足");
    }
    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1设置订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 7.2设置用户ID
    voucherOrder.setUserId(userId);
    // 7.3设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 7.4保存订单
    save(voucherOrder);
    // 8.返回订单id
    return Result.ok(orderId);
}

说明:

  • 修改VoucherOrderServiceImpl类的业务逻辑,实现手动加锁与手动释放锁
  • 值得注意的是创建锁的对象时,需要指定Key的值为用户的唯一标识,为不同用户创建锁,这样才可以实现一人一单功能。若不加用户的唯一标识,则标识锁住所有用户

补充:

image-20230625155426245

  • 根据分布式锁初级版本的实现情况,可能会导致线程误删除别人的锁的情况。因为线程1的业务阻塞导致锁超时释放,此时线程2发现巧合获得了锁。此时,线程1业务完成后,执行了手动释放锁,于是导致误删除了线程2的锁,会出现并发线程的误删问题

image-20230625155548142

  • 此时,要想改变解决这个问题可以在释放锁之前判断一下,这个锁是不是自己的锁,如果是则释放,不是则不释放

4.6.3实现Redis分布式锁改进版本

image-20230625160146377

说明:

  • 实现思路:

    ​ 1.在获取锁时存入线程标识(可以用UUID表示)

    ​ 2.在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致。如果一致则释放锁,如果不一致则不释放锁

补充:

​ 为什么会用到UUID作为存入线程的标识呢,在JVM内部每创建一个线程,ID就会递增。在不同的JVM中,如果直接使用ID作为线程的标识,可能会出现冲突。此时,用UUID区分不同的JVM

步骤一:使用setnx制作分布式锁

  • 修改SimpleRedisLock类的tryLockunLock方法
public class SimpleRedisLock implements ILock {
    
    

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; // 标识忽略UUID默认自带的下划线

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
    
    
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }


    @Override
    public boolean tryLock(Long timeoutSec) {
    
    
        String threadID = ID_PREFIX + Thread.currentThread().getId(); //获取线程的ID作为锁值
        Boolean success = stringRedisTemplate.opsForValue() // 设置锁的超时时间,防止锁的释放
                .setIfAbsent(KEY_PREFIX + name, threadID, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //因为返回结果为包装类型,拆箱涉及到可能空指针的影响,这里判断一下
    }

    @Override
    public void unLock() {
    
    
        // 获取线程的标识
        String threadID = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断锁标识和线程标识是否一致
        if (ObjectUtil.equal(threadID, id)) {
    
    
            // 相等,则删除
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

说明:

​ 每次删除锁之前,先进行判断。判断线程标识与锁的标识是否一致,相同则删除,不相同则不是我的,就不删除

补充:

image-20230625164907229

  • 根据分布式锁改进版本的实现情况,还是可能会导致线程误删除别人的锁的情况。因为线程1的业务完成后判断完自己的线程成功后,将要执行释放锁机制的时候,线程阻塞导致锁超时释放(JVM的垃圾回收机制,可能导致线程阻塞)。此时线程2发现巧合获得了锁。此时,线程1业务完成后,执行了手动释放锁,于是导致误删除了线程2的锁,会出现并发线程的误删问题.。

  • 如果,我们能够将业务完成后判断线程与释放锁作为一个事务来完成(利用Lua脚本),确保其原子性就可以完成Redis分布式锁的实现

  • 此时,由于Redis在7.0.x的版本中不再支持Lua脚本的操作,所以我们不再完成Lua脚本的制作

  • 更何况,当使用了Setnx实现了Redis分布式锁也会出现如下问题

    image-20230625172721617

  • 接下来请看Redisson的方式对Redission的方式对分布式锁进行优化

4.6.4Redisson工具

4.6.4.1概述

​ Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

image-20230625174331908

​ 官网地址: https://redisson.org、GitHub地址: https://github.com/redisson/redisson

4.6.4.2实现Redis分布式锁高级版本

步骤一:引入依赖

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

补充:

​ 建议建立分布式锁的时候,不建议用通过starter来对SpringBoot进行整合,因为它会替代Spring官网提供的Redis的API的实现

步骤二:添加Redisson配置类

@Configuration
public class RedisConfig {
    
    
    @Bean
    public RedissonClient getRedisClient() {
    
    
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://10.13.164.55:6379").setPassword("qweasdzxc");
        // 创建客户端
        return Redisson.create(config);
    }
}

说明:

​ 使用Redis的单节点方式配置Redisson客户端

步骤三:使用Redisson的分布式锁

  • 修改VoucherOrderServiceImpl实现类的seckillVoucher方法
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
@Override
public Result seckillVoucher(Long voucherId) {
    
    
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
    
    
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
    
    
        // 已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
    
    
        return Result.fail("库存不足");
    }
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象
    RLock lock = redissonClient.getLock("lock:order:" + userId);
    // 获取锁(参数含义分别是,获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位)
    boolean isLock = lock.tryLock(); // 默认不重试,锁超过30秒自动释放
    if (BooleanUtil.isFalse(isLock)) {
    
    
        // 获取锁失败,返回错误或重试
        return Result.fail("不允许重复下单");
    }

    try {
    
    
        // 获取代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); //通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
        return proxy.createVoucherOrder(voucherId); //用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
    } finally {
    
    
        // 释放锁(其Redisson内部会自动进行锁释放标识的比对)
        lock.unlock();
    }
}

说明:

​ 可以看到Redisson使用的方式跟我们的自建锁类SimpleRedisLock的方式相似

4.6.4.3可重入锁原理

image-20230625200845778

说明:

​ 可重入锁不仅仅在锁里面记录了获取的锁,还记录了获取锁的次数。在底层核心是利用Redis中的Hash类型来记录线程ID,和记录重入次数

image-20230625201031654

说明:

​ 这是Redisson中的可重入锁的原理。从流程图可以分为获取锁和释放锁两部分

  • 获取锁

image-20230625201627571

说明:

  • 当获取锁时,判断锁是否存在
  • 锁不存在则获取锁后添加线程标识,再设置锁的有效期
  • 锁存在则根据线程标识判断是否是自己,若是则计数加1,并设置锁的有效期,不是自己的,可能因为其余线程正在使用该锁儿导致,因此则获取失败
  • 释放锁

image-20230625202449241

说明:

  • 当业务执行完后,再次判断锁是否是自己的。
  • 若不是自己的锁则不再释放锁,防止误删除其余线程正在使用的锁
  • 若是自己的锁则技术减1
  • 此时判断计数是否为0,因为本线程执行的业务可能为嵌套业务中的子业务,所以不能直接释放锁,而是判断计数后再决定要不要释放
  • 为0则释放锁
  • 不为0则重置锁的有效期再次循环以上步骤
  • 代码示例
@SpringBootTest
@Slf4j
public class RedissonTests {
    
    
    @Autowired
    RedissonClient redissonClient;

    RLock lock;

    @BeforeEach
    void before() {
    
    
        lock = redissonClient.getLock("order");
    }

    @Test
    void method1() {
    
    
        boolean isLock = lock.tryLock();
        if (!isLock) {
    
    
            log.error("获取锁失败,1");
            return;
        }
        try {
    
    
            log.info("获取锁成功,1");
            method2();
        } finally {
    
    
            log.info("释放锁,1");
            lock.unlock();
        }
    }

    void method2() {
    
    
        boolean isLock = lock.tryLock();
        if (!isLock) {
    
    
            log.error("获取锁失败, 2");
            return;
        }
        try {
    
    
            log.info("获取锁成功,2");
        } finally {
    
    
            log.info("释放锁,2");
            lock.unlock();
        }
    }
}

说明:结果

image-20230625200521130

补充:

  • Redisson获取锁源码

image-20230625191737341

  • 查看获取锁的源码,可以看到依旧是用Lua脚本执行,并且逻辑跟画的流程图近似。但此处成功返回的是nil,获取失败返回的是剩余的时间
  • Redisson释放锁源码

image-20230625191919102

  • 查看释放的源码,可以看到依旧是用Lua脚本执行,并且逻辑跟画的流程图近似。但此处实际上还发布了一条消息

4.6.4.4可重试原理

image-20230625214826176

说明:

  • 此时若获取成功则返回nil,获取失败返回锁的过期时间
  • 查看可重试的源码
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
     
     
    long time = unit.toMillis(waitTime);  // 将等待时间转换为毫秒
    long current = System.currentTimeMillis();  // 当前时间
    long threadId = Thread.currentThread().getId();  // 当前线程ID
    Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);  // 尝试获取锁的剩余过期时间
    if (ttl == null) {
     
     
        return true;  // 成功获取到锁,返回true
    } else {
     
     
        time -= System.currentTimeMillis() - current;  // 计算剩余等待时间
        if (time <= 0L) {
     
     
            this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
            return false;
        } else {
     
     
            current = System.currentTimeMillis();  // 更新当前时间
            RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);  // 订阅锁释放事件
            if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
     
       // 等待一段时间看是否能够获取到锁释放事件的通知
                if (!subscribeFuture.cancel(false)) {
     
       // 取消订阅
                    subscribeFuture.onComplete((res, e) -> {
     
     
                        if (e == null) {
     
     
                            this.unsubscribe(subscribeFuture, threadId);  // 取消订阅成功后,执行取消订阅操作
                        }
                    });
                }
                this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                return false;
            } else {
     
     
                try {
     
     
                    time -= System.currentTimeMillis() - current;  // 更新剩余等待时间
                    if (time <= 0L) {
     
     
                        this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                        return false;
                    } else {
     
     
                        boolean var16;
                        do {
     
     
                            long currentTime = System.currentTimeMillis();  // 当前时间
                            ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);  // 再次尝试获取锁的剩余过期时间
                            if (ttl == null) {
     
     
                                var16 = true;  // 成功获取到锁,返回true
                                return var16;
                            }
                            time -= System.currentTimeMillis() - currentTime;  // 更新剩余等待时间
                            if (time <= 0L) {
     
     
                                this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                                var16 = false;
                                return var16;
                            }
                            currentTime = System.currentTimeMillis();  // 当前时间
                            if (ttl >= 0L && ttl < time) {
     
     
                                ((RedissonLockEntry) subscribeFuture.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);  // 使用剩余过期时间进行等待
                            } else {
     
     
                                ((RedissonLockEntry) subscribeFuture.getNow()).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);  // 使用剩余等待时间进行等待
                            }
                            time -= System.currentTimeMillis() - currentTime;  // 更新剩余等待时间
                        } while (time > 0L);
                        this.acquireFailed(waitTime, unit, threadId);  // 等待时间已经用完,获取锁失败
                        var16 = false;
                        return var16;
                    }
                } finally {
     
     
                    this.unsubscribe(subscribeFuture, threadId);  // 释放订阅锁释放事件
                }
            }
        }
    }
}

  • 该方法用于尝试获取分布式锁。首先,它计算等待时间并获取当前线程的ID。然后,它尝试获取锁的剩余过期时间。如果成功获取锁(剩余过期时间为null),则返回true。如果剩余等待时间已经用完,则获取锁失败,返回false。
  • 如果剩余等待时间仍然可用,则订阅锁释放事件(此释放事件,是由释放锁时发布),并等待一段时间看是否能够获取到锁释放事件的通知。如果等待超时或者取消了订阅,则获取锁失败,返回false。
  • 如果成功获取到锁释放事件的通知,则再次尝试获取锁的剩余过期时间。如果成功获取到锁(剩余过期时间为null),则返回true。如果剩余等待时间已经用完,则获取锁失败,返回false。
  • 在获取锁的过程中,使用了一个计时器来更新剩余等待时间,并在等待过程中进行等待操作。最后,释放订阅的锁释放事件,并返回获取锁的结果。

4.6.4.5超时续约原理

image-20230626075937778

说明:

  • 当任务完成后,会进行回调,回调如果剩余有效期(ttlRemainingFuture)还有剩余,就回去更新锁的过期时间

补充:

  • 当leaseTime不等于负1时,就不会开启看门狗的机制,换句话说,也就不会进行超时续约。30秒后过期就会自动释放锁

image-20230626090000985

说明:

  • 查看源码
private void scheduleExpirationRenewal(long threadId) {
     
     
    ExpirationEntry entry = new ExpirationEntry(); // 创建一个过期时间续约条目对象
    ExpirationEntry oldEntry = (ExpirationEntry) EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry); // 尝试将续约条目放入续约映射中

    if (oldEntry != null) {
     
      // 如果续约映射中已存在旧的续约条目
        oldEntry.addThreadId(threadId); // 将当前线程的ID添加到旧的续约条目中
    } else {
     
      // 如果续约映射中不存在旧的续约条目
        entry.addThreadId(threadId); // 将当前线程的ID添加到新的续约条目中
        this.renewExpiration(); // 开始进行过期时间的续约操作
    }
}
  • EXPIRATION_RENEWAL_MAP为RedissonLock类中的ConcurrentMap<String, ExpirationEntry>集合,用于放入需要续约条目,便于释放锁时使用

image-20230626090301890

  • 刷新过期时间,会开启一个任务,并持续的刷新。也就意味着,此时的锁是永不过期的锁

补充:

  • 那么这个锁既然是永不过期的锁,那什么时候会释放呢?其实是在释放锁的时候才会过期

image-20230626091511067

4.6.4.6主从一致性问题

image-20230626164550074

说明:

  • 当使用Redis集群的方式,容易出现主从一致性问题
  • 当获取锁的主节点意外宕机,会从从节点中任选一个作为新主节点,但此时旧主节点无法同步数据到新主节点,从而导致数据丢失
  • 解决方案

image-20230626164817871

说明:

  • 现在,新建一个Redis集群,每次获取锁时将锁数据都同步到各个主节点中
  • 即便主节点宕机,但仍然可以保证数据的不丢失
  • 示例代码

1.修改RedisConfig配置文件

@Configuration
public class RedisConfig {
    
    
    @Bean
    public RedissonClient redissonClient() {
    
    
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://10.13.164.55:6379").setPassword("qweasdzxc");
        // 创建客户端
        return Redisson.create(config);
    }

    @Bean
    public RedissonClient redissonClient2() {
    
    
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://10.13.164.55:6380").setPassword("qweasdzxc");
        // 创建客户端
        return Redisson.create(config);
    }
}

2.修改RedissonTests测试类

@SpringBootTest
@Slf4j
public class RedissonTests {
    
    
    @Autowired
    RedissonClient redissonClient;

    @Autowired
    RedissonClient redissonClient2;

    RLock lock;

    @BeforeEach
    void before() {
    
    
        RLock lock1 = redissonClient.getLock("order");
        RLock lock2 = redissonClient2.getLock("order");
        // 创建连锁MultiLock
        lock = redissonClient.getMultiLock(lock1, lock2); //这里无论使用哪一个客户端来创建MultiLock都可以
    }

    @Test
    void method1() throws InterruptedException {
    
    
        boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
        if (!isLock) {
    
    
            log.error("获取锁失败,1");
            return;
        }
        try {
    
    
            log.info("获取锁成功,1");
            method2();
        } finally {
    
    
            log.info("释放锁,1");
            lock.unlock();
        }
    }

    void method2() {
    
    
        boolean isLock = lock.tryLock();
        if (!isLock) {
    
    
            log.error("获取锁失败, 2");
            return;
        }
        try {
    
    
            log.info("获取锁成功,2");
        } finally {
    
    
            log.info("释放锁,2");
            lock.unlock();
        }
    }
}

补充:

  • 查看获取锁源码
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
     
     
    long newLeaseTime = -1L;

    // 计算新的租期时间
    if (leaseTime != -1L) {
     
     
        if (waitTime == -1L) {
     
     
            newLeaseTime = unit.toMillis(leaseTime);
        } else {
     
     
            newLeaseTime = unit.toMillis(waitTime) * 2L;
        }
    }

    long time = System.currentTimeMillis();
    long remainTime = -1L;

    // 计算剩余等待时间
    if (waitTime != -1L) {
     
     
        remainTime = unit.toMillis(waitTime);
    }

    long lockWaitTime = this.calcLockWaitTime(remainTime);
    int failedLocksLimit = this.failedLocksLimit();
    List<RLock> acquiredLocks = new ArrayList<>(this.locks.size());
    ListIterator<RLock> iterator = this.locks.listIterator();

    while (iterator.hasNext()) {
     
     
        RLock lock = (RLock) iterator.next();
        boolean lockAcquired;

        try {
     
     
            if (waitTime == -1L && leaseTime == -1L) {
     
     
                // 尝试获取锁,不设置等待时间和租期时间
                lockAcquired = lock.tryLock();
            } else {
     
     
                // 计算实际等待时间
                long awaitTime = Math.min(lockWaitTime, remainTime);
                // 尝试获取锁,设置等待时间和租期时间
                lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
            }
        } catch (RedisResponseTimeoutException var21) {
     
     
            // 获取锁超时,释放已获取的锁
            this.unlockInner(Arrays.asList(lock));
            lockAcquired = false;
        } catch (Exception var22) {
     
     
            lockAcquired = false;
        }

        if (lockAcquired) {
     
     
            acquiredLocks.add(lock);
        } else {
     
     
            if (this.locks.size() - acquiredLocks.size() == this.failedLocksLimit()) {
     
     
                // 已获取的锁数量达到失败限制,退出循环
                break;
            }

            if (failedLocksLimit == 0) {
     
     
                // 已达到失败限制次数,释放已获取的锁
                this.unlockInner(acquiredLocks);

                if (waitTime == -1L) {
     
     
                    return false;
                }

                // 重新设置失败锁限制和已获取锁列表
                failedLocksLimit = this.failedLocksLimit();
                acquiredLocks.clear();

                while (iterator.hasPrevious()) {
     
     
                    iterator.previous();
                }
            } else {
     
     
                // 减少失败锁限制次数
                --failedLocksLimit;
            }
        }

        if (remainTime != -1L) {
     
     
            // 更新剩余等待时间
            remainTime -= System.currentTimeMillis() - time;
            time = System.currentTimeMillis();

            if (remainTime <= 0L) {
     
     
                // 等待时间已用完,释放已获取的锁并返回失败
                this.unlockInner(acquiredLocks);
                return false;
            }
        }
    }

    if (leaseTime != -1L) {
     
     
        // 设置锁的租期时间
        List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());
        Iterator<RLock> var24 = acquiredLocks.iterator();

        while (var24.hasNext()) {
     
     
            RLock rLock = (RLock) var24.next();
            // 异步设置锁的租期时间
            RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
            futures.add(future);
        }

        var24 = futures.iterator();

        while (var24.hasNext()) {
     
     
            // 等待设置锁的租期时间操作完成
            RFuture<Boolean> rFuture = (RFuture) var24.next();
            rFuture.syncUninterruptibly();
        }
    }

    return true;
}
  • 多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。若在获取所有节点的任一一个锁失败,则获取锁失败

4.6.4.7原理总结

笔记小结:

1)不可重入Redis分布式锁:

  • 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
  • 缺陷:不可重入、无法重试、锁超时失效

2)可重入的Redis分布式锁:

  • 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
  • 缺陷:redis宕机引起锁失效问题

3)Redisson的multiLock:

  • 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
  • 缺陷:运维成本高、实现复杂
  • 获取锁与释放锁(单节点模式下)流程原理图

image-20230626090958895

说明:

​ 左侧图为尝试获取锁的逻辑执行流程、右侧图为尝试释放锁的逻辑执行流程

4.7Redis优化秒杀(难点)

4.7.1概述

image-20230626173658068

说明:

​ 基于当前的秒杀业务实现方案,依旧会出现业务流程耗时。因为当执行查询优惠券、查询订单等操作都涉及到频繁的操作数据库

image-20230626173837981

说明:

​ 现在基于Redis来优化秒杀功能,减轻数据库的操作业务。并将同步操作数据库的方式改为异步的方式。减轻秒杀业务的流程,让秒杀完成执行更快

4.7.2基本用例

image-20230626174252715

说明:

  • 实现思路:

    ​ 判断库存是否充足,不充足则结束,充足则进行判断、判断用户是否已完成下单,下单过则结束,没下单则扣减库存、库存扣减后,保存用户的ID到Set集合。(先利用Redis完成库存余量、一人一单判断,完成抢单业务)若下单成功则,返回该订单编号的ID,若下单失败,则秒杀失败(再将下单业务放入阻塞队列,利用独立线程异步下单)

  • 使用Lua脚本是为了保证操作的原子性,要么判断库存、扣减库存同时成功,要么同时失败

image-20230626174827790

  • 将用户ID保存到Set集合,是利用自带除重功能,防止该用户重复下单,以实现一人一单的功能

步骤一:新增秒杀优惠券的同时,将优惠券信息保存到Redis中

  • 修改VoucherServiceImpladdSeckillVoucher方法
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    
    
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存库存到redis中
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), String.valueOf(voucher.getStock()));
}

说明:

​ 让秒杀券的库存信息,保存到Redis中,减少读取MySQl数据库的频率

步骤二:基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功

  • Resources里添加seckill.lua脚本
-- 1.参数列表
-- 1.1优惠券id
local voucherId = ARGV[1]
-- 1.2用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1库存Key,用于获取秒杀券的信息
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单Key,用于保存购买用户的用户Id,值为Set集合
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 3.2 库存不足,返回1
    return 1
end 
-- 3.3判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
	return 2
end
-- 3.4.扣库存incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5,下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0

步骤三:如果抢购成功,将优惠券id和用户id封装后存入阻塞队列

  • 修改VoucherOrderServiceImpl类中的seckillVoucher方法
public IVoucherOrderService PROXY;   //此时,无法子线程中拿到代理对象,因此定义为成员变量,让子线程可用获取此类的代理对象
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 定义一个阻塞队列
private BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024); //类似于MQ

// 静态代码块,用于初始化秒杀脚本
static {
    
    
    SECKILL_SCRIPT = new DefaultRedisScript<>();

    // 设置秒杀脚本的位置为类路径下的 "seckill.lua" 文件
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));

    // 设置秒杀脚本执行结果的类型为 Long
    SECKILL_SCRIPT.setResultType(Long.class);
}
/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
@Override
public Result seckillVoucher(Long voucherId) {
    
    
    // 获取用户ID
    Long userId = UserHolder.getUser().getId();
    // 1.执行Lua脚本(参数含义:脚本,key,Value)
    Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(), // 因为我们的Lua脚本没有Key,因此利用Collections工具传递空集合
        voucherId.toString(), //将Long类型转换为String类型
        userId.toString()
    );
    // 2.判断结果是否为0
    int r = result.intValue();
    if (r != 0) {
    
    
        // 2.1不为0,代表没有购买资格
        return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
    }
    // 2.2为0,有购买资格,把下单信息保存到阻塞队列
    long orderId = redisIdWorker.nextId("order");
    // 2.3.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 2.4设置订单ID;
    voucherOrder.setId(orderId);
    // 2.5设置用户ID
    voucherOrder.setUserId(userId);
    // 2.6设置优惠券ID
    voucherOrder.setVoucherId(voucherId);
    // 2.7保存订单到阻塞队列
    orderTask.add(voucherOrder);
    // 3.获取代理对象
    PROXY = (IVoucherOrderService) AopContext.currentProxy();// 在主线程中获取代理对象,便于子线程中使用
    // 4.返回订单Id
    return Result.ok(orderId);
}

步骤四:开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

// 定义一个线程池
private final static ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
/**
     * 利用Spring框架提供的注解,让在本类初始化完成之后来执行方法内的内容
     */
@PostConstruct
private void init() {
    
    
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
/**
     * 定义一个内部类,用于实现异步订单的处理
     */
private class VoucherOrderHandler implements Runnable {
    
    
    @Override
    public void run() {
    
    
        while (true) {
    
     // 此处循环执行代码逻辑
            try {
    
    
                // 1.获取队列中的订单信息
                VoucherOrder voucherOrder = orderTask.take(); // 此时,若队列里面没有订单信息,则会阻塞在这里
                // 2.创建订单
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
    
    
                log.error("处理订单异常" + e); // 因为这里是子线程来执行任务的处理,因此不用抛出异常,仅仅打印日志即可
            }
        }
    }
}

/**
     * 处理订单信息的业务逻辑
     *
     * @param voucherOrder 阻塞队列里面的订单信息
     */
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    
    
    // 1.获取用户
    Long userId = voucherOrder.getUserId(); //因为此方法交给子线程来执行,因此无法通过主线程的 UserHolder.getUser().getId()方法来获取用户的ID
    // 2.获取锁对象
    RLock lock = redissonClient.getLock(LOCK_ORDER_KEY + userId); // 其实此处无需加锁,因为每个用户不会有多次下单的操作,除非Redis出现异常。因此,这里加锁判断一下比较好
    // 3.获取锁
    boolean isLock = lock.tryLock();
    // 4.判断锁是否获取成功
    if (!isLock) {
    
    
        // 获取锁失败,返回错误重试
        log.error("不允许重复下单");
        return;
    }
    // 5.获取代理对象
    try {
    
     // 此时无法从子线程中获取
        PROXY.createVoucherOrder(voucherOrder);// 用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
    } finally {
    
    
        // 释放锁
        lock.unlock();
    }
}

/**
     * 创建订单信息
     * 这里主要实现了一个对数据库的事务操作,保证
     *
     * @param voucherOrder 阻塞队列里面的订单信息
     */
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    
    
    // 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
    // 5.1获取用户ID
    Long userId = voucherOrder.getUserId();

    // 5.2判断此订单是否存在
    int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
    if (count > 0) {
    
     // 此处同样也不可能出现重复,除非Redis集群宕机
        // 该用户已经购买过了
        log.error("用户已经购买过一次!");
        return;
    }
    // 6.扣减库存-使用CAS方式解决超卖问题
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherOrder.getVoucherId())
        .gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
        .update();
    if (!success) {
    
    
        // 扣减失败
        log.error("库存不足");
        return;
    }

    // 7.保存订单
    save(voucherOrder);
}

补充:

​ 现在异步阻塞仍然存在问题,内存限制问题,此时阻塞队列是保存在Jvm中,因此Jvm宕机,那么订单信息就会丢失,从而导致了数据安全问题

4.8Redis消息队列实现异步秒杀

4.8.1概述

消息队列(Message Queue),字面意思就是存放消息的队列。

最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

image-20230627154130025

说明:

  • MQ不受Jvm限制。MQ内的消息会做持久化

其中,Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

4.8.2基于List结构模拟消息队列

消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。

队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。

image-20230627155018228

说明:

  • 不过要注意的是,当队列中没有消息时RPOPLPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。

补充:基于List结构模拟消息队列的优点

  • 优点:
    • 利用Redis存储,不受限于JVM内存上限
    • 基于Redis的持久化机制,数据安全性有保证
    • 可以满足消息有序性
  • 缺点:
    • 无法避免消息丢失
    • 只支持单消费者
  • 如果此时从Redis中获取了消息队列中的消息,而消费队列中的服务挂掉了,那么数据也就丢失了

4.8.3基于PubSub的消息队列

​ **PubSub(发布订阅)**是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

  • SUBSCRIBE channel [channel] :订阅一个或多个频道
  • PUBLISH channel msg :向一个频道发送消息
  • PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道

image-20230627155559906

说明:

  • 发布订阅模式,简单的说就是一生产多消费的模式

image-20230627155826890

说明:

  • 当订阅与Pattern格式相匹配的所有频道后,可获得多个格式相匹配的频道消费消息

补充:基于PubSub的消息队列有哪些优缺点

  • 优点:
    • 采用发布订阅模型,支持多生产、多消费
  • 缺点:
    • 不支持数据持久化
    • 无法避免消息丢失
    • 消息堆积有上限,超出时数据丢失
  • 基于PubSub进行发布于订阅的模式,会将消息存储在消费者那里。然而消费者那里的缓存空间是有上限的,如果超出就会丢失
  • 客户端的保存的消息,会如果生产者发出的数据,没有人在Redis中做订阅,那么数据就会丢失。

4.8.4基于Stream的消息队列

​ Stream 是 Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列

​ Redis Stream主要用于消息队列(MQ,Message Queue),Redis Stream提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失

img

说明:

  • Consumer Group :消费组,使用 XGROUP CREATE 命令创建,一个消费组有多个消费者(Consumer)
  • last_delivered_id :游标,每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动
  • pending_ids :消费者(Consumer)的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有 ack (Acknowledge character:确认字符)
  • 业务流程-使用消费者方式实现

image-20230627170053442

说明:

  • 当使用阻塞式读取消息队列中的数据的时候,超时则返回nil
  • 在业务开发中,我们可以循环的调用XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:

image-20230627170202223

注意:

  • 当指定ID为$时,此时,又有多条消息到达队列,那么每次获取到的信息都是最新的一条,就会出现漏读的情况
  • 业务流程-使用消费者组方式实现

image-20230627175003679

说明:

​ 消费者组(Consumer Group),是将多个消费者划分到一个组中,监听同一个队列。具备如上特点,

image-20230627194312915

说明:

  • 以上是基于Stream的消息队列来实现异步秒杀的伪代码
  • 如果有报错异常,那么就会一直执行,直到本条消息处理成功为止

补充:STREAM类型消息队列的XREADGROUP命令特点

  • 消息可回溯
  • 可以多消费者争抢消息,加快消费速度
  • 可以阻塞读取
  • 没有消息漏读的风险
  • 有消息确认机制,保证消息至少被消费一次

4.8.5三种方式的消息队列区别

image-20230627205550184

说明:

  • List集合不支持消息回溯:当消费者处理消息时发生错误或异常退出时,已经入队但尚未处理的消息可能会丢失
  • PubSub不支持消息回溯:由于消息的发布和订阅是异步的,无法保证消息的严格顺序性。并且,Redis本身不负责跟踪订阅者的状态,当订阅者断开连接后,无法获知其状态变化。

4.8.6实现Redis消息队列异步秒杀(重点)

image-20230626174252715

说明:

​ 实现思路:接着修改上一小节中的Redis优化秒杀的功能

步骤一:创建一个Stream类型的消息队列,名为stream.orders

XGROUP CREATE stream.orders g1 0 MKSTREAM

说明:

​ 使用命令行工具提前在Reids中创好,因为在整个Redis中仅创建一个就可以了,不需要重复的创建

步骤二:修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId

1.在Resources里修改seckill.lua脚本

-- 1.参数列表
-- 1.1优惠券id
local voucherId = ARGV[1]
-- 1.2用户id
local userId = ARGV[2]

-- 1.3订单id
local orderId = ARGV[3]

-- 2.数据key
-- 2.1库存Key,用于获取秒杀券的信息
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单Key,用于保存购买用户的用户Id,值为Set集合
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
	return 2
end
-- 3.4.扣库存incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5,下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6发送消息到队列中,XADD stream.orders * k1 v1 k2 v2 ...return
redis.call("xadd", "stream.orders","*", "userId", userId,"voucherId", voucherId,"id",orderId)
return 0

说明:

​ 使用操作数据库的xadd,添加Lua脚本中发送消息到阻塞队列

2.修改VoucherOrderServiceImpl类中的seckillVoucher方法

/**
     * 秒杀优惠券业务
     *
     * @param voucherId 优惠券的Id
     * @return 控制层对此业务的处理结果
     */
@Override
public Result seckillVoucher(Long voucherId) {
    
    
    // 获取用户ID
    Long userId = UserHolder.getUser().getId();
    // 获取订单ID
    long orderId = redisIdWorker.nextId("order");
    // 1.执行Lua脚本(参数含义:脚本,key,Value)
    Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(), // 因为我们的Lua脚本没有Key,因此利用Collections工具传递空集合
        voucherId.toString(), //将Long类型转换为String类型
        userId.toString(),
        String.valueOf(orderId)
    );
    // 2.判断结果是否为0
    int r = result.intValue();
    if (r != 0) {
    
    
        // 2.1不为0,代表没有购买资格
        return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
    }
    // 3.获取代理对象
    PROXY = (IVoucherOrderService) AopContext.currentProxy();// 在主线程中获取代理对象,便于子线程中使用
    // 4.返回订单Id
    return Result.ok(orderId);
}

步骤三:项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

  • 修改VoucherOrderServiceImpl类中的VoucherOrderHandler方法
/**
     * 定义一个内部类,用于实现异步订单的处理
     */
private class VoucherOrderHandler implements Runnable {
    
    
    String queueName = "stream.orders"; // 注意,此处的队列名称,需要与Lua脚本里的相对于

    @Override
    public void run() {
    
    
        while (true) {
    
    
            try {
    
    
                // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.order >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"), // 读取消费者信息
                    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                    StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );
                // 2.判断消息获取是否成功
                if (list == null || list.isEmpty()) {
    
    
                    // 2.1 如果获取失败,说明没有消息,继续下一次循环
                    continue;
                }
                // 3.解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                // 4.如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                // 5.ACK确认 SACK stream.order g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); //设置消息ID

            } catch (Exception e) {
    
    
                log.error("处理订单异常" + e);
                handlePendingList();
            }
        }
    }

    private void handlePendingList() {
    
    
        while (true) {
    
    
            try {
    
    
                // 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.order 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( // 注意,此处读取消息变为0
                    Consumer.from("g1", "c1"), // 读取消费者信息
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create(queueName, ReadOffset.from("0"))// 未提供API,需要自己上传数据
                );
                // 2.判断消息获取是否成功
                if (list == null || list.isEmpty()) {
    
    
                    // 2.1 如果获取失败,说明pendingList没有消息, 结束循环
                    break;
                }
                // 3.解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                // 4.如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                // 5.ACK确认 SACK stream.order g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); //设置消息ID

            } catch (Exception e) {
    
    
                log.error("处理pendingList订单异常" + e);
                try {
    
    
                    Thread.sleep(20); // 若抛出异常,可休眠一段时间后再进行PendingList的消息处理
                } catch (InterruptedException ex) {
    
    
                    ex.printStackTrace();
                }
            }
        }
    }
}

说明:

​ 修改此处代码,实现基于Stream的消息队列的伪代码逻辑

5.达人探店

笔记小结:

  1. Redis功能点:

    • 在点赞功能中,使用Redis中的SortedSet命令,通过score方法查询该登录用户是否为该博客或每篇博客点赞。通过add方法记录登录用户为该篇博客的点赞信息。通过remove方法移除登录用户为该篇博客的点赞信息

    • 在点赞排行榜功能中,使用Redis中的SortedSet命令,通过range方法查询点赞博客的用户排名

  2. 功能实现疑难点:

    1. MyBatis-plus中使用equpdatesetSql、添加SQL语句last的基本使用
    2. Stream流中,类型转换map()、终结方法toList()
    3. Hutool工具中,拼接字符串StrUtil.join、实体类拷贝BeanUtil.copyProperties

5.1概述

​ 达人探店是一种旅游和探索体验的活动,通常由旅行者或旅游爱好者作为达人(专家或导游)来引导其他人探索特定目的地的文化、历史、美食、景点

5.2发布探店笔记

image-20230628210054151

5.3点赞

补充:

  • 同一个用户只能点赞一次,再次点击则取消点赞
  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

步骤一:给Blog类中添加一个isLike字段,标示是否被当前用户点赞

  • 修改Blog类的成员属性
/**
     * 是否点赞过了
     */
@TableField(exist = false)
private Boolean isLike;

说明:

​ 新增isLike成员属性,用于判断用户是否点赞。这个属性不存在于数据库,因此添加注解,便于查询时是否选择高亮

步骤二:修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1

  • 修改BlogServiceImpl类的likeBlog方法
@Override
public Result likeBlog(Long id) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();

    String key = "blog:liked:" + id; //以商铺的Key作为键
    // 2.判断当前登录用户是否已经点赞
    Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    // 3.如果未点赞,可以点赞
    if (BooleanUtil.isFalse(isMember)) {
    
     //包装类自动拆箱时,可能为空
        // 3.1数据库点赞数+1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        // 3.2保存用户到Redis的Set集合
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForSet().add(key, userId.toString());
        }
    } else {
    
    
        // 4.如果已点赞,取消点赞
        // 4.1数据库点赞数-1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        // 4.2把用户从Redis的set集合中移除
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}

说明:

​ 修改MySQL数据库点赞总数,查询Redis数据库的点赞用户

步骤三:修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段

  • 修改BlogServiceImpl类的queryBlogById方法,封装queryBlogUser方法
@Override
public Result queryBlogById(Long id) {
    
    
    // 1.查询blog
    Blog blog = getById(id);
    if (blog == null) {
    
    
        return Result.fail("笔记不存在!");
    }
    // 2.查询blog有关的用户
    queryBlogUser(blog);
    // 3.查询blog是否被点赞
    queryBlogLiked(blog);
    return Result.ok(blog);
}

// 查询Redis中,该商铺,该用户是否点赞
private void queryBlogLiked(Blog blog) {
    
    
    UserDTO user = UserHolder.getUser();
    if (ObjectUtil.isNull(user)) {
    
    
        // 用户未登录的情况,不做查询
        return;
    }
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断当前登录用户是否已经点赞
    String key = BLOG_LIKED_KEY + blog.getId(); //以商铺的Key作为键
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(ObjectUtil.isNotNull(score));
}


/**
     * 根据博客查询用户
     *
     * @param blog 商铺
     */
private void queryBlogUser(Blog blog) {
    
    
    Long userId = blog.getUserId();
    User user = userService.getById(userId);
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

说明:

​ 查询商铺内博客的详细信息,查询博客的内容,以及该博客是由哪个用户发布和登录用户为博客的点赞信息

步骤四:修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段

@Override
public Result queryHotBlog(Integer current) {
    
    
    // 根据用户查询
    Page<Blog> page = query()
        .orderByDesc("liked")
        .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
    // 获取当前页数据
    List<Blog> records = page.getRecords();
    // 查询用户
    records.forEach(blog -> {
    
    
        queryBlogUser(blog);
        queryBlogLiked(blog); //每个商铺都需要查询该用户是否已点赞
    });
    return Result.ok(records);
}

说明:

​ 遍历所有商铺,查询该用户为哪些商铺点赞,并将该用户所点赞的所有商铺设置为True

5.4点赞排行榜

说明:

image-20230629183444535

  通过SortedSet集合记录用户点赞情况,并且为点赞排行做铺垫。而List、Set在点赞排行上各有缺点

步骤一:替换点赞的Redis命令集为SortedSet

1.修改BlogServiceImpl类的likeBlog方法

@Override
public Result likeBlog(Long id) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();

    String key = BLOG_LIKED_KEY + id; //以商铺的Key作为键
    // 2.判断当前登录用户是否已经点赞
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    // 3.如果未点赞,可以点赞
    if (ObjectUtil.isNull(score)) {
    
     //包装类自动拆箱时,可能为空
        // 3.1数据库点赞数+1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        // 3.2保存用户到Redis的Set集合
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
        }
    } else {
    
    
        // 4.如果已点赞,取消点赞
        // 4.1数据库点赞数-1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        // 4.2把用户从Redis的set集合中移除
        if (isSuccess) {
    
    
            stringRedisTemplate.opsForZSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}

2.修改BlogServiceImpl类的queryBlogLiked方法

// 查询Redis中,该商铺,该用户是否点赞
private void queryBlogLiked(Blog blog) {
    
    
    UserDTO user = UserHolder.getUser();
    if (ObjectUtil.isNull(user)) {
    
    
        // 用户未登录的情况,不做查询
        return;
    }
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断当前登录用户是否已经点赞
    String key = BLOG_LIKED_KEY + blog.getId(); //以商铺的Key作为键
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(ObjectUtil.isNotNull(score));
}

步骤二:实现点赞排行榜查询功能

  • 添加BlogServiceImpl类中queryBlogLikes方法
@Override
public Result queryBlogLikes(Long id) {
    
    
    // 1.查询Top5的点赞用户
    String key = BLOG_LIKED_KEY + id;
    Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
    // 2.解析出用户的Id
    if (ObjectUtil.isEmpty(top5) || ObjectUtil.isNull(top5)) {
    
    
        return Result.ok(Collections.emptyList()); // 返回一个空集合
    }
    List<Long> ids = top5.stream().map(Long::valueOf).toList();

    String idStr = StrUtil.join(",", ids);
    // 3.根据Id查询用户信息 WHERE id in (5,1) ORDER BY FIELD(id,5,1)
    // 因为关系型数据库SQL语句查询in的特性,默认根据ID升序来排序,会改变用户排行榜的顺序,因此需要通过ORDER BY FIELD来进行自定义排序
    List<UserDTO> userList = userService.query()
        .in("id", ids)
        .last("ORDER BY FIELD(id," + idStr + ")") //拼接ID
        .list()
        .stream()
        .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
        .toList();
    return Result.ok(userList);
}

6.好友关注

笔记小结:

  1. Redis功能点
    • 在关注和取关功能中,使用了Redis的Set命令集,通过addremove方法,对登录用户关注其余博主实现了关注和取关的功能
    • 在共同关注功能中,使用了Redis的Set命令集,通过intersect方法对二者集合求交集处理实现了共同关注的功能
    • 在关注推送功能中,使用Redis中的SortedSet命令,通过add方法将博主发布的博客添加到粉丝的收件箱中(实现的为Feed流的推模式)。通过reverseRangeByScoreWithScores方法进行滚动分页查询实现了分页滚动的查询功能
  2. 功能实现疑难点:
    • MyBatis-plus中使用removelast进行SQL语句添加使用
    • Stream流中,联合MyBatis-plus类型转换的map()方法联合使用
    • Hutool工具中,拼接字符串StrUtil.join通过完成数据库的SQLORDER BY FIELD实现多条数据的有序查询、实体类拷贝BeanUtil.copyProperties完成DTO的拷贝
    • 关注推送功能中,Feed流推模式的动态查询,通过记录最小时间,与跳过元素次数对简单的算法进行实现。查询博客后的基本信息,以及点赞信息的细节实现

6.1关注和取关

6.1.1概述

image-20230701092307957

说明:

​ 实现关注和取消关注功能

6.1.2基本用例

步骤一:实现关注功能

  • 新增FollowServiceImpl类的follow方法
@Override
public Result follow(Long followUserId, Boolean isFollow) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断是关注还是取消关注
    if (BooleanUtil.isTrue(isFollow)) {
    
    
        // 关注
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        save(follow);
    } else {
    
    
        // 取消关注,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
        remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
    }
    return Result.ok();
}

步骤二:实现查询关注功能

  • 新增FollowServiceImpl类的isFollow方法
@Override
public Result isFollow(Long followUserId) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.查询是否关注
    Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
    // 3.判断
    return Result.ok(count > 0);
}

6.2共同关注

6.2.1概述

image-20230701092407919

说明:

实现关注和取消关注功能

6.2.2基本用例

步骤一:修改关注逻辑

  • 修改FollowServiceImpl类的follow方法,添加Redis中关注记录
@Override
public Result follow(Long followUserId, Boolean isFollow) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断是关注还是取消关注
    if (BooleanUtil.isTrue(isFollow)) {
    
    
        // 关注
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        if (isSuccess) {
    
    
            // 当前登录用户记录被关注者的ID
            String key = "follows:" + userId;
            stringRedisTemplate.opsForSet().add(key, followUserId.toString());
        }
    } else {
    
    
        // 取消关注,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
        boolean isSuccess = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
        if (isSuccess) {
    
    
            // 把关注用户的ID从Redis中移除
            String key = "follows:" + userId;
            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
        }
    }
    return Result.ok();
}

步骤二:实现共同关注逻辑

  • 添加FollowServiceImpl类的followCommon方法
@Override
public Result followCommon(Long followUserId) {
    
    
    // 1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    String userKey = "follows:" + userId;
    String followKey = "follows:" + followUserId;
    // 2.查询登录用户与该博客账号共同关注名单
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(userKey, followKey);
    // 3.解析Id集合
    if (ObjectUtil.isNull(intersect) || ObjectUtil.isEmpty(intersect)) {
    
    
        return Result.ok(Collections.emptyList());
    }
    List<Long> ids = intersect.stream().map(Long::valueOf).toList();
    // 4.查询用户
    List<UserDTO> userIds = userService.query().in("id", ids).list()
        .stream()
        .map(user -> BeanUtil.copyProperties(user, UserDTO.class)).toList();
    return Result.ok(userIds);
}

说明:

​ 主要是利用Redis中Set集合的intersect方法,寻找共同值

6.3关注推送

6.3.1概述

image-20230701092500665

说明:

​ 关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息

6.3.2Feed流

Feed流产品有两种常见模式:

  • Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
    • 优点:信息全面,不会有缺失。并且实现也相对简单
    • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
  • 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
    • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
    • 缺点:如果算法不精准,可能起到反作用
  • 本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
    • 拉模式
    • 推模式
    • 推拉结合

拉模式:也叫做读扩散

image-20230630072650029

说明:

​ 当博主发布博客时,会将博客存储在自己的发件箱内。粉丝在自己的收件箱内拉取发件箱的内容,内容自动排序

推模式:也叫做写扩散

image-20230630072901471

说明:

​ 当博主发布博客时,此时博主没有收件箱,直接推送消息到每个粉丝用户。每个粉丝用户从自己的收件箱中获取博客消息

推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点

image-20230630073034751

说明:

​ 博主发送博客,如果是大V博主,则有自己的发件箱,普通粉丝读取发件箱里的内的内容,活跃粉丝则直接将发件箱内的博客收入自己的收件箱。如果是普通博主,则直接推送博客到粉丝的收件箱中

Feed流实现方案

image-20230630073340946

6.3.3基本用例

步骤一:修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱

  • 新增BlogServiceImpl类的saveBlog方法
@Override
public Result saveBlog(Blog blog) {
    
    
    // 1.获取登录用户
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    // 2.保存探店博文
    boolean isSuccess = save(blog);
    if (!isSuccess) {
    
    
        return Result.fail("新增笔记失败");
    }
    // 3. 查询笔记作者的所有粉丝
    List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
    // 4. 推送笔记Id到所有粉丝
    for (Follow follow : follows) {
    
    
        // 4.1获取粉丝Id
        Long userId = follow.getUserId();
        // 4.2推送到粉丝收件箱
        String key = "feed:" + userId;
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    // 5.返回id
    return Result.ok(blog.getId());
}

步骤二:实现分页查询

image-20230630091507562

说明:

​ 使用滚动分页的形式,实现Feed流的滚动分页查询

  • 修改BlogServiceImpl类的queryBlogOfFollow方法
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    
    
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
    String key = FEED_KEY + userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
        .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
    // 3.非空判断
    if (typedTuples == null || typedTuples.isEmpty()) {
    
    
        return Result.ok(); // 如果关注列表没有数据,则返回
    }
    // 4.解析数据:blogId、minTime(时间戳)、offset(集合中分数值等于最新时间的元素个数),拿到最新时间戳粉丝和
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0;
    int os = 1; //定义需要跳过的元素个数
    for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
    
    
        // 4.1.获取id
        System.out.println("value:::" + tuple.getValue());
        ids.add(Long.valueOf(tuple.getValue())); // 收集一下Blog集合
        // 4.2.获取分数(时间戳)
        long time = tuple.getScore().longValue(); //记录一下最新时间戳分数
        System.out.println("value:::" + tuple.getScore());
        if (time == minTime) {
    
     // 如果 最新时间戳跟上一个元素时间戳相同则增加跳过元素次数,避免重复查询
            os++;
        } else {
    
    
            minTime = time;
            os = 1; // 重置跳过元素的个数
        }
    }
    // 5.根据id查询blog
    String idStr = StrUtil.join(",", ids);
    List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); // 因为In语句不能保证查询出的ID
    for (Blog blog : blogs) {
    
    
        // 5.1.查询blog有关的用户
        queryBlogUser(blog); // 设置一下博客的基本信息
        // 5.2.查询blog是否被点赞
        queryBlogLiked(blog); // 该博客是否点赞
    }
    // 6. 封装并返回
    ScrollResult r = new ScrollResult();
    r.setList(blogs);
    r.setOffset(os); // 将本次查询跳过次数进行封装返回,避免下一次结果的重复查询
    r.setMinTime(minTime);
    return Result.ok(r);
}

补充:

ZREVRANGEBYSCORE方法命令是返回有序集中指定分数区间内的所有的成员

7.附近的商户

笔记小结:

  1. Redis功能点
    • 在关注和取关功能中,使用了Redis的Set命令集,通过addremove方法,对登录用户关注其余博主实现了关注和取关的功能
    • 在共同关注功能中,使用了Redis的Set命令集,通过intersect方法对二者集合求交集处理实现了共同关注的功能
    • 在关注推送功能中,使用Redis中的SortedSet命令,通过add方法将博主发布的博客添加到粉丝的收件箱中(实现的为Feed流的推模式)。通过reverseRangeByScoreWithScores方法进行滚动分页查询实现了分页滚动的查询功能
  2. 功能实现疑难点:
    • MyBatis-plus中使用removelast进行SQL语句添加使用
    • Stream流中,联合MyBatis-plus类型转换的map()方法联合使用
    • Hutool工具中,拼接字符串StrUtil.join通过完成数据库的SQLORDER BY FIELD实现多条数据的有序查询、实体类拷贝BeanUtil.copyProperties完成DTO的拷贝
    • 关注推送功能中,Feed流推模式的动态查询,通过记录最小时间,与跳过元素次数对简单的算法进行实现。查询博客后的基本信息,以及点赞信息的细节实现

7.1概述

image-20230630171653042

说明:

​ 每个商铺都有类型进行分类

image-20230630173449822

说明:

​ 我们在Redis中,可以利用商铺的类型作为一组ID作为Redis的键,每个商铺的ID作为值,每个商铺的地理位置作为分数

7.2基本用例

补充:

image-20230701170333393

步骤一:导入店铺的信息到GEO

  • 新建test方法中的loadShopData方法
/**
     * 导入店铺信息到GEO
     */
@Test
public void loadShopData() {
    
    
    // 1.查询商铺信息
    List<Shop> shopList = shopService.list();
    // 2.根据商铺typeId分组
    Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
    // 3.分批完成Redis的写入
    for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
    
    
        // 3.1获取商铺类型id
        Long typeId = entry.getKey();
        String key = SHOP_GEO_KEY + typeId;
        // 3.2获取同类型的商铺集合
        List<Shop> value = entry.getValue();
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
        // 3.3写入redis GEOADD key 经度 纬度 member
        for (Shop shop : value) {
    
    
            RedisGeoCommands.GeoLocation<String> e = new RedisGeoCommands.GeoLocation<>(
                shop.getId().toString(),
                new Point(shop.getX(), shop.getY())
            );
            locations.add(e);
        }
        stringRedisTemplate.opsForGeo().add(key, locations);
    }
}

说明:添加成功

image-20230701105323078

步骤二:实现通过商铺类型查询商铺

新建ShopServiceImpl方法中的ShopServiceImpl方法

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    
    
    // 1.判断是否通过距离进行查询
    if (ObjectUtil.isNull(x) || ObjectUtil.isNull(y)) {
    
    
        // 根据类型分页查询
        Page<Shop> page = query()
            .eq("type_id", typeId)
            .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        // 返回数据
        return Result.ok(page.getRecords());
    }
    // 2.计算分页查询
    String key = SHOP_GEO_KEY + typeId;
    Integer from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
    Integer end = current * SystemConstants.DEFAULT_PAGE_SIZE;
    // 3.查询redis,按照距离排序、分页。结果:shopId、distance
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() //GEOSEARSH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
        .search(key,
                GeoReference.fromCoordinate(x, y),
                new Distance(5000), // 指定距离范围
                RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance() //返回结果带上距离
                .limit(end) //limit方法,指定从 0 到 end
               );
    // 4.解析出ID
    if (ObjectUtil.isNull(results) || ObjectUtil.isEmpty(results)) {
    
    
        return Result.ok(Collections.emptyList()); //无最新数据
    }

    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
    if (list.size() <= from) {
    
    
        return Result.ok(Collections.emptyList()); //无最新数据
    }
    // 4.1截取from ~ end的部分
    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> skipList = list.stream().skip(from).toList();
    List<Long> shopIds = new ArrayList<>(skipList.size());
    Map<Long, Distance> shopDistances = new HashMap<>(skipList.size());
    for (GeoResult<RedisGeoCommands.GeoLocation<String>> geoLocationGeoResult : skipList) {
    
    
        // 4.2 获取店铺Id
        Long shopId = Long.valueOf(geoLocationGeoResult.getContent().getName());
        shopIds.add(shopId);
        // 4.3 获取距离
        Distance distance = geoLocationGeoResult.getDistance();
        shopDistances.put(shopId, distance);
    }

    // 5.根据ID查询shop
    String idStr = StrUtil.join(",", shopIds);
    List<Shop> shopList = query().in("id", shopIds).last("ORDER BY FILED(id," + idStr + ")").list();
    for (Shop shop : shopList) {
    
    
        Distance distance = shopDistances.get(shop.getId());
        shop.setDistance(distance.getValue());
    }
    // 6.返回
    return Result.ok(shopList);
}

8.用户签到

笔记小结:

  1. Redis功能点:

    • 在签到能中,使用Redis中的Bitmap命令,通过setBit方法设置当天用户是否签到(记住offset方法参数从0开始)

    • 在签到统计功能中,使用Redis中的Bitmap命令,通过bitField方法,获取数组中指定范围的签到十进制数

  2. 功能实现疑难点:

    • 在实现签到统计功能中,使用了&运算符,通过个位数与运算结果是否为0判断是否签到,并进行位移,记录了连续签到数,实现了算法的小实现

8.1签到功能

image-20230701152613608

image-20230701152959915

说明:

​ bitMap这种位图为是实现签到思路的好法

image-20230701153108419

说明:

​ 通过操作bitMap即可实现签到的次数统计

8.1.1概述

8.1.2基本用例

  • 添加UserServiceImpl类中的UserServiceImpl方法
/**
     * 登录方法
     *
     * @return 成功
     */
@Override
public Result sign() {
    
    
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.获取日期
    LocalDateTime now = LocalDateTime.now();
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    // 3.拼接Key
    String key = USER_SIGN_KEY + userId + keySuffix;
    // 4.获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    // 5.写入Redis SETBIT key offset 1
    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    return Result.ok();
}

说明:

​ 在StringRedisTemplate中,对bitMap的命令放在opsForValue方法中

8.2签到统计功能

8.2.1概述

image-20230701160020416

image-20230701160027488

8.2.2基本用例

  • 添加UserServiceImpl类中的signCount方法
/**
     * 统计最近一次连续签到天数
     *
     * @return 签到天数
     */
@Override
public Result signCount() {
    
    
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.获取日期
    LocalDateTime now = LocalDateTime.now();
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    // 3.拼接Key
    String key = USER_SIGN_KEY + userId + keySuffix;
    // 4.获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    // 5.获取本月截至今天为止的所有签到记录,并返回一个十进制数 BITFIELD sign:5:202303 GET u14 0
    List<Long> result = stringRedisTemplate.opsForValue().bitField(key,
                                                                   BitFieldSubCommands.create().
                                                                   get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)) //指定从第几天结束
                                                                   .valueAt(0) // 指定从第几天开始
                                                                  );

    if (ObjectUtil.isNull(result) || ObjectUtil.isEmpty(result)) {
    
    
        // 没有任何签到结果
        return Result.ok(0);
    }
    Long num = result.get(0);
    if (ObjectUtil.isNull(num) || num == 0) {
    
    
        // 没有任何签到结果
        return Result.ok(0);
    }
    // 6.遍历循环
    int count = 0;
    while (true) {
    
    
        // 6.1让这个数字与1做与运算,得到数字的最后一个bit位。判断这个bit位是否位零
        if ((num & 1) == 0) {
    
    
            // 为0,则表示未签到
            break;
        } else {
    
    
            //为1,则表示已签到
            count++;
        }
        // 继续位移,判断下一位
        num >>>= 1; // num = num >> 1
    }
    return Result.ok(count);
}

9.UV统计

笔记小结:

  • Redis功能点:

    ​ 在UA统计的功能中,使用Redis中的HyperLogLog命令,通过add方法将用户添加至Redis中,实现了用户访问量的统计

9.1概述

​ UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
​ PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

​ UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。

9.2基本用例

  • 新建测试方法
@Test
public void testHyperLogLog() {
    
    
    String key = "user:ua:2023:7";
    // 准备一个空数组
    String[] users = new String[1000];
    int index = 0;
    for (int i = 1; i <= 1000000; i++) {
    
    
        users[index++] = "user_" + i;
        if (i % 1000 == 0) {
    
    
            index = 0;
            // 发送到redis
            stringRedisTemplate.opsForHyperLogLog().add(key, users);
        }
    }
    // 统计数量
    Long count = stringRedisTemplate.opsForHyperLogLog().size(key);
    System.out.println(count);
}

知识加油站

1.IDEA的常用快捷键

Idea快捷键Ctrl+alt+u 可以出现类的关系依赖图

idea快捷键Ctrl+shift+u将选中内容大小写

2.Collectors工具类

(55条消息) 【Java 8系列】收集器Collector与工具类Collectors_collector 处理_善良勤劳勇敢而又聪明的老杨的博客-CSDN博客

3.Executor框架

(53条消息) Java并发——Executor框架详解(Executor框架结构与框架成员)_tongdanping的博客-CSDN博客

日志

添加knife4j在线API文档

步骤一:添加依赖

<!--knife4j-->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>

步骤二:添加配置文件

/**
 * knife4j配置信息
 */
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfig {
    
    

    @Bean
    public Docket defaultApi() {
    
    
        return new Docket(DocumentationType.SWAGGER_2)
                .groupName("黑马点评管理系统后台接口文档")
                .apiInfo(defaultApiInfo())
                .select()
                .apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo defaultApiInfo() {
    
    
        return new ApiInfoBuilder()
                .title("管理系统后台接口文档")
                .description("管理系统后台接口文档")
                .contact(new Contact("开发组", "", ""))
                .version("1.0")
                .build();
    }
}

步骤三:修改Yml配置

spring:
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

步骤四:修改拦截器规则

@Override
public void addInterceptors(InterceptorRegistry registry) {
    
    
    registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
        "/shop/**",
        "/voucher/**",
        "/shop-type/**",
        "/upload/**",
        "/blog/hot",
        "/user/code",
        "/user/login",
        "/doc.html/**", // 需要放行此类文件需要的路径请求
        "/swagger-resources/**",
        "/v2/**"
    ).order(1);
    registry.addInterceptor(getRefreshTokenInterceptor()).addPathPatterns("/**").order(0);

}

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    
      // 处理一下,静态访问的路径
    registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
    registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}

猜你喜欢

转载自blog.csdn.net/D_boj/article/details/131333229