黑马程序员Redis学习笔记--Redis实战篇

写在最前

  • 完整版请移步至我的个人博客查看此文:https://cyborg2077.github.io/2022/10/22/RedisPractice/

内容概述

  • 短信登录

    • 这部分会使用Redis共享session来实现
    • 但其实我在之前的瑞吉外卖的项目优化部分就做过了,用Redis替换session来存储邮箱验证码
  • 商户查询缓存

    • 这部分要理解缓存击穿,缓存穿透,缓存雪崩等问题,对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
  • 优惠卷秒杀

    • 这部分我们可以学会Redis的计数器功能,结合Lua(之前一直想学Lua然后写饥荒mod)完成高性能的Redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
  • 附近的商户

    • 利用Redis的GEOHash(新数据结构,前面没有应用场景就没介绍)来完成对于地理坐标的操作
  • UV统计

    • 主要是使用Redis来完成统计功能
  • 用户签到

    • 使用Redis的BitMap数据统计功能
  • 好友关注

    • 基于Set集合的关注、取消关注,共同关注等等功能,这部分在上篇的练习题中出现过,这次我们在项目中来使用一下
  • 达人探店

    • 基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能

短信登录

导入项目

在实现功能之前,我们先来导入项目,让项目跑起来

导入SQL

黑马已经在资料中提供好了SQL文件,这里简单分析一下提供的表

说明
tb_user 用户表
tb_user_info 用户详情表
tb_shop 商户信息表
tb_shop_type 商户类型表
tb_blog 用户日记表(达人探店日记)
tb_follow 用户关注表
tb_voucher 优惠券表
tb_voucher_order 优惠券的订单表

有关当前模型

  • 该项目采用的是前后端分离开发模式

  • 手机或者app端发起请求,请求我们的Nginx服务器,Nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开Tomcat访问Redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游Tomcat服务器,打散流量,我们都知道一台4核8G的Tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过Nginx的负载均衡分流后,利用集群支撑起整个项目,同时Nginx在部署了前端项目后,更是可以做到动静分离,进一步降低Tomcat服务的压力,这些功能都得靠Nginx起作用,所以Nginx是整个项目中重要的一环。

  • 在Tomcat支撑起并发流量后,我们如果让Tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。

导入后端项目

  • 黑马已经提供好了后端项目源码压缩包,我们将其解压之后,放到自己的workspace里
  • 然后修改MySQL和Reids的连接要素为自己的,随后启动项目
  • 访问http://localhost:8081/shop-type/list, 如果可以看到JSON数据,则说明导入成功

导入前端工程

  • 黑马已经提供好了前端项目源码压缩包,我们将其解压之后,放到自己的workSpace里
  • 然后在nginx所在目录打开一个cmd窗口,输入命令,即可启动项目
start nginx.exe
  • 访问http://localhost:8080/, 打开开发者模式,可以看到页面

基于Session实现登录流程

  1. 发送验证码
    用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
    如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
  2. 短信验证码登录、注册
    用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
  3. 校验登录状态
    用户在请求的时候,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并放行

实现发送短信验证码功能

  • 输入手机号,点击发送验证码按钮,查看发送的请求

请求网址: http://localhost:8080/api/user/code?phone=15832165478
请求方法: POST

  • 看样子是调用UserController中的code方法,携带参数是phone,看黑马提供的源码也证实了我的猜想
/**
    * 发送手机验证码
    */
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    // TODO 发送短信验证码并保存验证码
    return Result.fail("功能未完成");
}
  • 但是黑马这里并不会真的使用短信服务发送验证码,只是随机生成了一个验证码,那我这里为了后期项目能真的部署上线,还是打算用邮箱验证
  • 由于黑马这里貌似没有设置前端的手机号正则判断,所以我们只需要去数据库中修改phone的字段类型,将varchar(11)改为varchar(100)
  • 导入邮箱验证需要的maven坐标
<!-- https://mvnrepository.com/artifact/javax.activation/activation -->
<dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.mail/mail -->
<dependency>
    <groupId>javax.mail</groupId>
    <artifactId>mail</artifactId>
    <version>1.4.7</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-email -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-email</artifactId>
    <version>1.4</version>
</dependency>
  • 然后编写一个工具类,用于发送邮件验证码
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Properties;

import javax.mail.Authenticator;
import javax.mail.MessagingException;
import javax.mail.PasswordAuthentication;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMessage.RecipientType;

public class MailUtils {
    public static void main(String[] args) throws MessagingException {
        //可以在这里直接测试方法,填自己的邮箱即可
        sendTestMail("[email protected]", new MailUtils().achieveCode());
    }

    public static void sendTestMail(String email, String code) throws MessagingException {
        // 创建Properties 类用于记录邮箱的一些属性
        Properties props = new Properties();
        // 表示SMTP发送邮件,必须进行身份验证
        props.put("mail.smtp.auth", "true");
        //此处填写SMTP服务器
        props.put("mail.smtp.host", "smtp.qq.com");
        //端口号,QQ邮箱端口587
        props.put("mail.smtp.port", "587");
        // 此处填写,写信人的账号
        props.put("mail.user", "[email protected]");
        // 此处填写16位STMP口令
        props.put("mail.password", "tXXXXXXXXXfgjb");
        // 构建授权信息,用于进行SMTP进行身份验证
        Authenticator authenticator = new Authenticator() {
            protected PasswordAuthentication getPasswordAuthentication() {
                // 用户名、密码
                String userName = props.getProperty("mail.user");
                String password = props.getProperty("mail.password");
                return new PasswordAuthentication(userName, password);
            }
        };
        // 使用环境属性和授权信息,创建邮件会话
        Session mailSession = Session.getInstance(props, authenticator);
        // 创建邮件消息
        MimeMessage message = new MimeMessage(mailSession);
        // 设置发件人
        InternetAddress form = new InternetAddress(props.getProperty("mail.user"));
        message.setFrom(form);
        // 设置收件人的邮箱
        InternetAddress to = new InternetAddress(email);
        message.setRecipient(RecipientType.TO, to);
        // 设置邮件标题
        message.setSubject("Kyle's Blog 邮件测试");
        // 设置邮件的内容体
        message.setContent("尊敬的用户:你好!\n注册验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");
        // 最后当然就是发送邮件啦
        Transport.send(message);
    }

    public static String achieveCode() {  //由于数字 1 、 0 和字母 O 、l 有时分不清楚,所以,没有数字 1 、 0
        String[] beforeShuffle = new String[]{"2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F",
                "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a",
                "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
                "w", "x", "y", "z"};
        List<String> list = Arrays.asList(beforeShuffle);//将数组转换为集合
        Collections.shuffle(list);  //打乱集合顺序
        StringBuilder sb = new StringBuilder();
        for (String s : list) {
            sb.append(s); //将集合转化为字符串
        }
        return sb.substring(3, 8);
    }
}
  • 修改sendCode方法,逻辑如下
    • 验证手机号/邮箱格式
      • 不正确则返回错误信息
      • 正确则发送验证码
/**
* 发送手机验证码
*/
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) throws MessagingException {
    // TODO 发送短信验证码并保存验证码
    if (RegexUtils.isEmailInvalid(phone)) {
        return Result.fail("邮箱格式不正确");
    }
    String code = MailUtils.achieveCode();
    session.setAttribute(phone, code);
    log.info("发送登录验证码:{}", code);
    MailUtils.sendTestMail(phone, code);
    return Result.ok();
}
  • 然后输入邮箱,发送验证码,看看能否接收到验证码

  • 测试没有问题之后,我们继续来编写登录功能,点击登录按钮,查看发送的请求

请求网址: http://localhost:8080/api/user/login
请求方法: POST

  • 看样子是UserController中的login方法,携带的参数也就是我们的邮箱和验证码
{phone: "[email protected]", code: "iMPKc"}
  • 黑马提供的代码如下,看样子是把邮箱和验证码封装到了LoginFormDto中
    {% tabs 登录业务逻辑和dto %}
    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // TODO 实现登录功能
        return Result.fail("功能未完成");
    }
@Data
public class LoginFormDTO {
    private String phone;
    private String code;
    private String password;
}

{% endtabs %}

  • 修改login方法,逻辑如下
    • 校验手机号/邮箱
      • 不正确则返回错误信息
      • 正确则继续校验验证码
        • 不一致则报错
        • 一致则先根据手机号/邮箱查询用户
          • 用户不存在则创建
          • 存在则继续执行程序
        • 保存用户信息到session中

{% tabs 实现登录业务逻辑651 %}

/**
* 登录功能
*
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
    // TODO 实现登录功能
    //获取登录账号
    String phone = loginForm.getPhone();
    //获取登录验证码
    String code = loginForm.getCode();
    //获取session中的验证码
    Object cacheCode = session.getAttribute(phone);

    //1. 校验邮箱
    if (RegexUtils.isEmailInvalid(phone)) {
        //2. 不符合格式则报错
        return Result.fail("邮箱格式不正确!!");
    }
    //3. 校验验证码
    log.info("code:{},cacheCode{}", code, cacheCode);
    if (code == null || !cacheCode.toString().equals(code)) {
        //4. 不一致则报错
        return Result.fail("验证码不一致!!");
    }
    //5. 根据账号查询用户是否存在
    LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(User::getPhone, phone);
    User user = userService.getOne(queryWrapper);
    //6. 如果不存在则创建
    if (user == null) {
        // 创建的逻辑封装成了一个方法
        user = createUserWithPhone(phone);
    }
    //7. 保存用户信息到session中
    session.setAttribute("user", user);
    return Result.ok();
}
private User createUserWithPhone(String phone) {
    
    
    //创建用户
    User user = new User();
    //设置手机号
    user.setPhone(phone);
    //设置昵称(默认名),一个固定前缀+随机字符串
    user.setNickName("user_" + RandomUtil.randomString(8));
    //保存到数据库
    userService.save(user);
    return user;
}

{% endtabs %}

实现登录拦截功能

  • 这部分需要用到拦截器的知识,我在前面的SSM整合篇做过详细介绍
    {% link SSM整合, https://cyborg2077.github.io/2022/09/10/SSMIntegration/, https://pic1.imgdb.cn/item/6335135c16f2c2beb100182d.jpg %}

  • 创建一个LoginInterceptor类,实现HandlerInterceptor接口,重写其中的两个方法,前置拦截器和完成处理方法,前置拦截器主要用于我们登陆之前的权限校验,完成处理方法是用于处理登录后的信息,避免内存泄露

{% tabs LoginInterceptor拦截器 %}

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取session
        HttpSession session = request.getSession();
        //2. 获取session中的用户信息
        User user = (User) session.getAttribute("user");
        //3. 判断用户是否存在
        if (user == null) {
            //4. 不存在,则拦截
            response.setStatus(401);
            return false;
        }
        //5. 存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
        UserHolder.saveUser(user);
        //6. 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

这是黑马已经提供好了的一个工具类

public class UserHolder {
    
    
    private static final ThreadLocal<User> tl = new ThreadLocal<>();

    public static void saveUser(User user){
    
    
        tl.set(user);
    }

    public static User getUser(){
    
    
        return tl.get();
    }

    public static void removeUser(){
    
    
        tl.remove();
    }
}

让拦截器生效

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    
    
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                );
    }
}

{% endtabs %}

  • 顺便再写一下me方法
@GetMapping("/me")
public Result me() {
    // TODO 获取当前登录的用户并返回
    User user = UserHolder.getUser();
    return Result.ok(user);
}

隐藏用户敏感信息

  • 我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
{
    "success":true,
    "data":{
        "id":1010,
        "phone":"[email protected]",
        "password":"",
        "nickName":"user_i1b3ir09",
        "icon":"",
        "createTime":"2022-10-22T14:20:33",
        "updateTime":"2022-10-22T14:20:33"
    }
}
  • UserDto类如下,将User对象中的属性拷贝给UserDto,就可以避免暴露用户的隐藏信息
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}
  • 修改UserHolder,将其User类型都换为UserDto
public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        tl.set(user);
    }

    public static UserDTO getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}
  • 修改login方法
   @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
        // TODO 实现登录功能
        //获取登录账号
        String phone = loginForm.getPhone();
        //获取登录验证码
        String code = loginForm.getCode();
        //获取session中的验证码
        Object cacheCode = session.getAttribute(phone);

        //1. 校验邮箱
        if (RegexUtils.isEmailInvalid(phone)) {
            //2. 不符合格式则报错
            return Result.fail("邮箱格式不正确!!");
        }
        //3. 校验验证码
        log.info("code:{},cacheCode{}", code, cacheCode);
        if (code == null || !cacheCode.toString().equals(code)) {
            //4. 不一致则报错
            return Result.fail("验证码不一致!!");
        }
        //5. 根据账号查询用户是否存在
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone, phone);
        User user = userService.getOne(queryWrapper);
        //6. 如果不存在则创建
        if (user == null) {
            user = createUserWithPhone(phone);
        }
        //7. 保存用户信息到session中
-       session.setAttribute("user", user);
+       UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
+       session.setAttribute("user", userDTO);
        return Result.ok();
    }
  • 修改拦截器
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取session
        HttpSession session = request.getSession();
        //2. 获取session中的用户信息
-       User user = (User) session.getAttribute("user");
+       UserDTO user = (UserDTO) session.getAttribute("user");
        //3. 判断用户是否存在
        if (user == null) {
            //4. 不存在,则拦截
            response.setStatus(401);
            return false;
        }
        //5. 存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
        UserHolder.saveUser(user);
        //6. 放行
        return true;
    }
  • 重启服务器,登录后查看此时的用户信息,敏感信息已经不存在了
{
    "success":true,
    "data":{
        "id":1016,
        "nickName":"user_zkhf7cfv",
        "icon":""
    }
}

session共享问题

  • 每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

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

    1. 每台服务器中都有完整的一份session数据,服务器压力过大。
    2. session拷贝数据时,可能会出现延迟
  • 所以我们后面都是基于Redis来完成,我们把session换成Redis,Redis数据本身就是共享的,就可以避免session共享的问题了

Redis替代session的业务流程

设计key结构

  • 首先我们来思考一下该用什么数据结构来存储数据
  • 由于存入的数据比较简单,我们可以使用String或者Hash
    • 如果使用String,以JSON字符串来保存数据,会额外占用部分空间
    • 如果使用Hash,则它的value中只会存储数据本身
  • 如果不是特别在意内存,直接使用String就好了

设计key的具体细节

  • 我们这里就采用的是简单的K-V键值对方式
  • 但是对于key的处理,不能像session一样用phone或code来当做key
  • 因为Redis的key是共享的,code可能会重复,phone这种敏感字段也不适合存储到Redis中
  • 在设计key的时候,我们需要满足两点
    1. key要有唯一性
    2. key要方便携带
  • 所以我们在后台随机生成一个token,然后让前端带着这个token就能完成我们的业务逻辑了

整体访问流程

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

基于Redis实现短信登录

  • 由于前面已经分析过业务逻辑了,所以这里我们直接开始写代码,在此之前我们要在UserController中注入StringRedisTemplate
@Autowired
private StringRedisTemplate stringRedisTemplate;
  • 修改sendCode方法
    {% tabs 基于Redis实现短信登录 %}

这里的key使用用login:code:email的形式,并设置有效期2分钟,我们也可以定义一个常量类来替换这里的login:code:2,让代码显得更专业一点

@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) throws MessagingException {
    // TODO 发送短信验证码并保存验证码
    if (RegexUtils.isEmailInvalid(phone)) {
        return Result.fail("邮箱格式不正确");
    }
    String code = MailUtils.achieveCode();
-   session.setAttribute(phone, code);
-   stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 2, TimeUnit.MINUTES);
+   stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    log.info("发送登录验证码:{}", code);
//        MailUtils.sendTestMail(phone, code);
    return Result.ok();
}

定义的常量类

public class RedisConstants {
    
    
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 36000L;

    public static final Long CACHE_NULL_TTL = 2L;

    public static final Long CACHE_SHOP_TTL = 30L;
    public static final String CACHE_SHOP_KEY = "cache:shop:";

    public static final String LOCK_SHOP_KEY = "lock:shop:";
    public static final Long LOCK_SHOP_TTL = 10L;

    public static final String SECKILL_STOCK_KEY = "seckill:stock:";
    public static final String BLOG_LIKED_KEY = "blog:liked:";
    public static final String FEED_KEY = "feed:";
    public static final String SHOP_GEO_KEY = "shop:geo:";
    public static final String USER_SIGN_KEY = "sign:";
}

{% endtabs %}

  • 修改login方法
    {% tabs 修改login方法 %}
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
        // TODO 实现登录功能
        //获取登录账号
        String phone = loginForm.getPhone();
        //获取登录验证码
        String code = loginForm.getCode();
-       //获取session中的验证码
-       Object cacheCode = session.getAttribute(phone);
+       //获取redis中的验证码
+       String sessionCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + userCode);
        //1. 校验邮箱
        if (RegexUtils.isEmailInvalid(phone)) {
            //2. 不符合格式则报错
            return Result.fail("邮箱格式不正确!!");
        }
        //3. 校验验证码
        log.info("code:{},cacheCode{}", code, cacheCode);
        if (code == null || !cacheCode.toString().equals(code)) {
            //4. 不一致则报错
            return Result.fail("验证码不一致!!");
        }
        //5. 根据账号查询用户是否存在
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone, phone);
        User user = userService.getOne(queryWrapper);
        //6. 如果不存在则创建
        if (user == null) {
            user = createUserWithPhone(phone);
        }
-       //7. 保存用户信息到session中
+       //7. 保存用户信息到Redis中
+       //7.1 随机生成token,作为登录令牌
+       String token = UUID.randomUUID().toString();
+       //7.2 将UserDto对象转为HashMap存储
+       UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
+       HashMap<String, String > userMap = new HashMap<>();
+       userMap.put("icon", userDTO.getIcon());
+       userMap.put("id", String.valueOf(userDTO.getId()));
+       userMap.put("nickName", userDTO.getNickName(    ));
+       //7.3 存储
+       String tokenKey = LOGIN_USER_KEY + token;
+       stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
+       //7.4 设置token有效期为30分钟
+       stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
+       //7.5 登陆成功则删除验证码信息
+       stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
-       session.setAttribute("user", userDTO);
+       //8. 返回token
+       return Result.ok(token);
    }
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
    
    
        // TODO 实现登录功能
        //获取登录账号
        String phone = loginForm.getPhone();
        //获取登录验证码
        String code = loginForm.getCode();
        //获取redis中的验证码
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);

        //1. 校验邮箱
        if (RegexUtils.isEmailInvalid(phone)) {
    
    
            //2. 不符合格式则报错
            return Result.fail("邮箱格式不正确!!");
        }
        //3. 校验验证码
        log.info("code:{},cacheCode{}", code, cacheCode);
        if (cacheCode == null || !cacheCode.equals(code)) {
    
    
            // 不一致,报错
            return Result.fail("验证码错误");
        }
        //5. 根据账号查询用户是否存在
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone, phone);
        User user = userService.getOne(queryWrapper);
        //6. 如果不存在则创建
        if (user == null) {
    
    
            user = createUserWithPhone(phone);
        }
        //7. 保存用户信息到session中
        //7. 保存用户信息到Redis中
        //7.1 随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString();
        //7.2 将UserDto对象转为HashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        HashMap<String, String > userMap = new HashMap<>();
        userMap.put("icon", userDTO.getIcon());
        userMap.put("id", String.valueOf(userDTO.getId()));
        userMap.put("nickName", userDTO.getNickName());
        //高端写法,现在我还学不来,工具类还不太了解,只能自己手动转换类型然后put了
//        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
//                CopyOptions.create()
//                        .setIgnoreNullValue(true)
//                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        //7.3 存储
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        //7.4 设置token有效期为30分钟
        stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
        //7.5 登陆成功则删除验证码信息
        stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
        //8. 返回token
        return Result.ok(token);
    }

{% endtabs %}

解决状态登录刷新问题

初始方案

  • 我们可以通过拦截器拦截到的请求,来证明用户是否在操作,如果用户没有任何操作30分钟,则token会消失,用户需要重新登录
  • 通过查看请求,我们发现我们存的token在请求头里,那么我们就在拦截器里来刷新token的存活时间

authorization: 6867061d-a8d0-4e60-b92f-97f7d698a1ca

  • 修改我们的登陆拦截器LoginInterceptor
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取请求头中的token
        String token = request.getHeader("authorization");
        //2. 如果token是空,则未登录,拦截
        if (StrUtil.isBlank(token)) {
            response.setStatus(401);
            return false;
        }
        String key = RedisConstants.LOGIN_USER_KEY + token;
        //3. 基于token获取Redis中的用户数据
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        //4. 判断用户是否存在,不存在,则拦截
        if (userMap.isEmpty()) {
            response.setStatus(401);
            return false;
        }
        //5. 将查询到的Hash数据转化为UserDto对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //6. 将用户信息保存到ThreadLocal
        UserHolder.saveUser(userDTO);
        //7. 刷新tokenTTL,这里的存活时间根据需要自己设置,这里的常量值我改为了30分钟
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }
  • 在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

优化方案

  • 既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

  • 新建一个RefreshTokenInterceptor类,其业务逻辑与之前的LoginInterceptor类似,就算遇到用户未登录,也继续放行,交给LoginInterceptor处理
    由于这个对象是我们手动在WebConfig里创建的,所以这里不能用@AutoWired自动装配,只能声明一个私有的,到了WebConfig里再自动装配
public class RefreshTokenInterceptor implements HandlerInterceptor {
    //这里并不是自动装配,因为RefreshTokenInterceptor是我们手动在WebConfig里new出来的
    private StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取请求头中的token
        String token = request.getHeader("authorization");
        //2. 如果token是空,直接放行,交给LoginInterceptor处理
        if (StrUtil.isBlank(token)) {
            return true;
        }
        String key = RedisConstants.LOGIN_USER_KEY + token;
        //3. 基于token获取Redis中的用户数据
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        //4. 判断用户是否存在,不存在,也放行,交给LoginInterceptor
        if (userMap.isEmpty()) {
            return true;
        }
        //5. 将查询到的Hash数据转化为UserDto对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //6. 将用户信息保存到ThreadLocal
        UserHolder.saveUser(userDTO);
        //7. 刷新tokenTTL,这里的存活时间根据需要自己设置,这里的常量值我改为了30分钟
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}
  • 修改我们之前的LoginInterceptor类,只需要判断用户是否存在,不存在,则拦截,存在则放行
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断用户是否存在
        if (UserHolder.getUser()==null){
            //不存在则拦截
            response.setStatus(401);
            return false;
        }
        //存在则放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}
  • 修改WebConfig配置类,拦截器的执行顺序可以由order来指定,如果未设置拦截路径,则默认是拦截所有路径
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    //到了这里才能自动装配
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                ).order(1);
        //RefreshTokenInterceptor是我们手动new出来的
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
    }
}
  • 那么至此,大功告成,我们重启服务器,登录,然后去Redis的图形化界面查看token的ttl,如果每次切换界面之后,ttl都会重置,那么说明我们的代码没有问题

商户查询缓存

什么是缓存

  • 什么是缓存?
    • 缓存就像自行车、越野车的避震器
  • 举个例子
    • 越野车、山地自行车都有避震器,防止车体加速之后因惯性,在U型地形上飞跃硬着陆导致损坏,像个弹簧意义
  • 同样,在实际开发中,系统也需要避震器,防止过高的数据量猛冲系统,导致其操作线程无法及时处理信息而瘫痪
  • 在实际开发中,对企业来讲,产品口碑、用户评价都是致命的,所以企业非常重视缓存技术
  • 缓存(Cache)就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地,例如
    {% tabs 三种缓存方式举例 %}
Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>();
static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build();
Static final Map<K,V> map =  new HashMap();

{% endtabs %}

  • 由于其被static修饰,所以随着类的加载而加载到内存之中,作为本地缓存,由于其又被final修饰,所以其引用之间的关系是固定的,不能改变,因此不用担心复制导致缓存失败

为什么要使用缓存

  • 言简意赅:速度快,好用
  • 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
  • 实际开发中,企业的数据量,少则几十万,多则几千万,这么大的数据量,如果没有缓存来作为避震器系统是几乎撑不住的,所以企业会大量运用缓存技术
  • 但是缓存也会增加代码复杂度和运营成本
  • 缓存的作用
    1. 降低后端负载
    2. 提高读写效率,降低响应时间
  • 缓存的成本
    1. 数据一致性成本
    2. 代码维护成本
    3. 运维成本(一般采用服务器集群,需要多加机器,机器就是钱)

如何使用缓存

  • 实际开发中,会构筑多级缓存来时系统运行速度进一步提升,例如:本地缓存与Redis中的缓存并发使用
  • 浏览器缓存:主要是存在于浏览器端的缓存
  • 应用层缓存:可以分为toncat本地缓存,例如之前提到的map或者是使用Redis作为缓存
  • 数据库缓存:在数据库中有一片空间是buffer pool,增改查数据都会先加载到mysql的缓存中
  • CPU缓存:当代计算机最大的问题就是CPU性能提升了,但是内存读写速度没有跟上,所以为了适应当下的情况,增加了CPU的L1,L2,L3级的缓存

添加商户缓存

  • 我们先启动前端和后端的项目,登陆之后随便访问一个商户,查看浏览器发送的请求

请求网址: http://localhost:8080/api/shop/10
请求方法: GET

  • 不出意外是ShopController里的业务逻辑,而且restFul风格的

  • 在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库肯定慢

/**
    * 根据id查询商铺信息
    * @param id 商铺id
    * @return 商铺详情数据
    */
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
    return Result.ok(shopService.getById(id));
}
  • 所以我们可以在客户端与数据库之间加上一个Redis缓存,先从Redis中查询,如果没有查到,再去MySQL中查询,同时查询完毕之后,将查询到的数据也存入Redis,这样当下一个用户来进行查询的时候,就可以直接从Redis中获取到数据

缓存模型和思路

  • 标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入Redis。

代码实现

  • 代码思路:如果Redis缓存里有数据,那么直接返回,如果缓存中没有,则去查询数据库,然后存入Redis
    {% tabs 缓存商户信息到slkdgh %}

业务逻辑我们写到Service中,需要在Service层创建这个queryById方法,然后去ServiceImpl中实现

@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
    
    
    return shopService.queryById(id);
}
public interface IShopService extends IService<Shop> {
    
    
    Result queryById(Long id);
}
@Override
public Result queryById(Long id) {
    
    
    //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    //如果不为空(查询到了),则转为Shop类型直接返回
    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("店铺不存在!!");
    }
    //查到了则转为json字符串
    String jsonStr = JSONUtil.toJsonStr(shop);
    //并存入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr);
    //最终把查询到的商户信息返回给前端
    return Result.ok(shop);
}

{% endtabs %}

  • 重启服务器,访问商户信息,观察控制台日志输出,后续刷新页面,不会出现SQL语句查询商户信息,去Redis图形化界面中查看,可以看到缓存的商户信息数据

趁热打铁

  • 完成了商户数据缓存之后,我们尝试做一下商户类型数据缓存
    {% tabs 存之后,我们尝试做一下商户类型 %}

业务逻辑依旧是写在Service中

@GetMapping("list")
public Result queryTypeList() {
    
    
    return typeService.queryList();
}
public interface IShopTypeService extends IService<ShopType> {
    
    
    Result queryList();
}
  • 整体代码都是类似的,前面只需要将单个店铺信息从JSON和Bean之间相互转换
  • 这里只不过是将查询到的多个店铺类型信息从JSON和Bean之间相互转换,只是多了一个foreach循环
    @Override
    public Result queryList() {
        //先从Redis中查,这里的常量值是固定前缀 + 店铺id
        List<String> shopTypes =
                stringRedisTemplate.opsForList().range(CACHE_SHOP_TYPE_KEY, 0, -1);
        //如果不为空(查询到了),则转为ShopType类型直接返回
        if (!shopTypes.isEmpty()) {
            List<ShopType> tmp = new ArrayList<>();
            for (String types : shopTypes) {
                ShopType shopType = JSONUtil.toBean(types, ShopType.class);
                tmp.add(shopType);
            }
            return Result.ok(tmp);
        }
        //否则去数据库中查
        List<ShopType> tmp = query().orderByAsc("sort").list();
        if (tmp == null){
            return Result.fail("店铺类型不存在!!");
        }
        //查到了转为json字符串,存入redis
        for (ShopType shopType : tmp) {
            String jsonStr = JSONUtil.toJsonStr(shopType);
            shopTypes.add(jsonStr);
        }
        stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOP_TYPE_KEY,shopTypes);
        //最终把查询到的商户分类信息返回给前端
        return Result.ok(tmp);
    }

{% endtabs %}

缓存更新策略

  • 缓存更新是Redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们想Redis插入太多数据,此时就可能会导致缓存中数据过多,所以Redis会对部分数据进行更新,或者把它成为淘汰更合适
  • 内存淘汰:Redis自动进行,当Redis内存大道我们设定的max-memery时,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
  • 超时剔除:当我们给Redis设置了过期时间TTL之后,Redis会将超时的数据进行删除,方便我们继续使用缓存
  • 主动更新:我们可以手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题
内存淘汰 超时剔除 主动更新
说明 不用自己维护,
利用Redis的内存淘汰机制,
当内存不足时自动淘汰部分数据。
下次查询时更新缓存。
给缓存数据添加TTL时间,
到期后自动删除缓存。
下次查询时更新缓存。
编写业务逻辑,
在修改数据库的同时,
更新缓存。
一致性 一般
维护成本
  • 业务场景
    • 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存(因为这个很长一段时间都不需要更新)
    • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存

数据库和缓存不一致解决方案

  • 由于我们的缓存数据源来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是
    • 用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等
  • 那么如何解决这个问题呢?有如下三种方式
    1. Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案
    2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
    3. Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了

数据库和缓存不一致采用什么方案

  • 综上所述,在企业的实际应用中,还是方案一最可靠,但是方案一的调用者该如何处理呢?

  • 如果采用方案一,假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大,所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来

  • 对比删除缓存与更新缓存

    • 更新缓存:每次更新数据库都需要更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,再次查询时更新缓存
  • 如何保证缓存与数据库的操作同时成功/同时失败

    • 单体系统:将缓存与数据库操作放在同一个事务
    • 分布式系统:利用TCC等分布式事务方案
  • 先操作缓存还是先操作数据库?我们来仔细分析一下这两种方式的线程安全问题

  • 先删除缓存,在操作数据库
    删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题

  • 先操作数据库,再删除缓存
    线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题

  • 虽然这二者都存在线程安全问题,但是相对来说,后者出现线程安全问题的概率相对较低,所以我们最终采用后者先操作数据库,再删除缓存的方案

实现商铺缓存与数据库双写一致

  • 核心思路如下

    • 修改ShopController中的业务逻辑,满足以下要求
    1. 根据id查询店铺时,如果缓存未命中,则查询数据库,并将数据库结果写入缓存,并设置TTL
    2. 根据id修改店铺时,先修改数据库,再删除缓存
  • 修改ShopController的queryById方法,写入缓存时设置一下TTL

    @Override
    public Result queryById(Long id) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        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("店铺不存在!!");
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(shop);
        //并存入redis,设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //最终把查询到的商户信息返回给前端
        return Result.ok(shop);
    }
  • 修改update方法
    {% tabs 之前的update方法 %}
/**
    * 更新商铺信息
    *
    * @param shop 商铺数据
    * @return 无
    */
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
    
    
    // 写入数据库
    shopService.updateById(shop);
    return Result.ok();
}

业务逻辑我们依旧是放在Service层去写

/**
    * 更新商铺信息
    *
    * @param shop 商铺数据
    * @return 无
    */
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
    
    
    return shopService.update(shop);
}

新增一个方法,Impl里去实现

Result update(Shop shop);
    @Override
    public Result update(Shop shop) {
    
    
        //首先先判一下空
        if (shop.getId() == null){
    
    
            return Result.fail("店铺id不能为空!!");
        }
        //先修改数据库
        updateById(shop);
        //再删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
        return Result.ok();
    }

{% endtabs %}

  • 修改完毕之后我们重启服务器进行测试,首先随便挑一个顺眼的数据,我这里就是拿餐厅数据做测试,,我们先访问该餐厅,将该餐厅的数据缓存到Redis中,之后使用POSTMAN发送PUT请求,请求路径http://localhost:8080/api/shop/,携带JSON数据如下
{
  "area": "大关",
  "openHours": "10:00-22:00",
  "sold": 4215,
  "address": "金华路锦昌文华苑29号",
  "comments": 3035,
  "avgPrice": 80,
  "score": 37,
  "name": "476茶餐厅",
  "typeId": 1,
  "id": 1
}
  • 之后再Redis图形化页面刷新数据,发现该餐厅的数据确实不在Redis中了,之后我们刷新网页,餐厅名会被改为476茶餐厅,然后我们再去Redis中刷新,发现新数据已经被缓存了
  • 那么现在功能就实现完毕了,只有当我们刷新页面的时候,才会重新查询数据库,并将数据缓存到Redis,中途无论修改多少次,只要不刷新页面访问,Redis中都不会更新数据

缓存穿透问题的解决思路

  • 缓存穿透:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效(只有数据库查到了,才会让redis缓存,但现在的问题是查不到),会频繁的去访问数据库。

  • 常见的结局方案有两种

    1. 缓存空对象
      • 优点:实现简单,维护方便
      • 缺点:额外的内存消耗,可能造成短期的不一致
    2. 布隆过滤
      • 优点:内存占用啥哦,没有多余的key
      • 缺点:实现复杂,可能存在误判
  • 缓存空对象思路分析:当我们客户端访问不存在的数据时,会先请求redis,但是此时redis中也没有数据,就会直接访问数据库,但是数据库里也没有数据,那么这个数据就穿透了缓存,直击数据库。但是数据库能承载的并发不如redis这么高,所以如果大量的请求同时都来访问这个不存在的数据,那么这些请求就会访问到数据库,简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个这个数据存在redis中去(这就是为啥说会有额外的内存消耗),这样下次用户过来访问这个不存在的数据时,redis缓存中也能找到这个数据,不用去查数据库。可能造成的短期不一致是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的TTL还没过,所以当用户来查询的时候,查询到的还是空对象,等TTL过了之后,才能访问到正确的数据,不过这种情况很少见罢了

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

编码解决商品查询的缓存穿透问题

  • 核心思路如下
  • 在原来的逻辑中,我们如果发现这个数据在MySQL中不存在,就直接返回一个错误信息了,但是这样存在缓存穿透问题
    @Override
    public Result queryById(Long id) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        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("店铺不存在!!");
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(shop);
        //并存入redis,设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //最终把查询到的商户信息返回给前端
        return Result.ok(shop);
    }
  • 现在的逻辑是:如果这个数据不存在,将这个数据写入到Redis中,并且将value设置为空字符串,然后设置一个较短的TTL,返回错误信息。当再次发起查询时,先去Redis中判断value是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息
    @Override
    public Result queryById(Long id) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //如果查询到的是空字符串,则说明是我们缓存的空数据
        if (shopjson != null) {
            return Result.fail("店铺不存在!!");
        }
        //否则去数据库中查
        Shop shop = getById(id);
        //查不到,则将空字符串写入Redis
        if (shop == null) {
            //这里的常量值是2分钟
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在!!");
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(shop);
        //并存入redis,设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //最终把查询到的商户信息返回给前端
        return Result.ok(shop);
    }

{% note info no-icon %}
小结:

  • 缓存穿透产生的原因是什么?
    • 用户请求的数据在缓存中和在数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力
  • 缓存产投的解决方案有哪些?
    • 缓存null值
    • 布隆过滤
    • 增强id复杂度,避免被猜测id规律(可以采用雪花算法)
    • 做好数据的基础格式校验
    • 加强用户权限校验
    • 做好热点参数的限流
      {% endnote %}

缓存雪崩问题及解决思路

  • 缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
  • 解决方案
    • 给不同的Key的TTL添加随机值,让其在不同时间段分批失效
    • 利用Redis集群提高服务的可用性(使用一个或者多个哨兵(Sentinel)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存(浏览器访问静态资源时,优先读取浏览器本地缓存;访问非静态资源(ajax查询数据)时,访问服务端;请求到达Nginx后,优先读取Nginx本地缓存;如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat);如果Redis查询未命中,则查询Tomcat;请求进入Tomcat后,优先查询JVM进程缓存;如果JVM进程缓存未命中,则查询数据库)

缓存击穿问题及解决思路

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

  • 举个不太恰当的例子:一件秒杀中的商品的key突然失效了,大家都在疯狂抢购,那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿

  • 常见的解决方案有两种

    1. 互斥锁
    2. 逻辑过期
  • 逻辑分析:假设线程1在查询缓存之后未命中,本来应该去查询数据库,重建缓存数据,完成这些之后,其他线程也就能从缓存中加载这些数据了。但是在线程1还未执行完毕时,又进来了线程2、3、4同时来访问当前方法,那么这些线程都不能从缓存中查询到数据,那么他们就会在同一时刻访问数据库,执行SQL语句查询,对数据库访问压力过大

  • 解决方案一:互斥锁

  • 利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用tryLock方法+double check来解决这个问题

  • 线程1在操作的时候,拿着锁把房门锁上了,那么线程2、3、4就不能都进来操作数据库,只有1操作完了,把房门打开了,此时缓存数据也重建好了,线程2、3、4直接从redis中就可以查询到数据。

  • 解决方案二:逻辑过期方案
  • 方案分析:我们之所以会出现缓存击穿问题,主要原因是在于我们对key设置了TTL,如果我们不设置TTL,那么就不会有缓存击穿问题,但是不设置TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案
  • 我们之前是TTL设置在redis的value中,注意:这个过期时间并不会直接作用于Redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断当前数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的进程他会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成者逻辑之后,才会释放锁,而线程1直接进行返回,假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据
  • 这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据

对比互斥锁与逻辑删除

  • 互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,只是加了一把锁而已,也没有其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁的情况,就可能死锁,所以只能串行执行,性能会受到影响
  • 逻辑过期方案:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构缓存数据,但是在重构数据完成之前,其他线程只能返回脏数据,且实现起来比较麻烦
解决方案 优点 缺点
互斥锁 没有额外的内存消耗
保证一致性
实现简单
线程需要等待,性能受影响
可能有死锁风险
逻辑过期
线程无需等待,性能较好

不保证一致性
有额外内存消耗
实现复杂

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

  • 核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是,进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,知道获取到锁为止,才能进行查询

  • 如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿

  • 操作锁的代码

  • 核心思路就是利用redis的setnx方法来表示获取锁,如果redis没有这个key,则插入成功,返回1,如果已经存在这个key,则插入失败,返回0。在StringRedisTemplate中返回true/false,我们可以根据返回值来判断是否有线程成功获取到了锁
    {% tabs 操作所的代码46513 %}

private boolean tryLock(String key) {
    
    
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    //避免返回值为null,我们这里使用了BooleanUtil工具类
    return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

{% endtabs %}

  • 然后这里先把我们之前写的缓存穿透代码修改一下,提取成一个独立的方法
    @Override
    public Shop queryWithPassThrough(Long id) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        if (shopjson != null) {
            return null;
        }
        //否则去数据库中查
        Shop shop = getById(id);
        //查不到,则将空值写入Redis
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(shop);
        //并存入redis,设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //最终把查询到的商户信息返回给前端
        return shop;
    }
  • 之后编写我们的互斥锁代码,其实与缓存穿透代码类似,只需要在上面稍加修改即可
    {% tabs 编写我们的互斥锁代码,其 %}
    @Override
-   public Shop queryWithPassThrough(Long id) {
+   public Shop queryWithMutex(Long id) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        if (shopjson != null) {
            return null;
        }
        //否则去数据库中查
+       //从这里,用try/catch/finally包裹
+       //获取互斥锁
+       boolean flag = tryLock(LOCK_SHOP_KEY + id);
+       //判断是否获取成功
+       if (!flag) {
+           //失败,则休眠并重试
+           Thread.sleep(50);
+           return queryWithMutex(id);
+       }
        Shop shop = getById(id);
        //查不到,则将空值写入Redis
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(shop);
        //并存入redis,设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
+       //try/catch/finally包裹到这里,然后把释放锁的操作放到finally里
+       //释放互斥锁
+       unlock(LOCK_SHOP_KEY + id);
        //最终把查询到的商户信息返回给前端
        return shop;
    }

在上面的基础上,使用try/catch/finally包裹,因为不管前面是否会有异常,最终都必须释放锁

    @Override
    public Shop queryWithMutex(Long id) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        if (shopJson != null) {
            return null;
        }
        Shop shop = null;
        try {
            //否则去数据库中查
            boolean flag = tryLock(LOCK_SHOP_KEY + id);
            if (!flag) {
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //查不到,则将空值写入Redis
            shop = getById(id);
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //查到了则转为json字符串
            String jsonStr = JSONUtil.toJsonStr(shop);
            //并存入redis,设置TTL
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
            //最终把查询到的商户信息返回给前端
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            unlock(LOCK_SHOP_KEY + id);
        }
        return shop;
    }

{% endtabs %}

  • 最终修改queryById方法
    @Override
    public Result queryById(Long id) {
        Shop shop = queryWithMutex(id);
        if (shop == null) {
            return Result.fail("店铺不存在!!");
        }
        return Result.ok(shop);
    }
  • 使用Jmeter进行测试
    • 我们先来模拟一下缓存击穿的情景,缓存击穿是指在某时刻,一个热点数据的TTL到期了,此时用户不能从Redis中获取热点商品数据,然后就都得去数据库里查询,造成数据库压力过大。
    • 那么我们首先将Redis中的热点商品数据删除,模拟TTL到期,然后用Jmeter进行压力测试,开100个线程来访问这个没有缓存的热点数据
      • 如果后台日志只输出了一条SQL语句,则说明我们的互斥锁是生效的,没有造成大量用户都去查询数据库,执行SQL语句
      : ==>  Preparing: SELECT id,name,type_id,images,area,address,x,y,avg_price,sold,comments,score,open_hours,create_time,update_time FROM tb_shop WHERE id=?
      : ==> Parameters: 2(Long)
      : <==      Total: 1
      
      • 如果日志输出了好多SQL语句,则说明我们的代码有问题

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

  • 需求:根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
  • 思路分析:当用户开始查询redis时,判断是否命中
    • 如果没有命中则直接返回空数据,不查询数据库
    • 如果命中,则将value取出,判断value中的过期时间是否满足
      • 如果没有过期,则直接返回redis中的数据
      • 如果过期,则在开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁
  • 封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么新建一个类包含原有的数据和过期时间
  • 步骤一
  • 这里我们选择新建一个实体类,包含原有数据(用万能的Object)和过期时间,这样对原有的代码没有侵入性
@Data
public class RedisData<T> {
    private LocalDateTime expireTime;
    private T data;
}
  • 步骤二
  • 在ShopServiceImpl中新增方法,进行单元测试,看看能否写入数据
public void saveShop2Redis(Long id, Long expirSeconds) {
    Shop shop = getById(id);
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds));
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
  • 编写测试方法
@SpringBootTest
class HmDianPingApplicationTests {

    @Autowired
    private ShopServiceImpl shopService;

    @Test
    public void test(){
        shopService.saveShop2Redis(1L,1000L);
    }
}
  • 运行测试方法,去Redis图形化页面看到存入的value,确实包含了data和expireTime1
{
  "data": {
    "area": "大关",
    "openHours": "10:00-22:00",
    "sold": 4215,
    "images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-_QiKuOvyio1OOxsRtFoXqu0G3iT2T27qat3WhLVEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vfCF2ubeXzk49OsGrXt_KYDCngOyCwZK-s3fqawWswzk.jpg,https://qcloud.dpfile.com/pc/IOf6VX3qaBgFXFVgp75w-KKJmWZjFc8GXDU8g9bQC6YGCpAmG00QbfT4vCCBj7njuzFvxlbkWx5uwqY2qcjixFEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vmIU_8ZGOT1OjpJmLxG6urQ.jpg",
    "address": "金华路锦昌文华苑29号",
    "comments": 3035,
    "avgPrice": 80,
    "updateTime": 1666502007000,
    "score": 37,
    "createTime": 1640167839000,
    "name": "476茶餐厅",
    "x": 120.149192,
    "y": 30.316078,
    "typeId": 1,
    "id": 1
  },
  "expireTime": 1666519036559
}
  • 步骤三:正式代码
    正式代码我们就直接照着流程图写就好了
    //这里需要声明一个线程池,因为下面我们需要新建一个现成来完成重构缓存
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    @Override
    public Shop queryWithLogicalExpire(Long id) {
        //1. 从redis中查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //2. 如果未命中,则返回空
        if (StrUtil.isBlank(json)) {
            return null;
        }
        //3. 命中,将json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        //3.1 将data转为Shop对象
        JSONObject shopJson = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        //3.2 获取过期时间
        LocalDateTime expireTime = redisData.getExpireTime();
        //4. 判断是否过期
        if (LocalDateTime.now().isBefore(time)) {
            //5. 未过期,直接返回商铺信息
            return shop;
        }
        //6. 过期,尝试获取互斥锁
        boolean flag = tryLock(LOCK_SHOP_KEY + id);
        //7. 获取到了锁
        if (flag) {
            //8. 开启独立线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    this.saveShop2Redis(id, LOCK_SHOP_TTL);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(LOCK_SHOP_KEY + id);
                }
            });
            //9. 直接返回商铺信息
            return shop;
        }
        //10. 未获取到锁,直接返回商铺信息
        return shop;
    }
  • 使用Jmeter进行测试
    • 先来复现一遍场景,当某个用户去Redis中访问缓存的数据时,发现该数据已经过期了,于是新开一个线程去重构缓存数据,但在重构完成之前,用户得到的数据都是脏数据,重构完成之后,才是新数据
    • 那我们先使用saveShop2Redis方法,向redis中添加一个逻辑过期数据,设置过期时间为2秒,这样很快就过期了,
      {% tabs 先使用saveShop2Redis方法 %}
      ```JAVA
    
    public void saveShop2Redis(Long id, Long expirSeconds) {
    Shop shop = getById(id);
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds));
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }
    <!-- endtab -->
    <!-- tab Test -->
    ```JAVA
    @Test
    public void test(){
        shopService.saveShop2Redis(2L,2L);
    }
    
    {% endtabs %}
    • 之后去数据库把这个数据修改一下,这样逻辑过期前和逻辑过期后的数据就不一致,当用户来访问数据的时候,需要花时间来进行重构缓存数据,但是在重构完成之前,都只能获得脏数据(也就是我们修改前的数据),只有当重构完毕之后,才能获得新数据(我们修改后的数据)
    • 测试结果如下,同样是开了100个线程去访问逻辑过期数据,前面的用户只能看到脏数据,后面的用户看到的才是新数据

封装Redis工具类

  • 基于StringRedisTemplate封装一个缓存工具类,需满足下列要求
    • 方法1:将任意Java对象序列化为JSON,并存储到String类型的Key中,并可以设置TTL过期时间
    public void set(String key, Object value, Long time, TimeUnit timeUnit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
    }
    
    • 方法2:将任意Java对象序列化为JSON,并存储在String类型的Key中,并可以设置逻辑过期时间,用于处理缓存击穿问题
    public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) {
        //由于需要设置逻辑过期时间,所以我们需要用到RedisData
        RedisData<Object> redisData = new RedisData<>();
        //redisData的data就是传进来的value对象
        redisData.setData(value);
        //逻辑过期时间就是当前时间加上传进来的参数时间,用TimeUnit可以将时间转为秒,随后与当前时间相加
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
        //由于是逻辑过期,所以这里不需要设置过期时间,只存一下key和value就好了,同时注意value是ridisData类型
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }
    
    • 方法3:根据指定的Key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
      {% tabs 方法3:根据 %}
    @Override
    public Shop queryWithPassThrough(Long id) {
          
          
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(shopJson)) {
          
          
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        if (shopjson != null) {
          
          
            return null;
        }
        //否则去数据库中查
        Shop shop = getById(id);
        //查不到,则将空值写入Redis
        if (shop == null) {
          
          
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(shop);
        //并存入redis,设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //最终把查询到的商户信息返回给前端
        return shop;
    }
    
    • 改为通用方法,那么返回值就需要进行修改,不能返回Shop了,那我们直接设置一个泛型,同时ID的类型,也不一定都是Long类型,所以我们也采用泛型。
    • Key的前缀也会随着业务需求的不同而修改,所以参数列表里还需要加入Key的前缀
    • 通过id去数据库查询的具体业务需求我们也不清楚,所以我们也要在参数列表中加入一个查询数据库逻辑的函数
    • 最后再加上设置TTL需要的两个参数
    • 那么综上所述,我们的参数列表需要
      1. key前缀
      2. id(类型泛型)
      3. 返回值类型(泛型)
      4. 查询的函数
      5. TTL需要的两个参数
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        //如果不为空(查询到了),则转为R类型直接返回
        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }
        if (json != null) {
            return null;
        }
        //否则去数据库中查,查询逻辑用我们参数中注入的函数
        R r = dbFallback.apply(id);
        //查不到,则将空值写入Redis
        if (r == null) {
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(r);
        //并存入redis,设置TTL
        this.set(key, jsonStr, time, timeUnit);
        //最终把查询到的商户信息返回给前端
        return r;
    }
    
    public Result queryById(Long id) {
        Shop shop = cacheClient.
                queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        if (shop == null) {
            return Result.fail("店铺不存在!!");
        }
        return Result.ok(shop);
    }
    
    {% endtabs %}
    • 方法4:根据指定的Key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
        //1. 从redis中查询商铺缓存
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        //2. 如果未命中,则返回空
        if (StrUtil.isBlank(json)) {
            return null;
        }
        //3. 命中,将json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //4. 判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //5. 未过期,直接返回商铺信息
            return r;
        }
        //6. 过期,尝试获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean flag = tryLock(lockKey);
        //7. 获取到了锁
        if (flag) {
            //8. 开启独立线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R tmp = dbFallback.apply(id);
                    this.setWithLogicExpire(key, tmp, time, timeUnit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(lockKey);
                }
            });
            //9. 直接返回商铺信息
            return r;
        }
        //10. 未获取到锁,直接返回商铺信息
        return r;
    }
    
    • 方法5:根据指定的Key查询缓存,并反序列化为指定类型,需要利用互斥锁解决缓存击穿问题
    public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }
        if (json != null) {
            return null;
        }
        R r = null;
        String lockKey = LOCK_SHOP_KEY + id;
        try {
            //否则去数据库中查
            boolean flag = tryLock(lockKey);
            if (!flag) {
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, timeUnit);
            }
            r = dbFallback.apply(id);
            //查不到,则将空值写入Redis
            if (r == null) {
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //并存入redis,设置TTL
            this.set(key, r, time, timeUnit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            unlock(lockKey);
        }
        return r;
    }
    
  • 完整代码如下
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.RedisData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import static com.hmdp.utils.RedisConstants.*;

@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

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

    public void set(String key, Object value, Long time, TimeUnit timeUnit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
    }

    public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) {
        RedisData<Object> redisData = new RedisData<>();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        //如果不为空(查询到了),则转为R类型直接返回
        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }
        if (json != null) {
            return null;
        }
        //否则去数据库中查,查询逻辑用我们参数中注入的函数
        R r = dbFallback.apply(id);
        //查不到,则将空值写入Redis
        if (r == null) {
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //查到了则转为json字符串
        String jsonStr = JSONUtil.toJsonStr(r);
        //并存入redis,设置TTL
        this.set(key, jsonStr, time, timeUnit);
        //最终把查询到的商户信息返回给前端
        return r;
    }

    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
        //1. 从redis中查询商铺缓存
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        //2. 如果未命中,则返回空
        if (StrUtil.isBlank(json)) {
            return null;
        }
        //3. 命中,将json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //4. 判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //5. 未过期,直接返回商铺信息
            return r;
        }
        //6. 过期,尝试获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean flag = tryLock(lockKey);
        //7. 获取到了锁
        if (flag) {
            //8. 开启独立线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R tmp = dbFallback.apply(id);
                    this.setWithLogicExpire(key, tmp, time, timeUnit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(lockKey);
                }
            });
            //9. 直接返回商铺信息
            return r;
        }
        //10. 未获取到锁,直接返回商铺信息
        return r;
    }

    public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
        //先从Redis中查,这里的常量值是固定的前缀 + 店铺id
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        //如果不为空(查询到了),则转为Shop类型直接返回
        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }
        if (json != null) {
            return null;
        }
        R r = null;
        String lockKey = LOCK_SHOP_KEY + id;
        try {
            //否则去数据库中查
            boolean flag = tryLock(lockKey);
            if (!flag) {
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, timeUnit);
            }
            r = dbFallback.apply(id);
            //查不到,则将空值写入Redis
            if (r == null) {
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //并存入redis,设置TTL
            this.set(key, r, time, timeUnit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            unlock(lockKey);
        }
        return r;
    }

    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

优惠券秒杀

Redis实现全局唯一ID

  • 在各类购物App中,都会遇到商家发放的优惠券
  • 当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题
    1. id规律性太明显
    2. 受单表数据量的限制
  • 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
  • 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
  • 那么这就引出我们的全局ID生成器
    • 全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性
      • 唯一性
      • 高可用
      • 高性能
      • 递增性
      • 安全性
  • 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
  • ID组成部分
    • 符号位:1bit,永远为0
    • 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
    • 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
  • 那我们就根据我们分析的ID生成策略,来编写代码
public static void main(String[] args) {
    //设置一下起始时间,时间戳就是起始时间与当前时间的秒数差
    LocalDateTime tmp = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
    System.out.println(tmp.toEpochSecond(ZoneOffset.UTC));
    //结果为1640995200L
}
  • 完整代码如下
@Component
public class RedisIdWorker {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    //设置起始时间,我这里设定的是2022.01.01 00:00:00
    public static final Long BEGIN_TIMESTAMP = 1640995200L;
    //序列号长度
    public static final Long COUNT_BIT = 32L;

    public long nextId(String keyPrefix){
        //1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long currentSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = currentSecond - BEGIN_TIMESTAMP;
        //2. 生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("inc:"+keyPrefix+":"+date);
        //3. 拼接并返回,简单位运算
        return timeStamp << COUNT_BIT | count;
    }
}

添加优惠券

  • 每个店铺度可以发布优惠券,分为平价券和特价券,平价券可以任意购买,而特价券需要秒杀抢购
  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
Field Type Collation Null Key Default Extra Comment
id bigint unsigned (NULL) NO PRI (NULL) auto_increment 主键
shop_id bigint unsigned (NULL) YES (NULL) 商铺id
title varchar(255) utf8mb4_general_ci NO (NULL) 代金券标题
sub_title varchar(255) utf8mb4_general_ci YES (NULL) 副标题
rules varchar(1024) utf8mb4_general_ci YES (NULL) 使用规则
pay_value bigint unsigned (NULL) NO (NULL) 支付金额,单位是分。例如200代表2元
actual_value bigint (NULL) NO (NULL) 抵扣金额,单位是分。例如200代表2元
type tinyint unsigned (NULL) NO 0 0,普通券;1,秒杀券
status tinyint unsigned (NULL) NO 1 1,上架; 2,下架; 3,过期
create_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
update_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAMP 更新时间
  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
Field Type Collation Null Key Default Extra Comment
voucher_id bigint unsigned (NULL) NO PRI (NULL) 关联的优惠券的id
stock int (NULL) NO (NULL) 库存
create_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
begin_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 生效时间
end_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 失效时间
update_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAMP 更新时间
  • 平价卷由于优惠力度并不是很大,所以是可以任意领取
  • 而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
  • 添加优惠券的代码已经提供好了
    {% tabs 添加优惠券的代码312 %}

新增普通券,也就只是将普通券的信息保存到表中

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


新增秒杀券主要看addSeckillVoucher中的业务逻辑

    /**
     * 新增秒杀券
     * @param voucher 优惠券信息,包含秒杀信息
     * @return 优惠券id
     */
    @PostMapping("seckill")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }

秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联

    @Override
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        // 关联普通券id
        seckillVoucher.setVoucherId(voucher.getId());
        // 设置库存
        seckillVoucher.setStock(voucher.getStock());
        // 设置开始时间
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        // 设置结束时间
        seckillVoucher.setEndTime(voucher.getEndTime());
        // 保存信息到秒杀券表中
        seckillVoucherService.save(seckillVoucher);
    }

{% endtabs %}

  • 由于这里并没有后台管理页面,所以我们只能用POSTMAN模拟发送请求来新增秒杀券,请求路径http://localhost:8081/voucher/seckill,请求方式POST,JSON数据如下
{
    "shopId":1,
    "title":"100元代金券",
    "subTitle":"周一至周五可用",
    "rules":"全场通用\\n无需预约\\n可无限叠加",
    "payValue":8000,
    "actualValue":10000,
    "type":1,
    "stock":100,
    "beginTime":"2022-01-01T00:00:00",
    "endTime":"2022-10-31T23:59:59"
}
  • 效果如下

实现秒杀下单

  • 我们点击限时抢购,然后查看发送的请求
请求网址: http://localhost:8080/api/voucher-order/seckill/13
请求方法: POST
  • 看样子是VoucherOrderController里的方法
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return Result.fail("功能未完成");
    }
}
  • 那我们现在来分析一下怎么抢优惠券
    • 首先提交优惠券id,然后查询优惠券信息
    • 之后判断秒杀时间是否开始
      • 开始了,则判断是否有剩余库存
        • 有库存,那么删减一个库存
          • 然后创建订单
        • 无库存,则返回一个错误信息
      • 没开始,则返回一个错误信息
  • 对应的流程图如下
  • 那现在我们就根据我们刚刚的分析和流程图,来编写对应的代码
    {% tabs 那现在我们就根据我们65132 %}

具体的业务逻辑我们还是放到Service层里写,在Service层创建seckillVoucher方法

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
    
    
    @Autowired
    private ISeckillVoucherService seckillVoucherService;
    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
    
    

        return seckillVoucherService.seckillVoucher(voucherId);
    }
}
public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result seckillVoucher(Long voucherId);
}
    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
        //1. 查询优惠券
        queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
        SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
        //2. 判断秒杀时间是否开始
        if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("秒杀还未开始,请耐心等待");
        }
        //3. 判断秒杀时间是否结束
        if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("秒杀已经结束!");
        }
        //4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
        }
        //5. 扣减库存
        boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id",voucherId)
            .update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //6. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 设置订单id
        long orderId = redisIdWorker.nextId("order");
        //6.2 设置用户id
        Long id = UserHolder.getUser().getId();
        //6.3 设置代金券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(id);
        //7. 将订单数据保存到表中
        save(voucherOrder);
        //8. 返回订单id
        return Result.ok(orderId);
    }

{% endtabs %}

超卖问题

  • 我们之前的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景,URL为 localhost:8081/voucher-order/seckill/13,请求方式为POST
    {% note warning no-icon %}
    注意使用Jmeter进行压测时,需要携带我们登录的token

    {% endnote %}
  • 测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张
  • 那么如何解决这个问题呢?先来看看我们的代码中是怎么写的
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
    if (!success) {
        return Result.fail("库存不足");
    }
  • 假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题
  • 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案
    1. 悲观锁
      • 悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
      • 例如Synchronized、Lock等,都是悲观锁
    2. 乐观锁
      • 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
        • 如果没有修改,则认为自己是安全的,自己才可以更新数据
        • 如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常
  • 悲观锁:悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
  • 乐观锁:乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas
  • 乐观锁的典型代表:就是cas,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值
int var5;
do {
    var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
  • 其中do while是为了操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次

  • 该项目中的具体解决方式
  • 这里并不需要真的来指定一下版本号,完全可以使用stock来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同
@Override
public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
        return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
        return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
+           .eq("stock",seckillVoucher.getStock())
            .update();
    if (!success) {
        return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
}
  • 以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
  • 那么我们继续完善代码,修改我们的逻辑,在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作
@Override
public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
        return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
        return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
-           .eq("stock",seckillVoucher.getStock())
+           .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
}
  • 重启服务器,继续使用Jmeter进行测试,这次就能顺利将优惠券刚好抢空了

一人一单

  • 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
  • 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
    • 如果已存在,则不能下单,返回错误信息
    • 如果不存在,则继续下单,获取优惠券
  • 初步代码
    @Override
    public Result seckillVoucher(Long voucherId) {
        LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
        //1. 查询优惠券
        queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
        SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
        //2. 判断秒杀时间是否开始
        if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("秒杀还未开始,请耐心等待");
        }
        //3. 判断秒杀时间是否结束
        if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("秒杀已经结束!");
        }
        //4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
        }
+       // 一人一单逻辑
+       Long userId = UserHolder.getUser().getId();
+       int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
+       if (count > 0){
+           return Result.fail("你已经抢过优惠券了哦");
+       }
        //5. 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //6. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 设置订单id
        long orderId = redisIdWorker.nextId("order");
        //6.2 设置用户id
        Long id = UserHolder.getUser().getId();
        //6.3 设置代金券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(id);
        //7. 将订单数据保存到表中
        save(voucherOrder);
        //8. 返回订单id
        return Result.ok(orderId);
    }
  • 存在问题:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,那我们这里使用悲观锁来解决这个问题
  • 初步代码,我们把一人一单逻辑之后的代码都提取到一个createVoucherOrder方法中,然后给这个方法加锁
  • 不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
    private Result createVoucherOrder(Long voucherId) {
        // 一人一单逻辑
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
        if (count > 0) {
            return Result.fail("你已经抢过优惠券了哦");
        }
        //5. 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //6. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 设置订单id
        long orderId = redisIdWorker.nextId("order");
        //6.2 设置用户id
        Long id = UserHolder.getUser().getId();
        //6.3 设置代金券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(id);
        //7. 将订单数据保存到表中
        save(voucherOrder);
        //8. 返回订单id
        return Result.ok(orderId);
    }
  • 但是这样加锁,锁的细粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住,现在的情况就是所有用户都公用这一把锁,串行执行,效率很低,我们现在要完成的业务是一人一单,所以这个锁,应该只加在单个用户上,用户标识可以用userId
@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 一人一单逻辑
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
        if (count > 0) {
            return Result.fail("你已经抢过优惠券了哦");
        }
        //5. 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //6. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 设置订单id
        long orderId = redisIdWorker.nextId("order");
        //6.2 设置用户id
        Long id = UserHolder.getUser().getId();
        //6.3 设置代金券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(id);
        //7. 将订单数据保存到表中
        save(voucherOrder);
        //8. 返回订单id
        return Result.ok(orderId);
    }
    //执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
}
  • 由于toString的源码是new String,所以如果我们只用userId.toString()拿到的也不是同一个用户,需要使用intern(),如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。
public static String toString(long i) {
    if (i == Long.MIN_VALUE)
        return "-9223372036854775808";
    int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
    char[] buf = new char[size];
    getChars(i, size, buf);
    return new String(buf, true);
}
  • 但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题
@Override
public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
        return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
        return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        return createVoucherOrder(voucherId);
    }
}
  • 但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService中创建createVoucherOrder方法
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
}
  • 但是该方法会用到一个依赖,我们需要导入一下
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
  • 同时在启动类上加上@EnableAspectJAutoProxy(exposeProxy = true)注解
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}
  • 重启服务器,再次使用Jmeter测试,200个线程并发,但是只能抢到一张优惠券,目的达成

集群环境下的并发问题

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

    1. 我们将服务启动两份,端口分别为8081和8082
    2. 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
  • 具体操作,我们使用POSTMAN发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。

  • 失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥

  • 这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)

分布式锁

基本原理和实现方式对比

  • 分布式锁:满足分布式系统或集群模式下多线程课件并且可以互斥的锁

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

  • 那么分布式锁应该满足一些什么条件呢?

    1. 可见性:多个线程都能看到相同的结果。
      {% note warning no-icon %}
      注意:这里说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
      {% endnote %}
    2. 互斥:互斥是分布式锁的最基本条件,使得程序串行执行
    3. 高可用:程序不已崩溃,时时刻刻都保证较高的可用性
    4. 高性能:由于加锁本身就让性能降低,所以对于分布式锁需要他较高的加锁性能和释放锁性能
    5. 安全性:安全也是程序中必不可少的一环
  • 常见的分布式锁有三种

    1. MySQL:MySQL本身就带有锁机制,但是由于MySQL的性能一般,所以采用分布式锁的情况下,使用MySQL作为分布式锁比较少见
    2. Redis:Redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都是用Redis或者Zookeeper作为分布式锁,利用SETNX这个方法,如果插入Key成功,则表示获得到了锁,如果有人插入成功,那么其他人就回插入失败,无法获取到锁,利用这套逻辑完成互斥,从而实现分布式锁
    3. Zookeeper:Zookeeper也是企业级开发中较好的一种实现分布式锁的方案,但本文是学Redis的,所以这里就不过多阐述了
MySQL Redis Zookeeper
互斥 利用mysql本身的互斥锁机制 利用setnx这样的互斥命令 利用节点的唯一性和有序性实现互斥
高可用
高性能 一般 一般
安全性 断开连接,自动释放锁 利用锁超时时间,到期释放 临时节点,断开连接自动释放

Redis分布式锁的实现核心思路

  • 实现分布式锁时需要实现两个基本方法
    1. 获取锁
      • 互斥:确保只能有一个线程获取锁
      • 非阻塞:尝试一次,成功返回true,失败返回false
      SET lock thread01 NX EX 10
      
    2. 释放锁
      • 手动释放
      • 超时释放:获取锁的时候添加一个超时时间
      DEL lock
      
  • 核心思路
    • 我们利用redis的SETNX方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试

实现分布式锁

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

    /**
     * 释放锁
     */
    void unlock();
}
  • 然后创建一个SimpleRedisLock类实现接口
public class SimpleRedisLock implements ILock {
    //锁的前缀
    private static final String KEY_PREFIX = "lock:";
    //具体业务名称,将前缀和业务名拼接之后当做Key
    private String name;
    //这里不需要@Autowired,因为该对象是我们使用构造函数手动new出来的
    private StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程标识
        long threadId = Thread.currentThread().getId();
        //获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        //自动拆箱可能会出现null,这样写更稳妥
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        //通过DEL来删除锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}
  • 修改业务代码
    @Override
    public Result seckillVoucher(Long voucherId) {
        LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
        //1. 查询优惠券
        queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
        SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
        //2. 判断秒杀时间是否开始
        if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("秒杀还未开始,请耐心等待");
        }
        //3. 判断秒杀时间是否结束
        if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("秒杀已经结束!");
        }
        //4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
        }
        Long userId = UserHolder.getUser().getId();
        // 创建锁对象
        SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        // 获取锁对象
        boolean isLock = redisLock.tryLock(120);
        // 加锁失败,说明当前用户开了多个线程抢优惠券,但是由于key是SETNX的,所以不能创建key,得等key的TTL到期或释放锁(删除key)
        if (!isLock) {
            return Result.fail("不允许抢多张优惠券");
        }
        try {
            // 获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            // 释放锁
            redisLock.unlock();
        }
    }
  • 使用Jmeter进行压力测试,请求头中携带登录用户的token,最终只能抢到一张优惠券

猜你喜欢

转载自blog.csdn.net/qq_33888850/article/details/129770077
今日推荐