天天看點

淘東電商項目(74) -秒殺系統(庫存超賣解決方案)

引言

本文代碼已送出至Github(版本号:4a1e952df7a06cb764166262b02c8c23962e6084​),有興趣的同學可以下載下傳來看看:https://github.com/ylw-github/taodong-shop

在上一篇部落格《淘東電商項目(73) -秒殺系統(前端優化)》主要講解了秒殺系統的前端優化,本文開始講解後端的秒殺系統設計。

本文目錄結構:

l____引言

l____ 1.什麼是庫存超賣?

l____ 2.庫存超賣的解決方案

l________ 2.1 解決方案

l________ 2.2 資料庫表設計

l________ 2.3 使用DB行鎖(悲觀鎖)

l________ 2.4 使用version控制(樂觀鎖)

l____ 3. 測試

l________ 3.1 測試悲觀鎖

l________ 3.2 測試樂觀鎖

1.什麼是庫存超賣?

在秒殺系統中,同一時刻大量的使用者會并發通路秒殺接口,此時資料庫會相應的減少庫存,舉個例子:

比如一件商品有100件,此時有10萬個使用者同時通路秒殺接口,當資料庫還剩一件商品時,A使用者和B使用者同時進入接口,操作資料庫,都做扣減庫存操作(set sum=sum-1),由于資料庫的行鎖機制,A使用者先擷取到行鎖,是以A使用者擷取後,庫存應該為0(即目前庫存-1)。A使用者操作完後,釋放行鎖,B使用者進行操作,庫存變為-1(即目前庫存-1),這很明顯是不符合需求的,那該如何解決呢?下面來講解。

2.庫存超賣的解決方案

2.1 解決方案

為了應對庫存超賣的問題,有兩種解決方案:

  • 使用DB行鎖,也就是悲觀鎖(WHERE控制)。
  • 使用version控制,也就是樂觀鎖(CAS無鎖機制)。

2.2 資料庫表設計

講解代碼前,先看看秒殺系統資料庫的表設計:

①訂單表:

CREATE TABLE `order` (
  `seckill_id` bigint(20) NOT NULL COMMENT '秒殺商品id',
  `user_phone` bigint(20) NOT NULL COMMENT '使用者手機号',
  `state` tinyint(4) NOT NULL DEFAULT '-1' COMMENT '狀态标示:-1:無效 0:成功 1:已付款 2:已發貨',
  `create_time` datetime NOT NULL COMMENT '建立時間',
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒殺成功明細表';      

②秒殺庫存表:

CREATE TABLE `seckill` (
  `seckill_id` bigint(20) NOT NULL COMMENT '商品庫存id',
  `name` varchar(120) NOT NULL COMMENT '商品名稱',
  `inventory` int(11) NOT NULL COMMENT '庫存數量',
  `start_time` datetime NOT NULL COMMENT '秒殺開啟時間',
  `end_time` datetime NOT NULL COMMENT '秒殺結束時間',
  `create_time` datetime NOT NULL COMMENT '建立時間',
  `version` bigint(20) NOT NULL DEFAULT '0' COMMENT '樂觀鎖',
  PRIMARY KEY (`seckill_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒殺庫存表';      

2.3 使用DB行鎖(悲觀鎖)

首先看看秒殺接口的代碼邏輯:

@Transactional
public BaseResponse<JSONObject> spike(String phone, Long seckillId) {
  
  // TODO 1.參數驗證

  // TODO 2.使用者頻率限制 setnx 如果key存在話

  // TODO 3.修改資料庫對應的庫存 1萬中隻有100個搶購成功 提前生成好100個token 誰能夠搶購成功toen放入到mq中實作異步修改庫存
  
  // TODO 4.添加秒殺成功訂單 基于MQ實作異步形式
  
}      

庫存超賣邏輯在第3個步驟,下面直接貼出Mybatis SQL語句:

update
  seckill 
set 
  inventory=inventory-1 
where  
  seckill_id=#{seckillId} and inventory>0;      

上面的語句主要是由where來控制,在inventory(庫存數量)大于0的情況下,才允許修改庫存減一。

缺點:​由于DB裡面使用的是行鎖,是以效率比較低,要等一個更新操作完才能進行下一個更新操作,在使用者并發量高的情況下,效率非常慢。

解決方案:使用version控制,即樂觀鎖,下面講解。

2.4 使用version控制(樂觀鎖)

注意:​樂觀鎖CAS無鎖機制主要的兩個變量:“預期值"和"結果值”。

下面看看使用樂觀鎖之後的MyBatis SQL語句:

①首先擷取目前樂觀鎖的version版本号:

SELECT 
  seckill_id AS seckillId,name as name,inventory as inventory,start_time as startTime,end_time as endTime,create_time as createTime,version as version 
from 
  seckill 
where 
  seckill_id=#{seckillId}      

②然後傳入查詢的樂觀鎖的version版本号,并更新庫存:

update 
  seckill 
set 
  inventory=inventory-1, version=version+1 
where  
  seckill_id=#{seckillId} and inventory>0  and version=#{version} ;      

優點:​效率高同時也防止庫存超賣。

3. 測試

首先資料庫模拟插入一條資料:

INSERT INTO `seckill`(`seckill_id`, `name`, `inventory`, `start_time`, `end_time`, `create_time`, `version`) VALUES (100001, 'iphoneX', 100, '2020-05-25 17:16:11', '2020-05-25 17:16:13', '2020-05-25 17:16:16', 1);      

使用JMeter測試,定義200個使用者通路:

淘東電商項目(74) -秒殺系統(庫存超賣解決方案)
淘東電商項目(74) -秒殺系統(庫存超賣解決方案)

3.1 測試悲觀鎖

調用悲觀鎖接口pessimisticDeduction,核心代碼如下:

@Transactional
public BaseResponse<JSONObject> spike(String phone, Long seckillId) {
  // 1.參數驗證
  if (StringUtils.isEmpty(phone)) {
    return setResultError("手機号碼不能為空!");
  }
  if (seckillId == null) {
    return setResultError("商品庫存id不能為空!");
  }
  SeckillEntity seckillEntity = seckillMapper.findBySeckillId(seckillId);
  if (seckillEntity == null) {
    return setResultError("商品資訊不存在!");
  }
  // 2.使用者頻率限制 setnx 如果key存在話

  // 3.(悲觀鎖 )修改資料庫對應的庫存 1萬中隻有100個搶購成功 提前生成好100個token 誰能夠搶購成功token放入到mq中實作異步修改庫存
  int inventoryDeduction = seckillMapper.pessimisticDeduction(seckillId);
  if (!toDaoResult(inventoryDeduction)) {
    log.info(">>>修改庫存失敗>>>>inventoryDeduction傳回為{} 秒殺失敗!", inventoryDeduction);
    return setResultError("親,請稍後重試!");
  }

  // 4.添加秒殺成功訂單 基于MQ實作異步形式
  OrderEntity orderEntity = new OrderEntity();
  orderEntity.setUserPhone(phone);
  orderEntity.setSeckillId(seckillId);
  int insertOrder = orderMapper.insertOrder(orderEntity);
  if (!toDaoResult(insertOrder)) {
    return setResultError("親,請稍後重試!");
  }
  log.info(">>>修改庫存成功>>>>inventoryDeduction傳回為{} 秒殺成功", inventoryDeduction);
  return setResultSuccess("恭喜您,秒殺成功!");
}      

運作JMeter,可以看到資料庫的庫存減為0,并新增了100條訂單:

運作前 運作後
淘東電商項目(74) -秒殺系統(庫存超賣解決方案)
淘東電商項目(74) -秒殺系統(庫存超賣解決方案)
淘東電商項目(74) -秒殺系統(庫存超賣解決方案)
淘東電商項目(74) -秒殺系統(庫存超賣解決方案)

3.2 測試樂觀鎖

調用樂觀鎖接口optimisticDeduction,核心代碼如下:

@Transactional
public BaseResponse<JSONObject> spike(String phone, Long seckillId) {
  // 1.參數驗證
  if (StringUtils.isEmpty(phone)) {
    return setResultError("手機号碼不能為空!");
  }
  if (seckillId == null) {
    return setResultError("商品庫存id不能為空!");
  }
  SeckillEntity seckillEntity = seckillMapper.findBySeckillId(seckillId);
  if (seckillEntity == null) {
    return setResultError("商品資訊不存在!");
  }
  // 2.使用者頻率限制 setnx 如果key存在話

  // 3.(樂觀鎖 )修改資料庫對應的庫存 1萬中隻有100個搶購成功 提前生成好100個token 誰能夠搶購成功token放入到mq中實作異步修改庫存
  Long version = seckillEntity.getVersion();
  int inventoryDeduction = seckillMapper.optimisticDeduction(seckillId, version);
  if (!toDaoResult(inventoryDeduction)) {
    log.info(">>>修改庫存失敗>>>>inventoryDeduction傳回為{} 秒殺失敗!", inventoryDeduction);
    return setResultError("親,請稍後重試!");
  }
  // 4.添加秒殺成功訂單 基于MQ實作異步形式
  OrderEntity orderEntity = new OrderEntity();
  orderEntity.setUserPhone(phone);
  orderEntity.setSeckillId(seckillId);
  int insertOrder = orderMapper.insertOrder(orderEntity);
  if (!toDaoResult(insertOrder)) {
    return setResultError("親,請稍後重試!");
  }
  log.info(">>>修改庫存成功>>>>inventoryDeduction傳回為{} 秒殺成功", inventoryDeduction);
  return setResultSuccess("恭喜您,秒殺成功!");
}      

運作JMeter,可以看到資料庫的庫存減少了24個,并新增了24條訂單:

運作前 運作後
淘東電商項目(74) -秒殺系統(庫存超賣解決方案)
淘東電商項目(74) -秒殺系統(庫存超賣解決方案)