暢購商城第十四天

第14章 秒殺

學習目標

  • 防止秒殺重複排隊

    重複排隊:一個人搶購商品,如果沒有支付,不允許重複排隊搶購
    
  • 併發超賣問題解決

    1個商品賣給多個人:1商品多訂單
    
  • 秒殺訂單支付

    秒殺支付:支付流程需要調整
    
  • 超時支付訂單庫存回滾

    1.RabbitMQ延時隊列
    2.利用延時隊列實現支付訂單的監聽,根據訂單支付狀況進行訂單數據庫回滾
    

1 防止秒殺重複排隊

用戶每次搶單的時候,一旦排隊,我們設置一個自增值,讓該值的初始值爲1,每次進入搶單的時候,對它進行遞增,如果值>1,則表明已經排隊,不允許重複排隊,如果重複排隊,則對外拋出異常,並拋出異常信息100表示已經正在排隊。

1.1 後臺排隊記錄

修改SeckillOrderServiceImpl的add方法,新增遞增值判斷是否排隊中,代碼如下:
在這裏插入圖片描述
上圖代碼如下:

//遞增,判斷是否排隊
Long userQueueCount = redisTemplate.boundHashOps("UserQueueCount").increment(username, 1);
if(userQueueCount>1){
    //100:表示有重複搶單
    throw new RuntimeException(String.valueOf(StatusCode.REPERROR));
}

2 併發超賣問題解決

超賣問題,這裏是指多人搶購同一商品的時候,多人同時判斷是否有庫存,如果只剩一個,則都會判斷有庫存,此時會導致超賣現象產生,也就是一個商品下了多個訂單的現象。

2.1 思路分析

在這裏插入圖片描述
解決超賣問題,可以利用Redis隊列實現,給每件商品創建一個獨立的商品個數隊列,例如:A商品有2個,A商品的ID爲1001,則可以創建一個隊列,key=SeckillGoodsCountList_1001,往該隊列中塞2次該商品ID。

每次給用戶下單的時候,先從隊列中取數據,如果能取到數據,則表明有庫存,如果取不到,則表明沒有庫存,這樣就可以防止超賣問題產生了。

在我們隊Redis進行操作的時候,很多時候,都是先將數據查詢出來,在內存中修改,然後存入到Redis,在併發場景,會出現數據錯亂問題,爲了控制數量準確,我們單獨將商品數量整一個自增鍵,自增鍵是線程安全的,所以不擔心併發場景的問題。

在這裏插入圖片描述

2.2 代碼實現

每次將商品壓入Redis緩存的時候,另外多創建一個商品的隊列。

修改SeckillGoodsPushTask,添加一個pushIds方法,用於將指定商品ID放入到指定的數字中,代碼如下:

/***
 * 將商品ID存入到數組中
 * @param len:長度
 * @param id :值
 * @return
 */
public Long[] pushIds(int len,Long id){
    Long[] ids = new Long[len];
    for (int i = 0; i <ids.length ; i++) {
        ids[i]=id;
    }
    return ids;
}

修改SeckillGoodsPushTask的loadGoodsPushRedis方法,添加隊列操作,代碼如下:
在這裏插入圖片描述
上圖代碼如下:

//商品數據隊列存儲,防止高併發超賣
Long[] ids = pushIds(seckillGood.getStockCount(), seckillGood.getId());
redisTemplate.boundListOps("SeckillGoodsCountList_"+seckillGood.getId()).leftPushAll(ids);
//自增計數器
redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillGood.getId(),seckillGood.getStockCount());

2.3 超賣控制

修改多線程下單方法,分別修改數量控制,以及售罄後用戶搶單排隊信息的清理,修改代碼如下圖:
在這裏插入圖片描述
上圖代碼如下:

/***
 * 多線程下單操作
 */
@Async
public void createOrder(){
    //從隊列中獲取排隊信息
    SeckillStatus seckillStatus = (SeckillStatus) redisTemplate.boundListOps("SeckillOrderQueue").rightPop();

    try {
        //從隊列中獲取一個商品
        Object sgood = redisTemplate.boundListOps("SeckillGoodsCountList_" + seckillStatus.getGoodsId()).rightPop();
        if(sgood==null){
            //清理當前用戶的排隊信息
            clearQueue(seckillStatus);
            return;
        }

        //時間區間
        String time = seckillStatus.getTime();
        //用戶登錄名
        String username=seckillStatus.getUsername();
        //用戶搶購商品
        Long id = seckillStatus.getGoodsId();

        //獲取商品數據
        SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_" + time).get(id);

        //如果有庫存,則創建秒殺商品訂單
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setId(idWorker.nextId());
        seckillOrder.setSeckillId(id);
        seckillOrder.setMoney(goods.getCostPrice());
        seckillOrder.setUserId(username);
        seckillOrder.setCreateTime(new Date());
        seckillOrder.setStatus("0");

        //將秒殺訂單存入到Redis中
        redisTemplate.boundHashOps("SeckillOrder").put(username,seckillOrder);

        //商品庫存-1
        Long surplusCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(id, -1);//商品數量遞減
        goods.setStockCount(surplusCount.intValue());    //根據計數器統計

        //判斷當前商品是否還有庫存
        if(surplusCount<=0){
            //並且將商品數據同步到MySQL中
            seckillGoodsMapper.updateByPrimaryKeySelective(goods);
            //如果沒有庫存,則清空Redis緩存中該商品
            redisTemplate.boundHashOps("SeckillGoods_" + time).delete(id);
        }else{
            //如果有庫存,則直數據重置到Reids中
            redisTemplate.boundHashOps("SeckillGoods_" + time).put(id,goods);
        }
        //搶單成功,更新搶單狀態,排隊->等待支付
        seckillStatus.setStatus(2);
        seckillStatus.setOrderId(seckillOrder.getId());
        seckillStatus.setMoney(seckillOrder.getMoney().floatValue());
        redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStatus);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

/***
 * 清理用戶排隊信息
 * @param seckillStatus
 */
public void clearQueue(SeckillStatus seckillStatus){
    //清理排隊標示
    redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());

    //清理搶單標示
    redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
}

3 訂單支付

在這裏插入圖片描述
完成秒殺下訂單後,進入支付頁面,此時前端會每3秒中向後臺發送一次請求用於判斷當前用戶訂單是否完成支付,如果完成了支付,則需要清理掉排隊信息,並且需要修改訂單狀態信息。

3.2 創建支付二維碼

下單成功後,會跳轉到支付選擇頁面,在支付選擇頁面要顯示訂單編號和訂單金額,所以我們需要在下單的時候,將訂單金額以及訂單編號信息存儲到用戶查詢對象中。

選擇微信支付後,會跳轉到微信支付頁面,微信支付頁面會根據用戶名查看用戶秒殺訂單,並根據用戶秒殺訂單的ID創建預支付信息並獲取二維碼信息,展示給用戶看,此時頁面每3秒查詢一次支付狀態,如果支付成功,需要修改訂單狀態信息。

3.2.1 回顯訂單號、金額

下單後,進入支付選擇頁面,需要顯示訂單號和訂單金額,所以需要在用戶下單後將該數據傳入到pay.html頁面,所以查詢訂單狀態的時候,需要將訂單號和金額封裝到查詢的信息中,修改查詢訂單裝的方法加入他們即可。

修改SeckillOrderController的queryStatus方法,代碼如下:
在這裏插入圖片描述
上圖代碼如下:

return new Result(true,seckillStatus.getStatus(),"搶購狀態",seckillStatus);

使用Postman測試,效果如下:

http://localhost:18084/seckill/order/query
在這裏插入圖片描述

3.2.2 創建二維碼

用戶創建二維碼,可以先查詢用戶的秒殺訂單搶單信息,然後再發送請求到支付微服務中創建二維碼,將訂單編號以及訂單對應的金額傳遞到支付微服務:/weixin/pay/create/native

使用Postman測試效果如下:

http://localhost:9022/weixin/pay/create/native?outtradeno=1132510782836314112&money=1
在這裏插入圖片描述

3.3 支付流程分析

在這裏插入圖片描述
如上圖,步驟分析如下:

1.用戶搶單,經過秒殺系統實現搶單,下單後會將向MQ發送一個延時隊列消息,包含搶單信息,延時半小時後才能監聽到
2.秒殺系統同時啓用延時消息監聽,一旦監聽到訂單搶單信息,判斷Redis緩存中是否存在訂單信息,如果存在,則回滾
3.秒殺系統還啓動支付回調信息監聽,如果支付完成,則將訂單喫句話到MySQL,如果沒完成,清理排隊信息回滾庫存
4.每次秒殺下單後調用支付系統,創建二維碼,如果用戶支付成功了,微信系統會將支付信息發送給支付系統指定的回調地址,支付系統收到信息後,將信息發送給MQ,第3個步驟就可以監聽到消息了。

3.4 支付回調更新

支付回調這一塊代碼已經實現了,但之前實現的是訂單信息的回調數據發送給MQ,指定了對應的隊列,不過現在需要實現的是秒殺信息發送給指定隊列,所以之前的代碼那塊需要動態指定隊列。

3.4.1 支付回調隊列指定

關於指定隊列如下:

1.創建支付二維碼需要指定隊列
2.回調地址回調的時候,獲取支付二維碼指定的隊列,將支付信息發送到指定隊列中

在微信支付統一下單API中,有一個附加參數,如下:

attach:附加數據,String(127),在查詢API和支付通知中原樣返回,可作爲自定義參數使用。

我們可以在創建二維碼的時候,指定該參數,該參數用於指定回調支付信息的對應隊列,每次回調的時候,會獲取該參數,然後將回調信息發送到該參數對應的隊列去。

3.4.1.1 改造支付方法

修改支付微服務的WeixinPayController的createNative方法,代碼如下:
在這裏插入圖片描述

修改支付微服務的WeixinPayService的createNative方法,代碼如下:
在這裏插入圖片描述

修改支付微服務的WeixinPayServiceImpl的createNative方法,代碼如下:
在這裏插入圖片描述

我們創建二維碼的時候,需要將下面幾個參數傳遞過去

username:用戶名,可以根據用戶名查詢用戶排隊信息
outtradeno:商戶訂單號,下單必須
money:支付金額,支付必須
queue:隊列名字,回調的時候,可以知道將支付信息發送到哪個隊列

修改WeixinPayApplication,添加對應隊列以及對應交換機綁定,代碼如下:

@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
public class WeixinPayApplication {

    public static void main(String[] args) {
        SpringApplication.run(WeixinPayApplication.class,args);
    }

    @Autowired
    private Environment env;


    /***
     * 創建DirectExchange交換機
     * @return
     */
    @Bean
    public DirectExchange basicExchange(){
        return new DirectExchange(env.getProperty("mq.pay.exchange.order"), true,false);
    }

    /***
     * 創建隊列
     * @return
     */
    @Bean(name = "queueOrder")
    public Queue queueOrder(){
        return new Queue(env.getProperty("mq.pay.queue.order"), true);
    }

    /***
     * 創建秒殺隊列
     * @return
     */
    @Bean(name = "queueSeckillOrder")
    public Queue queueSeckillOrder(){
        return new Queue(env.getProperty("mq.pay.queue.seckillorder"), true);
    }

    /****
     * 隊列綁定到交換機上
     * @return
     */
    @Bean
    public Binding basicBindingOrder(){
        return BindingBuilder
                .bind(queueOrder())
                .to(basicExchange())
                .with(env.getProperty("mq.pay.routing.orderkey"));
    }

    /****
     * 隊列綁定到交換機上
     * @return
     */
    @Bean
    public Binding basicBindingSeckillOrder(){
        return BindingBuilder
                .bind(queueSeckillOrder())
                .to(basicExchange())
                .with(env.getProperty("mq.pay.routing.seckillorderkey"));
    }
}

修改application.yml,添加如下配置

#位置支付交換機和隊列
mq:
  pay:
    exchange:
      order: exchange.order
      seckillorder: exchange.seckillorder
    queue:
      order: queue.order
      seckillorder: queue.seckillorder
    routing:
      key: queue.order
      seckillkey: queue.seckillorder
3.4.1.2 測試

使用Postman創建二維碼測試

http://localhost:18092/weixin/pay/create/native?username=szitheima&out_trade_no=1132510782836314121&total_fee=1&queue=queue.seckillorder&routingkey=queue.seckillorder&exchange=exchange.seckillorder
在這裏插入圖片描述
以後每次支付,都需要帶上對應的參數,包括前面的訂單支付。

3.4.1.3 改造支付回調方法

修改com.changgou.pay.controller.WeixinPayController的notifyUrl方法,獲取自定義參數,並轉成Map,獲取queue地址,並將支付信息發送到綁定的queue中,代碼如下:
在這裏插入圖片描述

3.4.2 支付狀態監聽

支付狀態通過回調地址發送給MQ之後,我們需要在秒殺系統中監聽支付信息,如果用戶已支付,則修改用戶訂單狀態,如果支付失敗,則直接刪除訂單,回滾庫存。

在秒殺工程中創建com.changgou.seckill.consumer.SeckillOrderPayMessageListener,實現監聽消息,代碼如下:

@Component
@RabbitListener(queues = "${mq.pay.queue.seckillorder}")
public class SeckillOrderPayMessageListener {


    /**
     * 監聽消費消息
     * @param message
     */
    @RabbitHandler
    public void consumeMessage(@Payload String message){
        System.out.println(message);
        //將消息轉換成Map對象
        Map<String,String> resultMap = JSON.parseObject(message,Map.class);
        System.out.println("監聽到的消息:"+resultMap);
    }
}

修改SeckillApplication創建對應的隊列以及綁定對應交換機。

@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
@MapperScan(basePackages = {"com.changgou.seckill.dao"})
@EnableScheduling
@EnableAsync
public class SeckillApplication {


    public static void main(String[] args) {
        SpringApplication.run(SeckillApplication.class,args);
    }

    @Bean
    public IdWorker idWorker(){
        return new IdWorker(1,1);
    }

    @Autowired
    private Environment env;


    /***
     * 創建DirectExchange交換機
     * @return
     */
    @Bean
    public DirectExchange basicExchange(){
        return new DirectExchange(env.getProperty("mq.pay.exchange.order"), true,false);
    }

    /***
     * 創建隊列
     * @return
     */
    @Bean(name = "queueOrder")
    public Queue queueOrder(){
        return new Queue(env.getProperty("mq.pay.queue.order"), true);
    }

    /***
     * 創建秒殺隊列
     * @return
     */
    @Bean(name = "queueSeckillOrder")
    public Queue queueSeckillOrder(){
        return new Queue(env.getProperty("mq.pay.queue.seckillorder"), true);
    }

    /****
     * 隊列綁定到交換機上
     * @return
     */
    @Bean
    public Binding basicBindingOrder(){
        return BindingBuilder
                .bind(queueOrder())
                .to(basicExchange())
                .with(env.getProperty("mq.pay.routing.orderkey"));
    }


    /****
     * 隊列綁定到交換機上
     * @return
     */
    @Bean
    public Binding basicBindingSeckillOrder(){
        return BindingBuilder
                .bind(queueSeckillOrder())
                .to(basicExchange())
                .with(env.getProperty("mq.pay.routing.seckillorderkey"));
    }
}

修改application.yml文件,添加如下配置:

#位置支付交換機和隊列
mq:
  pay:
    exchange:
      order: exchange.order
      seckillorder: exchange.seckillorder
    queue:
      order: queue.order
      seckillorder: queue.seckillorder
    routing:
      key: queue.order
      seckillkey: queue.seckillorder

3.4.3 修改訂單狀態

監聽到支付信息後,根據支付信息判斷,如果用戶支付成功,則修改訂單信息,並將訂單入庫,刪除用戶排隊信息,如果用戶支付失敗,則刪除訂單信息,回滾庫存,刪除用戶排隊信息。

3.4.3.1 業務層

修改SeckillOrderService,添加修改訂單方法,代碼如下

/***
 * 更新訂單狀態
 * @param out_trade_no
 * @param transaction_id
 * @param username
 */
void updatePayStatus(String out_trade_no, String transaction_id,String username);

修改SeckillOrderServiceImpl,添加修改訂單方法實現,代碼如下:

/***
 * 更新訂單狀態
 * @param out_trade_no
 * @param transaction_id
 * @param username
 */
@Override
public void updatePayStatus(String out_trade_no, String transaction_id,String username) {
    //訂單數據從Redis數據庫查詢出來
    SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(username);
    //修改狀態
    seckillOrder.setStatus("1");

    //支付時間
    seckillOrder.setPayTime(new Date());
    //同步到MySQL中
    seckillOrderMapper.insertSelective(seckillOrder);

    //清空Redis緩存
    redisTemplate.boundHashOps("SeckillOrder").delete(username);

    //清空用戶排隊數據
    redisTemplate.boundHashOps("UserQueueCount").delete(username);

    //刪除搶購狀態信息
    redisTemplate.boundHashOps("UserQueueStatus").delete(username);
}
3.4.3.2 修改訂單對接

修改微信支付狀態監聽的代碼,當用戶支付成功後,修改訂單狀態,也就是調用上面的方法,代碼如下:
在這裏插入圖片描述

3.4.4 刪除訂單回滾庫存

如果用戶支付失敗,我們需要刪除用戶訂單數據,並回滾庫存。

3.4.4.1 業務層實現

修改SeckillOrderService,創建一個關閉訂單方法,代碼如下:

/***
 * 關閉訂單,回滾庫存
 */
void closeOrder(String username);

修改SeckillOrderServiceImpl,創建一個關閉訂單實現方法,代碼如下:

/***
 * 關閉訂單,回滾庫存
 * @param username
 */
@Override
public void closeOrder(String username) {
    //將消息轉換成SeckillStatus
    SeckillStatus seckillStatus = (SeckillStatus) redisTemplate.boundHashOps("UserQueueStatus").get(username);
    //獲取Redis中訂單信息
    SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(username);

    //如果Redis中有訂單信息,說明用戶未支付
    if(seckillStatus!=null && seckillOrder!=null){
        //刪除訂單
        redisTemplate.boundHashOps("SeckillOrder").delete(username);
        //回滾庫存
        //1)從Redis中獲取該商品
        SeckillGoods seckillGoods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_"+seckillStatus.getTime()).get(seckillStatus.getGoodsId());

        //2)如果Redis中沒有,則從數據庫中加載
        if(seckillGoods==null){
            seckillGoods = seckillGoodsMapper.selectByPrimaryKey(seckillStatus.getGoodsId());
        }

        //3)數量+1  (遞增數量+1,隊列數量+1)
        Long surplusCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillStatus.getGoodsId(), 1);
        seckillGoods.setStockCount(surplusCount.intValue());
        redisTemplate.boundListOps("SeckillGoodsCountList_" + seckillStatus.getGoodsId()).leftPush(seckillStatus.getGoodsId());

        //4)數據同步到Redis中
        redisTemplate.boundHashOps("SeckillGoods_"+seckillStatus.getTime()).put(seckillStatus.getGoodsId(),seckillGoods);

        //清理排隊標示
        redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());

        //清理搶單標示
        redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
    }
}
3.4.4.2 調用刪除訂單

修改SeckillOrderPayMessageListener,在用戶支付失敗後調用關閉訂單方法,代碼如下:

//支付失敗,刪除訂單
seckillOrderService.closeOrder(attachMap.get("username"));
3.4.4.3 測試

使用Postman完整請求創建二維碼下單測試一次。

商品ID:1131814854034853888

數量:49
在這裏插入圖片描述
下單:

http://localhost:18084/seckill/order/add?id=1131814854034853888&time=2019052614

下單後,Redis數據
在這裏插入圖片描述

下單查詢:

http://localhost:18084/seckill/order/query

創建二維碼:

http://localhost:9022/weixin/pay/create/native?username=szitheima&outtradeno=1132530879663575040&money=1&queue=queue.seckillorder

秒殺搶單後,商品數量變化:
在這裏插入圖片描述

支付微服務回調方法控制檯:

{
	nonce_str=Mnv06RIaIwxzg3bA, 
    code_url=weixin://wxpay/bizpayurl?pr=iTidd5h, 
    appid=wx8397f8696b538317, 
    sign=1436E43FBA8A171D79A9B78B61F0A7AB, 
    trade_type=NATIVE, 
    return_msg=OK, 
    result_code=SUCCESS, 
    mch_id=1473426802, 
    return_code=SUCCESS, 
    prepay_id=wx2614182102123859e3869a853739004200
}
{money=1, queue=queue.seckillorder, username=szitheima, outtradeno=1132530879663575040}

訂單微服務控制檯輸出

{
    transaction_id=4200000289201905268232990890,
    nonce_str=a1aefe00a9bc4e8bb66a892dba38eb42,
    bank_type=CMB_CREDIT,
    openid=oNpSGwUp-194-Svy3JnVlAxtdLkc,
    sign=56679BC02CC82204635434817C1FCA46,
    fee_type=CNY,
    mch_id=1473426802,
    cash_fee=1,
    out_trade_no=1132530879663575040,
    appid=wx8397f8696b538317,
    total_fee=1,
    trade_type=NATIVE,
    result_code=SUCCESS,
    attach={
    "username": "szitheima",
    "outtradeno": "1132530879663575040",
    "money": "1",
    "queue": "queue.seckillorder"
  }, time_end=20190526141849, is_subscribe=N, return_code=SUCCESS
}

附錄:

支付微服務application.yml

server:
  port: 9022
spring:
  application:
    name: pay
  main:
    allow-bean-definition-overriding: true
  rabbitmq:
    host: 127.0.0.1 #mq的服務器地址
    username: guest #賬號
    password: guest #密碼
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:6868/eureka
  instance:
    prefer-ip-address: true
feign:
  hystrix:
    enabled: true
#hystrix 配置
hystrix:
  command:
    default:
      execution:
        timeout:
        #如果enabled設置爲false,則請求超時交給ribbon控制
          enabled: true
        isolation:
          strategy: SEMAPHORE

#微信支付信息配置
weixin:
  appid: wx8397f8696b538317
  partner: 1473426802
  partnerkey: T6m9iK73b0kn9g5v426MKfHQH7X8rKwb
  notifyurl: http://2cw4969042.wicp.vip:36446/weixin/pay/notify/url

#位置支付交換機和隊列
mq:
  pay:
    exchange:
      order: exchange.order
    queue:
      order: queue.order
      seckillorder: queue.seckillorder
    routing:
      orderkey: queue.order
      seckillorderkey: queue.seckillorder

秒殺微服務application.yml配置

server:
  port: 18084
spring:
  application:
    name: seckill
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/changgou_seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: itcast
  rabbitmq:
    host: 127.0.0.1 #mq的服務器地址
    username: guest #賬號
    password: guest #密碼
  main:
    allow-bean-definition-overriding: true
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:6868/eureka
  instance:
    prefer-ip-address: true
feign:
  hystrix:
    enabled: true
mybatis:
  configuration:
    map-underscore-to-camel-case: true
  mapper-locations: classpath:mapper/*Mapper.xml
  type-aliases-package: com.changgou.seckill.pojo

#hystrix 配置
hystrix:
  command:
    default:
      execution:
        timeout:
        #如果enabled設置爲false,則請求超時交給ribbon控制
          enabled: true
        isolation:
          thread:
            timeoutInMilliseconds: 10000
          strategy: SEMAPHORE
#位置支付交換機和隊列
mq:
  pay:
    exchange:
      order: exchange.order
    queue:
      order: queue.order
      seckillorder: queue.seckillorder
    routing:
      orderkey: queue.order
      seckillorderkey: queue.seckillorder

4 RabbitMQ延時消息隊列

4.1 延時隊列介紹

延時隊列即放置在該隊列裏面的消息是不需要立即消費的,而是等待一段時間之後取出消費。
那麼,爲什麼需要延遲消費呢?我們來看以下的場景

網上商城下訂單後30分鐘後沒有完成支付,取消訂單(如:淘寶、去哪兒網)
系統創建了預約之後,需要在預約時間到達前一小時提醒被預約的雙方參會
系統中的業務失敗之後,需要重試
這些場景都非常常見,我們可以思考,比如第二個需求,系統創建了預約之後,需要在預約時間到達前一小時提醒被預約的雙方參會。那麼一天之中肯定是會有很多個預約的,時間也是不一定的,假設現在有1點 2點 3點 三個預約,如何讓系統知道在當前時間等於0點 1點 2點給用戶發送信息呢,是不是需要一個輪詢,一直去查看所有的預約,比對當前的系統時間和預約提前一小時的時間是否相等呢?這樣做非常浪費資源而且輪詢的時間間隔不好控制。如果我們使用延時消息隊列呢,我們在創建時把需要通知的預約放入消息中間件中,並且設置該消息的過期時間,等過期時間到達時再取出消費即可。

Rabbitmq實現延時隊列一般而言有兩種形式:
第一種方式:利用兩個特性: Time To Live(TTL)、Dead Letter Exchanges(DLX)[A隊列過期->轉發給B隊列]

第二種方式:利用rabbitmq中的插件x-delay-message

4.2 TTL DLX實現延時隊列

4.2.1 TTL DLX介紹

TTL
RabbitMQ可以針對隊列設置x-expires(則隊列中所有的消息都有相同的過期時間)或者針對Message設置x-message-ttl(對消息進行單獨設置,每條消息TTL可以不同),來控制消息的生存時間,如果超時(兩者同時設置以最先到期的時間爲準),則消息變爲dead letter(死信)

Dead Letter Exchanges(DLX)
RabbitMQ的Queue可以配置x-dead-letter-exchange和x-dead-letter-routing-key(可選)兩個參數,如果隊列內出現了dead letter,則按照這兩個參數重新路由轉發到指定的隊列。
x-dead-letter-exchange:出現dead letter之後將dead letter重新發送到指定exchange

x-dead-letter-routing-key:出現dead letter之後將dead letter重新按照指定的routing-key發送
在這裏插入圖片描述

4.2.3 DLX延時隊列實現

4.2.3.1 創建工程

創建springboot_rabbitmq_delay工程,並引入相關依賴

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>changgou_parent</artifactId>
        <groupId>com.changgou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>springboot_rabbitmq_delay</artifactId>

    <dependencies>
        <!--starter-web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--加入ampq-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!--測試-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
</project>

application.yml配置

spring:
  application:
    name: springboot-demo
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    password: guest
    username: guest
4.2.3.2 隊列創建

創建2個隊列,用於接收消息的叫延時隊列queue.message.delay,用於轉發消息的隊列叫queue.message,同時創建一個交換機,代碼如下:

@Configuration
public class QueueConfig {

    /** 短信發送隊列 */
    public static final String QUEUE_MESSAGE = "queue.message";

    /** 交換機 */
    public static final String DLX_EXCHANGE = "dlx.exchange";

    /** 短信發送隊列 延遲緩衝(按消息) */
    public static final String QUEUE_MESSAGE_DELAY = "queue.message.delay";

    /**
     * 短信發送隊列
     * @return
     */
    @Bean
    public Queue messageQueue() {
        return new Queue(QUEUE_MESSAGE, true);
    }

    /**
     * 短信發送隊列
     * @return
     */
    @Bean
    public Queue delayMessageQueue() {
        return QueueBuilder.durable(QUEUE_MESSAGE_DELAY)
                .withArgument("x-dead-letter-exchange", DLX_EXCHANGE)        // 消息超時進入死信隊列,綁定死信隊列交換機
                .withArgument("x-dead-letter-routing-key", QUEUE_MESSAGE)   // 綁定指定的routing-key
                .build();
    }

    /***
     * 創建交換機
     * @return
     */
    @Bean
    public DirectExchange directExchange(){
        return new DirectExchange(DLX_EXCHANGE);
    }


    /***
     * 交換機與隊列綁定
     * @param messageQueue
     * @param directExchange
     * @return
     */
    @Bean
    public Binding basicBinding(Queue messageQueue, DirectExchange directExchange) {
        return BindingBuilder.bind(messageQueue)
                .to(directExchange)
                .with(QUEUE_MESSAGE);
    }
}
4.2.3.3 消息監聽

創建MessageListener用於監聽消息,代碼如下:

@Component
@RabbitListener(queues = QueueConfig.QUEUE_MESSAGE)
public class MessageListener {


    /***
     * 監聽消息
     * @param msg
     */
    @RabbitHandler
    public void msg(@Payload Object msg){
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println("當前時間:"+dateFormat.format(new Date()));
        System.out.println("收到信息:"+msg);
    }

}
4.2.3.4 創建啓動類
@SpringBootApplication
@EnableRabbit
public class SpringRabbitMQApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringRabbitMQApplication.class,args);
    }
}
4.2.3.5 測試
@SpringBootTest
@RunWith(SpringRunner.class)
public class RabbitMQTest {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /***
     * 發送消息
     */
    @Test
    public void sendMessage() throws InterruptedException, IOException {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println("發送當前時間:"+dateFormat.format(new Date()));
        Map<String,String> message = new HashMap<>();
        message.put("name","szitheima");
        rabbitTemplate.convertAndSend(QueueConfig.QUEUE_MESSAGE_DELAY, message, new MessagePostProcessor() {
            @Override
            public Message postProcessMessage(Message message) throws AmqpException {
                message.getMessageProperties().setExpiration("10000");
                return message;
            }
        });

        System.in.read();
    }
}

其中message.getMessageProperties().setExpiration(“10000”);設置消息超時時間,超時後,會將消息轉入到另外一個隊列。

測試效果如下:
在這裏插入圖片描述

5 庫存回滾(作業)

5.1 秒殺流程回顧

在這裏插入圖片描述
如上圖,步驟分析如下:

1.用戶搶單,經過秒殺系統實現搶單,下單後會將向MQ發送一個延時隊列消息,包含搶單信息,延時半小時後才能監聽到
2.秒殺系統同時啓用延時消息監聽,一旦監聽到訂單搶單信息,判斷Redis緩存中是否存在訂單信息,如果存在,則回滾
3.秒殺系統還啓動支付回調信息監聽,如果支付完成,則將訂單喫句話到MySQL,如果沒完成,清理排隊信息回滾庫存
4.每次秒殺下單後調用支付系統,創建二維碼,如果用戶支付成功了,微信系統會將支付信息發送給支付系統指定的回調地址,支付系統收到信息後,將信息發送給MQ,第3個步驟就可以監聽到消息了。

延時隊列實現訂單關閉回滾庫存:

1.創建一個過期隊列  Queue1
2.接收消息的隊列    Queue2
3.中轉交換機
4.監聽Queue2
	1)SeckillStatus->檢查Redis中是否有訂單信息
	2)如果有訂單信息,調用刪除訂單回滾庫存->[需要先關閉微信支付]
	3)如果關閉訂單時,用於已支付,修改訂單狀態即可
	4)如果關閉訂單時,發生了別的錯誤,記錄日誌,人工處理

5.2 關閉支付

用戶如果半個小時沒有支付,我們會關閉支付訂單,但在關閉之前,需要先關閉微信支付,防止中途用戶支付。

修改支付微服務的WeixinPayService,添加關閉支付方法,代碼如下:

/***
 * 關閉支付
 * @param orderId
 * @return
 */
Map<String,String> closePay(Long orderId) throws Exception;

修改WeixinPayServiceImpl,實現關閉微信支付方法,代碼如下:

/***
 * 關閉微信支付
 * @param orderId
 * @return
 * @throws Exception
 */
@Override
public Map<String, String> closePay(Long orderId) throws Exception {
    //參數設置
    Map<String,String> paramMap = new HashMap<String,String>();
    paramMap.put("appid",appid); //應用ID
    paramMap.put("mch_id",partner);    //商戶編號
    paramMap.put("nonce_str",WXPayUtil.generateNonceStr());//隨機字符
    paramMap.put("out_trade_no",String.valueOf(orderId));   //商家的唯一編號

    //將Map數據轉成XML字符
    String xmlParam = WXPayUtil.generateSignedXml(paramMap,partnerkey);

    //確定url
    String url = "https://api.mch.weixin.qq.com/pay/closeorder";

    //發送請求
    HttpClient httpClient = new HttpClient(url);
    //https
    httpClient.setHttps(true);
    //提交參數
    httpClient.setXmlParam(xmlParam);

    //提交
    httpClient.post();

    //獲取返回數據
    String content = httpClient.getContent();

    //將返回數據解析成Map
    return  WXPayUtil.xmlToMap(content);
}

5.3 關閉訂單回滾庫存

5.3.1 配置延時隊列

在application.yml文件中引入隊列信息配置,如下:

#位置支付交換機和隊列
mq:
  pay:
    exchange:
      order: exchange.order
    queue:
      order: queue.order
      seckillorder: queue.seckillorder
      seckillordertimer: queue.seckillordertimer
      seckillordertimerdelay: queue.seckillordertimerdelay
    routing:
      orderkey: queue.order
      seckillorderkey: queue.seckillorder

配置隊列與交換機,在SeckillApplication中添加如下方法

/**
 * 到期數據隊列
 * @return
 */
@Bean
public Queue seckillOrderTimerQueue() {
    return new Queue(env.getProperty("mq.pay.queue.seckillordertimer"), true);
}

/**
 * 超時數據隊列
 * @return
 */
@Bean
public Queue delaySeckillOrderTimerQueue() {
    return QueueBuilder.durable(env.getProperty("mq.pay.queue.seckillordertimerdelay"))
            .withArgument("x-dead-letter-exchange", env.getProperty("mq.pay.exchange.order"))        // 消息超時進入死信隊列,綁定死信隊列交換機
            .withArgument("x-dead-letter-routing-key", env.getProperty("mq.pay.queue.seckillordertimer"))   // 綁定指定的routing-key
            .build();
}

/***
 * 交換機與隊列綁定
 * @return
 */
@Bean
public Binding basicBinding() {
    return BindingBuilder.bind(seckillOrderTimerQueue())
            .to(basicExchange())
            .with(env.getProperty("mq.pay.queue.seckillordertimer"));
}

5.3.2 發送延時消息

修改MultiThreadingCreateOrder,添加如下方法:

/***
 * 發送延時消息到RabbitMQ中
 * @param seckillStatus
 */
public void sendTimerMessage(SeckillStatus seckillStatus){
    rabbitTemplate.convertAndSend(env.getProperty("mq.pay.queue.seckillordertimerdelay"), (Object) JSON.toJSONString(seckillStatus), new MessagePostProcessor() {
        @Override
        public Message postProcessMessage(Message message) throws AmqpException {
            message.getMessageProperties().setExpiration("10000");
            return message;
        }
    });
}

在createOrder方法中調用上面方法,如下代碼:

//發送延時消息到MQ中
sendTimerMessage(seckillStatus);

5.3.3 庫存回滾

創建SeckillOrderDelayMessageListener實現監聽消息,並回滾庫存,代碼如下:

@Component
@RabbitListener(queues = "${mq.pay.queue.seckillordertimer}")
public class SeckillOrderDelayMessageListener {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private SeckillOrderService seckillOrderService;

    @Autowired
    private WeixinPayFeign weixinPayFeign;

    /***
     * 讀取消息
     * 判斷Redis中是否存在對應的訂單
     * 如果存在,則關閉支付,再關閉訂單
     * @param message
     */
    @RabbitHandler
    public void consumeMessage(@Payload String message){
        //讀取消息
        SeckillStatus seckillStatus = JSON.parseObject(message,SeckillStatus.class);

        //獲取Redis中訂單信息
        String username = seckillStatus.getUsername();
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(username);

        //如果Redis中有訂單信息,說明用戶未支付
        if(seckillOrder!=null){
            System.out.println("準備回滾---"+seckillStatus);
            //關閉支付
            Result closeResult = weixinPayFeign.closePay(seckillStatus.getOrderId());
            Map<String,String> closeMap = (Map<String, String>) closeResult.getData();

            if(closeMap!=null && closeMap.get("return_code").equalsIgnoreCase("success") &&
                    closeMap.get("result_code").equalsIgnoreCase("success") ){
                //關閉訂單
                seckillOrderService.closeOrder(username);
            }
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章