好好學習,天天向上
本文已收錄至我的Github倉庫DayDayUP:github.com/RobodLee/DayDayUP,歡迎Star
流程分析
上面這張圖是整個秒殺系統的流程。簡單介紹一下:
秒殺是一個併發量很大的系統,資料吞吐量都很大,MySQL的資料是儲存在硬碟中的,資料吞吐的能力滿足不了整個秒殺系統的需求。為了提高系統的訪問速度,我們定時將秒殺商品從MySQL載入進Redis,因為Redis的資料是儲存在記憶體中的,速度非常快,可以滿足很高的吞吐量。
使用者訪問秒殺系統,請求到了OpenResty,OpenResty從Redis中載入秒殺商品,然後使用者來到了秒殺列表頁。當使用者點選某個秒殺商品時,OpenResty再從Redis中載入秒殺商品詳情資訊,接著使用者就來到了秒殺商品詳情頁。
當進入到商品詳情頁之後使用者就可以點選下單了,點選下單的時候,OpenResty會檢查商品是否還有庫存,沒有庫存就下單失敗。有庫存的話還需要檢查一下使用者是否登入,沒有登入的話再到OAuth2.0認證服務那邊去登入,登入成功後再進入到秒殺微服務中,開始正式的下單流程。
理論上這時候還要對使用者進行一些合法性檢測,比如賬號是否異常等,但是這太耗時了,為了減少系統響應的時間,使用者檢測這一步先省略。直接讓使用者進行排隊,排隊就是將使用者id和商品id存入Redis佇列,成功排隊後給使用者返回一個 “正在排隊”的資訊。
當排隊成功後就開啟多執行緒搶單,為每個排隊的使用者分配一個執行緒。在排隊使用者自己的執行緒中開始檢測賬號的狀態是否正常,然後從Redis中檢測庫存時候足夠,當所有條件都滿足的時候,下單成功,將訂單資訊存入Redis。並將Redis中的排隊資訊從“排隊中”改為“待支付”,這樣前端在查詢狀態的時候就知道可以開始支付了,然後跳轉到支付頁面進行支付。當使用者支付成功後,將搶單資訊從Redis中刪除,並同步到MySQL中。
最後一個問題,有的使用者成功搶單後並不去付款,所以我們需要定時去處理未支付的訂單。方案和上一篇文章中提到的一樣,使用RabbitMQ死信佇列。在搶單成功後將訂單id、使用者id和商品id存到RabbitMQ的佇列1,設定半個小時後過期,過期後將資訊傳送給佇列2,我們去監聽佇列2。當監聽到佇列2中的訊息的時候,說明半個小時已經到了,這時候我們再去Redis中查詢訂單的狀態,如果已經支付了就不去管它;如果沒有支付就向微信伺服器傳送請求關閉支付,然後回滾庫存,並將Redis中的搶單資訊刪除。
這樣整個秒殺流程就結束了。
定時任務
怎麼搭建秒殺微服務就不記錄了,沒什麼好說的,秒殺微服務名為changgou-service-seckill。定時任務我也是第一次接觸,所以在這裡記錄一下。
首先在啟動類上新增一個註解@EnableScheduling去開始對定時任務的支援。然後建立一個類SeckillGoodsPushTask,在這個類上新增@Component註解,將其注入Spring容器。然後再新增一個方法,加上@Scheduled註解,宣告這個方法是一個定時任務。
/**
* SeckillGoodsPushTask
* 定時將秒殺商品載入到redis中
*/
@Scheduled(cron = "0/5 * * * * ?")
public void loadGoodsPushRedis() {
List<Date> dateMenu = DateUtil.getDateMenus();
for (Date date : dateMenu) {
date.setYear(2019-1900); //2019-6-1 為了方便測試
date.setMonth(6-1);
date.setDate(1);
String dateString = SystemConstants.SEC_KILL_GOODS_PREFIX +DateUtil.data2str(date,"yyyyMMddHH");
BoundHashOperations boundHashOperations = redisTemplate.boundHashOps(dateString);
Set<Long> keys = boundHashOperations.keys(); //獲取Redis中已有的商品的id集合
List<SeckillGoods> seckillGoods;
//將秒殺商品的資訊從資料庫中載入出來
if (keys!=null && keys.size()>0) {
seckillGoods = mapper.findSeckillGoodsNotIn(date,keys);
} else {
seckillGoods = mapper.findSeckillGoods(date);
}
//遍歷秒殺商品集合,將商品依次放入Redis中
for (SeckillGoods seckillGood : seckillGoods) {
boundHashOperations.put(seckillGood.getId(),seckillGood);
}
}
}
----------------------------------------------------------------------------------------------------------------
@Repository("seckillGoodsMapper")
public interface SeckillGoodsMapper extends Mapper<SeckillGoods> {
//查詢符合條件的秒殺商品
@Select("SELECT" +
" * " +
" FROM " +
" tb_seckill_goods " +
" WHERE " +
" status = 1 " +
" AND stock_count > 0 " +
" AND start_time >= #{date} " +
" AND end_time < DATE_ADD(#{date},INTERVAL 2 HOUR)")
List<SeckillGoods> findSeckillGoods(@Param("date") Date date);
//查詢出符合條件的秒殺商品,排除之前已存入的
@SelectProvider(type = SeckillGoodsMapper.SeckillProvider.class, method = "findSeckillGoodsNotIn")
List<SeckillGoods> findSeckillGoodsNotIn(@Param("date") Date date, @Param("keys") Set<Long> keys);
class SeckillProvider {
public String findSeckillGoodsNotIn(@Param("date") Date date, @Param("keys") Set<Long> keys) {
StringBuilder sql = new StringBuilder("SELECT" +
" * " +
" FROM " +
" tb_seckill_goods " +
" WHERE " +
" status = 1 " +
" AND stock_count > 0 " +
" AND start_time >= ");
sql.append("'").append(date.toLocaleString()).append("'")
.append(" AND end_time < DATE_ADD(")
.append("'").append(date.toLocaleString()).append("'")
.append(" ,INTERVAL 2 HOUR) ")
.append(" AND id NOT IN (");
for (Long key : keys) {
sql.append(key).append(",");
}
sql.deleteCharAt(sql.length() - 1).append(")");
System.out.println(sql.toString());
return sql.toString();
}
}
}
(cron = "0/5 * * * * ?")
中幾個引數分別代表秒-分-時-日-月-周-年。年可以省略,所以是6個。*
表示所有值,比如 “分” 是*就代表每分鐘都執行。?
表示不需要關心這個值是多少。/
表示遞增觸發,0/5表示從0秒開始每5秒觸發一次。所以這段程式碼配置的就是每5秒執行一次定時任務。
上面這段程式碼的意思是:將MySQL中的秒殺商品放入Redis,為了避免新增重複的商品,先獲取Redis中已有商品的id集合,然後在查詢資料庫的時候將已有的排除掉。redis中存入商品的鍵為秒殺開始的時間,例如 "2020100110"表示2020年10月1日10點,獲取時間選單用的是資料提供的一個工具類DateUtil。DateUtil的程式碼不難,我就不介紹了,開除錯模式跟著走一遍就能看懂。為了方便測試,我將日期定在了2019年6月1日,實際開發中應該用當前日期。
秒殺頻道頁
將商品載入到Redis中後就可以開始下單流程了,首先需要有個秒殺頻道頁,就是將對應時間段的秒殺商品載入到頁面上展示出來。前端將當前時間的字串(yyyyMMddHH)傳到後端,後端從Redis中查詢出對應的商品返回到前端,前端進行展示。
// SeckillGoodsController
//根據時間段(2019090516) 查詢該時間段的所有的秒殺的商品
@GetMapping("/list")
public Result<List<SeckillGoods>> list(@RequestParam("time") String time){
List<SeckillGoods> list = seckillGoodsService.list(time);
return new Result<>(true,StatusCode.OK,"查詢成功",list);
}
-----------------------------------------------------------------------------------
// SeckillGoodsServiceImpl
@Override
public List<SeckillGoods> list(String time) {
return redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX+time).values();
}
程式碼很簡單,就是根據鍵將商品從Redis中查詢出來。
秒殺商品詳情頁
當使用者點選秒殺頻道頁的商品後,就會進入到秒殺商品詳情頁。前端將當前時間段和商品的id傳到後端,後端從Redis中將商品資訊查詢出來,然後返回給前端進行展示。
// SeckillGoodsController
//根據時間段 和秒殺商品的ID 獲取商品的資料
@GetMapping("/one")
public Result<SeckillGoods> one(String time,Long id){
SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
return new Result<>(true,StatusCode.OK,"查詢商品資料成功",seckillGoods);
}
------------------------------------------------------------------------------------------
// SeckillGoodsServiceImpl
@GetMapping("/one")
public Result<SeckillGoods> one(String time,Long id){
SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
return new Result<>(true,StatusCode.OK,"查詢商品資料成功",seckillGoods);
}
多執行緒搶單
上面兩個小節內容都不多,現在正式進入下單的流程。因為在秒殺環境中,併發量都很大,如果只開一個執行緒的話,使用者不知道要等到猴年馬月,所以為每個下單的使用者分配一個執行緒去進行處理是比較妥當的。
要在SpringBoot中開啟多執行緒,首先在啟動類上新增一個註解@EnableAsync去開啟對非同步任務的支援。
//SeckillOrderController
//下單
@RequestMapping("/add")
public Result<Boolean> add(String time,Long id){
//1.獲取當前登入的使用者的名稱
String username ="robod";//測試用寫死
boolean flag = seckillOrderService.add(id, time, username);
return new Result(true,StatusCode.OK,"排隊中。。。",flag);
}
前端將時間段和商品的id傳進來,使用者名稱暫時寫死,方便測試。
// SeckillOrderServiceImpl
@Override
public boolean add(Long id, String time, String username) {
SeckillStatus seckillStatus = new SeckillStatus(username,LocalDateTime.now(),1,id,time);
//將seckillStatus存入redis佇列
redisTemplate.boundListOps(SystemConstants.SEC_KILL_USER_QUEUE_KEY).leftPush(seckillStatus);
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY).put(username,seckillStatus);
multiThreadingCreateOrder.createOrder();
return true;
}
在這段程式碼中,先根據已有的資訊建立了一個SeckillStatus物件,這個類中存放了秒殺的一些狀態資訊。然後將seckillStatus放入redis佇列中,如果及時地處理訂單系統響應速度就會變慢,所以先建立一個SeckillStatus放入redis,然後呼叫multiThreadingCreateOrder.createOrder()去開啟一個執行緒處理訂單。
@Component
public class MultiThreadingCreateOrder {
…………
//非同步搶單
@Async //宣告該方法是個非同步任務,另開一個執行緒去執行
public void createOrder() {
//從redis佇列中取出seckillStatus
SeckillStatus seckillStatus = (SeckillStatus)
redisTemplate.boundListOps(SystemConstants.SEC_KILL_USER_QUEUE_KEY).rightPop();
BoundHashOperations seckillGoodsBoundHashOps =
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + seckillStatus.getTime());
//從redis中查詢出秒殺商品
SeckillGoods seckillGoods = (SeckillGoods)seckillGoodsBoundHashOps.get(seckillStatus.getGoodsId());
if (seckillGoods == null || seckillGoods.getStockCount() <=0 ) {
throw new RuntimeException("已售罄");
}
//建立秒殺訂單
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setSeckillId(seckillGoods.getId());
seckillOrder.setMoney(seckillGoods.getCostPrice());
seckillOrder.setUserId(seckillStatus.getUsername());
seckillOrder.setCreateTime(LocalDateTime.now());
seckillOrder.setStatus("0");
//將秒殺訂單存入redis,鍵為使用者名稱,確保一個使用者只有一個秒殺訂單
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_ORDER_KEY)
.put(seckillStatus.getUsername(),seckillOrder);
//減庫存,如果庫存沒了就從redis中刪除,並將庫存資料寫到MySQL中
seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
if (seckillGoods.getStockCount() <= 0) {
seckillGoodsBoundHashOps.delete(seckillStatus.getGoodsId());
seckillGoodsMapper.updateByPrimaryKeySelective(seckillGoods);
} else {
seckillGoodsBoundHashOps.put(seckillStatus.getGoodsId(),seckillGoods);
}
//下單成功,更改seckillstatus的狀態,再存入redis中
seckillStatus.setOrderId(seckillOrder.getId());
seckillStatus.setMoney(Float.valueOf(seckillGoods.getCostPrice()));
seckillStatus.setStatus(2); //等待支付
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY)
.put(seckillStatus.getUsername(),seckillStatus);
}
}
在這個方法上新增了一個@Async註解,說明該方法是個非同步任務,每次執行該方法的時候都會另開一個執行緒去執行。之前不是將訂單存入redis佇列中了嗎,現在從redis佇列中取出。然後根據商品id查詢出商品資訊。接著進行庫存判斷,如果沒有商品或者庫存沒了說明已經賣完了,丟擲已售罄的異常。如果有庫存的話,就建立一個秒殺訂單,將status置為0表示未支付。 然後將訂單存入redis中,這樣訂單就算建立完成了。成功建立訂單後就應該減去相應的庫存。如果減完庫存後發現庫存沒了,說明最後一件商品已經賣完了,這時候就可以將redis中的該商品刪除,並更新到MySQL中。
最後修改seckillstatus的內容,並更新到redis中。之前沒說把seckillstatus存入redis的作用,其實它的作用就是供前端查詢訂單狀態。
既然是查詢訂單狀態,得提供一個介面吧?
// SeckillOrderController
//查詢當前登入的使用者的搶單資訊(狀態)
@GetMapping("/query")
public Result<SeckillStatus> queryStatus(String username) {
SeckillStatus seckillStatus = seckillOrderService.queryStatus(username);
if (seckillStatus == null) {
return new Result<>(false,StatusCode.NOT_FOUND_ERROR,"未查詢到訂單資訊");
}
return new Result<>(true,StatusCode.OK,"訂單查詢成功",seckillStatus);
}
-------------------------------------------------------------------------------------------
//SeckillOrderServiceImpl
@Override
public SeckillStatus queryStatus(String username) {
return (SeckillStatus) redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY).get(username);
}
前端將使用者名稱傳入進來,然後查詢訂單狀態,如果查詢出來的狀態是待支付的話,就可以進入支付流程了。
總結
好了,這篇文章到這裡就結束了,主要介紹了一下秒殺的流程,然後實現了定時任務,秒殺頻道頁,秒殺商品詳情頁和多執行緒搶單的功能。這個秒殺系統還沒有結束,還存在很多問題,在下一篇文章中,將會修改現有的問題並繼續完善秒殺的流程。讓我們下期再見!
碼字不易,可以的話,給我來個
點贊
,收藏
,關注