單機秒殺系統的架構設計與實現

尹會東發表於2020-12-11

一,秒殺系統

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快取,使用訊息佇列實現非同步下單支付。同時,如果體量再次升級,我們可以考慮使用叢集,分散式,隨之而來就產生了新的問題,分散式鎖的解決。

相關文章