# flashorder **Repository Path**: nlp-greyfoss/flashorder ## Basic Information - **Project Name**: flashorder - **Description**: 学习秒杀项目 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2018-12-23 - **Last Updated**: 2021-07-13 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 项目说明 一个SpringBoot秒杀项目实现,仅用于学习 # 项目启动 用到的中间件:redis,rabbitmq,mysql 修改配置文件 # 两次MD5 * 客户端(浏览器): PASS(FRONT) = MD5(明文 + 固定Salt) * 服务端: PASS = MD5(PASS(FRONT)+随机Salt) # 参数校验 通过引入`@Valid`注解`public Result doLogin(HttpServletResponse response, @Valid LoginVO loginVO)`,以下代码是不需要每次编写的: ```java /*通过@Valid注解来校验 String pass = loginVO.getPassword(); String mobile = loginVO.getMobile(); if (StringUtils.isEmpty(pass)) { return Result.error(CodeMessage.PASSWORD_EMPTY); } if (StringUtils.isEmpty(mobile)) { return Result.error(CodeMessage.MOBILE_EMPTY); }*/ //登录失败会抛出异常,然后被全局异常处理器拦截处理 ``` 要检验的类编写也很简单: ```java @Data public class LoginVO { @NotNull @IsMobile private String mobile; @Length(min = 32) private String password; } ``` 其中`@IsMobile`是自定义的一个校验注解,具体可以看代码实现。 # 分布式Session 通过redis实现,服务端生成token,token可以保存到用户浏览器的cookie中。 然后以token作为key,用户对象作为value,加上一个过期时间存入redis缓存中。 # ArgumentResolver的使用 继承`WebMvcConfigurerAdapter`,复写`addArgumentResolvers()` ```java @Configuration public class WebConfig extends WebMvcConfigurerAdapter{ @Autowired private UserArgumentResolver userArgumentResolver; @Override public void addArgumentResolvers(List argumentResolvers) { argumentResolvers.add(userArgumentResolver); } } ``` 编写`UserArgumentResolver`类需要实现`HandlerMethodArgumentResolver`接口 ```java @Component public class UserArgumentResolver implements HandlerMethodArgumentResolver { @Autowired private UserService userService; /** * 只处理含有User参数的方法 * @param methodParameter * @return */ @Override public boolean supportsParameter(MethodParameter methodParameter) { Class clazz = methodParameter.getParameterType(); return clazz == User.class; } /** * 从cookie或参数中获取token * @param methodParameter * @param modelAndViewContainer * @param nativeWebRequest * @param webDataBinderFactory * @return * @throws Exception */ @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 paramToken = request.getParameter(UserService.COOKIE_NAME_TOKEN); String cookieToken = getCookieValue(request,UserService.COOKIE_NAME_TOKEN); if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) { log.info("all params are empty!"); return null; } String token = StringUtils.isEmpty(cookieToken) ? paramToken : cookieToken; return userService.getByToken(token,response); } private String getCookieValue(HttpServletRequest request,String cookieName) { for (Cookie cookie : request.getCookies()) { if (cookie.getName().equals(cookieName)) { return cookie.getValue(); } } return null; } } ``` # @ControllerAdvice的使用 一个特殊的`@Component`,可以被扫描到。 * 结合方法型注解`@ExceptionHandler`,用于捕获Controller中抛出的指定类型的异常,从而达到不同类型的异常区别处理的目的; * 结合方法型注解`@InitBinder`,用于request中自定义参数解析方式进行注册,从而达到自定义指定格式参数的目的; * 结合方法型注解`@ModelAttribute`,表示其标注的方法将会在目标Controller方法执行之前执行。 ```java /** * 全局异常处理 * 通过@ResponseBody来解析Result,比如解析为Json格式 */ @ControllerAdvice @ResponseBody @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(value = Exception.class) public Result exceptionHandler(HttpServletRequest request,Exception e) { log.error("exception:{}",e); if (e instanceof BindException) { //如果是绑定异常 BindException ex = (BindException) e; String msg = ex.getAllErrors().get(0).getDefaultMessage(); return Result.error(CodeMessage.BIND_ERROR.fillArgs(msg)); } else if (e instanceof GlobalException) { GlobalException ex = GlobalException.class.cast(e); return Result.error(ex.getCm()); } else { return Result.error(CodeMessage.SERVER_ERROR); } } } ``` # 压测秒杀 * 秒杀商品数:5 * QPS:2006/秒 * 生成订单数:23 * 压测完秒杀商品库存数:(-18) > 出现了超卖问题 ## 通过JMeter进行压测 通过`UserUtil#createUser()`创建5000个用户,并生成token。将token保存到tokens.txt中,用于压测 ### CSV数据文件设置 filename D:/tokens.txt Viriable Names userId,token # 页面优化技术 ## 页面缓存+URL缓存+对象缓存 ### 页面缓存 访问页面时从缓存中取,取不到才手动渲染模板并缓存 它的步骤分为三步: 1. 取缓存 2. 手动渲染模板 3. 结果输出 渲染模板的代码如下: ```java //生成SpringWebContext,用于渲染模板 SpringWebContext context = new SpringWebContext(request,response,request.getServletContext(), request.getLocale(),model.asMap(),applicationContext); //手动渲染 html = thymeleafViewResolver.getTemplateEngine().process("goods_list",context); ``` 不要忘记增加`@ResponseBody` ```java @ResponseBody public String list(Model model, User user, HttpServletRequest request, HttpServletResponse response) { ``` 缓存的结果其实就是静态的html代码。 ### URL缓存 和页面缓存的唯一区别是缓存key和URL中参数有关: ```java String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class); ``` ### 对象缓存 缓存一个对象,比如从redis中根据token获取用户信息就属于对象缓存: ```java return userService.getByToken(token,response); ``` ## 页面静态化,前后端分离 常用技术是AngularJS和Vue.js等 resources\static\goods_detail.htm 就是通过页面静态化实现的 通过Ajax去获取商品信息 前端会缓存页面,查看状态码为304,表示客户端可以使用本地缓存的页面 ``` Request URL: http://localhost:8080/goods_detail.htm?goodsId=1 Request Method: GET Status Code: 304 ``` 还可以通过以下配置来进行优化 ```properties #static spring.resources.add-mappings=true spring.resources.cache-period= 3600 spring.resources.chain.cache=true spring.resources.chain.enabled=true spring.resources.chain.gzipped=true spring.resources.chain.html-application-cache=true spring.resources.static-locations=classpath:/static/ ``` ## 静态资源优化 * JS/CSS压缩,减少流量 * 多个JS/CSS组合,减少连接数 > 可用工具:tengine ## CDN优化 CDN就可以理解为分布在每个县城的火车票代售点,用户在浏览网站的时候,CDN会选择一个离用户最近的CDN边缘节点来响应用户的请求, 这样海南移动用户的请求就不会千里迢迢跑到北京电信机房的服务器(假设源站部署在北京电信机房)上了。 该服务一般是有偿的。 # 解决超卖问题 首先修改sql,增加`stock > 0`判断 ```java @Update("update t_flashsale_goods set stock = stock - 1 where goods_id = #{goodsId} and stock > 0") int reduceStock(FlashsaleGoods g); ``` ```java /** * 秒杀 * @param user * @param goods * @return */ @Transactional public OrderInfo order(User user, GoodsVO goods){ //减库存,下订单,写入秒杀订单 if (goodsService.reduceStock(goods) == 1) { //生成order订单 return orderService.createOrder(user,goods); } return null; } ``` 还需要将`order()`方法修改成如上所示,若`goodsService.reduceStock(goods)`返回0,即插入失败后是不能继续往下走的。 但是还是无法解决同一个用户同时秒杀到了两个商品的情况。 ```java if (orderService.getOrderByUserIdAndGoodsId(user.getId(),goodsId) != null) { return Result.error(CodeMessage.FLASHSALE_DUPLICATE); } ``` 同一用户的两个请求同时进入了上面的判断,都返回为空,然后继续往下执行 解决方案是修改t_falshsale_order,为userId和goodsId建立唯一索引(同时选择这两个字段)。 # 服务端优化 核心思路是减少数据库的访问 * 系统初始化,把商品库存数量加载到Redis * 收到请求,Redis减库存,库存不足,直接返回 * 请求入队,立即返回**排队中** * 请求出队,生成订单,减库存 * 客户端轮询,返回是否秒杀成功 ## 压测 * 秒杀商品数:5 * QPS:4050秒 * 生成订单数:5 * 压测完秒杀商品库存数:(0) > QPS基本上翻了一倍