秒杀的设计与实践

秒杀在电商中是一个很常见的场景。所谓“秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式
从技术角度出发,秒杀是指在短时间内,大量用户并发请求去争夺有限数量的产品。

一.考虑的问题

对于秒杀,设计时要考虑哪些问题呢?

1. 超卖

何谓超卖?即指卖出的商品超出了设定的库存。秒杀场景下,商品的价格一般是特别低的,本身就是亏本,吸引客户。一旦出现超卖,会造成大量经济损失。所以超卖可谓第一个要注意点。

2.高并发

秒杀都是数秒内出现大量请求,如果这些请求都到数据库,很容易造成奔溃。故而如何设计避免出现这种状态很重要。

3.接口防刷

接口防刷主要指有大量恶意请求秒杀接口,导致服务器反应不过来。这里包括脚本去抢秒杀商品或者进行本身恶意的攻击。

4. 数据库设计

数据库设计指发生秒杀业务时,影响到了其他正常的数据库业务。

二.秒杀设计

对于秒杀请求,类似于漏斗处理,层层过滤,最终实现少量数据入库的过程。
在这里插入图片描述

1. 首先是分流

在现实中,硬件设备是有限的,单个服务器所能处理的请求也是有限的,故而分流是首先要做的。这里的分流采用冷热分离的思想,主要包括如下几点:

  1. 页面数据缓存:
  • 参与秒杀商品信息是固定的,可以预加载数据到缓存中
  • 对于静态的页面资源例如html,图片等可通过CDN进行加速
    这部分操作主要是为了是加快’冷’数据的响应,减少客户等待时间
  1. 动态化秒杀地址
    这部分操作是为了避免提前暴露秒杀地址。如果是一个固定的地址,只要稍微懂些技术的人员就可以找到地址,提前进行发起请求。当然在点击秒杀按钮之后,要对按钮进行置灰操作,避免客户频繁点击,这个很简单但很实用。

示例代码如下:

  public String getSkillUrl(Long activityId) {
       ValueOperations ops = redisTemplate.opsForValue();
       String raw = (String) ops.get(String.format(ACTIVITY_KEY, activityId));
       if (!StringUtils.hasText(raw)) {
           return "";
       }
       ActivityDO activityDO = JSON.parseObject(raw, ActivityDO.class);
       LocalDateTime now = LocalDateTime.now();
       // 做些校验,这里简单做个时间校验
       if (now.isBefore(activityDO.getStartTime()) || now.isAfter(activityDO.getEndTime())) {
           log.info("活动还未开始");
           return "";
       }
       // 获取加密秘钥
       String url = SecretUtil.md5Encode(String.valueOf(activityDO.getGoodsId()), SKILL_SALT);
       ops.set(String.format(STOCK_URL_KEY,url),"1",60, TimeUnit.SECONDS);
       return url;
   }

虽然动态秒杀地址避免了直接泄露,但是这个还是无法避免脚本的攻击
3. nginx进行分流
这层不仅仅进行请求的分流,也可以对一些ip进行限制,一定程度减少攻击。
4. 服务单一化
单独分离处秒杀服务进行部署,避免因为秒杀导致其他服务访问出现问题。

2. 接着是限流

限流操作时为了保证流量始终在服务器能接受的合理范围,超出可以执行一些熔断,风控策略。当然这一层是处理接口防刷最关键的地方。

  1. 网关
    网关这层既可以做到流量的分发,限制,还可以进行服务降级保护的操作。
  2. 限流中间件
    限流中间件分布式场景下,Sentinel还是很好用的,基本能满足常见场景。对于单机场景可以使用Guava工具包RateLimiter工具类。
3. 然后是秒杀校验

一般到这一步,并发数已处于一个合理的范围了主要做一些基础校验,然后把秒杀到的信息发给MQ。这里处理缓存库存时,要获取全局锁,这一步主要是防止超卖。
大致需要的校验如下:

  • 校验动态地址获取的凭证
  • 活动时间是否结束
  • 库存数是否足够
  • 其他额外的业务校验

示例代码如下:

    public Boolean skill(String url,Long goodsId) {
       // 校验动态地址
       if (!redisTemplate.hasKey(String.format(STOCK_URL_KEY, url))) {
           return Boolean.FALSE;
       }

       // 获取全局锁
       RLock lock = redissonClient.getLock(String.format("skill:lock:%d", goodsId));
       // 执行其他基础校验,例如身份等
       ValueOperations ops = redisTemplate.opsForValue();
       String key = String.format(STOCK_KEY, goodsId);
       if (!lock.tryLock()) {
           return Boolean.FALSE;
       }
       try {
           // 校验库存,扣减库存
           Integer num = (Integer) ops.get(key);
           if (num <= 0) {
               return false;
           }
           ops.decrement(key);
       }finally {
           lock.unlock();
       }
       // 这里随机生成用户id,正常为获取登录的用户
       Long userId = (long) new Random().nextInt(1000);
       // 发送消息队列,生成预订单,扣减库存等操作
       messageService.sendSkillInfo(new MsgDTO(goodsId,userId));
       return Boolean.TRUE;
   }

上例中存在一个问题就是,就是同个key全局锁扣减库存会造成一个性能瓶颈,其他请求将会等待获取到锁的线程处理完才能继续。
在这里插入图片描述
优化方案: 分段,去中心化的分布锁。
以key+库存数的方式获取全局锁,类似下图方式
在这里插入图片描述
每个请求来临,先扣减库存,如果库存不足,执行补偿操作。每个请求都锁一个库存数,执行失败进行补偿操作。

 public Boolean skill(String url,Long goodsId) {
        // 校验动态地址
        if (!redisTemplate.hasKey(String.format(STOCK_URL_KEY, url))) {
            return Boolean.FALSE;
        }
        // 执行其他基础校验,例如身份等
        ValueOperations ops = redisTemplate.opsForValue();
        String key = String.format(STOCK_KEY, goodsId);
        // 扣减库存,返回扣减之后的数值
        Long decrement = ops.decrement(key);
        // 小于0表示,库存不足,执行补偿操作
        if (decrement < 0) {
            ops.increment(key);
            return Boolean.FALSE;
        }
        // 获取全局锁
        RLock lock = redissonClient.getLock(String.format("skill:lock:%d:%d", goodsId,decrement));
        // 尝试加锁,加锁失败执行补偿操作
        if (!lock.tryLock()) {
            ops.increment(key);
            return Boolean.FALSE;
        }
        try {
          // 执行业务操作
        }finally {
            lock.unlock();
        }
        // 这里随机生成用户id,正常为获取登录的用户
        Long userId = (long) new Random().nextInt(1000);
        // 发送消息队列,生成预订单,扣减库存等操作
        messageService.sendSkillInfo(new MsgDTO(goodsId,userId));
        return Boolean.TRUE;
    }
4. 最后是MQ以及数据落库
  • MQ方面
    要注意消息的持久化避免消息丢失,另外要考虑消费者的处理能力,避免消息积压过多。通常MQ都采用的推模式,有消息就会推给消费者,如果消息过多,消费者的压力会很大,那时可以考虑采用拉模式,定量获取MQ的数据。采用拉模式就得靠考虑时效问题了,每次拉多少,隔多久拉一次。
  • 消费者
    到这里具体处理消息,生成预订单,扣减库存。这里要考虑超时之后库存的恢复。

三.总结

本文主要对秒杀进行回顾,设计核心还是超卖和高并发,动静分离处理是关键思想。
后续有新思路继续补充。示例demo地址

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值