1.解決超賣和重復(fù)秒殺
秒殺動(dòng)作執(zhí)行的操作比較多,多線程場(chǎng)景下,將會(huì)出現(xiàn)超賣和重復(fù)秒殺的情況,這屬于異常情況,必須解決。
解決超賣,我們利用MySQL來解決,只對(duì)庫存>0的商品執(zhí)行減庫存操作,即增加stock > 0的判斷。
update goods set stock = stock - 1 where id = #{id} and stock > 0
解決重復(fù)秒殺,我們同樣利用MySQL來解決,對(duì)訂單表的goods_id和user_id建立唯一索引,保障goods_id和user_id組合起來是唯一的。
2. 在redis中預(yù)減庫存/判斷是否重復(fù)秒殺
項(xiàng)目啟動(dòng),將庫存加載到redis
秒殺時(shí)從redis中預(yù)減庫存,如果減為0,則直接返回秒殺失敗,減少不必要的數(shù)據(jù)庫訪問
假設(shè)有10個(gè)商品,200個(gè)請(qǐng)求,其實(shí)到第11~200個(gè)請(qǐng)求完全不需要去redis查庫存了。所以直接在tomcat建一個(gè)map<goodsId,是否沒庫存了>,直接在內(nèi)存中判斷,不訪問redis。
//服務(wù)端維護(hù)商品庫存,減少redis的訪問
public static ConcurrentHashMap<String,Boolean> hasNoStock = new ConcurrentHashMap<>();
//獲取當(dāng)前用戶id
int userId = user.getId();
//判斷秒殺是否開始,因?yàn)闉g覽器倒計(jì)時(shí)使用的是客戶端時(shí)間,即使瀏覽器使用服務(wù)端時(shí)間進(jìn)行倒計(jì)時(shí),也不能保證絕對(duì)沒有誤差,所以最好還是判斷一下
if(System.currentTimeMillis() < Constant.BARGAIN_DASH_START_TIME) {
return Result.fail("秒殺還未開始呢");
}
//先從map中判斷庫存是否已經(jīng)沒了
if(BargainsDashController.hasNoStock.get(goodsId+"_stock")) {
return Result.fail("慢了一步,商品已售完");
}
//判斷秒殺商品的庫存,如果已經(jīng)沒了,返回 秒殺結(jié)束
//預(yù)減緩存
int stock = redisTemplate.opsForValue().increment(goodsId+"_stock",-1L).intValue();
if(stock < 0) {
//在map中記錄商品庫存已經(jīng)沒了
BargainsDashController.hasNoStock.put(goodsId+"_stock",true);
return Result.fail("慢了一步,商品已售完");
}
雖然已經(jīng)通過唯一索引保證了不會(huì)出現(xiàn)重復(fù)秒殺的情況,但是我們更應(yīng)該盡早地將不必要請(qǐng)求攔截下來。既能減少對(duì)數(shù)據(jù)庫的操作,又能盡快響應(yīng),釋放當(dāng)前線程。在入訂單表之后,將userid_goodsid_order記錄到redis,下次判斷直接從redis中讀取看是否有訂單記錄。
public int countOrder2(int userId,int goodsId) {
//從redis中判斷是否重復(fù)秒殺
return redisTemplate.opsForValue().size(userId+"_"+goodsId+"_order").intValue();
}
3.服務(wù)端異步處理
對(duì)于滿足庫存的請(qǐng)求,加入RabbitMQ慢慢處理(這里使用的是一生產(chǎn)者,多消費(fèi)者模式),向客戶端響應(yīng)“排隊(duì)中,請(qǐng)稍后”,最大限度地降低并發(fā)量
在MQ將請(qǐng)求處理完成后,將結(jié)果記錄到redis;客戶端每隔1s進(jìn)行一次查詢,服務(wù)端直接從redis取秒殺結(jié)果返給客戶端。
//將請(qǐng)求入MQ,向redis記錄當(dāng)前狀態(tài)為排隊(duì)中,并返回該狀態(tài)
rabbitTemplate.convertAndSend(RabbitMQConfig.QUEUE_NAME,new RabbitMessage(userId, goodsId));
redisTemplate.opsForValue().set(userId+"_"+goodsId+"_result", Result.queue());
return Result.queue();
/**
* 將處理結(jié)果放入redis中,由客戶端定時(shí)輪詢處理結(jié)果
* @param rabbitMessage
* @throws InterruptedException
*/
// 收到消息后,將調(diào)用該方法處理
@RabbitHandler
public void handleMessage(RabbitMessage rabbitMessage) throws InterruptedException {
logger.debug("消費(fèi)者" + this + "收到MQ消息:" + rabbitMessage);
int goodsId = rabbitMessage.getGoodsId();
int userId = rabbitMessage.getUserId();
try{
// 判斷秒殺商品的庫存,如果已經(jīng)沒了,返回 秒殺結(jié)束
int stock = goodsService.getStockById(goodsId);
if (stock <= 0) {
//將秒殺結(jié)果放入redis
redisTemplate.opsForValue().set(userId+"_"+goodsId+"_result", Result.fail("慢了一步,商品已售完"));
}
// 判斷訂單表是否已經(jīng)有記錄了,防止重復(fù)秒殺
int count = orderService.countOrder2(userId, goodsId);
if (count > 0) {
//將秒殺結(jié)果放入redis
redisTemplate.opsForValue().set(userId+"_"+goodsId+"_result", Result.fail("請(qǐng)勿重復(fù)秒殺,把機(jī)會(huì)留給其他人吧"));
}
// 執(zhí)行秒殺(在一個(gè)事務(wù)中 1.減庫存 2.入訂單表)
bargainsDashService.bargainsDash2(userId, goodsId);
//將秒殺的訂單放入redis
redisTemplate.opsForValue().set(userId+"_"+goodsId+"_result", Result.success(orderService.findOrderByUser(userId)));
}catch(Exception e) {
logger.error("秒殺失敗",e);
redisTemplate.opsForValue().set(userId+"_"+goodsId+"_result", Result.fail("服務(wù)器開小差了,秒殺失敗"));
}
}
/**
* 客戶端輪詢秒殺結(jié)果
* @param goodsId
* @param user
* @return
*/
@RequestMapping("/bargainsDashResult/{goodsId}")
@ResponseBody
public Object bargainDashResult(@PathVariable("goodsId") int goodsId,User user) {
//獲取當(dāng)前用戶id
int userId = user.getId();
return redisTemplate.opsForValue().get(userId+"_"+goodsId+"_result");
}
對(duì)于客戶端獲取秒殺結(jié)果,可以考慮使用websocket由服務(wù)端進(jìn)行推送,但如果服務(wù)器集群的話,可能處理秒殺請(qǐng)求的服務(wù)器,和客戶端websocket連接上的服務(wù)器,不是同一個(gè),為了正確推送,還得處理服務(wù)端之間通信,就比較麻煩了。所以還是客戶端去輪詢吧。
4.壓力測(cè)試
可以看出來,性能提升的效果還是極明顯的。
附:所有代碼在github上