简单几步,设计一个高性能的秒杀系统!

栏目: 数据库 · 发布时间: 5年前

内容简介:本次采用循序渐进的方式逐步提高性能达到并发秒杀的效果,文章较长,请准备好瓜子板凳!图片来自包图网

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

简单几步,设计一个高性能的秒杀系统!

图片来自包图网

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

本文所有涉及的代码:

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

首先来看看最终架构图:

简单几步,设计一个高性能的秒杀系统!

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

  • 前端请求进入 Web 层,对应的代码就是 Controller。
  • 之后将真正的库存校验、下单等请求发往 Service 层,其中 RPC 调用依然采用的 Dubbo,只是更新为最新版本。
  • Service 层再对数据进行落地,下单完成。

无限制

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

  • 校验库存
  • 扣库存
  • 创建订单
  • 支付

基于上文的架构,我们有了以下实现,先看看实际项目的结构:

简单几步,设计一个高性能的秒杀系统!

还是和以前一样:

  • 提供出一个 API 用于 Service 层实现,以及 Web 层消费。
  • Web 层简单来说就是一个 Spring MVC。
  • 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 实现:

@Autowired 
   private StockService stockService; 
 
   @Autowired 
   private OrderService orderService; 
 
   @RequestMapping("/createWrongOrder/{sid}") 
   @ResponseBody 
   public String createWrongOrder(@PathVariable int sid) { 
       logger.info("sid=[{}]", sid); 
       int id = 0; 
       try { 
           id = orderService.createWrongOrder(sid); 
       } catch (Exception e) { 
           logger.error("Exception",e); 
       } 
       return String.valueOf(id); 
   } 

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

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

@Service 
public class OrderServiceImpl implements OrderService { 
 
    @Resource(name = "DBOrderService") 
    private com.crossoverJie.seconds.kill.service.OrderService orderService ; 
 
    @Override 
    public int createWrongOrder(int sid) throws Exception { 
        return orderService.createWrongOrder(sid); 
    } 
} 

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

DBOrderService 实现:

Transactional(rollbackFor = Exception.class) 
@Service(value = "DBOrderService") 
public class OrderServiceImpl implements OrderService { 
    @Resource(name = "DBStockService") 
    private com.crossoverJie.seconds.kill.service.StockService stockService; 
 
    @Autowired 
    private StockOrderMapper orderMapper; 
 
    @Override 
    public int createWrongOrder(int sid) throws Exception{ 
 
        //校验库存 
        Stock stock = checkStock(sid); 
 
        //扣库存 
        saleStock(stock); 
 
        //创建订单 
        int id = createOrder(stock); 
 
        return id; 
    } 
 
    private Stock checkStock(int sid) { 
        Stock stock = stockService.getStockById(sid); 
        if (stock.getSale().equals(stock.getCount())) { 
            throw new RuntimeException("库存不足"); 
        } 
        return stock; 
    } 
 
    private int saleStock(Stock stock) { 
        stock.setSale(stock.getSale() + 1); 
        return stockService.updateStockById(stock); 
    } 
 
    private int createOrder(Stock stock) { 
        StockOrder order = new StockOrder(); 
        order.setSid(stock.getId()); 
        order.setName(stock.getName()); 
        int id = orderMapper.insertSelective(order); 
        return id; 
    }         
 
} 

预先初始化了 10 条库存。手动调用下 createWrongOrder/1 接口发现:

简单几步,设计一个高性能的秒杀系统!

库存表

简单几步,设计一个高性能的秒杀系统!

订单表

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

简单几步,设计一个高性能的秒杀系统!

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

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

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

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

乐观锁更新

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

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

@Override 
   public int createOptimisticOrder(int sid) throws Exception { 
 
       //校验库存 
       Stock stock = checkStock(sid); 
 
       //乐观锁更新库存 
       saleStockOptimistic(stock); 
 
       //创建订单 
       int id = createOrder(stock); 
 
       return id; 
   } 
 
   private void saleStockOptimistic(Stock stock) { 
       int count = stockService.updateStockByOptimistic(stock); 
       if (count == 0){ 
           throw new RuntimeException("并发更新库存失败") ; 
       } 
   } 

对应的 XML:

<update id="updateByOptimistic" parameterType="com.crossoverJie.seconds.kill.pojo.Stock"> 
       update stock 
       <set> 
           sale = sale + 1, 
           version = version + 1, 
       </set> 
 
       WHERE id = #{id,jdbcType=INTEGER} 
       AND version = #{version,jdbcType=INTEGER} 
 
   </update> 

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

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

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

查看日志发现:

简单几步,设计一个高性能的秒杀系统!

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

提高吞吐量

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

  • Web 利用 Nginx 进行负载。
  • Service 也是多台应用。

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

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

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

Shell 脚本实现简单的 CI

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

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

构建 Web:

#!/bin/bash 
 
# 构建 web 消费者 
 
#read appname 
 
appname="consumer" 
echo "input="$appname 
 
PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}') 
 
# 遍历杀掉 pid 
for var in ${PID[@]}; 
do 
    echo "loop pid= $var" 
    kill -9 $var 
done 
 
echo "kill $appname success" 
 
cd .. 
 
git pull 
 
cd SSM-SECONDS-KILL 
 
mvn -Dmaven.test.skip=true clean package 
 
echo "build war success" 
 
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 
echo "cp tomcat-dubbo-consumer-8083/webapps ok!" 
 
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 
echo "cp tomcat-dubbo-consumer-7083-slave/webapps ok!" 
 
sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/bin/startup.sh 
echo "tomcat-dubbo-consumer-8083/bin/startup.sh success" 
 
sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/bin/startup.sh 
echo "tomcat-dubbo-consumer-7083-slave/bin/startup.sh success" 
 
echo "start $appname success" 

构建 Service:

# 构建服务提供者 
 
#read appname 
 
appname="provider" 
 
echo "input="$appname 
 
 
PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}') 
 
#if [ $? -eq 0 ]; then 
#    echo "process id:$PID" 
#else 
#    echo "process $appname not exit" 
#    exit 
#fi 
 
# 遍历杀掉 pid 
for var in ${PID[@]}; 
do 
    echo "loop pid= $var" 
    kill -9 $var 
done 
 
echo "kill $appname success" 
 
 
cd .. 
 
git pull 
 
cd SSM-SECONDS-KILL 
 
mvn -Dmaven.test.skip=true clean package 
 
echo "build war success" 
 
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 
 
echo "cp tomcat-dubbo-provider-8080/webapps ok!" 
 
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 
 
echo "cp tomcat-dubbo-provider-7080-slave/webapps ok!" 
 
sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/bin/startup.sh 
echo "tomcat-dubbo-provider-8080/bin/startup.sh success" 
 
sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/bin/startup.sh 
echo "tomcat-dubbo-provider-8080/bin/startup.sh success" 
 
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 如下:

@Configuration 
public class RedisLimitConfig { 
 
    private Logger logger = LoggerFactory.getLogger(RedisLimitConfig.class); 
 
    @Value("${redis.limit}") 
    private int limit; 
 
 
    @Autowired 
    private JedisConnectionFactory jedisConnectionFactory; 
 
    @Bean 
    public RedisLimit build() { 
        RedisLimit redisLimit = new RedisLimit.Builder(jedisConnectionFactory, RedisToolsConstant.SINGLE) 
                .limit(limit) 
                .build(); 
 
        return redisLimit; 
    } 
} 

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

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

③限流实现

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

/** 
  * limit traffic 
  * @return if true 
  */ 
 public boolean limit() { 
 
     //get connection 
     Object connection = getConnection(); 
 
     Object result = limitRequest(connection); 
 
     if (FAIL_CODE != (Long) result) { 
         return true; 
     } else { 
         return false; 
     } 
 } 
 
 private Object limitRequest(Object connection) { 
     Object result = null; 
     String key = String.valueOf(System.currentTimeMillis() / 1000); 
     if (connection instanceof Jedis){ 
         result = ((Jedis)connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit))); 
         ((Jedis) connection).close(); 
     }else { 
         result = ((JedisCluster) connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit))); 
         try { 
             ((JedisCluster) connection).close(); 
         } catch (IOException e) { 
             logger.error("IOException",e); 
         } 
     } 
     return result; 
 } 
 
 private Object getConnection() { 
     Object connection ; 
     if (type == RedisToolsConstant.SINGLE){ 
         RedisConnection redisConnection = jedisConnectionFactory.getConnection(); 
         connection = redisConnection.getNativeConnection(); 
     }else { 
         RedisClusterConnection clusterConnection = jedisConnectionFactory.getClusterConnection(); 
         connection = clusterConnection.getNativeConnection() ; 
     } 
     return connection; 
 } 

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

实际使用如下,Web 端:

/** 
     * 乐观锁更新库存 限流 
     * @param sid 
     * @return 
     */ 
    @SpringControllerLimit(errorCode = 200) 
    @RequestMapping("/createOptimisticLimitOrder/{sid}") 
    @ResponseBody 
    public String createOptimisticLimitOrder(@PathVariable int sid) { 
        logger.info("sid=[{}]", sid); 
        int id = 0; 
        try { 
            id = orderService.createOptimisticOrder(sid); 
        } catch (Exception e) { 
            logger.error("Exception",e); 
        } 
        return String.valueOf(id); 
    } 

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

再压测看下效果 /createOptimisticLimitOrderByRedis/1:

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

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

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

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

简单几步,设计一个高性能的秒杀系统!

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

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

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

  • 这次主要改造的是 Service 层:
  • 每次查询库存时走 Redis。
  • 扣库存时更新 Redis。

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

主要代码如下:

@Override 
  public int createOptimisticOrderUseRedis(int sid) throws Exception { 
      //检验库存,从 Redis 获取 
      Stock stock = checkStockByRedis(sid); 
 
      //乐观锁更新库存 以及更新 Redis 
      saleStockOptimisticByRedis(stock); 
 
      //创建订单 
      int id = createOrder(stock); 
      return id ; 
  } 
 
 
  private Stock checkStockByRedis(int sid) throws Exception { 
      Integer count = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_COUNT + sid)); 
      Integer sale = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_SALE + sid)); 
      if (count.equals(sale)){ 
          throw new RuntimeException("库存不足 Redis currentCount=" + sale); 
      } 
      Integer version = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_VERSION + sid)); 
      Stock stock = new Stock() ; 
      stock.setId(sid); 
      stock.setCount(count); 
      stock.setSale(sale); 
      stock.setVersion(version); 
 
      return stock; 
  }     
 
 
  /** 
   * 乐观锁更新数据库 还要更新 Redis 
   * @param stock 
   */ 
  private void saleStockOptimisticByRedis(Stock stock) { 
      int count = stockService.updateStockByOptimistic(stock); 
      if (count == 0){ 
          throw new RuntimeException("并发更新库存失败") ; 
      } 
      //自增 
      redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_SALE + stock.getId(),1) ; 
      redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_VERSION + stock.getId(),1) ; 
  }     

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

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

简单几步,设计一个高性能的秒杀系统!

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

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

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

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

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

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

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

总结

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

  • 尽量将请求拦截在上游。
  • 还可以根据 UID 进行限流。
  • 最大程度的减少请求落到 DB。
  • 多利用缓存。
  • 同步操作异步化。
  • Fail Fast,尽早失败,保护应用。

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

以上内容欢迎讨论,感兴趣的朋友可以看下:

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

简单几步,设计一个高性能的秒杀系统!


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

创新者的处方

创新者的处方

[美]克莱顿·克里斯坦森、杰罗姆·格罗斯曼、黄捷升 / 朱恒鹏、张琦 / 中国人民大学出版社 / 2015-9 / 89.90元

[内容简介] ● 创新大师克里斯坦森采用了哈佛商学院在20年研究中总结而出的、在各行业实践中获得成功的管理创新经验,把颠覆式创新理念引入美国医疗行业研究。医疗机构需要量体裁衣,选择合适的商业模式展开创新之举。 ● 作者同时探讨了医疗保险公司、制药企业、医学院和政府机构在医疗改革中起到的作用,从社会性角度深入剖析了医疗保健行业未来之路。 ● 医疗界人士、政策制定者、对医疗界现......一起来看看 《创新者的处方》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

SHA 加密
SHA 加密

SHA 加密工具

html转js在线工具
html转js在线工具

html转js在线工具