暢購商城(十三):秒殺系統「上」

Robod丶發表於2020-09-20

好好學習,天天向上

本文已收錄至我的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);
}

前端將使用者名稱傳入進來,然後查詢訂單狀態,如果查詢出來的狀態是待支付的話,就可以進入支付流程了。

總結

好了,這篇文章到這裡就結束了,主要介紹了一下秒殺的流程,然後實現了定時任務,秒殺頻道頁,秒殺商品詳情頁和多執行緒搶單的功能。這個秒殺系統還沒有結束,還存在很多問題,在下一篇文章中,將會修改現有的問題並繼續完善秒殺的流程。讓我們下期再見!

碼字不易,可以的話,給我來個點贊收藏關注

程式碼:https://github.com/RobodLee/changgou

相關文章