引言
本文代碼已提交至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個用戶訪問:
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條訂單:
運行前 | 運行後 |
---|---|
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條訂單:
運行前 | 運行後 |
---|---|