背景:
某電商網站實現秒殺功能,用戶在某個時間段內能夠搶購到特價商品,且某一商品最多只能被同一用戶搶購一次。
基本思路:
- 秒殺商品由商家后台添加,秒殺商品數據保存在tb_seckilll_goods表中,關鍵字段包括:
id,status(審核狀態),start_time(開始時間),end_time(結束時間),stock_count(庫存量); - 寫一個定時器,定時從秒殺商品表中掃描數據,將符合條件的商品加載到緩存中;條件:審核狀態="1",start_time < 當前時間 < end_time,庫存量大於0;
- 前端展示,此處略
- 點擊搶購,拿着秒殺商品的id去緩存中查詢,如果緩存中商品不存在或者為空,提示“已售罄”,否則生成訂單,保存到緩存中,訂單表tb_seckill_order
- 庫存-1,判斷減完之后緩存中商品的庫存是否大於0,大於0則更新緩存,否則刪除該秒殺商品的緩存,並更新到數據庫
技術選型:緩存redis,定時器:spring整合quartz
如下完成了一個基本的秒殺下單的業務:
掃描秒殺商品加載到redis:
@Scheduled(cron = "0 */1 * * * ?")//cron表達式:每分鍾執行一次,周期可任意定義
public void importToRedis(){
//1.查詢合法秒殺商品數據
TbSeckillGoodsExample example = new TbSeckillGoodsExample();
Date date = new Date();
example.createCriteria().andStatusEqualTo("1").andStockCountGreaterThan(0)
.andStartTimeLessThan(date).andEndTimeGreaterThan(date);
List<TbSeckillGoods> tbSeckillGoods = seckillGoodsMapper.selectByExample(example);
for (TbSeckillGoods seckillGood : tbSeckillGoods) {//將秒殺商品依次存入redis
//注意如果redis中已經有的商品,則不更新,只添加之前未加入過的秒殺商品
if(redisTemplate.boundHashOps("TbSeckillGoods").get(seckillGood.getId()) == null){
redisTemplate.boundHashOps("TbSeckillGoods").put(seckillGood.getId(), seckillGood);
}
}
}
對所有的秒殺商品都使用同一個key:“TbSeckillGoods”,值的存儲類型為hash
下單的service代碼:
public Result saveOrder(Long id, String userId) {
//根據商品id從redis中查出商品
TbSeckillGoods seckillGood = (TbSeckillGoods) redisTemplate.boundHashOps(TbSeckillGoods.class.getSimpleName()).get(id);
//如果緩存中秒殺商品不存在或者庫存為空,則提示已售罄
if(seckillGood == null || seckillGood.getStockCount() <= 0){
return new Result(false, "已售罄");
}
//如果時間已截止,提示秒殺時間已結束
if(seckillGood.getEndTime().getTime() < System.currentTimeMillis()){
return new Result(false, "活動已結束");
}
//生成訂單保存到緩存中
TbSeckillOrder seckillOrder = new TbSeckillOrder();
seckillOrder.setUserId(userId);
seckillOrder.setSeckillId(idWorker.nextId());
seckillOrder.setSellerId(seckillGood.getSellerId());
seckillOrder.setMoney(seckillGood.getCostPrice());
seckillOrder.setStatus("0");//未支付
seckillOrder.setCreateTime(new Date());
redisTemplate.boundHashOps(TbSeckillOrder.class.getSimpleName()).put(userId, seckillOrder);
//秒殺商品庫存量減1
seckillGood.setStockCount(seckillGood.getStockCount() - 1);
//判斷減完之后redis中商品的庫存是否大於0,大於0則更新緩存,否則刪除該秒殺商品的緩存,並更新到數據庫
if(seckillGood.getStockCount() > 0){
redisTemplate.boundHashOps(TbSeckillGoods.class.getSimpleName()).put(seckillGood.getGoodsId(), seckillGood);
}else {
redisTemplate.boundHashOps(TbSeckillGoods.class.getSimpleName()).delete(seckillGood.getGoodsId());
seckillGoodsMapper.updateByPrimaryKey(seckillGood);
}
return new Result(true, "恭喜您搶購到商品,請盡快支付");
}
以上是關鍵代碼,其他業務代碼可不關注,完整代碼可在我的github中查看
分析上述代碼:
上述代碼在多線程環境下存在三個問題:
1.超賣:
if(seckillGood == null || seckillGood.getStockCount() <= 0){
return new Result(false, "已售罄");
}
業務邏輯是如果seckillGood不為null,且庫存>0,即可進行下單,但是在實際環境中,可能會有很多的用戶同時獲取到redis中的商品信息,每個用戶讀取到的庫存量一樣且均大於0,假如庫存只有2,但是有三個用戶都符合下單條件,就出現了超賣情況
2.沒有對用戶多次搶購做限制
3.下單和生成訂單串行,影響並發效率。完全可以在用戶搶購之后立即能夠下單成功,后續的訂單處理可以利用多線程來異步操作
解決方案:
1.對於超賣問題,很容易想到是就是對下單操作加鎖,一次只能有一個用戶進行下單並減庫存。這種方法可以避免超賣問題,但是卻會導致效率下降。
redis中有一種存儲結構list,它的元素在彈出時能夠保證一次只有一個線程進行操作,並且效率比較高。例如,我們在錄入秒殺商品的同時,對每一種商品都創建一個list,該商品的庫存有多少,list中的元素就有多少個,每次下單就從list中彈出一個元素,防止超賣。
如圖:以“SECKILLGOODS_ID_PREFIX_秒殺商品ID”的格式字符串作為list的key,商品庫存有n,則該list就有n個元素,元素的壓入在錄入商品時完成,每下單一次,就彈出一個元素。
2.對於同一用戶多次搶購的問題,我們同樣可以使用redis來記錄每種商品已搶購成功的用戶id,我們使用set來記錄用戶id,防止用戶id重復
如圖:以“USER_ID_PREFIX_秒殺商品ID”的格式字符串作為set的key,一旦有一個用戶搶購了該商品,則在先判斷Set集合中是否存在用戶id,不存在則添加
3.多線程處理訂單,在redis中創建一個隊列,每當一個用戶成功搶購一個商品,就往隊列中壓入一個下單數據,包含商品id和用戶id即可。線程從隊列中彈出一個包含下單數據的元素,進行訂單的生成
如圖:OrederRecorder作為key,集合中記錄了搶購成功的商品id和用戶id,等待多線程去從集合中彈出元素進行處理
整個秒殺業務的大致流程如下: