1 Star 12 Fork 3

追梦 / 商品秒杀系统的设计与实现

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
该仓库未声明开源许可证文件(LICENSE),使用请关注具体项目描述及其代码上游依赖。
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README

1. 技术点介绍

输入图片说明

2. 秒杀方案

输入图片说明

3. 学习目标

输入图片说明

4. 如何设计

秒杀,对我们来说,都不是一个陌生的东西。每年的双11,618以及时下流行的直播等等。秒杀然而,这对于我们系统而言是一个巨大的考验。 那么,如何才能更好地理解秒杀系统呢?我觉得作为一个程序员,你首先需要从高纬度出发,从整体上思考 问题。在我看来,秒杀其实主要解决两个问题,一个 并发读,一个并发写。并发读的核心优化理念是尽量减少用户到服务来"读"数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况兜底方案,以防止最坏的情况发生。 其实,秒杀的整体架构可以概况为:"稳、准、快"几个关键字。 "稳",就是整个系统架构要满足高可用,流量符合预期肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提。 "准",就是秒杀10台手机,那就是只能交10台,多一台都不行。一旦库存不对,那平台就要承担损失,所以"准"就是要求保证数量的一致性。 "快",就是说系统的性能要足够的高,否则你怎么支撑这么大的流量呢。不光服务端做到极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整一个系统就完美了。

5. 项目搭建

5.1 创建项目

创建springboot项目

输入图片说明

设置项目信息

输入图片说明

选择Lombok、Spring Web、Thymeleaf、MySQL Driver依赖

输入图片说明

写好项目名称和路径

输入图片说明 最后完成项目的创建。

5.2 引入依赖

引入mybatis-plus依赖

<!--	mybatisPlus	-->
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-boot-starter</artifactId>
  <version>3.4.0</version>
</dependency>

5.3 配置文件

配置application.yml文件

spring:
  # thymeleaf配置
  thymeleaf:
    # 关闭缓存
    cache: false
  # 数据源配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: H8888
    # 配置连接池
    hikari:
      # 连接池名称
      pool-name: DataHikariCP
      # 最小空闲连接
      minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
      idle-timeout: 1800000
      # 最大连接数
      maximum-pool-size: 18
      # 从连接池返回的连接自动提交
      auto-commit: true
      # 连接最大存活时间,0表示永久存活,默认1800000(30分钟)
      max-lifetime: 1800000
      # 连接超时间,默认30000(30秒)
      connection-timeout: 30000
      # 测试连接 是否可用的查询语句
      connection-test-query: SELECT 1

# mybatis-plus配置
mybatis-plus:
  # 配置mapper.xml映射文件位置
  mapper-locations: classpath*:/mapper/*Dao.xml
  # 配置mybatis数据返回类型的别名(默认别名是类名)
  type-aliases-package: com.hsb.seckill.entity

# mybatis SQL(方法接口所在的包,不是Mapper.xml所在的包)
logging:
  level:
    com.hsb.seckill.dao: debug

5.4 创建项目包名

在com.hsb.seckill包下分别如下包名:

  • entity:存放实体类
  • dao:存放dao类
  • service:存放service类
  • impl:存放service实现类
  • controller:存放控制类
  • utils:存放工具类
  • 在resource文件下创建mapper文件存放mybatis的映射文件

5.5 封装响应结果

公共返回对象枚举

/**
 * 公共返回对象枚举
 */
@Getter
@ToString
@AllArgsConstructor
public enum ResBeanEnum {
    // 通用
    SUCCESS(200,"SUCCESS"),
    ERROR(500,"服务端异常"),
    // 登录异常
    LOGIN_ERROR(500210,"用户名或密码错误"),
    MOBILE_ERROR(500211,"手机号码格式不正确");
    private final Integer code;
    private final String message;
}

公共返回对象

/**
 * 公共返回对象
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResBean {
    private long code;
    private String message;
    private Object obj;

    /**
     * 成功返回结果
     * @return
     */
    public static ResBean success(){
        return new ResBean(ResBeanEnum.SUCCESS.getCode(),ResBeanEnum.SUCCESS.getMessage(),null);
    }

    /**
     * 成功返回结果
     * @param obj 传入一个对象
     * @return
     */
    public static ResBean success(Object obj){
        return new ResBean(ResBeanEnum.SUCCESS.getCode(),ResBeanEnum.SUCCESS.getMessage(),obj);
    }

    /**
     * 失败返回结果
     * @param resBeanEnum
     * @return
     */
    public static ResBean error(ResBeanEnum resBeanEnum){
        return new ResBean(resBeanEnum.getCode(), resBeanEnum.getMessage(),null);
    }

    /**
     * 失败返回结果
     * @param resBeanEnum
     * @param obj
     * @return
     */
    public static ResBean error(ResBeanEnum resBeanEnum,Object obj){
        return new ResBean(resBeanEnum.getCode(), resBeanEnum.getMessage(),obj);
    }
}

6. 登录功能

6.1 创建user用户表

CREATE TABLE `t_user`  (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `mobile` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '手机号码',
  `nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `password` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt)+salt)',
  `slat` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `head` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '头像',
  `register_date` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
  `last_login_date` datetime NULL DEFAULT NULL COMMENT '最后一次登录时间',
  `login_count` int NULL DEFAULT 0 COMMENT '登录次数',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of t_user
-- ----------------------------
INSERT INTO `t_user` VALUES (1, '18476816500', 'admin', 'b7797cce01b4b131b433b6acf4add449', '1a2b3c4d', NULL, '2021-09-04 15:55:11', NULL, 0);

6.2 逆向工程

通过EasyCode插件自动生成user表的entity、dao、service、impl、controller和mapper映射文件 输入图片说明

6.3 MD5加密

整体加密流程

MD5(MD5(pass明文+固定salt)+随机salt) 第一次固定salt写死在前端 第二次加密采用随机的salt 并将每次生成的salt保存在数据库中

登录流程

前端对用户输入的密码进行md5加密(固定的salt) 将加密后的密码传递到后端 后端使用用户id取出用户信息 后端对加密后的密码在进行md5加密(取出盐),然后与数据库中存储的密码进行对比, ok登录成功,否则登录失败

注册流程

前端对用户输入的密码进行md5加密(固定的salt) 将加密后的密码传递到后端 后端随机生成一个salt, 使用生成salt对前端传过来的密码进行加密,然后将加密后密码和salt一起保存到db中

引入依赖

<dependency>
  <groupId>commons-codec</groupId>
  <artifactId>commons-codec</artifactId>
</dependency>

创建MD5加密工具类MD5Util

/**
 * MD5工具类
 */
@Component
public class MD5Util {

    // md5加密
    public static String md5(String src){
        return DigestUtils.md5Hex(src);
    }

    private static final String salt="1a2b3c4d";

    // 客户端到服务端加密
    public static String inputPassToFromPass(String inputPass){
        String str = "" + salt.charAt(0)+salt.charAt(2)+inputPass+salt.charAt(5)+salt.charAt(4);
        return md5(str);
    }

    // 服务端到数据库加密
    public static String fromPassToDBPass(String fromPass,String salt){
        String str = "" + salt.charAt(0)+salt.charAt(2)+fromPass+salt.charAt(5)+salt.charAt(4);
        return md5(str);
    }

    // 客户端到数据库,两次加密
    public static String inputPassToDBPass(String inputPass,String salt){
        String fromPass = inputPassToFromPass(inputPass);
        String dbPass = fromPassToDBPass(fromPass, salt);
        return dbPass;
    }

    public static void main(String[] args) {
        System.out.println(inputPassToFromPass("123456"));
        System.out.println(fromPassToDBPass("d3b1294a61a07da9b49b6e22b2cbd7f9","1a2b3c4d"));
        System.out.println(inputPassToDBPass("123456","1a2b3c4d"));
    }
}

6.4 登录页面

引入登录页面和静态资源文件

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>登录</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
    <script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- md5.js -->
    <script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
    
</head>
<body>

<form name="loginForm" id="loginForm" method="post"  style="width:50%; margin:0 auto">

	<h2 style="text-align:center; margin-bottom: 20px">用户登录</h2>
	
	<div class="form-group">
       	<div class="row">
	       	<label class="form-label col-md-4">请输入手机号码</label>
	        <div class="col-md-5">
	        	<input id="mobile" name = "mobile" class="form-control" type="text" placeholder="手机号码" required="true"  minlength="11" maxlength="11" />
	    	</div>
	    	<div class="col-md-1">
	    	</div>
    	</div>
    </div>
    
    <div class="form-group">
     		<div class="row">
		        <label class="form-label col-md-4">请输入密码</label>
		        <div class="col-md-5">
		        	<input id="password" name="password" class="form-control" type="password"  placeholder="密码" required="true" minlength="6" maxlength="16" />
	       		</div>
      		</div>
	</div>
	
	<div class="row">
		     	<div class="col-md-5">
	       	 		<button class="btn btn-primary btn-block" type="reset" onclick="reset()">重置</button>
	       	 	</div>
	       	 	<div class="col-md-5">
	       	 		<button class="btn btn-primary btn-block" type="submit" onclick="login()">登录</button>
	       	 	</div>
	 </div>
	 
</form>
</body>
<script>
function login(){
	$("#loginForm").validate({
        submitHandler:function(form){
             doLogin();
        }    
    });
}
function doLogin(){
	g_showLoading();
	
	var inputPass = $("#password").val();
	var salt = g_passsword_salt;
	var str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
	var password = md5(str);
	
	$.ajax({
		url: "/login/doLogin",
	    type: "POST",
	    data:{
	    	mobile:$("#mobile").val(),
	    	password: password
	    },
	    success:function(data){
	    	layer.closeAll();
	    	if(data.code == 200){
	    		layer.msg("成功");
	    		window.location.href="/goods/to_list";
	    	}else{
	    		layer.msg(data.message);
	    	}
	    },
	    error:function(){
	    	layer.closeAll();
	    }
	});
}
</script>
</html>

实现登录页面的跳转

@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {
    /**
     * 跳转登录页面
     * @return
     */
    @RequestMapping("/toLogin")
    public String toLogin(){
        return "login";
    }
}

输入图片说明

6.5 登录功能实现

根据用户手机查询用户

dao

/**
     * 根据手机号码查询用户
     * @return
     */
    User queryByMobile(String mobile);

service

/**
     * 登录
     * @param loginVo
     * @return
     */
    ResBean doLogin(LoginVo loginVo);

impl

/**
     * 登录
     * @param loginVo
     * @return
     */
    @Override
    public ResBean doLogin(LoginVo loginVo) {
        String mobile = loginVo.getMobile();
        String password = loginVo.getPassword();
        //判断手机号码或密码是否为空
        if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(password)){
            return ResBean.error(ResBeanEnum.LOGIN_ERROR);
        }
        //校验手机号码是否合法
        if (!ValidatorUtil.isMobile(mobile)){
            return ResBean.error(ResBeanEnum.MOBILE_ERROR);
        }
        //根据手机号查询用户
        User user = userDao.queryByMobile(mobile);
        //判断用户是否存在
        if (null == user){
            return ResBean.error(ResBeanEnum.LOGIN_ERROR);
        }
        //判断密码是否正确
        if (!MD5Util.fromPassToDBPass(password,user.getSlat()).equals(user.getPassword())){
            return ResBean.error(ResBeanEnum.LOGIN_ERROR);
        }
        return ResBean.success();
    }

controller

/**
     * 登录功能
     * @param loginVo
     * @return
     */
    @ResponseBody
    @RequestMapping("/doLogin")
    public ResBean doLogin(@Valid LoginVo loginVo){
//        log.info("{}",loginVo);
        return userService.doLogin(loginVo);
    }

6.6 自定义注解参数校验

引入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

在参数类属性上添加注解

/**
 * 登录参数
 */
@Data
public class LoginVo {
    @NotNull
    @IsMobile
    private String mobile;

    @NotNull
    @Length(min = 32)
    private String password;
}

校验手机号码校验注解

/**
 * 校验手机号注解
 */
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {IsMobileValidator.class}
)
public @interface IsMobile {

    boolean require() default true;

    String message() default "手机号码格式错误";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

手机号码校验规则类

/**
 * 校验手机号码规则
 */
public class IsMobileValidator implements ConstraintValidator<IsMobile,String> {

    private boolean required = false;

    @Override
    public void initialize(IsMobile constraintAnnotation) {
        required = constraintAnnotation.require();
    }

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        if (required){
            return ValidatorUtil.isMobile(s);
        }else {
            if (StringUtils.isEmpty(s)){
                return true;
            }else {
                return ValidatorUtil.isMobile(s);
            }
        }
    }
}

手机号校验工具类

/**
 * 手机号码校验
 */
public class ValidatorUtil {

    private static final Pattern mobile_pattern = Pattern.compile("[1]([3-9])[0-9]{9}$");

    public static boolean isMobile(String mobile){
        if (StringUtils.isEmpty(mobile)){
            return false;
        }
        Matcher matcher = mobile_pattern.matcher(mobile);
        return matcher.matches();
    }
}

6.7 异常处理

我们知道,系统异常包括:编译时异常和运行时异常RuntimeException,前者通过捕获异常从而获取异常信息,后者主要通过规范代码开发、测试通过手段减少运行时异常的发生。在开发中,不管时dao层、service层还是controller层,都有可能抛出异常,在springmvc中,能将所有类型的异常处理过程解耦出来,既保证了相关处理过程的功能较单一,也实现了异常信息的统一处理和维护。 springboot全局异常处理方式主要有两种:

  • 使用@ControllerAdvice和@ExceptionHandler注解
  • 使用ErrorController类来实现

区别: 1、@ControllerAdvice方式只能处理控制器抛出异常,此时请求已经进行控制器中。 2、ErrorController类方式可以处理所有的异常,包括未进入控制器的异常,比如404、401等错误。 3、如果应用中两者公同存在,则@ControllerAdvice方式处理控制器的异常,ErrorController方式处理为进入控制器的异常。 4、@ControllerAdvice方式可以定义多个拦截方法,拦截不同的异常类,并且可以获取异常信息,自由度更大。

公共返回结果类

/**
 * 公共返回对象
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResBean {
    private long code;
    private String message;
    private Object obj;

    /**
     * 成功返回结果
     * @return
     */
    public static ResBean success(){
        return new ResBean(ResBeanEnum.SUCCESS.getCode(),ResBeanEnum.SUCCESS.getMessage(),null);
    }

    /**
     * 成功返回结果
     * @param obj 传入一个对象
     * @return
     */
    public static ResBean success(Object obj){
        return new ResBean(ResBeanEnum.SUCCESS.getCode(),ResBeanEnum.SUCCESS.getMessage(),obj);
    }

    /**
     * 失败返回结果
     * @param resBeanEnum
     * @return
     */
    public static ResBean error(ResBeanEnum resBeanEnum){
        return new ResBean(resBeanEnum.getCode(), resBeanEnum.getMessage(),null);
    }

    /**
     * 失败返回结果
     * @param resBeanEnum
     * @param obj
     * @return
     */
    public static ResBean error(ResBeanEnum resBeanEnum,Object obj){
        return new ResBean(resBeanEnum.getCode(), resBeanEnum.getMessage(),obj);
    }
}

自定义异常枚举类

/**
 * 公共返回对象枚举
 */
@Getter
@ToString
public enum ResBeanEnum {
    // 通用
    SUCCESS(200,"SUCCESS"),
    ERROR(500,"服务端异常"),
    // 登录异常
    LOGIN_ERROR(500210,"用户名或密码错误"),
    MOBILE_ERROR(500211,"手机号码格式不正确"),
    //绑定异常
    BIND_ERROR(500212,"参数校验异常");

    private final Integer code;
    private final String message;
    
    ResBeanEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}

全局异常类

/**
 * 全局异常
 */
public class GlobalException extends RuntimeException{
    private ResBeanEnum resBeanEnum;

    public GlobalException(ResBeanEnum resBeanEnum){
        this.resBeanEnum = resBeanEnum;
    }

    public ResBeanEnum getResBeanEnum() {
        return resBeanEnum;
    }

    public void setResBeanEnum(ResBeanEnum resBeanEnum) {
        this.resBeanEnum = resBeanEnum;
    }
}

全局异常处理类

/**
 * 全局异常处理类
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResBean ExceptionHandler(Exception e){
        if (e instanceof GlobalException){
            GlobalException ex = (GlobalException) e;
            return ResBean.error(ex.getResBeanEnum());
        }else if (e instanceof BindException){
            BindException ex = (BindException) e;
            ResBean resBean = ResBean.error(ResBeanEnum.BIND_ERROR);
            resBean.setMessage("参数校验异常:"+ex.getBindingResult().getAllErrors().get(0).getDefaultMessage());
            return resBean;
        }
        return ResBean.error(ResBeanEnum.ERROR);
    }
}

登录校验

/**
     * 登录
     * @param loginVo
     * @return
     */
    @Override
    public ResBean doLogin(LoginVo loginVo) {
        String mobile = loginVo.getMobile();
        String password = loginVo.getPassword();
        //根据手机号查询用户
        User user = userDao.queryByMobile(mobile);
        //判断用户是否存在
        if (null == user){
//            return ResBean.error(ResBeanEnum.LOGIN_ERROR);
            throw new GlobalException(ResBeanEnum.LOGIN_ERROR);
        }
        //判断密码是否正确
        if (!MD5Util.fromPassToDBPass(password,user.getSlat()).equals(user.getPassword())){
//            return ResBean.error(ResBeanEnum.LOGIN_ERROR);
            throw new GlobalException(ResBeanEnum.LOGIN_ERROR);
        }
        return ResBean.success();
    }

6.8 设置cookie和session

UUID工具类

/**
 * uuid工具类
 */
public class UUIDUtil {
	// 生成uuid
    public static String uuid(){
        return UUID.randomUUID().toString().replace("-","");
    }
}

cookie工具类

public final class CookieUtil {

    /**
     * 得到Cookie的值, 不编码
     *
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName) {
        return getCookieValue(request, cookieName, false);
    }

    /**
     * 得到Cookie的值,
     *
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    if (isDecoder) {
                        retValue = URLDecoder.decode(cookieList[i].getValue(), "UTF-8");
                    } else {
                        retValue = cookieList[i].getValue();
                    }
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 得到Cookie的值,
     *
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, String encodeString) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    retValue = URLDecoder.decode(cookieList[i].getValue(), encodeString);
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 设置Cookie的值 不设置生效时间默认浏览器关闭即失效,也不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue) {
        setCookie(request, response, cookieName, cookieValue, -1);
    }

    /**
     * 设置Cookie的值 在指定时间内生效,但不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage) {
        setCookie(request, response, cookieName, cookieValue, cookieMaxage, true);
    }

    /**
     * 设置Cookie的值 不设置生效时间,但编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, boolean isEncode) {
        setCookie(request, response, cookieName, cookieValue, -1, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage, boolean isEncode) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数(指定编码)
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage, String encodeString) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, encodeString);
    }

    /**
     * 删除Cookie带cookie域名
     */
    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response,
                                    String cookieName) {
        doSetCookie(request, response, cookieName, "", -1, false);
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     *
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
                                          String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else if (isEncode) {
                cookieValue = URLEncoder.encode(cookieValue, "utf-8");
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0)
                cookie.setMaxAge(cookieMaxage);
            if (null != request) {// 设置域名的cookie
                String domainName = getDomainName(request);
//                System.out.println(domainName);
                if (!"localhost".equals(domainName)) {
                    cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     *
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
                                          String cookieName, String cookieValue, int cookieMaxage, String encodeString) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else {
                cookieValue = URLEncoder.encode(cookieValue, encodeString);
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0)
                cookie.setMaxAge(cookieMaxage);
            if (null != request) {// 设置域名的cookie
                String domainName = getDomainName(request);
                System.out.println(domainName);
                if (!"localhost".equals(domainName)) {
                    cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 得到cookie的域名
     */
    private static final String getDomainName(HttpServletRequest request) {
        String domainName = null;

        String serverName = request.getRequestURL().toString();
        if (serverName == null || serverName.equals("")) {
            domainName = "";
        } else {
            serverName = serverName.toLowerCase();
            serverName = serverName.substring(7);
            final int end = serverName.indexOf("/");
            serverName = serverName.substring(0, end);
            final String[] domains = serverName.split("\\.");
            int len = domains.length;
            if (len > 3) {
                // www.xxx.com.cn
                domainName = "." + domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
            } else if (len <= 3 && len > 1) {
                // xxx.com or xxx.cn
                domainName = "." + domains[len - 2] + "." + domains[len - 1];
            } else {
                domainName = serverName;
            }
        }

        if (domainName != null && domainName.indexOf(":") > 0) {
            String[] ary = domainName.split("\\:");
            domainName = ary[0];
        }
        return domainName;
    }

}

在登录方法上设置cookie和session

//设置session和cookie
String ticket = UUIDUtil.uuid();
request.getSession().setAttribute(ticket,user);
CookieUtil.setCookie(request,response,"userTicket",ticket);

登录成功后,检验seesion和cookie

/**
     * 检验用户,跳转商品页面
     * @param session
     * @param model
     * @param ticket
     * @return
     */
    @RequestMapping("/toList")
    public String toList(HttpSession session, Model model,@CookieValue("userTicket") String ticket){
        if (StringUtils.isEmpty(ticket)){
            return "login";
        }
        User user = (User) session.getAttribute(ticket);
        if (null == user){
            return "login";
        }
        model.addAttribute("user",user);
        return "goodsList";
    }

6.9 SpringSession实现分布式session

首先要安装redis

引入依赖

<!--   spring data redis     -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--    commons-pool2 对象池依赖    -->
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-pool2</artifactId>
</dependency>
<!--    spring session依赖    -->
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>

配置redis

# 配置redis
  redis:
    # 服务器地址
    host: 192.168.159.200
    # 端口
    port: 6379
    # 操作数据库
    database: 0
    # 超时时间
    timeout: 10000ms
    lettuce:
      pool:
        #最大连接数
        max-active: 8
        #最大连接阻塞等待时间,默认-1
        max-wait: 10000ms
        #最大空闲连接,默认8
        max-idle: 200
        #最小空闲连接,默认0
        min-idle: 5

最后启动项目即可实现分布式session

6.10 Redis存储用户信息

去除spring session依赖

将session存储到redis中

//设置session和cookie
String ticket = UUIDUtil.uuid();
//request.getSession().setAttribute(ticket,user);
//将用户信息存入redis中
redisTemplate.opsForValue().set("user:"+ticket,user);
CookieUtil.setCookie(request,response,"userTicket",ticket);

根据cookie获取用户

/**
     * 根据cookie获取用户
     * @param userTicket
     * @return
     */
    @Override
    public User getUserByCookie(String userTicket,HttpServletRequest request,HttpServletResponse response) {
        if (StringUtils.isEmpty(userTicket)){
            return null;
        }
        User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
        if (user != null){
            CookieUtil.setCookie(request,response,"userTicket",userTicket);
        }
        return user;
    }

6.11 登录优化

自定义用户参数

/**
 * 自定义用户参数
 */
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    private UserService userService;

    //返回true才执行下面的方法
    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        Class<?> clazz = methodParameter.getParameterType();
        return clazz== User.class;
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        HttpServletRequest request = nativeWebRequest.getNativeRequest(HttpServletRequest.class);
        HttpServletResponse response = nativeWebRequest.getNativeResponse(HttpServletResponse.class);
        
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        if (StringUtils.isEmpty(ticket)){
            return null;
        }
        return userService.getUserByCookie(ticket,request,response);
    }
}

MVC配置类

/**
 * MVC配置类
 */
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private UserArgumentResolver userArgumentResolver;
    //设置参数解析器
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userArgumentResolver);
    }
}

检验用户

/**
     * 检验用户,跳转商品页面
     * @param model
     * @return
     */
    @RequestMapping("/toList")
    public String toList(Model model,User user){
        model.addAttribute("user",user);
        return "goodsList";
    }

7. 商品功能

7.1 创建数据表

商品表

	create table `t_goods`(
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
	`goods_name` VARCHAR(16) DEFAULT NULL COMMENT '商品名称',
	`goods_title` VARCHAR(64) DEFAULT NULl COMMENT '商品标题',
	`goods_img` VARCHAR(64) DEFAULT NULL COMMENT '商品图片',
	`goods_detail` LONGTEXT COMMENT '商品详情',
	`goods_price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品价格',
	`goods_stock` INT(11) DEFAULT '0' COMMENT '商品库存,-1表示没有限制',
	PRIMARY KEY(`id`)
	)

订单表

	create table `t_order`(
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '订单id',
	`user_id` BIGINT(20) DEFAULT NULL COMMENT '用户id',
	`goods_id` BIGINT(20) DEFAULT NULL COMMENT '商品id',
	`delivery_addr_id` BIGINT(20) DEFAULT NULL COMMENT '收货地址',
	`goods_name` VARCHAR(16) DEFAULT NULL COMMENT '冗余的商品名称',
	`goods_count` INT(11) DEFAULT '0' COMMENT '商品数量',
	`goods_price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品单价',
	`order_channel` TINYINT(4) DEFAULT '0' COMMENT '1pc,2android,3ios',
	`status` TINYINT(4) DEFAULT '0' COMMENT '订单状态,1已支付,2已发货,3已收货,4已退款,5已完成',
	`create_date` datetime DEFAULT NULL COMMENT '订单创建时间',
	`pay_date` datetime DEFAULT NULL COMMENT '支付时间',
	PRIMARY KEY(`id`) 
	)

秒杀商品表

	create table `t_seckill_goods`(
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀商品id',
	`goods_id` BIGINT(20) DEFAULT NULL COMMENT '商品id',
	`seckill_price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '秒杀价',
	`stock_count` INT(10) DEFAULT NULL COMMENT '库存数量',
	`start_date` datetime DEFAULT NULL COMMENT '秒杀开始时间',
	`end_date` datetime DEFAULT NULL COMMENT '秒杀结束时间',
	PRIMARY KEY(`id`)
	)

秒杀订单表

	create table `t_seckill_order`(
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀订单id',
	`user_id` BIGINT(20) DEFAULT NULL COMMENT '用户id',
	`order_id` BIGINT(20) DEFAULT NULL COMMENT '订单id',
	`goods_id` BIGINT(20) DEFAULT NULL COMMENT '商品id',
	PRIMARY KEY(`id`)
	)

7.2 商品列表

商品列表页面

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>商品列表</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
    <script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- md5.js -->
    <script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
    <style type="text/css">
        html,body{
            height:100%;
            width:100%;
        }
        body{
            /*background:url('/img/bg.jpg') no-repeat;*/
            background-size:100% 100%;
        }
        #goodslist td{
            border-top:1px solid #39503f61;
        }
    </style>
</head>
<body>

<div class="panel panel-default" style="height:100%;">
    <div class="panel-heading">秒杀商品列表</div>
    <table class="table" id="goodslist">
        <tr><td>商品名称</td><td>商品图片</td><td>商品原价</td><td>秒杀价</td><td>库存数量</td><td>详情</td></tr>
        <tr  th:each="goods,goodsStat : ${goodsList}">
            <td th:text="${goods.goodsName}"></td>
            <td ><img th:src="@{${goods.goodsImg}}" width="100" height="100" /></td>
            <td th:text="${goods.goodsPrice}"></td>
            <td th:text="${goods.seckillPrice}"></td>
            <td th:text="${goods.goodsStock}"></td>
            <td><a th:href="'/goods/toDetail/'+${goods.id}">详情</a></td>
        </tr>
    </table>
</div>
</body>
</html>

商品返回对象

/**
 * 商品返回对象
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoodsVo extends Goods {
    private BigDecimal seckillPrice;
    private Integer stockCount;
    private Date startDate;
    private Date endDate;
}

GoodsController

/**
     * 检验用户,跳转商品页面
     * @param model
     * @return
     */
    @RequestMapping("/toList")
    public String toList(Model model,User user){
        if (null == user){
            return "login";
        }
        // 查询所有秒杀商品
        List<GoodsVo> goodsList = goodsService.querySeckillGoods();
        model.addAttribute("goodsList",goodsList);
        model.addAttribute("user",user);
        return "goods_list";
    }

7.3 商品详情

商品详情页面

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>商品详情</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
    <script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- md5.js -->
    <script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>

<div class="panel panel-default">
    <div class="panel-heading">秒杀商品详情</div>
    <div class="panel-body">
        <span th:if="${user eq null}"> 您还没有登录,请登陆后再操作<br/></span>
        <span>没有收货地址的提示。。。</span>
    </div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td colspan="3" th:text="${goods.goodsName}"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="3"><img th:src="@{${goods.goodsImg}}" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>秒杀开始时间</td>
            <td th:text="${#dates.format(goods.startDate, 'yyyy-MM-dd HH:mm:ss')}"></td>
            <td id="miaoshaTip">
                <input type="hidden" id="remainSeconds" th:value="${remainSeconds}" />
                <input type="hidden" id="betweenSeconds" th:value="${betweenSeconds}" />
                <span th:if="${miaoshaStatus eq 0}">秒杀倒计时:<span id="countDown" th:text="${remainSeconds}"></span></span>
                <span th:if="${miaoshaStatus eq 1}">秒杀进行中</span>
                <span th:if="${miaoshaStatus eq 2}">秒杀已结束</span>
            </td>
            <td>
                <form id="miaoshaForm" method="post" action="/miaosha/do_miaosha">
                    <button disabled="disabled" class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
                    <input type="hidden" name="goodsId" th:value="${goods.id}" />
                </form>
            </td>
        </tr>
        <tr>
            <td>秒杀结束时间</td>
            <td th:text="${#dates.format(goods.endDate, 'yyyy-MM-dd HH:mm:ss')}"></td>
        </tr>
        <tr>
            <td>商品原价</td>
            <td colspan="3" th:text="${goods.goodsPrice}"></td>
        </tr>
        <tr>
            <td>秒杀价</td>
            <td colspan="3" th:text="${goods.seckillPrice}"></td>
        </tr>
        <tr>
            <td>库存数量</td>
            <td colspan="3" th:text="${goods.stockCount}"></td>
        </tr>
    </table>
</div>
</body>
<script>
    $(function(){
        countDown();
    });

    function countDown(){
        var remainSeconds = $("#remainSeconds").val();
        var betweenSeconds = $("#betweenSeconds").val();
        var timeout;
        if(remainSeconds > 0){//秒杀还没开始,倒计时
            $("#buyButton").attr("disabled", true);
            timeout = setTimeout(function(){
                $("#countDown").text(remainSeconds - 1);
                $("#remainSeconds").val(remainSeconds - 1);
                countDown();
            },1000);
        }else if(remainSeconds == 0 && betweenSeconds>0){//秒杀进行中
            $("#buyButton").attr("disabled", false);
            if(timeout){
                clearTimeout(timeout);
            }
            $("#miaoshaTip").html("秒杀进行中");
            //秒杀结束
            setTimeout(function(){
                $("#buyButton").attr("disabled", true);
                $("#miaoshaTip").html("秒杀已经结束");
            },betweenSeconds*1000);
        }
    }
</script>
</html>

GoodsController

 /**
     * 秒杀商品详情页
     * @param model
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping("/toDetail/{goodsId}")
    public String toDetail(Model model, User user, @PathVariable("goodsId") Integer goodsId){
        if (null == user){
            return "login";
        }

        //根据商品id查询秒杀商品
        GoodsVo goods = goodsService.querySeckillGoodsById(goodsId);
        //秒杀状态
        int miaoshaStatus = 0;
        //秒杀开始倒计时
        int remainSeconds = 0;
        //秒杀结束倒计时
        int betweenSeconds = 0;
        //秒杀开始时间
        Date startDate = goods.getStartDate();
        //秒杀结束时间
        Date endDate = goods.getEndDate();
        //获取当前时间
        Date nowDate = new Date();

        if (nowDate.after(endDate)){//秒杀已结束
            miaoshaStatus = 2;
            remainSeconds=-1;
        }else if (nowDate.before(startDate)){//秒杀倒计时
            remainSeconds = (int) ((startDate.getTime()- nowDate.getTime())/1000);
        }else {//秒杀中
            miaoshaStatus = 1;
        }
        //秒杀结束倒计时
        betweenSeconds = (int) ((endDate.getTime()-startDate.getTime())/1000);

        model.addAttribute("betweenSeconds",betweenSeconds);
        model.addAttribute("remainSeconds",remainSeconds);
        model.addAttribute("miaoshaStatus",miaoshaStatus);
        model.addAttribute("goods",goods);
        model.addAttribute("user",user);
        return "goods_detail";
    }

7.4 秒杀功能

订单详情页面

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>订单详情</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
    <script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- md5.js -->
    <script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒杀订单详情</div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td th:text="${goods.goodsName}" colspan="3"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="2"><img th:src="@{${goods.goodsImg}}" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>订单价格</td>
            <td colspan="2" th:text="${order.goodsPrice}"></td>
        </tr>
        <tr>
            <td>下单时间</td>
            <td th:text="${#dates.format(order.createDate, 'yyyy-MM-dd HH:mm:ss')}" colspan="2"></td>
        </tr>
        <tr>
            <td>订单状态</td>
            <td >
                <span th:if="${order.status eq 0}">未支付</span>
                <span th:if="${order.status eq 1}">待发货</span>
                <span th:if="${order.status eq 2}">已发货</span>
                <span th:if="${order.status eq 3}">已收货</span>
                <span th:if="${order.status eq 4}">已退款</span>
                <span th:if="${order.status eq 5}">已完成</span>
            </td>
            <td>
                <button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
            </td>
        </tr>
        <tr>
            <td>收货人</td>
            <td colspan="2">XXX  18812341234</td>
        </tr>
        <tr>
            <td>收货地址</td>
            <td colspan="2">北京市昌平区回龙观龙博一区</td>
        </tr>
    </table>
</div>

</body>
</html>

SeckillController

@Controller
@RequestMapping("/seckill")
public class SeckillController {

    @Autowired
    private GoodsService goodsService;
    @Autowired
    private SeckillOrderService seckillOrderService;
    @Autowired
    private OrderService orderService;

    @RequestMapping("/doSeckill")
    public String seckill(Model model, User user, long goodsId){
        if (null == user){
            return "login";
        }
        //根据商品id查询商品
        GoodsVo goods = goodsService.querySeckillGoodsById(goodsId);
        //判断库存
        if (goods.getStockCount()<1){
            model.addAttribute("errMsg", ResBeanEnum.EMPTY_STOCK.getMessage());
            return "seckill_fail";
        }
        //判断是否有重复用户抢购
        SeckillOrder seckillOrder = seckillOrderService.getOne(new QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq("goods_id", goodsId));
        if (seckillOrder != null){
            model.addAttribute("errMsg",ResBeanEnum.REPEATE_ERROR.getMessage());
            return "seckill_fail";
        }
        //进行秒杀,创建订单
        Order order = orderService.sekill(user,goods);

        model.addAttribute("user",user);
        model.addAttribute("order",order);
        model.addAttribute("goods",goods);
        return "order_detail";
    }
}

OrderServiceImpl

@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, Order> implements OrderService {
    @Resource
    private OrderDao orderDao;
    @Autowired
    private SeckillGoodsService seckillGoodsService;
    @Autowired
    private SeckillOrderService seckillOrderService;

    /**
     * 秒杀,创建订单
     * @param user
     * @param goods
     * @return
     */
    @Override
    public Order sekill(User user, GoodsVo goods) {
        //根据商品id秒杀商品
        SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goods.getId()));
        //秒杀商品库存减1
        seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
        //更新秒杀商品数据表
        seckillGoodsService.updateById(seckillGoods);

        //创建订单
        Order order = new Order();
        order.setUserId(user.getId());
        order.setGoodsId(goods.getId());
        order.setDeliveryAddrId(0L);
        order.setGoodsName(goods.getGoodsName());
        order.setGoodsCount(1);
        order.setGoodsPrice(seckillGoods.getSeckillPrice());
        order.setOrderChannel(1);
        order.setStatus(0);
        order.setCreateDate(new Date());
        orderDao.insert(order);
        //生产秒杀订单
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setUserId(user.getId());
        seckillOrder.setOrderId(order.getId());
        seckillOrder.setGoodsId(goods.getId());
        seckillOrderService.save(seckillOrder);

        return order;
    }
}

7.5 商品超卖

解决用户重复抢购同一件商品的问题

  • 向秒杀订单表添加user_id和goods_id作为唯一索引

image.png 方案1:

  • 在redis中缓存用户订单信息
 // 将订单缓存到redis中
 redisTemplate.opsForValue().set(("order:"+user.getId()+":"+goods.getId()),seckillOrder,60, TimeUnit.SECONDS);
  • 每次秒杀之前先判断redis中是否存在该商品的订单信息,如果存在返回提示用户不能重复购买此商品
// 从redis获取订单
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goods.getId());
if (seckillOrder != null){
    model.addAttribute("errMsg",ResBeanEnum.REPEATE_ERROR.getMessage());
    return "seckill_fail";
}

方案二:(推荐)

  • 当用户进行秒杀之前,先判断redis是否存在key(seckillCount:user.id:goodsId)的值,如果存在则说明前面已经抢购过该商品了,返回提示信息(商品不能重复抢购),否则设置key值进redis
Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent("seckillCount:" + user.getId() + ":" + goodsId, user.getId());
if (!ifAbsent){
    return ResBean.error(ResBeanEnum.REPEATE_ERROR);
}

解决商品超卖问题

  • 判断库存数量是否大于0,如果大于0就可以进行抢购,否则不能抢购,返回提示商品库存不足
 //判断库存
if (goods.getStockCount()<1){
    model.addAttribute("errMsg", ResBeanEnum.EMPTY_STOCK.getMessage());
    return "seckill_fail";
}
  • 在更新商品的库存,先判断当前的库存数量是否大于0,如果库存大于0,即可更新库存减一,并创建订单,否则更新失败
 // 解决库存超卖
boolean result = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
        .setSql("stock_count = stock_count-1")
        .eq("goods_id", goods.getId())
        .gt("stock_count", 0));

if (!result){
    return null;
}

8. 压力测试

8.1 JMeter的安装

到Apache官网下载JMeter压缩包,解压到文件夹下,打开bin目录,打开jmeter配置文件jmeter.properies

修改语言

输入图片说明

修改编码

输入图片说明

启动jmeter

打开bin目录下的jmeter.bat,即可打开jmeter 输入图片说明

8.2 JMeter的使用

创建线程组

右键点击测试计划->添加->线程(用户)->线程组 输入图片说明 创建1000个线程,0秒开始启动,循环10次 输入图片说明

设置HTTP请求默认值

右键点击线程组->添加->配置元件->HTTP请求默认值 输入图片说明 设置HTTP协议,服务器名称localhost,端口8080 [图片上传失败(image-bfYgnuuldYBwSBOXkgM9)]

设置HTTP请求

右键点击线程组->添加->取样器->HTTP请求 [图片上传失败(image-qUnOl9W7LNwXeb3lKu6K)] 名称为商品列表,GET请求,路径为/goods/toList的HTTP请求 image.png

聚合报告

右键点击线程组->添加->监听器->聚合 image.png image.png

设置CSV数据配置文件

右键点击线程组->添加->配置元件->CSV Data Set Config image.png 选择文件,设置文件编码为UTF-8,变量名称 image.png

HTTP Cookie管理器

右键点击线程组->添加->配置元件->HTTP Cookie 管理器 image.png 添加名称,设置值,域,路径 image.png

8.3 使用工具类生产用户

UserUtil

public class UserUtil {
    private static void createUser(int count) throws SQLException, ClassNotFoundException, IOException {
        ArrayList<User> users = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            User user = new User();
            long num = 13000000000L+i;
            user.setMobile(String.valueOf(num));
            user.setNickname("user"+i);
            user.setSlat("1a2b3c4d");
            user.setPassword(MD5Util.inputPassToDBPass("123456",user.getSlat()));
            user.setLoginCount(1);
            user.setRegisterDate(new Date());
            users.add(user);
        }
        System.out.println("create User");
        //插入数据库
        Connection conn = getConn();
		String sql = "insert into t_user(login_count, nickname, register_date, slat, password, mobile)values(?,?,?,?,?,?)";
		PreparedStatement pstmt = conn.prepareStatement(sql);
		for(int i=0;i<users.size();i++) {
			User user = users.get(i);
			pstmt.setInt(1, user.getLoginCount());
			pstmt.setString(2, user.getNickname());
			pstmt.setTimestamp(3, new Timestamp(user.getRegisterDate().getTime()));
			pstmt.setString(4, user.getSlat());
			pstmt.setString(5, user.getPassword());
			pstmt.setString(6, user.getMobile());
			pstmt.addBatch();
		}
		pstmt.executeBatch();
		pstmt.close();
		conn.close();
		System.out.println("insert to db");

        //登录,生成token
        String urlString = "http://localhost:8080/login/doLogin";
        File file = new File("C:/Users/H/Desktop/config.txt");
        if(file.exists()) {
            file.delete();
        }
        RandomAccessFile raf = new RandomAccessFile(file, "rw");
        file.createNewFile();
        raf.seek(0);
        for(int i=0;i<users.size();i++) {
            User user = users.get(i);
            URL url = new URL(urlString);
            HttpURLConnection co = (HttpURLConnection)url.openConnection();
            co.setRequestMethod("POST");
            co.setDoOutput(true);
            OutputStream out = co.getOutputStream();
            String params = "mobile="+user.getMobile()+"&password="+MD5Util.inputPassToFromPass("123456");
            out.write(params.getBytes());
            out.flush();
            InputStream inputStream = co.getInputStream();
            ByteArrayOutputStream bout = new ByteArrayOutputStream();
            byte buff[] = new byte[1024];
            int len = 0;
            while((len = inputStream.read(buff)) >= 0) {
                bout.write(buff, 0 ,len);
            }
            inputStream.close();
            bout.close();
            String response = new String(bout.toByteArray());
            System.out.println(response);
            ObjectMapper mapper = new ObjectMapper();
            ResBean resBean = mapper.readValue(response, ResBean.class);
            String userTicket = (String) resBean.getObj();
            System.out.println("create token : " + user.getMobile());

            String row = user.getMobile()+","+userTicket;
            raf.seek(raf.length());
            raf.write(row.getBytes());
            raf.write("\r\n".getBytes());
            System.out.println("write to file : " + user.getMobile());
        }
        raf.close();
        System.out.println("over");

    }

    private static Connection getConn() throws ClassNotFoundException, SQLException {
        String driver= "com.mysql.cj.jdbc.Driver";
        String url ="jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";
        String username= "root";
        String password = "H8888";
        Class.forName(driver);
        return DriverManager.getConnection(url,username,password);
    }

    public static void main(String[] args) throws SQLException, IOException, ClassNotFoundException {
        createUser(1000);
    }
}

8.4 压测商品列表接口

设置HTTP请求

image.png

压测结果

image.png

8.5 压测商品秒杀接口

设置HTTP请求

image.png

压测结果

image.png 商品出现 image.png

9. 页面优化

9.1 页面缓存

商品列表页面

@RequestMapping(value = "/toList",produces = "text/html;charset=utf-8")
@ResponseBody
public String toList(Model model, User user, HttpServletRequest request, HttpServletResponse response){
    if (null == user){
        //return "login";
        WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale());
        return thymeleafViewResolver.getTemplateEngine().process("login", context);
    }
    // 商品列表页面缓存,redis获取页面,如果不为空,直接获取页面
    String html = (String) redisTemplate.opsForValue().get("goodsList");
    if (!StringUtils.isEmpty(html)){
        return html;
    }

    //redis实现分布式锁
    String uuid = UUIDUtil.uuid();
    Boolean flag = redisTemplate.opsForValue().setIfAbsent("goods", uuid, 30, TimeUnit.SECONDS);
    if (flag){
        try{
            // 查询所有秒杀商品
            List<GoodsVo> goodsList = goodsService.querySeckillGoods();
            model.addAttribute("goodsList",goodsList);
            model.addAttribute("user",user);

            //如果为空,利用thymeleaf手动渲染页面,存储redis中
            WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
            html = thymeleafViewResolver.getTemplateEngine().process("goods_list", context);
            if (!StringUtils.isEmpty(html)){
                redisTemplate.opsForValue().set("goodsList",html,1, TimeUnit.SECONDS);
            }
        }finally {
            if (uuid.equals(redisTemplate.opsForValue().get("goods"))){
                redisTemplate.delete("goods");
            }
        }
    }

    return html;
}

商品详情页面

@RequestMapping(value = "/toDetail/{goodsId}",produces = "text/html;charset=utf-8")
@ResponseBody
public String toDetail(Model model, User user, @PathVariable("goodsId") Integer goodsId,HttpServletRequest request,HttpServletResponse response){
    if (null == user){
        //return "login";
        WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale());
        return thymeleafViewResolver.getTemplateEngine().process("login", context);
    }

    //详情页面缓存,redis获取页面,如果不为空,直接获取页面
    String html = (String) redisTemplate.opsForValue().get("goodsDetail:"+goodsId);
    if (!StringUtils.isEmpty(html)){
        return html;
    }

    //redis实现分布式锁
    String uuid = UUIDUtil.uuid();
    Boolean flag = redisTemplate.opsForValue().setIfAbsent("goodsDetail", uuid, 30, TimeUnit.SECONDS);
    if (flag){
        try{
            //根据商品id查询秒杀商品
            GoodsVo goods = goodsService.querySeckillGoodsById(goodsId);
            //秒杀状态
            int miaoshaStatus = 0;
            //秒杀开始倒计时
            int remainSeconds = 0;
            //秒杀结束倒计时
            int betweenSeconds = 0;
            //秒杀开始时间
            Date startDate = goods.getStartDate();
            //秒杀结束时间
            Date endDate = goods.getEndDate();
            //获取当前时间
            Date nowDate = new Date();

            if (nowDate.after(endDate)){//秒杀已结束
                miaoshaStatus = 2;
                remainSeconds=-1;
            }else if (nowDate.before(startDate)){//秒杀倒计时
                remainSeconds = (int) ((startDate.getTime()- nowDate.getTime())/1000);
            }else {//秒杀中
                miaoshaStatus = 1;
            }
            //秒杀结束倒计时
            betweenSeconds = (int) ((endDate.getTime()-startDate.getTime())/1000);

            model.addAttribute("betweenSeconds",betweenSeconds);
            model.addAttribute("remainSeconds",remainSeconds);
            model.addAttribute("miaoshaStatus",miaoshaStatus);
            model.addAttribute("goods",goods);
            model.addAttribute("user",user);

            //如果为空,利用thymeleaf手动渲染页面,存储redis中
            WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
            html = thymeleafViewResolver.getTemplateEngine().process("goods_detail",context);
            if (!StringUtils.isEmpty(html)){
                redisTemplate.opsForValue().set("goodsDetail:"+goodsId,html,60,TimeUnit.SECONDS);
            }
        }finally {
            if (uuid.equals(redisTemplate.opsForValue().get("goodsDetail"))){
                redisTemplate.delete("goodsDetail");
            }
        }
    }
    return html;
}

9.2 对象缓存

用户对象缓存

/**
     * 根据cookie获取用户
     * @param userTicket
     * @return
     */
    @Override
    public User getUserByCookie(String userTicket,HttpServletRequest request,HttpServletResponse response) {
        if (StringUtils.isEmpty(userTicket)){
            return null;
        }
        //将用户对象缓存到redis中
        User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
        if (user != null){
            CookieUtil.setCookie(request,response,"userTicket",userTicket);
        }
        return user;
    }

更新用户密码并删除缓存

/**
     * 更新用户密码
     * @param ticket
     * @param password
     * @return
     */
    @Override
    public ResBean updatePassword(String ticket, String password,HttpServletRequest request,HttpServletResponse response) {
        User user = getUserByCookie(ticket, request, response);
        if (user == null){
            throw new GlobalException(ResBeanEnum.MOBILE_NOT_EXIST);
        }
        user.setPassword(MD5Util.inputPassToDBPass(password,user.getSlat()));
        int result = userDao.updateById(user);
        if (1 == result){
            // 删除redis中的user缓存
            redisTemplate.delete("user:"+ticket);
            return ResBean.success();
        }
        return ResBean.error(ResBeanEnum.PASSWORD_UPDATE_FAIL);
    }

9.3 页面静态化

配置

spring:
  # 静态资源处理
  resources:
    # 启动默认静态资源处理,默认开启
    add-mappings: true
    cache:
      cachecontrol:
        # 缓存相应的时间,单位为秒
        max-age: 3600
    chain:
      # 资源链启动缓存。默认启动
      cache: true
      # 启动资源链,默认禁用
      enabled: true
      # 启动压缩资源(gzip,brotli)解析,默认禁用
      compressed: true
      # 启用h5应用缓存,默认禁用
      html-application-cache: true
    # 静态资源路径
    static-locations: classpath:/stataic/

商品详情页面静态化

GoodsDetail

/**
 * 商品详情返回对象
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoodsDetailVo {
    private User user;
    private GoodsVo goodsVo;
    private int remainSeconds;
    private int miaoshaStatus;
    private int betweenSeconds;
}

goods_detail.htm

<!DOCTYPE HTML>
<html>
<head>
    <title>商品详情</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <!-- jquery -->
    <script type="text/javascript" src="/js/jquery.min.js"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" href="/bootstrap/css/bootstrap.min.css" />
    <script type="text/javascript" src="/bootstrap/js/bootstrap.min.js"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" src="/jquery-validation/jquery.validate.min.js"></script>
    <script type="text/javascript" src="/jquery-validation/localization/messages_zh.min.js"></script>
    <!-- layer -->
    <script type="text/javascript" src="/layer/layer.js"></script>
    <!-- md5.js -->
    <script type="text/javascript" src="/js/md5.min.js"></script>
    <!-- common.js -->
    <script type="text/javascript" src="/js/common.js"></script>
</head>
<body>

<div class="panel panel-default">
    <div class="panel-heading">秒杀商品详情</div>
    <div class="panel-body">
        <span id="userTip"> 您还没有登录,请登陆后再操作<br/></span>
        <span>没有收货地址的提示。。。</span>
    </div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td colspan="3" id="goodsName"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="3"><img id="goodsImg" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>秒杀开始时间</td>
            <td id="startTime"></td>
            <td>
                <input type="hidden" id="remainSeconds" />
                <input type="hidden" id="betweenSeconds" />
                <span id="miaoshaTip"></span>
            </td>
            <td>
                <button class="btn btn-primary btn-block" type="button" id="buyButton" onclick="doSeckill()">立即秒杀</button>
                <input type="hidden" name="goodsId" id="goodsId" />
            </td>
        </tr>
        <tr>
            <td>秒杀结束时间</td>
            <td id="endDate"></td>
        </tr>
        <tr>
            <td>商品原价</td>
            <td colspan="3" id="goodsPrice"></td>
        </tr>
        <tr>
            <td>秒杀价</td>
            <td colspan="3" id="seckillPrice"></td>
        </tr>
        <tr>
            <td>库存数量</td>
            <td colspan="3" id="stockCount"></td>
        </tr>
    </table>
</div>
</body>
<script>
    $(function(){
        // countDown();
        getDetail()
    });
  
  	function doSeckill() {
        $.ajax({
            url:'/seckill/doSeckill',
            type:'POST',
            data:{
                goodsId:$("#goodsId").val()
            },
            success:function (data) {
                if (data.code==200){
                    window.location.href="/order_detail.htm?orderId="+data.obj.id;
                }else{
                    layer.msg("客户端请求错误");
                }
            },
            error:function () {
                layer.msg("客户端请求错误");
            }
        })
    }

    function getDetail() {
        var goodsId = g_getQueryString("goodsId");
        console.log(goodsId)
        $.ajax({
            url:'/goods/toDetail/'+goodsId,
            type:'GET',
            success:function (data) {
                if (data.code==200){
                    render(data.obj);
                }else{
                    layer.msg("客户端请求失败")
                }
            },
            error:function (){
                layer.msg("客户端请求失败")
            }
        })
    }

    function render(detail) {
        var user = detail.user;
        var goods = detail.goodsVo;
        var remainSeconds = detail.remainSeconds;
        var miaoshaStatus = detail.miaoshaStatus;
        var betweenSeconds = detail.betweenSeconds;
        if(user){
            $("#userTip").hide();
        }
        $("#goodsName").text(goods.goodsName);
        $("#goodsImg").attr("src",goods.goodsImg);
        $("#startTime").text(new Date(goods.startDate).format("yyyy-MM-dd hh:mm:ss"));
        $("#endDate").text(new Date(goods.endDate).format("yyyy-MM-dd hh:mm:ss"));
        $("#remainSeconds").val(remainSeconds);
        $("#betweenSeconds").val(betweenSeconds);
        $("#goodsId").val(goods.id);
        $("#goodsPrice").text(goods.goodsPrice);
        $("#seckillPrice").text(goods.seckillPrice);
        $("#stockCount").text(goods.stockCount);

        countDown()
    }


    function countDown(){
        var remainSeconds = $("#remainSeconds").val();
        var betweenSeconds = $("#betweenSeconds").val();
        console.log(betweenSeconds)
        var timeout;
        if(remainSeconds > 0){//秒杀还没开始,倒计时
            $("#buyButton").attr("disabled", true);
            $("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"");
            timeout = setTimeout(function(){
                $("#remainSeconds").val(remainSeconds - 1);
                countDown();
            },1000);
        }else if(remainSeconds == 0 && betweenSeconds>0){//秒杀进行中
            $("#buyButton").attr("disabled", false);
            if(timeout){
                clearTimeout(timeout);
            }
            $("#miaoshaTip").html("秒杀进行中");
            //秒杀结束
            setTimeout(function(){
                $("#buyButton").attr("disabled", true);
                $("#miaoshaTip").html("秒杀已经结束");
            },betweenSeconds*1000);
        }else {
            $("#buyButton").attr("disabled", true);
            $("#miaoshaTip").html("秒杀已经结束");
        }
    }
</script>
</html>

controller

 @RequestMapping(value = "/toDetail/{goodsId}",method = RequestMethod.GET)
    @ResponseBody
    public ResBean toDetail(User user, @PathVariable("goodsId") Integer goodsId){
        if (null == user){
            return ResBean.error(ResBeanEnum.USER_NOT_LOGIN);
        }

        //根据商品id查询秒杀商品
        GoodsVo goods = goodsService.querySeckillGoodsById(goodsId);
        //秒杀状态
        int miaoshaStatus = 0;
        //秒杀开始倒计时
        int remainSeconds = 0;
        //秒杀结束倒计时
        int betweenSeconds = 0;
        //秒杀开始时间
        Date startDate = goods.getStartDate();
        //秒杀结束时间
        Date endDate = goods.getEndDate();
        //获取当前时间
        Date nowDate = new Date();

        if (nowDate.after(endDate)){//秒杀已结束
            miaoshaStatus = 2;
            remainSeconds=-1;
        }else if (nowDate.before(startDate)){//秒杀倒计时
            remainSeconds = (int) ((startDate.getTime()- nowDate.getTime())/1000);
        }else {//秒杀中
            miaoshaStatus = 1;
        }
        //秒杀结束倒计时
        betweenSeconds = (int) ((endDate.getTime()-startDate.getTime())/1000);

        GoodsDetailVo goodsDetailVo = new GoodsDetailVo();
        goodsDetailVo.setUser(user);
        goodsDetailVo.setGoodsVo(goods);
        goodsDetailVo.setRemainSeconds(remainSeconds);
        goodsDetailVo.setMiaoshaStatus(miaoshaStatus);
        goodsDetailVo.setBetweenSeconds(betweenSeconds);

        return ResBean.success(goodsDetailVo);
    }

秒杀静态化

controller

    @RequestMapping(value = "/doSeckill",method = RequestMethod.POST)
    @ResponseBody
    public ResBean seckill(Model model, User user, long goodsId){
        if (null == user){
            return ResBean.error(ResBeanEnum.USER_NOT_LOGIN);
        }

        //根据商品id查询商品
        GoodsVo goods = goodsService.querySeckillGoodsById(goodsId);
        //判断库存
        if (goods.getStockCount()<1){
            model.addAttribute("errMsg", ResBeanEnum.EMPTY_STOCK.getMessage());
            return ResBean.error(ResBeanEnum.EMPTY_STOCK);
        }

        // 从redis获取订单
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goods.getId());
        if (seckillOrder != null){
            model.addAttribute("errMsg",ResBeanEnum.REPEATE_ERROR.getMessage());
            return ResBean.error(ResBeanEnum.REPEATE_ERROR);
        }
        //进行秒杀,创建订单
        Order order = orderService.sekill(user,goods);
        return ResBean.success(order);
    }

订单详情页面静态化

订单详情返回对象

/**
 * 订单详情返回对象
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderDetailVo {
    private Order order;
    private GoodsVo goodsVo;
}

order_detail.htm

<!DOCTYPE HTML>
<html>
<head>
    <title>订单详情</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <!-- jquery -->
    <script type="text/javascript" src="/js/jquery.min.js"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" href="/bootstrap/css/bootstrap.min.css" />
    <script type="text/javascript" src="/bootstrap/js/bootstrap.min.js"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" src="/jquery-validation/jquery.validate.min.js"></script>
    <script type="text/javascript" src="/jquery-validation/localization/messages_zh.min.js"></script>
    <!-- layer -->
    <script type="text/javascript" src="/layer/layer.js"></script>
    <!-- md5.js -->
    <script type="text/javascript" src="/js/md5.min.js"></script>
    <!-- common.js -->
    <script type="text/javascript" src="/js/common.js"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒杀订单详情</div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td id="goodsName" colspan="3"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="2"><img id="goodsImg" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>订单价格</td>
            <td colspan="2" id="goodsPrice"></td>
        </tr>
        <tr>
            <td>下单时间</td>
            <td id="createDate" colspan="2"></td>
        </tr>
        <tr>
            <td>订单状态</td>
            <td id="status">
<!--                <span th:if="${order.status eq 0}">未支付</span>-->
<!--                <span th:if="${order.status eq 1}">待发货</span>-->
<!--                <span th:if="${order.status eq 2}">已发货</span>-->
<!--                <span th:if="${order.status eq 3}">已收货</span>-->
<!--                <span th:if="${order.status eq 4}">已退款</span>-->
<!--                <span th:if="${order.status eq 5}">已完成</span>-->
            </td>
            <td>
                <button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
            </td>
        </tr>
        <tr>
            <td>收货人</td>
            <td colspan="2">XXX  18812341234</td>
        </tr>
        <tr>
            <td>收货地址</td>
            <td colspan="2">北京市昌平区回龙观龙博一区</td>
        </tr>
    </table>
</div>
<script>
    $(function () {
        getOrderDetail();
    });

    function getOrderDetail() {
        var orderId = g_getQueryString("orderId");
        console.log(orderId)
        $.ajax({
            url:'/order/detail',
            type:'GET',
            data:{
                orderId:orderId
            },
            success:function (data) {
                if (data.code==200){
                    render(data.obj)
                }else{
                    layer.msg(data.message);
                }
            },
            error:function () {
                layer.msg("客户端请求异常");
            }
        })
    }

    function render(detail) {
        console.log(detail)
        var goods = detail.goodsVo;
        var order = detail.order;

        $("#goodsName").text(goods.goodsName);
        $("#goodsImg").attr("src",goods.goodsImg);
        $("#goodsPrice").text(order.goodsPrice);
        $("#createDate").text(new Date(order.createDate).format("yyyy-MM-dd hh:mm:ss"));
        var status = order.status;
        var statusText=""
        switch (status) {
            case 0:
                statusText="未支付";
                break;
            case 1:
                statusText="代发货";
                break;
            case 2:
                statusText="已发货";
                break;
            case 3:
                statusText="已收货";
                break;
            case 4:
                statusText="已退款";
                break;
            case 5:
                statusText="已完成";
                break;
        }
        $("#status").text(statusText);

    }
</script>
</body>
</html>

orederController

/**
     * 订单详情
     * @return
     */
    @RequestMapping("/detail")
    @ResponseBody
    public ResBean detail(User user,Long orderId){
        if (user == null){
            return ResBean.error(ResBeanEnum.USER_NOT_LOGIN);
        }
        OrderDetailVo detail = orderService.detail(orderId);
        return ResBean.success(detail);
    }

10. RabbitMQ

10.1 rabbitMQ安装

安装包下载

rabbitmq3.8.5 https://github.com/rabbitmq/rabbitmq-server/releases/tag/v3.8.5 relang23 https://github.com/rabbitmq/erlang-rpm/releases/tag/v23.3.4.6

在CenOS安装

  • 安装erlang

yum -y install erlang-23.3.4.6-1.el7.x86_64.rpm 安装成功 输入图片说明

  • 安装rabbitmq

yum -y install rabbitmq-server-3.8.5-1.el7.noarch.rpm

  • 展示所有插件

rabbitmq-plugins list 输入图片说明

  • 安装可视化管理控制台

rabbitmq-plugins enable rabbitmq_management 输入图片说明

  • 启动rabbitmq服务

systemctl start rabbitmq-server.service

  • 查看是否启动成功

systemctl status rabbitmq-server.service

  • 访问rabbitmq

linux服务器地址+端口15672,用户名和密码都为guest 输入图片说明 不允许远程访问 输入图片说明

  • 添加配置文件,设置远程登录访问

进入/etc/rabbitmq/目录,创建rabbitmq.conf文件,写上[{rabbit,[{loopback_users, []}]}].,保存重启rabbitmq服务systemctl restart rabbitmq-server.service 输入图片说明

  • 再次访问登录成功

输入图片说明

10.2 SpringBoot整合rabbitmq

引入依赖

<!--    AMQP依赖    -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

配置rabbitmq

  # rabbitmq配置
  rabbitmq:
    # 服务器
    host: 192.168.159.200
    # 用户名
    username: guest
    # 密码
    password: guest
    # 虚拟主机
    vritual-host: /
    # 端口
    port: 5672
    # 监听
    listener:
      simple:
        # 消费者最小数量
        concurrency: 10
        # 消费者最大数量
        max-concurrency: 10
        # 限制消费者每次只处理一条消息,处理完在继续下一条消息
        prefetch: 1
        # 启动时是否默认启动容器,默认true
        auto-startup: true
        # 被拒绝时重新进行入队列
        default-requeue-rejected: true
    template:
      retry:
        # 发布重试
        enabled: true
        # 重试时间,默认1000ms
        initial-interval: 1000ms
        # 重试最大次数,默认为3
        max-attempts: 3
        # 重试最大间隔时间 默认10000ms
        max-interval: 10000ms
        # 重试的时间乘数,比如2.0,第一次等10秒,第二次等20s,第三次等40
        multiplier: 1

rabbitmq配置类

import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * rabbitmq配置类
 */
@Configuration
public class RabbitMQConfig {

    @Bean
    public Queue queue(){
        //名称、是否持久化
        return new Queue("queue",true);
    }
}

消息发送者

/**
 * 消息发送者
 */
@Service
@Slf4j
public class MQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void send(Object msg){
        log.info("发送消息:"+msg);
        rabbitTemplate.convertAndSend("queue",msg);
    }
}

消息接收消费者

/**
 * 消息消费者
 */
@Service
@Slf4j
public class MQReceiver {

    @RabbitListener(queues = "queue")
    public void receiver(Object msg){
        log.info("接收消息:"+msg);
    }
}

测试

@RestController
@RequestMapping("/mq")
public class RabbitmqController {

    @Autowired
    private MQSender mqSender;

    /**
     * 测试发送rabbitmq消息
     */
    @RequestMapping("/mq")
    @ResponseBody
    public void mq(){
        mqSender.send("hello rabbitmq");
    }
}

10.3 Fanout模式

输入图片说明

rabbitmq配置

/**
 * rabbitmq配置类
 */
@Configuration
public class RabbitMQConfig {

    private static final String QUEUE01 = "fanout01";//队列1
    private static final String QUEUE02 = "fanout02";//队列2
    private static final String EXCHANGE = "fanoutExchange";//交换机

    @Bean
    public Queue queue01(){
        return new Queue(QUEUE01);
    }

    @Bean
    public Queue queue02(){
        return new Queue(QUEUE02);
    }

    //创建交换机
    @Bean
    public FanoutExchange fanoutExchange(){
        return new FanoutExchange(EXCHANGE);
    }

    //交换机绑定队列1
    @Bean
    public Binding binding01(){
        return BindingBuilder.bind(queue01()).to(fanoutExchange());
    }
    //交换机绑定队列2
    @Bean
    public Binding binding02(){
        return BindingBuilder.bind(queue02()).to(fanoutExchange());
    }
}

消息发送者

/**
 * 消息发送者
 */
@Service
@Slf4j
public class MQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void send(Object msg){
        log.info("发送消息:"+msg);
        rabbitTemplate.convertAndSend("fanoutExchange","",msg);
    }
}

消息消费者

/**
 * 消息消费者
 */
@Service
@Slf4j
public class MQReceiver {

    @RabbitListener(queues = "fanout01")
    public void receiver01(Object msg){
        log.info("接收消息:"+msg);
    }

    @RabbitListener(queues = "fanout02")
    public void receiver02(Object msg){
        log.info("接收消息:"+msg);
    }

}

10.4 Direct模式

输入图片说明

rabbitmq配置类

/**
 * rabbitmq配置类(direct模式)
 */
@Configuration
public class RabbitMQDirectConfig {

    private static final String QUEUE01 = "direct01";
    private static final String QUEUE02 = "direct02";
    private static final String EXCHANGE = "directExchange";
    private static final String ROUTINGKEY01 = "queue.red";
    private static final String ROUTINGKEY02 = "queue.green";

    //创建队列1
    @Bean
    public Queue queue01(){
        return new Queue(QUEUE01);
    }
    //创建队列2
    @Bean
    public Queue queue02(){
        return new Queue(QUEUE02);
    }

    //创建交换机
    @Bean
    public DirectExchange directExchange(){
        return new DirectExchange(EXCHANGE);
    }

    //交换机绑定队列1
    @Bean
    public Binding binding01(){
        return BindingBuilder.bind(queue01()).to(directExchange()).with(ROUTINGKEY01);
    }
    //交换机绑定队列2
    @Bean
    public Binding binding02(){
        return BindingBuilder.bind(queue02()).to(directExchange()).with(ROUTINGKEY02);
    }
}

消息发送者

/**
 * 消息发送者
 */
@Service
@Slf4j
public class MQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    //direct模式
    public void sendDirect01(Object msg){
        log.info("发送red消息:"+msg);
        rabbitTemplate.convertAndSend("directExchange","queue.red",msg);
    }
    public void sendDirect02(Object msg){
        log.info("发送green消息:"+msg);
        rabbitTemplate.convertAndSend("directExchange","queue.green",msg);
    }
}

消息接收消费者

/**
 * 消息消费者
 */
@Service
@Slf4j
public class MQReceiver {

    //direct模式
    @RabbitListener(queues = "direct01")
    public void receiverDirect01(Object msg){
        log.info("接收消息:"+msg);
    }

    @RabbitListener(queues = "direct02")
    public void receiverDirect02(Object msg){
        log.info("接收消息:"+msg);
    }
}

10.5 Topic模式

输入图片说明

rabbitmq配置类

/**
 * rabbitmq配置类(topic模式)
 */
@Configuration
public class RabbitMQTopicConfig {

    private static final String QUEUE01 = "topic01";
    private static final String QUEUE02 = "topic02";
    private static final String EXCHANGE = "topicExchange";
    private static final String ROUTINGKEY01 = "#.queue.#";
    private static final String ROUTINGKEY02 = "*.queue.#";

    //创建队列1
    @Bean
    public Queue queue01(){
        return new Queue(QUEUE01);
    }
    //创建队列2
    @Bean
    public Queue queue02(){
        return new Queue(QUEUE02);
    }

    //创建交换机
    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(EXCHANGE);
    }

    //交换机绑定队列1
    @Bean
    public Binding binding01(){
        return BindingBuilder.bind(queue01()).to(topicExchange()).with(ROUTINGKEY01);
    }
    //交换机绑定队列2
    @Bean
    public Binding binding02(){
        return BindingBuilder.bind(queue02()).to(topicExchange()).with(ROUTINGKEY02);
    }
}

消息发送者

/**
 * 消息发送者
 */
@Service
@Slf4j
public class MQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    //topic模式
    public void sendTopic01(Object msg){
        log.info("发送消息:"+msg);
        rabbitTemplate.convertAndSend("topicExchange","queue.red.message",msg);
    }
    public void sendTopic02(Object msg){
        log.info("发送消息:"+msg);
        rabbitTemplate.convertAndSend("topicExchange","message.queue.green.abc",msg);
    }
}

消息接收消费者

/**
 * 消息消费者
 */
@Service
@Slf4j
public class MQReceiver {

    //topIC模式
    @RabbitListener(queues = "topic01")
    public void receiverTopic01(Object msg){
        log.info("接收消息:"+msg);
    }

    @RabbitListener(queues = "topic02")
    public void receiverTopic02(Object msg){
        log.info("接收消息:"+msg);
    }
}

11. 服务优化

11.1 redis预减库存

系统初始化时,将秒杀商品库存加载到redis中

controller类实现InitializingBean接口
/**
     * 系统初始化,把商品库存数量加载到redis中
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> list = goodsService.querySeckillGoods();
        if (CollectionUtils.isEmpty(list)){
            return;
        }

        list.forEach(goodsVo -> {
            emptyStockMap.put(goodsVo.getId(),false);
            redisTemplate.opsForValue().set("seckillGoods:"+goodsVo.getId(),goodsVo.getStockCount());
        });
    }

秒杀时redis的商品库存减1,判断redis中的商品库存是否小于0

//redis中的商品库存减1
        Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
        //判断当前商品库存是否小于0
        if (decrement<0){
            emptyStockMap.put(goodsId,true);
            //使库存为0
            redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
            return ResBean.error(ResBeanEnum.EMPTY_STOCK);
        }

内存标记,减少redis的访问

//1、创建map存放商品是否有库存
private final Map<Long,Boolean> emptyStockMap = new HashMap<>();
//2、初始化
emptyStockMap.put(goodsVo.getId(),false);
//3、判断该商品是否有库存,通过内存标记,减少redis的访问
if (emptyStockMap.get(goodsId)){
    return ResBean.error(ResBeanEnum.EMPTY_STOCK);
}
//4、当商品库存小于0时,设置该商品为true,表示无库存
emptyStockMap.put(goodsId,true);

11.2 RabbitMQ秒杀操作

秒杀消息类

/**
 * 秒杀消息
 */
@Data
@AllArgsConstructor@NoArgsConstructor
public class SeckillMessage {
    private User user;
    private Long goodsId;
}

配置rabbitmq

/**
 * rabbitmq配置类
 */
@Configuration
public class RabbitmqConfig {

    private static final String QUEUE = "seckillQueue";
    private static final String EXCHANGE = "seckillExchange";
    private static final String ROUTINGKEY = "seckill.#";

    //创建队列
    @Bean
    public Queue queue(){
        return new Queue(QUEUE);
    }

    //创建交换机
    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(EXCHANGE);
    }

    //队列绑定交换机
    @Bean
    public Binding binding(){
        return BindingBuilder.bind(queue()).to(topicExchange()).with(ROUTINGKEY);
    }
}

秒杀消息发送者

/**
 * 消息发送者
 */
@Service
@Slf4j
public class MQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    //发送商品秒杀消息
    public void sendSeckillMessage(String msg){
        log.info("发送秒杀商品消息:"+msg);
        rabbitTemplate.convertAndSend("seckillExchange","seckill.doSeckill",msg);
    }
}

秒杀消息接收者

/**
 * 消息消费者
 */
@Service
@Slf4j
public class MQReceiver {

    @Autowired
    private GoodsService goodsService;
    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private OrderService orderService;

    @RabbitListener(queues = "seckillQueue")
    public void receiverSeckillMessage(String msg){
        log.info("接收秒杀消息:"+ msg);

        SeckillMessage seckillMessage = JSON.parseObject(msg, SeckillMessage.class);
        User user = seckillMessage.getUser();
        Long goodsId = seckillMessage.getGoodsId();
        GoodsVo goods = goodsService.querySeckillGoodsById(goodsId);
        System.out.println(goodsId+":"+goods);
        //判断库存
        if (goods.getStockCount()<1){
            return;
        }
        // 从redis获取订单,判断是否有重复用户抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
        if (seckillOrder != null){
            return;
        }
        //进行秒杀,创建订单
        Order order = orderService.sekill(user,goods);
    }
}

秒杀controller

/**
     * 商品秒杀
     */
    @RequestMapping(value = "/doSeckill",method = RequestMethod.POST)
    @ResponseBody
    public ResBean seckill(Model model, User user, long goodsId){
        if (null == user){
            return ResBean.error(ResBeanEnum.USER_NOT_LOGIN);
        }
        //判断该商品是否有库存,通过内存标记,减少redis的访问
        if (emptyStockMap.get(goodsId)){
            return ResBean.error(ResBeanEnum.EMPTY_STOCK);
        }
        //redis中的商品库存减1
        Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
        //判断当前商品库存是否小于0
        if (decrement<0){
            emptyStockMap.put(goodsId,true);
            //使库存为0
            redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
            return ResBean.error(ResBeanEnum.EMPTY_STOCK);
        }

        // 从redis获取订单,判断是否有重复用户抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
        if (seckillOrder != null){
            return ResBean.error(ResBeanEnum.REPEATE_ERROR);
        }

        //rabbitmq发送消息,异步处理下订单
        SeckillMessage seckillMessage = new SeckillMessage(user,goodsId);
        //将Object转为JSONString
        String msg = JSON.toJSONString(seckillMessage);
        mqSender.sendSeckillMessage(msg);
        return ResBean.success(0);
    }

11.3 客户端轮询秒杀结果

前端goods_detail.htm页面处理

function doSeckill() {
        $.ajax({
            url:'/seckill/doSeckill',
            type:'POST',
            data:{
                goodsId:$("#goodsId").val()
            },
            success:function (data) {
                if (data.code==200){
                    // window.location.href="/order_detail.htm?orderId="+data.obj.id;
                    getResult($("#goodsId").val());
                }else{
                    layer.msg(data.message);
                }
            },
            error:function () {
                layer.msg("客户端请求错误");
            }
        })
    }

    function getResult(goodsId) {
        g_showLoading();
        $.ajax({
            url:'/seckill/result',
            type:'GET',
            data:{
                goodsId:goodsId
            },
            success:function (data) {
                if (data.code==200){
                    var result = data.obj;
                    if (result<0){
                        layer.msg("秒杀失败!");
                    }else if (result==0){
                        //轮询判断是否秒杀成功
                        setTimeout(function () {
                            getResult(goodsId);
                        },50);
                    }else{
                        layer.confirm("秒杀成功!是否查看订单?",{btn:["确定","取消"]},
                        function () {
                            window.location.href="/order_detail.htm?orderId="+result;
                        },
                        function () {
                            layer.close();
                        })
                    }
                }
            },
            error:function () {
                layer.msg("客户端请求异常")
            }
        })
    }

获取秒杀结果

/**
     * 获取秒杀结果
     * orderId:成功,-1:失败,0:排队中
     * @param user
     * @param goodsId
     * @return
     */
    @Override
    public Long getResult(User user, Long goodsId) {
        // 查询订单
        SeckillOrder seckillOrder = seckillOrderDao.selectOne(new QueryWrapper<SeckillOrder>()
                .eq("user_id", user.getId()).eq("goods_id", goodsId));
		
        if (null != seckillOrder){
            return seckillOrder.getOrderId();
        }else if (redisTemplate.hasKey("isStockEmpty:"+goodsId)){//判断是否存在isStockEmpty
            return -1L;
        }else {
            return 0L;
        }
    }

返回秒杀结果

/**
     * 获取秒杀结果
     * orderId:成功,-1:失败,0:排队中
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping(value = "/result",method = RequestMethod.GET)
    @ResponseBody
    public ResBean getResult(User user,Long goodsId){
        if (user == null){
            return ResBean.error(ResBeanEnum.USER_NOT_LOGIN);
        }

        Long orderId = seckillOrderService.getResult(user,goodsId);
        return ResBean.success(orderId);
    }

11.4 redis实现分布式锁

test

@SpringBootTest
class SeckillSystemApplicationTests {

    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private DefaultRedisScript<Boolean> redisScript;

    @Test
    void contextLoads() {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        String value = UUIDUtil.uuid();
        //占位,如果key不存在才可以设置成功
        //给锁设置一个超时时间,防止应用运行过程中抛出异常导致无法释放锁
        Boolean isLock = valueOperations.setIfAbsent("k1", value, 10, TimeUnit.SECONDS);
        //如果占位成功进行正常操作
        if (isLock){
            try {
                valueOperations.set("name","HSB");
                String name = (String)valueOperations.get("name");
                System.out.println(name);
            } finally {
                //操作结束删除锁,执行lua脚本删除锁,保证原子性
                //比较当前的value是否为之前设定的value,如果是则进行删除
                Boolean result = (Boolean) redisTemplate.execute(redisScript, Collections.singletonList("k1"), value);
                System.out.println(result);
            }
        }else {
            System.out.println("有线程正在执行中,请稍后再试");
        }
    }
}

Lua脚本

if redis.call("get",KEYS[1])==ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

redis配置类,配置脚本

@Bean
public DefaultRedisScript<Boolean> defaultRedisScript(){
    DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
    //lock.lua脚本的位置和application.yml同级目录
    redisScript.setLocation(new ClassPathResource("lock.lua"));
    //设置返回结果类型
    redisScript.setResultType(Boolean.class);
    return redisScript;
}

11.5 优化redis减库存

Lua脚本

// 判断redis是否存在商品的key值,如果存在取出该商品的库存
// 如果库存大于0,则进行减库存操作,返回预减后的库存
if (redis.call("exist",KEYS[1])==1) then
    local stock = tonumber(redis.call("get",KEYS[1]);
    if(stock>0) then
        redis.call("incryby",KEYS[1],-1);
        return stock;
    end;
        return 0;
end;

redis配置类,配置脚本

 @Bean
    public DefaultRedisScript<Long> defaultRedisScript(){
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        //lock.lua脚本的位置和application.yml同级目录
        redisScript.setLocation(new ClassPathResource("stock.lua"));
        //设置返回结果类型
        redisScript.setResultType(Long.class);
        return redisScript;
    }

执行脚本

 Long stock = (Long) redisTemplate.execute(redisScript, Collections.singletonList("seckillGoods:" + goodsId), Collections.EMPTY_LIST);

12. 接口优化

12.1 秒杀接口地址隐藏

前端商品详情页面

修改秒杀方法,先获取秒杀路径在进行秒杀

<button class="btn btn-primary btn-block" type="button" id="buyButton" onclick="getSeckillPath()">立即秒杀</button>

前端获取秒杀路径方法

function getSeckillPath() {
        var goodsId = $("#goodsId").val();
        $.ajax({
            url:'/seckill/getPath',
            type:'GET',
            data:{
                goodsId:goodsId
            },
            success:function (data) {
                if (data.code == 200){
                    var path = data.obj;
                    doSeckill(path);
                }else {
                    layer.msg(data.message)
                }
            }
        })
    }

前端秒杀方法

 function doSeckill(path) {
        $.ajax({
            url:'/seckill/'+path+'/doSeckill',
            type:'POST',
            data:{
                goodsId:$("#goodsId").val()
            },
            success:function (data) {
                if (data.code==200){
                    // window.location.href="/order_detail.htm?orderId="+data.obj.id;
                    getResult($("#goodsId").val());
                }else{
                    layer.msg(data.message);
                }
            },
            error:function () {
                layer.msg("客户端请求错误");
            }
        })
    }

controller

/**
     * 获取秒杀地址
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping(value = "/getPath",method = RequestMethod.GET)
    @ResponseBody
    public ResBean getPath(User user,Long goodsId){
        if (user == null){
            return ResBean.error(ResBeanEnum.USER_NOT_LOGIN);
        }
        String str = orderService.getPath(user,goodsId);
        return ResBean.success(str);
    }

service

/**
     * 获取秒杀地址
     * @param user
     * @param goodsId
     * @return
     */
    @Override
    public String getPath(User user, Long goodsId) {
        // 设置随机生成路径
        String s = MD5Util.md5(UUIDUtil.uuid() + "12345");
        // 存储到redis
        redisTemplate.opsForValue().set("seckillPath:"+user.getId()+":"+goodsId,s,60,TimeUnit.SECONDS );
        return s;
    }

后端校验秒杀路径

// 校验秒杀路径(controller)
Boolean check = orderService.checkPath(user,goodsId,path);
if (!check){
    return ResBean.error(ResBeanEnum.REQUEST_ILLEGAL);
}
--------------------------------------------------------
/**
     * 校验秒杀路径是否合法
     * @param user
     * @param goodsId
     * @param path
     * @return
     */
    @Override
    public Boolean checkPath(User user, long goodsId, String path) {
        if (user == null || goodsId <0 || StringUtils.isEmpty(path)){
            return false;
        }
        String str = (String) redisTemplate.opsForValue().get("seckillPath:"+user.getId()+":"+goodsId);
        return path.equals(str);
    }

12.2 实现验证码

生成验证码

引入依赖https://gitee.com/lian_jianfeng/EasyCaptcha?_from=gitee_search

<!--验证码-->
<dependency>
  <groupId>com.github.whvcse</groupId>
  <artifactId>easy-captcha</artifactId>
  <version>1.6.2</version>
</dependency>

后端生成验证码

/**
     * 生成验证码
     * @param user
     * @param goodsId
     * @param request
     * @param response
     * @throws IOException
     */
    @RequestMapping(value = "/captcha",method = RequestMethod.GET)
    public void captcha(User user,Long goodsId,HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 设置请求头为输出图片类型
        response.setContentType("image/jpg");
        response.setHeader("Pragma", "No-cache");
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expires", 0);

        // 算术类型
        ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32);
        captcha.setLen(3);  // 几位数运算,默认是两位
        captcha.getArithmeticString();  // 获取运算的公式:3+2=?
        captcha.text();  // 获取运算的结果:5

        redisTemplate.opsForValue().set("captcha:"+user.getId()+":"+goodsId,captcha.text());
        // 输出图片流
        captcha.out(response.getOutputStream());
    }

前端展示

<div class="row">
  <div class="form-inline">
    <img id="captchaImg" width="130" height="32" onclick="refreshCaptcha()" style="display: none;">
    <input id="captcha" class="form-control" style="display: none;width: 100px">
    <button class="btn btn-primary" type="button" id="buyButton" style="width: 200px" onclick="getSeckillPath()">立即秒杀</button>
    <input type="hidden" name="goodsId" id="goodsId" />
  </div>
</div>

JS渲染

// 刷新验证码
    function refreshCaptcha() {
        $("#captchaImg").attr("src","/seckill/captcha?goodsId="+$("#goodsId").val()+"&time="+new Date())
    }

后台校验验证码

/**
     * 校验验证码
     * @param user
     * @param goodsId
     * @param captcha
     * @return
     */
    @Override
    public Boolean checkCaptcha(User user, Long goodsId, String captcha) {

        if (user == null || goodsId < 0 || StringUtils.isEmpty(captcha)){
            return false;
        }
        //从redis中获取验证码
        String s = (String) redisTemplate.opsForValue().get("captcha:"+user.getId()+":"+goodsId);

        return captcha.equals(s);
    }

------------------------
// 校验验证码
Boolean check = orderService.checkCaptcha(user,goodsId,captcha);
if (!check){
  return ResBean.error(ResBeanEnum.CAPTCHA_ERROR);
}

12.3 接口限流

计数器限流

// 限制访问次数,5秒内访问5次
//获取URL
String uri = request.getRequestURI();
Integer count = (Integer) redisTemplate.opsForValue().get(uri+":"+user.getId());
if (count == null){
  redisTemplate.opsForValue().set(uri+":"+user.getId(),1,5, TimeUnit.SECONDS);
}else if (count<5){
  redisTemplate.opsForValue().increment(uri+":"+user.getId());
}else {
  return ResBean.error(ResBeanEnum.ACCESS_LIMIT_REAHCED);
}

利用注解实现限流

@AccessLimit注解

/**
 * 接口限流注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
    //时间间隔
    int second();
    // 最大次数
    int maxCount();
    //是否需要登录
    boolean needLogin() default true;
}

将user设置到线程ThreadLocal中

/**
 *  将user设置到线程ThreadLocal中
 */
public class UserContent {

    private static ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
    
    //设置user到当前线程中
    public static void setUser(User user){
        userThreadLocal.set(user);
    }

    // 获取当前线程的user
    public static User getUser(){
        return userThreadLocal.get();
    }
}

限流拦截器

/**
 * 限流拦截器
 */
@Component
public class AccessLimitInterceptor implements HandlerInterceptor {

    @Autowired
    private UserService userService;
    @Autowired
    private RedisTemplate redisTemplate;

    // 进入方法前处理,返回true放行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判断handler是否是要处理的方法
        if (handler instanceof HandlerMethod) {
            // 获取当前用户
            User user = getUser(request, response);
            //将user存到当前的线程中
            UserContent.setUser(user);
            // 处理的方法
            HandlerMethod method = (HandlerMethod) handler;
            // 获取方法上的AccessLimit注解
            AccessLimit accessLimit = method.getMethodAnnotation(AccessLimit.class);
            if (accessLimit == null) {
                return true;
            }
            //获取注解的属性值
            int second = accessLimit.second();
            int maxCount = accessLimit.maxCount();
            boolean needLogin = accessLimit.needLogin();
            //获取uri
            String uri = request.getRequestURI();
            if (needLogin) {
                if (user == null){
                    render(response,ResBeanEnum.USER_NOT_LOGIN);
                    return false;
                }
            }

            // 限制访问次数,5秒内访问5次
            Integer count = (Integer) redisTemplate.opsForValue().get(uri+":"+user.getId());
            if (count == null){
                // 在redis设置当前用户的访问次数
                redisTemplate.opsForValue().set(uri+":"+user.getId(),1,second, TimeUnit.SECONDS);
            }else if (count<maxCount){
                // 当每访问一个就进行加一
                redisTemplate.opsForValue().increment(uri+":"+user.getId());
            }else {
                // 当在规定的时间超过最大访问数则抛出异常
                render(response,ResBeanEnum.ACCESS_LIMIT_REAHCED);
                return false;
            }
        }
        return true;
    }

    /**
     * 构建返回对象
     * @param response
     * @param resBeanEnum
     */
    private void render(HttpServletResponse response,ResBeanEnum resBeanEnum) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        PrintWriter out = response.getWriter();
        ResBean resBean = ResBean.error(resBeanEnum);
        out.write(new ObjectMapper().writeValueAsString(resBean));
        out.flush();
        out.close();
    }

    /**
     * 获取当前登录用户
     * @param request
     * @param response
     * @return
     */
    private User getUser (HttpServletRequest request, HttpServletResponse response){
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        if (StringUtils.isEmpty(ticket)) {
            return null;
        }
        return userService.getUserByCookie(ticket, request, response);
    }
}

在MVC配置类注册拦截器

@Autowired
private AccessLimitInterceptor accessLimitInterceptor;

//添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(accessLimitInterceptor);
}

13. 总结

一、项目框架搭建
    1.SPringBoot环境搭建
    2.集成THymeleaf
    3.Mybatis-plus
二、分布式会话
    1.用户登录
      a.设计数据库
      b.返回对象枚举类及其封装
      c.明文密码二次MD5加密
      d.参数校验
      e.全局异常处理
    2.共享session
      a.SpringSession
      b.Redis
三、功能开发
    1.商品列表
    2.商品详情
    3.秒杀
    4.订单详情
四、系统压测
    1.JMeter使用
    2.自定义变量模拟多用户
    3.JMeter命令的使用
    4.正式压测
      a.商品列表
      b.秒杀
五、页面优化
    1.页面缓存
    2.URL缓存
    3.对象缓存
    4.静态资源优化
    5.CDN优化
六、接口优化
    1.Redis预减库存减少数据库的访问
    2.内存标记减少Redis的访问
    3.Redis分布式锁
    4.Redis判断是否重复抢购商品
    5.RabbitMQ异步下单
      a.SpringBoot整合RabbitMQ
      b.topic模式
七、安全优化
    1.秒杀接口地址隐藏
    2.算数验证码
    3.接口防刷
      a.计数器
      b.使用自定义注解对某个方法接口进行限流

空文件

简介

基于SpringBoot、MyBatis-Plus、Redis、RabbitMQ实现的商品秒杀系统,解决商品超卖问题,做了相应的优化,能抵抗一定的并发量,实现商品的秒杀功能。 展开 收起
取消

发行版

暂无发行版

贡献者

全部

近期动态

加载更多
不能加载更多了
1
https://gitee.com/H-study/seckill.git
git@gitee.com:H-study/seckill.git
H-study
seckill
商品秒杀系统的设计与实现
master

搜索帮助