【Redis实战篇】黑马点评学习笔记(16万字超详细、Redis实战项目学习必看、欢迎点赞⭐收藏)

Hiヽ(゜▽゜ )-欢迎来到蓝染Aizen的CSDN博客~
🔥 博客主页: 【✨蓝染 の Blog😘
💖感谢大家点赞👍 收藏⭐ 评论✍
白菜欢迎

博客start

文章目录


一、项目知识点介绍

本项目包含了Redis各种应用技巧和解决方案,具体包括:

  • 短信登录:使用Redis共享session来实现。
  • 商户查询缓存:企业的缓存使用技巧、缓存击穿,缓存穿透,缓存雪崩等问题解决。
  • 优惠卷秒杀:Redis的计数器功能、 结合Lua脚本完成高性能的Redis操作、Redis分布式锁、Redis的三种消息队列。
  • 好友关注:基于Set集合的关注、取消关注,共同关注、消息推送等功能。
  • 达人探店:基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞排行榜功能。
  • 附近的商户:利用Redis的GEOHash来完成对于地理坐标的操作。
  • 用户签到:使用Redis的BitMap数据统计功能。
  • UV统计:使用Redis的HyperLogLog来完成统计功能。

✅ 视频地址:[实战篇] 黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目

💡 Redis基础篇在这里👉【Redis基础篇】超详细♥Redis安装教程、5种常用数据结构和常见命令、Jedis和SpringDataRedis的使用

📂 Redis实战篇官方代码资料

📚 本文代码gitee仓库地址




二、短信登录

1、导入黑马点评项目

(1)导入SQL

首先,创建名为hmdp数据库,执行hmdp.sql脚本:

// 此处省略sql文件和导入步骤

注意:MySQL的版本采用5.7及以上,最好是8.0版本

(2)项目架构模型

Nginx服务器作为静态资源服务器,部署前端项目,经过Nginx负载均衡分流到下游tomcat服务器。在高并发场景下,会选择使用MySQL集群,同时为了进一步降低MySQL的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。

(3)导入后端项目

后端项目代码导入idea后,修改yaml配置文件中mysql和redis相关配置信息。

启动后端项目后,在浏览器访问:http://localhost:8081/shop-type/list,如果可以看到app界面,则运行成功。

(4)导入前端项目

前端项目代码已经放在了nginx-1.18.0/html 下,在nginx所在目录下打开一个CMD窗口,输入start nginx.exe即可启动前端项目。

启动前端项目后,在浏览器访问:http://localhost:8080,如果可以看到app界面,则运行成功。




2、基于Session实现登录流程


发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号。

如果手机号合法,后台此时生成对应的验证码,同时将验证码保存到session中,然后再通过短信的方式将验证码发送给用户。


短信验证码登录、注册:

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验;如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,创建session时tomcat会自动生成JsessionId写到用户浏览器的Cookie里,方便后续获得当前登录信息。

校验登录状态:

用户在请求时候,会从cookie中携带着JsessionId到后台,后台通过JsessionId从session中拿到用户信息,也就是说登录凭证就是JsessionId。如果没有session信息,则进行拦截;如果有session信息,则将用户信息保存到threadLocal中,并且放行。

ThreadLocal是一个线程域对象,在我们的业务中,每一个请求到达微服务都是一个独立的线程,如果没有用ThreadLocal,直接把用户保存到一个本地变量,就有可能出现多线程环境下的并发修改安全问题。而将用户保存在ThreadLocal中,它会在每一个线程的内部创建一个Map将数据保存,这样每一个线程都有自己独立的存储空间,与其他请求线程相互之间没有干扰,实现了线程隔离的效果。




3、实现阿里云发送短信验证码功能

  • 发送手机验证码页面流程

  • 发送验证码
/**
 * 发送手机验证码
 */
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    // 发送短信验证码并保存验证码
    return userService.sendCode(phone, session);
}

/**
 * 发送手机验证码
 */
@Override
public Result sendCode(String phone, HttpSession session) {
    // 校验手机号格式是否有效
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 格式不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 格式有效,生成验证码
    String code = RandomUtil.randomNumbers(6);  // 随机6位数字
    // 保存验证码到session
    session.setAttribute(phone, code);
    // 模拟发送短信验证码
    log.debug("向{}发送短信验证码成功,验证码:{}", phone, code);
    // 返回ok
    return Result.ok();
}

由于这里是日志输出模拟发送验证码,如果我们真的想要给手机发送验证码,需要去开通阿里云短信服务,申请签名和短信模板,阿里云SMS短信服务官方帮助文档:https://help.aliyun.com/zh/sms/

去官网申请和配置好AK密钥对后,就可以使用下面的工具类发送阿里云短信啦~

  • 导入SMS依赖坐标
<!-- 阿里云短信SMS -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>dysmsapi20170525</artifactId>
    <version>3.0.0</version>
</dependency>
  • 阿里云短信验证码发送工具类SMSUtils(支持双参数短信模板)
/**
 * 阿里云短信验证码发送工具类
 */
public class SMSUtils {

    /**
     * 发送短信(双参数模板)
     * <phoneNumbers>: 接收短信的手机号码,多个用英文逗号隔开
     * <signNameJson>: 短信签名名称,eg: "阿里云"
     * <templateCode>: 短信模板CODE
     * <templateParamJson>: 短信模板变量对应的实际值,eg:{"code":"1234"}
     *
     * @param signName 签名
     * @param templateCode 模板
     * @param phoneNumbers 手机号
     * @param code 验证码
     * @param time 验证码有效期(单位:秒)
     */
    public static void sendMessage(String signName, String templateCode, String phoneNumbers, String code, int time) {
        // 初始化请求客户端
        Client client = null;
        try {
            client = SMSUtils.createClient();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 构造请求对象,请填入请求参数值
        SendSmsRequest sendSmsRequest = new SendSmsRequest()
                .setPhoneNumbers(phoneNumbers)
                .setSignName(signName)
                .setTemplateCode(templateCode)
                .setTemplateParam("{\"code\":\"" + code + "\",\"time\":\"" + time + "\"}");

        // 获取响应对象
        SendSmsResponse sendSmsResponse = null;
        try {
            sendSmsResponse = client.sendSms(sendSmsRequest);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 响应包含服务端响应的 body 和 headers
        System.out.println(toJSONString(sendSmsResponse));
        //System.out.println(sendSmsResponse.getBody());
    }

    private static Client createClient() throws Exception {
        Config config = new Config()
                // 配置 AccessKey ID,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID。
                .setAccessKeyId(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"))
                // 配置 AccessKey Secret,请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_SECRET。
                .setAccessKeySecret(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"));

        // 配置 Endpoint
        config.endpoint = "dysmsapi.aliyuncs.com";
        return new Client(config);
    }

    /**
     * 发送短信(单参数模板)
     * <phoneNumbers>: 接收短信的手机号码,多个用英文逗号隔开
     * <signNameJson>: 短信签名名称,eg: "阿里云"
     * <templateCode>: 短信模板CODE
     * <templateParamJson>: 短信模板变量对应的实际值,eg:{"code":"1234"}
     *
     * @param signName 签名
     * @param templateCode 模板
     * @param phoneNumbers 手机号
     * @param code 验证码
     */
    public static void sendMessage(String signName, String templateCode, String phoneNumbers, String code) {
        // 初始化请求客户端
        Client client = null;
        try {
            client = SMSUtils.createClient();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 构造请求对象,请填入请求参数值
        SendSmsRequest sendSmsRequest = new SendSmsRequest()
                .setPhoneNumbers(phoneNumbers)
                .setSignName(signName)
                .setTemplateCode(templateCode)
                .setTemplateParam("{\"code\":\"" + code + "\"}");

        // 获取响应对象
        SendSmsResponse sendSmsResponse = null;
        try {
            sendSmsResponse = client.sendSms(sendSmsRequest);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 响应包含服务端响应的 body 和 headers
        System.out.println(toJSONString(sendSmsResponse));
    }
}
  • 调用工具类发送短信
// 发送验证码,有效期LOGIN_CODE_TTL分钟
SMSUtils.sendMessage("签名signName", "SMS_474225xxx", phone, code, LOGIN_CODE_TTL);
log.debug("向{}发送短信验证码成功,验证码:{}", phone, code);
  • 登录页面流程

  • 登录、注册
/**
 * 登录功能
 */
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
    // 实现登录功能
    return userService.login(loginForm, session);
}

/**
 * 登录、注册
 * @param loginForm 验证码登录:手机号、验证码;密码登录:手机号、密码
 * @param session session中获取验证码,保存用户到session
 * @return
 */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 格式不符合,返回错误信息
        Result.fail("手机号格式错误!");
    }
    // 校验验证码
    Object cacheCode = session.getAttribute(phone);
    String code = loginForm.getCode();
    // 如果没有发送验证码,或者session中验证码过期、验证码不一致
    if (cacheCode == null || !cacheCode.toString().equals(code)) {
        // 不一致报错
        return Result.fail("验证码错误");
    }
    // 一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();
    // 判断用户是否存在
    if (user == null) {
        // 不存在,创建新用户保存并返回
        user = createUserWithPhone(phone);
    }
    // 保存用户到session中
    session.setAttribute("user", BeanUtil.copyProperties(user, UserVo.class));
    return Result.ok();
}

/**
 * 根据手机号创建用户
 * @param phone 手机号
 * @return 用户
 */
private User createUserWithPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    save(user);
    return user;
}



4、实现登录拦截和登录验证功能

  • Tomcat运行原理

当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket像互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应。

我们可以得知:每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用ThreadLocal来做到线程隔离,每个线程操作自己的一份数据。

当随着业务的开发,越来越多的业务都需要去校验用户的登录,我们应该考虑把用户登录验证功能的逻辑抽取到一个地方,就是SpringMVC的拦截器,它可以在请求所有Controller之前去做,用户的所有请求必须先经过拦截器,再由拦截器判断是否放行到对应的Controller。

第二个问题是,拦截器帮我们完成了对用户的校验,拿到了用户信息,那对应的Controller如何拿到用户信息呢?因此我们应该设计一个方案,将拦截器里得到的用户信息传递到Controller,在传递过程中需要保证线程安全问题。这个方案就是将用户信息保存到ThreadLocal中。

  • 关于ThreadLocal

ThreadLocal是一个线程域对象,每一个进入Tomcat的请求都是一个独立的线程,ThreadLocal会在当前用户线程内开辟一块独立的内存空间,保存信息到对应的ThreadLocalMap,保证每个线程互相不干扰。在ThreadLocal的源码中,无论是它的put方法还是get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离。

  • 存入ThreadLocal中的主要原因
    • (1)避免后续业务操作频繁向session域中存取数据,减少session的访问开销。
    • (2)避免线程安全问题。

  • 登录验证功能页面请求

  • 使用UserVo返回给前端,仅提供前端视图需要展示的属性,隐藏用户敏感信息
/**
 * 用户Vo
 */
@Data
public class UserVo {
    // 用户id
    private Long id;
    // 昵称
    private String nickName;
    // 头像
    private String icon;
}
  • ThreadLocal工具类
/**
 * ThreadLocal工具类
 */
public class BaseContext {
    /** ThreadLocal对象 */
    private static final ThreadLocal threadLocal = new ThreadLocal();

    /**
     * 根据键获取值
     */
    public static <T> T get() {
        return (T) threadLocal.get();
    }

    /**
     * 存储键值对
     */
    public static <T> void set(T value) {
        threadLocal.set(value);
    }


    /**
     * 清除ThreadLocal,销毁线程,防止内存泄漏
     */
    public static void remove() {
        threadLocal.remove();
    }
}
  • 拦截器代码
/**
 * 登录拦截器:需要实现 HandlerInterceptor 接口
 */
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    /**
     * 前置拦截,在Controller执行之前
     * 做登录验证校验
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获取session
        HttpSession session = request.getSession();
        // 获取session中的用户
        Object user = session.getAttribute("user");
        // 判断用户是否存在
        if (user == null) {
            // 不存在,拦截,响应401状态码:未授权
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            return false;
        }
        // 存在,保存用户信息到ThreadLocal中
        BaseContext.set((UserVo) user);
        log.debug("拦截器BaseContext.UserVo = {}", (UserVo) BaseContext.get());
        // 放行
        return true;
    }

    /**
     * 后置拦截,在Controller执行之后
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

    /**
     * 在视图渲染之后,返回给用户之前
     * 业务执行完后,销毁用户信息,避免内存泄露
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户信息,销毁当前线程,防止内存泄露
        BaseContext.remove();
    }
}
  • 在SpringMVC配置类中注册拦截器,让拦截器生效
/**
 * SpringMVC配置类:需要实现 WebMvcConfigurer 接口
 */
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    /**
     * 向拦截器注册器中添加拦截器,使拦截器生效
     * @param registry 拦截器注册器
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 本项目是前后端分离的,静态资源在nginx前端服务器中,因此访问静态资源的时候不会走到后端拦截器,所以不用放行前端静态资源
        registry.addInterceptor(new LoginInterceptor())
                // 排除不需要拦截的请求路径
                .excludePathPatterns(
                    "/user/code",
                    "/user/login",
                    "/blog/hot",
                    "/shop/**",
                    "/shop-type/**",
                    "/voucher/**",
                    "/upload/**"
                );
    }
}
  • 实现登录验证功能
/**
 * 校验登录状态
 */
@GetMapping("/me")
public Result me(){
    // 获取当前登录的用户并返回
    UserVo userVo = BaseContext.get();
    return Result.ok(userVo);
}
  • 实现登出功能
/**
 * 登出功能
 */
@PostMapping("/logout")
public Result logout(HttpSession session){
    // 销毁ThreadLocal中的用户信息
    BaseContext.remove();
    // 销毁session
    session.invalidate();
    return Result.ok();
}



5、集群的Session共享问题

Session共享问题:当搭建多节点Tomcat服务器集群时,多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。

核心问题分析

每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时整个登录拦截功能就会出现问题。

我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的tomcat服务器的session,这样的话,就可以实现session的共享了。

但是这种方案具有两个大问题:

  1. 每台服务器中都有完整的一份session数据,服务器压力过大,冗余备份问题
  2. session拷贝数据时,可能会出现延迟,数据一致性存在问题

此时适合解决这个场景的方案需要满足三点:

  1. 数据共享
  2. 高并发下读写迅速
  3. 存储结构为key-value

由于redis是基于内存存储,性能非常强,读写延迟基本在微秒级别。因此我们将session换成redis存储数据,redis脱离了tomcat存储数据,支持多台tomcat服务器共享数据,也就避免了session共享的问题。为了保证redis数据不丢失,后期还可以考虑搭建redis集群。




6、基于Redis实现共享Session登录流程梳理

(1)设计存储的数据结构

首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String 或者hash ,如下图。

  • String结构与Hash结构存储值的比较
    • String的值是将Java对象序列化为JSON字符串的形式保存,更加直观,操作也更加简单,但是 JSON 结构会有很多非必须的内存开销,比如双引号、大括号,内存占用比Hash更高。
    • Hash的值是以hash表的形式保存,通过field单独存储对象中每个属性的值,对单个字段增删改查更加灵活,hash类型存储空间占用比String类型更小。

这里我们选择使用hash结构来实现存储。


(2)设计Key的细节

关于key的处理,session是每个用户都有自己独立的session,因此key可以写死为code,因为每个session有不同的sessionId来保证唯一性。但是redis的key是共享的,就不能使用硬编码的key了。

因此在设计key的适合,需要满足两点:

  1. key要具有唯一性
  2. key要方便客户端携带,方便从redis中取出这个value

如果我们采用phone手机号作为key来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适。所以我们在后台生成一个随机串token,tomcat并不会像sessionId那样把token自动写到客户端浏览器中,因此我们需要手动的把token返回给客户端,客户端保存token作为登录凭证,之后客户端携带着token来发送请求,后端服务器验证token后就可以基于token从redis中获取数据。


(3)整体访问流程

当注册完成后,用户去登录会去校验用户提交的手机号和验证码是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

前端处理token的代码:

前端发送axios请求,被前端拦截器拦截,在请求头中填充token,经过后端拦截器,从请求头中的通过authorization获取token字符串,从而拿出redis中的用户数据。




7、基于Redis实现短信登录

  • UserServiceImpl
/**
 * 登录、注册
 * @param loginForm 验证码登录:手机号、验证码;密码登录:手机号、密码
 * @return
 */
@Override
public Result login(LoginFormDTO loginForm) {
    // 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 格式不符合,返回错误信息
        Result.fail("手机号格式错误!");
    }
    // 从redis中获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY_PREFIX + phone);
    String code = loginForm.getCode();
    // 如果没有发送验证码或者redis中验证码过期、验证码不一致
    if (cacheCode == null || !cacheCode.equals(code)) {
        // 不一致报错
        return Result.fail("验证码错误");
    }
    // 一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();
    // 判断用户是否存在
    if (user == null) {
        // 不存在,创建新用户保存并返回
        user = createUserWithPhone(phone);
    }
    // 随机生成token,作为登录令牌,相当于JsessionId
    String token = UUID.fastUUID().toString(true);  // fastUUID比randomUUID生成速度更快,toString(true)表示生成的UUID不带-短横线分隔
    // 将UserVo对象转为HashMap存储
    UserVo userVo = BeanUtil.copyProperties(user, UserVo.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userVo, new HashMap<>(),
            CopyOptions.create()
                    // 忽略值为null的数据
                    .setIgnoreNullValue(true)
                    // 参数:字段名和字段值,返回值是修改后的字段值。将字段值转为字符串
                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 保存用户信息到redis中
    String tokenKey = LOGIN_USER_KEY_PREFIX + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 设置用户信息的有效期
    stringRedisTemplate.expire(tokenKey, Duration.ofMinutes(LOGIN_USER_TTL));
    // 返回token
    return Result.ok(token);
}
  • LoginInterceptor
/**
 * 登录拦截器:需要实现 HandlerInterceptor 接口
 */
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;

    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 前置拦截,在Controller执行之前
     * 做登录验证校验
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获取请求头中的token
        String token = request.getHeader("authorization");
        // 判断用户是否是已登录状态
        if (StrUtil.isBlank(token)) {
            // token为空说明未登录,拦截,响应401状态码:未授权
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            return false;
        }
        // 基于token获取redis中的用户信息(若entries的key为空默认返回空Map)
        String tokenKey = LOGIN_USER_KEY_PREFIX + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 判断用户是否存在
        if (userMap.isEmpty()) {
            // 不存在,拦截,响应401状态码:未授权
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            return false;
        }
        // 将查询到的Hash数据转为UserVo对象
        UserVo userVo = BeanUtil.fillBeanWithMap(userMap, new UserVo(), false);
        // 存在,保存用户信息到ThreadLocal中
        BaseContext.set(userVo);
        //log.debug("拦截器BaseContext.UserVo = {}", (UserVo) BaseContext.get());
        // 只要用户在操作发送请求,就刷新token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 放行
        return true;
    }

    /**
     * 在视图渲染之后,返回给用户之前
     * 业务执行完后,销毁用户信息,避免内存泄露
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户信息,销毁当前线程,防止内存泄露
        BaseContext.remove();
    }
}



8、解决登录状态刷新问题

(1)初始方案问题分析

在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的。

(2)优化登录拦截器

单独配置一个拦截器用户刷新Redis中的token:在基于Session实现短信验证码登录时,我们只配置了一个拦截器,这里需要另外再配置一个拦截器专门用户刷新存入Redis中的token。因为我们现在改用Redis了,为了防止用户在操作网站时突然由于Redis中的token过期,导致直接退出网站,严重影响用户体验。

那为什么不把刷新的操作放到一个拦截器中呢?因为之前的那个拦截器只是用来拦截一些需要进行登录校验的请求,对于哪些不需要登录校验的请求是不会走拦截器的,刷新操作显然是要针对所有请求比较合理,所以单独创建一个拦截器拦截一切请求,对于已登录的用户刷新Redis中的token,对于未登录的用户请求,不需要刷新Redis中的token,直接放行交给第二个登录拦截器去做未授权拦截,完成整体刷新功能。

因为第一个拦截器有了ThreadLocal的数据,所以此时第二个拦截器只需要判断ThreadLocal中的用户信息是否存在即可,存在说明已登录放行,不存在说明未登录进行未授权拦截。

  • 刷新token的拦截器
/**
 * 刷新Token拦截器:需要实现 HandlerInterceptor 接口
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 拦截一切用户请求
     * 对于已登录的用户刷新Redis中的token
     * 对于未登录的用户请求,不需要刷新Redis中的token,直接放行交给第二个登录拦截器去做未授权拦截
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获取请求头中的token
        String token = request.getHeader("authorization");
        // 判断用户是否是已登录状态
        if (StrUtil.isBlank(token)) {
            // token不存在,说明当前用户未登录,不需要刷新直接放行
            return true;
        }
        // 基于token获取redis中的用户信息(若entries的key为空默认返回空Map)
        String tokenKey = LOGIN_USER_KEY_PREFIX + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 判断用户是否存在
        if (userMap.isEmpty()) {
            // 用户不存在,说明当前用户未登录,不需要刷新直接放行
            return true;
        }
        // 将查询到的Hash数据转为UserVo对象
        UserVo userVo = BeanUtil.fillBeanWithMap(userMap, new UserVo(), false);
        // 存在,保存用户信息到ThreadLocal中
        BaseContext.set(userVo);
        // 只要用户在操作发送请求,就刷新token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 放行
        return true;
    }

    /**
     * 在视图渲染之后,返回给用户之前
     * 业务执行完后,销毁用户信息,避免内存泄露
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户信息,销毁当前线程,防止内存泄露
        BaseContext.remove();
    }
}
  • 登录拦截器
/**
 * 登录拦截器:需要实现 HandlerInterceptor 接口
 */
public class LoginInterceptor implements HandlerInterceptor {
    /**
     * 前置拦截,在Controller执行之前
     * 做登录验证校验,只需要判断ThreadLocal中的用户信息是否存在即可,存在说明已登录放行,不存在说明未登录进行未授权拦截。
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判断是否需要拦截(ThreadLocal中是否有用户)
        if (BaseContext.get() == null) {
            // 没有用户信息,未登录,设置状态码401
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            // 拦截
            return false;
        }
        // 有用户信息,已登录,放行
        return true;
    }
}
  • 改进登出功能
/**
 * 登出功能
 */
@PostMapping("/logout")
public Result logout(HttpSession session, HttpServletRequest request) {
    // 销毁ThreadLocal中的用户信息
    BaseContext.remove();
    // 销毁session
    session.invalidate();
    // 获取请求头中的token
    String tokenKey = RedisConstants.LOGIN_USER_KEY_PREFIX + request.getHeader("authorization");
    // 判断redis中的tokenKey是否存在
    if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(tokenKey))) {
        // 使redis中的token失效
        stringRedisTemplate.delete(tokenKey);
    }
    return Result.ok();
}



三、商户查询缓存

  • 什么是缓存?

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

缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力。

但是缓存也会增加代码复杂度和运营的成本:


1、添加商户缓存

当我们根据id查询商户信息时,我们是直接操作从数据库中去进行查询的,所以我们需要增加缓存,

代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,存入redis之后再返回。

/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@Override
public Result queryById(Long id) {
    // 从redis中查询店铺数据
    String cacheKey = CACHE_SHOP_KEY_PREFIX + id;
    String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
    // 判断缓存是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        // 缓存命中,直接返回店铺数据
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 缓存未命中,从数据库中查询店铺数据
    Shop shop = getById(id);
    // 判断数据库是否存在店铺数据
    if (shop == null) {
        // 数据库中不存在,返回失败信息
        return Result.fail("店铺不存在");
    }
    // 数据库中存在,写入redis,并返回店铺数据
    stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop));
    return Result.ok(shop);
}

对于店铺详细这类变化较为频繁的数据,我们是直接存入Redis中,后面还会进行优化,设置合适的缓存更新策略,确保Redis和MySQL的数据一致性,以及解决缓存常见的三大问题




2、查询店铺类型缓存

对于店铺类型数据,一般变动会比较小,所以这里我们直接将店铺类型的数据持久化存储到Redis中

  • 使用String类型的key-value结构缓存店铺类型数据

  • String存储写法
/**
 * 查询店铺类型列表
 * @return 店铺类型列表
 */
@Override
public Result queryTypeList() {
    // 从redis中查询店铺类型数据
    String shopTypeJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY);
    List<ShopType> shopTypeList = null;
    // 判断缓存是否命中
    if (StrUtil.isNotBlank(shopTypeJson)) {
        // 缓存命中,直接返回缓存数据
        shopTypeList = JSONUtil.toList(shopTypeJson, ShopType.class);
        return Result.ok(shopTypeList);
    }
    // 缓存未命中,查询数据库
    shopTypeList = this.query().orderByAsc("sort").list();
    // 判断数据库中是否存在该数据
    if (shopTypeList == null) {
        // 数据库中不存在该数据,返回失败信息
        return Result.fail("店铺类型不存在");
    }
    // 数据库中的数据存在,写入Redis,并返回查询的数据
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(shopTypeList), CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
    // 将数据库查到的数据返回
    return Result.ok(shopTypeList);
}
  • 使用List类型缓存店铺类型数据

  • List存储写法
/**
 * 查询店铺类型列表
 * @return 店铺类型列表
 */
@Override
public Result queryTypeList() {
    // 从redis中查询店铺类型数据
    ListOperations<String, String> ops = stringRedisTemplate.opsForList();
    List<ShopType> shopTypeList;
    // 0到-1表示查询List中所有元素
    List<String> shopTypeJsonList = ops.range(CACHE_SHOP_TYPE_KEY, 0, -1);
    // 判断缓存是否命中
    if (CollUtil.isNotEmpty(shopTypeJsonList)) {
        // 缓存命中,直接返回缓存数据
        shopTypeList = shopTypeJsonList.stream()
                // 将 List<String> 转换为 List<ShopType> 返回
                .map((shopTypeJson) -> JSONUtil.toBean(shopTypeJson, ShopType.class))
                .collect(Collectors.toList());
        return Result.ok(shopTypeList);
    }
    // 缓存未命中,查询数据库
    shopTypeList = this.query().orderByAsc("sort").list();
    // 判断数据库中是否存在该数据
    if (shopTypeList == null) {
        // 数据库中不存在该数据,返回失败信息
        return Result.fail("店铺类型不存在");
    }
    // 数据库中的数据存在,写入Redis,并返回查询的数据
    ops.rightPushAll(CACHE_SHOP_TYPE_KEY, shopTypeList.stream().map(JSONUtil::toJsonStr).collect(Collectors.toList()));
    // 设置key的过期时间
    stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY, CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
    // 将数据库查到的数据返回
    return Result.ok(shopTypeList);
}

由于在店铺类型ShopType中定义的createTime和updateTime是LocalDateTime类型。如果不自定义配置RedisConfig,那么日期类型存入Redis中会序列化为无意义的数字。

// 创建时间
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;

// 更新时间
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;

因此我们可以写一个RedisConfig配置类,在里面配置序列化器与反序列化器,这次value和hashValue使用配置好的json对象映射器。下面给出Redis序列化器配置模板:

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        // 创建RedisTemplate<String, Object>对象
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 设置redis连接工厂
        template.setConnectionFactory(connectionFactory);
        // 使用StringRedisSerializer来序列化和反序列化Redis的key值
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        // 配置对象映射器
        Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        // 序列化时将类的数据类型存入json,以便反序列化的时候转换成正确的类型
        ObjectMapper mapper = new ObjectMapper();
        // 指定要序列化的域,field,get和set,以及修饰符范围。ANY指包括private和public修饰符范围
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 指定序列化输入类型,类的信息也将添加到json中,这样才可以根据类名反序列化。没有这行,将存储为纯json字符串
        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.WRAPPER_ARRAY);
        // 解决jackson2无法反序列化LocalDateTime的问题
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.registerModule(new JavaTimeModule());
        //mapper.registerModule(new Jdk8Module()).registerModule(new JavaTimeModule()).registerModule(new ParameterNamesModule());
        // 将对象映射器添加到序列化器中
        jsonRedisSerializer.setObjectMapper(mapper);

        // 设置Key的序列化,使用String类型的序列化工具 StringRedisSerializer
        template.setKeySerializer(stringRedisSerializer);
        template.setHashKeySerializer(stringRedisSerializer);

        // 设置Value的序列化,使用JSON类型的序列化工具 Jackson2JsonRedisSerializer
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);

        template.afterPropertiesSet();
        return template;
    }
}
  • 改用RedisTemplate来缓存店铺类型
@Resource
private RedisTemplate redisTemplate;

@Override
public Result queryTypeList() {
    // 从redis中查询店铺类型数据
    ListOperations ops = redisTemplate.opsForList();
    // 由于配置了序列化和反序列化器,存入Java对象,取出时也为Java对象
    List<ShopType> shopTypeList = ops.range(CACHE_SHOP_TYPE_KEY, 0, -1);
    // 判断缓存是否命中
    if (CollUtil.isNotEmpty(shopTypeList)) {
        // 缓存命中,直接返回缓存数据
        return Result.ok(shopTypeList);
    }
    // 缓存未命中,查询数据库
    shopTypeList = this.query().orderByAsc("sort").list();
    // 判断数据库中是否存在该数据
    if (shopTypeList == null) {
        // 数据库中不存在该数据,返回失败信息
        return Result.fail("店铺类型不存在");
    }
    // 数据库中的数据存在,写入Redis,并返回查询的数据
    ops.rightPushAll(CACHE_SHOP_TYPE_KEY, shopTypeList);    // 直接将Java对象存入redis,不需要转为Json字符串再存储
    // 设置key的过期时间
    stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY, CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
    // 将数据库查到的数据返回
    return Result.ok(shopTypeList);
}

这样可以简化代码的编写,但会在value中存入反序列化时需要的全类名,虽然存储空间增加了,但也解决了日期类LocalDateTime序列化和反序列化时格式不正确的问题。

配置RedisConfig解决日期类序列化和反序列化问题可以一劳永逸,如果不配置序列化器,还可以在日期属性上添加这三个注解。(但是需要每个LocalDateTime日期属性上都添加,可维护性差)

// 格式化LocalDateTime在Json中的日期格式
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
// @JsonDeserialize:json反序列化注解,用于字段或set方法上,作用于setter()方法,将json数据反序列化为java对象
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
// @JsonSerialize:json序列化注解,用于字段或set方法上,作用于getter()方法,将java对象序列化为json数据
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime updateTime;



3、缓存一致性问题

使用缓存的好处:降低了后端负载,提高了读写的效率,降低了响应的时间。

缓存带来的问题:缓存的添加提高了系统的维护成本,同时也带来了数据一致性问题。

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步 ,此时就存在 缓存数据一致性问题

缓存数据一致性问题的根本原因是 缓存和数据库中的数据不同步

那么我们该如何让 缓存数据库中的数据尽可能的保证同步?首先需要选择一个比较好的缓存更新策略


(1)常见的缓存更新策略

内存淘汰(自动): 利用 Redis的内存淘汰机制 实现缓存更新,Redis的内存淘汰机制是当Redis发现内存不足时,会根据一定的策略自动淘汰部分数据。

Redis中常见的淘汰策略:

  • noeviction(默认):当达到内存限制并且客户端尝试执行写入操作时,Redis 会返回错误信息,拒绝新数据的写入,保证数据完整性和一致性
  • allkeys-lru:从所有的键中选择最近最少使用(Least Recently Used,LRU)的数据进行淘汰。即优先淘汰最长时间未被访问的数据
  • allkeys-random:从所有的键中随机选择数据进行淘汰
  • volatile-lru:从设置了过期时间的键中选择最近最少使用的数据进行淘汰
  • volatile-random:从设置了过期时间的键中随机选择数据进行淘汰
  • volatile-ttl:从设置了过期时间的键中选择剩余生存时间(Time To Live,TTL)最短的数据进行淘汰

超时剔除(半自动):手动给缓存数据设置过期时间TTL,到期后Redis自动删除超时的数据。

主动更新(手动):手动编码实现缓存更新,在修改数据库的同时更新缓存。我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题。

缓存更新策略的选择,应该看业务场景对数据一致性的的需求

  • 低一致性需求:使用Redis自带的内存淘汰机制 + 超时更新。
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案。

(2)主动更新策略的三种方案

  • 双写方案(Cache Aside Pattern):人工编码方式,缓存调用者在更新完数据库后再去更新缓存。维护成本高,灵活度高。
  • 读写穿透方案(Read/Write Through Pattern):将数据库和缓存整合为一个服务,由服务来维护缓存与数据库的一致性,调用者无需关心数据一致性问题,降低了系统的可维护性,但是实现困难,也没有较好的第三方服务供我们使用。
  • 写回方案(Write Behind Caching Pattern):调用者只操作缓存,其他独立的线程去异步处理数据库,将待写入的数据放入一个缓存队列,在适当的时机,通过批量操作或异步处理,将缓存队列中的数据持久化到数据库,实现最终一致。

双写方案读写穿透方案 在写入数据时都会直接更新缓存,以保持缓存和底层数据存储的一致性。

写回方案 延迟了缓存的更新操作,又由于异步更新机制,将多次对数据库的写合并成一次写,将多次对数据库的更新以最后一次更新的结果作为有效数据,去更新数据库。

主动更新策略中三种方案的应用场景

  • 双写方案 较适用于读多写少的场景,数据的一致性由应用程序主动管理
  • 读写穿透方案 适用于数据实时性要求较高、对一致性要求严格的场景
  • 写回方案 适用于追求写入性能的场景,对数据的实时性要求相对较低、可靠性也相对低,延迟写入的数据是在内存中的。

综合考虑使用方案一,虽然双写方案需要缓存调用者手动编码维护,但可控性更高。

(3)双写方案 操作缓存和数据库需要考虑的三个问题

使用双写方案操作缓存和数据库时有三个问题需要考虑:

  • 是删除缓存还是更新缓存?(两种缓存更新方案,选择效率更高的)
    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多(不推荐

如果采用更新缓存,假如我们执行100次更新数据库操作,那么就要执行100次写入缓存的操作,而在这期间并没有查询请求,也就是写多读少,那么这100次写入缓存的操作就是无效的写操作。

    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(推荐

如果采用删除缓存,假设更新100次,只需要删一次缓存,在这期间没有被访问,则不会去更新缓存,等到数据库被查询了再去写入缓存,相当于延迟加载模式,这种写缓存的频率更低,有效更新会更多。

  • 如何保证缓存与数据库的操作的同时成功或失败(原子性)?
    • 单体系统,将缓存与数据库操作放在一个 事务
    • 分布式系统,利用TCC(Try-Confirm-Cancel)等 分布式事务 方案
  • 先操作缓存 还是 先操作数据库 ?(多线程环境需要考虑)
    • 先删除缓存,再操作数据库(线程安全问题发生概率较高)

如果选择第一种方案,在两个线程并发来访问时,线程1先来,先把缓存删了,假设数据库更新的业务比较复杂耗时较久,由于没有加锁,此时线程2过来,他查询缓存数据不存在,此时他查询数据库查到的是数据库未更新完成的旧数据,当他写入旧数据到缓存后,线程1继续将新数据更新到数据库,这样就出现了多线程环境下的数据库与缓存不一致的问题。

当线程1删除缓存到更新数据库之间的时间段,会有其它线程进来查询数据,且线程1将缓存删除了,这就导致请求会直接打到数据库上,给数据库带来巨大压力,还可能造成缓存击穿。这个事件发生的概率很大,因为数据库的读写速度慢,而缓存的读写速度块。

    • 先操作数据库,再删除缓存(线程安全问题发生概率较低)

当线程1在查询缓存,此时缓存恰好失效,缓存未命中,此时线程1查询数据库数据,查询完正准备写入缓存时,由于没有加锁线程2抢占到CPU执行权,线程2在这期间对数据库进行了更新,接着删除缓存(此时缓存为空相当于没变),线程2结束后线程1接着写缓存,但是线程1写入缓存的是之前查数据库的旧数据。

这个事件发生的概率很低,因为先是需要满足 线程在并行执行 查询缓存时恰好失效未命中,且在写入缓存(微秒级别)的那段时间内有一个线程抢占执行更新操作,缓存的查询很快,这段空隙时间很小,在这期间完成耗时的写操作,可能性不大。

因此,我们选择 先操作数据库,再删除缓存

读操作:缓存命中则直接返回,未命中则查询数据库,并写入缓存,设置超时时间。

写操作:先写数据库,再删除缓存,要确保数据库和缓存的原子性。




4、实现商铺查询的数据库与缓存双写一致

需求:修改ShopController,给查询商铺的缓存添加超时剔除和主动更新的策略

  • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。
/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@Override
public Result queryById(Long id) {
    // 从redis中查询店铺数据
    String cacheKey = CACHE_SHOP_KEY_PREFIX + id;
    String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
    // 判断缓存是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        // 缓存命中,直接返回店铺数据
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 缓存未命中,从数据库中查询店铺数据
    Shop shop = getById(id);
    // 判断数据库是否存在店铺数据
    if (shop == null) {
        // 数据库中不存在,返回失败信息
        return Result.fail("店铺不存在");
    }
    // 数据库中存在,重建缓存,并返回店铺数据
    stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}
  • 根据id修改店铺时,先修改数据库,再删除缓存。
/**
 * 更新商铺信息(写操作,先更新数据库,再删除缓存)
 * @param shop 商铺数据
 * @return
 */
@Override
@Transactional
public Result updateShopById(Shop shop) {
    Long id = shop.getId();
    if (id == null) {
        return Result.fail("店铺id不能为空");
    }
    // 更新数据库
    updateById(shop);
    // 删除缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY_PREFIX + id);
    return Result.ok();
}

代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中查询数据并重新写入缓存,从而避免数据库和缓存不一致的问题。




5、缓存穿透的解决方案

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

常见的解决方案有两种:

  • 缓存空对象
    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗、可能造成短期的不一致
  • 布隆过滤
    • 优点:内存占用较少,没有多余key
    • 缺点:实现复杂、存在误判可能(有穿透的风险)、无法删除数据

缓存空对象思路分析: 当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库。数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了。

布隆过滤: 布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,假设布隆过滤器判断这个数据不存在,则直接返回。这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。

这里使用方案一(缓存空对象)解决缓存穿透问题:

核心思路如下:

  • 在原来的逻辑中,我们如果发现这个数据在MySQL中不存在,直接就返回404了,这样是会存在缓存穿透问题的。
  • 现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把这个数据写入到Redis中,只不过设置写入的value为空值。当再次发起查询时,我们如果发现命中之后,判断这个value是否是空值,如果是空值,则是缓存穿透数据,如果不是,则直接返回数据。
/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@Override
public Result queryById(Long id) {
    // 从redis中查询店铺数据
    String cacheKey = CACHE_SHOP_KEY_PREFIX + id;
    String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
    // 判断缓存是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        // 缓存命中,直接返回店铺数据
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 防止缓存穿透:缓存未命中,判断缓存中命中的是否是空值(isNotBlank把null和空字符串给排除了)
    if ("".equals(shopJson)) {
        // 当前数据是空字符串(说明该数据是之前缓存的空对象),返回失败信息
        return Result.fail("店铺不存在");
    }
    // 缓存未命中,从数据库中查询店铺数据
    Shop shop = getById(id);
    // 判断数据库是否存在店铺数据
    if (shop == null) {
        // 数据库中不存在,将空值写入redis
        stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 返回失败信息
        return Result.fail("店铺不存在");
    }
    // 数据库中存在,重建缓存,并返回店铺数据
    stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}

第一次查询数据库中和缓存中不存在的数据,请求经过了数据库,并缓存了空字符串,设置了短期TTL。

第二次查询(TTL短期内),发起请求相同,查询不存在的数据,未经过数据库,提前判空值返回失败信息。

总结:缓存穿透的解决方案有哪些?

  • 缓存空对象(被动)
  • 布隆过滤(被动)
  • 增强id的复杂度,避免被猜测id规律(主动)
  • 做好数据的基础格式校验(主动)
  • 加强用户权限校验(主动)
  • 做好热点参数的限流(主动)



6、缓存雪崩的解决方案

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

缓存雪崩常见解决方案:

  • 给不同的Key的TTL添加随机值(让失效时间离散分布,确保Key不会在同一时间大量失效)
  • 利用Redis集群提高服务的可用性(主从集群、哨兵机制)
  • 给缓存业务添加降级限流策略(比如快速失败机制,让请求尽可能打不到数据库上)
  • 给业务添加多级缓存(浏览器缓存 -> Nginx反向代理缓存 -> Redis缓存 -> JVM本地缓存…)

概念补充:

  • 缓存预热:缓存预热是指在系统启动之前或系统达到高峰期之前,将常用数据预先加载到缓存中,以提高缓存命中率和系统性能的过程。缓存预热的目的是模拟爆发式的请求,尽可能地避免缓存击穿和缓存雪崩,还可以减轻后端存储系统的负载,提高系统的响应速度和吞吐量。
  • 哨兵模式:在集群模式下,监控Redis各个节点是否正常,如果主节点故障通过发布订阅模式通知其他节点,并进行故障转移,将其他正常的从节点指定为主节点。



7、缓存击穿的解决方案

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

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了。但是假设查询数据库重建缓存的过程耗时较长,在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法,那么这些线程就会同一时刻来查询缓存,都未命中,接着同一时间去查询数据库,重复进行缓存重建,导致数据库访问压力过大,这就是高并发访问的热点key失效造成缓存击穿

缓存击穿与缓存雪崩有一定的区别,缓存雪崩是指许多key同时过期,导致大量数据查询失败,从而造成数据库负载激增。而缓存击穿则是由于高并发查同一条数据而导致数据库压力瞬间增大。

缓存击穿的常见解决方案:

  • 互斥锁(确保一致性、牺牲服务可用性)
    • 优点:没有额外的内存消耗,保证一致性,实现简单。
    • 缺点:线程需要等待、性能较低,可能有死锁风险。

方案分析:因为锁能实现互斥性。假设并发的线程过来,只能允许单个线程去访问数据库,从而避免对于数据库访问压力过大,但这也会影响业务的性能,因为此时会让业务从并行变成了串行,我们可以采用 tryLock方法 + double check 来解决这样的问题。

假设现在线程1过来访问,它查询缓存没有命中,但是此时它获得了锁的资源,那么线程1就会单独去执行查询数据库重建缓存的逻辑。假设现在线程2过来,并没有获得到锁,那么线程2就可以先休眠一段时间再去重试查询缓存,直到线程1把锁释放后,线程2再查询缓存,此时就能命中缓存拿到数据了。

  • 逻辑过期 (确保服务可用性,牺牲一致性)
    • 优点:线程无需等待,性能较好,不会影响并发能力。
    • 缺点:不保证一致性、有额外内存消耗,实现复杂。

方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案,配合redis的缓存淘汰策略,去避免高并发时期的缓存击穿。

我们把过期时间expire设置在redis的value中,注意这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去判断处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程会开启一个异步新线程去进行查询数据库重建缓存的逻辑,直到新开的线程2完成这个逻辑后,才释放锁,而线程1直接返回过期旧数据。假设现在线程3过来访问,由于异步线程2持有着锁,所以线程3无法获得锁,线程3也直接返回过期数据,只有等到新开的线程2把重建数据构建完后,其他线程才能命中缓存,返回没有过期的新数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

两者相比较:

  • 互斥锁更加易于实现,但是加锁会导致这些并发的线程 并行 变成 串行 ,导致系统性能下降,还可能 发生不同业务之间的死锁。
  • 逻辑过期实现起来相较复杂,因为需要额外维护一个逻辑过期时间,有额外的内存开销,但是通过异步开启子线程重建缓存,使原来的同步阻塞变成异步,提高系统的响应速度,在重建缓存这段时间内,其他线程来查询缓存发现缓存已过期,会直接返回过期数据。



8、利用互斥锁解决缓存击穿问题

需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题。

核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是查询Redis之后,如果从缓存没有查询到数据,则尝试获取互斥锁,如果没有获得到锁,则休眠一段时间,过一会儿再进行重试。如果未命中且获取到锁,说明是第一个拿到锁的线程,查询数据库,将数据写入Redis后再释放锁,最后返回数据。休眠结束后的线程重新查询Redis命中后直接返回数据,无需查询数据库。

需要注意的是:这个互斥锁不能使用synchronized和Lock这样的本地单机锁,如果是分布式服务集群就会锁不住,如果要解决这个问题,需要使用分布式锁(后面会具体讲)。并且这两个单机锁加锁了只会阻塞等待,无法进行后面的休眠重试逻辑。互斥锁要求当有一个线程能获取到锁,其他线程都获取失败,因此这里可以使用Redis中String类型的setnx操作实现互斥锁。

提示:setnx的功能是如果不存在这个key,则可以set,如果存在了这个key,则无法set。

在StringRedisTemplate中一般使用setIfAbsent()方法,相当于setnx命令,并且在setIfAbsent()中设置有效时间,底层是set key value [EX seconds] [PX milliseconds] [NX|XX],保证命令的原子性。

使用setnx命令模拟加锁,即使是分布式服务的多线程环境下,由于Redis是单线程的,也只会允许一个线程去执行setnx加锁操作,所以不用担心多个线程同时setnx。而且在设置完锁后为了防止意外情况不释放锁,一般我们还会在setnx的时候加一个TTL有效期,避免锁无法释放产生死锁。这就是利用互斥锁保证只有一个线程去执行操作数据库,防止高并发环境下多个线程同时访问失效热点key造成缓存击穿。

  • 使用互斥锁改造queryById()方法,解决缓存击穿
/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@Override
public Result queryById(Long id) {
    // 用互斥锁解决缓存击穿、同时解决缓存穿透
    return queryWithMutex(id);
}

/**
 * 根据id查询商铺信息(用互斥锁解决缓存击穿、同时解决缓存穿透)
 * @param id 商铺id
 * @return 查询结果
 */
private Result queryWithMutex(Long id) {
    // 从redis中查询店铺数据
    String cacheKey = CACHE_SHOP_KEY_PREFIX + id;
    Shop shopFromCache = getShopFromCache(cacheKey);
    // 判断缓存是否命中
    if (shopFromCache != null) {
        // 命中,直接返回
        return Result.ok(shopFromCache);
    }
    // 实现重建缓存
    String lockKey = LOCK_SHOP_KEY_PREFIX + id;
    Shop shop = null;
    try {
        // 尝试获取锁,判断是否获取锁成功
        if (!tryLock(lockKey)) {
            // 获取失败,则休眠并重试
            Thread.sleep(50L);	// 休眠50毫秒
            return queryWithMutex(id);
        }
        // 获取锁成功,再次检测redis中缓存是否存在(DoubleCheck),如果存在则无需重建缓存,防止堆积的线程全部请求数据库
        shopFromCache = getShopFromCache(cacheKey);
        // 判断缓存是否命中
        if (shopFromCache != null) {
            // 命中,直接返回
            return Result.ok(shopFromCache);
        }
        // 缓存未命中,从数据库中查询店铺数据
        shop = getById(id);
        // 判断数据库是否存在店铺数据
        if (shop == null) {
            // 数据库中不存在,将空值写入redis,解决缓存穿透
            stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return Result.fail("店铺不存在");
        }
        // 数据库中存在,重建缓存,并返回店铺数据
        stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 释放互斥锁
        unLock(lockKey);
    }
    return Result.ok(shop);
}

/**
 * 从缓存中查询店铺数据
 * @param cacheKey 商铺缓存key
 * @return 商铺详情数据
 */
private Shop getShopFromCache(String cacheKey) {
    // 从redis中查询店铺数据
    String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
    // 判断缓存是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        // 缓存命中,直接返回店铺数据
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    // 防止缓存穿透:判断缓存中命中的是否是空值(isNotBlank把null和空字符串给排除了)
    if ("".equals(shopJson)) {
        // 当前数据是空字符串(说明该数据是之前缓存的空对象),缓存命中空对象,返回null
        return null;
    }
    // 缓存未命中,返回null
    return null;
}

/**
 * 尝试获取锁,判断是否获取锁成功
 * setIfAbsent():如果缺失不存在这个key,则可以set,返回true;存在key不能set,返回false。相当于setnx命令
 * @param lockKey 互斥锁的key
 * @return 是否获取到锁
 */
private boolean tryLock(String lockKey) {
    // 原子命令:set lock value ex 10 nx
    Boolean isGetLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
    // 为了避免Boolean直接返回自动拆箱未null,用工具包将null和false都返回为false
    return BooleanUtil.isTrue(isGetLock);
}

/**
 * 释放互斥锁
 * @param lockKey 互斥锁的key
 */
private void unLock(String lockKey) {
    if (BooleanUtil.isTrue(stringRedisTemplate.hasKey(lockKey))) {
        stringRedisTemplate.delete(lockKey);
    }
}

使用JMeter模拟多机同时请求,设置5秒内发送1000个请求,预计QPS将达到200左右。

配置请求接口参数

绿色表示线程成功执行

压测结果显示,吞吐量为203.0/sec,由于这个请求业务中不涉及事务,即多个业务表的操作,所以这里的吞吐量Throughput可以理解为QPS,也就是每秒钟发送200个请求,每秒钟处理203个请求。

TPS(Transactions Per Second):每秒传输的事物处理个数。即服务器每秒处理的事务数。

QPS(Queries Per Second):每秒查询率。即服务器每秒能够处理的查询请求次数。

那么我们对于一个页面做一次访问,就会形成一个TPS;但一次页面访问,可能产生多次对服务器的请求,服务器对这些请求,计为QPS。




9、利用逻辑过期解决缓存击穿问题

需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。

思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据(说明不是热点key),不查询数据库。而一旦命中后,将value取出,判断value中的逻辑过期时间是否过期,如果未过期,直接返回redis中的新数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程异步去重建缓存,重建完成后释放互斥锁。

  • 使用逻辑过期改造queryById()方法,解决缓存击穿
/**
 * 缓存重建线程池
 */
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@Override
public Result queryById(Long id) {
    // 用逻辑过期解决缓存击穿
    return queryWithLogicalExpire(id);
}

/**
 * 使用逻辑过期解决缓存击穿
 * 对于热点业务,提前预热缓存数据,设置永不自动过期,默认缓存一定被命中,不用考虑缓存穿透问题
 * @param id 商铺id
 * @return 商铺详情数据
 */
private Result queryWithLogicalExpire(Long id) {
    // 从缓存中获取热点数据
    String cacheKey = CACHE_SHOP_KEY_PREFIX + id;
    String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
    // 判断缓存是否命中(由于是热点数据,提前进行缓存预热,默认缓存一定会命中)
    if (StrUtil.isBlank(shopJson)) {
        // 缓存未命中,说明查到的不是热点key,直接返回空
        return Result.fail("店铺不存在(非热点数据)");
    }
    // 缓存命中,先把json反序列化为逻辑过期对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    // RedisData<Shop>使用了泛型处理:JSONUtil.toBean(shopJson, new TypeReference<RedisData<Shop>>() {}, false);
    // 将Object对象转成JSONObject再反序列化为目标对象
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    // 判断是否逻辑过期
    if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
        // 未过期,直接返回正确数据
        return Result.ok(shop);
    }
    // 已过期,先尝试获取互斥锁,再判断是否需要缓存重建
    String lockKey = LOCK_SHOP_KEY_PREFIX + id;
    // 判断是否获取锁成功
    if (tryLock(lockKey)) {
        // 在线程1重建缓存期间,线程2进行过期判断,假设此时key是过期状态,线程1重建完成并释放锁,线程2立刻获取锁,并启动异步线程执行重建,那此时的重建就与线程1的重建重复了
        // 因此需要在线程2获取锁成功后,在这里再次检测redis中缓存是否过期(DoubleCheck),如果未过期则无需重建缓存,防止数据过期之后,刚释放锁就有线程拿到锁的情况,重复访问数据库进行重建
        shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
        // 缓存命中,先把json反序列化为逻辑过期对象
        redisData = JSONUtil.toBean(shopJson, RedisData.class);
        // 将Object对象转成JSONObject再反序列化为目标对象
        shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        // 判断是否逻辑过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            // 命中且未过期,直接返回新数据
            return Result.ok(shop);
        }
        // 获取锁成功,开启一个独立子线程去重建缓存
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                // 缓存重建并设置逻辑过期时间
                saveShop2Cache(id, CACHE_SHOP_LOGICAL_TTL);
            } finally {
                // 释放锁
                unLock(lockKey);
            }
        });
    }
    // 获取锁失败,直接返回过期的旧数据
    return Result.ok(shop);
}

/**
 * 根据商铺id查询店铺数据,并将数据封装逻辑过期时间,保存到缓存中(缓存预热、重建缓存使用)
 * - 逻辑过期时间根据具体业务而定,逻辑过期过长,会造成缓存数据的堆积,浪费内存;过短造成频繁缓存重建,降低性能。
 * - 所以设置逻辑过期时间时,需要实际测试和评估不同参数下的性能和资源消耗情况,可以通过观察系统的表现,在业务需求和性能要求之间找到一个平衡点
 * @param id 商铺id
 * @param expireSeconds 有效期(单位:秒)
 */
public void saveShop2Cache(Long id, Long expireSeconds) {
    // 查询店铺数据
    Shop shop = getById(id);
    // 封装逻辑过期数据(热点数据)
    RedisData redisData = new RedisData();
    redisData.setData(shop);    // 设置缓存数据
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));    // 设置逻辑过期时间=当前时间+有效期TTL
    // 将逻辑过期数据写入Redis,不设置TTL过期时间,key永久有效,真正的过期时间为逻辑过期时间
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY_PREFIX + id, JSONUtil.toJsonStr(redisData));
}

  • 测试

先对缓存进行热点数据预热,预热数据的逻辑过期时间设置了20秒,所以等待20秒缓存逻辑过期后,在数据库中修改id为1的店铺数据(热点数据),使用Jmeter进行压力测试(可以将重建缓存的过程延迟1秒钟,让效果更加明显),第一次查询结果还是旧数据,1秒左右以后返回的缓存数据发生了更新(因为此时缓存已经重建好了),并且访问数据库只重建了一次缓存,验证了并发安全问题。




10、封装Redis工具类

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

  • 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
  • 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
  • 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
  • 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

方法1与方法3对应,负责非热点数据的缓存,利用空值法解决缓存穿透;

方法2与方法4对应,负责热点数据的缓存,利用逻辑过期解决缓存击穿。

  • CacheClient工具类
@Slf4j
@Component
public class CacheClient {
    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将数据加入Redis,并设置有效期
     *
     * @param key   缓存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               缓存key
     * @param value             缓存数据值
     * @param expireTime        逻辑过期时间
     * @param unit              时间单位
     */
    public void setWithLogicalExpire(String key, Object value, long expireTime, TimeUnit unit) {
        // 封装逻辑过期数据(热点数据)
        RedisData redisData = new RedisData();
        // 设置缓存数据
        redisData.setData(value);
        // 设置逻辑过期时间=当前时间+有效期TTL,unit.toSeconds()将时间统一转换为秒
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime)));
        // 将逻辑过期数据写入Redis,不设置TTL过期时间,key永久有效,真正的过期时间为逻辑过期时间
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 根据id查询数据(使用缓存空值法解决缓存穿透)
     *
     * @param keyPrefix  缓存key前缀
     * @param id         查询id,与缓存key前缀拼接
     * @param type       查询数据的Class类型
     * @param dbFallback 根据id查询数据的函数式接口
     * @param time       有效期
     * @param unit       时间单位
     * @param <R>
     * @param <ID>
     * @return
     */
    public <R, ID> R handleCachePenetrationByBlankValue(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, long time, TimeUnit unit) {
        // 从缓存中查询数据
        String cacheKey = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(cacheKey);
        // 判断缓存是否命中
        if (StrUtil.isNotBlank(json)) {
            // 缓存命中,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 防止缓存穿透:缓存未命中,判断缓存中命中的是否是空值(isNotBlank把null和空字符串给排除了)
        if ("".equals(json)) {
            // 当前数据是空字符串(说明该数据是之前缓存的空对象),返回null
            return null;
        }
        // 缓存未命中,交给调用者查询数据库
        R r = dbFallback.apply(id);
        // 数据库中不存在,将空值写入redis
        if (r == null) {
            stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回null
            return null;
        }
        // 数据库中存在,重建缓存,并返回店铺数据
        this.set(cacheKey, r, time, unit);
        return r;
    }

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


    /**
     * 根据id查询热点数据(使用逻辑过期解决缓存击穿)
     *
     * @param cacheKeyPrefix    缓存key前缀
     * @param id                查询id,与缓存key前缀拼接
     * @param type              查询数据的Class类型
     * @param lockKeyPrefix     缓存数据锁前缀,与查询id拼接
     * @param dbFallback        根据id查询数据的函数式接口
     * @param expireTime        逻辑过期时间
     * @param unit              时间单位
     * @param <R>
     * @param <ID>
     * @return
     */
    public <R, ID> R handleCacheBreakdownByLogicalExpire(String cacheKeyPrefix, ID id, Class<R> type, String lockKeyPrefix, Function<ID, R> dbFallback, long expireTime, TimeUnit unit) {
        // 从缓存中获取热点数据
        String cacheKey = cacheKeyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(cacheKey);
        // 判断缓存是否命中(由于是热点数据,提前进行缓存预热,默认缓存一定会命中)
        if (StrUtil.isBlank(json)) {
            // 缓存未命中,说明查到的不是热点key,直接返回空
            return null;
        }
        // 缓存命中,先把json反序列化为逻辑过期对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        // 将Object对象转成JSONObject再反序列化为目标对象
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        // 判断是否逻辑过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            // 未过期,直接返回正确数据
            return r;
        }
        // 已过期,先尝试获取互斥锁,再判断是否需要缓存重建
        String lockKey = lockKeyPrefix + id;
        // 判断是否获取锁成功
        if (tryLock(lockKey)) {
            // 在线程1重建缓存期间,线程2进行过期判断,假设此时key是过期状态,线程1重建完成并释放锁,线程2立刻获取锁,并启动异步线程执行重建,那此时的重建就与线程1的重建重复了
            // 因此需要在线程2获取锁成功后,在这里再次检测redis中缓存是否过期(DoubleCheck),如果未过期则无需重建缓存,防止数据过期之后,刚释放锁就有线程拿到锁的情况,重复访问数据库进行重建
            json = stringRedisTemplate.opsForValue().get(cacheKey);
            // 缓存命中,先把json反序列化为逻辑过期对象
            redisData = JSONUtil.toBean(json, RedisData.class);
            // 将Object对象转成JSONObject再反序列化为目标对象
            r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
            // 判断是否逻辑过期
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
                // 命中且未过期,直接返回新数据
                return r;
            }
            // 获取锁成功,开启一个独立子线程去重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R result = dbFallback.apply(id);
                    // 写入redis
                    this.setWithLogicalExpire(cacheKey, result, expireTime, unit);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 获取锁失败,直接返回过期的旧数据
        return r;
    }

    /**
     * 尝试获取锁,判断是否获取锁成功
     * setIfAbsent():如果缺失不存在这个key,则可以set,返回true;存在key不能set,返回false。相当于setnx命令
     * @param lockKey 互斥锁的key
     * @return 是否获取到锁
     */
    private boolean tryLock(String lockKey) {
        // 原子命令:set lock value ex 10 nx
        Boolean isGetLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10L, TimeUnit.SECONDS);
        // 为了避免Boolean直接返回自动拆箱未null,用工具包将null和false都返回为false
        return BooleanUtil.isTrue(isGetLock);
    }

    /**
     * 释放互斥锁
     * @param lockKey 互斥锁的key
     */
    private void unLock(String lockKey) {
        if (BooleanUtil.isTrue(stringRedisTemplate.hasKey(lockKey))) {
            stringRedisTemplate.delete(lockKey);
        }
    }
}
  • 在ShopServiceImpl中替换为工具类
@Resource
private CacheClient cacheClient;

/**
 * 根据id查询商铺信息
 * @param id 商铺id
 * @return 商铺详情数据
 */
@Override
public Result queryById(Long id) {
    // 用空值法解决缓存穿透
    //Shop shop = cacheClient.handleCachePenetrationByBlankValue(CACHE_SHOP_KEY_PREFIX, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

    // 用逻辑过期解决缓存击穿
    Shop shop = cacheClient.handleCacheBreakdownByLogicalExpire(CACHE_SHOP_KEY_PREFIX, id, Shop.class, LOCK_SHOP_KEY_PREFIX, this::getById, CACHE_SHOP_LOGICAL_TTL, TimeUnit.SECONDS);
    if (shop == null) {
        return Result.fail("店铺不存在");
    }
    return Result.ok(shop);
}



四、优惠券秒杀

1、全局唯一ID

(1)数据库自增ID存在的问题

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
    • 场景分析一:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感业务信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
  • 受单表数据量的限制
    • 场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行分库分表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。

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

(2)基于Redis自增器实现分布式全局ID

分布式ID的实现方式:

  1. UUID(生成16进制字符串,字符串id不利于数据库作为索引查询)
  2. Redis自增(自定义的方式实现:时间戳+序列号+数据库自增
  3. 数据库自增(单独维护一个全局id表,作为多张表的全局id)
  4. snowflake算法(雪花算法)

这里我们基于Redis的自增器实现生成分布式ID,为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是在Redis自增器基础上拼接一些其它信息:

我们本次使用数值型id,也就是Java中的long类型,占用8个字节。

ID的组成部分:符号位:1bit,永远为0

时间戳(id生成时间 - 初始时间的秒数):31bit,以秒为单位,大概可以支持使用69年(2^31/3600/24/365≈69)

序列号(Redis自增后的value):32bit,秒内的计数器,支持每秒产生2^32个不同ID

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息,比如时间戳、UUID、业务关键词

例如,计算下单时间戳,利用下单时间 - 初始时间的时间差秒数作为时间戳。首先我们需要生成一个初始时间:

// 获取指定日期的时间戳
LocalDateTime time = LocalDateTime.of(2024, 1, 1, 0, 0, 0);
// 将时间戳转换为秒数
long second = time.toEpochSecond(ZoneOffset.UTC);
// 例如2024年1月1日0时0分0秒的时间戳秒数second = 1704067200(初始时间秒数)
System.out.println("second = " + second);
  • 全局唯一ID生成器RedisIDWorker
@Component
public class RedisIDWorker {
    /**
     * 初始时间戳秒数
     */
    private static final long BEGIN_TIMESTAMP = 1704067200L;
    /**
     * 序列号位数
     */
    private static final int SEQUENCE_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIDWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 获取自增后的全局唯一ID
     * 注意:单个key我们需要保证key不相同,即使是同一个业务内的key也不能相同。
     * Redis中对同一个key做自增,value最多到2^64,而我们的全局唯一ID(value)策略里,真正记录序列号的位数最多只有32bit
     * 因此我们在业务key前缀后面拼接一个当天日期,这样设计保证每天都自增的是一个新key的id,而一天的下单量不可能超过2^32个
     * @param keyPrefix 业务key前缀
     * @return 自增后的全局唯一ID
     */
    public long nextId(String keyPrefix) {
        // 生成时间戳部分
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
        // 生成序列号部分
        // 获取当前日期,精确到天(好处1:避免自增相同key的value超过32位上限;好处2:根据key中的日期,方便进行统计)
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        String key;
        if (!keyPrefix.endsWith(":")) {
            key = "incr:" + keyPrefix + ":" + date;
        }else {
            key = "incr:" + keyPrefix + date;
        }
        // 根据key对value(分布式全局ID)做自增长,如果key不存在,则自动创建该key并返回自增后的结果
        long sequence = stringRedisTemplate.opsForValue().increment(key);
        // 返回自增后的全局唯一ID,将timestamp从最低位左移SEQUENCE_BITS位,低SEQUENCE_BITS位补0,再与sequence做或运算填充
        return timestamp << SEQUENCE_BITS | sequence;
    }
}
  • 测试生成分布式ID效果
@Resource
private RedisIDWorker redisIDWorker;
// 利用线程池测试并发环境下,测试生成分布式全局ID
private ExecutorService es = Executors.newFixedThreadPool(500);

@Test
void testIdWorker() throws InterruptedException {
    // CountDownLatch相当于线程计数器,参数值代表了需要进行多少次任务
    CountDownLatch latch = new CountDownLatch(300);
    Runnable task = () -> {
        for (int i = 0; i < 100; i++) {
            long id = redisIDWorker.nextId("order");
            System.out.println("id = " + id);
        }
        // 每执行完一次任务(生成100个id),该子线程就调用countDown()方法-1,直到减为0
        latch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        es.submit(task);    // 任务提交300次,每个任务生成100个id,共生成30000个id
    }
    // await谁调用就是让谁暂停,要等CountDownLatch计数器为0,才能进行主线程
    latch.await();
    long end = System.currentTimeMillis();
    System.out.println("time = " + (end - begin));
}
  • 关于CountDownLatch

CountDownLatch名为信号枪:主要的作用是同步协调在多线程的等待与唤醒问题

如果不使用CountDownLatch,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,此时就需要使用到CountDownLatch。

CountDownLatch中有两个最重要的方法:

  • countDown()
  • await()

await()是阻塞方法,我们担心分线程没有执行完时,main线程就先执行,所以使用await()可以让main线程阻塞,那么什么时候main线程不再阻塞呢?我们创建 CountDownLatch(int count) 的时候会给一个参数作为内部变量初始值,当CountDownLatch内部维护的变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch维护的变量变为0呢,我们只需要调用一次countDown() ,内部变量就减少1,我们让分线程和内部变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完 ,CountDownLatch维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。




2、实现秒杀下单优惠券功能

每个店铺都可以发布优惠券,分为 平价券特价券(秒杀券)。平价券可以任意购买,而特价券需要秒杀抢购:

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
  • tb_seckill_voucher:优惠券的基本信息、优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
-- tb_voucher 优惠券表
create table tb_voucher
(
    id           bigint unsigned auto_increment comment '主键'
        primary key,
    shop_id      bigint unsigned                            null comment '商铺id',
    title        varchar(255)                               not null comment '代金券标题',
    sub_title    varchar(255)                               null comment '副标题',
    rules        varchar(1024)                              null comment '使用规则',
    pay_value    bigint unsigned                            not null comment '支付金额,单位是分。例如200代表2元',
    actual_value bigint                                     not null comment '抵扣金额,单位是分。例如200代表2元',
    type         tinyint unsigned default '0'               not null comment '0,普通券;1,秒杀券',
    status       tinyint unsigned default '1'               not null comment '1,上架; 2,下架; 3,过期',
    create_time  timestamp        default CURRENT_TIMESTAMP not null comment '创建时间',
    update_time  timestamp        default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
collate = utf8mb4_general_ci;

-- tb_seckill_voucher 秒杀优惠券表,与优惠券是一对一关系 
create table tb_seckill_voucher
(
    voucher_id  bigint unsigned                     not null comment '关联的优惠券的id'
        primary key,
    stock       int                                 not null comment '库存',
    create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
    begin_time  timestamp default CURRENT_TIMESTAMP not null comment '生效时间',
    end_time    timestamp default CURRENT_TIMESTAMP not null comment '失效时间',
    update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
    comment '秒杀优惠券表,与优惠券是一对一关系' collate = utf8mb4_general_ci;
  • 平价券由于优惠力度并不是很大,所以是可以任意发放领取
  • 而特价券由于优惠力度大,所以像第二种券,就得限制数量和抢购时间
  • 秒杀优惠券表,与优惠券是一对一关系,通过优惠券的id关联优惠券表,相当于对优惠券表做了字段的扩展

新增普通券和秒杀券代码: VoucherController

/**
 * 新增普通券
 * @param voucher 优惠券信息
 * @return 优惠券id
 */
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}

/**
 * 新增秒杀券
 * @param voucher 优惠券信息,包含秒杀信息
 * @return 优惠券id
 */
@PostMapping("seckill")
public Result addSecKillVoucher(@RequestBody Voucher voucher) {
    voucherService.addSecKillVoucher(voucher);
    return Result.ok(voucher.getId());
}
  • VoucherServiceImpl:新增普通券和秒杀券共用同一个业务方法addSecKillVoucher()
@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);
}

有了新增秒杀券的接口后,要秒杀下单优惠券,需要先有优惠券。由于该项目没有管理后台,因此通过ApiFox或Postman请求该接口,添加优惠券保存到数据库

POST http://localhost:8081/voucher/seckill

{
"shopId": 1,
"title": "100元代金券",
"subTitle": "周一至周五均可使用",
"rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue": 8000,
"actualValue": 10000,
"type": 1,
"stock": 100,
"beginTime": "2022-01-25T10:09:17",
"endTime": "2037-01-26T12:09:04"
}

确保添加的优惠券没有过期,即 endTime > 当前时间 且 endTime > beginTime

注意:在MySQL中,TIMESTAMP占用4个字节、并且查询的时候系统会帮你自动转成(Y-m-d H:i:s),可读性强。TIMESTAMP 的取值范围是 '1970-01-01 00:00:01' UTC'2038-01-19 03:14:07' UTC。这是因为它使用 32 位存储空间,而这个空间能够表示的秒数是有限的,最多只能表示到 2038 年。

  • 下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的controller即可

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

下单核心逻辑分析:

当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件,如果是否在秒杀活动时间内,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

  • VoucherOrderServiceImpl
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIDWorker redisIDWorker;

/**
 * 秒杀下单优惠券
 * @param voucherId 优惠券id
 * @return 下单id
 */
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 查询秒杀优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断秒杀是否开始
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 秒杀活动尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 判断秒杀是否结束
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 秒杀活动已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        // 秒杀券库存不足
        return Result.fail("秒杀券已抢空!");
    }
    // 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            .update();
    if (!success) {
        // 扣减失败
        throw new RuntimeException("扣减失败,秒杀券扣减失败(库存不足)!");
    }
    // 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER));  // 订单id
    voucherOrder.setUserId(((UserVo) BaseContext.get()).getId());   // 用户id
    voucherOrder.setVoucherId(voucherId);   // 代金券id
    success = this.save(voucherOrder);
    if (!success) {
        // 创建秒杀券订单失败
        throw new RuntimeException("创建秒杀券订单失败!");
    }
    // 返回订单id
    return Result.ok(voucherOrder.getId());
}
  • 修改前端代码,解决抢券下单后页面不更新最新优惠券数量问题




3、单体服务下一人多单超卖问题

我们利用分布式ID完成了优惠券秒杀的下单基本功能,但是在高并发场景下可能会存在超卖问题。我们将数据库中的秒杀券库存恢复到100,并且清空优惠券订单表,使用JMeter来还原一下单体架构下的一人下多单超卖场景。

为了方便测试,先把全局异常处理器WebExceptionAdvice中的RuntimeException拦截注释掉,方便我们在JMeter中查看请求的异常率(如果不注释直接返回Result.fail()或者抛出RuntimeException,JMeter这边也会显示请求成功)。

/**
 * 全局异常处理器
 */
@Slf4j
@RestControllerAdvice
public class WebExceptionAdvice {

    //@ExceptionHandler(RuntimeException.class)	// 暂时注释掉,方便测试
    public Result handleRuntimeException(RuntimeException e) {
        log.error(e.toString(), e);
        return Result.fail("服务器异常");
    }
}

秒杀业务需要用户登录,因此在JMeter的HTTP信息头管理器中设置token,模拟一个用户同时发送多次下单请求。

设置线程数为200,正常情况下应该是卖出100张,另外50%请求抢券失败,而结果异常率却是56.50%

数据库中下单数量为109,库存为-9,这就是高并发场景下的超卖问题。

为什么会产生超卖问题呢?

线程1过来查询库存,发现库存充足,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,同样发现库存充足,那么这两个线程都会去扣减库存,最终相当于多个线程一起去扣减库存,导致库存变成了负数,这就是库存超卖问题出现的原因。

超卖问题的常见解决方案

  • 悲观锁:认为线程安全问题一定会发生,因此操作数据库之前都需要先获取锁,确保线程串行执行。悲观锁中又可以再细分为公平锁、非公平锁、可重入锁等等。常见的悲观锁有:synchronizedlock
  • 乐观锁:认为线程安全问题不一定发生,因此不加锁,只会在更新数据库的时候去判断有没有其它线程对数据进行修改,如果没有修改则认为是安全的,直接更新数据库中的数据即可,如果修改了则说明不安全,直接抛异常或者等待重试。常见的实现方式有:版本号法CAS操作

悲观锁和乐观锁的比较

  • 悲观锁和乐观锁的解决共享变量冲突方式不同:悲观锁在冲突发生时直接阻塞其他线程;乐观锁则是在提交阶段检查冲突并进行重试。
  • 悲观锁比乐观锁的性能低:悲观锁需要先加锁再操作,限制了并发性能;而乐观锁不需要加锁,所以乐观锁通常具有更好的性能。
  • 应用场景:两者都是互斥锁,悲观锁适合写入操作较多、冲突频繁的场景;乐观锁适合读取操作较多、冲突较少的场景。



4、乐观锁解决一人多单超卖问题

  • 实现方式一:CAS版本号法

首先我们要为 tb_seckill_voucher 表新增一个版本号字段 version ,线程1查询完库存,在进行库存扣减操作的同时将版本号+1,线程2在查询库存时,同时查询出当前的版本号,发现库存充足,也准备执行库存扣减操作,但是需要判断当前的版本号是否和之前查询时的版本号一致,结果发现版本号发生了改变,这就说明数据库中的数据已经被其他线程修改,需要进行重试(或者直接抛异常中断)

  • 实现方式二:CAS法

CAS法类似与版本号法,但是不需要另外在添加一个 version 字段,而是直接使用库存替代版本号,线程1查询完库存后进行库存扣减操作,线程2在查询库存时,发现库存充足,也准备执行库存扣减操作,但是需要判断当前的库存是否和之前查询时的库存一致,结果发现库存数量发生了改变,这就说明数据库中的数据已经被其他线程修改,需要进行重试(或者直接抛异常中断)

拓展:CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于解决多线程环境下的数据竞争问题。它是基于乐观锁思想的一种实现方式。CAS体现的是无锁并发无阻塞并发

CAS操作包含三个参数:内存地址V、旧的预期值A和新的值B。CAS的执行过程如下:

  • 比较(Compare):将内存地址V中的值与预期值A进行比较。
  • 判断(Judgment):如果相等,则说明当前值和预期值相等,表示没有发生其他线程的修改。
  • 交换(Swap):使用新的值B来更新内存地址V中的值。


CAS操作是一个原子操作,意味着在执行过程中不会被其他线程中断,保证了线程安全性。CAS在硬件层面使用了cmpxchg(x86架构)原子指令,处理器会自动锁定总线,防止其他 CPU 访问共享变量,然后执行比较和交换操作,最后释放总线,避免了线程的上下文切换和内核态的开销。如果CAS操作失败(即当前值与预期值不相等),通常会进行重试,直到CAS操作成功为止。

CAS也有一些限制和适用条件:

  • CAS操作能够保证原子性,但无法解决ABA问题(某个值先变为A,后又变回原来的A,那么在CAS检查时可能无法识别出这种变化)。为了解决ABA问题,可以使用带有版本号或时间戳的CAS。
  • CAS操作适合在高度竞争的情况下使用,当竞争不激烈时,使用传统锁可以更好地处理。
  • CAS操作只能针对单个变量的原子操作,无法实现复杂的同步需求。而传统锁可以支持更复杂的同步机制,如读写锁、悲观锁等。

由于我们是下订单秒杀业务,需要维护的更新的字段只有库存一个,因此更适合CAS法,即不需要添加version字段,根据库存字段变化来判断是否执行更新,如果数据库查询的库存和当前数据库中的库存数量不一致,则不执行更新操作。

// 扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")    // set stoke = stoke - 1
        // where id = ? and stock = ?
        .eq("voucher_id", voucherId)
        .eq("stock", seckillVoucher.getStock())
        .update();
  • 将数据库订单全部删除,库存还原回100,JMeter压测qps200

结果是超卖成负数的问题解决了,但是一共100张库存,订单只卖了20张,库存还有80张没有卖出,还是存在超卖问题。正常来说应该是100个线程买100张,另外100个线程返回库存不足。

这个原因其实就是乐观锁的弊端,成功的概率太低。只要发现数据被修改的不一致就直接终止操作了,而其他线程扣减时的库存和之前从数据库查询到的库存数量很多都不一样,所以没有更新成功。因此我们只需要修改一下判断条件,即只要库存stock > 0满足我们当前的业务逻辑就可以进行修改,而不是库存数据修改就终止更新操作。

// 扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")    // set stoke = stoke - 1
        .eq("voucher_id", voucherId).gt("stock", 0)	// where id = ? and stock > 0
        .update();
// 或者使用LambdaUpdateWrapper更新,防止字段写错
boolean success = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
        // set stoke = stoke - 1
        .setSql("stock = stock -1")
        // where id = ? and stock > 0
        .eq(SeckillVoucher::getVoucherId, voucherId)
        .gt(SeckillVoucher::getStock, 0));
  • 再次测试,一人下多单情况下超卖问题解决!100张优惠券正常卖出,下单量100,另外100个线程用户没有抢到秒杀券。




5、单体服务下一人一单超卖问题

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单

现在的问题在于:

优惠券是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠券,所以我们应当增加一层判断逻辑,让一个用户只能下一个单,而不是让一个用户下多个单。

具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠券id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单。

// 判断是否是一人一单
Long userId = ((UserVo) BaseContext.get()).getId();
// 根据用户id和优惠券id查询订单是否存在
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
    // 该用户已经购买过了,不允许下多单
    return Result.fail("该秒杀券用户已经购买过一次了!");
}

使用JMeter测试是否完成一人一单要求,结果是库存90,同一人下了10单,还是存在一人一单超卖问题。

这个原因其实和多线程并发下一人多单超卖成负数的问题一样,线程1查询当前用户是否有该优惠券的订单,当前用户没有订单准备下单,此时线程2也查询当前用户是否有订单,由于线程1还没有完成下单操作,线程2同样发现当前用户未下单,也准备下单,这样明明一个用户只能下一单,结果下了两单,也就出现了超卖问题。




6、悲观锁解决单体服务下一人一单超卖问题

一般这种多线程超卖问题可以使用悲观锁乐观锁两种常见的解决方案。乐观锁一般是用在数据更新时判断数据是否修改,而现在是判断订单是否存在(查询)并且下订单(插入),所以无法像解决库存下单超卖一样使用CAS机制,但是可以使用版本号法,而版本号法需要新增一个字段,所以选择使用悲观锁解决超卖问题。

  • 将下订单的逻辑抽取到createSecKillVoucherOrder()方法中,使用synchronized保证并发安全,使用@Transactional保证事务一致性
/**
 * 秒杀下单优惠券
 * @param voucherId 优惠券id
 * @return 下单id
 */
@Override
public Result seckillVoucher(Long voucherId) {
    // 查询秒杀优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断秒杀是否开始
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 秒杀活动尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 判断秒杀是否结束
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 秒杀活动已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        // 秒杀券库存不足
        return Result.fail("秒杀券已抢空!");
    }
    return createSecKillVoucherOrder(voucherId);
}

@Transactional
public synchronized Result createSecKillVoucherOrder(Long voucherId) {
    // 判断是否是一人一单
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 根据用户id和优惠券id查询订单是否存在
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
        // 该用户已经购买过了,不允许下多单
        return Result.fail("该秒杀券用户已经购买过一次了!");
    }
    // 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")    // set stoke = stoke - 1
            .eq("voucher_id", voucherId).gt("stock", 0)	// where id = ? and stock > 0
            .update();
    if (!success) {
        // 扣减失败
        throw new RuntimeException("扣减失败,秒杀券扣减失败(库存不足)!");
    }
    // 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER));  // 订单id
    voucherOrder.setUserId(userId);   // 用户id
    voucherOrder.setVoucherId(voucherId);   // 代金券id
    success = this.save(voucherOrder);
    if (!success) {
        // 创建秒杀券订单失败
        throw new RuntimeException("创建秒杀券订单失败!");
    }
    // 返回订单id
    return Result.ok(voucherOrder.getId());
}

但是这种方案存在的问题就是:synchronized加在方法上,锁的范围是整个方法,锁的对象是this,不管任何一个用户来了都要加这把锁,而且是同一把锁,锁的粒度太大,整个方法是串行执行,性能很差。而一人一单只是同一个用户多次请求下单才判断并发安全问题,如果不是同一个用户则不用加锁。因此我们加锁的对象不应该是VoucherOrderServiceImpl对象,而是给用户id加锁,也就是说不同用户加的是不同的锁,这样就可以缩小锁的范围。

@Transactional
public Result createSecKillVoucherOrder(Long voucherId) {
    // 判断是否是一人一单
    Long userId = ((UserVo) BaseContext.get()).getId();
    // intern()方法才能保证每个用户的锁对象唯一
    synchronized(userId.toString().intern()) {
        // 根据用户id和优惠券id查询订单是否存在
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            // 该用户已经购买过了,不允许下多单
            return Result.fail("该秒杀券用户已经购买过一次了!");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")    // set stoke = stoke - 1
            .eq("voucher_id", voucherId).gt("stock", 0)	// where id = ? and stock > 0
            .update();
        if (!success) {
            // 扣减失败
            throw new RuntimeException("扣减失败,秒杀券扣减失败(库存不足)!");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER));  // 订单id
        voucherOrder.setUserId(userId);   // 用户id
        voucherOrder.setVoucherId(voucherId);   // 代金券id
        success = this.save(voucherOrder);
        if (!success) {
            // 创建秒杀券订单失败
            throw new RuntimeException("创建秒杀券订单失败!");
        }
        // 返回订单id
        return Result.ok(voucherOrder.getId());
    }
}

由于toString的源码底层是new String(),每次toString都是new了一个新字符串对象在堆中,所以如果我们只用userId.toString()拿到的也不是同一个用户,需要使用intern()方法,用intern()方法可以让同一个值的字符串对象不重复(放到了字符串常量池中)。如果字符串常量池中已经包含了一个等于值的String字符串对象,那么将返回池中的字符串地址引用;否则,将此String对象添加到池中,并返回对此String对象的引用。

  • 关于synchronized锁住对象:

(1)对于同步方法,锁当前对象(this)
(2)对于静态同步方法,锁当前类的Class对象
(3)对于同步代码块,锁住的是synchronized括号中的对象

在createSecKillVoucherOrder()方法内部加锁还存在一个问题,就是下完订单后先释放锁,后进行事务提交,因为@Transactional加在方法上,是由Spring进行事务管理,所以事务的提交是在方法执行完以后由Spring进行提交。由于锁已经释放了,其他线程已经可以在提交事务前进来了,而因为事务尚未提交,数据还没有写入数据库,此时其他线程查询订单依然不存在,接着再去下单,因此存在并发安全问题。

我们锁的范围小了,应该把整个方法锁起来。先获取锁,待方法执行完事务提交之后,再去释放锁,保证数据库更新的原子性,确保线程安全:

// 判断是否是一人一单,如果是再去下订单
Long userId = ((UserVo) BaseContext.get()).getId();
// intern()方法才能保证每个用户的锁对象唯一
synchronized(userId.toString().intern()) {
    return this.createSecKillVoucherOrder(userId, voucherId);
}

由于@Transactional是加在createSecKillVoucherOrder()上,而不是加在seckillVoucher()上。这里使用this.createSecKillVoucherOrder()调用,this是当前的VoucherOrderServiceImpl对象(目标对象),而不是它的代理对象。我们知道事务要想生效,其实是Spring对当前的VoucherOrderServiceImpl对象做了动态代理(Spring默认使用JDK动态代理,即对接口做代理,所以代理对象为IVoucherOrderService接口),拿到代理对象后去做事务处理。而当前的this非代理对象,而是目标对象,不具有事务功能。这个场景就是Spring事务失效的几种可能性之一。

  • Spring事务失效解决方案

我们需要使用AopContext.currentProxy()方法拿到当前目标对象的代理对象IVoucherOrderService接口,该代理对象被Spring管理,因此使用带有事务功能的代理对象去调用createSecKillVoucherOrder()方法。

  • VoucherOrderServiceImpl
/**
 * 优惠券订单Service实现类
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIDWorker redisIDWorker;

    /**
     * 秒杀下单优惠券
     * @param voucherId 优惠券id
     * @return 下单id
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询秒杀优惠券信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 秒杀活动尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 判断秒杀是否结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 秒杀活动已经结束
            return Result.fail("秒杀已经结束!");
        }
        // 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            // 秒杀券库存不足
            return Result.fail("秒杀券已抢空!");
        }
        // 判断是否是一人一单,如果是再去下订单
        Long userId = ((UserVo) BaseContext.get()).getId();
        // intern()方法才能保证每个用户的锁对象唯一
        synchronized(userId.toString().intern()) {
            // 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            // 使用带有事务功能的代理对象去调用
            return proxy.createSecKillVoucherOrder(userId, voucherId);
        }
    }

    /**
     * 判断是否是一人一单,如果是再去创建秒杀券订单
     * @param userId 用户id
     * @param voucherId 订单id
     * @return 订单id
     */
    @Transactional
    public Result createSecKillVoucherOrder(Long userId, Long voucherId) {
        // 根据用户id和优惠券id查询订单是否存在
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            // 该用户已经购买过了,不允许下多单
            return Result.fail("该秒杀券用户已经购买过一次了!");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")    // set stoke = stoke - 1
            .eq("voucher_id", voucherId).gt("stock", 0)	// where id = ? and stock > 0
            .update();
        if (!success) {
            // 扣减失败
            throw new RuntimeException("扣减失败,秒杀券扣减失败(库存不足)!");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER));  // 订单id
        voucherOrder.setUserId(userId);   // 用户id
        voucherOrder.setVoucherId(voucherId);   // 代金券id
        success = this.save(voucherOrder);
        if (!success) {
            // 创建秒杀券订单失败
            throw new RuntimeException("创建秒杀券订单失败!");
        }
        // 返回订单id
        return Result.ok(voucherOrder.getId());
    }
}
  • IVoucherOrderService
/**
 * 优惠券订单Service
 */
public interface IVoucherOrderService extends IService<VoucherOrder> {
    /**
     * 秒杀下单优惠券
     * @param voucherId 优惠券id
     * @return 下单id
     */
    Result seckillVoucher(Long voucherId);

    /**
     * 判断是否是一人一单,如果是再去创建秒杀券订单
     * @param userId 用户id
     * @param voucherId 订单id
     * @return 订单id
     */
    Result createSecKillVoucherOrder(Long userId, Long voucherId);
}
  • 由于使用AOP实现动态代理模式,因此在pom中引入aspectj依赖。
<!-- aspectj -->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
  • 在启动类中添加@EnableAspectJAutoProxy(exposeProxy = true)注解,允许暴露代理对象,默认是关闭的。
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableTransactionManagement
@EnableAspectJAutoProxy(exposeProxy = true) // 允许暴露代理对象(显式获取)
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}
  • 还原数据库,再次使用JMeter测试一人一单问题,发现相同优惠券同一个用户最多只能下一单。

本次通过悲观锁解决了单体服务下一人一单超卖问题,通过特殊的锁对象userId,减少了锁定资源的范围,从一定程度上提高了性能!




7、集群服务下一人一单超卖问题

(1)搭建服务集群并实现负载均衡

首先,在IDEA中启动两个SpringBoot程序,一个端口号是8081,另一个端口是8082(指定VM参数-Dserver.port=8082),模拟服务集群。

在Nginx中配置负载均衡:

保存nginx.conf文件,重启nginx:nginx -s reload

浏览器访问Nginx服务器(8080端口),nginx监听到8080带/api的请求,就会反向代理到backend,backend就会负载均衡到8081和8082端口。

(2)一人一单的并发安全问题

准备两个接口,两个接口的authorization相同确保两个接口是同一个用户发出,用于模拟集群下的用户重复下单。

这里虽然两个服务拿到的userId相同,但是仔细观察引用地址已经不再相同。锁不住查询订单都是0,造成集群环境下的一人一单超卖问题。

  • 有关锁失效的原因分析

synchronized是本地锁,只能保证单个JVM内部多个线程之间的互斥。由于现在我们部署了多个tomcat节点,每个tomcat都有一个属于自己的JVM,每个JVM都有自己的堆、栈、方法区和常量池。每个JVM都有一把synchronized锁,在JVM内部是一个锁监视器,多个JVM就有多个锁监视器,导致每一个锁都可以有一个线程获取,于是从原来的本地互斥锁变成了并行执行,就会发送并发安全问题。这就是在集群环境或分布式系统下,synchronized锁失效的原因,在这种情况下,我们就需要使用分布式锁(跨JVM锁、跨进程锁)来解决这个问题,让多个JVM只能使用同一把锁。




8、分布式锁介绍

前面synchronized锁失效的原因是由于每一个JVM都有一个独立的锁监视器,用于监视当前JVM中的synchronized锁,所以无法保障多个集群下只有一个线程访问一个代码块。所以我们直接将使用一个分布式锁,在整个系统的全局中设置一个锁监视器,从而保障不同节点的JVM都能够识别,从而实现集群下只允许一个线程访问一个代码块。

  • 分布式锁:满足分布式系统集群模式多进程可见并且互斥的锁。

分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路。

  • 分布式锁的特点
    • 多线程可见性:多个线程都能看到相同的结果,多个进程之间都能感知到变化
    • 互斥:互斥是分布式锁的最基本的条件,分布式锁必须能够确保在任何时刻只有一个节点能够获得锁,其他节点需要等待。
    • 高可用:分布式锁应该具备高可用性,即使在网络分区或节点故障的情况下,程序不易崩溃,仍然能够正常工作。(容错性)当持有锁的节点发生故障或宕机时,系统需要能够自动释放该锁,以确保其他节点能够继续获取锁。
    • 高性能:由于加锁本身就让性能降低,对于分布式锁需要较高的加锁性能和释放锁性能,尽可能减少对共享资源的访问等待时间,以及减少锁竞争带来的开销。
    • 安全性:(可重入性)如果一个节点已经获得了锁,那么它可以继续请求获取该锁而不会造成死锁。(锁超时机制)为了避免某个节点因故障或其他原因无限期持有锁而影响系统正常运行,分布式锁通常应该设置超时机制,确保锁的自动释放。

  • 分布式锁的常见实现方式

  • 基于关系数据库:可以利用数据库的事务特性和唯一索引来实现分布式锁。通过向数据库插入一条具有唯一约束的记录作为锁,其他进程在获取锁时会受到数据库的并发控制机制限制。
  • 基于缓存(如Redis):使用分布式缓存服务(如Redis)提供的原子操作来实现分布式锁。通过将锁信息存储在缓存中,其他进程可以通过检查缓存中的锁状态来判断是否可以获取锁。
  • 基于ZooKeeper:ZooKeeper是一个分布式协调服务,可以用于实现分布式锁。通过创建临时有序节点,每个请求都会尝试创建一个唯一的节点,并检查自己是否是最小节点,如果是,则表示获取到了锁。
  • 基于分布式算法:还可以利用一些分布式算法来实现分布式锁,例如Chubby、DLM(Distributed Lock Manager)等。这些算法通过在分布式系统中协调进程之间的通信和状态变化,实现分布式锁的功能。



9、Redis分布式锁解决集群超卖问题

由于本项目是专门学习Redis的,所以在这里将会使用Redis的setnx指令实现分布式锁解决超卖问题。

SETNX命令特点(互斥):只能设置key不存在的值,值不存在设置成功,返回1;值存在设置失败,返回0;

SET key value EX seconds NX:相当于SETNX命令,成功返回ok,失败返回nil,但是该命令可以保证setnx和expire两个命令的原子性,要么同时成功,要么同时失败。防止在执行完setnx后Redis服务宕机,还没来得及执行expire设置过期时间的情况,保证了分布式锁的安全性。

  • 获取分布式锁
# 添加锁,利用setnx互斥的特性
SETNX key value
# 为锁设置过期时间,超时释放,避免死锁
EXPIRE key time

# 为了保证上面两条命令的原子性,使用下面的命令获取锁
SET key value EX seconds NX
  • 释放分布式锁
# 手动释放(还可设置过期时间,超时剔除释放锁)
DEL key

获取锁失败后,重试获取锁有两种机制,阻塞式获取和非阻塞式获取:

  • 阻塞锁:没有获取到锁,则继续等待获取锁。浪费CPU,线程等待时间较长,实现较麻烦。
  • 非阻塞锁:尝试一次,没有获取到锁后,不继续等待,直接返回锁失败。(本次采用非阻塞机制)

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

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

/**
 * Redis分布式锁(版本一)
 */
public class SimpleRedisLock implements ILock {

    // 锁key的业务名称
    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;
    }

    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功; false代表获取锁失败
     */
    @Override
    public boolean tryLock(long timeoutSec) {
        String threadId = String.valueOf(Thread.currentThread().getId());   // 获取线程id作为value
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放锁
     */
    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}
  • 使用分布式锁改造前面VoucherOrderServiceImpl中的代码,将之前使用synchronized锁的地方,改成我们自己实现的分布式锁
@Resource
private StringRedisTemplate stringRedisTemplate;

/**
 * 秒杀下单优惠券
 * @param voucherId 优惠券id
 * @return 下单id
 */
@Override
public Result seckillVoucher(Long voucherId) {
    // 查询秒杀优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断秒杀是否开始
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 秒杀活动尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 判断秒杀是否结束
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 秒杀活动已经结束
        return Result.fail("秒杀已经结束!");
    }
    // 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        // 秒杀券库存不足
        return Result.fail("秒杀券已抢空!");
    }
    // 判断是否是一人一单,如果是再去下订单
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 创建锁对象
    SimpleRedisLock lock = new SimpleRedisLock(SECKILL_VOUCHER_ORDER + userId, stringRedisTemplate);
    // 尝试获取锁
    boolean isLock = lock.tryLock(10L);
    // 获取锁失败
    if (!isLock) {
        // 获取锁失败,返回错误信息或重试
        return Result.fail("不允许重复下单,一个人只允许下一单");
    }
    try {
        // 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        // 使用带有事务功能的代理对象去调用
        return proxy.createSecKillVoucherOrder(userId, voucherId);
    } finally {
        // 释放锁
        lock.unlock();
    }
}

因为锁的是用户id,即同一个用户不能同时下单多次,所以相同用户再次下单获取锁会失败,并且不会再次重试,直接返回错误信息。

  • 测试效果

恢复数据库并启动两台SpringBoot服务集群,在ApiFox中使用同一个用户的token发送两次请求,可以发现同一个用户只能成功获取一次锁,实现了Redis分布式锁的互斥效果,解决了一人一单集群超卖问题。




10、Redis分布式锁优化

(1)Redis分布式锁超时误删问题

上一节,我们实现了一个简单的分布式锁,但是会存在一个问题:

  • 逻辑说明:

首先线程1获取到锁,持有锁的线程1由于业务复杂或业务异常,出现了业务阻塞,而锁的过期时间少于业务完成时间,导致线程1的锁自动释放。这时线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1继续执行业务执行完毕,准备释放锁,此时就会把本应该属于线程2的锁进行删除,这就是误删其他线程锁的问题。

  • 解决方案:

在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则进行锁的删除;如果不属于自己,则不进行释放锁逻辑。我们之前是使用当前获取锁的线程id作为锁的标识,但是在多个JVM内部,因为线程id是递增分配的,可能会出现线程id重复的情况。因此我们在线程id前面添加一个UUID,用于区分不同的JVM,而线程id用于区分同一个JVM内部的不同请求。这样就保证了分布式锁的标识唯一。


(2)解决Redis分布式锁超时误删问题

本次优化主要解决了锁超时自动释放出现的超卖问题

  • SimpleRedisLock
/**
 * Redis分布式锁
 */
public class SimpleRedisLock implements ILock {

    // 锁key的业务名称
    private String name;

    private StringRedisTemplate stringRedisTemplate;

    // 锁key的统一前缀
    private static final String KEY_PREFIX = "lock:";
    // 锁value = "UUID-ThreadId",ID_PREFIX用于区分不同JVM,线程唯一标识用于区分不同服务
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

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

    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功; false代表获取锁失败
     */
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取当前JVM内部的当前线程id
        String threadId = ID_PREFIX + Thread.currentThread().getId();   // 锁value = "UUID-ThreadId"
        // 尝试获取锁
        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 (threadId.equals(id)) {
            // 如果一致,释放锁,否则什么都不做
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

(3)Redis分布式锁释放锁原子性问题

  • 问题分析

在上一节中,我们通过给锁添加一个线程唯一标识,并且在释放锁时添加一个判断,从而防止锁超时自动释放的问题,但是仍然存在超卖问题:

当线程1获取到锁并且执行完业务后,判断完当前锁是自己的锁正准备释放锁时,由于JVM的垃圾回收机制导致短暂的阻塞发生了阻塞,恰好在阻塞期间锁被超时释放了。线程2获得锁执行业务,但就在此时线程1阻塞完成,由于已经判断过锁标识,已经确定锁是自己的锁了,于是直接删除了锁。而这时删的是线程2的锁,没有了锁的互斥,线程3再来了之后就会发生超卖问题。

  • 原因分析

因为判断锁标识和释放锁的这两个操作不是真的原子性,而是在java代码中判断的,在这两个操作之前虽然没有任何Java代码,但是由于JVM中的垃圾回收机制Full GC的存在,就有可能出现阻塞问题(概率非常低)。

  • 解决方案

所以为了解决这个问题,必须要保证判断锁标识释放锁这两个动作是一个原子性操作。因此我们需要使用Lua脚本


(4)解决Redis分布式锁释放锁原子性问题

本次优化主要解决了释放锁时的原子性问题(本质还是锁超时误删问题)。

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基本语法参考菜鸟教程:https://www.runoob.com/lua/lua-tutorial.html

这里重点介绍Redis提供的调用函数,语法如下:

# 执行Redis命令
redis.call('命令名称', 'key', '其他参数', ...)

例如,我们要执行set name jack,则脚本是这样:

# 执行 set name jack
redis.call('set', 'name', 'jack')

例如,我们要先执行set name jack,再执行get name,则脚本如下:

# 先执行 set name jack
redis.call('set', 'name', 'jack')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name

写好脚本以后,需要用Redis命令来调用脚本,调用脚本的命令用法如下:

例如,我们要执行redis.call('set', 'name', 'jack')这个脚本,语法如下:

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

  • 优化分布式锁

IDEA中编写Lua脚本语法高亮提示插件:EmmyLua(安装第二个Luanalysis也可以)

释放锁的业务流程是这样的:

  1. 获取锁中的线程标示
  2. 判断是否与指定的标示(当前线程标示)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做
  • 编写释放锁的Lua脚本:unlock.lua
-- 锁的key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]
-- 获取锁中的线程标识
local id = redis.call('get', key)
-- 比较当前线程标识是否和锁中的线程标识一致
if (id == threadId) then
    -- 一致,释放锁
    return redis.call('del', key)
end
-- 不一致,返回0,表示释放锁失败
return 0
  • SimpleRedisLock
/**
 * Redis分布式锁
 */
public class SimpleRedisLock implements ILock {

    // 锁key的业务名称
    private String name;

    private StringRedisTemplate stringRedisTemplate;

    // 锁key的统一前缀
    private static final String KEY_PREFIX = "lock:";
    // 锁value = "UUID-ThreadId",ID_PREFIX用于区分不同JVM,线程唯一标识用于区分不同服务
    // 因为这里锁是final的静态常量,仅在项目启动时随着该类加载,而去初始化该变量,UUID只会被初始化一次,所以当前服务器JVM拿到的ID_PREFIX都一样
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    // RedisScript接口实现类
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    // 静态代码块初始化加载脚本
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("script/unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }


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

    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功; false代表获取锁失败
     */
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取当前JVM内部的当前线程id
        String threadId = ID_PREFIX + Thread.currentThread().getId();   // 锁value = "UUID-ThreadId"
        // 尝试获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        // 拆箱判断
        return Boolean.TRUE.equals(success);
    }

    /**
     * 调用lua脚本释放锁
     */
    @Override
    public void unlock() {
        // Redis调用Lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId()
        );
    }
}
  • 测试效果

同时启动两台tomcat服务,用ApiFox模拟同一个用户同时重复下单两次请求。假设线程1先获取到锁

线程1的锁线程标识为:2264fef5b6544e8f889bb209cba9569a-50,此时删除线程1的锁,模拟锁超时释放

由于线程1的锁已经被超时释放,线程2现在也可以获取到锁

线程2的锁线程标识为:9fb0b47e16bb4a2791c87085e00014a3-54

此时线程1执行完业务,尝试释放锁,由于现在锁是线程2的,线程1判断线程标识不同后,不会进行锁误删。线程2仍然可以在锁内正常执行业务。

当线程2执行完业务并释放锁后,锁才会正常被删除,并且通过Lua脚本保证了判断锁标识和删除锁两个操作的原子性,不再受到JVM垃圾回收机制的干扰,进一步避免了锁超时后的误删问题。

现在我们的分布式锁满足了以下特性:

  1. 多线程可见,将锁放到Redis中,相当于全局的锁监视器,所有的JVM都可以同时看到
  2. 互斥,set ex nx指令互斥
  3. 高可用,层层优化,即使是特别极端的情况下照样可以防止超卖,后期Redis还可以扩展搭建主从集群
  4. 高性能,Redis的IO速度很快,Lua脚本的性能也很快
  5. 安全性,通过给锁设置当前线程标识+Lua封装Redis指令,充分保障了线程安全,同时采用超时释放避免死锁

基于Redis的分布式锁实现思路:

  1. 利用set ex nx获取锁,并设置过期时间,保存线程标识
  2. 释放锁时先判断线程标识是否与自己一致,一致则删除锁



11、Redisson

经过对我们自定义实现的Redis分布式锁的优化后,已经达到生产可用级别了,但是还不够完善,比如:

  • 不可重入:同一线程不能重复获取同一把锁。比如,方法A中调用方法B,在方法A中执行业务并获取锁,方法B需要获取同一把锁,如果锁是不可重入的,在方法A获取了锁,方法B无法再次获取这把锁,方法B此时就会等待方法A的锁释放,而方法A还没有执行完,因为还在调方法B,导致死锁。这种场景下要求锁是可重入的,可重入锁的意义在于防止死锁,我们的synchronized和Lock锁都是可重入的。
  • 不可重试:我们之前实现的锁是非阻塞式,获取锁只尝试一次就返回false,没有重试机制。有些业务场景在获取锁失败后,需要等待一小段时间,再次进行重试的(阻塞式、可重试的)。
  • 超时释放:虽然我们之前利用判断锁标识+Lua脚本解决了因为锁超时释放导致的误删问题,但是还是存在超时释放的时间问题。
    • 如果业务执行耗时过长,期间锁就释放了,这样存在安全隐患。
    • 如果锁的有效期过短,容易出现业务没执行完就被释放,这样存在并发安全问题。
    • 如果锁的有效期过长,容易出现锁的阻塞周期过长问题。
  • 主从一致性问题:如果Redis提供了主从集群(相当于读写分离,写操作访问主节点,读操作访问从节点),主节点需要把自己的数据同步给从节点,保证主从数据一致,如果主节点宕机,还可以选择一个从节点成为新的主节点。但是主从同步之间存在延迟,比如在极端情况下,线程在主节点获取了锁,写操作在主节点完成后尚未同步给从节点时,主节点宕机,此时会选一个新的从作为主,而从节点没有完成数据同步,没有锁的标识,此时多个从节点就会获取到锁,存在安全隐患。

我们如果想要更进一步优化分布式锁,当然是可以的,但是没必要,我们完全可以直接使用已经造好的轮子,比如:Redssion


(1)介绍

官方定义:Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。

简单来讲Redisson是一个在Redis的基础上实现的分布式工具的集合。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

Redission提供了分布式锁的多种多样的功能

官网地址:https://redisson.org

GitHub地址:https://github.com/redisson/redisson

Redisson帮助文档:https://redisson.org/docs/


(2)Redisson实现分布式锁

  • 引入redisson依赖
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.37.0</version>
</dependency>

温馨提示:此外还有一种引入方式,直接引入redission的starter依赖,然后在yam|文件中配置Redisson,但是不推荐这种方式,因为他会替换掉Spring官方提供的这套对 Redisson 的配置。所以我们采用@Bean手动配置。

  • RedissonConfig:配置Redisson客户端
/**
 * Redisson配置类
 */
@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://192.168.8.100:6379").setPassword("123321");
        // 创建RedissonClient客户端对象
        return Redisson.create(config);
    }
}
  • VoucherOrderServiceImpl:只需要修改使用锁的地方,将我们自己实现的分布式锁SimpleRedisLock替换成Redisson,其它的业务代码无需修改
@Resource
private RedissonClient redissonClient;

/**
 * 秒杀下单优惠券
 * @param voucherId 优惠券id
 * @return 下单id
 */
@Override
public Result seckillVoucher(Long voucherId) {
    // 业务校验省略...

    // 判断是否是一人一单,如果是再去下订单
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 创建锁对象(可重入),指定锁的名称
    RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + SECKILL_VOUCHER_ORDER + userId);
    // 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    // 空参表示默认参数,获取锁的最大等待时间waitTime为-1,表示获取失败不等待不重试,直接返回结果;锁自动释放时间leaseTime为30秒,表示超过30秒还没有释放的话会自动释放锁
    boolean isLock = lock.tryLock();    // 空参默认失败不等待
    // 获取锁失败
    if (!isLock) {
        // 获取锁失败,返回错误信息或重试(该业务是一人一单,直接返回失败信息,不重试)
        return Result.fail("不允许重复下单,一个人只允许下一单");
    }
    try {
        // 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        // 使用带有事务功能的代理对象去调用
        return proxy.createSecKillVoucherOrder(userId, voucherId);
    } finally {
        // 释放锁
        lock.unlock();
    }
}
  • tryLock 方法介绍
    • tryLock():它会使用默认的超时时间(默认30秒)和等待机制。具体的超时时间是由 Redisson 配置文件或者自定义配置决定的。
    • tryLock(long time,TimeUnit unit):它会在指定的时间内尝试获取锁(等待time后重试),如果获取成功则返回 true,表示获取到了锁;如果在指定时间内(Redisson内部默认指定的)未能获取到锁,则返回false。
    • tryLock(long waitTime,long leaseTime,TimeUnit unit)waitTime是获取锁失败后的重试等待时间,等待时间过了之后会接着重试,例如等待时间为一秒,那获取锁失败等一秒后再次尝试获取锁,在超时时间内反复重试,直到获取锁成功后返回true。leaseTime 是锁的超时时间,如果超过 leaseTime 后还没有获取锁就直接返回false。

总之tryLock的灵活性逐渐提高,无参tryLock()时,waitTime的默认值是-1,代表不等待,leaseTime的默认值是30,unit默认值是 seconds ,也就是锁超过30秒还没有释放就自动释放。


(3)Redisson分布式锁原理

  • 什么是重入锁和不可重入锁?
  1. 可重入锁:又称之为递归锁,也就是一个线程可以反复获取锁多次,一个线程获取到锁之后,内部如果还需要获取锁,可以直接再获取锁,前提是同一个对象或者class。可重入锁的最重要作用就是避免死锁的情况。
  2. 不可重入锁:又称之为自旋锁,底层是一个循环加上unsafe和cas机制,就是一直循环直到抢到锁,这个过程通过cas进行限制,如果一个线程获取到锁,cas会返回1,其它线程包括自己就不能再持有锁,需要等线程释放锁。
① 可重入锁的原理

ReentrantLock和synchronized都是可重入锁。在ReentrantLock锁中,底层借助于一个voaltile的state变量来记录重入状态的次数的,所以允许一个线程多次获取资源锁。第一次调用lock时,计数设置为1,再次获取资源锁时加1,调用unlock解锁,计数减1,直到减为0,释放锁资源。在synchronized锁中,它在c语言代码中会有一个count,原理和state类似,也是重入一次就+1,释放一次就-1 ,直到减少成 0 时,表示当前这把锁没有被持有。

我们之前的自定义的分布式锁不具有可重入性的原因,是因为:重入锁的设计必须要求既记录线程标识,又要记录重入次数,而我们String数据类型的锁已经不够用了。因此,需要一个key里同时记录两个字段的情况,可以使用hash数据结构。

而Redisson底层也是以 hash 数据结构的形式将锁存储在Redis中,并且Redisson分布式锁也具有可重入性,每次获取锁,都将 value 的值+1,每次释放锁,都将 value 的值-1,只有锁的 value 值归0时才会真正的释放锁,从而确保锁的可重入性。

  • 测试Redisson锁的可重入性
@SpringBootTest
@Slf4j
public class RedissonLockTest {

    @Resource
    private RedissonClient redissonClient;

    // 创建锁对象
    private RLock lock = redissonClient.getLock("lock");

    /**
     * 方法1 获取一次锁
     */
    @Test
    void method1() {
        boolean isLock = false;
        try {
            // 尝试获取锁
            isLock = lock.tryLock();
            if (!isLock) {
                log.error("1...获取锁失败");
                return;
            }
            log.info("1...获取锁成功");
            // 方法1内部调用方法2
            log.info("1...开始执行并发业务");
            method2();
            log.info("1...继续执行剩余的并发业务");
        } finally {
            if (isLock) {
                log.warn("1...释放锁");
                lock.unlock();
            }
        }
    }

    /**
     * 方法2 再获取一次锁
     */
    void method2() {
        log.info("2...业务方法1调用执行业务方法2");
        boolean isLock = false;
        try {
            // 再次尝试获取锁
            isLock = lock.tryLock();
            if (!isLock) {
                log.error("2...获取锁失败");
                return;
            }
            log.info("2...获取锁成功");
            log.info("2...开始执行业务");
        } finally {
            if (isLock) {
                log.warn("2...释放锁");
                lock.unlock();
            }
        }
    }
}
  • 执行流程如下


② Redisson源码流程原理解析

之前我们分析发现,自己实现的锁不够灵活,具有不可重入、不可重试、超时释放、主从一致性四大问题。虽然我们对自定义的Redis分布式锁进行了优化,但是不如直接使用别人造好的轮子,Redisson分布式锁不仅提供了多种锁实现,而且还解决了分布式锁不够灵活的这四大问题,下面我们通过深入剖析Redisson源码,看看底层是如何实现和解决:锁的可重入性可重试机制超时续约机制主从一致性问题的。

1)Redisson的可重入锁原理

因为在之前我们使用String类型的setnx命令可以保证获取锁的原子性,释放锁的原子性使用了Lua脚本。但是我们为了保证锁的可重入性,需要使用Hash结构来存储线程标识和重入次数这两个字段,而Hash类型中并没有这种组合命令保证原子性。所以下面我们来分析一下,Redisson的获取锁 tryLock 和释放锁 unlock 方法的底层是如何实现这种重入性,并且保证命令的原子性的

  • tryLock源码

首先跟踪一下tryLock的源码

空参调用的是Lock接口的空参方法,有参调用的是RLock接口的有参方法,不管是哪种调用,我们选择创建锁对象的实现类都是RedissonLock

我们从空参和有参的方法同步进行分析,最后它们都会执行到同一个获取锁方法。

空参tryLock调用tryLockAsync方法内部传入的waitTime和leaseTime的默认值都是-1,调用tryAcquireOnceAsync方法作为参数。

有参tryLock经过传递调用,最后也同样会执行tryAcquireOnceAsync方法。

在tryAcquireOnceAsync方法内部,如果过期时间leaseTime > 0,说明调用trylockInnerAsync方法,该方法最终也是调用Lua脚本保证命令原子性,尝试获取锁的脚本逻辑如下。

  • unlock源码

跟踪unlock接口的抽象实现类RedissonBaseLock

经过层层调用,最终释放锁的实现类还是RedissonLock

实现类RedissonLock对父类的unlockInnerAsync抽象方法进行了重写实现,可以看到,内部还是使用Lua脚本去构建和执行释放锁的逻辑。

由于这段脚本中KEYS和ARGV参数的可读性不强,所以这里单独提取出来,方便阅读。释放锁的脚本逻辑如下:

--- 新版Redisson释放锁Lua脚本
local lockKey = KEYS[1];    -- 锁的key
local channelName = KEYS[2];    -- 频道名称
local unlockLatchNameByRequestId = KEYS[3]; -- 释放锁请求id
local unlockMessage = ARGV[1];  -- 释放锁消息
local leaseTime = ARGV[2];  -- 锁的有效期
local threadId = ARGV[3];   -- 线程唯一标识
local publishCommand = ARGV[4]; -- 发布消息命令
local timeout = ARGV[5];    -- 超时时间

-- 获取解锁请求标志位的值
local val = redis.call('get', unlockLatchNameByRequestId);
-- 如果标志位存在且不为false,直接返回其数值
if val ~= false then
    return tonumber(val);
end;
-- 检查锁是否是当前线程所属的
if (redis.call('hexists', lockKey, threadId) == 0) then
    -- 如果锁不属于当前线程,则不能释放其他线程的锁,返回nil
    return nil;
end;
-- 在释放锁之前,先将当前线程锁的重入计数器减一
local counter = redis.call('hincrby', lockKey, threadId, -1);
-- 再判断计数器是否大于0,如果重入次数大于0,说明锁的业务不在最外层,因此不能释放锁
if (counter > 0) then
    -- 重置锁的过期时间
    redis.call('pexpire', lockKey, leaseTime);
    -- 设置释放锁请求标志位为0,并设置超时时间
    redis.call('set', unlockLatchNameByRequestId, 0, 'px', timeout);
    -- 释放锁失败,仅将重入计数器-1后返回0
    return 0;
else    -- 如果重入计数器等于0,说明该锁的最外层业务已执行完,可以释放锁
    -- 删除锁
    redis.call('del', lockKey);
    -- 向释放锁的频道发布锁已释放的消息,通知每一个订阅该频道的消费者
    redis.call(publishCommand, channelName, unlockMessage);
    -- 设置释放锁请求标志位为1,并设置过期时间
    redis.call('set', unlockLatchNameByRequestId, 1, 'px', timeout);
    -- 释放锁成功,返回1
    return 1;
end;

总结:Redisson锁重入也是利用hash结构记录线程id和重入次数。重入性的实现和可重入锁所使用的原理类似,并且通过Lua脚本保证命令执行的原子性。

注意:本文分析的是Redisson 3.37.0最新版本源码,旧版源码实现方式可能有所不同,但大同小异,原理都是差不多的。


2)Redisson的锁重试和WatchDog机制源码解析

刚刚我们分析了我们自定义的分布式锁不可重入的原因,并且剖析了Redisson的tryLock和unlock的源码,知道了它解决锁不可重入问题的实现原理是:通过hash类型的锁结构的设计,存储当前线程标识和重入计数器,来控制当前线程的再次重入和重入锁释放锁。这和JDK里的ReentrantLock重入锁的实现原理基本一致。

下面我们来看一下Redisson是如何解决不可重试、超时释放问题的。

  • 锁重试

redisson在尝试获取锁的时候,如果传了时间参数,就不会在获取锁失败时立即返回失败,而是会进行重试。三个参数:最大重试时间,锁释放时间(默认为-1,会触发看门狗机制),时间单位。

  • 源码分析:

在RLock接口中,找到三个参数的tryLock方法的实现,选择RedissonLock

tryLock(long waitTime, long leaseTime, TimeUnit unit) 方法解读:

@Override
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();
    // 尝试获取锁,返回的ttl为null,表示获取成功
    Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
    // lock获取成功
    if (ttl == null) {
        return true;
    }
    // 获取失败,计算剩余时间
    time -= System.currentTimeMillis() - current;
    // 剩余时间小于等于零,不再重试,返回失败
    if (time <= 0) {
        acquireFailed(waitTime, unit, threadId);
        return false;
    }
    // 剩余时间大于零,重新获取当前时间current,subscribe订阅拿到锁的线程,该线程释放锁后会发布通知,其余等待的线程可以继续争抢。
    current = System.currentTimeMillis();
    // 创建一个用于订阅锁释放通知的subscribeFuture,调用subscribe()方法进行订阅
    CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
    try {
        // 等待time毫秒时间,等待订阅结果
        subscribeFuture.get(time, TimeUnit.MILLISECONDS);
    } catch (TimeoutException e) {
        // 如果在等待期间未收到订阅结果,表示等待超时。在等待超时后,代码会尝试取消订阅任务
        if (!subscribeFuture.completeExceptionally(new RedisTimeoutException(
                "Unable to acquire subscription lock after " + time + "ms. " +
                        "Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
            // 判断是否需要取消订阅,并调用unsubscribe()方法进行处理
            subscribeFuture.whenComplete((res, ex) -> {
                if (ex == null) {
                    unsubscribe(res, threadId);
                }
            });
        }
        // 如果取消成功,则代码调用acquireFailed()方法进行处理,表示当前线程获取锁失败,最终返回 false
        acquireFailed(waitTime, unit, threadId);
        return false;
    } catch (ExecutionException e) {
        LOGGER.error(e.getMessage(), e);
        acquireFailed(waitTime, unit, threadId);
        return false;
    }

    try {
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(waitTime, unit, threadId);
            return false;
        }
    
        while (true) {
            long currentTime = System.currentTimeMillis();
            // 仍有等待时间,则进行获取锁重试
            ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
            // lock获取成功
            if (ttl == null) {
                return true;
            }
            // // 剩余时间小于等于零,不再重试,返回失败
            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(waitTime, unit, threadId);
                return false;
            }

            // waiting for message
            currentTime = System.currentTimeMillis();
            if (ttl >= 0 && ttl < time) {	// ttl < time(等待时间),代表在等待之间锁就已经释放了
                // 信号量形式订阅等待锁释放
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {	// ttl > time(等待时间),如果等了time的时间,经过time的时间,锁还没有被释放,也就没必要等了
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
            }
            // 剩余时间小于等于零,不再重试,返回失败
            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(waitTime, unit, threadId);
                return false;
            }
            // 若time > 0,表示时间还很充足,仍可等待,继续执行while(true)循环
        }
    } finally {
        // 取消订阅
        unsubscribe(commandExecutor.getNow(subscribeFuture), threadId);
    }
//        return get(tryLockAsync(waitTime, leaseTime, unit));
}

这段代码实现了在等待获取锁的过程中对剩余时间的动态调整,确保在等待过程中可以根据实际情况调整等待时间,这里设计的巧妙之处就在于利用了PubSub消息订阅、信号量的机制,它不是无休止的这种盲等机制,也避免了不断的重试,而是检测到锁被释放才去尝试重新获取,这对CPU十分的友好。同时,使用 try-finally 语句块确保在获取锁过程中发生异常时可以正确地取消订阅并释放资源。

订阅的通知就是释放锁Lua脚本当中发布的通知,然后等待订阅结果,等待的时间就是time(锁的最大剩余时间);

总结:只要给定waitTime最大重试时间,就可以做到锁重试的机制。

  • 看门狗(WatchDog)机制

线程获取锁,由于业务阻塞导致锁超时释放了该如何解决呢?这就需要使用看门狗机制了,确保我们的业务是执行完释放,而不是阻塞释放。

  • 源码分析:

tryAcquireAsync()方法

当我们没有设置leaseTime的时候,也就是内部leaseTime=-1的时候,过期时间为默认 internalLockLeaseTime。查看如下代码可知 internalLockLeaseTime 调用 getLockwatchdogTimeout()赋值默认时间是30s。

回到tryAcquireAsync()方法,接着看下面的续期方法

进入 scheduleExpectationRenew(long threadId) 方法中查看

EXPIRATION_RENEWAL_MAP中的key我们进去看一下,发现这是一个concurrentHashMap,并且entryNameidname两部分组成,id就是当前连接的id,name就是当前锁的名称。

renewExpiration()方法主要开启一段定时任务,不断的去更新有效期,定时任务的的时间就是internalLockLeaseTime / 3,默认也就是10s后刷新有效期

// 到期续约方法
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {	// 从map中没有获取到,直接返回
        return;
    }
    // Timeout定时任务,或者叫周期任务
    Timeout task = getServiceManager().newTimeout(new TimerTask() {	// 开启异步定时任务
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            // renewExpirationAsync()执行续命操作的方法,10s之后刷新有效期
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                if (e != null) {
                    if (getServiceManager().isShuttingDown(e)) {
                        return;
                    }

                    log.error("Can't update lock {} expiration", getRawName(), e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }
                
                if (res) {
                    // reschedule itself
                    renewExpiration();	// 递归调用自己,一直续期,直到锁释放
                } else {
                    cancelExpirationRenewal(null, null);
                }
            });
        }
        // 刷新周期internalLockLeaseTime / 3, 默认释放时间是30秒,除以3就是每10秒更新一次
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

因此,WatchDog机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个RedissonBaseLock.EXPIRATION_RENEWAL_MAP里面,然后每隔10秒(internalLockLeaseTime / 3)检查一下,如果客户端1还持有锁key(判断客户端是否还持有key,其实就是遍历EXPIRATION_RENEWAL_MAP里面线程id,然后根据线程id去Redis中查,如果存在就会延长key的时间),那么就会不断的延长锁key的生存时间,重置锁的超时时间。

renewExpirationAsync方法源码,其调用了Lua脚本执行续期操作的。代码如下图:

看完了刷新续约操作,我们知道了它会递归调用续约方法,一直刷新有效期,那么什么时候才不进行续命呢?当然是在释放锁的时候。我们在来看看释放锁操作。

释放锁操作,取消续期

在unlockAsync0方法中,当执行完lua脚本删除锁操作后,返回了个future,当这个future执行完成后,就执行取消续期操作。源码如下图:

跟进着看cancelExpirationRenewal(threadId)这个方法:

先从map中取出任务,先移除任务的线程Id,再取消这个任务,最后再移除entry,到这里看门狗的流程就结束了。

注意:如果服务宕机了,因为没有人再去调用renewExpiration这个方法,WatchDog机制线程也就没有了,所以等到时间之后自然就释放了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程就可以获取到锁。如果调用带过期时间的lock方法,则不会启动看门狗任务去自动续期。

整体执行流程图

总结:看门狗机制是解决超时续期的问题,在获取锁成功以后,开启一个WatchDog定时任务,每隔一段时间(默认30 秒)就会去重置锁的超时时间,以确保锁是在程序执行完unlock手动释放的,不会发生因为业务阻塞key超时,而导致锁自动释放的情况。只要任务在运行中,看门狗就会持续续期。

3)Redisson的主从一致性问题解决方案(MultiLock原理)

下面我们来看一下Redisson是如何解决主从一致性问题的。

为了提高redis的可用性,我们会搭建集群或者主从,现在以主从为例

此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。

为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

那么MultiLock加锁原理是什么呢?下面这幅图来说明

当我们去设置了多个锁时,redission会将多个锁添加到一个集合中,然后用while循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试。

  • 测试主从节点锁的一致性

1)注入三个RedissonClient

@Bean
public RedissonClient redissonClient() {
    // 获取Redisson配置对象
    Config config = new Config();
    // 添加redis地址,这里添加的是单节点地址,也可以通过config.userClusterServers()添加集群地址
    config.useSingleServer().setAddress("redis://192.168.8.100:6379").setPassword("123321");
    // 获取RedisClient对象,并交给IOC进行管理
    return Redisson.create(config);
}

@Bean
public RedissonClient2 redissonClient() {
    // 获取Redisson配置对象
    Config config = new Config();
    // 添加redis地址,这里添加的是单节点地址,也可以通过config.userClusterServers()添加集群地址
    config.useSingleServer().setAddress("redis://192.168.8.100:6380");
    // 获取RedisClient对象,并交给IOC进行管理
    return Redisson.create(config);
}

@Bean
public RedissonClient3 redissonClient() {
    // 获取Redisson配置对象
    Config config = new Config();
    // 添加redis地址,这里添加的是单节点地址,也可以通过config.userClusterServers()添加集群地址
    config.useSingleServer().setAddress("redis://192.168.8.100:6381");
    // 获取RedisClient对象,并交给IOC进行管理
    return Redisson.create(config);
}

2)编写测试类

@Resource
private RedissonClient redissonClient;
@Resource
private RedissonClient redissonClient2;
@Resource
private RedissonClient redissonClient3;

private RLock lock;

@BeforeEach
void setUp(){
    RLock lock1 = redissonClient.getLock("lock");
    RLock lock2 = redissonClient2.getLock("lock");
    RLock lock3 = redissonClient3.getLock("lock");
    // 创建联锁 MultiLock
    redissonClient.getMultiLock(lock1, lock2, lock3);
}

// 获取锁和释放锁代码与之前都是一样的,执行代码后可以在节点1、节点2、节点3中查看到锁,联锁中的每一个锁都是可重入锁
  • 源码分析:

1)先来看获取锁:RLock multiLock = redisson.getMultiLock(lock1, lock2, lock3);实际上就是拿个一个数组来存放这些锁。

@Override
public RLock getMultiLock(RLock... locks) {
    return new RedissonMultiLock(locks);
}

// RedissonMultiLock.java
// 存放到一个列表中
final List<RLock> locks = new ArrayList<>();

public RedissonMultiLock(RLock... locks) {
    if (locks.length == 0) {
        throw new IllegalArgumentException("Lock objects are not defined");
    }
    this.locks.addAll(Arrays.asList(locks));
}

2)加锁

// RedissonMultiLock.java
@Override
public void lock() {
    try {
        lockInterruptibly();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

@Override
public void lockInterruptibly() throws InterruptedException {
    lockInterruptibly(-1, null);
}

@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    // 1、计算等待时间 leaseTime=-1 unit=null
    // baseWaitTime等待时间 = 锁的数量(3个) * 1500 = 4500毫秒
    long baseWaitTime = locks.size() * 1500;
    // 2、等待全部加锁成功
    while (true) {
        long waitTime;
        if (leaseTime <= 0) {
            // waitTime = 4500毫秒
            waitTime = baseWaitTime;
        } else {	// 自定义等待时间
            waitTime = unit.toMillis(leaseTime);
            if (waitTime <= baseWaitTime) {
                waitTime = ThreadLocalRandom.current().nextLong(waitTime/2, waitTime);
            } else {
                waitTime = ThreadLocalRandom.current().nextLong(baseWaitTime, waitTime);
            }
        }

        if (leaseTime > 0) {
            leaseTime = unit.toMillis(leaseTime);
        }
        // 不停的去获取锁,waitTime = 4500毫秒,leaseTime = -1
        if (tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS)) {
            return;
        }
    }
}

此过程,主要分为两部分:

  1. 计算等待时间:跟锁数量相关; 3个锁,等待时间 = 3 * 1500 = 4500
  2. 尝试加全部锁:无限循环,至全部锁加成功

3)进入tryLock(),查看其源码:

@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
//        try {
//            return tryLockAsync(waitTime, leaseTime, unit).get();
//        } catch (ExecutionException e) {
//            throw new IllegalStateException(e);
//        }
    long newLeaseTime = -1;
    if (leaseTime > 0) {	// 给锁一个初始化的有效期,并且这个时间是大于我们需要的锁的有效期的
        if (waitTime > 0) {
            newLeaseTime = unit.toMillis(waitTime)*2;
        } else {
            newLeaseTime = unit.toMillis(leaseTime);
        }
    }
    
    long time = System.currentTimeMillis();	// 记录加锁的过程开始时间
    long remainTime = -1;	// 加锁剩余时间
    if (waitTime > 0) {
        remainTime = unit.toMillis(waitTime);
    }
    long lockWaitTime = calcLockWaitTime(remainTime);	// 官方弃用了RedissonRedLock, 就是剩余时间
    
    int failedLocksLimit = failedLocksLimit();	// 官方弃用了RedissonRedLock,failedLocksLimit()返回值是0
    List<RLock> acquiredLocks = new ArrayList<>(locks.size());	// 需要加锁的lock集合
    for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {	// 拿到锁的迭代器
        RLock lock = iterator.next();	// 获取其中一个锁
        boolean lockAcquired;	// 是否加锁成功
        try {
            if (waitTime <= 0 && leaseTime <= 0) {	// 如果等待时间,有效时间都没有设置就使用默认的方式去获取锁
                lockAcquired = lock.tryLock();
            } else {
                // awaitTime=-1,在tryLock中,-1代表了如果获取锁成功了,就会启动一个lock watchDog,不停的刷新锁的生存时间
                long awaitTime = Math.min(lockWaitTime, remainTime);
                // 获取锁,等待awaitTime=4500毫秒,获取锁成功,启动一个watchDog
                lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
            }
        } catch (RedisResponseTimeoutException e) {	// 任意一台redis服务器响应超时了,就应该释放所有锁
            unlockInner(Arrays.asList(lock));
            lockAcquired = false;
        } catch (Exception e) {
            lockAcquired = false;
        }
        
        if (lockAcquired) {	// 加锁成功了就添加进去
            acquiredLocks.add(lock);
        } else {
            if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {	// 若是成功的锁达到了设定的值就不用再去获取锁了
                break;
            }
            // 失败机制,设置为0 且还加锁失败了那就直接清空所有锁,本次获取锁失败了
            if (failedLocksLimit == 0) {
                unlockInner(acquiredLocks);
                if (waitTime <= 0) {
                    return false;
                }
                failedLocksLimit = failedLocksLimit();
                acquiredLocks.clear();
                // reset iterator
                while (iterator.hasPrevious()) {
                    iterator.previous();
                }
            } else {
                failedLocksLimit--;
            }
        }
        // 计算本次加锁花费的时间 ,看看是否超时
        if (remainTime > 0) {
            // 如果获取锁成功,当前时间减去获取锁耗费的时间time
            remainTime -= System.currentTimeMillis() - time;
            time = System.currentTimeMillis();
            if (remainTime <= 0) {
                // 如果remainTime < 0 说明获取锁超时,那么就释放掉这个锁
                unlockInner(acquiredLocks);
                return false;	// 返回false,加锁失败
            }
        }
    }
    // 若是没超时说明都加锁成功了,就添加锁的过期时间
    if (leaseTime > 0) {
        acquiredLocks.stream()
                .map(l -> (RedissonBaseLock) l)
                .map(l -> l.expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS))
                .forEach(f -> f.toCompletableFuture().join());
    }
    
    return true;
}

4)unlock()释放锁,每个锁调用自己的unlock 方法

// 在RedissonMultiLock中释放锁,就是依次调用所有的锁的释放的逻辑,lua脚本,同步等待所有的锁释放完毕,才会返回
@Override
public void unlock() {
    locks.forEach(Lock::unlock);
}

调用的是之前分析过的RedissonBaseLock中unlock方法,最终指向的是各自重写的unlockInnerAsync(),即异步解锁方法。


Redisson分布式锁原理

  • 可重入:利用hash结构记录线程id重入次数

  • 可重试:利用信号量PubSub功能实现等待、唤醒,获取锁失败的重试机制。

  • 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间。

  • 主从一致性:利用multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。

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

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

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

    • 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
    • 缺陷:运维成本高、实现复杂



12、Redis异步秒杀优化

(1)秒杀业务性能测试

  • 在测试类中添加如下方法,根据1000条用户信息生成1000条token
@SpringBootTest
public class GenerateTokenTest {
    
    @Resource
    private IUserService userService;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Test
    public void generateToken() throws IOException {
        // 数据库查询1000个用户信息
        List<User> userList = userService.list(new QueryWrapper<User>().last("limit 1000"));
        // 创建字符输出流准备写入token到文件
        BufferedWriter br = new BufferedWriter(new FileWriter("D:\\Develop\\Redis-code\\hm-dianping\\src\\test\\java\\com\\hmdp\\tokens.txt"));
        for (User user : userList) {
            // 随机生成Token作为登录令牌
            String token = UUID.randomUUID().toString(true);
            // 将User对象转为Hash存储
            UserVo userVo = BeanUtil.copyProperties(user, UserVo.class);
            // 将User对象转为HashMap存储
            Map<String, Object> userMap = BeanUtil.beanToMap(userVo, new HashMap<>(),
                    CopyOptions.create()
                            .setIgnoreNullValue(true)
                            .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
            // 保存用户token到redis,设置token有效期
            String tokenKey = LOGIN_USER_KEY_PREFIX + token;
            stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
            stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
            // 写入token到文件
            br.write(token);
            br.newLine();
            br.flush();
        }
    }
}

执行结果如下,生成了1000条用户登录token到tokens.txt,方便JMeter读取


清空订单表,更改秒杀库存为200

Jmeter中线程数设为1000,执行时间为1秒,模拟1000个用户高并发访问秒杀业务

在CSV数据文件设置中进行如下设置

在HTTP信息头管理器中进行如下设置

测试秒杀id为14的优惠券接口

  • 测试结果

由于JMeter模拟发送请求不是同时发送,是又少到多的请求,所以响应时间最小值是4毫秒,最大值是1727毫秒,平均响应时间是1215毫秒,吞吐量为556.2/sec(随着并发量增加,吞吐量减少)。

  • 数据库200条数据正常扣减库存


(2)异步秒杀思路分析

之前的秒杀业务流程:

  1. 查询优惠券,判断是否在秒杀时间区间、判断秒杀券库存是否充足
  2. 查询订单(为了校验是否是一人一单)
  3. 扣减优惠券的库存(不能超卖,判断库存是否充足)
  4. 将用户抢购的优惠券信息写入订单,完成订单的创建,返回订单id

其中,查询优惠券、查询订单、扣减库存、创建订单四步都是数据库操作,前两步是对用户秒杀资格的判断,后两部是下单的数据库写操作耗时较久。

我们的异步优化思路是:判断秒杀库存和校验一人一单的逻辑放到Redis中完成,在Redis中预先扣减库存(优化1:将数据库查询操作改为Redis查询,将秒杀资格的判断分离,缩短业务流程)保存优惠券id、用户id、订单id到阻塞队列,将耗时较久的减库存、创建订单这两步操作去开启独立的线程异步写入数据库(优化2:异步读取队列中的订单信息,完成下单)。

  • 那如何在Redis判断库存是否充足和一人一单呢?

我们需要把优惠券库存信息订单被哪些用户购买过的信息缓存在Redis中,我们应该选择什么样数据结构来保存这两个信息呢?

  • 对于优惠券库存信息,使用String类型,key为优惠券库存key前缀+优惠券id,value为库存
  • 对于订单购买信息,使用Set类型(方便去重判断一人一单),key为优惠券订单key前缀+优惠券id,value为购买过该优惠券的用户id集合

  • 异步秒杀业务流程如下

  1. 使用Lua脚本保证以下操作的原子性:判断用户秒杀资格,根据不同情况返回不同的标识(0:满足条件已下单1:库存不足2:该优惠券此用户已下过单),如果满足秒杀条件,预先扣减Redis中的库存(数据库的库存先不扣减,后面异步扣减),将用户id存入当前优惠券的Set集合中,作为下次判断一人一单的依据。
  2. 在Tomcat中,首先执行Lua脚本,判断返回的结果是否为0,如果没有购买资格返回提示信息,如果有购买资格,将优惠卷id。用户id和订单id存入阻塞队列,方便异步线程去读取信息,完成异步下单,最后返回订单id,至此基于Redis的秒杀业务已经结束,用户已经可以拿到订单id去完成支付操作了。
  3. 开启异步线程去读取阻塞队列中的信息,完成数据库下单和扣减库存的动作,这一步对时效性要求就不是那么高了。

(3)改进秒杀业务,提高并发性能

需求:

  1. 新增秒杀优惠券的同时,将优惠券库存信息保存到Redis中
  2. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  3. 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  4. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
  • 新增秒杀优惠券的同时,将优惠券库存信息保存到Redis中。
/**
 * 优惠券Service的实现类
 */
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @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_PREFIX + voucher.getId(), voucher.getStock().toString());
    }
}

测试新增秒杀券,返回的秒杀券id为16

新增的秒杀券成功添加到数据库和Redis中

  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功。
-- 参数列表
local voucherId = ARGV[1]   -- 优惠券id(用于判断库存是否充足)
local userId = ARGV[2]  -- 用户id(用于判断用户是否下过单)
-- 构造缓存数据key
local stockKey = 'hmdp:seckill:stock:' .. voucherId -- 库存key
local orderKey = 'hmdp:seckill:order:' .. voucherId -- 订单key
-- 脚本业务
-- 判断库存是否充足
if tonumber(redis.call('get', stockKey)) <= 0 then
    -- 库存不足,返回1
    return 1
end
-- 判断用户是否下过单 SISMEMBER orderKey userId,SISMEMBER:判断Set集合中是否存在某个元素,存在返回1,不存在放回0
if redis.call('sismember', orderKey, userId) == 1 then
    -- 存在,说明用户已经下单,返回2
    return 2
end
-- 缓存中预先扣减库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 有下单资格,允许下单,返回0
return 0

在Java代码中调用seckill.lua脚本

  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列,开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能。
  • IVoucherOrderService
/**
 * 优惠券订单Service
 */
public interface IVoucherOrderService extends IService<VoucherOrder> {
    /**
     * 秒杀下单优惠券
     * @param voucherId 优惠券id
     * @return 下单id
     */
    Result seckillVoucher(Long voucherId);

    /**
     * 判断是否是一人一单,如果是再去创建秒杀券订单
     * @param userId 用户id
     * @param voucherId 订单id
     * @return 订单id
     */
    Result createSecKillVoucherOrder(Long userId, Long voucherId);

    /**
     * 将创建的秒杀券订单异步写入数据库
     * @param voucherOrder 订单信息
     */
    void createSecKillVoucherOrder(VoucherOrder voucherOrder);
}
  • 改造VoucherOrderServiceImpl
/**
 * 优惠券订单Service实现类
 */
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIDWorker redisIDWorker;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedissonClient redissonClient;
    // RedisScript接口实现类
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    // 静态代码块初始化加载脚本
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("script/seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }
    // 阻塞队列特点:当一个线程尝试从队列中获取元素,没有元素,线程就会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
    // 线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    @PostConstruct  // 在类初始化时执行该方法
    private void init() {
        // 启动线程池,执行任务
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    // 线程任务内部类
    private class VoucherOrderHandler implements Runnable {
        // 线程任务: 不断从阻塞队列中获取订单信息
        @Override
        public void run() {
            while (true) {
                try {
                    // take()方法:从阻塞队列中获取元素,如果队列为空,线程会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
                    VoucherOrder voucherOrder = orderTasks.take();
                    // 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        // 从订单信息里获取用户id(从线程池中取出的是一个全新线程,不是主线程,所以不能从BaseContext中获取用户信息)
        Long userId = voucherOrder.getUserId();
        // 创建锁对象(可重入),指定锁的名称
        RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + SECKILL_VOUCHER_ORDER + userId);
        // 尝试获取锁,空参默认失败不等待,失败直接返回
        boolean isLock = lock.tryLock();
        // 获取锁失败,返回错误或重试(这里理论上不需要再做加锁和判断,因为抢单环节的lua脚本已经保证了业务执行的原子性,不允许重复下单)
        if (!isLock) {
            log.error("不允许重复下单,一个人只允许下一单!");
            return;
        }
        try {
            // 将创建的秒杀券订单异步写入数据库
            proxy.createSecKillVoucherOrder(voucherOrder);
        } finally {
            // 释放锁
            lock.unlock();
        }
    }

    // 事务代理对象
    private IVoucherOrderService proxy;

    /**
     * 秒杀下单优惠券(Redis分布式锁+异步秒杀优化)
     * @param voucherId 优惠券id
     * @return 下单id
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 获取用户id
        Long userId = ((UserVo) BaseContext.get()).getId();
        // 执行Lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT, 
                Collections.emptyList(), 
                voucherId.toString(), userId.toString()
        );
        // 判断结果是否为0
        int r = result.intValue();
        if (r != 0) {
            // 不为0,表示没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        // 为0,表示有购买资格,把下单信息保存到阻塞队列
        long orderId = redisIDWorker.nextId(SECKILL_VOUCHER_ORDER);
        // 创建订单(包括订单id,用户id,秒杀券id)
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(orderId);  // 订单id
        voucherOrder.setUserId(userId);   // 用户id
        voucherOrder.setVoucherId(voucherId);   // 秒杀券id
        // 获取当前目标对象的事务代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        // 把订单信息保存到阻塞队列
        orderTasks.add(voucherOrder);
        // 返回订单id
        return Result.ok(orderId);
    }

    /**
     * 将创建的秒杀券订单异步写入数据库
     * @param voucherOrder 订单信息
     */
    @Transactional
    public void createSecKillVoucherOrder(VoucherOrder voucherOrder) {
        // 根据用户id和优惠券id查询订单是否存在
        int count = query().eq("user_id", voucherOrder.getUserId()).eq("voucher_id", voucherOrder.getVoucherId()).count();
        // 一人一单判断
        if (count > 0) {
            // 该用户已经购买过了,不允许下多单
            log.error("该秒杀券用户已经购买过一次了!");
            return;
        }
        // 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")    // set stoke = stoke - 1
                // where id = ? and stock > 0
                .eq("voucher_id", voucherOrder.getVoucherId())
                .gt("stock", 0)
                //.eq("stock", seckillVoucher.getStock())   // CAS乐观锁(成功卖出概率太低、需要用 stock > 0 来判断)
                .update();
        if (!success) {
            // 扣减失败
            log.error("扣减失败,秒杀券扣减失败(库存不足)!");
            return;
        }
        // 将订单信息写入数据库
        success = this.save(voucherOrder);
        if (!success) {
            // 创建秒杀券订单失败
            throw new RuntimeException("创建秒杀券订单失败!");
        }
    }
}
  • 注意:AopContext.currentProxy()底层也是利用ThreadLocal获取的,所以异步线程中也无法使用。解决方案就是提升代理对象的作用域,放到成员变量位置,在主线程中初始化,或者在主线程中创建后作为方法参数一起传递给阻塞队列。

  • 接口测试

先用ApiFox测试一人一单功能

再来用JMeter做性能测试,结果发现,由于异步线程写入数据库,耗时减少,吞吐量大幅增加,提高了秒杀系统的并发性能!

总结

  • 秒杀业务的优化思路是什么?
    • 先利用Redis完成库存余量、一人一单判断,完成抢单业务
    • 再将下单业务放入阻塞队列,利用独立线程异步下单
  • 基于阻塞队列的异步秒杀存在哪些问题?
    • 内存限制问题(JDK的阻塞队列使用的是JVM的内存,高并发订单量可能导致内存溢出,队列大小是由我们自己指定的,可能会超出阻塞队列的上限)
    • 数据安全问题(情况①:JVM内存是没有持久化机制的,服务重启或意外宕机时,阻塞队列中的所有任务都会丢失。情况②:当我们从阻塞队列拿到一个任务尚未处理时,如果此时发生异常,该任务也会丢失,就没有机会再次被处理了,导致数据不一致)



13、Redis消息队列实现异步秒杀

消息队列(Message Queue):字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)

  • 生产者:发送消息到消息队列

  • 消费者:从消息队列获取消息并处理消息

  • 核心优点:解耦、异步、削峰。

  • 常见的消息队列:RabbitMQ、RocketMQ、Kafka、ActiveMQ等,我们也可以直接使用Redis提供的MQ方案,降低我们的部署和学习成本。

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

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

(1)基于List结构模拟消息队列

Redis的list数据结构是一个双向链表,很容易模拟出队列效果。

队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。

  • 生产消息:BRPUSH key value [value ...] 将一个或多个元素推入到指定列表的头部。如果列表不存在,BRPUSH命令会自动创建一个新的列表。
  • 消费消息:BRPOP key [key ...] timeout 从指定的一个或多个列表中弹出最后一个元素。如果 list 列表为空,BRPOP命令会导致客户端阻塞,直到有数据可用或超过指定的超时时间。
  • 使用案例

基于List的消息队列有哪些优缺点?

优点:

  • 利用Redis存储,不受限于JVM内存上限
  • 基于Redis的持久化机制,数据安全性有保证
  • 可以满足消息有序性

缺点:

  • 无法避免消息丢失
  • 只支持单消费者

(2)基于PubSub的消息队列

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

  • 生产消息
# 向指定频道发布一条消息
PUBLISH channel message
  • 消费消息
# 订阅一个或多个频道
SUBSCRIBE channel [channel ...]
# 取消订阅一个或多个频道
UNSUBSCRIBE channel [channel ...]
# 订阅与pattern格式匹配的所有频道
PSUBSCRIBE pattern [pattern ...]
# 取消订阅与pattern格式匹配的所有频道
PUNSUBSCRIBE pattern [pattern ...]
  • 使用案例

基于PubSub的消息队列有哪些优缺点?

优点:

  • 采用发布订阅模型,支持多生产多消费

缺点:

  • 不支持数据持久化
  • 无法避免消息丢失(发送到的channel无消费者订阅,消息直接丢失,数据安全无法保证)
  • 消息堆积有上限,超出时数据丢失(发送的消息如果有消费者监听,消息会缓存在消费者(客户端)的缓存区,若消费者处理消息耗时较久,新接收到的消息就会堆积,超出缓存上限就会丢失,因此可靠性不高)

(3)基于Stream的消息队列

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

Stream命令参数:https://redis.io/docs/latest/commands/?group=stream


① Stream的单消费模式
  • 生产消息

# 向指定的Stream中添加一个消息
XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value ...]
# 最简用法:XADD 添加消息的队列名称 消息id 消息Entry
XADD key *|ID field value [field value ...]

# 例如:创建名为users的队列,并向其中发送一个消息,内容是:{name=jack,age=21},并且使用Redis自动生成ID
127.0.0.1:6379> XADD users * name jack age 21
"1644805700523-0"
  • 消费消息

# XREAD COUNT 读取消息数量 BLOCK 阻塞时长 STREAMS 要读取的阻塞队列名称 ID 起始id
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

# 例如:使用XREAD读取第一条消息
XREAD COUNT 1 STREAMS users 0
# XREAD阻塞方式,读取最新的消息(阻塞1秒钟后读取最新消息)
XREAD COUNT 1 BLOCK 1000 STREAMS users $

在业务开发中,我们可以循环的调用XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:

注意:当我们指定起始ID为$时,代表读取最后一条消息(读取最新的消息),ID为0时代表读最开始的一条消息(读取最旧的消息)。如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。

STREAM类型消息队列的单消费模式XREAD命令)有哪些优缺点?

优点:

  • 消息可回溯(重复读取)
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取

缺点:

  • 有消息漏读的风险

② Stream的消费者组模式

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

  • 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度。
  • 消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费。
  • 消息确认:消费者获取消息后,消息处于pending(待处理)状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。

消费者监听消息的基本思路,伪代码如下:

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

  • 消息可回溯

  • 可以多消费者争抢消息,加快消费速度

  • 可以阻塞读取

  • 没有消息漏读的风险

  • 有消息确认机制,保证消息至少被消费一次

  • 三种Redis消息队列实现对比


(4)Stream消息队列优化异步秒杀

1)创建一个Stream类型的消息队列,名为stream.orders

  • 方法一:在redis-cli中直接使用命令创建
# 创建队列(消费者组模式)MKSTREAM:当创建消费者组时,若队列不存在,将自动创建队列和消费者组
XGROUP CREATE stream.orders g1 0 MKSTREAM
  • 方法二:在Java代码中创建
// Stream消息队列相关属性
private static final String GROUP_NAME = "g1";    // 消费者组 groupName
private static final String CONSUMER_NAME = "c1";    // 消费者名称 consumer,该项后期可以在yaml中配置多个消费者,并实现消费者组多消费模式
private static final String QUEUE_NAME = "stream.orders";    // 消息队列名称 key

@PostConstruct  // 在类初始化时执行该方法
private void init() {
    // 创建消息队列
    if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(QUEUE_NAME))) {
        stringRedisTemplate.opsForStream().createGroup(QUEUE_NAME, ReadOffset.from("0"), GROUP_NAME);
        log.debug("Stream队列创建成功");
    }
    // 启动线程池,执行任务
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

2)修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherld、userld、orderld

  • 修改lua脚本,加入xadd发送消息命令,向名为queueName的Stream消息队列发送下单信息
-- 参数列表
local voucherId = ARGV[1]   -- 优惠券id(用于判断库存是否充足)
local userId = ARGV[2]  -- 用户id(用于判断用户是否下过单)
local orderId = ARGV[3]  -- 订单id
local queueName = ARGV[4]   -- Stream消息队列名称
-- 构造缓存数据key
local stockKey = 'hmdp:seckill:stock:' .. voucherId -- 库存key
local orderKey = 'hmdp:seckill:order:' .. voucherId -- 订单key
-- 脚本业务
-- 判断库存是否充足
if tonumber(redis.call('get', stockKey)) <= 0 then
    -- 库存不足,返回1
    return 1
end
-- 判断用户是否下过单 SISMEMBER orderKey userId,SISMEMBER:判断Set集合中是否存在某个元素,存在返回1,不存在放回0
if redis.call('sismember', orderKey, userId) == 1 then
    -- 存在,说明用户已经下单,返回2
    return 2
end
-- 缓存中预先扣减库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 发送订单消息到队列中 XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', queueName, '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
-- 有下单资格,允许下单,返回0
return 0
  • 在Java代码中修改lua脚本调用,将阻塞队列添加消息改为lua脚本操作stream发送消息
/**
 * 秒杀下单优惠券(Stream消息队列优化异步秒杀)
 * @param voucherId 优惠券id
 * @return 下单id
 */
@Override
public Result seckillVoucher(Long voucherId) {
    // 获取用户id
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 获取订单id
    long orderId = redisIDWorker.nextId(SECKILL_VOUCHER_ORDER);
    // 执行Lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString(), String.valueOf(orderId), QUEUE_NAME
    );
    // 判断结果是否为0
    int r = result.intValue();
    if (r != 0) {   // 不为0,表示没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 为0,表示有购买资格,lua脚本中已经将订单相关消息发送到消息队列中,待消费者读取
    proxy = (IVoucherOrderService) AopContext.currentProxy();   // 获取当前目标对象的事务代理对象
    // 返回订单id
    return Result.ok(orderId);
}

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

// 线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

// Stream消息队列相关属性
private static final String GROUP_NAME = "g1";    // 消费者组 groupName
private static final String CONSUMER_NAME = "c1";    // 消费者名称 consumer,该项后期可以在yaml中配置多个消费者,并实现消费者组多消费模式
private static final String QUEUE_NAME = "stream.orders";    // 消息队列名称 key

@PostConstruct  // 在类初始化时执行该方法
private void init() {
    // 创建消息队列
    if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(QUEUE_NAME))) {
        stringRedisTemplate.opsForStream().createGroup(QUEUE_NAME, ReadOffset.from("0"), GROUP_NAME);
        log.debug("Stream队列创建成功");
    }
    // 启动线程池,执行任务
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

// 线程任务内部类
private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {  // 不断获取消息队列中的订单信息
            try {
                // 获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from(GROUP_NAME, CONSUMER_NAME),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        StreamOffset.create(QUEUE_NAME, ReadOffset.lastConsumed())
                );
                // 判断消息获取是否成功
                if (list == null || list.isEmpty()) {
                    // 如果获取失败,说明没有消息,继续下一次读取
                    continue;
                }
                // 解析消息中的订单信息 MapRecord<消息id, 消息key,消息value>
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                // 如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                // ACK确认 SACK stream.orders g1 id [id1 id2 id3 ...]
                stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, GROUP_NAME, record.getId());
            } catch (Exception e) {
                log.error("处理订单异常", e);
                handlePendingList();
            }
        }
    }

    // 处理pending-list中的异常订单信息
    private void handlePendingList() {
        while (true) {  // 不断获取消息队列中的订单信息
            try {
                // 获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream.orders 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from(GROUP_NAME, CONSUMER_NAME),
                        StreamReadOptions.empty().count(1),
                        StreamOffset.create(QUEUE_NAME, ReadOffset.from("0"))
                );
                // 判断异常消息获取是否成功
                if (list == null || list.isEmpty()) {
                    // 如果获取失败,说明pending-list中没有异常消息,结束循环
                    break;
                }
                // 解析消息中的订单信息 MapRecord<消息id, 消息key,消息value>
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                // 如果获取成功,可以下单
                handleVoucherOrder(voucherOrder);
                // ACK确认 SACK stream.orders g1 id [id1 id2 id3 ...]
                stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, GROUP_NAME, record.getId());
            } catch (Exception e) {
                log.error("处理订单异常", e);
                // 防止处理频繁,下次循环休眠20毫秒
                try {
                    Thread.sleep(20);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
            }
        }
    }
}
  • 测试下单

恢复数据库和Redis为201个库存,先测试单人下单,正常下单。

再进行压力性能测试,与之前的阻塞队列的吞吐量差不多,但是Stream更加灵活和可靠。




五、达人探店

1、发布探店笔记

探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:

  • tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
  • tb_blog_comments:其他用户对探店笔记的评价

这里主要先关注第一张表tb_blog,本功能已经实现,请求流程如下:

UploadController的uploadImage方法负责接收前端上传的图片,并根据文件名fileName和图片上传路径SystemConstants.IMAGE_UPLOAD_DIR保存文件并上传,最后返回给前端一个生成好的、可访问的图片地址。

修改图片上传地址,这里上传到本地服务器,在SystemConstants类中将图片上传地址修改到前端服务器nginx下的html\hmdp\imgs目录下。

点击发布后请求BlogController的saveBlog方法,保存笔记

  • 测试功能

登录后点击下方+号,跳转发布探店笔记页面

用户填写对应的标题、内容、选择关联的商户,上传探店图片

填写完成后点击发布,会自动跳到个人主页的笔记展示页面,成功发布探店笔记

首页下方会出现新发布的笔记,因为还没有点赞,所以排在最下方

同时,数据库也新增成功




2、查看探店笔记

  • 代码实现
/**
 * 探店笔记Service实现类
 */
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
    @Resource
    private IUserService userService;

    @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(this::queryBlogUser);
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
        // 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        }
        // 查询blog有关的用户
        queryBlogUser(blog);
        return Result.ok(blog);
    }

    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }
}
  • 测试功能

首页点击用户发布的探店笔记,进入详情页面




3、Set实现点赞功能

  • 点赞请求流程:

原始代码存在的问题:一个用户可以无限点赞。这显然是不合理的,所以我们需要对点赞功能进行一个优化,实现一人只能点赞一次。

@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
    // 修改点赞数量
    blogService.update()
            .setSql("liked = liked + 1").eq("id", id).update();
    return Result.ok();
}

需求:

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

实现步骤:

  • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
  • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
  • 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
  • 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
  • 代码实现:使用Redis的Set集合存储已点赞用户的id,保证点赞唯一性
/**
 * 探店笔记Service实现类
 */
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
    @Resource
    private IUserService userService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @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 -> {
            this.queryBlogUser(blog);   // 查询blog有关的用户
            this.isBlogLiked(blog); // 查询blog是否被点赞
        });
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
        // 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        }
        // 查询blog有关的用户
        queryBlogUser(blog);
        // 查询blog是否被点赞
        isBlogLiked(blog);
        return Result.ok(blog);
    }

    /**
     * 查询blog是否被点赞
     * @param blog
     */
    private void isBlogLiked(Blog blog) {
        // 获取当前登录用户
        Long userId = ((UserVo) BaseContext.get()).getId();
        // 判断当前用户是否已经点赞
        String key = BLOG_LIKED_KEY_PREFIX + blog.getId();
        Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        blog.setIsLike(BooleanUtil.isTrue(isLiked));
    }

    /**
     * Set实现用户点赞笔记功能
     * @param id 笔记id
     * @return
     */
    @Override
    public Result likeBlog(Long id) {
        // 获取当前登录用户
        Long userId = ((UserVo) BaseContext.get()).getId();
        // 判断当前用户是否已经点赞
        String key = BLOG_LIKED_KEY_PREFIX + id;
        Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        if (BooleanUtil.isFalse(isLiked)) { // 如果未点赞,可以点赞
            // 数据库点赞数 + 1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            if (isSuccess) {
                // 保存用户到Redis的Set集合
                stringRedisTemplate.opsForSet().add(key, userId.toString());
            }
        }else { // 如果已经点赞,取消点赞
            // 数据库点赞数 - 1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            if (isSuccess) {
                // 把用户从Redis的Set集合移除
                stringRedisTemplate.opsForSet().remove(key, userId.toString());
            }
        }
        return Result.ok();
    }

    /**
     * 查询发布笔记的用户信息
     * @param blog
     */
    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }
}
  • 测试功能

登录后,点击笔记右下角的点赞按钮,点赞数+1,已点赞高亮显示。

Redis的Set集合中记录了该笔记已点赞的用户id

同一个用户再次点赞同一篇笔记后,点赞数-1,高亮取消,Redis的Set集合中移除该用户id,防止了用户重复点赞。




4、Sorted Set实现点赞排行榜功能

需求:按照点赞时间先后排序,返回Top5的用户

三种数据结构选择的对比:

Set是无序的,无法满足这个需求,虽然List使用RPUSH可以满足有序性,但是不唯一,查找效率也比较低,而SortedSet可以根据score值进行默认升序排序。

  1. ZADD key value score 向ZSet中添加元素,并指定score排序值
  2. ZSCORE key value 判断ZSet是否有value元素,如果存在,返回该value对应的score值,如果不存在,则返回nil
  3. ZRANGE key start end 根据score排序值,查询并返回序号从start到end区间的value值
  • 代码实现:改用ZSet后的存储结构,点赞后,value存储用户id、score存储点赞时的时间戳,并且实现查询点赞前5名的用户列表
/**
 * 查询blog是否被点赞,设置isLike属性
 * @param blog
 */
private void isBlogLiked(Blog blog) {
    // 获取当前登录用户
    UserVo userVo = BaseContext.get();
    if (userVo == null) {
        // 用户未登录,无需查询是否点赞
        return;
    }
    Long userId = userVo.getId();
    // 判断当前用户是否已经点赞
    String key = BLOG_LIKED_KEY_PREFIX + blog.getId();
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(score != null);
}

/**
 * Sorted Set实现用户点赞笔记功能
 * @param id 笔记id
 * @return
 */
@Override
public Result likeBlog(Long id) {
    // 获取当前登录用户
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 判断当前用户是否已经点赞
    String key = BLOG_LIKED_KEY_PREFIX + id;
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    if (score == null) { // 如果时间戳不存在,说明未点赞,可以点赞
        // 数据库点赞数 + 1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        if (isSuccess) {
            // 保存用户到Redis的ZSet集合 zadd key value score
            stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
        }
    }else { // 如果已经点赞,取消点赞
        // 数据库点赞数 - 1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        if (isSuccess) {
            // 把用户从Redis的ZSet集合移除
            stringRedisTemplate.opsForZSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}

/**
 * 查询ZSet中的top5的点赞用户
 * @param id 笔记id
 * @return
 */
@Override
public Result queryBlogLikes(Long id) {
    // 查询top5的点赞用户 zrange key 0 4
    String key = BLOG_LIKED_KEY_PREFIX + id;
    Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
    // 判断top5是否为空
    if (CollUtil.isEmpty(top5)) {
        return Result.ok(Collections.emptyList());
    }
    // 解析出其中的用户id
    List<Long> userIds = top5.stream().map(Long::valueOf).collect(Collectors.toList());
    String userIdsStr = StrUtil.join(",", userIds);	// 每个userId已逗号分隔
    // 根据用户id查询用户,并转为UserVo集合 WHERE id IN (6, 2, 1) ORDER BY FIELD(id, 6, 2, 1) FIELD函数用于根据指定的顺序对结果进行排序
    List<UserVo> userVoList = userService.query()
            .in("id", userIds).last("ORDER BY FIELD(id, " + userIdsStr + ")").list()
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserVo.class))
            .collect(Collectors.toList());
    // 返回UserVo集合
    return Result.ok(userVoList);
}

注意:如果直接使用MyBatis-Plus的listByIds方法,底层默认使用in进行条件查询,而MySQL查出来的结果默认是按照主键id升序排序的,这样直接返回给前端点赞排行榜顺序是不对的,而Redis中查出来是正确顺序(默认按照时间戳升序,也就是后点赞的排在后面),因此我们可以使用MySQL中的一个FIELD函数,该函数用于对指定字段按自定义的顺序对结果进行排序。

  • 测试功能

展示点赞前5名,先点赞的用户排在前面,后点赞的用户排在后面,同时支持取消点赞不重复

Redis中的点赞顺序是按点赞时间更新顺序的




六、好友关注

1、关注和取关

需求:基于该表数据结构,实现两个接口,用户可以对其他用户进行关注和取消关注功能。

  • 关注和取关接口
  • 判断是否关注的接口

关注是User之间的关系,是博主与粉丝的关系,多对多,数据库用中间表tb_follow表示:

  • FollowController
/**
 * 用户关注Controller
 */
@RestController
@RequestMapping("/follow")
public class FollowController {
    @Resource
    private IFollowService followService;
    
    @PutMapping("/{id}/{isFollow}")
    public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
        return followService.follow(followUserId, isFollow);
    }

    @GetMapping("/or/not/{id}")
    public Result isFollow(@PathVariable("id") Long followUserId) {
        return followService.isFollow(followUserId);
    }
}
  • FollowServiceImpl
/**
 * 用户关注Service实现类
 */
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
    /**
     * 根据isFollow判断是关注还是取关,如果关注则插入数据,否则删除数据
     * @param followUserId 被关注的用户id
     * @param isFollow 关注true/取关false
     * @return
     */
    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 获取当前登录用户id
        Long userId = ((UserVo) BaseContext.get()).getId();
        // 判断是关注还是取关
        if (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 LambdaQueryWrapper<Follow>()
                    .eq(Follow::getUserId, userId)
                    .eq(Follow::getFollowUserId, followUserId));
        }
        return Result.ok();
    }

    /**
     * 判断当前登录用户是否关注了指定用户
     * @param followUserId 被关注的用户id
     * @return 是否关注,true关注,false未关注
     */
    @Override
    public Result isFollow(Long followUserId) {
        // 获取当前登录用户id
        Long userId = ((UserVo) BaseContext.get()).getId();
        // 查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
        Long count = lambdaQuery()
                .eq(Follow::getUserId, userId)
                .eq(Follow::getFollowUserId, followUserId)
                .count();
        // 判断是否关注
        return Result.ok(count > 0);
    }
}

这里使用lambdaQuery进行反射时报错,解决方法:将MP的版本升级到3.5.3,并且将count()方法的返回值改为Long。

  • 测试功能

首先自己不能关注自己,没有关注按钮,这里关注其他用户,关注成功后又查询了一次是否关注接口,按钮变为取消关注

数据库tb_follow表中插入关注数据

用户点击取消关注,取关成功,数据库tb_follow表中会删除相关关注数据。




2、共同关注

需求:利用Redis的Set集合求交集,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同好友。

  • 代码实现:修改关注和取关接口代码,在更新数据库的同时,将用户关注的id记录到Redis的Set集合中;新增查询目标用户的共同关注接口,求两个用户的Set集合的交集,即为共同关注
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private IUserService userService;

/**
 * 根据isFollow判断是关注还是取关,如果关注则插入数据,否则删除数据
 * @param followUserId 被关注的用户id
 * @param isFollow 关注true/取关false
 * @return
 */
@Override
public Result follow(Long followUserId, Boolean isFollow) {
    // 获取当前登录用户id
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 判断是关注还是取关
    if (isFollow) {
        // 关注,新增数据
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        if (isSuccess) {
            // 把关注用户的id放入Redis的Set集合 sadd userId followUserId
            stringRedisTemplate.opsForSet().add(FOLLOW_KEY_PREFIX + userId, followUserId.toString());
        }
    }else {
        // 取关,删除数据 delete from tb_follow where user_id = ? and follow_user_id = ?
        boolean isSuccess = remove(new LambdaQueryWrapper<Follow>()
                .eq(Follow::getUserId, userId)
                .eq(Follow::getFollowUserId, followUserId));
        if (isSuccess) {
            // 把关注用户的id从Redis集合中移除
            stringRedisTemplate.opsForSet().remove(FOLLOW_KEY_PREFIX + userId, followUserId.toString());
        }
    }
    return Result.ok();
}

/**
 * 查询目标用户的共同关注
 * @param id
 * @return
 */
@Override
public Result followCommons(Long id) {
    // 获取当前用户
    Long userId = ((UserVo) BaseContext.get()).getId();
    String key = FOLLOW_KEY_PREFIX + userId;
    // 求交集
    String targetKey = FOLLOW_KEY_PREFIX + id;
    Set<String> intersection = stringRedisTemplate.opsForSet().intersect(key, targetKey);
    if (CollUtil.isEmpty(intersection)) {
        // 无交集
        return Result.ok(Collections.emptyList());
    }
    List<Long> ids = intersection.stream().map(Long::valueOf).collect(Collectors.toList());
    List<UserVo> userVoList = userService.listByIds(ids)
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserVo.class))
            .collect(Collectors.toList());
    return Result.ok(userVoList);
}
  • 功能测试

首先让 可可今天不吃肉(2) 和 小鱼同学(1) 关注 娃娃菜(6),数据库和Redis中同步存入娃娃菜的id

接着登录可可今天不吃肉(2),查看小鱼同学(1)的共同关注里有娃娃菜(6)




3、关注推送

(1)Feed流实现方案

当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容

对于新型的Feed流的的效果:不需要用户自己去检索信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。

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

Timeline不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈

  • 优点:信息全面,不会有缺失。并且实现也相对简单
  • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

智能排序利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户。例如抖音、快手

  • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
  • 缺点:如果算法不精准,可能起到反作用
    本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:

我们本次针对好友的操作,采用的就是Timeline的方式,只需要拿到我们关注用户的信息,然后按照时间排序即可,因此采用Timeline的模式。该模式的实现方案有三种:

  • 拉模式
  • 推模式
  • 推拉结合

  1. 拉模式:也叫做读扩散。在拉模式中,终端用户或应用程序主动发送请求来获取最新的数据流。它是一种按需获取数据的方式,用户可以在需要时发出请求来获取新数据。在Feed流中,数据提供方将数据发布到实时数据源中,而终端用户或应用程序通过订阅或请求来获取新数据。

例如,当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序

优点:节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清除。

缺点:延迟较高,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。

  1. 推模式:也叫做写扩散。在推模式中,数据提供方主动将最新的数据推送给终端用户或应用程序。数据提供方会实时地将数据推送到终端用户或应用程序,而无需等待请求。

推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了

优点:时效快,不用临时拉取

缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多份数据到粉丝那边去

  1. 推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。在推拉结合模式中,数据提供方会主动将最新的数据推送给终端用户或应用程序,同时也支持用户通过拉取的方式来获取数据。这样可以实现实时的数据更新,并且用户也具有按需获取数据的能力。

推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。


(2)推送到粉丝收件箱

需求:

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询
  • Feed流中的分页问题:Feed流中的数据会不断更新,可能随时发生变化,所以数据的角标也在变化,因此不能采用传统的分页模式。

假设在t1时刻,我们去读取第一页,此时page=1,size=5,那么我们拿到的就是10~6 这几条记录,假设现在t2时候又新发布了一条记录11,此时t3时刻,我们来读取第二页,读取第二页传入的参数是page=2,size=5,那么此时读取到的第二页实际上是从6开始,查询6~2 ,那么我们就读取到了重复的数据6,所以feed流的分页,不能采用原始方案来做。

  • Feed流的滚动分页:因为我们存的数据是有序的,可以用翻页游标 lastId 记录每次操作的最后一条,然后从这个位置开始去读取数据,这样查询不依赖于角标,因此不会受到数据角标变化带来的影响。

从t1时刻开始,拿第一页数据,拿到了10 ~ 6,然后记录下当前最后一次拿取的记录,lastId就是6,t2时刻发布了新的记录11放到最前面,但是不会影响我们之前记录的6,此时t3时刻来查询第二页,第二页起始位置从6的下一个5开始获取,就拿到了5 ~ 1的记录。

之前分析过,虽然List和SortedSet都能支持排序,但List结构依赖角标查询,因此不支持滚动分页。而SortedSet会按照score值排序,数据排序完会有一个排名,如果按排名查询,那和角标查询没有区别,而SortedSet还支持按score值的范围进行查询,因此我们可以采用SortedSet来做,按时间戳从大到小降序排列后进行范围查询,查询时每次记录最小的时间戳,下次查询时找比这个时间戳次小的,从这里开始,从而实现滚动分页。

  • 代码实现:保存笔记,并推送给所有粉丝
/**
 * 保存笔记,并推送给所有粉丝
 * @param blog
 * @return
 */
@Override
public Result saveBlog(Blog blog) {
    // 获取登录用户
    UserVo userVo = BaseContext.get();
    blog.setUserId(userVo.getId());
    // 保存探店笔记
    boolean isSuccess = save(blog);
    if (!isSuccess) {
        return Result.fail("新增笔记失败!");
    }
    // 查询笔记作者(当前登录用户)的所有粉丝(注意:之前Redis中存的是某用户关注的人,不是某用户的粉丝)select * from tb_follow where follow_user_id = ?
    List<Follow> follows = followService.lambdaQuery().eq(Follow::getFollowUserId, userVo.getId()).list();
    // 推送笔记id给所有粉丝
    for (Follow follow : follows) {
        // 获取粉丝id
        Long fanId = follow.getUserId();
        // 推送笔记id
        String key = FEED_KEY_PREFIX + fanId;
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    // 返回id
    return Result.ok(blog.getId());
}

(3)实现滚动分页查询

需求:在个人主页的“关注”卡片中,查询并展示推送的Blog信息

ZRANGE 是按照角标(排名)从小到大排序:

ZREVRANGE 是按照角标从大到小排序:

ZREVRANGEBYSCORE 是按照分数从大到小排序:

其中的参数:

  • max:分数的最大值
  • min:分数的最小值
  • offset:偏移量
  • count:查的数量

滚动查询:每一次都记住上一次查询分数的最小值,将最小值作为下一次的最大值

当分数一致时offset给固定的1会出现问题,这里的offset应该为上一次查询到与最小值min相同的元素个数,上次查询到的最小值min,也就是下次来查询的最大值max

规律总结:分数最小值min(无需关注最小值是几,固定给score能取到最小值就行)和 查的数量count(前端人为决定)固定不变。最大值max为上一次查询的最小值(第一次为当前时间戳)、偏移量offset第一次为0,之后为在上一次的结果中,与最小值相同的元素的个数。

  • ScrollResult
/**
 * 滚动分页查询结果对象
 */
@Data
public class ScrollResult {
    // 结果集合
    private List<?> list;
    // 本次查询的最小score值,作为下一次请求的lastIId
    private Long minTime;
    // 偏移量
    private Integer offset;
}
  • BlogServiceImpl
/**
 * 推送关注笔记,滚动分页查询
 * @param max
 * @param offset
 * @return
 */
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    // 获取当前登录用户
    Long userId = ((UserVo) BaseContext.get()).getId();
    String key = FEED_KEY_PREFIX + userId;
    // 查询收件箱(关注推送笔记列表)ZREVRANGEBYSCORE key max min LIMIT offset count
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
    // 非空判断
    if (CollUtil.isEmpty(typedTuples)) {
        return Result.ok();
    }
    // 解析数据:blogId、minTime(时间戳)、offset
    List<Long> blogIds = new ArrayList<>(typedTuples.size());
    long minTime = 0L;  // 循环最后一次取出的是最小时间戳
    int os = 1; // 默认初始偏移量为1。表示只有自己是相同的
    for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
        // 获取blogId
        blogIds.add(Long.valueOf(typedTuple.getValue()));
        // 获取score(时间戳)
        long time = typedTuple.getScore().longValue();
        if (time == minTime) {
            // 当前时间等于最小时间,偏移量+1
            os++;
        }else {
            // 当前时间不等于最小时间,更新覆盖最小时间,重置偏移量为1
            minTime = time;
            os = 1;
        }
    }
    // 根据id查询blog,注意保持blogIds的有序性,封装为blog集合
    String blogIdsStr = StrUtil.join(",", blogIds);
    List<Blog> blogs = query().in("id", blogIds).last("order by field(id, " + blogIdsStr + ")").list();
    for (Blog blog : blogs) {
        // 设置blog有关的用户
        queryBlogUser(blog);
        // 设置blog是否被点赞
        isBlogLiked(blog);
    }
    // 封装为滚动分页结果对象,返回给前端
    ScrollResult scrollResult = new ScrollResult();
    scrollResult.setList(blogs);
    scrollResult.setMinTime(minTime);
    scrollResult.setOffset(os);
    return Result.ok(scrollResult);
}
  • 测试功能

现在可可今天不吃肉(2) 和 小鱼同学(1) 都关注了 娃娃菜(6),并且娃娃菜发布了3篇笔记

登录可可今天不吃肉(2) 或 小鱼同学(1) 查看关注推送效果




七、附近商铺

1、GEO的基本用法

GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:

  • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
  • GEODIST:计算指定的两个点之间的距离并返回
  • GEOHASH:将指定member的坐标转为hash字符串形式并返回
  • GEOPOS:返回指定member的坐标
  • GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
  • GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能,代替GEORADIUS
  • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能

# 添加坐标数据
GEOADD g1 116.378248 39.865275 bjnz 116.42803 39.903738 bjz 116.322287 39.893729 bjxz
# 计算北京西站到北京站的距离
GEODIST g1 bjnz bjz km
# 搜索天安门附近10km内的所有火车站,并按照距离升序排序(默认升序)
GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST




2、附近商户搜索

前端发请求携带的经纬度坐标是指:当前登录用户的坐标。这里仅演示写死的,但真实项目中经纬度坐标信息会对接第三方服务接口获取,实时定位不会写死的。

  • 数据库存储分析

  • 存储方案设计:按照商户类型做分组,类型相同的商户作为同一组,以typeld为key存入同一个GEO集合中即可(在店铺新增存入的时候,就提前用key分好组)

  • 将数据库中已经存在的店铺数据按typeId分组导入Redis
@Test
void loadShopData() {
    // 查询店铺信息
    List<Shop> shops = shopService.list();
    // 把店铺分组,按照typeId分组,id一致的放到一个集合
    Map<Long, List<Shop>> groupByShopTypeToMap = shops.stream().collect(Collectors.groupingBy(Shop::getTypeId));
    // 分批完成写入Redis
    for (Map.Entry<Long, List<Shop>> entry : groupByShopTypeToMap.entrySet()) {
        // 获取类型id
        Long typeId = entry.getKey();
        String geoKey = SHOP_GEO_KEY_PREFIX + typeId;
        // 获取同类型的店铺的集合
        List<Shop> shopList = entry.getValue();
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(shopList.size());
        // 写入Redis的GEO GEOADD KEY 经度 纬度 Member
        for (Shop shop : shopList) {
            //stringRedisTemplate.opsForGeo().add(geoKey, new Point(shop.getX(), shop.getY()), shop.getId().toString());
            locations.add(new RedisGeoCommands.GeoLocation<>(
                    shop.getId().toString(),
                    new Point(shop.getX(), shop.getY())
            ));
        }
        // 批量写入Redis的GEO
        stringRedisTemplate.opsForGeo().add(geoKey, locations);
    }
}

  • 为了使用 GEOSEARCH 命令,引入新版本lettuce和spring-data-redis依赖,排除旧版本依赖

  • pom.xml
<!-- spring-data-redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <artifactId>lettuce-core</artifactId>
            <groupId>io.lettuce</groupId>
        </exclusion>
        <exclusion>
            <artifactId>spring-data-redis</artifactId>
            <groupId>org.springframework.data</groupId>
        </exclusion>
    </exclusions>
</dependency>
<!-- 引入新版本lettuce和spring-data-redis -->
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.6.2</version>
</dependency>
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.1.6.RELEASE</version>
</dependency>
  • ShopServiceImpl:在普通查询数据库的基础上,增加实现查询附近商户功能
/**
 * 根据店铺类型分组查询店铺信息,支持查询附近商户,按距离升序排序
 * @param typeId 店铺类型
 * @param current 当前页码
 * @param x 用户经度
 * @param y 用户纬度
 * @return 店铺数据
 */
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    // 判断是否需要根据坐标查询
    if (x == null || y == null) {
        // 不需要坐标查询,按数据库查询,根据类型分页查询
        Page<Shop> page = query().eq("type_id", typeId).page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        // 返回数据
        return Result.ok(page.getRecords());
    }
    // 计算分页参数
    int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
    int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
    // 根据坐标查询redis,按照距离排序、分页查询。结果:shopId,maxDistance
    String geoKey = SHOP_GEO_KEY_PREFIX + typeId;
    // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE WITHHASH
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(
            geoKey,
            GeoReference.fromCoordinate(x, y),  // 查询以给定的经纬度为中心的圆形区域
            new Distance(10000),    // 查询10km范围内的店铺,单位默认为米
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)   // 分页查询0~end条
    );
    // 解析出id
    if (results == null) {
        // 未查到结果,返回错误
        return Result.fail("没有查到店铺");
    }
    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
    // from跳过前面元素不足from个,跳过后集合为空,说明查完了没有下一页了,返回空集合
    if (list.size() <= from) {
        return Result.ok(Collections.emptyList());
    }
    // 截取from ~ end的部分,方法一:list.subList(from, end); 方法二:stream流的skip方法,跳过from前面的元素,从from开始,截取end-from个元素
    List<Long> ids = new ArrayList<>(list.size());
    Map<String, Distance> distanceMap = new HashMap<>(list.size());
    list.stream().skip(from).forEach(result -> {
        // 获取店铺id(Member)
        String shopIdStr = result.getContent().getName();
        ids.add(Long.valueOf(shopIdStr));
        // 获取距离
        Distance distance = result.getDistance();
        distanceMap.put(shopIdStr, distance);
    });
    // 根据id查询店铺数据
    String idStr = StrUtil.join(",", ids);
    List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
    // 遍历店铺数据,设置距离
    for (Shop shop : shops) {
        shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
    }
    return Result.ok(shops);
}
  • 测试效果




八、用户签到

1、BitMap的基本用法

bitmap实际上就是由一个一个的二进制位所组成的,在bitmap中每一个位只存放0或者1,如下所示的bitmap结构图:

Redis中是利用String类型数据结构实现存储BitMap,因此最大上限是512M,转换为bit则是232个bit位(102410248*512=42亿)。

BitMap的操作命令有:

  • SETBIT:向指定位置(offset)存入一个0或1
  • GETBIT:获取指定位置(offset)的bit值
  • BITCOUNT:统计BitMap中值为1的bit位的数量
  • BITFIELD:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
  • BITFIELD_RO:获取BitMap中bit数组,并以十进制形式返回
  • BITOP:将多个BitMap的结果做位运算(与 、或、异或)
  • BITPOS:查找bit数组中指定范围内第一个0或1出现的位置

以8bit的一字节存储,如超出8个位,自动扩容到16位进行存储,后面未设置1的位置补0,以此类推。

u表示无符号,i表示有符号,一般使用无符号u。例如BITFIELD bm1 get u2 0中u2表示获取两个bit位,0表示从0位置开始获取,我们存入的数据是11100111,从0开始计数,往后数两个bit位就是11,11的十进制是3,所以返回3。同理BITFIELD bm1 get u3 0对应的就是111,返回的数据就是7。

注意:Redis客户端(RESP),必须使用2020以前版本,或者2022.2之后的版本,2021不支持二进制数据的展示。




2、实现签到功能

Redis将BitMap的所有操作封装到字符串String中了,因此spring-data-redis使用opsForValue,操作BitMap。

  • 实现用户每日签到
/**
 * 用户每日签到
 * @return
 */
@Override
public Result sign() {
    // 获取当前登录用户
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 获取当前日期
    LocalDateTime now = LocalDateTime.now();
    // 拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyy:MM"));
    String key = USER_SIGN_KEY_PREFIX + userId + keySuffix;
    // 获取今天是本月的第几天(offset = dayOfMonth - 1)
    int dayOfMonth = now.getDayOfMonth();
    // 写入redis完成签到 SETBIT key offset 1
    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    return Result.ok();
}
  • 由于签到没有做对应的前端界面,所以这里用ApiFox测试接口




3、实现统计连续签到天数

  • 问题1:什么叫做连续签到天数?

从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

  • 问题2:如何得到本月到今天为止的所有签到数据?

BITFIELD key GET u[dayOfMonth] 0

例1:BITFIELD key GET u1 0 // 从0开始,统计1天

例2:BITFIELD key GET u2 0 // 从0开始,统计2天

例3:BITFIELD key GET u2 0 // 从0开始,统计3天

例4:BITFIELD key GET u[dayOfMonth] 0 // 从0开始,统计dayOfMonth天

  • 问题3:如何从后向前遍历每个bit位?

与1做与运算,就能得到最后一个bit位。随后右移1位,丢弃最后1位,下一个bit位就成为最后一个bit位,直到遍历到从右往左数第一个为0的位置,结束统计。

  • 统计截止今日的连续签到次数
/**
 * 统计截止今日的连续签到次数
 * @return 返回连续签到天数
 */
@Override
public Result signCount() {
    // 获取当前登录用户
    Long userId = ((UserVo) BaseContext.get()).getId();
    // 获取当前日期
    LocalDateTime now = LocalDateTime.now();
    // 拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyy:MM"));
    String key = USER_SIGN_KEY_PREFIX + userId + keySuffix;
    // 获取今天是本月的第几天(offset)
    int dayOfMonth = now.getDayOfMonth();
    // 获取本月截止今天的所有签到记录,返回一个十进制的数字 BITFIELD key GET u[dayOfMonth] 0
    List<Long> results = stringRedisTemplate.opsForValue().bitField(
            key,
            BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
    );
    // 没有任何签到结果
    if (CollUtil.isEmpty(results)) {
        return Result.ok(0);
    }
    long num = results.get(0).longValue();
    if (num == 0) return Result.ok(0);
    // 循环遍历,计数统计
    int count = 0;
    while ((num & 1) != 0) {
        // 如果最后一位不为0,说明已签到,计数器+1
        count++;
        // 把数字右移一位,抛弃最后一位,继续下一个位
        num >>>= 1; // 因为java中的整形都是有符号数,>>>是无符号右移,左边最高位补0,如果是>>最高位补符号位,对于正数来说,无符号右移和有符号右移结果相同
    }
    return Result.ok(count);
}
  • 测试功能




九、UV统计

1、HyperLogLog的基本用法

首先我们搞懂两个概念:

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

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

  • HyperLogLog用法

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0

Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。

  • HyperLogLog常用指令
    • PFADD key element [element...]:添加指定元素到HyperLogLog中
    • PFCOUNT key [key ...]:返回给定HyperLogLog的基数估算值,即统计key的value有多少个
    • PFMERGE destkey sourcekey [sourcekey ...]:将多个HyperLogLog合并为一个HyperLogLog
  • HyperLogLog的作用:做海量数据的唯一性统计工作

  • HyperLogLog的优缺点
    • 优点:内存占用极低、性能非常好
    • 缺点:有一定的误差

HLL与布隆过滤器的有些特点比较相似,目标都是节省存储空间,结果都是非精准,都有一定的设置误判率或误差率的能力。布隆过滤器核心在于判定是元素是否存在,HLL核心是计数。




2、实现百万数据的统计

由于当前系统并没有足够的用户数据量,所以这里我们需要模拟实现UV统计

为了看出HLL占用内存大小,我们先查看一下当前Redis内存占用情况

# 查看Redis内存
info memory

当前Redis已占用内存大小为2152944 Byte,used_memory_human为2.05MB。

  • 存储模拟用户数据
@Test
void testHyperLogLog() {
    String[] values = new String[1000];
    for (int i = 0; i < 1000000; i++) {
        values[i % 1000] = "user_" + i;
        // 每1000次,添加一次,添加到Redis
        if (i % 1000 == 999) {
            stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
        }
    }
    // 统计数量
    Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
    System.out.println("count = " + count); // count = 997593
}

再使用info memory查看HLL存储100w条数据后内存:2167328 Byte,之前是2152944 Byte,相差(2167328 - 2152944) / 1024 = 14.046875KB,看看误差率 (1000000 - 997593) / 1000000 ≈ 0.002407%,远低于官方说的0.81%,再次运行插入相同数据去重后count仍为997593。

总结:HyperLogLog统计数据量基本准确,并且还能过滤重复元素,存储海量数据占用内存极低,满足了大数据量的UV统计。



参考文章:

  1. 黑马点评项目学习笔记(15w字详解,堪称史上最详细,欢迎收藏)
  2. Kyle’s Blog Redis实战篇
  3. 黑马程序员 Redis 踩坑及解决
  4. 美团首次面试项目话术1——黑马点评篇
  5. 【BAT面试题系列】面试官:你了解乐观锁和悲观锁吗?
  6. Java的CAS原理与应用
  7. Lua 教程 | 菜鸟教程
  8. 并发编程系列之ReentrantLock用法简介

本篇到这里就结束啦😃,感谢各位大佬观看🌹,希望本文能够为您提供有益的参考和启示💡,创作不易📝,还请各位大佬点赞关注支持!~
祝大家学习进步😉工作顺利😊天天开心哦 (●’◡’●) 让我们一起在编程道路上不断前行!

白菜点赞

博客end

评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值