# seckill秒杀系统 **Repository Path**: wlby/seckill ## Basic Information - **Project Name**: seckill秒杀系统 - **Description**: 秒杀项目以及优化 - **Primary Language**: Java - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 14 - **Forks**: 3 - **Created**: 2021-06-03 - **Last Updated**: 2023-12-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 一、基础项目搭建 克隆项目之后导入SQL文件 主要有五张表 + t_goods 保存了所有商品列表 + t_order 保存订单信息 + t_seckill_goods 将秒杀的商品列为新的一张表,因为商品会有各种优惠活动,如果在商品表新建字段不好维护,或者秒杀渠道和不秒杀渠道可能同时开启,所以新建一张有利于维护秒杀商品 + t_seckill_order 存储秒杀订单 + t_user 用户表 并且插入一些数据用于测试 ```sql /* Navicat MySQL Data Transfer Source Server : localhost Source Server Version : 50536 Source Host : localhost:3306 Source Database : seckill Target Server Type : MYSQL Target Server Version : 50536 File Encoding : 65001 Date: 2021-05-31 17:02:53 */ SET FOREIGN_KEY_CHECKS=0; -- ---------------------------- -- Table structure for `t_goods` -- ---------------------------- DROP TABLE IF EXISTS `t_goods`; CREATE TABLE `t_goods` ( `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '商品ID', `goods_name` varchar(255) DEFAULT NULL COMMENT '商品名称', `goods_title` varchar(255) DEFAULT NULL COMMENT '商品标题', `goods_img` varchar(255) DEFAULT NULL COMMENT '商品图片', `goods_detail` varchar(255) DEFAULT NULL COMMENT '商品详情', `goods_price` decimal(10,2) DEFAULT NULL COMMENT '商品价格', `goods_stock` int(11) DEFAULT NULL COMMENT '商品库存', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; -- ---------------------------- -- Records of t_goods -- ---------------------------- INSERT INTO `t_goods` VALUES ('1', 'IPHONE 12 64GB', 'IPHONE 12 64GB', '/img/iphone12.png', 'IPHONE12 销量秒杀', '6299.00', '100'); INSERT INTO `t_goods` VALUES ('2', 'IPHONE12 PRO 128GB', 'IPHONE12 PRO 128GB', '/img/iphone12pro.png', 'IPHONE12PRO限量,限时秒杀,先到先得', '9299.00', '100'); -- ---------------------------- -- Table structure for `t_order` -- ---------------------------- DROP TABLE IF EXISTS `t_order`; CREATE TABLE `t_order` ( `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '订单ID', `user_id` bigint(11) DEFAULT NULL COMMENT '用户ID', `goods_id` bigint(11) DEFAULT NULL COMMENT '商品ID', `deliver_addr_id` bigint(11) DEFAULT NULL COMMENT '收获地址ID', `goods_name` varchar(255) DEFAULT NULL COMMENT '商品名称', `goods_count` int(11) DEFAULT NULL COMMENT '商品数量', `goods_price` decimal(10,2) DEFAULT NULL COMMENT '商品单价', `order_channel` int(11) DEFAULT NULL COMMENT '设备信息', `status` int(11) DEFAULT NULL COMMENT '订单状态', `create_date` datetime DEFAULT NULL COMMENT '订单创建时间', `pay_date` datetime DEFAULT NULL COMMENT '支付时间', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1548 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; -- ---------------------------- -- Records of t_order -- ---------------------------- -- ---------------------------- -- Table structure for `t_seckill_goods` -- ---------------------------- DROP TABLE IF EXISTS `t_seckill_goods`; CREATE TABLE `t_seckill_goods` ( `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '秒杀商品ID', `goods_id` bigint(11) DEFAULT NULL COMMENT '商品ID', `seckill_price` decimal(10,2) DEFAULT NULL COMMENT '秒杀价', `stock_count` int(11) DEFAULT NULL COMMENT '库存数量', `start_date` datetime DEFAULT NULL COMMENT '秒杀开始时间', `end_date` datetime DEFAULT NULL COMMENT '秒杀结束时间', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; -- ---------------------------- -- Records of t_seckill_goods -- ---------------------------- INSERT INTO `t_seckill_goods` VALUES ('1', '1', '629.00', '10', '2021-05-25 22:47:47', '2021-06-06 21:29:57'); INSERT INTO `t_seckill_goods` VALUES ('2', '2', '929.00', '10', '2021-05-25 21:30:14', '2021-06-05 21:30:17'); -- ---------------------------- -- Table structure for `t_seckill_order` -- ---------------------------- DROP TABLE IF EXISTS `t_seckill_order`; CREATE TABLE `t_seckill_order` ( `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '订单ID', `user_id` bigint(11) DEFAULT NULL COMMENT '用户ID', `order_id` bigint(11) DEFAULT NULL COMMENT '订单ID', `goods_id` bigint(11) DEFAULT NULL COMMENT '商品ID', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `seckill_uid_gid` (`user_id`,`goods_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1547 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; -- ---------------------------- -- Records of t_seckill_order -- ---------------------------- -- ---------------------------- -- Table structure for `t_user` -- ---------------------------- DROP TABLE IF EXISTS `t_user`; CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL, `nickname` varchar(255) NOT NULL, `password` varchar(32) DEFAULT NULL, `slat` varchar(10) DEFAULT NULL, `head` varchar(128) DEFAULT NULL, `register_date` datetime DEFAULT NULL, `last_login_date` datetime DEFAULT NULL, `login_count` int(11) DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO `t_user` VALUES ('18012345678', 'admin', 'b7797cce01b4b131b433b6acf4add449', '1a2b3c4d', null, null, null, '0'); ``` 将application.yml Redis 和 RabbitMQ的配置改为自己的配置应该就可以启动了! 可以访问 localhost:8080/user/toLogin 账号:18012345678 密码:123456 # 二、项目优化 ## 1. 前置:JMeter 压测方法 1. 进入 util 包下的UserUtil中将getConn() 方法中改为自己的数据库连接 2. 先启动SpringBoot项目,再运行UserUtil的main方法,会在创建一个config.txt文件,并且创建很多个user用户在数据库。 3. 再进行如下配置,即可进行压测 **配置线程组** ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210601171157353.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80ODkyMjE1NA==,size_16,color_FFFFFF,t_70) **配置Http请求地址** ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210601171217267.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80ODkyMjE1NA==,size_16,color_FFFFFF,t_70) **选中生成的config.txt如图配置用户信息** ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210601171243428.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80ODkyMjE1NA==,size_16,color_FFFFFF,t_70) **配置cookie的管理器** ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210601171309934.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80ODkyMjE1NA==,size_16,color_FFFFFF,t_70) **配置秒杀地址** ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210601171331924.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80ODkyMjE1NA==,size_16,color_FFFFFF,t_70) ## 2. 解决超卖 ### 2.1 唯一索引 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210603130917354.png) 将user_id 和 goods_id 设置为唯一索引,防止同一个用户重复抢购 ### 2.2 Redis 预减 ```java // redis 预减库存 Long stock = valueOperations.decrement("seckillGoods:" + goodsId); if (stock < 0) { //将该商品置为true emptyStockMap.put(goodsId, true); return RespBean.error(RespBeanEnum.EMPTY_STOCK); } ``` + 利用redis预减,如果库存小于0了就会将内存标记设置为true,并且直接返回,不会有后续下单操作 ### 2.3 减少数据库库存的时候加上判断条件 + 如果大于0才减少库存 ```java boolean seckillGoodsResult = seckillGoodsService.update(new UpdateWrapper() .setSql("stock_count = stock_count - 1") .eq("goods_id", goods.getId()) .gt("stock_count", 0)); ``` ## 3. 虚拟机优化 使用JDK8默认参数,5000个线程10组 QPS在1500左右,会引发四到五次Full GC 因为秒杀大多数对象朝生夕死,对象生命周期短,并且通过visual VM观察老年代空间都是突然爆满引发fullGC,所以调整年轻代大小,分别使用CMS 收集器和 G1 收集器QPS在2200左右,没有产生full GC,可能由于我的内存空间比较小并且并发量不大,所以G1对于CMS并没有压倒性的优势 ```xml -server -Xmx3g -Xms3g -Xmn2g -Xss500k -XX:MetaspaceSize=2048m -XX:MaxMetaspaceSize=2048m -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:LargePageSizeInBytes=64m -XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -Dfile.encoding=UTF8 -Duser.timezone=GMT+08 ``` ```xml -server -Xmx3g -Xms3g -Xmn2g -Xss500k -XX:+UseG1GC -XX:LargePageSizeInBytes=64m -XX:MetaspaceSize=2048m -XX:MaxMetaspaceSize=2048m -XX:+UseFastAccessorMethods -Dfile.encoding=UTF8 -Duser.timezone=GMT+08 ``` ## 4. Tomcat优化 **1. application.yml 中配置一些参数,例如** ```yml server: tomcat: accept-count: 1000 # 等待队列长度 threads: max: 800 #最大工作线程数 min-spare: 100 #最小工作线程数 ``` 利用这些参数可以提高tomcat可以使用的最大线程数,提高支持的并发数 + accept-count : 任务队列的长度,可以接受更多的任务(不能无限长,出入队列也会耗费cpu并且,任务堆积有可能造成out of memory) + threads.max : 最大工作线程数,当任务队列满后,创建救急线程工作(4核cpu 8G 内存 800 - 1000合适,否则将花费巨大的时间在cpu调度上) + min-spare : 最小工作线程,初始的工作线程,当无法满足需求再慢慢增加 **2. 通过编程式定制内嵌tomcat** 使用发起keepAlive请求,使用长连接,减少握手挥手的消耗 ```java import org.apache.catalina.connector.Connector; import org.apache.coyote.http11.Http11NioProtocol; import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.ConfigurableWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Configuration; /** * @Description: * @Author: Aiguodala * @CreateDate: 2021/5/28 13:38 */ @Configuration public class WebServerConfig implements WebServerFactoryCustomizer { @Override public void customize(ConfigurableWebServerFactory factory) { ((TomcatServletWebServerFactory)factory).addConnectorCustomizers(new TomcatConnectorCustomizer() { @Override public void customize(Connector connector) { Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler(); // 设置三十秒没有请求则自动断开keepAlive protocol.setKeepAliveTimeout(30000); // 设置超过10000个请求就断开keepAlive protocol.setMaxKeepAliveRequests(10000); } }); } } ``` ## 5. 缓存优化 ### 5.1 商品页面缓存 将商品列表以及商品信息缓存至redis,如果获取不到再到数据库查询。QPS提升较大 也可以使用三级缓存,利用guava包的将热点数据存入到本地缓存,如果没有再去redis中取,如果还没有则去查询数据库。 ```java /** * 跳转商品列表 * * windows 优化前 5000个线程 10 组 QPS : 1360.2 * windows 缓存优化后 5000个线程 10 组 QPS : 6037 * * @param model * @param user * @return */ @RequestMapping(value = "/toList", produces = "text/html;charset=utf-8") @ResponseBody public String toList(Model model,User user, HttpServletRequest request, HttpServletResponse response) { ValueOperations operations = redisTemplate.opsForValue(); String html = (String) operations.get("goodsList"); if (!StringUtils.isEmpty(html)) { return html; } model.addAttribute("user", user); List goodsList = goodsService.listGoodsVo(); model.addAttribute("goodsList", goodsList); WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap()); html = thymeleafViewResolver.getTemplateEngine().process("goodsList", context); if (!StringUtils.isEmpty(html)) { operations.set("goodsList", html, 60, TimeUnit.SECONDS); } return html; } /** * 商品详情 * @param model * @param user * @param goodsId * @param request * @param response * @return */ @RequestMapping(value = "/toDetail/{goodsId}", produces = "text/html;charset=utf-8") @ResponseBody public String toDetail(Model model, User user, @PathVariable(value = "goodsId") Long goodsId , HttpServletRequest request, HttpServletResponse response) { ValueOperations operations = redisTemplate.opsForValue(); String html = (String) operations.get("goodsDetail:" + goodsId); if (!StringUtils.isEmpty(html)) { return html; } model.addAttribute("user", user); GoodsVo goodsVo = goodsService.getGoodsVoById(goodsId); Date startDate = goodsVo.getStartDate(); Date endDate = goodsVo.getEndDate(); Date nowDate = new Date(); //秒杀状态 int secKillStatus = 0; //秒杀倒计时 int remainSeconds = 0; //秒杀还未开始 if (nowDate.before(startDate)) { remainSeconds = ((int) ((startDate.getTime() - nowDate.getTime()) / 1000)); } else if (nowDate.after(endDate)) { // 秒杀已结束 secKillStatus = 2; remainSeconds = -1; } else { //秒杀中 secKillStatus = 1; remainSeconds = 0; } model.addAttribute("remainSeconds", remainSeconds); model.addAttribute("secKillStatus", secKillStatus); model.addAttribute("goods", goodsVo); WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap()); html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", context); if (!StringUtils.isEmpty(html)) { operations.set("goodsDetail:" + goodsId, html, 60, TimeUnit.SECONDS); } return html; } ``` ### 5.2 秒杀缓存以及秒杀逻辑 + 让该类实现InitializingBean 接口,重写afterPropertiesSet方法,在该bean初始化属性赋值之后进行操作,也就是系统初始化的时候将商品秒杀库存加载到redis中 ```java public class SeckillGoodsController implements InitializingBean { . . . /** * 系统初始化的时候将商品库存数量加载到redis * @throws Exception */ @Override public void afterPropertiesSet() throws Exception { List goodsVos = goodsService.listGoodsVo(); if (CollectionUtils.isEmpty(goodsVos)) { return; } goodsVos.forEach(goodsVo -> { redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount()); emptyStockMap.put(goodsVo.getId(), false); }); } ``` + 添加一个内存标记emptyStockMap ,使用支持并发的ConcurrentHashMap,如果已经被抢完了,就给该商品ID的value置为true,则无需再访问redis。 + 之后判断是否是同一个用户重复抢购,每次抢购生成订单以后会在redis中生成一条订单数据用来判断 + 如果以上均没有问题,则对redis该商品库存进行预减,使用decrement是原子操作,减少之后如果不小于0,则预减成功,则像消息队列发送消息,如果库存小于0则失败,并且将内存标记置为true ```java /** * 判断库存是否已经是空 */ private Map emptyStockMap = new ConcurrentHashMap<>(); @PostMapping(value = "/doSeckill") @ResponseBody public RespBean doSeckill(User user, Long goodsId) { if (user == null) { return RespBean.error(RespBeanEnum.SESSION_ERROR); } ValueOperations valueOperations = redisTemplate.opsForValue(); // 通过内存标记,如果已经被抢购空了则无需访问redis if (emptyStockMap.get(goodsId)) { return RespBean.error(RespBeanEnum.EMPTY_STOCK); } // 判断是否重复抢购 SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId); if (seckillOrder != null) { return RespBean.error(RespBeanEnum.REPEATE_ERROR); } // redis 预减库存 Long stock = valueOperations.decrement("seckillGoods:" + goodsId); if (stock < 0) { //将该商品置为true emptyStockMap.put(goodsId, true); return RespBean.error(RespBeanEnum.EMPTY_STOCK); } SeckillMessage seckillMessage = new SeckillMessage(user, goodsId); mqProvider.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage)); return RespBean.success(0); } ``` ## 6. 异步处理订单 + 如上如果redis预减成功,则将消息发送到消息队列 + 监听消息队列消费者则接受到消息,进行一些缓慢的生成订单等数据库操作 + 发送消息之后服务器马上返回结果,减轻服务器压力,之后客户端再通过轮询调用getResult方法获取结果 ```java @RabbitListener(queues = "seckillQueue") public void receive(String message) { log.info("接受消息" + message); SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class); User user = seckillMessage.getUser(); Long goodsId = seckillMessage.getGoodsId(); GoodsVo goodsVo = goodsService.getGoodsVoById(goodsId); if (goodsVo.getStockCount() < 1) { return; } SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsVo.getId()); if (seckillOrder != null) { return; } orderService.seckill(user, goodsVo); } ``` ### 6.1 确保消息不丢失 + 发送消息给MQ的时候注册一个回调事件,如果ack为false 既任务失败或者没有发送成功则将库存加上一 ```java /** * 发送秒杀信息 * @param message */ public void sendSeckillMessage(String message) { // 注册回调,如果发送失败,将库存加1 rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() { @Override public void confirm(CorrelationData correlationData, boolean ack, String cause) { if (!ack) { SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class); redisTemplate.opsForValue().increment("seckillGoods:" + seckillMessage.getGoodsId()); } } }); log.info("发送信息" + message); rabbitTemplate.convertAndSend("seckillExchange", "seckill.message", message); } ``` + 消费者确保消息不丢失 + 通过取消自动的ack,采用手动的ack,如果有异常则返回错误ack,触发回调中的逻辑 ```java @RabbitListener(queues = "seckillQueue") public void receive(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) { try { SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class); User user = seckillMessage.getUser(); Long goodsId = seckillMessage.getGoodsId(); GoodsVo goodsVo = goodsService.getGoodsVoById(goodsId); if (goodsVo.getStockCount() < 1) { return; } SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsVo.getId()); if (seckillOrder != null) { return; } orderService.seckill(user, goodsVo); /** * 无异常就确认消息 * basicAck(long deliveryTag, boolean multiple) * deliveryTag:取出来当前消息在队列中的的索引; * multiple:为true的话就是批量确认 */ channel.basicAck(tag, false); }catch (Exception e) { /** * 有异常就绝收消息 * basicNack(long deliveryTag, boolean multiple, boolean requeue) * requeue:true为将消息重返当前消息队列,还可以重新发送给消费者; * false:将消息丢弃 */ try { channel.basicNack(tag,false,true); } catch (IOException ioException) { ioException.printStackTrace(); } } ``` ## 7. 接口防刷 + 先校验验证码 + 再采用先在redis中获取秒杀路径,再通过拼接秒杀路径进行秒杀 ```java @PostMapping(value = "/{path}/doSeckill") @ResponseBody public RespBean doSeckill(@PathVariable String path, User user, Long goodsId) { if (user == null) { return RespBean.error(RespBeanEnum.SESSION_ERROR); } ValueOperations valueOperations = redisTemplate.opsForValue(); boolean check = orderService.checkPath(user, goodsId, path); if (!check) { return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL); } // 通过内存标记,如果已经被抢购空了则无需访问redis if (emptyStockMap.get(goodsId)) { return RespBean.error(RespBeanEnum.EMPTY_STOCK); } // 判断是否重复抢购 SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId); if (seckillOrder != null) { return RespBean.error(RespBeanEnum.REPEATE_ERROR); } // redis 预减库存 Long stock = valueOperations.decrement("seckillGoods:" + goodsId); /* Long stock = (Long) redisTemplate.execute(script, Collections.singletonList("seckillGoods:" + goodsId), Collections.EMPTY_LIST);*/ if (stock < 0) { //将该商品置为true emptyStockMap.put(goodsId, true); return RespBean.error(RespBeanEnum.EMPTY_STOCK); } SeckillMessage seckillMessage = new SeckillMessage(user, goodsId); mqProvider.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage)); return RespBean.success(0); } @AccessLimit(second = 5, maxCount = 5, needLogin = true) @RequestMapping(value = "/path", method = RequestMethod.GET) @ResponseBody public RespBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request) { if (user == null) { return RespBean.error(RespBeanEnum.SESSION_ERROR); } boolean check = orderService.checkCaptcha(user, goodsId, captcha); if (!check) { return RespBean.error(RespBeanEnum.ERROR_CAPTCHA); } String str = orderService.createPath(user, goodsId); return RespBean.success(str); } @GetMapping(value = "/captcha") public void verifyCode(User user, Long goodsId, HttpServletResponse response) { if (user == null || goodsId < 0) { throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL); } //设置请求头为输出图片的类型 response.setContentType("image/jpg"); response.setHeader("Pargam", "No-cache"); response.setHeader("Cache-Control", "no-cache"); response.setDateHeader("Expires", 0); //生成验证码,将结果放入Redis ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32, 3); redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId, captcha.text(), 300, TimeUnit.SECONDS); try { captcha.out(response.getOutputStream()); } catch (IOException e) { log.error("验证码生成失败", e.getMessage()); } } ``` + 另外我还通过自定义注解,来减少疯狂点击的大量请求, second 表示秒数,maxCount表示在该秒数下最多能进行几次请求 + 具体可以看我的源码实现,再通过拦截器AccessLimitInterceptor进行处理逻辑 ```java @AccessLimit(second = 5, maxCount = 5, needLogin = true) ``` + 同样也可以采用令牌桶的方式解决