商城秒杀系统个人总结(浅析)

444 阅读7分钟

本文只是对个人所写的秒杀系统项目一些总结,并没有进行细致解析,如有错误之处恳请指教

一.涉及技术

SSM,Spring-boot,redis,RocketMQ,nginx

二.秒杀系统技术架构

整体流程如下:

红框内容表示该项目与之前项目(简历版本)不同之处

整体流程描述以及与之前版本不同处:

  1. 登录,校验用户名密码,生成唯一的token,token为key,value为用户信息,存入redis

(之前早期版本采用过springSession组件甚至更早版本采用过nginx+hash分组方法来解决session一致性问题,就原理而言SpringSession与token都是将信息存储到第三方数据库中,两者要比nginx+hash分组策略好)

  1. 从本地缓存GuavaCache,redis或者mysql中批量获取商品基本信息,单击商品进入商品详情页面

  2. 从redis或者mysql中获取商品详情信息

  3. 查看当前商品状态是否处于秒杀活动中,调用下单接口,下单请求被nginx拦截,查看当前用户请求频率是否异常

    (与之前早期版本相比,早期版本是将下单+支付整合为一个流程,相当于原子操作,目前改为了下单+支付两个页面为后续项目扩大了改进空间比如取消订单等场景)

  4. 创建订单成功,调用秒杀接口,查询redis中商品库存状态并尝试获取由RateLimiter组件产生的令牌,如果商品库存为空或者令牌获取失败则返回秒杀失败

  5. 消息入队,发送异步消息成功后直接返回给前端订单号

  6. 消费者端消费消息,包含创建支付订单以及库存扣减两个操作,这两个操作在一个事务中,如果其中一个操作失败的话则回滚并且消费者端捕获异常操作redis将该订单号状态置为fail

    (早期版本在这里采用的是RocketMQ事务性消息,将"支付订单创建"和"库存扣减"这两个操作分别放入本地事务和消费者那端,如果本地事务"支付订单创建"失败则进行回滚并且不会进行库存扣减,但这样做的缺点是无法保证"库存扣减"是否成功,并且早期版本还有redis库存预减操作,大大增加了保持系统缓存一致性的难度,目前所采用的策略是redis只用来读取数据并且将"支付订单创建"和"库存扣减"这两个操作合并为一个事务操作)

  7. 前端在收到订单号之后定时查询redis中该订单号状态,如果成功则返回下单成功,反之则返回下单失败

三.设计原则

秒杀系统的一大难点就是瞬时高并发流量的挑战 ,高并发指的是同一时刻有大量的用户请求到达服务器,服务器需要对请求进行处理,并及时返回响应信息。通过有限的服务器资源,尽可能快速地处理尽可能多的网络请求,是一个值得深入研究与探讨的话题。 对于该系统的优化思路,总结起来有以下几点:

  1. 限流:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。
  2. 缓存:热点数据都从缓存获得,尽可能减小数据库的访问压力;
  3. 异步:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。
  4. 分流:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。

其他:

  1. 数据库中数据量过大时需考虑分库分表

  2. 考虑系统的可扩展性

    ...

四.表设计

五.技术点总结

  1. 分布式session

    最早期版本为图方便采用的时nginx+hash策略来实现多级部署环境下分布式session一致性,原理就是通过对请求过来的ip地址进行hash然后与web应用服务器数量取模选定一个特定服务器来处理该ip的请求,这样做的优点是方便简单,缺点是session存储在服务器占用服务器内存,并且同一区段的ip请求数较多的话可能都会将请求打在同一台web应用服务器上无法达到负载均衡,或者服务器宕机后请求打在其他服务器上可该服务器并没有储存session。

    中期(简历版本)采用的是SpringSession组件,大致原理时是通过对IOC容器注入RedisHttpSessionConfiguration 这个Bean,该Bean创建了一个名称为 springSessionRepositoryFilter的bean ,该Bean被添加到FilterChain中,在请求打过来时会对request进行拦截并封装,然后将会话信息保存至第三方服务中,如redis,mysql中等。web应用服务器之间通过连接第三方服务来共享数据,实现Session共享。

    目前版本采用的是存储Token来保存用户信心,原理与SpringSession组件基本一致。

    //用户登录,返回token
        public String login(HttpServletResponse resp, UserVO userVO) throws BusinessException {
            if (userVO==null)throw new BusinessException(EmBusinessError.USER_NOT_EXIT,"用户信息为空");
            String phone = userVO.getPhone();
            String password = userVO.getPassword();
            UserDO userFromDB = userDOMapper.selectByPhone(phone);
            if (userFromDB==null)throw new BusinessException(EmBusinessError.USER_NOT_EXIT);
            //验证密码
            String passwordDB = userFromDB.getPassword();
            if (!StringUtils.equals(password,passwordDB)){
                throw new BusinessException(EmBusinessError.USER_MESSAGE_ERROR);
            }else {
                //生成token
                String token = UUIDUtil.uuid();
                userVO.setUserId(userFromDB.getUserId());
                //保存至redis中
                redisTemplate.opsForValue().set(token,userVO);
                redisTemplate.expire(token,1,TimeUnit.HOURS);
                return token;
            }
        }
    
  2. 用户下单频率限频

    通过采用nginx+Redis来限制IP访问频率,通过安装以nginx为核心同时包含多个第三方模块的Web应用服务器OpenResty来完成。

    local ip_block_time=300 --封禁IP时间(秒)
    local ip_time_out=30    --指定ip访问频率时间段(秒)
    local ip_max_count=20 --指定ip访问频率计数最大值(秒)
    local BUSINESS = ngx.var.business --nginx的location中定义的业务标识符
    
    --连接redis
    local redis = require "resty.redis"  
    local conn = redis:new()  
    ok, err = conn:connect("*.*.*.*", 6379)  
    conn:set_timeout(2000) --超时时间2秒
    
    --如果连接失败,跳转到脚本结尾
    if not ok then
        goto FLAG
    end
    
    --查询ip是否被禁止访问,如果存在则返回403错误代码
    is_block, err = conn:get(BUSINESS.."-BLOCK-"..ngx.var.remote_addr)  
    if is_block == '1' then
        ngx.exit(403)
        goto FLAG
    end
    
    --查询redis中保存的ip的计数器
    ip_count, err = conn:get(BUSINESS.."-COUNT-"..ngx.var.remote_addr)
    
    if ip_count == ngx.null then --如果不存在,则将该IP存入redis,并将计数器设置为1、该KEY的超时时间为ip_time_out
        res, err = conn:set(BUSINESS.."-COUNT-"..ngx.var.remote_addr, 1)
    	res, err = conn:expire(BUSINESS.."-COUNT-"..ngx.var.remote_addr, ip_time_out)
    else
        ip_count = ip_count + 1 --存在则将单位时间内的访问次数加1
      
        if ip_count >= ip_max_count then --如果超过单位时间限制的访问次数,则添加限制访问标识,限制时间为ip_block_time
            res, err = conn:set(BUSINESS.."-BLOCK-"..ngx.var.remote_addr, 1)
            res, err = conn:expire(BUSINESS.."-BLOCK-"..ngx.var.remote_addr, ip_block_time)
    	else
            res, err = conn:set(BUSINESS.."-COUNT-"..ngx.var.remote_addr,ip_count)
    		res, err = conn:expire(BUSINESS.."-COUNT-"..ngx.var.remote_addr, ip_time_out)
        end
    end
    
    -- 结束标记
    ::FLAG::
    local ok, err = conn:close()
    
  3. 下单按钮置灰,实时显示秒杀活动状态

    function reLoad() {
            $("#name").text(item.name);
            $("#itemPrice").text(item.itemsPrice);
            $("#promoPrice").text(item.promoPrice);
            $("#stock").text(item.stock);
            $("#note").text(item.note);
            $("#img").attr("src",item.imgUrl);
            var formatST=Common.formatTime(item.startDate,'yyyy-MM-dd HH:mm:ss');
            var startDate = new Date(formatST).valueOf();
            var nowDate = Date.parse(new Date());
            var delta = (nowDate-startDate)/1000;
            if(delta<0){
                //秒杀活动还未开始,按钮无法点击
                $("#promo").text("秒杀活动将于:"+formatST+"开始,倒计时:"+(-delta)+"秒");
                $("#order").attr("disabled",true);
            }else {
                $("#promo").text("秒杀活动正在进行中!");
                $("#order").attr("disabled",false);
            }
        }
    
  4. 创建订单号

    //创建订单,返回订单号
        @Transactional(propagation = Propagation.REQUIRED)
        public OrderVO order(Integer itemId, Integer amount, UserVO user, ItemDetailVO item){
            OrderDO order=new OrderDO();
            String num = generateOrderNum();
            order.setAmount(amount);
            order.setItemId(itemId);
            order.setOrderPrice(item.getPromoPrice());
            order.setUserId(user.getUserId());
            order.setOrderId(num);
            orderDOMapper.insertSelective(order);
            OrderVO orderVO=new OrderVO();
            orderVO.setPhone(user.getPhone());
            orderVO.setStatus(0);
            orderVO.setImgUrl(item.getImgUrl());
            orderVO.setPromoPrice(item.getPromoPrice());
            orderVO.setOrderDate(new Date());
            orderVO.setOrder(num);
            orderVO.setUserId(user.getUserId());
            orderVO.setItemId(order.getItemId());
            return orderVO;
        }
    
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public String generateOrderNum(){
            StringBuilder stringBuilder=new StringBuilder();
            //订单号16位
            //前八位为年月份yyyyMMdd,
            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
            String s1 = simpleDateFormat.format(new Date()).replace("-", "");
            stringBuilder.append(s1);
            //中六位为依次递增序列
            //依次递增的操作通过创建一个sequence表来完成
            Sequence order_info = sequenceMapper.selectByName("order_info");
            Integer currentValue = order_info.getCurrentValue();
            order_info.setCurrentValue(order_info.getCurrentValue()+order_info.getStep());
            sequenceMapper.updateByPrimaryKey(order_info);
            String str = String.valueOf(currentValue);
            for (int i=0;i<6-str.length();i++){
                stringBuilder.append(0);
            }
            stringBuilder.append(str);
            //后两位为分库分表位,暂且定死
            stringBuilder.append("00");
            return stringBuilder.toString();
        }
    
  5. redis维护商品库存状态,获取秒杀令牌通过消息队列进行异步下单

    @ResponseBody
        @RequestMapping(value = "/pay",method = RequestMethod.POST)
        public CommenReturnType pay(@RequestParam("userId")int userId,@RequestParam("token")String token) throws BusinessException, InterruptedException {
            OrderVO order = (OrderVO) redisTemplate.opsForValue().get("order_" + userId);
            //用户校验
            //...
    
            //令牌桶限流
            if (!rateLimiter.tryAcquire(1000,TimeUnit.MILLISECONDS)){
                throw new BusinessException(EmBusinessError.SKILL_FAIL);
            }
            //查看redis中商品库存清空标记
            Long stock = (Long) redisTemplate.opsForValue().get(order.getItemName() + "_stock");
            if (stock==null){
                //库存key失效,分布式锁操作数据库避免缓存雪崩
                //(这里采用分布式锁欠缺考虑,高并发情况下分布式锁太耗性能,后期考虑删除更换策略)
                try {
                    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", token,5,TimeUnit.SECONDS);
                    if (lock){
                        ItemDetailVO item = itemService.getItemById(order.getItemId());
                        if (item.getStock()>0){
                            //如果库存大于0放入redis中
                            redisTemplate.opsForValue().set(order.getItemName()+"_stock",item.getStock());
                        }else {
                            redisTemplate.opsForValue().set(order.getItemName()+"_stock",-1);
                        }
                    }else {
                        //sleep一秒然后继续查询reids中商品库存,防止请求直接打在mysql上
                        Thread.sleep(1000);
                        Long newStock = (Long) redisTemplate.opsForValue().get(order.getItemName() + "_stock");
                        if (newStock==null)throw new BusinessException(EmBusinessError.SKILL_FAIL_R);
                        else if (newStock==-1)throw new BusinessException(EmBusinessError.SKILL_FAIL);
                    }
                }finally {
                    if (token.equals(redisTemplate.opsForValue().get("lock")))redisTemplate.delete("lock");
                }
            }else if (stock==-1) {
                throw new BusinessException(EmBusinessError.SKILL_FAIL);
            }
    //        //预减库存(已去除,目前版本redis只用于读)
    //        Long stock = redisTemplate.opsForValue().decrement(order.getItemName() + "_stock");
    //        if (stock<0){
    //            overMap.put(order.getItemName()+"_stock",true);
    //            throw new BusinessException(EmBusinessError.SKILL_FAIL);
    //        }
            //减库存操作,该处操作为RocketMQ异步下单操作
            try {
                payService.pay(order.getOrder(),userId,order.getItemId());
            } catch (Exception e) {
    //            redisTemplate.opsForValue().increment(order.getItemName()+"_stock");
                return CommenReturnType.create("商品秒杀失败,请重新下单","fail");
            }
            return CommenReturnType.create(null);
        }
    
    
  6. 消息队列消费端消息消费

    @Override
        public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
            for (int i=0;i<msgs.size();i++){
                MessageExt messageExt = msgs.get(i);
                Integer id;
                Integer userId;
                String order="";
                try {
                    //获取itemID等数据进行数据库减库存
                    String messageBody = new String(messageExt.getBody(), RemotingHelper.DEFAULT_CHARSET);
                    logger.info("MQ:consumer接收消息成功:"+messageExt.getMsgId()+messageExt.getQueueId()+"消息为"+messageBody);
                    Map<String,Object> map = JSON.parseObject(messageBody, Map.class);
                    id = (Integer) map.get("item_stock_id");
                    userId = (Integer) map.get("user_id");
                    order = (String) map.get("order");
                    //数据库实行商品库存减库存操作以及支付订单创建操作
                    //该操作为一个事务操作
                    payService.pay(order,id,userId);
                    //消费成功的话在redis中对该订单添加秒杀成功标记
                    redisTemplate.opsForValue().set(order,"success");
                } catch (Exception e) {
                    //消费异常的话在redis中对该订单添加秒杀失败标记
                    redisTemplate.opsForValue().set(order,"fail");
                    logger.error("MQ:接收消息异常"+e);
                }
            }
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
    

    整体流程大致如上,还有很多细节以及问题还未解决比如redis集群搭建,nginx本地缓存,负载均衡,多机部署过程中所出现的问题以及redis与数据库之间的缓存一致性的问题,先挖些坑后续再来填吧。

    参考资料:

    juejin.cn/post/684490…

    mp.weixin.qq.com/s/0ce-W6w4h…

    blog.csdn.net/u010648555/…

    juejin.cn/post/684490…