[一個簡單的秒殺架構的演變]0. 整體流程

一直想自己寫一個簡單的秒殺架構的演變,加強自己,參考了很多博客和文章,如有不正確的地方請指出,感謝😋

地址

目錄

1. 常見場景

最典型的就是淘寶京東等電商雙十一秒殺了,短時間上億的用戶涌入,瞬間流量巨大(高併發)。例如,200萬人準備在凌晨12:00準備搶購一件商品,但是商品的數量是有限的100件,這樣真實能購買到該件商品的用戶也只有100人及以下,不能賣超

但是從業務上來說,秒殺活動是希望更多的人來參與,也就是搶購之前希望有越來越多的人來看購買商品,但是,在搶購時間達到後,用戶開始真正下單時,秒殺的服務器後端卻不希望同時有幾百萬人同時發起搶購請求

我們都知道服務器的處理資源是有限的,所以出現峯值的時候,很容易導致服務器宕機,用戶無法訪問的情況出現,這就好比出行的時候存在早高峯和晚高峯的問題,爲了解決這個問題,出行就有了錯峯限行的解決方案

同理,在線上的秒殺等業務場景,也需要類似的解決方案,需要平安度過同時搶購帶來的流量峯值的問題,這就是流量削峯的由來

2. 流量削峯

削峯從本質上來說就是更多地延緩用戶請求,以及層層過濾用戶的訪問需求,遵從最後落地到數據庫的請求數要儘量少的原則

流量削峯主要有三種操作思路(排隊,答題,過濾),簡單說下

  1. 排隊最容易想到的解決方案就是用消息隊列來緩衝瞬時流量,把同步的直接調用轉換成異步的間接推送,中間通過一個隊列在一端承接瞬時的流量洪峯,在另一端平滑地將消息推送出去,在這裏,消息隊列就像水庫一樣,攔蓄上游的洪水,削減進入下游河道的洪峯流量,從而達到減免洪水災害的目的

  2. 答題目的其實就是延緩請求,起到對請求流量進行削峯的作用,從而讓系統能夠更好地支持瞬時的流量高峯

  3. 前面介紹的排隊和答題,要麼是在接收請求時做緩衝,要麼是減少請求的同時發送,而針對秒殺場景還有一種方法,就是對請求進行分層過濾,從而過濾掉一些無效的請求,從Web層接到請求,到緩存,消息隊列,最終到數據庫這樣就像漏斗一樣,儘量把數據量和請求量一層一層地過濾和減少了,最終,到漏斗最末端(數據庫)的纔是有效請求

3. 項目準備

3.1. 表結構

這裏我採用的是MySQL,簡單的使用兩個表,一個庫存表,一個訂單表,插入一條商品數據

--- 刪除數據庫
drop database seckill;
--- 創建數據庫
create database seckill;
--- 使用數據庫
use seckill;
--- 創建庫存表
DROP TABLE IF EXISTS `t_seckill_stock`;
CREATE TABLE `t_seckill_stock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '庫存ID',
  `name` varchar(50) NOT NULL DEFAULT 'OnePlus 7 Pro' COMMENT '名稱',
  `count` int(11) NOT NULL COMMENT '庫存',
  `sale` int(11) NOT NULL COMMENT '已售',
  `version` int(11) NOT NULL COMMENT '樂觀鎖,版本號',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='庫存表';
--- 插入一條商品,初始化10個庫存
INSERT INTO `t_seckill_stock` (`count`, `sale`, `version`) VALUES ('10', '0', '0');
--- 創建庫存訂單表
DROP TABLE IF EXISTS `t_seckill_stock_order`;
CREATE TABLE `t_seckill_stock_order` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `stock_id` int(11) NOT NULL COMMENT '庫存ID',
  `name` varchar(30) NOT NULL DEFAULT 'OnePlus 7 Pro' COMMENT '商品名稱',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '創建時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='庫存訂單表';

3.2. 工程創建

這個自行創建即可,我創建的是一個SpringBoot2項目,然後使用代碼生成工具: ViewGenerator,根據表結構生成一下對應的文件,記得移除表前綴參數t_seckill_

圖片

使用通用Mapper要在Application處加個註解@tk.mybatis.spring.annotation.MapperScan

@SpringBootApplication
@tk.mybatis.spring.annotation.MapperScan("com.example.dao")
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

SpringBoot2連接MySQLurl屬性要配置serverTimezone=GMT%2B8driver-class-name屬性要改爲com.mysql.cj.jdbc.Driver

server:
    port: 8080

spring:
    datasource:
        name: SeckillEvolution
        url: jdbc:mysql://127.0.0.1:3306/seckill?serverTimezone=GMT%2B8&useSSL=false&useUnicode=true&characterEncoding=UTF-8
        username: root
        password: root
        # 使用Druid數據源
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.jdbc.Driver
        druid:
            filters: stat
            maxActive: 20
            initialSize: 1
            maxWait: 60000
            minIdle: 1
            timeBetweenEvictionRunsMillis: 60000
            minEvictableIdleTimeMillis: 300000
            validationQuery: select 'x'
            testWhileIdle: true
            testOnBorrow: false
            testOnReturn: false
            poolPreparedStatements: true
            maxOpenPreparedStatements: 20
    # 404交給異常處理器處理
    mvc:
        throw-exception-if-no-handler-found: true
    # 404交給異常處理器處理
    resources:
        add-mappings: false

mybatis:
    # Mybatis配置Mapper路徑
    mapper-locations: classpath:mapper/*.xml
    # Mybatis配置Model類對應
    type-aliases-package: com.example.dto.custom

pagehelper:
    params: count=countSql
    # 指定分頁插件使用哪種方言
    helper-dialect: mysql
    # 分頁合理化參數 pageNum<=0時會查詢第一頁 pageNum>pages(超過總數時) 會查詢最後一頁
    reasonable: 'true'
    support-methods-arguments: 'true'

mapper:
    # 通用Mapper的insertSelective和updateByPrimaryKeySelective中是否判斷字符串類型!=''
    not-empty: true

logging:
    # Debug打印SQL
    level.com.example.dao: debug

3.3. 初始代碼

先編寫一個入口Controller,默認有一個初始化庫存方法

  • SeckillEvolutionController
/**
 * 一個簡單的秒殺架構的演變
 *
 * @author wliduo[[email protected]]
 * @date 2019/11/20 19:49
 */
@RestController
@RequestMapping("/seckill")
public class SeckillEvolutionController {

    /**
     * logger
     */
    private static final Logger logger = LoggerFactory.getLogger(SeckillEvolutionController.class);

    private final IStockService stockService;

    private final IStockOrderService stockOrderService;

    private final ISeckillEvolutionService seckillEvolutionService;

    /**
     * 構造注入
     * @param stockService
     * @param stockOrderService
     */
    @Autowired
    public SeckillEvolutionController(IStockService stockService, IStockOrderService stockOrderService,
                                      ISeckillEvolutionService seckillEvolutionService) {
        this.stockService = stockService;
        this.stockOrderService = stockOrderService;
        this.seckillEvolutionService = seckillEvolutionService;
    }

    /**
     * 初始化庫存數量
     */
    private static final Integer ITEM_STOCK_COUNT = 10;

    /**
     * 初始化賣出數量,樂觀鎖版本
     */
    private static final Integer ITEM_STOCK_SALE = 0;

    /**
     * 初始化庫存數量
     * 
     * @param id 商品ID
     * @return com.example.common.ResponseBean
     * @throws 
     * @author wliduo[[email protected]]
     * @date 2019/11/22 15:59
     */
    @PutMapping("/init/{id}")
    public ResponseBean init(@PathVariable("id") Integer id) {
        // 更新庫存表該商品的庫存,已售,樂觀鎖版本號
        StockDto stockDto = new StockDto();
        stockDto.setId(id);
        stockDto.setName(Constant.ITEM_STOCK_NAME);
        stockDto.setCount(ITEM_STOCK_COUNT);
        stockDto.setSale(ITEM_STOCK_SALE);
        stockDto.setVersion(ITEM_STOCK_SALE);
        stockService.updateByPrimaryKey(stockDto);
        // 刪除訂單表該商品所有數據
        StockOrderDto stockOrderDto = new StockOrderDto();
        stockOrderDto.setStockId(id);
        stockOrderService.delete(stockOrderDto);
        return new ResponseBean(HttpStatus.OK.value(), "初始化庫存成功", null);
    }

}

再創建一個Service提供流程使用

  • ISeckillEvolutionService
package com.example.service;

/**
 * ISeckillEvolutionService
 *
 * @author wliduo[[email protected]]
 * @date 2019-11-20 18:03:33
 */
public interface ISeckillEvolutionService {

}
  • SeckillEvolutionServiceImpl
/**
 * StockServiceImpl
 *
 * @author wliduo[[email protected]]
 * @date 2019-11-20 18:03:33
 */
@Service("seckillEvolutionService")
public class SeckillEvolutionServiceImpl implements ISeckillEvolutionService {

}

最後提供一個秒殺接口以供實現

  • ISeckillService
package com.example.seckill;

import com.example.dto.custom.StockDto;

/**
 * 統一接口
 *
 * @author wliduo[[email protected]]
 * @date 2019-11-20 18:03:33
 */
public interface ISeckillService {

    /**
     * 檢查庫存
     *
     * @param id
     * @return com.example.dto.custom.StockDto
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/20 20:22
     */
    StockDto checkStock(Integer id);

    /**
     * 扣庫存
     *
     * @param stockDto
     * @return java.lang.Integer 操作成功條數
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/20 20:24
     */
    Integer saleStock(StockDto stockDto);

    /**
     * 下訂單
     *
     * @param stockDto
     * @return java.lang.Integer 操作成功條數
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/20 20:26
     */
    Integer createOrder(StockDto stockDto);

}

4. 思路流程

一般的秒殺流程從後臺接收到請求開始不外乎是這樣的(這裏不考慮前端的答題,驗證碼等流程,直接以最終後端下單的請求開始)

  1. 用戶通過前端校驗最終發起請求到後端
  2. 然後校驗庫存,扣庫存,創建訂單
  3. 最終數據落地,持久化保存

4.1. 傳統方式

我們首先搭建一個後臺服務接口(實現校驗庫存,扣庫存,創建訂單),不做任何限制,使用JMeter,模擬500個併發線程測試購買10個庫存的商品,地址: 1. 傳統方式

可以發現併發事務下會出現錯誤,出現賣超問題,這是因爲同一時間大量線程同時請求校驗庫存,扣庫存,創建訂單,這三個操作不在同一個原子,比如,很多線程同時讀到庫存爲10,這樣都穿過了校驗庫存的判斷,所以出現賣超問題

在這種情況下就引入了的概念,鎖區分爲樂觀鎖和悲觀鎖,悲觀鎖都是犧牲性能保證數據,所以在這種高併發場景下,一般都是使用樂觀鎖解決

4.2. 使用樂觀鎖

我們再搭建一個後臺服務接口(實現校驗庫存,扣庫存,創建訂單),但是這次我們需要使用樂觀鎖,這裏可以先查看一篇文章: MySQL那些鎖

使用JMeter,模擬500個併發線程測試購買10個庫存的商品,地址: 2. 使用樂觀鎖

可以發現樂觀鎖解決賣超問題,多個線程同時在檢查庫存的時候都會拿到當前商品的相同樂觀鎖版本號,然後在扣庫存時,如果版本號不對,就會扣減失敗,拋出異常結束,這樣每個版本號就只能有第一個線程扣庫存操作成功,其他相同版本號的線程秒殺失敗,就不會存在賣超問題

不過現在每次讀取庫存都去查數據庫,我們可以看下Druid的監控,地址: http://localhost:8080/druid/sql.html

圖片

可以看到,查詢庫存執行了500次,遵從最後落地到數據庫的請求數要儘量少的原則,其實我們可以把這個數據放緩存,提升性能

4.3. 使用緩存

我們繼續搭建一個後臺服務接口(實現校驗庫存,扣庫存,創建訂單),這次我們引入緩存,這裏可以先查看一篇文章: Redis與數據庫一致性

這裏我採用的是先更新數據庫再更新緩存,因爲這裏緩存數據計算簡單,只需要進行加減一即可,所以我們直接進行更新緩存

這次主要改造是檢查庫存和扣庫存方法,檢查庫存直接去Redis獲取,不再去查數據庫,而在扣庫存這裏本身是使用的樂觀鎖操作,只有操作成功(扣庫存成功)的才需要更新緩存數據

使用JMeter,模擬500個併發線程測試購買10個庫存的商品,地址: 3. 使用緩存

我們可以看下使用緩存後Druid的監控,地址: http://localhost:8080/druid/sql.html

使用了緩存,可以看到庫存查詢SQL只執行了一次,就是緩存預熱那執行了一次,不像之前每次庫存都去查數據庫

圖片

不過樂觀鎖更新操作還是執行了157SQL,遵從最後落地到數據庫的請求數要儘量少的原則,有沒有辦法優化這裏呢,可以的,實際上很多都是無效請求,這裏我們可以使用限流,把大部分無效請求攔截了,儘可能保證最終到達數據庫的都是有效請求

4.4. 使用分佈式限流

我們繼續搭建一個後臺服務接口(實現校驗庫存,扣庫存,創建訂單),這次我們引入限流,這裏可以先查看一篇文章: 高併發下的限流分析

使用JMeter,模擬500個併發線程測試購買10個庫存的商品,地址: 4. 使用分佈式限流

我們可以看下 Druid 的監控,地址: http://localhost:8080/druid/sql.html

圖片

使用了限流,可以看到樂觀鎖更新不像之前那樣執行 157 次了,只執行了 36 次,很多請求直接被限流了,我們看下後臺日誌,可以看到很多請求直接被限流限制了,這樣就達到了我們的目的

圖片

4.5. 使用隊列異步下單

那我們還可以怎麼優化提高吞吐量以及性能呢,我們上文所有例子其實都是同步請求,完全可以利用同步轉異步來提高性能,這裏我們將下訂單的操作進行異步化,利用消息隊列來進行解耦,這樣可以讓 DB 異步執行下單

每當一個請求通過了限流和庫存校驗之後就將訂單信息發給消息隊列,這樣一個請求就可以直接返回了,消費程序做下訂單的操作,對數據進行入庫落地,因爲異步了,所以最終需要採取回調或者是其他提醒的方式提醒用戶購買完成

地址: 5. 使用隊列異步下單

發佈了20 篇原創文章 · 獲贊 20 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章