专栏名称: 石杉的架构笔记
专注原创、用心雕琢!十余年BAT一线大厂架构经验倾囊相授
目录
相关文章推荐
搜猪  ·  生猪现货日报|全国均价14.78元/公斤 ... ·  10 小时前  
51好读  ›  专栏  ›  石杉的架构笔记

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

石杉的架构笔记  · 公众号  ·  · 2019-06-20 08:30

正文

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

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

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


往期文章

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

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

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

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

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

本文来源:crossoverJie


前言

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

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

本文所有涉及的代码:

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

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

首先来看看最终架构图:


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

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

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

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

无限制

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

  • 校验库存

  • 扣库存

  • 创建订单

  • 支付

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

先看看实际项目的结构:

还是和以前一样:

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

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

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

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

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

  1. CREATE TABLE `stock` (

  2.  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,

  3.  `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',

  4.  `count` int(11) NOT NULL COMMENT '库存',

  5.  `sale` int(11) NOT NULL COMMENT '已售',

  6.  `version` int(11) NOT NULL COMMENT '乐观锁,版本号',

  7.  PRIMARY KEY (`id`)

  8. ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;





  1. CREATE TABLE `stock_order` (

  2.  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,

  3.  `sid` int(11) NOT NULL COMMENT '库存ID',

  4.  `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称',

  5.  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',

  6.  PRIMARY KEY (`id`)

  7. ) 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 接口发现:


库存表:


订单表:


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


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


请求都响应成功,库存确实也扣完了,但是订单却生成了 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.     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

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


查看日志发现:

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


提高吞吐量

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

  • web 利用 Nginx 进行负载。

  • Service 也是多台应用。


再用 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 是两个应用。

数据库也有 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(







请到「今天看啥」查看全文