写在前面
最近参考github上的著名java秒杀项目,自己写了一个高并发秒杀商品项目,项目涉及springboot、redis、rabbitmq等,实现了异步下单还有安全防范等一些功能,并对优化前后做了性能对比。
参考项目链接:https://github.com/qiurunze123/miaosha
参考慕课课程链接:https://coding.imooc.com/class/168.html ( ps: miaosha项目的基础思路也是来自该课程)
我实现的miaosha链接:https://gitee.com/linfinity29/miaosha.git(我自己只实现了后端接口,采用的包和编码思路都与参考项目有一定差异,但总体实现思路一致,由于时间有限前端还没做,测试接口都是使用的swagger,参考项目是使用的thymleaf实现的前端,等有空我可能会用vue实现一下前端,做前后端分离。)
一、优化前项目
1.1登录
本项目登录使用的是jwt令牌登录,密码加密采用两次md5加密。
1、controller
@Resource MiaoshaUserService miaoshaUserService; @ApiOperation("登录") @PostMapping("login") public R login(@Valid LoginVO loginVO){ MiaoshaUser miaoshaUser = miaoshaUserService.login(loginVO); return R.ok().data("userInfo", miaoshaUser); }
2、service
@Transactional( rollbackFor = {Exception.class}) @Override public MiaoshaUser login(LoginVO loginVO) { String nickname = loginVO.getNickname(); String password = loginVO.getPassword(); //获取会员 QueryWrapper<MiaoshaUser> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("nickname", nickname); MiaoshaUser user= baseMapper.selectOne(queryWrapper); //用户不存在 //LOGIN_MOBILE_ERROR(-208, "用户不存在"), Assert.notNull(user, ResponseEnum.LOGIN_MOBILE_ERROR); //校验密码 //LOGIN_PASSWORD_ERROR(-209, "密码不正确"), Assert.equals(MD5Util.inputPassToDbPass(loginVO.getPassword(), user.getSalt()), user.getPassword(), ResponseEnum.LOGIN_PASSWORD_ERROR); //记录登录日志 LocalDateTime now = LocalDateTime.now(); MiaoshaUser miaoshaUser = new MiaoshaUser(); miaoshaUser.setLastLoginDate(now); miaoshaUser.setLoginCount(user.getLoginCount()+1); baseMapper.updateById(miaoshaUser); //生成token String token = JwtUtils.createToken(user.getId(), user.getNickname()); user.setToken(token); return user; }
3、MD5Util
/** * 两次md5加密 * inputPass = md5(明文密码+固定salt) * dbPass = md5(inputPass + 随机salt) */ public class MD5Util {
1.2秒杀接口
1、controller
@Resource MiaoShaService miaoShaService; /** * QPS:119 5000*10 优化前 * @return */ @ApiOperation("商品秒杀") @GetMapping("/{id}") public R miaosha(@ApiParam("商品id") @PathVariable("id") Long id, HttpServletRequest request){ String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token); OrderInfo orderInfo = miaoShaService.miaosha(id, userId); return R.ok().data("orderInfo", orderInfo); }
2、miaoShaService
@Resource GoodsService goodsService; @Resource MiaoshaGoodsService miaoshaGoodsService; @Resource MiaoshaOrderService miaoshaOrderService; @Resource MiaoShaService miaoShaService; @Resource OrderInfoService orderInfoService; @Override public OrderInfo miaosha(Long id, Long userId) { //判断库存 GoodsVO goodsVO = goodsService.getGoodsById(id); int stock = goodsVO.getStockCount(); Assert.isTrue(stock > 0, ResponseEnum.GOODS_STOCK_EMPTY); //判断是否已经秒杀到了 MiaoshaOrder order = miaoshaOrderService.getMiaoshaOrderByUserIdGoodsId(userId, id); Assert.isTrue(order == null, ResponseEnum.MIAOSHA_ORDER_EXIST); //减库存 下订单 写入秒杀订单 miaoshaGoodsService.reduceStock(id); OrderInfo orderInfo = orderInfoService.createOrder(goodsVO, userId); return orderInfo; }
3、orderInfoService
@Resource
MiaoshaOrderService miaoshaOrderService;
@Override
public OrderInfo createOrder(GoodsVO goodsVO, Long userId) {
//插入订单信息
OrderInfo orderInfo = new OrderInfo();
orderInfo.setCreateDate(LocalDateTime.now());
orderInfo.setDeliveryAddrId(0L);
orderInfo.setGoodsCount(1);
orderInfo.setGoodsId(goodsVO.getId());
orderInfo.setGoodsName(goodsVO.getGoodsName());
orderInfo.setGoodsPrice(goodsVO.getMiaoshaPrice());
orderInfo.setOrderChannel(1);
orderInfo.setStatus(0);
orderInfo.setUserId(userId);
baseMapper.insert(orderInfo);
//插入用户订单一对一记录
MiaoshaOrder miaoshaOrder = new MiaoshaOrder();
miaoshaOrder.setGoodsId(goodsVO.getId());
miaoshaOrder.setOrderId(orderInfo.getId());
miaoshaOrder.setUserId(userId);
miaoshaOrderService.save(miaoshaOrder);
return orderInfo;
}
1.3压测
实验环境:
在本地计算机使用压测工具jmeter对接口进行并发压测
java项目运行在本地计算机(8核16线程16G内存)
其他软件,mysql、redis、rabbimq等均部署在一台腾讯云服务器(1核2G)上以确保模拟实际生产环境性能。
1、创建线程组(5000个线程共并发50000个请求)
2、参加http请求默认值
3、使用UserUtil创建5000个用户,并登录获取token
/** * 该类用于生成user,并登录拿到token,把token存储下来,用于jmeter压测 */ public class UserUtil { private static void createUser(int count) throws Exception{
4、在请求头添加动态token
5、添加聚合报告查看结果
分析:
1)可以看到QPS只有119,即在50000个并发请求下每秒只能处理119个请求
2)查看数据库可以看到库存变成了负数,说明存在超卖现象
3)设置秒杀商品库存为10个,结果卖出了420个,数据完全对不上
二、优化一
2.1防止出现库存负数
更新库存时加条件 stock_count>0
MiaoshaGoodsService
@Override public void reduceStock(Long id) { // MiaoshaGoods miaoshaGoods = baseMapper.selectById(id); // miaoshaGoods.setStockCount(miaoshaGoods.getStockCount() - 1); // baseMapper.updateById(miaoshaGoods); baseMapper.reduceStock(id); }
public interface MiaoshaGoodsMapper extends BaseMapper<MiaoshaGoods> { @Update("UPDATE miaosha_goods SET stock_count=stock_count-1 WHERE goods_id=#{id} AND stock_count>0") void reduceStock(Long id); }
miaoShaService
...... //减库存 下订单 写入秒杀订单 OrderInfo orderInfo = orderInfoService.createOrder(goodsVO, userId); ......
orderInfoService
@Transactional(rollbackFor = Exception.class) @Override public OrderInfo createOrder(GoodsVO goodsVO, Long userId) { boolean b = miaoshaGoodsService.reduceStock(goodsVO.getId()); Assert.isTrue(b, ResponseEnum.GOODS_STOCK_EMPTY); //插入订单信息 OrderInfo orderInfo = new OrderInfo(); ........
2.2、防止一个人重复下单
数据库加唯一索引,service添加事务
miaosha_order表
2.3、优化效率
判断是否下过单时从redis判断
//判断是否已经秒杀到了 MiaoshaOrder miaoshaOrder = (MiaoshaOrder) redisTemplate.opsForValue().get("miaoshaOrder:" + userId + "_" + id); if (miaoshaOrder == null){ miaoshaOrder = miaoshaOrderService.getMiaoshaOrderByUserIdGoodsId(userId, id); if(miaoshaOrder != null){ redisTemplate.opsForValue().set("miaoshaOrder:" + userId + "_" + id, miaoshaOrder); } } Assert.isTrue(miaoshaOrder == null, ResponseEnum.MIAOSHA_ORDER_EXIST);
2.4、压测
聚合报告结果
分析:
1)可以看到QPS有109
2)查看数据库可以看到库存变成负数没有了
3)重复下单问题没有
4)下单信息只有10条,超卖问题解决
三、优化二
3.1Redis预减库存减少数据库访问
1、实现初始化bean接口
@Service public class MiaoShaServiceImpl implements MiaoShaService, InitializingBean {
2、实现初始化方法(此方法在bean的生命周期bean的自动填充后执行)
/** * 预热加载数据库库存到redis * @throws Exception */ @Override public void afterPropertiesSet() throws Exception { List<MiaoshaGoods> miaoshaGoodsList = miaoshaGoodsService.list(); if(miaoshaGoodsList == null) { return; } for(MiaoshaGoods miaoshaGoods : miaoshaGoodsList) { redisTemplate.opsForValue().set("miaoshaGoodsStock:"+miaoshaGoods.getId(), miaoshaGoods.getStockCount()); localOverMap.put(miaoshaGoods.getId(), false); } }
3、预减库存
//预减库存 long stock = redisTemplate.opsForValue().decrement("miaoshaGoodsStock:"+id);//10 if(stock < 0) { localOverMap.put(id, true); throw new BusinessException(ResponseEnum.GOODS_STOCK_EMPTY); }
3.2使用内存标记减少Redis访问
MiaoShaServiceImpl
//本地内存标记秒杀商品是否售空 private HashMap<Long, Boolean> localOverMap = new HashMap<Long, Boolean>();
//内存标记,减少redis访问 boolean over = localOverMap.get(id); Assert.isTrue(!over, ResponseEnum.GOODS_STOCK_EMPTY);
3.3使用RabbitMQ将请求入队缓存,异步下单,增强用户体验
1、MiaoShaServiceImpl
//秒杀请求入队 MiaoShaMessage mm = new MiaoShaMessage(); mm.setUserId(userId); mm.setGoodsId(id); sender.send(mm); return null;//排队中
2、异步完成下单
@Service @Slf4j public class MQReceiver { @Resource MiaoShaService miaoshaService; @Resource GoodsService goodsService; @Resource RedisTemplate redisTemplate; @Resource OrderInfoService orderInfoService; @RabbitListener(queues= RabbitMQConfig.QUEUE_NAME) public void receive(MiaoShaMessage mm) { log.info("======================================================"); log.info("receive message:"+mm); long userId = mm.getUserId(); long goodsId = mm.getGoodsId(); GoodsVO goodsVO = goodsService.getGoodsById(goodsId); int stock = goodsVO.getStockCount(); if(stock <= 0) { return; } //判断是否已经秒杀到了 MiaoshaOrder miaoshaOrder = (MiaoshaOrder) redisTemplate.opsForValue().get("miaoshaOrder:" + userId + "_" + goodsId); if(miaoshaOrder != null) { return; } //减库存 下订单 写入秒杀订单 orderInfoService.createOrder(goodsVO, userId); } }
3、MiaoShaController-》miaosha
@ApiOperation("商品秒杀") @GetMapping("/{id}") public R miaosha(@ApiParam("商品id") @PathVariable("id") Long id, HttpServletRequest request){ String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token); OrderInfo orderInfo = miaoShaService.miaosha(id, userId); if(orderInfo == null){//消息进队排队中。。。 return R.ok().message("正在抢购中"); } return R.ok().data("orderInfo", orderInfo); }
4、MiaoShaController-》result
@ApiOperation("获取商品秒杀结果") @GetMapping("/result/{id}") public R getMiaoshaResult(@ApiParam("商品id") @PathVariable("id") long goodsId, HttpServletRequest request) { String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token); MiaoshaOrder order = miaoshaOrderService.getMiaoshaOrderByUserIdGoodsId(userId, goodsId); if(order != null) {//秒杀成功 return R.ok().data("orderId",order.getOrderId()); }else { boolean over = miaoShaService.getGoodsOver(goodsId); if(over) { return R.error().message("抢购失败"); }else { return R.ok().message("正在抢购中"); } } }
3.4压测
1、聚合报告结果
分析:
1)可以看到QPS达到了819,相比之前增加了7倍多
2)异常率也变为了0
2)超卖现象依然没有
小结:至此,我们的接口优化大体完成,效率提升还是挺大的,而且我们的msql、redis和rabbitmq都安装在一台1核2g的服务器上,因此也会对我们的压测结果准确性造成一定影响,实际生产环境下msql、redis和rabbitmq部署在多台服务器上,性能提升会更加明显。
四、安全优化
4.1隐藏秒杀接口地址
1、新增获取path接口
@ApiOperation("获取秒杀隐藏路径") @GetMapping(value="/path") public R getMiaoshaPath( HttpServletRequest request, @ApiParam("商品Id") @RequestParam("goodsId")long goodsId) { String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token); String path = miaoShaService.createMiaoshaPath(userId, goodsId); Assert.isTrue(path!=null, ResponseEnum.MIAOSHAPATH_GEN_FAIL); return R.ok().data("path", path); }
2、修改miaosha接口
@ApiOperation("商品秒杀") @GetMapping("/{path}/{id}") public R miaosha( @ApiParam("秒杀路径") @PathVariable("path") String path, @ApiParam("商品id") @PathVariable("id") Long id, HttpServletRequest request){ String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token); //验证path boolean check = miaoShaService.checkPath(userId, id, path); Assert.isTrue(check, ResponseEnum.MIAOSHAPATH_CHECK_FAIL); 。。。。。。
3、miaoshaService
/** * 生成秒杀隐藏路径 * @param userId * @param goodsId * @return */ @Override public String createMiaoshaPath(Long userId, long goodsId) { if(goodsId <= 0) { return null; } String str = MD5Util.md5(UUID.randomUUID().toString()+"123456"); redisTemplate.opsForValue().set("miaoShaPath:"+userId + "_"+ goodsId, str); return str; } /** * 验证秒杀隐藏路径 * @param userId * @param goodsId * @return */ public boolean checkPath(Long userId, long goodsId, String path) { if(path == null) { return false; } String pathOld = (String) redisTemplate.opsForValue().get("miaoShaPath:"+userId + "_"+ goodsId); return path.equals(pathOld); }
4、测试
4.2数学公式验证码
效果:用户输入验证码并点击立即秒杀,先根据验证码获取到秒杀隐藏路径,再发送秒杀请求。相当于点击按钮后发送两次请求。
本项目为了偷懒只用随机字符串验证码,理论上越复杂的验证码校验规则越安全。
1、新增生成验证码接口
@ApiOperation("获取验证码") @GetMapping("/code/{id}") public void getMiaoShaCode( @ApiParam("商品id") @PathVariable("id") long goodsId, HttpServletRequest request, HttpServletResponse response) { String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token); //定义图形验证码的长、宽、验证码字符数、干扰线宽度 ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(200, 100, 4, 4); //把code存到redis String code = captcha.getCode(); redisTemplate.opsForValue().set("miaoShaCode:"+userId+"_"+goodsId, code); //图形验证码写出,可以写出到文件,也可以写出到流 try { captcha.write(response.getOutputStream()); } catch (IOException e) { e.printStackTrace(); } }
2、修改获取path接口
@ApiOperation("获取秒杀隐藏路径") @GetMapping(value = "/path/{goodsId}/{code}") public R getMiaoshaPath( HttpServletRequest request, @ApiParam("商品Id") @PathVariable("goodsId") long goodsId, @ApiParam("验证码") @PathVariable("code") String code) { String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token); //校验验证码 String codeOld = (String) redisTemplate.opsForValue().get("miaoShaCode:" + userId + "_" + goodsId); Assert.isTrue((code != null) && code.equals(codeOld), ResponseEnum.MIAOSHAPCODE_CHECK_FAIL); 。。。。。。
3、测试
4.3、接口防刷限流
思路:使用redis缓存对需要登录的uri路径进行限流访问,设置一段时间内最大访问次数。如10秒内最多访问8次。
实现:
1、创建一个注解,在需要限流的接口上添加注解即可,方便使用。
@Retention(RUNTIME) @Target(METHOD) public @interface AccessLimit { int seconds(); //几秒内 int maxCount(); //最大访问次数 boolean needLogin() default true; }
2、创建拦截器,使注解具有实际意义
@Service public class AccessInterceptor extends HandlerInterceptorAdapter{ @Resource MiaoshaUserService userService; @Autowired RedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if(handler instanceof HandlerMethod) {//handler可以获取很多有用信息,如拦截方法上的注解 HandlerMethod hm = (HandlerMethod)handler; AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class); if(accessLimit == null) {//如果访问的是没有添加这个注解的请求直接放行 return true; } int seconds = accessLimit.seconds(); int maxCount = accessLimit.maxCount(); boolean needLogin = accessLimit.needLogin(); String key = "miaoshaLimit:"+request.getRequestURI(); //要限流的路径 如 /api/core/miaosha if(needLogin) { Long userId = getUserId(request); if(userId == null) { render(response, R.setResult(ResponseEnum.LOGIN_AUTH_ERROR)); return false; }
//将userId放进ThreadLocal当中 UserContext.setUserId(userId); key += "_" + userId; //需要登录的接口,对userId限制访问次数 }else { key += "_" + request.getRemoteAddr(); //不需要登录的接口,对ip限制访问次数 } //统计有限时间内访问次数 Integer count = (Integer) redisTemplate.opsForValue().get(key); if(count == null) { redisTemplate.opsForValue().set(key, 1, seconds, TimeUnit.SECONDS); }else if(count < maxCount) { redisTemplate.opsForValue().increment(key); }else { render(response, R.setResult(ResponseEnum.ACCESS_LIMIT_REACHED)); return false; } } return true; } //响应结果 private void render(HttpServletResponse response, R r)throws Exception { response.setContentType("application/json;charset=UTF-8"); OutputStream out = response.getOutputStream(); String str = JSON.toJSONString(r); out.write(str.getBytes("UTF-8")); out.flush(); out.close(); } private Long getUserId(HttpServletRequest request) { String token = request.getHeader("token"); //使用不抛出异常的实现接口,以便在本拦截器响应请求 Long userId = JwtUtils.getUserIdNotException(token); return userId; } }
3、注册拦截器
WebConfig
@Configuration public class WebConfig extends WebMvcConfigurerAdapter{ @Resource AccessInterceptor accessInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(accessInterceptor); } }
4、修改controller代码
在需要登录的接口不再需要重复以下代码
String token = request.getHeader("token"); Long userId = JwtUtils.getUserId(token);
现在只需要:
1)添加注解
@AccessLimit(seconds = 10, maxCount = 8)
2)从ThreadLocal中获取userId
UserContext.getUserId()
5、测试
五、其他问题
在做这个项目过程中遇到一些与项目无关的小bug,做个笔记。
5.1找不到依赖包中的类
bug说明:明明依赖导入正确,但是启动时却报错找不到引用的包的类。
解决:在项目根目录执行
mvn idea:idea
5.2、打包时报错找不到依赖
bug说明:打包时报错找不到自己写的common包。
解决:
1)修改被被依赖common包的pom文件
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <classifier>exec</classifier> </configuration> </plugin> </plugins> </build>
2)mvn install
5.3、突然莫名其妙找不到符号log
bug说明:使用lombok包时,明明正确引入@slfj4但是启动时却报错。
解决:修改idea配置