欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

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

程序员文章站 2022-06-19 15:25:35
一,秒杀系统1,秒杀场景电商抢购限量商品抢购演唱会的门票火车票抢座12306…2.为什么要做个系统如果项目流量非常小,完全不用担心并发请求的购买,那么做这样一个系统的意义并不大。但是如果你的系统要是像12306一样,接受高并发访问和下单的考验,那么你就需要一套完整的流程保护措施,来保证你系统在用户流量高峰期不会被搞挂了。严格防止超卖:库存一百件,卖出去120件。防止黑产:一个人全买了,其他人啥也没有。保证用户体验:高并发下,网页打不开,支付不成功,购物车进不去,地址改不了,这个问题非常之...

一,秒杀系统

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