秒殺系統中,操作一般都是比較複雜的,而且併發量特別高。比如,檢查當前賬號操作是否已經秒殺過該商品,檢查該賬號是否存在存在刷單行爲,記錄用戶操作日誌等等。
而且我們熟悉的秒殺都是分時間段的,比如12-14點,14-16點。
那我們就可以根據時間段,將每個時間段的商品信息(含開始時間和結束時間)集合存入到Redis緩存中去。這裏我們約定商品對象爲SeckillGoods。
// 使用Hash存儲,Key爲標識時間的,如SeckillGoods_202002291400,即2020年2月29日14點整(商品秒殺開始時間)
// 次級Hash,商品id爲key,商品對象爲value
redisTemplate.boundHashOps("SeckillGoods_" + time).put(seckillGoods.getId(),seckillGoods);
此時我們點擊立即搶購就可以通過從Redis中根據商品id查詢到商品信息,渲染到商品詳情頁面上。
redisTemplate.boundHashOps("SeckillGoods_" + time).get(id);
在頁面詳情中,我們就可以通過點擊立即秒殺進行秒殺商品。秒殺商品這個操作可能面臨哪些問題呢?
因爲併發量特別高,所以採用多線程下單。同時我們需要保證用戶搶單的公平性,我們就可以記錄用戶搶單數據,將其存入Redis緩存隊列中,多線程從隊列中取出信息進行消費即可。存數搶單數據的時候,採用左邊存儲,右邊取出的方式,即先進先出原則。
同時要下單,我們必須要保證用戶是登錄了的,如果沒登錄則需要先登錄才能秒殺。用戶已登錄,則還需要判斷當前用戶是否多次下單(原則上,秒殺只允許下單一次,不允許多次提交訂單/排隊)。此時需要解決的就是怎麼判斷用戶有沒有多次下單。這裏可以通過Redis的自增特性來解決。
// Redis自增特性
// incr(key,value):讓自定key的值自增value,返回自增後的值,這是一個單線程操作
// 第一次:incr(username,1) -> 1
// 第二次:incr(username,1) -> 2
Long userQueueCount = redisTemplate.boundHashOps("UserQueueCount").increment(username,1);
第一次下單時,自增後的值是1,第二次下單時,自增後的值是2…… 依次類推
因此解決辦法來了,我們每次判斷這個自增後的值是否大於1,大於1則表明用戶已經下過單/排過隊了,就不允許再次下單即可。
如果此時是第一次下單,則可以進行下一步:創建排隊信息、將排隊信息存入到Redis緩存隊列中、調用多線程進行搶單。
這裏我們統一排隊信息對象爲SeckillStatus,該對象包含用戶名、創建時間、秒殺狀態(1.排隊中 2.等待支付 3.支付超時 4.秒殺失敗 5.支付完成)、秒殺商品id、應付金額、訂單號、商品時間段等信息。
// 創建排隊信息 - 用戶名、創建時間、狀態碼、商品所在搶購時間段
SeckillStatus seckillStatus = new SeckillStatus(username,new Date(),1,time);
// 將排隊信息存入到Redis緩存隊列中 - 左存
redisTemplate.boundHashOps("SeckillQueue").leftPush(seckillStatus);
完成上訴步驟,我們就可以調用多線程異步搶單的工作了。
在異步搶單中,我們首先就是從Redis緩存隊列中取出排隊信息。
// 取出排隊信息 - 右取
SeckillStatus seckillStauts = (SeckillStatus) redisTemplate.boundListOps("SeckillOrderQueue").rightPop();
此時,面臨一個問題,因爲多線程搶單,那麼用戶怎麼知道他搶沒搶到呢?因此我們還應該要在前端使用一個定時器每隔1秒發送一個異步請求取查詢一次訂單狀態。
後端查詢狀態的代碼怎麼寫?
回到之前,我們將排隊信息存入到Redis緩存隊列那裏。我們應該在將排隊信息存入到Redis緩存隊列之後,將這個排隊信息(因爲這個排隊信息包含訂單狀態)以key爲用戶名,value爲seckillStatus對象的方式存入一份到Redis中,當多線程異步搶單中(不管搶單成功,還是搶單失敗的時候),我們都要對狀態進行更新。
// 將排隊信息(排隊信息對象含狀態)存入到Redis中一份
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStatus);
這個步驟完成之後,才能進行調用異步搶單任務。
此時,我們又將面臨一個問題,那就是併發超賣現象。何爲併發超賣現象呢?
多個人在同一時刻搶購同一商品,都會同時判斷是否還有庫存,而此時庫存只剩一個,這幾個人都會判斷出有庫存,然後就會接着處理後面的業務,就會出現超賣現象。即本來明明只有1個庫存了,你卻給我賣出去10幾個。
如何解決這個問題呢?同樣,我們可以利用Redis緩存隊列來實現。給每一件商品創建一個獨立的商品個數隊列。比如說:A商品有2個,A商品的id爲1002。創建一個隊列,key爲SeckillGoodsCountList_1002,往這個隊列中塞入兩次1002這個ID。這個步驟應該在什麼時候做呢?這個步驟應該在根據每個時間段的商品信息集合存入到Redis緩存中的時候去做。
// 庫存數組大小
int len = seckillGoods.getStockCount();
Long[] ids = new Long[len];
// for循環將商品id填入
for(int i = 0; i < len; i++){
ids[i] = seckillGoods.getId();
}
// 存入Redis緩存隊列中
redisTemplate.boundListOps("SeckillGoodsCountList_" + seckillGoods.getId()).leftPushAll(ids);
每次給用戶下單的時候,先從隊列中取數據,如果能取到數據,則表明有庫存,如果取不到,則表明沒有庫存,這樣就可以防止超賣問題產生了。
// 獲取隊列中的商品id
Object sid = redisTemplate.boundListOps("SeckillGoodsCountList_" + id).rightPop();
if(sid == null){
// 商品售罄,處理售罄情況的業務
// 首先需要清空這個排隊信息
// 清空排隊信息,這個隊列是用來判斷是否重複下單的,商品都售罄了自然也就清理了
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());
// 清空狀態信息,這個隊列是用來保存訂單狀態的,商品都售罄了自然也就清理了
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
// ...其他業務
}
搶單成功,則需要創建一個訂單信息SeckillOrder,含訂單id,秒殺商品id、支付金額、用戶id、商家id、創建時間、支付時間、支付狀態、收貨人地址、收貨人電話、收貨人姓名等信息。
// 創建訂單
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setId(idWorker.nextId()); // 訂單編號
seckillOrder.setSeckillId(id); // 商品id
seckillOrder.setMoney(goods.getCostPrice()); // 支付金額
seckillOrder.setUserId(username); // 用戶名
seckillOrder.setSellerId(goods.getSellerId()); // 商家id
seckillOrder.setCreateTime(new Date()); // 創建時間
seckillOrder.setStatus("0"); // 狀態 - 未支付
redisTemplate.boundHashOps("SeckillOrder").put(username,seckillOrder);
那麼此時問題又來了,當用戶搶單成功,那商品庫存是不是會削減1個?如果我們從Redis中取出商品信息,將庫存-1,然後再存入Redis,這樣可行嗎?事實上這種方案是不可行的。如果某一時刻,十幾個用戶搶單成功,他們同時從Redis中取出商品信息(庫存),假如此時庫存爲50,那麼這十幾個用戶取出來的都是50,50 – 1 = 49。將這個49更新到Redis實際是不對的,因爲這十幾個用戶都搶單成功了,那麼剩餘庫存應該是50減去這十幾個搶單成功後的數量。即我明明賣出去10個,庫存本應還剩40個,你這麼給我一整,我咋還剩49個庫存呢?顯然是不對的。
怎麼做呢?同樣我們可以通過Redis的自增特性來做,因爲它是單線程的。我們可以在根據每個時間段的商品信息集合存入到Redis緩存中的時候去做這個事情。創建一個商品庫存緩存。
// 以id爲key,庫存爲value
redisTemplate.boundHashOps("SeckillGoodsCount").put(seckillGoods.getId(),seckillGoods.getStockCount());
而在搶單成功之後,使用自增特性,讓庫存-1。
// 每次搶單成功之後,庫存自減1
Long surplusCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillGoods.getId(), -1);
// 更新查詢到的商品的庫存
seckillGoods.setStockCount(surplusCount.intValue());
// 此時就根據庫存的剩餘量來判斷是否同步到MySQL中去
if(setStockCount <= 0){
// 商品庫存<=0,同步到MySQL,同時清理Redis緩存
seckillGoodsDao.updateByPrimaryKeySelective(seckillGoods);
// 清理Redis緩存
redisTemplate.boundHashOps("SeckillGoods_"+time).delete(seckillGoods.getId());
}else{
// 將數據同步到Redis中
redisTemplate.boundHashOps("SeckillGoods_" + time).put(id,goods);
}
此時搶單完成,更新狀態爲待支付(2)。
// 變更搶單狀態
seckillStauts.setOrderId(seckillOrder.getId()); // 訂單id
seckillStauts.setMoney(seckillOrder.getMoney().floatValue()); // 訂單金額
seckillStauts.setStatus(2); // 搶單成功,待支付
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStauts);
此時因爲前端的定時器一直在查詢狀態,如果查詢到狀態是待支付,則會跳轉到支付頁面(將訂單相關信息附帶過去)。跳轉到支付頁面之後,此時前端用定時器每3秒中向後臺發送一次請求用於判斷當前用戶訂單是否完成支付,如果完成了支付,則需要清理掉排隊信息,並且需要修改訂單狀態信息。
前端一旦查詢到訂單已支付,則就去修改訂單信息。
// 根據用戶名查詢訂單數據
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(username);
if(seckillOrder != null){
// 修改訂單 -> 持久化到MySQL中
seckillOrder.setPayTime(new Date()); // 支付時間
seckillOrder.setStatus("1"); // 狀態 - 已支付
seckillOrderDao.insertSelective(seckillOrder); // 持久化到MySQL中
// 清除Redis中的訂單
redisTemplate.boundHashOps("SeckillOrder").delete(username);
// 清除用戶排隊信息 - 用於判斷是否重複下單的那個隊列
redisTemplate.boundHashOps("UserQueueCount").delete(username);
// 清除排隊狀態存儲信息
redisTemplate.boundHashOps("UserQueueStatus").delete(username);
}
此時,新問題又來了。用戶每次下單後,不一定會立即支付,甚至有可能不支付,那麼此時我們需要刪除用戶下的訂單,並回滾庫存。這裏我們可以採用MQ的延時消息實現,每次用戶下單的時候,如果訂單創建成功,則立即發送一個延時消息到MQ中,等待消息被消費的時候,先檢查對應訂單是否下單支付成功,如果支付成功,會在MySQ中生成一個訂單,如果沒有支付,則Redis中還有該訂單信息的存在,需要刪除該訂單信息以及用戶排隊信息,並恢復庫存。
延時消息,將seckillStatus發送出去。半小時後監聽到消息,也就是seckillStatus。如果seckillStatus不爲null,則從Redis中根據seckillStatus獲取用戶名,判斷Redis中是否存在這個訂單,存在的話,則表明沒有支付(因爲支付成功,會清理Redis中的數據)。
// 判斷Redis中是否存在對應的訂單
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(seckillStatus.getUsername());
如果存在,則需要刪除訂單信息,並且回滾庫存。
// 刪除用戶訂單
redisTemplate.boundHashOps("SeckillOrder").delete(seckillOrder.getUserId());
// 查詢出商品數據
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_"+seckillStatus.getTime()).get(seckillStatus.getGoodsId());
if(goods == null){ // 說明Redis中已經賣完了
// 只能從數據庫中加載數據
goods = seckillGoodsMapper.selectByPrimaryKey(seckillStatus.getGoodsId());
}
// 遞增庫存 incr
Long seckillGoodsCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillStatus.getGoodsId(), 1);
goods.setStockCount(seckillGoodsCount.intValue());
// 將商品數據同步到Redis
redisTemplate.boundHashOps("SeckillGoods_"+seckillStatus.getTime()).put(seckillStatus.getGoodsId(),goods);
redisTemplate.boundListOps("SeckillGoodsCountList_"+seckillStatus.getGoodsId()).leftPush(seckillStatus.getGoodsId());
// 清理用戶搶單排隊信息
// 清理重複排隊標識
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());
// 清理排隊狀態存儲信息
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
整個秒殺過程,需要注意的就是多線程下單、防止秒殺重複排隊、併發超賣問題、超時支付庫存回滾問題。
本文是在看了某馬的青橙商城秒殺階段所寫,因此下面附上完整代碼。
1、時間相關的工具類
這個類的主要作用就是獲取秒殺時間段、在某個時間基礎上增加多少分鐘/小時、獲取秒殺頁面上顯示的秒殺時間段集合、時間格式轉換。
/**
* 時間工具類
*/
public class DateUtil {
/***
* 從yyyy-MM-dd HH:mm格式轉成yyyyMMddHH格式
* @param dateStr
* @return
*/
public static String formatStr(String dateStr){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm");
try {
Date date = simpleDateFormat.parse(dateStr);
simpleDateFormat = new SimpleDateFormat("yyyyMMddHH");
return simpleDateFormat.format(date);
} catch (ParseException e) {
e.printStackTrace();
}
return null;
}
/***
* 獲取指定日期的凌晨00:00
* 如2020-02-27 10:19,它的凌晨時間是2020-02-27 00:00
* @return
*/
public static Date toDayStartHour(Date date){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date start = calendar.getTime();
return start;
}
/***
* 在某個時間基礎上遞增N分鐘
* @param date 時間
* @param minutes 增加時間(分)
* @return
*/
public static Date addDateMinutes(Date date,int minutes){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.MINUTE, minutes);// 24小時制
date = calendar.getTime();
return date;
}
/***
* 在某個時間基礎上遞增N小時
* @param date 時間
* @param hour 增加時間(時)
* @return
*/
public static Date addDateHour(Date date,int hour){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.HOUR, hour);// 24小時制
date = calendar.getTime();
return date;
}
/***
* 獲取時間菜單
* @return
*/
public static List<Date> getDateMenus(){
// 定義一個List<Date>集合,存儲所有時間段
List<Date> dates = new ArrayList<Date>();
// 循環12次 - 每個秒殺時間段都是2小時,如12:00 - 14:00
Date date = toDayStartHour(new Date()); // 獲取凌晨時間
for (int i = 0; i <12 ; i++) {
// 每次遞增2小時,將每次遞增的時間存入到List<Date>集合中
dates.add(addDateHour(date,i * 2));
}
// 判斷當前時間屬於哪個時間範圍
Date now = new Date();
for (Date cdate : dates) {
// 開始時間 <= 當前時間 < 開始時間+2小時
if(cdate.getTime() <= now.getTime() && now.getTime() < addDateHour(cdate,2).getTime()){
// 當前時間段的開始時間,如2:53,開始時間則是2:00
now = cdate;
break;
}
}
// 當前需要顯示的時間菜單
List<Date> dateMenus = new ArrayList<Date>();
// 一次只顯示5個時間段
for (int i = 0; i < 5 ; i++) {
dateMenus.add(addDateHour(now,i * 2));
}
return dateMenus;
}
/***
* 時間轉成yyyyMMddHH格式
* @param date
* @return
*/
public static String date2Str(Date date){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddHH");
return simpleDateFormat.format(date);
}
}
2、秒殺商品初始化 - 任務調度,定時加載秒殺商品
這裏主要是將MySQL中的秒殺商品信息、商品庫存隊列、商品庫存值(商品具體的庫存數量)存入到Redis。
/**
* 秒殺商品初始化任務調度 - Redis緩存
*/
@Component
public class SeckillGoodsTask {
// SpringDataRedis中的RedisTemplate
@Autowired
private RedisTemplate redisTemplate;
// 秒殺商品Dao,使用的tkMybatis
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
/**
* 每30秒執行一次
*/
@Scheduled(cron = "0/15 * * * * ?")
public void loadGoods(){
// 1 查詢所有時間區間 - 如12-14,14-16
List<Date> dateMenus = DateUtil.getDateMenus();
// 循環時間區間,查詢每個時間區間的秒殺商品
for (Date startTime : dateMenus) {
Example example = new Example(SeckillGoods.class);
Example.Criteria criteria = example.createCriteria();
// 2.1 商品必須審覈通過
criteria.andEqualTo("status","1");
// 2.2 庫存 > 0
criteria.andGreaterThan("stockCount",0);
// 2.3 秒殺開始時間 >= 當前循環的時間區間的開始時間
criteria.andGreaterThanOrEqualTo("startTime",startTime);
// 2.4 秒殺結束時間 < 當前循環的時間區間的開始時間+2小時
criteria.andLessThan("endTime",DateUtil.addDateHour(startTime,2));
// 2.5 過濾Redis中已經存在的該區間的秒殺商品
Set keys = redisTemplate.boundHashOps("SeckillGoods_" + DateUtil.date2Str(startTime)).keys();
// 如果Redis中存在,就不用查詢
if(keys != null && keys.size() > 0){
criteria.andNotIn("id",keys);
}
// 2.6 執行查詢
List<SeckillGoods> seckillGoods =seckillGoodsMapper.selectByExample(example);
// 3 將秒殺商品存入到Redis緩存
for (SeckillGoods seckillGood : seckillGoods) {
// ★要秒殺商品完整數據加入到Redis緩存
redisTemplate.boundHashOps("SeckillGoods_"+DateUtil.date2Str(startTime)).put(seckillGood.getId(),seckillGood);
// 剩餘庫存個數 seckillGood.getStockCount() = 5
// 創建獨立隊列:存儲商品剩餘庫存
// SeckillGoodsList_110:
// [110,110,110,110,110]
Long[] ids = pushIds(seckillGood.getStockCount(), seckillGood.getId()); // 組裝商品ID,將商品ID組裝成數組
// ★創建獨立庫存隊列
redisTemplate.boundListOps("SeckillGoodsCountList_"+seckillGood.getId()).leftPushAll(ids);
// ★創建自定key的值,保存商品庫存值 - 用於初始保存庫存數量,下單減少庫存,回滾增加庫存所用
redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillGood.getId(),seckillGood.getStockCount());
}
}
}
/**
* 組裝商品ID,將商品ID組裝成數組
* @param len:商品剩餘個數
* @param id:商品ID
* @return
*/
public Long[] pushIds(int len,Long id){
Long[] ids = new Long[len];
for (int i = 0; i <len ; i++) {
ids[i]=id;
}
return ids;
}
}
3、秒殺頁面數據請求 - 請求數據展示
該類主要用於頁面查詢某個時間段的秒殺商品集合、某個商品詳情
public class SeckillGoodsServiceImpl implements SeckillGoodsService {
@Autowired
private RedisTemplate redisTemplate;
/****
* 根據商品ID查詢商品詳情
* @param time 商品秒殺時區
* @param id 商品ID
* @return
*/
@Override
public SeckillGoods one(String time, Long id) {
return (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_"+time).get(id);
}
/***
* 根據時間區間查詢秒殺商品列表
* @param time 時間段,即某個秒殺時間段的開始時間
* @return
*/
@Override
public List<SeckillGoods> list(String time) {
// 組裝key
String key = "SeckillGoods_"+time;
return redisTemplate.boundHashOps(key).values();
}
}
4、商品詳情頁立即秒殺業務 - 下單前的業務
在該業務前還應該判斷是否已經登錄,沒有登錄則需要先登錄。如果登錄了,則進行下單前的業務。
主要作用是判斷用戶是否重複下單、是否還有庫存(有沒有必要去排隊)、創建排隊信息、調用多線程搶單任務等
/***
* 下單實現
* @param id 商品ID
* @param time 商品時區 - 開始時間
* @param username 用戶名
* @return
*/
@Override
public Boolean add(Long id, String time, String username) {
// Redis自增特性
// incr(key,value):讓指定key的值自增value->返回自增的值->單線程操作
// 第1次: incr(username,1)->1
// 第2次: incr(username,1)->2
// 利用自增,如果用戶多次提交或者多次排隊,則遞增值>1
Long userQueueCount = redisTemplate.boundHashOps("UserQueueCount").increment(username, 1);
if(userQueueCount > 1){ // 如果用戶多次排隊,自增的值肯定大於1
System.out.println("重複搶單.....");
// 100:錯誤狀態碼,重複排隊,前端就可以通過錯誤碼來進行不同的處理
throw new RuntimeException("100");
}
// 減少無效排隊 - 秒殺商品庫存爲0了,就沒有必要排隊了
Long size = redisTemplate.boundListOps("SeckillGoodsCountList_" + id).size();
if(size <= 0){
// 101:表示沒有庫存,前端就可以通過錯誤碼來進行不同的處理
throw new RuntimeException("101");
}
// 創建隊列所需的排隊信息 - 用戶名、創建時間、狀態(排隊中)、商品時間段(開始時間)
SeckillStatus seckillStatus = new SeckillStatus(username,new Date(),1,id, time);
// 將排隊信息存入到Redis緩存隊列中 - 該隊列作用是爲了多線程下單消費
redisTemplate.boundListOps("SeckillOrderQueue").leftPush(seckillStatus);
// 將排隊信息存入到Redis緩存中,key爲用戶名 - 該隊列作用是爲了根據用戶名來查詢這個排隊信息的狀態
// 狀態:1:排隊中,2:秒殺成功等待支付,3:支付超時,4:秒殺失敗,5:支付完成
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStatus);
// 異步操作調用 - 多線程下單,多線程裏面纔是真正做下單操作的
multiThreadingCreateOrder.createOrder();
return true;
}
5、多線程搶單 - 多線程做的任務
主要作用是判斷是否當前庫存能否搶單成功、搶單成功之後應該創建訂單信息、搶單之後同步信息
/**
* 多線程任務 - 多線程搶單
*/
@Component
public class MultiThreadingCreateOrder {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
@Autowired
private IdWorker idWorker;
@Autowired
private RabbitTemplate rabbitTemplate;
/**
*
*/
@Async
public void createOrder() {
try {
// 從Redis排隊隊列中獲取排隊信息
SeckillStatus seckillStauts = (SeckillStatus) redisTemplate.boundListOps("SeckillOrderQueue").rightPop();
// 用戶搶單數據 - 用戶名、商品秒殺時間段、商品ID
String username = seckillStauts.getUsername();
String time = seckillStauts.getTime();
Long id = seckillStauts.getGoodsId();
// 獲取Redis庫存隊列
Object sid = redisTemplate.boundListOps("SeckillGoodsCountList_" + id).rightPop();
if (sid == null) { // 取出來的爲空,則表明商品暫時售罄
// 清理相關排隊信息
clearQueue(seckillStauts);
// 這裏應該拋出一個售罄的異常,方便前端根據狀態碼有不同的處理
return;
}
// 查詢商品詳情
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_" + time).get(id);
if (goods != null && goods.getStockCount() > 0) { // 如果
// 創建訂單
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setId(idWorker.nextId()); // 訂單編號
seckillOrder.setSeckillId(id); // 商品id
seckillOrder.setMoney(goods.getCostPrice()); // 應付金額
seckillOrder.setUserId(username); // 用戶名
seckillOrder.setSellerId(goods.getSellerId()); // 商家id
seckillOrder.setCreateTime(new Date()); // 訂單創建時間
seckillOrder.setStatus("0"); // 訂單狀態 - 未付款
redisTemplate.boundHashOps("SeckillOrder").put(username, seckillOrder);
// 庫存削減 - Redis庫存自減,保證庫存數量是對的,因爲Redis自增特性是單線程,安全的
Long surplusCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(goods.getId(), -1);
// 將新的庫存信息設置到商品對象中去
goods.setStockCount(surplusCount.intValue());
// 商品庫存=0 -> 將數據同步到MySQL,並清理Redis緩存
if (surplusCount <= 0) {
// 修改MySQL中的數據 - 商品售罄,應該同步到MySQL持久層
seckillGoodsMapper.updateByPrimaryKeySelective(goods);
// 清理Redis緩存 - 商品售罄,則應該移除
redisTemplate.boundHashOps("SeckillGoods_" + time).delete(id);
} else {
// 未售罄 - 將數據同步到Redis,維持最新商品信息(含庫存)
redisTemplate.boundHashOps("SeckillGoods_" + time).put(id, goods);
}
// 變更Redis中的搶單狀態 - 從排隊到搶單成功未支付狀態
seckillStauts.setOrderId(seckillOrder.getId()); // 訂單Id
seckillStauts.setMoney(seckillOrder.getMoney().floatValue()); // 應付金額
seckillStauts.setStatus(2); // 搶單成功,待支付
redisTemplate.boundHashOps("UserQueueStatus").put(username, seckillStauts);
// 發送MQ消息
sendDelayMessage(seckillStauts);
}
System.out.println("----正在執行----");
} catch (Exception e) {
e.printStackTrace();
}
}
/***
* 清理用戶排隊信息
* @param seckillStauts 商品信息
*/
private void clearQueue(SeckillStatus seckillStauts) {
// 清理重複排隊標識隊列
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStauts.getUsername());
// 清理排隊狀態標識
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStauts.getUsername());
}
/***
* 延時消息發送
* @param seckillStatus
*/
public void sendDelayMessage(SeckillStatus seckillStatus) {
rabbitTemplate.convertAndSend(
"exchange.delay.order.begin", // 交換器
"delay", // 延遲消息
JSON.toJSONString(seckillStatus), // 發送數據
new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
// 消息有效期30分鐘
message.getMessageProperties().setExpiration(String.valueOf(10000 * 60 * 30));
return message;
}
});
}
}
6、前端:點擊立即秒殺之後的前端業務 - 定時器,定時查詢排隊狀態
主要作用是在點擊秒殺之後,前端應該在一段時間裏向後端發起請求,查詢排隊狀態,根據返回的狀態碼,來做不同的操作:提示搶單失敗、提示售罄、成功跳轉支付頁面、排隊狀態繼續接着查詢、超時提示服務器繁忙
/**
* 查詢訂單搶單狀態 - 點擊立即秒殺即調用
*/
queryStatus:function () {
// 120秒查詢搶購信息
let count = 120;
// 定時查詢 -> window.setInterval()
let queryClock = window.setInterval(function () {
// 時間遞減
count--;
// 根據狀態判斷對應操作
axios.get('/seckill/order/query.do').then(function (response) {
// 403->未登錄->登錄
if(response.data.code === 403){
location.href='/redirect/back.do';
}else if(response.data.code === 1){
// 1->排隊中->繼續定時查詢
app.msg='正在排隊....'+count;
}else if(response.data.code === 2){
// 2->搶單成功
app.msg='搶單成功,即將進入支付!';
// 跳轉到支付頁->攜帶訂單號+訂單金額
location.href='/pay.html?orderId='+response.data.other.orderId+'&money='+response.data.other.money*100;
}else{
// 0 -> 搶單失敗
if(response.data.code==0){
// 停止查詢
window.clearInterval(queryClock);
app.msg='服務器繁忙,請稍後再試!';
}else if(response.data.code==404){
// 404 -> 找不到數據
app.msg='搶單失敗,稍後再試!';
}
//停止查詢
window.clearInterval(queryClock);
}
});
if(count <= 0){ // 定時結束
// 停止查詢
window.clearInterval(queryClock);
app.msg='服務器繁忙,請稍後再試!';
}
},1000);
}
7、前端:進入支付頁面之後,顯示訂單信息,選擇支付方式(比如微信支付) ,則跳轉到微信支付的頁面。
在微信支付頁面則需要創建一個付款二維碼、創建一個定時器隔一段時間查詢一次是否已經支付、已經支付則跳轉至支付成功頁面並且更新訂單狀態。
// 創建支付二維碼
var qrcode = new QRCode(document.getElementById("qrcode"), {
width : 200,
height : 200
});
new Vue({
el: '#app',
data(){
return {
orderId:"", // 訂單編號
money:"", // 支付金額
timer: null, // 定時器
}
},
methods:{
/**
* 創建二維碼
*/
createNative(){
let orderId = getQueryString("orderId"); //獲取訂單Id
// 請求生成二維碼的信息 - 怎麼生成的二維碼業務需根據實際情況做
axios.get(`/pay/createNative.do?orderId=${orderId}`).then(response => {
if(response.data.out_trade_no!=null){
qrcode.makeCode(response.data.code_url); // 生成二維碼
this.orderId= response.data.out_trade_no; // 訂單id
this.money= response.data.total_fee; // 應付金額
this.setTimer(); // 設置定時器
}else{
// 準確的說應該提示沒有權限或其他操作
// 應該根據返回結果碼來做,如果訂單不存在,則應該是無權限的
// 如果訂單存在,但已經支付,則應該是提示已支付信息的
// 具體業務具體做,這裏不細談,只談個流程
location.href='payfail.html'; // 跳轉支付失敗頁面
}
});
},
/**
* 查詢支付狀態
*/
queryPayStatus(){
axios.get(`/pay/queryPayStatus.do?orderId=${this.orderId}`).then(response => {
if(response.data.result_code=='SUCCESS'){ // 有成功的返回結果
if(response.data.trade_state=='SUCCESS'){ // 交易狀態爲成功
location.href='paysuccess.html'; // 跳轉支付成功頁面
}
}else{
location.href='payfail.html'; // 跳轉支付失敗頁面
}
});
},
/**
* 設置定時器
*/
setTimer() {
if(this.timer == null) {
this.timer = setInterval( () => {
console.log('開始定時...每過3秒執行一次')
this.queryPayStatus()//查詢支付狀態
}, 3000)
}
}
},
created(){
// 進入該頁面則就要創建支付二維碼和清空定時器
this.createNative();
clearInterval(this.timer);
this.timer = null;
},
destroyed: function () {
// 每次離開當前界面時,清除定時器
clearInterval(this.timer);
this.timer = null;
},
});
具體過程如下:
8、前端定時器一直在查詢是否已經支付 - 支付之後的業務,跳轉支付成功頁面
該方法作用是返回支付信息(是否已經支付成功)
@Override
public Map queryPayStatus(String orderId) {
Map param=new HashMap();
param.put("appid", appid); // 公衆賬號ID
param.put("mch_id", partner);// 商戶號
param.put("out_trade_no", orderId);// 訂單號
param.put("nonce_str", WXPayUtil.generateNonceStr());// 隨機字符串
String url="https://api.mch.weixin.qq.com/pay/orderquery";
try {
String xmlParam = WXPayUtil.generateSignedXml(param, partnerkey);
HttpClient client=new HttpClient(url);
client.setHttps(true);
client.setXmlParam(xmlParam);
client.post();
String result = client.getContent();
Map<String, String> map = WXPayUtil.xmlToMap(result);
System.out.println(map);
return map;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
流程如下:
9、監聽延時消息,根據延時消息查詢訂單狀態,如果30分鐘後未支付,則需要關閉微信支付,且要刪除該訂單信息以及用戶排隊信息,並恢復庫存。
/**
* 延時信息監聽器
*/
public class OrderMessageListener implements MessageListener {
// 注入相關類
@Autowired
private RedisTemplate redisTemplate;
@Reference
private WeixinPayService weixinPayService;
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
/***
* 消息監聽
* @param message
*/
@Override
public void onMessage(Message message) {
String content = new String(message.getBody());
System.out.println("監聽到的消息:" + content);
// 回滾操作
rollbackOrder(JSON.parseObject(content,SeckillStatus.class));
}
/*****
* 訂單回滾操作
* @param seckillStatus
*/
public void rollbackOrder(SeckillStatus seckillStatus){
if(seckillStatus == null){
return;
}
// 判斷Redis中是否存在對應的訂單
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(seckillStatus.getUsername());
// 如果存在,開始回滾 - 因爲不存在只會在支付成功後同步到MySQL,並會清理Redis中的緩存
if(seckillOrder!=null){
// 1.關閉微信支付
Map<String,String> map = weixinPayService.closePay(seckillStatus.getOrderId().toString());
// 關閉微信支付成功
if(map.get("return_code").equals("SUCCESS") && map.get("result_code").equals("SUCCESS")){
// 2.刪除Redis中的用戶訂單
redisTemplate.boundHashOps("SeckillOrder").delete(seckillOrder.getUserId());
// 3.查詢出商品數據
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_" + seckillStatus.getTime()).get(seckillStatus.getGoodsId());
if(goods == null){ // 如果爲null,說明Redis中庫存爲0,已經被移除
// 數據庫中加載數據
goods = seckillGoodsMapper.selectByPrimaryKey(seckillStatus.getGoodsId());
}
// 4.遞增庫存1 incr
Long seckillGoodsCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillStatus.getGoodsId(), 1);
goods.setStockCount(seckillGoodsCount.intValue());
// 5.將商品數據同步到Redis
redisTemplate.boundHashOps("SeckillGoods_" + seckillStatus.getTime()).put(seckillStatus.getGoodsId(),goods);
redisTemplate.boundListOps("SeckillGoodsCountList_"+seckillStatus.getGoodsId()).leftPush(seckillStatus.getGoodsId());
// 6.清理用戶搶單排隊信息
// 清理重複排隊標識
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());
// 清理排隊狀態存儲信息
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
}
}
}
}
整個流程就是這樣,具體實現可以參考某馬的青橙商城,我是根據他的源碼寫的本文。主要是想熟悉下流程,中間具體業務還得根據自己的項目需求來進行。 文章寫得有點久,就沒有檢查,可能裏面存在錯誤,由於太困,也就沒打算檢查。如果有朋友有耐心看到本文,希望可以給予一些指點和幫助,因爲我不是很清楚實際業務中秒殺系統是否是這樣做的,畢竟我也是根據別人源碼所提取的。