Springboot實戰——黑馬點評之秒殺最佳化

CandyWang-發表於2024-09-28

Springboot實戰——黑馬點評之 秒殺最佳化

1 秒殺最佳化

先來複習以下,秒殺優惠券業務的現有實現邏輯:


以上流程圖中的操作序列執行,效率極低。
其中 判斷秒殺庫存 以及 校驗一人一單 屬於對資料庫的讀取,耗時較少;扣減庫存 以及 建立訂單 屬於對資料庫的寫操作,耗時相對較久。

提升效率的方法我們可以考慮兩個方面:
1)引入併發(開啟多執行緒):主執行緒負責讀取操作,如果讀取檢驗資格透過,則開啟另外的執行緒負責寫操作
2)引入Redis快取:可以將訂單資訊以及秒殺券資訊存入Redis,在Redis中檢驗資格後,將符合資格的優惠券id+使用者id+訂單id存入阻塞佇列,單獨開啟第二執行緒來讀取阻塞佇列執行寫操作,即刻給使用者返回下單訂單號。

1.1 引入Redis進行資格檢驗

資格檢驗分為 檢查庫存是否充足 以及 使用者是否下單過該優惠券 兩個操作,如果引入Redis來實現,要考慮:

  • 秒殺券庫存匯入Redis,並且要資料及時更新同步,即 在檢驗資格透過後需要將Redis中的券庫存-1
  • 下單記錄:使用的資料結構需要滿足1 集合;2 元素唯一性
  • 使用Redis中的set型別來快取下單該優惠券的使用者id集合,並且要保證資料及時更新同步,即 在檢驗資格透過後需要向set中新增使用者id


以上所考慮的幾點還需要保證操作的原子性,所以使用Redis的Lua指令碼來實現。
Lua指令碼需要的ARGV引數列表中有兩個待定引數,分別是優惠券id 以及 使用者id,其他的業務邏輯均呼叫Redis命令即可實現

-- 1. 引數列表
-- 1.1. 優惠券id 用於查詢優惠券庫存時的關鍵字
local voucherId = ARGV[1]
-- 1.2. 使用者id 用於將查詢下單使用者對比
local useId = ARGV[2]

-- 2. 資料key
-- 2.1. 庫存key + 業務字首 拼接 優惠券id
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 訂單key
local orderKey = 'seckill:order:' .. voucherId


-- 3. 業務執行
-- 3.1 首先判斷庫存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2 庫存不足,返回錯誤碼 1
    return 1
end
-- 3.2. 判斷使用者是否下單 SISMEMBER orderkey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3 如果存在該使用者,說明是重複下單,返回錯誤碼 2
    return 2
end

--3.4 扣庫存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
--3.5 下單(插入使用者id) sadd orderKey userId
redis.call('sadd', orderKey, userId)

return 0

如果庫存不足則返回1(Long),如果該使用者重複下單則返回2(Long),如果資格檢驗透過則返回0

如果資格檢驗透過,則需要保證該有效訂單被阻塞佇列拿到,後續阻塞式執行成功,所以將“憑證”(封裝好使用者id、券id、訂單id的訂單例項)傳入阻塞佇列,等待非同步執行緒阻塞式讀取處理下單業務。

// 這裡直接封裝成訂單例項
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(redisIdWorker.nextId("order"));
// 放入阻塞佇列 blockingqueue
orderTasks.add(voucherOrder);

1.2 開啟非同步執行緒寫資料庫

需要準備以下幾個資料結構:

  • 阻塞佇列:當一個執行緒嘗試從該佇列中獲取元素時,當查詢到佇列為空時會阻塞等待,直到佇列中插入元素後被喚醒,不會導致執行緒空轉消耗CPU資源。
  • 非同步執行緒實現下單即 開啟非同步獨立執行緒來阻塞式執行下單業務,所以需要準備1 執行緒池 2 執行緒任務
  • 執行緒池常量(單執行緒執行緒處理器),用於提交非同步任務
// 執行緒池/執行緒處理器 此處建立的是單執行緒處理器
private static final ExecutorService SECKILL_ORDER_EXECUTOR
                    = Executors.newSingleThreadExecutor();

並且要保證在類初始化,在使用者最初呼叫該介面時就同步開啟執行緒處理器

@PostConstruct
private void init(){
    SECKILL_ORDER_EXECUTOR.submit(new voucherOrderHandler());
}
  • 執行緒任務
// 定義交給執行緒池執行的業務內容
private class voucherOrderHandler implements Runnable{

    @Override
    public void run(){
       // 該執行緒執行任務為阻塞式的 當發現佇列中存在元素時才進行
       while(true){
           //...這裡執行下單的具體業務
           //1. 獲取佇列中的訂單資訊
           try {
              VoucherOrder voucherOrder = orderTasks.take();
              // 2. 下單業務
              handleVoucherOrder(voucherOrder);
           } catch (Exception e) {
              log.error("處理訂單異常",e);
           }
    }

    }
}

1.3 以上非同步實現的弊端

1)記憶體限制問題:阻塞佇列是有JDK內部的,底層使用的是JVM記憶體,如果有大量的訂單資訊被存入阻塞佇列,將會帶來較大記憶體負擔,記憶體溢位
2)資料安全問題:JVM記憶體沒有持久化機制。如果服務當機,記憶體中的訂單資訊消失,使用者支付狀態與後臺儲存訂單狀態不一致;或者是 從阻塞佇列中取出訂單資訊後尚未來得及處理下單邏輯,服務當機了,將會造成訂單丟失的問題。

2 Redis訊息佇列實現非同步秒殺

使用Redis訊息佇列的兩個優勢:
1)Redis的訊息佇列是獨立於JVM之外的資料結構,不受JVM記憶體的限制
2)Redis的訊息佇列可對訊息作持久化,保證資料安全性,且封裝有訊息確認機制,確保了訊息至少被消費一次

2.1 Redis實現訊息佇列的三種方式

  • 基於List結構:

    使用BPOP來阻塞式從Redis的list資料結構中獲取隊首元素,本質上原理和JDK的阻塞佇列一樣的。





    這樣實現的弊端
    1)無法避免服務當機導致的訊息丟失
    2)只支援單消費者

  • 基於PubSub結構
    支援多消費者了,支援多生產、多消費


    這樣實現的弊端
    1)不支援資料持久化,傳送訊息時如果訊息無人訂閱,訊息不會永久儲存在Redis中
    2)訊息堆積有上限,消費者接收資料有快取區,如果訊息快取超額,則會造成資料丟失了
    3)無法避免訊息丟失

  • 基於Stream資料型別




    如果基於Stream資料型別來實現非同步下單業務,則會出現訊息漏讀問題

  • 基於Stream的消費者組
    消費者組:將多個消費者劃分到一個組中,該組監聽同一個佇列
    這樣設計有以下幾個特點:
    1)訊息分流:同組內的消費者用來“競爭”同一個佇列中的訊息,與單消費者相比,加快了處理訊息的速度,且訊息可回溯
    2)訊息標示:消費者組會維護一個標示,記錄最後一個被處理的訊息,如果服務當機重啟,能從標示之後讀取訊息,確保每一個訊息都被消費成功,避免像單消費者出現漏讀訊息的問題
    3)訊息確認:消費者組當獲取到一個訊息時,會將訊息插入Pending-list中,標誌該訊息尚未處理,當處理結束後,會透過XACK來確認訊息已處理,然後從pending-list中移除

    當消費者獲取到訊息時,訊息會自動放入pending-list中,等待消費者處理完畢後發出XACK確認後才將其移除


    消費者1和2相繼從s1佇列中讀取未讀取過的第一條訊息,與此同時這些訊息均被放入了Pending-list中,等待訊息確認Ack

2.2 Stream佇列實現非同步下單

所以可以將原有的非同步下單功能替換成用Stream佇列實現:
迴圈從Stream佇列中讀取訂單資訊 -> 消費者組以最後一個被獲取的訊息標識($),讀取佇列中還沒被消費的訊息,並設定2秒內阻塞式(|block)讀取 -> 如果阻塞等待時間內並未拿到最新訊息則continue -> 如果阻塞等待時間內獲取到新訊息,則按下單業務將其處理
捕獲異常 -> 意味著此時pending-list中存在已被消費但未被處理完畢的訊息 -> 迴圈從pending-list中獲取第0號訊息(非阻塞式,0)來嘗試繼續處理 -> 如果獲取到尚未處理過的訊息,則按正常下單業務繼續處理 -> 如果沒有異常中止的訊息則結束異常捕獲業務
如果捕獲異常過程中又遇到異常 -> 繼續迴圈讀取pending-list

相關文章