[一個簡單的秒殺架構(gòu)的演變]0. 整體流程

一直想自己寫一個簡單的秒殺架構(gòu)的演變,加強(qiáng)自己,參考了很多博客和文章,如有不正確的地方請指出,感謝:yum:

地址

目錄

1. 常見場景

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

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

我們都知道服務(wù)器的處理資源是有限的,所以出現(xiàn)峰值的時候,很容易導(dǎo)致服務(wù)器宕機(jī),用戶無法訪問的情況出現(xiàn),這就好比出行的時候存在早高峰和晚高峰的問題,為了解決這個問題,出行就有了錯峰限行的解決方案

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

2. 流量削峰

削峰從本質(zhì)上來說就是更多地延緩用戶請求,以及層層過濾用戶的訪問需求,遵從最后落地到數(shù)據(jù)庫的請求數(shù)要盡量少的原則

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

  1. 排隊最容易想到的解決方案就是用消息隊列來緩沖瞬時流量,把同步的直接調(diào)用轉(zhuǎn)換成異步的間接推送,中間通過一個隊列在一端承接瞬時的流量洪峰,在另一端平滑地將消息推送出去,在這里,消息隊列就像水庫一樣,攔蓄上游的洪水,削減進(jìn)入下游河道的洪峰流量,從而達(dá)到減免洪水災(zāi)害的目的

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

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

3. 項目準(zhǔn)備

3.1. 表結(jié)構(gòu)

這里我采用的是MySQL,簡單的使用兩個表,一個庫存表,一個訂單表,插入一條商品數(shù)據(jù)

--- 刪除數(shù)據(jù)庫
drop database seckill;
--- 創(chuàng)建數(shù)據(jù)庫
create database seckill;
--- 使用數(shù)據(jù)庫
use seckill;
--- 創(chuàng)建庫存表
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 '創(chuàng)建時間',
  `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');
--- 創(chuàng)建庫存訂單表
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 '創(chuàng)建時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='庫存訂單表';

3.2. 工程創(chuàng)建

這個自行創(chuàng)建即可,我創(chuàng)建的是一個SpringBoot2項目,然后使用代碼生成工具: ViewGenerator,根據(jù)表結(jié)構(gòu)生成一下對應(yīng)的文件,記得移除表前綴參數(shù)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數(shù)據(jù)源
        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類對應(yīng)
    type-aliases-package: com.example.dto.custom

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

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

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

3.3. 初始代碼

先編寫一個入口Controller,默認(rèn)有一個初始化庫存方法

  • SeckillEvolutionController
/**
 * 一個簡單的秒殺架構(gòu)的演變
 *
 * @author wliduo[i@dolyw.com]
 * @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;

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

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

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

    /**
     * 初始化庫存數(shù)量
     * 
     * @param id 商品ID
     * @return com.example.common.ResponseBean
     * @throws 
     * @author wliduo[i@dolyw.com]
     * @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);
        // 刪除訂單表該商品所有數(shù)據(jù)
        StockOrderDto stockOrderDto = new StockOrderDto();
        stockOrderDto.setStockId(id);
        stockOrderService.delete(stockOrderDto);
        return new ResponseBean(HttpStatus.OK.value(), "初始化庫存成功", null);
    }

}

再創(chuàng)建一個Service提供流程使用

  • ISeckillEvolutionService
package com.example.service;

/**
 * ISeckillEvolutionService
 *
 * @author wliduo[i@dolyw.com]
 * @date 2019-11-20 18:03:33
 */
public interface ISeckillEvolutionService {

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

}

最后提供一個秒殺接口以供實現(xiàn)

  • ISeckillService
package com.example.seckill;

import com.example.dto.custom.StockDto;

/**
 * 統(tǒng)一接口
 *
 * @author wliduo[i@dolyw.com]
 * @date 2019-11-20 18:03:33
 */
public interface ISeckillService {

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

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

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

}

4. 思路流程

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

  1. 用戶通過前端校驗最終發(fā)起請求到后端
  2. 然后校驗庫存,扣庫存,創(chuàng)建訂單
  3. 最終數(shù)據(jù)落地,持久化保存

4.1. 傳統(tǒng)方式

我們首先搭建一個后臺服務(wù)接口(實現(xiàn)校驗庫存,扣庫存,創(chuàng)建訂單),不做任何限制,使用JMeter,模擬500個并發(fā)線程測試購買10個庫存的商品,地址: 1. 傳統(tǒng)方式

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

在這種情況下就引入了的概念,鎖區(qū)分為樂觀鎖和悲觀鎖,悲觀鎖都是犧牲性能保證數(shù)據(jù),所以在這種高并發(fā)場景下,一般都是使用樂觀鎖解決

4.2. 使用樂觀鎖

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

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

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

不過現(xiàn)在每次讀取庫存都去查數(shù)據(jù)庫,我們可以看下Druid的監(jiān)控,地址: http://localhost:8080/druid/sql.html

圖片

可以看到,查詢庫存執(zhí)行了500次,遵從最后落地到數(shù)據(jù)庫的請求數(shù)要盡量少的原則,其實我們可以把這個數(shù)據(jù)放緩存,提升性能

4.3. 使用緩存

我們繼續(xù)搭建一個后臺服務(wù)接口(實現(xiàn)校驗庫存,扣庫存,創(chuàng)建訂單),這次我們引入緩存,這里可以先查看一篇文章: Redis與數(shù)據(jù)庫一致性

這里我采用的是先更新數(shù)據(jù)庫再更新緩存,因為這里緩存數(shù)據(jù)計算簡單,只需要進(jìn)行加減一即可,所以我們直接進(jìn)行更新緩存

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

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

我們可以看下使用緩存后Druid的監(jiān)控,地址: http://localhost:8080/druid/sql.html

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

圖片

不過樂觀鎖更新操作還是執(zhí)行了157SQL,遵從最后落地到數(shù)據(jù)庫的請求數(shù)要盡量少的原則,有沒有辦法優(yōu)化這里呢,可以的,實際上很多都是無效請求,這里我們可以使用限流,把大部分無效請求攔截了,盡可能保證最終到達(dá)數(shù)據(jù)庫的都是有效請求

4.4. 使用分布式限流

我們繼續(xù)搭建一個后臺服務(wù)接口(實現(xiàn)校驗庫存,扣庫存,創(chuàng)建訂單),這次我們引入限流,這里可以先查看一篇文章: 高并發(fā)下的限流分析

使用JMeter,模擬500個并發(fā)線程測試購買10個庫存的商品,地址: 4. 使用分布式限流

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

圖片

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

圖片

4.5. 使用隊列異步下單

那我們還可以怎么優(yōu)化提高吞吐量以及性能呢,我們上文所有例子其實都是同步請求,完全可以利用同步轉(zhuǎn)異步來提高性能,這里我們將下訂單的操作進(jìn)行異步化,利用消息隊列來進(jìn)行解耦,這樣可以讓 DB 異步執(zhí)行下單

每當(dāng)一個請求通過了限流和庫存校驗之后就將訂單信息發(fā)給消息隊列,這樣一個請求就可以直接返回了,消費(fèi)程序做下訂單的操作,對數(shù)據(jù)進(jìn)行入庫落地,因為異步了,所以最終需要采取回調(diào)或者是其他提醒的方式提醒用戶購買完成

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容