# High concurrency spike **Repository Path**: ExamlpePlugins/high-concurrency-spike ## Basic Information - **Project Name**: High concurrency spike - **Description**: 高并发秒杀系统 - **Primary Language**: Java - **License**: CC-BY-SA-4.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 2 - **Created**: 2024-12-21 - **Last Updated**: 2024-12-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 高并发秒杀接口优化 ## 普通下单接口 下单接口: ```java @Transactional(rollbackFor = Exception.class) @GetMapping("/product_order/{userId}/{productId}") @ResponseBody public String productOrder1(@PathVariable("userId") int userId, @PathVariable("productId") int productId){ //获取商品的库存 int productInventory = productMapper.getProductInventory(productId); if(productInventory > 0){ //库存大于0,进行创建订单操作 if (productMapper.updateInventory(productId)>0){ //判断库存是否修改成功 SpikeOrder order = new SpikeOrder(); order.setProductId(productId); order.setUserId(userId); spikeOrderMapper.insert(order); }else{ return "秒杀失败"; } }else { return "秒杀失败"; } return "秒杀成功"; } ``` 商品Mapper接口: ```java @Repository public interface ProductMapper extends BaseMapper { @Update("update product set product_inventory = product_inventory-1 where product_id = #{productId} and product_inventory > 0") int updateInventory(@Param("productId") int productId); @Select("select product_inventory from product where product_id = #{productId}") int getProductInventory(@Param("productId") int productId); } ``` jmeter压测结果 **吞吐量为1344/s(5000个线程发送请求,循环10次)** : ![输入图片说明](https://images.gitee.com/uploads/images/2020/0828/225839_a7e6165a_7352213.png "QQ图片20200828212341.png") ## 1、秒杀接口性能优化 ### 1.1 添加Redis缓存 #### Redis配置: ```properties spring.redis.port=6379 spring.redis.timeout=1000 spring.redis.jedis.pool.max-wait=-1 spring.redis.jedis.pool.max-idle=500 spring.redis.jedis.pool.max-active=1000 ``` 这里有些疑惑:SpringBoot 2.0之后默认使用lettuce对redis进行操作,但是在properties中配置lettuce时却会报错,而配置jedis却不会报错;查看依赖信息,SpringBoot 也确实是导入的lettuce依赖,查看jedis的相关方法也是由于缺少相关依赖而无法使用。 #### 在controller初始化时,将商品信息添加到Redis中 ```java @PostConstruct public void init() { List productList = productService.getProductList(); //将商品信息添加到elasticsearch中 //productESRepository.saveAll(productList); HashMap map = new HashMap<>(5); map.put("product_id", null); map.put("product_name", null); map.put("product_price", null); map.put("product_description", null); map.put("product_image", null); for (Product product : productList) { //将商品信息加入redis map.put("product_id", product.getProductId()); map.put("product_name", product.getProductName()); map.put("product_price", product.getMiaoshaPrice()); map.put("product_description", product.getProductDescription()); map.put("product_image", product.getProductImage()); redisUtil.hmset("product" + product.getProductId(), map); //将商品库存加入redis redisUtil.set("productInventory" + product.getProductId(), product.getProductInventory()); } } ``` #### 用户秒杀时,不直接操作数据库,而是先查看redis缓存 ```java //获取redis缓存中秒杀之后的商品库存 long inventory = redisUtil.decr("productInventory" + productId, 1L); //如果秒杀后的商品库存小于0,就在jvm缓存inventoryMap添加对应的键值对 if (inventory < 0) { redisUtil.incr("productInventory" + productId, 1L); inventoryMap.put(productId, false); return "已售空"; } ``` ### 1.2 添加JVM缓存(内存标记) 创建一个map,用来存放商品是否售空 ```java //记录商品的库存信息 public static ConcurrentHashMap inventoryMap = new ConcurrentHashMap<>(); //jvm缓存,通过内存标记来判断当前商品是否有库存 if (inventoryMap.get(productId) != null) { return "已售空"; } ``` ### 1.3 解决少卖和超卖 当对数据库进行操作,结果出错之后,除了需要回滚事务外,还需要修改对应的缓存信息,否则会造成超卖或少卖 ```java try { //数据库中的库存-1,放回修改成功的数量 int i = productService.productInventoryDecr(map.get("productId")); //库存修改成功,创建订单 if (i > 0) { SpikeOrder order = new SpikeOrder(); order.setProductId( map.get("productId")); order.setUserId(map.get("userId")); spikeOrderMapper.insert(order); } } catch (Exception e) { e.printStackTrace(); //如果修改库存和创建订单的过程中出现错误,修改对应的缓存 redisUtil.incr("productInventory" + map.get("productId"), 1L); if (ProductController.inventoryMap.get(map.get("productId")) != null) { ProductController.inventoryMap.put(map.get("productId"), null); } } ``` ### 1.4 使用RabbitMQ进行异步处理以及流量削峰 当请求到来时,如果还有库存,就加入消息队列中,秒杀接口不进行对数据库的操作,由另外的程序执行,减少了秒杀接口的执行时间,降低了耦合度;给消息队列设置一个大小限制,超出的新消息直接丢弃,这样就可以流量削峰,减轻系统压力。 秒杀接口对应代码: ```java //将秒杀请求放入RabbitMQ消息队列中,进行异步处理,并进行流量削峰 try { Map map = new HashMap<>(); map.put("userId", userId); map.put("productId", productId); rabbitTemplate.convertAndSend("spikeOrder", map); } catch (AmqpException e) { e.printStackTrace(); return "秒杀失败"; } ``` 消息处理程序: ```java /** * queue溢出行为,这将决定当队列达到设置的最大长度或者最大的存储空间时发送到消息队列的消息的处理方式; * 有效的值是: * drop-head(删除queue头部的消息)、 * reject-publish(最近发来的消息将被丢弃)、 * reject-publish-dlx(拒绝发送消息到死信交换器) * 类型为quorum 的queue只支持drop-head; */ //从消息对列中获得秒杀请求,并进行减库存和创建订单的处理 //消息队列的大小设置为10000,溢出的消息直接丢弃 @Transactional(rollbackFor = Exception.class) @RabbitListener(queuesToDeclare = @Queue(value = "spikeOrder", arguments = {@Argument(name = "x-max-length", value = "10000", type = "java.lang.Integer"), @Argument(name = "x-overflow", value = "reject-publish")})) public void spike(Map map) { try { //数据库中的库存-1,放回修改成功的数量 int i = productService.productInventoryDecr(map.get("productId")); //库存修改成功,创建订单 if (i > 0) { SpikeOrder order = new SpikeOrder(); order.setProductId( map.get("productId")); order.setUserId(map.get("userId")); spikeOrderMapper.insert(order); } } catch (Exception e) { e.printStackTrace(); //如果修改库存和创建订单的过程中出现错误,修改对应的缓存 redisUtil.incr("productInventory" + map.get("productId"), 1L); if (ProductController.inventoryMap.get(map.get("productId")) != null) { ProductController.inventoryMap.put(map.get("productId"), null); } } } ``` ### 1.5 其他优化方式 1. 页面静态化 2. 页面缓存 3. sql优化(添加相关的索引,避免在 where 子句中对字段进行表达式操作等) ### 优化结果: **吞吐量由1300/s提升到3930/s(5000个线程发送请求,循环10次)** ,增加并发量之后,吞吐量还可以更高。 ![输入图片说明](https://images.gitee.com/uploads/images/2020/0828/225902_177bc399_7352213.png "QQ图片20200828212323.png") ## 2、秒杀接口安全优化 ### 2.1 验证码 服务器随机生成3个数,进行随机的加减乘运算,将算术表达式返回前端,并将结果保存到redis缓存中,结果存活时间为10分钟。 ```java /** * 获取验证码 * @param userId 用户id * @param productId 商品id * @return 数学算式 */ @AccessLimit(seconds = 60,maxCount = 10) @GetMapping("/getVC/{userId}/{productId}") @ResponseBody public String getVC(@PathVariable("userId") int userId, @PathVariable("productId") int productId) { //验证码中的3个随机数字 int i = (int) (Math.random() * 10); int j = (int) (Math.random() * 10); int t = (int) (Math.random() * 10); //随机生成运算符 Random random = new Random(); char ops1 = ops[random.nextInt(3) % 3]; char ops2 = ops[random.nextInt(3) % 3]; //计算运算结果 int i1 = 0; int res = 0; switch (ops1) { case '+': i1 = i + j; break; case '-': i1 = i - j; break; case 'X': i1 = i * j; break; default: break; } switch (ops2) { case '+': res = i1 + t; break; case '-': res = i1 - t; break; case 'X': res = i1 * t; break; default: break; } String vc = "" + i + ops1 + j + ops2 + t; //将运算结果放入redis缓存中,存活时间为10分钟 redisUtil.set("VC_" + userId + "_" + productId, res, 600); return JSON.toJSONString(vc); } ``` 前端根据得到的表达式生成验证码图片 ```javascript $(function(){ //countDown(); $.ajax({ url: "/product/getVC/1/"+$("#productId").val(), type: "GET", success: function (data2) { console.log(data2); string = data2; //使用canvas绘制验证码 var data = canvasWrapText({canvas:document.getElementById("canvas"),text:string}); }, error: function () { console.log("客户端请求有误"); } }); }); //绘制验证码的js代码这里省略,详细代码在源码中 ``` ### 2.2 隐藏秒杀接口 用户输入正确的验证码结果,后端会生成一个UUID存入Redis,并返回给前端 ```java /** * 验证算式结果,并返回秒杀url * @param userId 用户id * @param productId 商品id * @param vc 验证结果 * @return 返回秒杀url中的uuid */ @AccessLimit(seconds = 60,maxCount = 10) @RequestMapping("/getUrl/{userId}/{productId}/{vc}") @ResponseBody public String getUrl( @PathVariable("userId") int userId, @PathVariable("productId") int productId, @PathVariable("vc") int vc) { //判断验证结果是否正确 int i = (int) redisUtil.get("VC_" + userId + "_" + productId); if (vc != i) { return "验证错误"; } //正确就删除缓存中的验证码结果 redisUtil.del("VC_" + userId + "_" + productId); //生成uuid,放入redis缓存中,并返回给前端 UUID id = UUID.randomUUID(); String[] idd = id.toString().split("-"); String uuid = idd[0] + idd[1]; redisUtil.set("url_" + userId + "_" + productId, uuid, 10); return JSON.toJSONString(uuid); } ``` 前端获得UUID后,拼接秒杀接口,发送秒杀请求 ```javascript function spikeBut() { //发送验证码结果 $.ajax({ url: "/product/getUrl/1/"+$("#productId").val()+"/"+$("#yanz").val(), type: "GET", success: function (data) { console.log(data); //window.location.href="/order_detail.htm?orderId="+data.data.id; //拼接秒杀接口,并发送请求 $.ajax({ url: "/product/product_order/"+JSON.parse(data)+"/1/"+$("#productId").val(), type: "GET", success: function (data1) { console.log(data1); }, error: function () { console.log("客户端请求有误"); } }); }, error: function () { console.log("客户端请求有误"); } }); } ``` 前端发送秒杀请求后,服务端根据用户id在redis中查找相应的UUID,并对比,如果结果正确,就进行秒杀业务 ```java /** * 秒杀功能 * @param uuid 秒杀url中的uuid * @param userId 用户id * @param productId 商品id * @return 秒杀结果 */ @AccessLimit(seconds = 60,maxCount = 10) //接口防刷:一分钟内同一个用户只可以请求10次 @GetMapping("/product_order/{uuid}/{userId}/{productId}") @ResponseBody public String productOrder(@PathVariable("uuid") String uuid, @PathVariable("userId") int userId, @PathVariable("productId") int productId) { //判断前端传来的uuid是否存在(正确) String uid = (String) redisUtil.get("url_" + userId + "_" + productId); if (uuid == null || !uuid.equals(uid)) { return "请求无效"; } //jvm缓存,通过内存标记来判断当前商品是否有库存 if (inventoryMap.get(productId) != null) { return "已售空"; } //获取redis缓存中秒杀之后的商品库存 long inventory = redisUtil.decr("productInventory" + productId, 1L); //如果秒杀后的商品库存小于0,就在jvm缓存inventoryMap添加对应的键值对 if (inventory < 0) { redisUtil.incr("productInventory" + productId, 1L); inventoryMap.put(productId, false); return "已售空"; } //将秒杀请求放入RabbitMQ消息队列中,进行异步处理,并进行流量削峰 try { Map map = new HashMap<>(); map.put("userId", userId); map.put("productId", productId); rabbitTemplate.convertAndSend("spikeOrder", map); } catch (AmqpException e) { e.printStackTrace(); return "秒杀失败"; } return "排队中"; } ``` ### 2.3 接口防刷 定义一个接口,来标记对某个API进行防刷处理 ```java /** * 接口限流防刷 * @author zzc */ @Retention(RUNTIME) @Target(ElementType.METHOD) public @interface AccessLimit { //访问时间范围 int seconds(); //最大访问次数 int maxCount(); } ``` 配置拦截器 ```java @Component public class AccessHandlerInterceptor implements HandlerInterceptor { @Autowired private RedisUtil redisUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //判断是否是映射到方法上的注解 if (handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; //获取AccessLimit注解 AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class); //没有AccessLimit注解,直接通过 if (accessLimit == null) { return true; } //获得AccessLimit注解的参数 int seconds = accessLimit.seconds(); int maxCount = accessLimit.maxCount(); //获取RESTful风格请求链接中的参数,用户数据 Map pathVariables = (Map) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); //System.out.println(pathVariables.toString()); String userId = (String) pathVariables.get("userId"); String productId = (String) pathVariables.get("productId"); //从redis缓存中获得该用户的id以及商品的id Integer count = (Integer) redisUtil.get("Limit_" + userId + "_" + productId); //如果缓存中不存在,就创建对应的键值对 if (count == null) { redisUtil.set("Limit_" + userId + "_" + productId, 1, seconds); return true; } else if (count > maxCount) { //访问次数大于最大次数,拒绝访问 response.setContentType("application/json;charset=UTF-8"); OutputStream out = response.getOutputStream(); String str = JSON.toJSONString("请求过于频繁"); out.write(str.getBytes("UTF-8")); out.flush(); out.close(); return false; } else { //访问次数+1 redisUtil.incr("Limit_" + userId + "_" + productId, 1L); return true; } } return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } } ```