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

好好學習,每天向上前端

本文已收錄至個人Github倉庫DayDayUP:github.com/RobodLee/DayDayUP,歡迎Starjava

流程分析

上面這張圖是整個秒殺系統的流程。簡單介紹一下:git

秒殺是一個併發量很大的系統,數據吞吐量都很大,MySQL的數據是保存在硬盤中的,數據吞吐的能力知足不了整個秒殺系統的需求。爲了提升系統的訪問速度,咱們定時將秒殺商品從MySQL加載進Redis,由於Redis的數據是保存在內存中的,速度很是快,能夠知足很高的吞吐量。github

用戶訪問秒殺系統,請求到了OpenResty,OpenResty從Redis中加載秒殺商品,而後用戶來到了秒殺列表頁。當用戶點擊某個秒殺商品時,OpenResty再從Redis中加載秒殺商品詳情信息,接着用戶就來到了秒殺商品詳情頁。redis

當進入到商品詳情頁以後用戶就能夠點擊下單了,點擊下單的時候,OpenResty會檢查商品是否還有庫存,沒有庫存就下單失敗。有庫存的話還須要檢查一下用戶是否登陸,沒有登陸的話再到OAuth2.0認證服務那邊去登陸,登陸成功後再進入到秒殺微服務中,開始正式的下單流程。sql

理論上這時候還要對用戶進行一些合法性檢測,好比帳號是否異常等,可是這太耗時了,爲了減小系統響應的時間,用戶檢測這一步先省略。直接讓用戶進行排隊,排隊就是將用戶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