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