一、基础项目搭建
==gitee 地址== : gitee.com/wlby/seckil…
克隆项目之后导入SQL文件
主要有五张表
- t_goods 保存了所有商品列表
- t_order 保存订单信息
- t_seckill_goods 将秒杀的商品列为新的一张表,因为商品会有各种优惠活动,如果在商品表新建字段不好维护,或者秒杀渠道和不秒杀渠道可能同时开启,所以新建一张有利于维护秒杀商品
- t_seckill_order 存储秒杀订单
- t_user 用户表
并且插入一些数据用于测试
/*
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 压测方法
- 进入 util 包下的UserUtil中将getConn() 方法中改为自己的数据库连接
- 先启动SpringBoot项目,再运行UserUtil的main方法,会在创建一个config.txt文件,并且创建很多个user用户在数据库。
- 再进行如下配置,即可进行压测
配置线程组
配置Http请求地址
选中生成的config.txt如图配置用户信息
配置cookie的管理器
配置秒杀地址
2. 解决超卖
2.1 唯一索引
将user_id 和 goods_id 设置为唯一索引,防止同一个用户重复抢购
2.2 Redis 预减
// 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才减少库存
boolean seckillGoodsResult = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
.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并没有压倒性的优势
-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
-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 中配置一些参数,例如
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请求,使用长连接,减少握手挥手的消耗
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<ConfigurableWebServerFactory> {
@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中取,如果还没有则去查询数据库。
/**
* 跳转商品列表
*
* 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<GoodsVo> 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中
public class SeckillGoodsController implements InitializingBean {
. . .
/**
* 系统初始化的时候将商品库存数量加载到redis
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
List<GoodsVo> 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
/**
* 判断库存是否已经是空
*/
private Map<Long, Boolean> 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方法获取结果
@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 既任务失败或者没有发送成功则将库存加上一
/**
* 发送秒杀信息
* @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,触发回调中的逻辑
@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中获取秒杀路径,再通过拼接秒杀路径进行秒杀
@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进行处理逻辑
@AccessLimit(second = 5, maxCount = 5, needLogin = true)
- 同样也可以采用令牌桶的方式解决