如何设计并实现一个秒杀系统?(含完整代码)

点击上方石杉的架构笔记,右上角选择“设为星标

每日早8点半,技术文章准时送上

公众号后台回复“学习”,获取作者独家秘制精品资料


640?wx_fmt=png

往期文章

BAT 面试官是如何360°无死角考察候选人的(上篇)

每秒上万并发下的Spring Cloud参数优化实战

分布式事务如何保障实际生产中99.99%高可用

记一位朋友斩获 BAT 技术专家Offer的面试经历

亿级流量架构系列之如何支撑百亿级数据的存储与计算

640?wx_fmt=png

本文来源:crossoverJie


前言

之前在 Java-Interview 中提到过秒杀架构的设计,这次基于其中的理论简单实现了一下。

本次采用循序渐进的方式逐步提高性能达到并发秒杀的效果,文章较长,请准备好瓜子板凳^_^

本文所有涉及的代码:

  • https://github.com/crossoverJie/SSM

  • https://github.com/crossoverJie/distributed-redis-tool

首先来看看最终架构图:

640?wx_fmt=png


先简单根据这个图谈下请求的流转,因为后面不管怎么改进这个都是没有变的。

  • 前端请求进入 web 层,对应的代码就是 controller

  • 之后将真正的库存校验、下单等请求发往 Service 层(其中 RPC 调用依然采用的 dubbo,只是更新为最新版本,本次不会过多讨论 dubbo 相关的细节,有兴趣的可以查看 基于dubbo 的分布式架构)

  • Service 层再对数据进行落地,下单完成

无限制

其实抛开秒杀这个场景来说正常的一个下单流程可以简单分为以下几步:

  • 校验库存

  • 扣库存

  • 创建订单

  • 支付

基于上文的架构所以我们有了以下实现:

先看看实际项目的结构:

640?wx_fmt=jpeg

还是和以前一样:

  • 提供出一个 API 用于 Service 层实现,以及 web 层消费。

  • web 层简单来说就是一个 SpringMVC

  • Service 层则是真正的数据落地。

  • SSM-SECONDS-KILL-ORDER-CONSUMER 则是后文会提到的 Kafka 消费。

数据库也是只有简单的两张表模拟下单:

CREATE TABLE `stock` (	
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,	
  `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',	
  `count` int(11) NOT NULL COMMENT '库存',	
  `sale` int(11) NOT NULL COMMENT '已售',	
  `version` int(11) NOT NULL COMMENT '乐观锁,版本号',	
  PRIMARY KEY (`id`)	
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;	
CREATE TABLE `stock_order` (	
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,	
  `sid` int(11) NOT NULL COMMENT '库存ID',	
  `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称',	
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',	
  PRIMARY KEY (`id`)	
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8;


web 层 controller 实现:

 
 
  1.    @Autowired

  2.    private StockService stockService;


  3.    @Autowired

  4.    private OrderService orderService;


  5.    @RequestMapping("/createWrongOrder/{sid}")

  6.    @ResponseBody

  7.    public String createWrongOrder(@PathVariable int sid) {

  8.        logger.info("sid=[{}]", sid);

  9.        int id = 0;

  10.        try {

  11.            id = orderService.createWrongOrder(sid);

  12.        } catch (Exception e) {

  13.            logger.error("Exception",e);

  14.        }

  15.        return String.valueOf(id);

  16.    }

其中 web 作为一个消费者调用看 OrderService 提供出来的 dubbo 服务。

Service 层, OrderService 实现:

首先是对 API 的实现(会在 API 提供出接口):

 
 
  1. @Service

  2. public class OrderServiceImpl implements OrderService {


  3.    @Resource(name = "DBOrderService")

  4.    private com.crossoverJie.seconds.kill.service.OrderService orderService ;


  5.    @Override

  6.    public int createWrongOrder(int sid) throws Exception {

  7.        return orderService.createWrongOrder(sid);

  8.    }

  9. }


这里只是简单调用了 DBOrderService 中的实现,DBOrderService 才是真正的数据落地,也就是写数据库了。

DBOrderService 实现:

 
 
  1. Transactional(rollbackFor = Exception.class)

  2. @Service(value = "DBOrderService")

  3. public class OrderServiceImpl implements OrderService {

  4.    @Resource(name = "DBStockService")

  5.    private com.crossoverJie.seconds.kill.service.StockService stockService;


  6.    @Autowired

  7.    private StockOrderMapper orderMapper;


  8.    @Override

  9.    public int createWrongOrder(int sid) throws Exception{


  10.        //校验库存

  11.        Stock stock = checkStock(sid);


  12.        //扣库存

  13.        saleStock(stock);


  14.        //创建订单

  15.        int id = createOrder(stock);


  16.        return id;

  17.    }


  18.    private Stock checkStock(int sid) {

  19.        Stock stock = stockService.getStockById(sid);

  20.        if (stock.getSale().equals(stock.getCount())) {

  21.            throw new RuntimeException("库存不足");

  22.        }

  23.        return stock;

  24.    }


  25.    private int saleStock(Stock stock) {

  26.        stock.setSale(stock.getSale() + 1);

  27.        return stockService.updateStockById(stock);

  28.    }


  29.    private int createOrder(Stock stock) {

  30.        StockOrder order = new StockOrder();

  31.        order.setSid(stock.getId());

  32.        order.setName(stock.getName());

  33.        int id = orderMapper.insertSelective(order);

  34.        return id;

  35.    }        


  36. }

预先初始化了 10 条库存。

手动调用下 createWrongOrder/1 接口发现:


库存表: 

640?wx_fmt=jpeg


订单表: 

640?wx_fmt=jpeg


一切看起来都没有问题,数据也正常。但是当用 JMeter 并发测试时:

640?wx_fmt=jpeg


测试配置是:300个线程并发,测试两轮来看看数据库中的结果:

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg


请求都响应成功,库存确实也扣完了,但是订单却生成了 124 条记录。这显然是典型的超卖现象。

其实现在再去手动调用接口会返回库存不足,但为时晚矣。


乐观锁更新

怎么来避免上述的现象呢?最简单的做法自然是乐观锁了,来看看具体实现:

其实其他的都没怎么改,主要是 Service 层。

 
 
  1.    @Override

  2.    public int createOptimisticOrder(int sid) throws Exception {


  3.        //校验库存

  4.        Stock stock = checkStock(sid);


  5.        //乐观锁更新库存

  6.        saleStockOptimistic(stock);


  7.        //创建订单

  8.        int id = createOrder(stock);


  9.        return id;

  10.    }


  11.    private void saleStockOptimistic(Stock stock) {

  12.        int count = stockService.updateStockByOptimistic(stock);

  13.        if (count == 0){

  14.            throw new RuntimeException("并发更新库存失败") ;

  15.        }

  16.    }


对应的 XML:

 
 
  1.    <update< span=""> id="updateByOptimistic" parameterType="com.crossoverJie.seconds.kill.pojo.Stock">

  2.        update stock

  3.        

  4.            sale = sale + 1,

  5.            version = version + 1,

  6.        


  7.        WHERE id = #{id,jdbcType=INTEGER}

  8.        AND version = #{version,jdbcType=INTEGER}


  9.    


同样的测试条件,我们再进行上面的测试 /createOptimisticOrder/1

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

这次发现无论是库存订单都是 OK 的。


查看日志发现:

640?wx_fmt=jpeg

很多并发请求会响应错误,这就达到了效果。


提高吞吐量

为了进一步提高秒杀时的吞吐量以及响应效率,这里的 web 和 Service 都进行了横向扩展。

  • web 利用 Nginx 进行负载。

  • Service 也是多台应用。

640?wx_fmt=jpeg

640?wx_fmt=jpeg


再用 JMeter 测试时可以直观的看到效果。

由于我是在阿里云的一台小水管服务器进行测试的,加上配置不高、应用都在同一台,所以并没有完全体现出性能上的优势( Nginx 做负载转发时候也会增加额外的网络消耗)。


shell 脚本实现简单的 CI

由于应用多台部署之后,手动发版测试的痛苦相信经历过的都有体会。

这次并没有精力去搭建完整的 CI CD,只是写了一个简单的脚本实现了自动化部署,希望对这方面没有经验的同学带来一点启发:

构建 web
 
 
  1. #!/bin/bash


  2. # 构建 web 消费者


  3. #read appname


  4. appname="consumer"

  5. echo "input="$appname


  6. PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')


  7. # 遍历杀掉 pid

  8. for var in ${PID[@]};

  9. do

  10.    echo "loop pid= $var"

  11.    kill -9 $var

  12. done


  13. echo "kill $appname success"


  14. cd ..


  15. git pull


  16. cd SSM-SECONDS-KILL


  17. mvn -Dmaven.test.skip=true clean package


  18. echo "build war success"


  19. cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/webapps

  20. echo "cp tomcat-dubbo-consumer-8083/webapps ok!"


  21. cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/webapps

  22. echo "cp tomcat-dubbo-consumer-7083-slave/webapps ok!"


  23. sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/bin/startup.sh

  24. echo "tomcat-dubbo-consumer-8083/bin/startup.sh success"


  25. sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/bin/startup.sh

  26. echo "tomcat-dubbo-consumer-7083-slave/bin/startup.sh success"


  27. echo "start $appname success"


构建 Service
 
 
  1. # 构建服务提供者


  2. #read appname


  3. appname="provider"


  4. echo "input="$appname



  5. PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')


  6. #if [ $? -eq 0 ]; then

  7. #    echo "process id:$PID"

  8. #else

  9. #    echo "process $appname not exit"

  10. #    exit

  11. #fi


  12. # 遍历杀掉 pid

  13. for var in ${PID[@]};

  14. do

  15.    echo "loop pid= $var"

  16.    kill -9 $var

  17. done


  18. echo "kill $appname success"



  19. cd ..


  20. git pull


  21. cd SSM-SECONDS-KILL


  22. mvn -Dmaven.test.skip=true clean package


  23. echo "build war success"


  24. cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/webapps


  25. echo "cp tomcat-dubbo-provider-8080/webapps ok!"


  26. cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/webapps


  27. echo "cp tomcat-dubbo-provider-7080-slave/webapps ok!"


  28. sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/bin/startup.sh

  29. echo "tomcat-dubbo-provider-8080/bin/startup.sh success"


  30. sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/bin/startup.sh

  31. echo "tomcat-dubbo-provider-8080/bin/startup.sh success"


  32. echo "start $appname success"

之后每当我有更新,只需要执行这两个脚本就可以帮我自动构建。都是最基础的 Linux 命令,相信大家都看得明白。

乐观锁更新 + 分布式限流

上文的结果看似没有问题,其实还差得远呢。这里只是模拟了 300 个并发没有问题,但是当请求达到了 3000 ,3W,300W 呢?

虽说可以横向扩展可以支撑更多的请求,但是能不能利用最少的资源解决问题呢?其实仔细分析下会发现:

假设我的商品一共只有 10 个库存,那么无论你多少人来买其实最终也最多只有 10 人可以下单成功。

所以其中会有 99% 的请求都是无效的。

大家都知道:大多数应用数据库都是压倒骆驼的最后一根稻草。通过 Druid 的监控来看看之前请求数据库的情况:

因为 Service 是两个应用。 640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

数据库也有 20 多个连接。

怎么样来优化呢? 其实很容易想到的就是分布式限流。我们将并发控制在一个可控的范围之内,然后快速失败这样就能最大程度的保护系统。

distributed-redis-tool ⬆️v1.0.3

为此还对 https://github.com/crossoverJie/distributed-redis-tool 进行了小小的升级。

因为加上该组件之后所有的请求都会经过 Redis,所以对 Redis 资源的使用也是要非常小心。

API 更新

修改之后的 API 如下:

 
 
  1. @Configuration

  2. public class RedisLimitConfig {


  3.    private Logger logger = LoggerFactory.getLogger(RedisLimitConfig.class);


  4.    @Value("${redis.limit}")

  5.    private int limit;



  6.    @Autowired

  7.    private JedisConnectionFactory jedisConnectionFactory;


  8.    @Bean

  9.    public RedisLimit build() {

  10.        RedisLimit redisLimit = new RedisLimit.Builder(jedisConnectionFactory, RedisToolsConstant.SINGLE)

  11.                .limit(limit)

  12.                .build();


  13.        return redisLimit;

  14.    }

  15. }

这里构建器改用了 JedisConnectionFactory,所以得配合 Spring 来一起使用。

并在初始化时显示传入 Redis 是以集群方式部署还是单机(强烈建议集群,限流之后对 Redis 还是有一定的压力)。

限流实现

既然 API 更新了,实现自然也要修改:

 
 
  1.    /**

  2.     * limit traffic

  3.     * @return if true

  4.     */

  5.    public boolean limit() {


  6.        //get connection

  7.        Object connection = getConnection();


  8.        Object result = limitRequest(connection);


  9.        if (FAIL_CODE != (Long) result) {

  10.            return true;

  11.        } else {

  12.            return false;

  13.        }

  14.    }


  15.    private Object limitRequest(Object connection) {

  16.        Object result = null;

  17.        String key = String.valueOf(System.currentTimeMillis() / 1000);

  18.        if (connection instanceof Jedis){

  19.            result = ((Jedis)connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));

  20.            ((Jedis) connection).close();

  21.        }else {

  22.            result = ((JedisCluster) connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));

  23.            try {

  24.                ((JedisCluster) connection).close();

  25.            } catch (IOException e) {

  26.                logger.error("IOException",e);

  27.            }

  28.        }

  29.        return result;

  30.    }


  31.    private Object getConnection() {

  32.        Object connection ;

  33.        if (type == RedisToolsConstant.SINGLE){

  34.            RedisConnection redisConnection = jedisConnectionFactory.getConnection();

  35.            connection = redisConnection.getNativeConnection();

  36.        }else {

  37.            RedisClusterConnection clusterConnection = jedisConnectionFactory.getClusterConnection();

  38.            connection = clusterConnection.getNativeConnection() ;

  39.        }

  40.        return connection;

  41.    }


如果是原生的 Spring 应用得采用 @SpringControllerLimit(errorCode=200)注解。

实际使用如下,web 端:

640?wx_fmt=png


Service 端就没什么更新了,依然是采用的乐观锁更新数据库。

再压测看下效果 /createOptimisticLimitOrderByRedis/1


640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

首先是看结果没有问题,再看数据库连接以及并发请求数都有明显的下降


乐观锁更新 + 分布式限流 + Redis 缓存

其实仔细观察 Druid 监控数据发现这个 SQL 被多次查询:

640?wx_fmt=jpeg

其实这是实时查询库存的 SQL,主要是为了在每次下单之前判断是否还有库存。

这也是个优化点

这种数据我们完全可以放在内存中,效率比在数据库要高很多。

由于我们的应用是分布式的,所以堆内缓存显然不合适,Redis 就非常适合。

这次主要改造的是 Service 层:

  • 每次查询库存时走 Redis。

  • 扣库存时更新 Redis。

  • 需要提前将库存信息写入 Redis(手动或者程序自动都可以)。

主要代码如下:

 
 
  1.    @Override

  2.    public int createOptimisticOrderUseRedis(int sid) throws Exception {

  3.        //检验库存,从 Redis 获取

  4.        Stock stock = checkStockByRedis(sid);


  5.        //乐观锁更新库存 以及更新 Redis

  6.        saleStockOptimisticByRedis(stock);


  7.        //创建订单

  8.        int id = createOrder(stock);

  9.        return id ;

  10.    }



  11.    private Stock checkStockByRedis(int sid) throws Exception {

  12.        Integer count = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_COUNT + sid));

  13.        Integer sale = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_SALE + sid));

  14.        if (count.equals(sale)){

  15.            throw new RuntimeException("库存不足 Redis currentCount=" + sale);

  16.        }

  17.        Integer version = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_VERSION + sid));

  18.        Stock stock = new Stock() ;

  19.        stock.setId(sid);

  20.        stock.setCount(count);

  21.        stock.setSale(sale);

  22.        stock.setVersion(version);


  23.        return stock;

  24.    }    



  25.    /**

  26.     * 乐观锁更新数据库 还要更新 Redis

  27.     * @param stock

  28.     */

  29.    private void saleStockOptimisticByRedis(Stock stock) {

  30.        int count = stockService.updateStockByOptimistic(stock);

  31.        if (count == 0){

  32.            throw new RuntimeException("并发更新库存失败") ;

  33.        }

  34.        //自增

  35.        redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_SALE + stock.getId(),1) ;

  36.        redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_VERSION + stock.getId(),1) ;

  37.    }    


压测看看实际效果 /createOptimisticLimitOrderByRedis/1

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

640?wx_fmt=jpeg

最后发现数据没问题,数据库的请求与并发也都下来了。

乐观锁更新 + 分布式限流 + Redis 缓存 + Kafka 异步

最后的优化还是想如何来再次提高吞吐量以及性能的。我们上文所有例子其实都是同步请求,完全可以利用同步转异步来提高性能啊。

这里我们将写订单以及更新库存的操作进行异步化,利用 Kafka 来进行解耦和队列的作用。

每当一个请求通过了限流到达了 Service 层通过了库存校验之后就将订单信息发给 Kafka ,这样一个请求就可以直接返回了。

消费程序再对数据进行入库落地。因为异步了,所以最终需要采取回调或者是其他提醒的方式提醒用户购买完成。

这里代码较多就不贴了,消费程序其实就是把之前的 Service 层的逻辑重写了一遍,不过采用的是 SpringBoot。

感兴趣的朋友可以看下:

https://github.com/crossoverJie/SSM/tree/master/SSM-SECONDS-KILL/SSM-SECONDS-KILL-ORDER-CONSUMER

总结

其实经过上面的一顿优化总结起来无非就是以下几点:

  • 尽量将请求拦截在上游。

  • 还可以根据 UID 进行限流。

  • 最大程度的减少请求落到 DB。

  • 多利用缓存。

  • 同步操作异步化。

  • fail fast,尽早失败,保护应用。

码字不易,这应该是我写过字数最多的了,想想当年高中 800 字的作文都憋不出来?,可想而知是有多难得了。

以上内容欢迎讨论

END


划至底部,点击“在看”,是你来过的仪式感!

640?wx_fmt=png



推荐阅读






更多文章:

640?wx_fmt=png


欢迎长按下图关注公众号石杉的架构笔记,后台回复“学习”,获取作者独家秘制精品资料

640?wx_fmt=jpeg

BAT架构经验倾囊相授

640?wx_fmt=gif

  • 3
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值