Java-秒杀系统的设计

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_35704236/article/details/81262185

Java-秒杀系统的设计

1 缘起

经常看到 某宝, 某东, 还有各种平台的秒杀 活动, 觉得很想学习一下秒杀技术,也顺便学习在 在高并发下系统的设计,于是学习了慕课网的秒杀教程。 这里写博客记录一下。

2 思路 & 实现

2.1 数据库

因为秒杀商品的经常变动所以设计了

  • 秒杀商品表
CREATE TABLE `miaosha_goods` (
  `id` bigint(20) NOT NULL,
  `goods_id` bigint(20) NOT NULL,
  `miaosha_price` decimal(10,2) NOT NULL,
  `stock_count` int(11) NOT NULL,
  `start_date` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP,
  `end_date` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

  • 秒杀订单表
CREATE TABLE `miaosha_order` (
  `id` bigint(20) NOT NULL,
  `user_id` bigint(20) NOT NULL,
  `order_id` bigint(20) NOT NULL,
  `goods_id` bigint(20) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.2 前端

2.2.1 前后端分离

这里不推荐后端模板引擎的原因是因为 模板需要后端渲染 , 生成页面,即使有缓存服务端的压力也过大。

2.2.2 尽量的缓存前端 页面,压缩js

主要手段:1 cdn , 2 nginx 的缓存,3 使用 压缩后的js ,4 开启 g-zip


2.3 服务端接口

2.3.1 对象缓存

通过 redis 缓存秒杀商品列表页, 和详情页的数据 ,查询出数据后交给前端模板引擎渲染

2.3.2 redis 预读库存 (重要)

  • 1 启动启动的时候 将需要秒杀商品的库存读入 redis 缓存,并且放入 秒杀是否结束的标志
    1 实现 InitializingBean 接口 重写 afterPropertiesSet 方法(在默认构造方法执行完之后执行)


    /**
     * 系统初始化
     * */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> goodsList = goodsService.listGoodsVo();
        if(goodsList == null) {
            return;
        }
        for(GoodsVo goods : goodsList) {
            redisService.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
            // 如果是分布式系统 可以交给 redis 去做
            localOverMap.put(goods.getId(), false);
        }
    }
  • 2 用户下单的时候 decr 减少 redis 中的库存(原子性操作) , 如果库存不足,则返回秒杀失败

2.3.3 使用 rabbitMq 进行异步下单

1 构建秒杀消息并且通过 mq 发送, 然后同步返回 排队中

@Autowired
MQSender sender;

... 省略业务代码

MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排队中

1.1 发送者的简单实现

@Service
public class MQSender {

    @Autowired
    AmqpTemplate amqpTemplate ;

    public void sendMiaoshaMessage(MiaoshaMessage mm) {
        String msg = RedisService.beanToString(mm);
        log.info("send message:"+msg);
        amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
    }
}

2 使用 定义 reciver 处理消息(监听指定队列, 接收消息)

@RabbitListener(queues=MQConfig.MIAOSHA_QUEUE)
public void receive(String message) {
    log.info("receive message:"+message);
    MiaoshaMessage mm  = RedisService.stringToBean(message, MiaoshaMessage.class);
    MiaoshaUser user = mm.getUser();
    long goodsId = mm.getGoodsId();

    GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    int stock = goods.getStockCount();
    if(stock <= 0) {
        return;
    }
    //判断是否已经秒杀到了
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    if(order != null) {
        return;
    }
    //减库存 下订单 写入秒杀订单
    miaoshaService.miaosha(user, goods);
}

3 使用 事物 保证 秒杀操作的数据一致性

这里默认的隔离级别,使用了 行锁(独占锁) 保证库存不会卖超

@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
    //减库存 下订单 写入秒杀订单, 
    boolean success = goodsService.reduceStock(goods);
    if(success) {
        //order_info maiosha_order
        return orderService.createOrder(user, goods);
    }else {
        setGoodsOver(goods.getId());
        return null;
    }
}

4 客户端的处理

客户端 接收到秒杀接口的返回后,判断是否成功, 如果失败 直接提示给用户 秒杀失败 如果返回排队中, 则调用查询接口查询秒杀结果

public long getMiaoshaResult(Long userId, long goodsId) {
    // 通过 redis 查询 该用户是否秒杀了指定产品
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
    if(order != null) {//秒杀成功
        return order.getOrderId();
    }else {
        // 获取该商品是否秒杀完的内存标记, 建议使用 redis
        boolean isOver = getGoodsOver(goodsId);
        if(isOver) {
            return -1;
        }else {
            // 返回处理中 客户端隔一段时间以后继续发起查询
            return 0;
        }
    }
}

2.4 其他优化手段

2.4.1 秒杀验证码

通过验证码 可以有效分散用户请求,大大降低系统瞬间的并发, 大概思路就是 创建一个验证码图片,写给客户端,并且在服务端保存结果

@RequestMapping(value="/verifyCode", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaVerifyCod(HttpServletResponse response,MiaoshaUser user,
        @RequestParam("goodsId")long goodsId) {
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    try {
        BufferedImage image  = miaoshaService.createVerifyCode(user, goodsId);
        OutputStream out = response.getOutputStream();
        ImageIO.write(image, "JPEG", out);
        out.flush();
        out.close();
        return null;
    }catch(Exception e) {
        e.printStackTrace();
        return Result.error(CodeMsg.MIAOSHA_FAIL);
    }
}

2.4.2 隐藏秒杀地址

通过动态的秒杀地址,并且在商品开始秒杀之前 无法获取, 增加别人的破解难度

@AccessLimit(seconds=5, maxCount=5, needLogin=true)
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletRequest request, MiaoshaUser user,
        @RequestParam("goodsId")long goodsId,
        @RequestParam(value="verifyCode", defaultValue="0")int verifyCode
        ) {
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
    if(!check) {
        return Result.error(CodeMsg.REQUEST_ILLEGAL);
    }
    String path  =miaoshaService.createMiaoshaPath(user, goodsId);
    return Result.success(path);
}

2.4.3 通过自定义注解限流

  • 1 定义注解 @AccessLimit(seconds=5, maxCount=5, needLogin=true)
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
    int seconds();
    int maxCount();
    boolean needLogin() default true;
}

-2 通过 ThreadLocal 来保证每一个线程 持有一个秒杀对象

public class UserContext {

    private static ThreadLocal<MiaoshaUser> userHolder = new ThreadLocal<MiaoshaUser>();

    public static void setUser(MiaoshaUser user) {
        userHolder.set(user);
    }

    public static MiaoshaUser getUser() {
        return userHolder.get();
    }

}
  • 3 通过 HandlerInterceptorAdapter 实现接口的限流

大概思路 通过方法拦截,获取方法上面的自定义注解, 然后 根据业务逻辑 自定义限流规则

@Service
public class AccessInterceptor  extends HandlerInterceptorAdapter{

    @Autowired
    MiaoshaUserService userService;

    @Autowired
    RedisService redisService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if(handler instanceof HandlerMethod) {
            MiaoshaUser user = getUser(request, response);
            UserContext.setUser(user);
            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 = request.getRequestURI();
            if(needLogin) {
                if(user == null) {
                    render(response, CodeMsg.SESSION_ERROR);
                    return false;
                }
                key += "_" + user.getId();
            }else {
                //do nothing
            }
            AccessKey ak = AccessKey.withExpire(seconds);
            Integer count = redisService.get(ak, key, Integer.class);
            if(count  == null) {
                 redisService.set(ak, key, 1);
            }else if(count < maxCount) {
                 redisService.incr(ak, key);
            }else {
                render(response, CodeMsg.ACCESS_LIMIT_REACHED);
                return false;
            }
        }
        return true;
    }

    private void render(HttpServletResponse response, CodeMsg cm)throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        OutputStream out = response.getOutputStream();
        String str  = JSON.toJSONString(Result.error(cm));
        out.write(str.getBytes("UTF-8"));
        out.flush();
        out.close();
    }

    private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response) {
        String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
        String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
        if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            return null;
        }
        String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
        return userService.getByToken(response, token);
    }

    private String getCookieValue(HttpServletRequest request, String cookiName) {
        Cookie[]  cookies = request.getCookies();
        if(cookies == null || cookies.length <= 0){
            return null;
        }
        for(Cookie cookie : cookies) {
            if(cookie.getName().equals(cookiName)) {
                return cookie.getValue();
            }
        }
        return null;
    }

}
  • 4 新增参数解析者
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    MiaoshaUserService userService;

    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> clazz = parameter.getParameterType();
        return clazz==MiaoshaUser.class;
    }

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return UserContext.getUser();
    }
}
  • 5 注册拦截器和参数解析者
@Configuration
public class WebConfig  extends WebMvcConfigurerAdapter{

    @Autowired
    UserArgumentResolver userArgumentResolver;

    @Autowired
    AccessInterceptor accessInterceptor;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(userArgumentResolver);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(accessInterceptor);
    }

}

3 结束

搞完收工,如果你喜欢博主的文章的话麻烦点一下关注,如果发现博主文章的错误的话 麻烦指出, 谢谢大家。

猜你喜欢

转载自blog.csdn.net/qq_35704236/article/details/81262185