单机秒杀系统的架构设计与实现

一,秒杀系统

1,秒杀场景

电商抢购限量商品

抢购演唱会的门票

火车票抢座12306

2.为什么要做个系统

如果项目流量非常小,完全不用担心并发请求的购买,那么做这样一个系统的意义并不大。但是如果你的系统要是像12306一样,接受高并发访问和下单的考验,那么你就需要一套完整的流程保护措施,来保证你系统在用户流量高峰期不会被搞挂了。

严格防止超卖:库存一百件,卖出去120件。

防止黑产:一个人全买了,其他人啥也没有。

保证用户体验:高并发下,网页打不开,支付不成功,购物车进不去,地址改不了,这个问题非常之大,涉及到各种技术。

3.保护措施有哪些

乐观锁防止超卖

令牌桶限流

redis缓存

消息队列异步处理订单

二,无锁状态下的秒杀系统

1.业务流程分析

1.前端接受一个秒杀请求传递到后端控制器

2.控制器接受请求参数,调用业务创建订单

3.业务层需要检验库存,扣除库存,(判断用户是否重复购买),创建订单

2.搭建项目

sql脚本

CREATE TABLE `ms_order` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '订单表',
  `product_id` int(11) DEFAULT NULL COMMENT '商品id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

CREATE TABLE `ms_stock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '库存表',
  `product_id` int(11) DEFAULT NULL COMMENT '商品id',
  `product_name` varchar(255) DEFAULT NULL COMMENT '商品名称',
  `sum` int(11) DEFAULT NULL COMMENT '商品数量',
  `sale` int(11) DEFAULT NULL COMMENT '售出数量',
  `version` int(11) DEFAULT '0' COMMENT '版本号',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

pom依赖

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.yhd</groupId>
    <artifactId>ms</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ms</name>
    <description>ms project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.48</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.2</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.0.5</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
            <version>1.18.8</version>
        </dependency>
    </dependencies>

实体类

@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_order")
//开启链式调用
@Accessors(fluent = true)
@Data
public class Order implements Serializable { 
    @TableId(type = IdType.AUTO)
    private Integer id;

    private Integer productId;

    private Date createTime;
}


@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_stock")
//开启链式调用
@Accessors(fluent = true)
public class Stock  implements Serializable { 

    private Integer id;

    private Integer productId;

    private String productName;

    private Integer sum;

    private Integer sale;

    private Integer version;
}

mapper

public interface OrderMapper extends BaseMapper<Order> { 
}

public interface StockMapper extends BaseMapper<Stock> { 
}

service

@Service
public class OrderService { 

    @Resource
    private OrderMapper orderMapper;

    @Resource
    private StockMapper stockMapper;

    /** * 1.验证库存 * 2.修改库存 * 3.创建订单 * @param productId * @return */
    @Transactional
    public Order Qg(Integer productId) { 
        Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
        if (product.sum().equals(product.sale())){ 
            throw  new RuntimeException("抢购失败,商品已经卖光!");
        }else{ 
            stockMapper.updateById(product.sale(product.sale()+1));
            Order order = new Order();
            orderMapper.insert(order.createTime(new Date()).productId(productId));
            return order;
        }
    }
}

controller

@RestController
@RequestMapping("order")
public class OrderController { 

    @Resource
    private OrderService orderService;


    /** * 用户点击抢购,开始下单 */
    @GetMapping("qg/{productId}")
    public Order Qg(@PathVariable("productId") Integer productId){ 
        return orderService.Qg(productId);
    }
}

配置文件

server.port=8888
server.servlet.context-path=/ms

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql:///ms?characterEncoding=utf-8&useSSL=false&serverTimezone=GMT%2B8

mybatis-plus.type-aliases-package=com.yhd.ms.domain
mybatis-plus.mapper-locations=classpath:mapper/*.xml
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

3.jmeter压测工具

没有并发的情况下能够正常生成订单,但是当产生并发请求的时候,就会发生超卖问题。

三,单机下使用悲观锁解决超卖问题

首先因为synchronized是本地锁,如果是集群模式下,这样加锁是无法解决超卖的。

1.synchronized和事务的小问题

    @Transactional
    public synchronized Order Qg(Integer productId) { 
        Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
        if (product.sum().equals(product.sale())){ 
            throw  new RuntimeException("抢购失败,商品已经卖光!");
        }else{ 
            stockMapper.updateById(product.sale(product.sale()+1));
            Order order = new Order();
            orderMapper.insert(order.createTime(new Date()).productId(productId));
            return order;
        }
    }

单机模式下,我们在业务层代码加上synchronized关键字,加上以后发现并没有解决超卖问题,原因是synchronized这把锁是在事务里面的一部分,释放锁以后,实际上事务并未执行完,当事务提交,还是会修改数据库,相当于锁白加了。

2.解决方案

第一种方法就是吧事务去掉,但是业务层代码不加事务的问题就不用多描述了。所以采用第二种

第二种:

    /** * 用户点击抢购,开始下单 */
    @GetMapping("qg/{productId}")
    public Order Qg(@PathVariable("productId") Integer productId){ 
        synchronized (this) { 
            return orderService.Qg(productId);
        }
    }
    /** * 1.验证库存 * 2.修改库存 * 3.创建订单 * * @param productId * @return */
    @Transactional
    public Order Qg(Integer productId) { 
        Stock stock = checkStock(productId);
        updateStock(stock);
        Order order = createOrder(stock);
        return order;
    }

    /** * 验证库存 * * @param productId * @return */
    private Stock checkStock(Integer productId) { 
        Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
        if (product.sum().equals(product.sale())) { 
            throw new RuntimeException("抢购失败,商品已经卖光!");
        }
        return product;
    }

    /** * 更新库存 * * @param stock * @return */
    private Integer updateStock(Stock stock) { 
        return stockMapper.updateById(stock.sale(stock.sale() + 1));
    }

    /** * 创建订单 * * @param stock * @return */
    private Order createOrder(Stock stock) { 
        Order order = new Order();
        orderMapper.insert(order.createTime(new Date()).productId(stock.productId()));
        return order;
    }

这次成功的解决了超卖问题,但是同时悲观锁也带来了效率低下的问题。

四,单机下使用乐观锁解决超卖问题

使用乐观搜解决商品超卖问题,实际上是把主要防止超卖问题交给数据库解决,利用数据库中定义的version字段以及数据库中的事务实现在并发情况下商品超卖问题。

select * from ms_stock where id =1 and version =0;

update ms_stock set sale=sale+1,version=version+1 where id=#{id} and version =#{version}

经过压力测试,发现不但解决了超卖问题,效率上也得到了很大的提高,但是当请求数量在一秒钟上升到20000个的时候,可以看到,系统崩溃了。

五,令牌桶接口限流防止系统崩溃

1.接口限流

限流:对某一时间窗口内的请求进行限制,保持系统的可用性和稳定性,防止因为流量暴增而导致的系统运行缓慢或者宕机。

在面临高并发的抢购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大压力。大量的请求抢购成功时需要调用下单接口,过多的请求打到数据库会对系统的稳定性造成影响。

2.如何解决接口限流

常用的限流算法有令牌桶算法和漏桶算法,而谷歌的开源项目Guava中的RateLimiter使用的就是令牌桶控制算法。在开发高并发系统时有三把利器保护系统:缓存,降级和限流。

缓存:缓存的目的是提升系统访问速度和增大系统的处理容量。

降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。

限流:通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务,排队或者等待,降级等处理。

3.漏桶算法和令牌桶算法

漏桶算法:请求先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。

令牌桶算法:在网络传输数据时,为了防止网络阻塞,需要限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断的产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断的增多,直到把通填满。后面在产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味着,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且那令牌的过程并不是消耗很大的事情。

4.令牌桶使用案例

    /** * 创建令牌桶 */
    private RateLimiter rateLimiter=RateLimiter.create(10);

    /** * 测试令牌桶算法 * * @return */
    @GetMapping("test")
    public String testLpt(){ 
        if (rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){ 
            log.info("争抢令牌消耗的时间为:" +rateLimiter.acquire());
            //模拟处理业务逻辑耗时
            try { 
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) { 
                e.printStackTrace();
            }
            return "成功抢到令牌,消耗时间为:+" +rateLimiter.acquire();
        }
        log.info("未抢到令牌,无法执行业务逻辑!");
        return "未抢到令牌,无法执行业务逻辑!";
    }

5.使用令牌桶优化秒杀系统

private RateLimiter rateLimiter=RateLimiter.create(10);

    /** * 用户点击抢购,开始下单 */
    @GetMapping("qg/{productId}")
    public String Qg(@PathVariable("productId") Integer productId) { 
        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){ 
            return "抢购失败,请重试!";
        }
        orderService.Qg(productId);
        return "抢购成功,耗时为:"+rateLimiter.acquire();
    }

六,隐藏秒杀接口

解决了超卖和限流问题,还要关注一些细节,此时秒杀系统还存在一些问题:

1.我们应该在一定的时间内执行秒杀处理,不能在任意时间都接受秒杀请求。如何加入时间验证?

2.对于稍微懂电脑的人,又会通过抓包的方式获取我们的接口地址,我们通过脚本进行抢购怎们么办?

3.秒杀开始之后如何限制单个用户的请求频率,即单位时间内限制访问次数?

1.使用redis实现限时抢购

    @Resource
    private StringRedisTemplate redisTemplate;

	@Transactional
    public Order Qg(Integer productId) { 
        checkTime(productId);
        Stock stock = checkStock(productId);
        updateStock(stock);
        Order order = createOrder(stock);
        return order;
    }

    /** * 使用redis实现限时抢购 */
    public void checkTime(Integer productId){ 
        Boolean flag = redisTemplate.hasKey("SECOND_KILL" + productId);
        if (!flag){ 
            throw new RuntimeException("秒杀活动已经结束,欢迎下次再来!");
        }
    }

2.秒杀接口的隐藏处理

我们需要将秒杀接口进行隐藏的具体方法:

每次点击秒杀按钮,实际上是两次请求,第一次先从服务器获取一个秒杀验证值(接口内判断是否到秒杀时间)

redis以缓存用户ID和商品ID为key,秒杀地址为value缓存验证值

用户请求秒杀商品的时候,要带上秒杀验证值进行校验

加入用户表

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_user")
//开启链式调用
@Accessors(fluent = true)
public class User implements Serializable { 

    private Integer id;

    private String name;

    private String pwd;
}

生成MD5接口

    /** * 生成MD5接口 */
    @GetMapping("md5/{pid}/{uid}")
    public String createMD5(@PathVariable("pid")Integer pid,@PathVariable("uid")Integer uid){ 
        return orderService.createMD5(pid,uid);
    }
    /** * 根据用户id和商品id生成随机盐 * 1.检验用户合法性 * 2.校验库存 * 3.生成hashkey * 4.生成MD5 * * @param pid * @param uid * @return */
    @Transactional
    public String createMD5(Integer pid, Integer uid) { 
        checkUser(uid);
        checkStock(pid);
        return createKey(pid, uid);
    }

    /** * 校验用户合法性 */
    public void checkUser(Integer id) { 
        User user = userMapper.selectById(id);
        if (user == null) { 
            throw new RuntimeException("用户不存在!");
        }
    }

    /** * 生成key,并存入redis * * @param pid * @param uid * @return */
    public String createKey(Integer pid, Integer uid) { 
        String key = "SECOND_KILL" + pid + uid;
        String value = MD5Encoder.encode(key.getBytes());
        redisTemplate.opsForValue().set(key, value, 60, TimeUnit.SECONDS);
        return value;
    }

修改下单接口

    /** * 0.检验MD5 * 1.验证库存 * 2.修改库存 * 3.创建订单 * * @param productId * @return */
    @Transactional
    public Order Qg(Integer productId, Integer uid, String md5) { 
        checkMD5(productId, uid, md5);
        checkTime(productId);
        Stock stock = checkStock(productId);
        updateStock(stock);
        Order order = createOrder(stock);
        return order;
    }
    
   /** * 生成订单前校验MD5 * * @param uid * @param md5 */
    private void checkMD5(Integer pid, Integer uid, String md5) { 
        if (!md5.equals(createMD5(pid, uid))) { 
            throw new RuntimeException("参数非法!");
        }
    }

3.单用户接口调用频率限制

为了防止出现用户撸羊毛,限制用户的购买数量。

用redis给每个用户做访问统计,甚至带上商品id,对单个商品进行访问统计。

    /** * 用户点击抢购,开始下单 */
    @GetMapping("qg/{productId}/{uid}/{md5}")
    public String Qg(@PathVariable("productId") Integer productId,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5) { 
        if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){ 
            return "抢购失败,请重试!";
        }
        //查询用户是否抢购过该商品 ,并在用户下单成功后将该商品加入redis
        if (!userService.checkIsBuy(uid,productId)){ 
            return "已经购买过该商品,请勿重复下单!";
        }
        orderService.Qg(productId,uid,md5);
        return "抢购成功,耗时为:"+rateLimiter.acquire();
    }
    /** * 校验用户是否购买过该商品 * @param uid * @param productId * @return */
    public boolean checkIsBuy(Integer uid, Integer productId) { 
        return redisTemplate.opsForHash().hasKey("SECOND_KILL_BUYED"+productId,uid);
    }
    /** * 0.检验MD5 * 1.验证库存 * 2.修改库存 * 3.创建订单 * 4.用户下单成功后将该商品加入redis * * @param productId * @return */
    @Transactional
    public Order Qg(Integer productId, Integer uid, String md5) { 
        checkMD5(productId, uid, md5);
        checkTime(productId);
        Stock stock = checkStock(productId);
        updateStock(stock);
        Order order = createOrder(stock);
        updateRedis(uid, productId);
        return order;
    }
  
    /** * 用户下单成功后将该商品加入redis */
    private void updateRedis(Integer uid, Integer productId) { 
        redisTemplate.opsForHash().increment("SECOND_KILL_BUYED"+productId,uid, 1);
    }

至此,单机的小体量秒杀系统基本结束,为什么说小体量?因为现在我们的合法购买请求完全打入到了数据库,对数据库压力过大,我们可以考虑操作redis缓存,使用消息队列实现异步下单支付。同时,如果体量再次升级,我们可以考虑使用集群,分布式,随之而来就产生了新的问题,分布式锁的解决。

本文地址:https://blog.csdn.net/weixin_45596022/article/details/111027429

(0)
上一篇 2022年3月21日
下一篇 2022年3月21日

相关推荐