# 商品秒杀系统的设计与实现 **Repository Path**: H-study/seckill ## Basic Information - **Project Name**: 商品秒杀系统的设计与实现 - **Description**: 基于SpringBoot、MyBatis-Plus、Redis、RabbitMQ实现的商品秒杀系统,解决商品超卖问题,做了相应的优化,能抵抗一定的并发量,实现商品的秒杀功能。 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 13 - **Forks**: 3 - **Created**: 2021-09-13 - **Last Updated**: 2025-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## 1. 技术点介绍 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/104755_f67ad73e_7380070.png "屏幕截图.png") ## 2. 秒杀方案 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/104742_52ac40a6_7380070.png "屏幕截图.png") ## 3. 学习目标 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/104728_0245cabb_7380070.png "屏幕截图.png") ## 4. 如何设计 秒杀,对我们来说,都不是一个陌生的东西。每年的双11,618以及时下流行的直播等等。秒杀然而,这对于我们系统而言是一个巨大的考验。 那么,如何才能更好地理解秒杀系统呢?我觉得作为一个程序员,你首先需要从高纬度出发,从整体上思考 问题。在我看来,秒杀其实主要解决两个问题,一个 并发读,一个并发写。并发读的核心优化理念是尽量减少用户到服务来"读"数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况兜底方案,以防止最坏的情况发生。 其实,秒杀的整体架构可以概况为:"稳、准、快"几个关键字。 "稳",就是整个系统架构要满足高可用,流量符合预期肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提。 "准",就是秒杀10台手机,那就是只能交10台,多一台都不行。一旦库存不对,那平台就要承担损失,所以"准"就是要求保证数量的一致性。 "快",就是说系统的性能要足够的高,否则你怎么支撑这么大的流量呢。不光服务端做到极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整一个系统就完美了。 ## 5. 项目搭建 ### 5.1 创建项目 > 创建springboot项目 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/104841_1b5153b5_7380070.png "屏幕截图.png") > 设置项目信息 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/104857_6f6de0a9_7380070.png "屏幕截图.png") > 选择Lombok、Spring Web、Thymeleaf、MySQL Driver依赖 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/104913_90d9428c_7380070.png "屏幕截图.png") > 写好项目名称和路径 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/104929_def3c059_7380070.png "屏幕截图.png") 最后完成项目的创建。 ### 5.2 引入依赖 > 引入mybatis-plus依赖 ```xml com.baomidou mybatis-plus-boot-starter 3.4.0 ``` ### 5.3 配置文件 > 配置application.yml文件 ```yaml 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 封装响应结果 > 公共返回对象枚举 ```java /** * 公共返回对象枚举 */ @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; } ``` > 公共返回对象 ```java /** * 公共返回对象 */ @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用户表 ```sql 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映射文件 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/105000_7e0193ae_7380070.png "屏幕截图.png") ### 6.3 MD5加密 > 整体加密流程 MD5(MD5(pass明文+固定salt)+随机salt) 第一次固定salt写死在前端 第二次加密采用随机的salt 并将每次生成的salt保存在数据库中 > 登录流程 前端对用户输入的密码进行md5加密(固定的salt) 将加密后的密码传递到后端 后端使用用户id取出用户信息 后端对加密后的密码在进行md5加密(取出盐),然后与数据库中存储的密码进行对比, ok登录成功,否则登录失败 > 注册流程 前端对用户输入的密码进行md5加密(固定的salt) 将加密后的密码传递到后端 后端随机生成一个salt, 使用生成salt对前端传过来的密码进行加密,然后将加密后密码和salt一起保存到db中 > 引入依赖 ```xml commons-codec commons-codec ``` > 创建MD5加密工具类MD5Util ```java /** * 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 登录页面 > 引入登录页面和静态资源文件 ```html 登录

用户登录

``` > 实现登录页面的跳转 ```java @Controller @RequestMapping("/login") @Slf4j public class LoginController { /** * 跳转登录页面 * @return */ @RequestMapping("/toLogin") public String toLogin(){ return "login"; } } ``` ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/105028_a9e04af5_7380070.png "屏幕截图.png") ### 6.5 登录功能实现 根据用户手机查询用户 > dao ```java /** * 根据手机号码查询用户 * @return */ User queryByMobile(String mobile); ``` > service ```java /** * 登录 * @param loginVo * @return */ ResBean doLogin(LoginVo loginVo); ``` > impl ```java /** * 登录 * @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 ```java /** * 登录功能 * @param loginVo * @return */ @ResponseBody @RequestMapping("/doLogin") public ResBean doLogin(@Valid LoginVo loginVo){ // log.info("{}",loginVo); return userService.doLogin(loginVo); } ``` ### 6.6 自定义注解参数校验 > 引入依赖 ```xml org.springframework.boot spring-boot-starter-validation ``` > 在参数类属性上添加注解 ```java /** * 登录参数 */ @Data public class LoginVo { @NotNull @IsMobile private String mobile; @NotNull @Length(min = 32) private String password; } ``` > 校验手机号码校验注解 ```java /** * 校验手机号注解 */ @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[] payload() default {}; } ``` > 手机号码校验规则类 ```java /** * 校验手机号码规则 */ public class IsMobileValidator implements ConstraintValidator { 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); } } } } ``` > 手机号校验工具类 ```java /** * 手机号码校验 */ 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方式可以定义多个拦截方法,拦截不同的异常类,并且可以获取异常信息,自由度更大。 > 公共返回结果类 ```java /** * 公共返回对象 */ @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); } } ``` > 自定义异常枚举类 ```java /** * 公共返回对象枚举 */ @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; } } ``` > 全局异常类 ```java /** * 全局异常 */ 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; } } ``` > 全局异常处理类 ```java /** * 全局异常处理类 */ @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); } } ``` > 登录校验 ```java /** * 登录 * @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工具类 ```java /** * uuid工具类 */ public class UUIDUtil { // 生成uuid public static String uuid(){ return UUID.randomUUID().toString().replace("-",""); } } ``` > cookie工具类 ```java 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 ```java //设置session和cookie String ticket = UUIDUtil.uuid(); request.getSession().setAttribute(ticket,user); CookieUtil.setCookie(request,response,"userTicket",ticket); ``` > 登录成功后,检验seesion和cookie ```java /** * 检验用户,跳转商品页面 * @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 > 引入依赖 ```xml org.springframework.boot spring-boot-starter-data-redis org.apache.commons commons-pool2 org.springframework.session spring-session-data-redis ``` > 配置redis ```yaml # 配置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中 ```java //设置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获取用户 ```java /** * 根据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 登录优化 > 自定义用户参数 ```java /** * 自定义用户参数 */ @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配置类 ```java /** * MVC配置类 */ @Configuration @EnableWebMvc public class WebConfig implements WebMvcConfigurer { @Autowired private UserArgumentResolver userArgumentResolver; //设置参数解析器 @Override public void addArgumentResolvers(List resolvers) { resolvers.add(userArgumentResolver); } } ``` > 检验用户 ```java /** * 检验用户,跳转商品页面 * @param model * @return */ @RequestMapping("/toList") public String toList(Model model,User user){ model.addAttribute("user",user); return "goodsList"; } ``` ## 7. 商品功能 ### 7.1 创建数据表 > 商品表 ```sql 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`) ) ``` > 订单表 ```sql 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`) ) ``` > 秒杀商品表 ```sql 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`) ) ``` > 秒杀订单表 ```sql 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 商品列表 > 商品列表页面 ```html 商品列表
秒杀商品列表
商品名称商品图片商品原价秒杀价库存数量详情
详情
``` > 商品返回对象 ```java /** * 商品返回对象 */ @Data @AllArgsConstructor @NoArgsConstructor public class GoodsVo extends Goods { private BigDecimal seckillPrice; private Integer stockCount; private Date startDate; private Date endDate; } ``` > GoodsController ```java /** * 检验用户,跳转商品页面 * @param model * @return */ @RequestMapping("/toList") public String toList(Model model,User user){ if (null == user){ return "login"; } // 查询所有秒杀商品 List goodsList = goodsService.querySeckillGoods(); model.addAttribute("goodsList",goodsList); model.addAttribute("user",user); return "goods_list"; } ``` ### 7.3 商品详情 > 商品详情页面 ```html 商品详情
秒杀商品详情
您还没有登录,请登陆后再操作
没有收货地址的提示。。。
商品名称
商品图片
秒杀开始时间 秒杀倒计时: 秒杀进行中 秒杀已结束
秒杀结束时间
商品原价
秒杀价
库存数量
``` > GoodsController ```java /** * 秒杀商品详情页 * @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 秒杀功能 > 订单详情页面 ```html 订单详情
秒杀订单详情
商品名称
商品图片
订单价格
下单时间
订单状态 未支付 待发货 已发货 已收货 已退款 已完成
收货人 XXX 18812341234
收货地址 北京市昌平区回龙观龙博一区
``` > SeckillController ```java @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().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 ```java @Service("orderService") public class OrderServiceImpl extends ServiceImpl 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().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](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631109855148-c0becc60-2ec3-4e77-a601-43b4028ce270.png#clientId=u3e4336ce-f219-4&from=paste&height=36&id=ub553f24a&margin=%5Bobject%20Object%5D&name=image.png&originHeight=72&originWidth=1255&originalType=binary&ratio=1&size=10342&status=done&style=none&taskId=u621112a6-121c-41e5-986b-0b47d7f600a&width=627.5) 方案1: - 在redis中缓存用户订单信息 ```java // 将订单缓存到redis中 redisTemplate.opsForValue().set(("order:"+user.getId()+":"+goods.getId()),seckillOrder,60, TimeUnit.SECONDS); ``` - 每次秒杀之前先判断redis中是否存在该商品的订单信息,如果存在返回提示用户不能重复购买此商品 ```java // 从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 ```java Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent("seckillCount:" + user.getId() + ":" + goodsId, user.getId()); if (!ifAbsent){ return ResBean.error(ResBeanEnum.REPEATE_ERROR); } ``` > 解决商品超卖问题 - 判断库存数量是否大于0,如果大于0就可以进行抢购,否则不能抢购,返回提示商品库存不足 ```java //判断库存 if (goods.getStockCount()<1){ model.addAttribute("errMsg", ResBeanEnum.EMPTY_STOCK.getMessage()); return "seckill_fail"; } ``` - 在更新商品的库存,先判断当前的库存数量是否大于0,如果库存大于0,即可更新库存减一,并创建订单,否则更新失败 ```java // 解决库存超卖 boolean result = seckillGoodsService.update(new UpdateWrapper() .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 > 修改语言 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111304_0c013876_7380070.png "屏幕截图.png") > 修改编码 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111314_9e94541a_7380070.png "屏幕截图.png") > 启动jmeter 打开bin目录下的jmeter.bat,即可打开jmeter ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111326_ef0b1de2_7380070.png "屏幕截图.png") ### 8.2 JMeter的使用 > 创建线程组 右键点击测试计划->添加->线程(用户)->线程组 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111341_89a5badf_7380070.png "屏幕截图.png") 创建1000个线程,0秒开始启动,循环10次 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111352_89f07392_7380070.png "屏幕截图.png") > 设置HTTP请求默认值 右键点击线程组->添加->配置元件->HTTP请求默认值 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111405_52c1f8b1_7380070.png "屏幕截图.png") 设置HTTP协议,服务器名称localhost,端口8080 [图片上传失败(image-bfYgnuuldYBwSBOXkgM9)] > 设置HTTP请求 右键点击线程组->添加->取样器->HTTP请求 [图片上传失败(image-qUnOl9W7LNwXeb3lKu6K)] 名称为商品列表,GET请求,路径为/goods/toList的HTTP请求 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631066857217-1e604e2e-c32d-4f7a-b9c0-060e3bf86586.png#clientId=u3e4336ce-f219-4&from=paste&height=224&id=u23800533&margin=%5Bobject%20Object%5D&name=image.png&originHeight=447&originWidth=1124&originalType=binary&ratio=1&size=30458&status=done&style=none&taskId=ue723757b-1261-4fb5-93fb-a8e82cf2737&width=562) > 聚合报告 右键点击线程组->添加->监听器->聚合 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631067002632-aa0d1286-1782-47a1-809f-73ef3d7ae7ba.png#clientId=u3e4336ce-f219-4&from=paste&height=362&id=ubeec57cb&margin=%5Bobject%20Object%5D&name=image.png&originHeight=724&originWidth=661&originalType=binary&ratio=1&size=56592&status=done&style=none&taskId=u17a3a4df-6636-4d03-a21f-aeab53b1c36&width=330.5) ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631067078974-75c66c2b-bd60-4123-8b28-241411e0b482.png#clientId=u3e4336ce-f219-4&from=paste&height=130&id=ud2f100b6&margin=%5Bobject%20Object%5D&name=image.png&originHeight=259&originWidth=1133&originalType=binary&ratio=1&size=22556&status=done&style=none&taskId=u39d9b094-d5c0-4f04-aee8-14bce8d51ea&width=566.5) > 设置CSV数据配置文件 右键点击线程组->添加->配置元件->CSV Data Set Config ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631067173774-c3344bf3-7f69-4a35-9afa-f63b619d51ca.png#clientId=u3e4336ce-f219-4&from=paste&height=376&id=u1ce65b9f&margin=%5Bobject%20Object%5D&name=image.png&originHeight=752&originWidth=739&originalType=binary&ratio=1&size=66397&status=done&style=none&taskId=ufcdd3669-be02-43ad-9873-5070206b1f8&width=369.5) 选择文件,设置文件编码为UTF-8,变量名称 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631067296809-e2a7b4c6-79e6-4a91-965b-915f47bbda36.png#clientId=u3e4336ce-f219-4&from=paste&height=232&id=ude8d2f54&margin=%5Bobject%20Object%5D&name=image.png&originHeight=463&originWidth=1126&originalType=binary&ratio=1&size=36371&status=done&style=none&taskId=ud340d048-1638-46d5-8bc8-045c0b298e8&width=563) > HTTP Cookie管理器 右键点击线程组->添加->配置元件->HTTP Cookie 管理器 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631067383683-c70442d0-53e6-47dc-83f2-954eaf346e20.png#clientId=u3e4336ce-f219-4&from=paste&height=368&id=ua206d8f1&margin=%5Bobject%20Object%5D&name=image.png&originHeight=736&originWidth=741&originalType=binary&ratio=1&size=64449&status=done&style=none&taskId=u7c5780b2-4580-48b1-9749-1739a651348&width=370.5) 添加名称,设置值,域,路径 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631067509971-19e0db9d-90f1-4d45-a471-4238cbbcd6d7.png#clientId=u3e4336ce-f219-4&from=paste&height=188&id=u2c084f60&margin=%5Bobject%20Object%5D&name=image.png&originHeight=376&originWidth=1119&originalType=binary&ratio=1&size=24547&status=done&style=none&taskId=u32b5a595-a80d-413b-852d-e4e4bb407c9&width=559.5) ### 8.3 使用工具类生产用户 > UserUtil ```java public class UserUtil { private static void createUser(int count) throws SQLException, ClassNotFoundException, IOException { ArrayList 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= 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](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631066857217-1e604e2e-c32d-4f7a-b9c0-060e3bf86586.png#clientId=u3e4336ce-f219-4&from=paste&height=224&id=hL7TW&margin=%5Bobject%20Object%5D&name=image.png&originHeight=447&originWidth=1124&originalType=binary&ratio=1&size=30458&status=done&style=none&taskId=ue723757b-1261-4fb5-93fb-a8e82cf2737&width=562) > 压测结果 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631069099858-bce1524d-8b3a-40a2-bfd7-e64d83f518c3.png#clientId=u3e4336ce-f219-4&from=paste&height=45&id=u65714e64&margin=%5Bobject%20Object%5D&name=image.png&originHeight=89&originWidth=1129&originalType=binary&ratio=1&size=13944&status=done&style=none&taskId=ufac14141-b8e8-4ff8-aa7f-d81c2750736&width=564.5) ### 8.5 压测商品秒杀接口 > 设置HTTP请求 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631069198586-3e8a79a5-010b-4911-8edc-73dfb6b20fdf.png#clientId=u3e4336ce-f219-4&from=paste&height=226&id=u171e22a5&margin=%5Bobject%20Object%5D&name=image.png&originHeight=451&originWidth=1126&originalType=binary&ratio=1&size=32898&status=done&style=none&taskId=u0b31d7e7-dd63-489c-8e9a-e01a9f02cb7&width=563) > 压测结果 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631071607610-dcb03f53-41aa-4e63-ad23-72e8a8692dd6.png#clientId=u3e4336ce-f219-4&from=paste&height=42&id=ubbf44622&margin=%5Bobject%20Object%5D&name=image.png&originHeight=84&originWidth=1136&originalType=binary&ratio=1&size=14395&status=done&style=none&taskId=u8e3c3986-f49a-48cd-bb1f-2b8e5149f31&width=568) 商品出现 ![image.png](https://cdn.nlark.com/yuque/0/2021/png/22501023/1631071634801-8095b287-dd84-4af7-a5d7-32905cb199f5.png#clientId=u3e4336ce-f219-4&from=paste&height=45&id=uc66d5429&margin=%5Bobject%20Object%5D&name=image.png&originHeight=89&originWidth=736&originalType=binary&ratio=1&size=8752&status=done&style=none&taskId=u28ed0b65-726e-46ac-addf-0154c7537db&width=368) ## 9. 页面优化 ### 9.1 页面缓存 > 商品列表页面 ```java @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 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; } ``` > 商品详情页面 ```java @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 对象缓存 > 用户对象缓存 ```java /** * 根据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; } ``` > 更新用户密码并删除缓存 ```java /** * 更新用户密码 * @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 页面静态化 > 配置 ```yaml 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 ```java /** * 商品详情返回对象 */ @Data @AllArgsConstructor @NoArgsConstructor public class GoodsDetailVo { private User user; private GoodsVo goodsVo; private int remainSeconds; private int miaoshaStatus; private int betweenSeconds; } ``` goods_detail.htm ```html 商品详情
秒杀商品详情
您还没有登录,请登陆后再操作
没有收货地址的提示。。。
商品名称
商品图片
秒杀开始时间
秒杀结束时间
商品原价
秒杀价
库存数量
``` controller ```java @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 ```java @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); } ``` > 订单详情页面静态化 订单详情返回对象 ```java /** * 订单详情返回对象 */ @Data @AllArgsConstructor @NoArgsConstructor public class OrderDetailVo { private Order order; private GoodsVo goodsVo; } ``` order_detail.htm ```html 订单详情
秒杀订单详情
商品名称
商品图片
订单价格
下单时间
订单状态
收货人 XXX 18812341234
收货地址 北京市昌平区回龙观龙博一区
``` orederController ```java /** * 订单详情 * @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](https://github.com/rabbitmq/rabbitmq-server/releases/tag/v3.8.5) relang23 [https://github.com/rabbitmq/erlang-rpm/releases/tag/v23.3.4.6](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 安装成功 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/110923_c191f9de_7380070.png "屏幕截图.png") - 安装rabbitmq yum -y install rabbitmq-server-3.8.5-1.el7.noarch.rpm - 展示所有插件 rabbitmq-plugins list ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/110938_8db7809c_7380070.png "屏幕截图.png") - 安装可视化管理控制台 rabbitmq-plugins enable rabbitmq_management ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/110951_72c9a15f_7380070.png "屏幕截图.png") - 启动rabbitmq服务 systemctl start rabbitmq-server.service - 查看是否启动成功 systemctl status rabbitmq-server.service - 访问rabbitmq linux服务器地址+端口15672,用户名和密码都为guest ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111009_f8ce7799_7380070.png "屏幕截图.png") 不允许远程访问 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111022_b3820cd4_7380070.png "屏幕截图.png") - 添加配置文件,设置远程登录访问 进入/etc/rabbitmq/目录,创建rabbitmq.conf文件,写上[{rabbit,[{loopback_users, []}]}].,保存重启rabbitmq服务systemctl restart rabbitmq-server.service ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111035_2d6f807f_7380070.png "屏幕截图.png") - 再次访问登录成功 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111049_4e7dbc87_7380070.png "屏幕截图.png") ### 10.2 SpringBoot整合rabbitmq > 引入依赖 ```xml org.springframework.boot spring-boot-starter-amqp ``` > 配置rabbitmq ```yaml # 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配置类 ```java 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); } } ``` > 消息发送者 ```java /** * 消息发送者 */ @Service @Slf4j public class MQSender { @Autowired private RabbitTemplate rabbitTemplate; public void send(Object msg){ log.info("发送消息:"+msg); rabbitTemplate.convertAndSend("queue",msg); } } ``` > 消息接收消费者 ```java /** * 消息消费者 */ @Service @Slf4j public class MQReceiver { @RabbitListener(queues = "queue") public void receiver(Object msg){ log.info("接收消息:"+msg); } } ``` > 测试 ```java @RestController @RequestMapping("/mq") public class RabbitmqController { @Autowired private MQSender mqSender; /** * 测试发送rabbitmq消息 */ @RequestMapping("/mq") @ResponseBody public void mq(){ mqSender.send("hello rabbitmq"); } } ``` ### 10.3 Fanout模式 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111117_13230030_7380070.png "屏幕截图.png") > rabbitmq配置 ```java /** * 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()); } } ``` > 消息发送者 ```java /** * 消息发送者 */ @Service @Slf4j public class MQSender { @Autowired private RabbitTemplate rabbitTemplate; public void send(Object msg){ log.info("发送消息:"+msg); rabbitTemplate.convertAndSend("fanoutExchange","",msg); } } ``` > 消息消费者 ```java /** * 消息消费者 */ @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模式 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111135_b3e4b7f0_7380070.png "屏幕截图.png") > rabbitmq配置类 ```java /** * 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); } } ``` > 消息发送者 ```java /** * 消息发送者 */ @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); } } ``` > 消息接收消费者 ```java /** * 消息消费者 */ @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模式 ![输入图片说明](https://images.gitee.com/uploads/images/2021/0913/111150_815efceb_7380070.png "屏幕截图.png") > rabbitmq配置类 ```java /** * 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); } } ``` > 消息发送者 ```java /** * 消息发送者 */ @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); } } ``` > 消息接收消费者 ```java /** * 消息消费者 */ @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中 ```java 注:controller类实现InitializingBean接口 /** * 系统初始化,把商品库存数量加载到redis中 * @throws Exception */ @Override public void afterPropertiesSet() throws Exception { List 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 ```java //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的访问 ```java //1、创建map存放商品是否有库存 private final Map 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秒杀操作 > 秒杀消息类 ```java /** * 秒杀消息 */ @Data @AllArgsConstructor@NoArgsConstructor public class SeckillMessage { private User user; private Long goodsId; } ``` > 配置rabbitmq ```java /** * 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); } } ``` > 秒杀消息发送者 ```java /** * 消息发送者 */ @Service @Slf4j public class MQSender { @Autowired private RabbitTemplate rabbitTemplate; //发送商品秒杀消息 public void sendSeckillMessage(String msg){ log.info("发送秒杀商品消息:"+msg); rabbitTemplate.convertAndSend("seckillExchange","seckill.doSeckill",msg); } } ``` > 秒杀消息接收者 ```java /** * 消息消费者 */ @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 ```java /** * 商品秒杀 */ @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页面处理 ```javascript 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("客户端请求异常") } }) } ``` > 获取秒杀结果 ```java /** * 获取秒杀结果 * orderId:成功,-1:失败,0:排队中 * @param user * @param goodsId * @return */ @Override public Long getResult(User user, Long goodsId) { // 查询订单 SeckillOrder seckillOrder = seckillOrderDao.selectOne(new QueryWrapper() .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; } } ``` > 返回秒杀结果 ```java /** * 获取秒杀结果 * 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 ```java @SpringBootTest class SeckillSystemApplicationTests { @Autowired private RedisTemplate redisTemplate; @Autowired private DefaultRedisScript 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脚本 ```lua if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end ``` > redis配置类,配置脚本 ```java @Bean public DefaultRedisScript defaultRedisScript(){ DefaultRedisScript redisScript = new DefaultRedisScript<>(); //lock.lua脚本的位置和application.yml同级目录 redisScript.setLocation(new ClassPathResource("lock.lua")); //设置返回结果类型 redisScript.setResultType(Boolean.class); return redisScript; } ``` ### 11.5 优化redis减库存 > Lua脚本 ```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配置类,配置脚本 ```java @Bean public DefaultRedisScript defaultRedisScript(){ DefaultRedisScript redisScript = new DefaultRedisScript<>(); //lock.lua脚本的位置和application.yml同级目录 redisScript.setLocation(new ClassPathResource("stock.lua")); //设置返回结果类型 redisScript.setResultType(Long.class); return redisScript; } ``` > 执行脚本 ```java Long stock = (Long) redisTemplate.execute(redisScript, Collections.singletonList("seckillGoods:" + goodsId), Collections.EMPTY_LIST); ``` ## 12. 接口优化 ### 12.1 秒杀接口地址隐藏 > 前端商品详情页面 修改秒杀方法,先获取秒杀路径在进行秒杀 ```html ``` 前端获取秒杀路径方法 ```javascript 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) } } }) } ``` > 前端秒杀方法 ```javascript 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 ```java /** * 获取秒杀地址 * @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 ```java /** * 获取秒杀地址 * @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; } ``` > 后端校验秒杀路径 ```java // 校验秒杀路径(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](https://gitee.com/lian_jianfeng/EasyCaptcha?_from=gitee_search) ```xml com.github.whvcse easy-captcha 1.6.2 ``` 后端生成验证码 ```java /** * 生成验证码 * @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()); } ``` 前端展示 ```html
``` JS渲染 ```javascript // 刷新验证码 function refreshCaptcha() { $("#captchaImg").attr("src","/seckill/captcha?goodsId="+$("#goodsId").val()+"&time="+new Date()) } ``` > 后台校验验证码 ```javascript /** * 校验验证码 * @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 接口限流 > 计数器限流 ```java // 限制访问次数,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注解 ```java /** * 接口限流注解 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface AccessLimit { //时间间隔 int second(); // 最大次数 int maxCount(); //是否需要登录 boolean needLogin() default true; } ``` > 将user设置到线程ThreadLocal中 ```java /** * 将user设置到线程ThreadLocal中 */ public class UserContent { private static ThreadLocal userThreadLocal = new ThreadLocal<>(); //设置user到当前线程中 public static void setUser(User user){ userThreadLocal.set(user); } // 获取当前线程的user public static User getUser(){ return userThreadLocal.get(); } } ``` _限流拦截器_ ```java /** * 限流拦截器 */ @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