秒殺系統 - 簡單理論實現

秒殺系統中,操作一般都是比較複雜的,而且併發量特別高。比如,檢查當前賬號操作是否已經秒殺過該商品,檢查該賬號是否存在存在刷單行爲,記錄用戶操作日誌等等。

而且我們熟悉的秒殺都是分時間段的,比如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());
            }
        }
    }
}

 

 

整個流程就是這樣,具體實現可以參考某馬的青橙商城,我是根據他的源碼寫的本文。主要是想熟悉下流程,中間具體業務還得根據自己的項目需求來進行。 文章寫得有點久,就沒有檢查,可能裏面存在錯誤,由於太困,也就沒打算檢查。如果有朋友有耐心看到本文,希望可以給予一些指點和幫助,因爲我不是很清楚實際業務中秒殺系統是否是這樣做的,畢竟我也是根據別人源碼所提取的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章