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

本文来源:crossoverJie

前言

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

本次采用按部就班的方式逐步提升性能达到并发秒杀的效果,文章较长,请准备好瓜子板凳^_^git

本文全部涉及的代码:github

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

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

首先来看看最终架构图:shell


先简单根据这个图谈下请求的流转,由于后面无论怎么改进这个都是没有变的。数据库

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

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

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

无限制

其实抛开秒杀这个场景来讲正常的一个下单流程能够简单分为如下几步:

  • 校验库存

  • 扣库存

  • 建立订单

  • 支付

基于上文的架构因此咱们有了如下实现:

先看看实际项目的结构:

仍是和之前同样:

  • 提供出一个 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 实现:

@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 提供出接口):

@Servicepublic 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< span=""> id="updateByOptimistic" parameterType="com.crossoverJie.seconds.kill.pojo.Stock">        update stock                    sale = sale + 1,            version = version + 1,                WHERE id = #{id,jdbcType=INTEGER} AND version = #{version,jdbcType=INTEGER}复制代码

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

此次发现不管是库存订单都是 OK 的。


查看日志发现:

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

提升吞吐量

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

  • web 利用 Nginx 进行负载。

  • Service 也是多台应用。


再用 JMeter 测试时能够直观的看到效果。

因为我是在阿里云的一台小水管服务器进行测试的,加上配置不高、应用都在同一台,因此并无彻底体现出性能上的优点( Nginx 作负载转发时候也会增长额外的网络消耗)。

shell 脚本实现简单的 CI

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

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

构建 web

#!/bin/bash# 构建 web 消费者#read appnameappname="consumer"echo "input="$appnamePID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')# 遍历杀掉 pidfor var in ${PID[@]};do echo "loop pid= $var" kill -9 $vardoneecho "kill $appname success"cd ..git pullcd SSM-SECONDS-KILLmvn -Dmaven.test.skip=true clean packageecho "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/webappsecho "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/webappsecho "cp tomcat-dubbo-consumer-7083-slave/webapps ok!"sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/bin/startup.shecho "tomcat-dubbo-consumer-8083/bin/startup.sh success"sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/bin/startup.shecho "tomcat-dubbo-consumer-7083-slave/bin/startup.sh success"echo "start $appname success"复制代码

构建 Service

# 构建服务提供者#read appnameappname="provider"echo "input="$appnamePID=$(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# 遍历杀掉 pidfor var in ${PID[@]};do echo "loop pid= $var" kill -9 $vardoneecho "kill $appname success"cd ..git pullcd SSM-SECONDS-KILLmvn -Dmaven.test.skip=true clean packageecho "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/webappsecho "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/webappsecho "cp tomcat-dubbo-provider-7080-slave/webapps ok!"sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/bin/startup.shecho "tomcat-dubbo-provider-8080/bin/startup.sh success"sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/bin/startup.shecho "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 以下:

@Configurationpublic 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 端:

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 层的逻辑重写了一遍,不过采用的是 SpringBoot。

感兴趣的朋友能够看下:

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

总结

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

  • 尽可能将请求拦截在上游。

  • 还能够根据 UID 进行限流。

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

  • 多利用缓存。

  • 同步操做异步化。

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

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

以上内容欢迎讨论

END


我的公众号:石杉的架构笔记(ID:shishan100)

欢迎长按下图关注公众号:石杉的架构笔记!

公众号后台回复资料,获取做者独家秘制学习资料

石杉的架构笔记,BAT架构经验倾囊相授

相关文章
相关标签/搜索