从零搭建秒杀系统

 前言

        本文将从零开始搭建一个秒杀的后台系统,整体思路如下图所示

前置准备

  • 整体后端框架采用的是 SpringBoot + mybatis plus
  • 运用到 redis ,rabbitmq 等中间件
  • 性能测试用到了 jmeter

正文

        秒杀在生活中的应用场景还是挺多的,比如双十一抢限购商品,12306抢座,大学抢课,抢门票等等。

这些场景下,就有可能带出以下问题

  • 高并发
    • 极短的时间内,用户请求量大
  • 超卖
    • 库存 100 件,最终下单了 120 件
  • 恶意请求
    • 一些不坏好意的黑客,或者黄牛,通过脚本来模拟请求。如果是用来抢商品的,机器的请求肯定比人快,那顶多算欺负老实人;要是恶意伪造请求,造成缓存穿透,处理不好整个服务都挂了。
  • 数据库
    • 上万甚至上百万的 qps 打到数据库,如果没有做降级,限流,熔断等处理,可能影响的就不是秒杀这一个业务了。

所以在我们设计的时候,就需要根据这些问题,对症下药。

1 普通下单

        建立一个简单的场景,数据库中存有一个商品,库存为 100,用户通过下单接口来下单,不做任何限制。

public int createWrongOrder(int sid) throws Exception {
    //校验库存
    Stock stock = checkStock(sid);
    //扣库存
    saleStock(stock);
    //创建订单
    int id = createOrder(stock);
    return id;
}

        通过 jmeter 进行性能测试,设置线程数1000,模拟 1000 位用户进行请求,观察结果。

        可以看到 http 请求全部正常返回,销量只卖出了 27 单,但是订单表里添加了 1000 条记录

 

                 这就是之前提出的超卖问题。

2 下单加锁(乐观锁)

        解决上述问题,我采用上锁的方式,选择的是乐观锁

        乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

public int createOptimisticOrder(int sid) {
    //校验库存
    Stock stock = checkStock(sid);
    //乐观锁更新库存
    boolean success = saleStockOptimistic(stock);
    if (!success) {
        throw new RuntimeException("过期库存值,更新失败");
    }
    //创建订单
    int id = createOrder(stock);
    return stock.getCount() - stock.getSale();
}

        代码层面,就修改了一下更新库存的 sql

public int updateStockByOptimistic(Stock stock) {
    UpdateWrapper<Stock> wrapper = new UpdateWrapper<>();
    wrapper.lambda().eq(Stock::getId, stock.getId()).eq(Stock::getVersion, stock.getVersion());
    stock.setSale(stock.getSale() + 1);
    stock.setVersion(stock.getVersion() + 1);
    return mapper.update(stock, wrapper);
}

        翻译成 sql 语句就是

UPDATE stock
SET sale = sale + 1,
 version = version + 1
WHERE
	id = 1
AND version = 0

        继续用 jmeter 进行测试。日志中可以看到,存在大量购买失败,销售量为 47,但是订单量也为 47,说明不存在超卖的情况。

3 下单接口限流

        解决了超卖的问题,接下来需要解决高并发下带来的压力。

        因此,我们需要选择更优雅的方式来处理大量请求。

        首先是前端。

  • 页面静态化
    • 可以对页面进行静态化处理。因为前端作为秒杀活动的入口,如果把入口限制住,就能很好的达到限流的效果。到了秒杀时间点,并且用户主动点了秒杀按钮,才会访问服务端。
  • CDN 缓存
    • 到了秒杀时间点,再更新秒杀按钮。

        而作为一名后端开发,本文的重点更多的在于后端的限流。

  • 单独部署
    • 一种最常见的方式,就是单独部署,以免秒杀业务崩溃而影响其他业务系统。
  • 缓存
    • 添加缓存可以避免请求直接打到数据库。具体过程如下:根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。
    • 这也会引发其他问题
      • 缓存击穿
        • 比如某一时刻缓存失效,大量请求还是会直接打到数据库。此时可以根据实际情况,将缓存的有效期设置为不失效,并在秒杀活动开始前,对缓存进行预热。同时对数据库查询加锁
        • 缓存穿透
          • 商品id可能非法,也会导致直接访问数据库的情况。加锁可以较好的缓解这一情况,同时,我们可以使用布隆过滤器,也能很好的解决这个问题。
  • 接口限流
    • 这边以令牌桶限流算法为例
    • 代码层面,使用Guava的RateLimiter实现令牌桶限流接口
    • // 每秒放行10个请求
      private RateLimiter rateLimiter = RateLimiter.create(10);
      
      @GetMapping("/createOptimisticOrder/{sid}")
      public String createOptimisticOrder(@PathVariable int sid) {
          // 1. 阻塞式获取令牌
          log.info("等待时间" + rateLimiter.acquire());
          // 2. 非阻塞式获取令牌
          // if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
          //     log.warn("你被限流了,真不幸,直接返回失败");
          //     return "你被限流了,真不幸,直接返回失败";
          // }
          int id;
          try {
              id = stockOrderService.createOptimisticOrder(sid);
              log.info("购买成功,剩余库存为: [{}]", id);
          } catch (Exception e) {
              log.error("购买失败:[{}]", e.getMessage());
              return "购买失败,库存不足";
          }
          return String.format("购买成功,剩余库存为:%d", id);
      }
    • 两种方式获取令牌
      • 非阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,会尝试等待设置好的时间(这里写了1000ms),其会自动判断在1000ms后,这个请求能不能拿到令牌,如果不能拿到,直接返回抢购失败。如果timeout设置为0,则等于阻塞时获取令牌。
      • 阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,就在这里阻塞住,等待令牌的发放。

    jmeter 测试,采用阻塞式获取令牌,可以看到吞吐量为 10

        再看订单情况,售出了 100 个,订单也有 100 条

4 下单接口加盐 

        我们的接口可以通过抓包轻易获取到,这会给一些不法分子可乘之机。

        一个简单的做法就是给我们的接口地址加盐,即动态的生成下单地址。

        获取盐值接口

        

@GetMapping(value = "/getVerifyHash")
public String getVerifyHash(@RequestParam(value = "sid") Integer sid,
                            @RequestParam(value = "userId") Integer userId) {
    String hash;
    try {
        hash = userService.getVerifyHash(sid, userId);
    } catch (Exception e) {
        log.error("获取验证hash失败,原因:[{}]", e.getMessage());
        return "获取验证hash失败";
    }
    return String.format("请求抢购验证hash值为:%s", hash);
}

        加盐下单接口

@GetMapping(value = "/createOrderWithVerifiedUrl")
public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid,
                                         @RequestParam(value = "userId") Integer userId,
                                         @RequestParam(value = "verifyHash") String verifyHash) {
    int stockLeft;
    try {
        stockLeft = stockOrderService.createVerifiedOrder(sid, userId, verifyHash);
        log.info("购买成功,剩余库存为: [{}]", stockLeft);
    } catch (Exception e) {
        log.error("购买失败:[{}]", e.getMessage());
        return e.getMessage();
    }
    return String.format("购买成功,剩余库存为:%d", stockLeft);
}

        另外,可以限制用户下单的频率。

@GetMapping(value = "/createOrderWithVerifiedUrl")
public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid,
                                         @RequestParam(value = "userId") Integer userId,
                                         @RequestParam(value = "verifyHash") String verifyHash) {
    int stockLeft;
    try {
        int count = userService.addUserCount(userId);
        log.info("用户截至该次的访问次数为: [{}]", count);
        boolean isBanned = userService.getUserIsBanned(userId);
        if (isBanned) {
            return "购买失败,超过频率限制";
        }
        stockLeft = stockOrderService.createVerifiedOrder(sid, userId, verifyHash);
        log.info("购买成功,剩余库存为: [{}]", stockLeft);
    } catch (Exception e) {
        log.error("购买失败:[{}]", e.getMessage());
        return e.getMessage();
    }
    return String.format("购买成功,剩余库存为:%d", stockLeft);
}

        用户访问频率可以放在缓存 redis 或者 memcached 中,限制了单个用户最多抢5单,(注意是抢5单,而不是限购5单),最终发现抢到了2单(这边抢到的单数属于随机事件)

5 保证 Redis 和 数据库 数据的一致性

        对于访问量很大的“热点”数据,尤其是一些读取量远大于写入量的数据,更应该被缓存,而不应该让请求打到数据库上。

缓存的优点

  • 能够缩短服务的响应时间,给用户带来更好的体验。
  • 能够增大系统的吞吐量,依然能够提升用户体验。
  • 减轻数据库的压力,防止高峰期数据库被压垮,导致整个线上服务挂掉。

缓存的问题

  • 缓存有多种选型,你是否都熟悉,如果不熟悉,无疑增加了维护的难度。
  • 缓存系统也要考虑分布式,无疑增加了系统的复杂性。
  • 如果对缓存的准确性有非常高的要求,就必须考虑「缓存和数据库的一致性问题」。

        接下来重点讨论缓存和数据库一致性的问题

5.1 不使用更新缓存而是删除缓存

        大部分观点认为,做缓存不应该是去更新缓存,而是应该删除缓存,然后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。

        其实如果业务非常简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是可以的。但是,淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。

5.2 先删除缓存,还是先操作数据库?

方案一 先删缓存,再更新数据库

该方案会导致请求数据不一致

同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

(1)请求A进行写操作,删除缓存

(2)请求B查询发现缓存不存在

(3)请求B去数据库查询得到旧值

(4)请求B将旧值写入缓存

(5)请求A将新值写入数据库

上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

方案二 先更新数据库,再删缓存

假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生

(1)缓存刚好失效

(2)请求A查询数据库,得一个旧值

(3)请求B将新值写入数据库

(4)请求B删除缓存

(5)请求A将查到的旧值写入缓存

如果发生上述情况,确实是会发生脏数据。

发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,「数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。

所以,如果你想实现基础的缓存数据库双写一致的逻辑,那么在大多数情况下,在不想做过多设计,增加太大工作量的情况下,请 先更新数据库,再删缓存!

方案三 先删缓存,再更新数据库,过一段时间,再同步/异步 删一次缓存

(1)先淘汰缓存

(2)再写数据库(这两步和原来一样)

(3)休眠1秒,再次淘汰缓存

这么做,可以将1秒内所造成的缓存脏数据,再次删除。

6 下单异步处理

        实际秒杀过程可以分为 秒杀 - 下单 - 支付 三个步骤。大部分的流量压力是在秒杀这一步,之后的步骤完全可以异步完成。

        这时候就可以用到 rabbitmq 的流量削峰的功能了。

        代码层面也简单,新增一个 controller 接口

/**
 * 下单接口:异步处理订单
 */
@GetMapping(value = "/createUserOrderWithMq")
public String createUserOrderWithMq(@RequestParam(value = "sid") Integer sid,
                                    @RequestParam(value = "userId") Integer userId) {
    try {
        // 检查缓存中该用户是否已经下单过
        Boolean hasOrder = stockOrderService.checkUserOrderInfoInCache(sid, userId);
        if (hasOrder != null && hasOrder) {
            log.info("该用户已经抢购过");
            return "你已经抢购过了,不要太贪心.....";
        }
        // 没有下单过,检查缓存中商品是否还有库存
        log.info("没有抢购过,检查缓存中商品是否还有库存");
        Integer count = stockService.getStockCount(sid);
        if (count == 0) {
            return "秒杀请求失败,库存不足.....";
        }

        // 有库存,则将用户id和商品id封装为消息体传给消息队列处理
        // 注意这里的有库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证
        log.info("有库存:[{}]", count);
        RabbitOrderDTO dto = new RabbitOrderDTO();
        dto.setSid(sid);
        dto.setUserId(userId);
        sendToOrderQueue(JSONObject.toJSONString(dto));
        return "秒杀请求提交成功";
    } catch (Exception e) {
        log.error("下单接口:异步处理订单异常:", e);
        return "秒杀请求失败,服务器正忙.....";
    }
}

        再配置一个消费者

@Component
@RabbitListener(queues = "orderQueue")
@Slf4j
public class OrderMqListener {

    @Autowired
    private StockOrderService orderService;

    @RabbitHandler
    public void process(String message) {
        log.info("OrderMqReceiver收到消息开始用户下单流程: " + message);
        try {
            RabbitOrderDTO dto = JSONObject.parseObject(message, RabbitOrderDTO.class);
            orderService.createOrderByMq(dto.getSid(), dto.getUserId());
        } catch (Exception e) {
            log.error("消息处理异常:", e);
        }
    }
}

        异步与非异步性能对比

        非异步下单(添加乐观锁,不限流,不限购)下单成功 54 单,吞吐量大约为 145

        异步下单 100单全部卖出,吞吐量达 448

        由此可以明显感受到异步下单的优越性的

总结

        至此,我们从超卖,高并发,缓存,限流,超链接加盐等多个角度简单设计了一个秒杀系统。但是实际生产运用过程中,要考虑的东西远不止这些,像缓存击穿,缓存穿透,分布式锁的设计,mq队列消息丢失,或者重复消费的问题,都是需要根据实际情况具体问题具体分析的。实践出真知,希望有朝一日能真正运用到生产中吧。

源码地址

https://github.com/kid626/seckill

参考

【秒杀系统】从零打造秒杀系统(一):防止超卖

面试必考:秒杀系统如何设计? - 云+社区 - 腾讯云

《进大厂系列》系列-秒杀系统设计 - 知乎

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
 

)">
< <上一篇
下一篇>>