秒殺業務優化之從分布式鎖到基于消息隊列的異步秒殺

一、業務場景介紹

? ? ? ? 優惠券、門票等限時搶購常常出現在各類應用中,這樣的業務一般為了引流宣傳而降低利潤,所以一旦出現問題將造成較大損失,那么在業務中就要求我們對這類型商品嚴格限時、限量、每位用戶限一次、準確無誤的創建訂單,這樣的要求看似簡單,但在分布式系統中,要求我們充分考慮高并發下的線程安全問題,今天我們來看一下兩種解決思路。

二、基于Redisson分布式鎖的秒殺方案

????????這里我們就不進行自定義redis鎖了,Redisson 基于 Redis 實現了?Java 駐內存數據網格(In-Memory Data Grid),它不僅提供了對 Redis 原生命令的封裝,還提供了一系列高級的分布式數據結構和服務,促進使用者對 Redis 的關注分離,讓開發者能夠更專注于業務邏輯,所以我們直接使用Redisson,但底層源碼還是需要我們去自己學習掌握的。

1.流程概覽

????????其實單看流程圖我們就能發現這一連串的串行邏輯就會非常影響效率,我們先留著這個問題后面優化 。

2.具體實現

    @Overridepublic Result generate(Long voucherId) {//查詢優惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);//活動是否開始/結束if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {return Result.fail("活動未開始!");}if (voucher.getEndTime().isBefore(LocalDateTime.now())) {return Result.fail("活動已結束!");}//庫存表是否充足if (voucher.getStock()<1) {return Result.fail("庫存不足!");}Long userId = UserHolder.getUser().getId();//只鎖同一個id//創建鎖對象RLock lock = redissonClient.getLock("lock:order:" + userId);//獲取鎖,防止同一用戶的并發請求boolean isLock = lock.tryLock();//默認不等待,30秒過期if (!isLock) {//獲取鎖失敗return Result.fail("網絡繁忙!");}//拿到spring事務代理,這里為了簡單解決事務自調用直接去拿代理可能造成問題,建議將事務方法重構至另一服務類并注入try {IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);} finally {//釋放鎖lock.unlock();}}@Transactional//要鎖住事物,防止事物在鎖釋放后才提交導致其他線程進入public Result createVoucherOrder(Long voucherId) {Long userId = UserHolder.getUser().getId();//一人一單int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count >0) {return Result.fail("您最多只可購買一單!");}//扣減庫存boolean flag = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId).gt("stock",0).update();if (!flag){//高并發下已經被其他用戶線程扣減return Result.fail("庫存不足2!");}//創建訂單VoucherOrder voucherOrder = new VoucherOrder();//唯一IDlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//用戶idvoucherOrder.setUserId(userId);//代金券IdvoucherOrder.setVoucherId(voucherId);save(voucherOrder);//返回訂單IDreturn Result.ok(orderId);}

3.測試分析

?接下來我們登錄數據庫中所有的用戶并記錄Authorization

@SpringBootTest
@Component
public class SecKill {@Autowiredprivate IUserService userService;@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Testvoid userLogin() throws IOException {// 定義保存 token 的文件路徑String filePath = "D:\\tokens.txt";// 使用 BufferedWriter 寫入文件try (BufferedWriter writer = new BufferedWriter(new FileWriter(filePath, true))) { // 追加模式for (User user : userService.list()) {String phone = user.getPhone();HttpSession session = null;userService.sendCode(phone, session);String code = stringRedisTemplate.opsForValue().get("login:code:" + phone);LoginFormDTO loginFormDTO = new LoginFormDTO();loginFormDTO.setCode(code);loginFormDTO.setPhone(phone);String token = userService.logIn(loginFormDTO, session);// 將 token 寫入文件writer.write(token);writer.newLine(); // 換行writer.flush(); // 刷新緩沖區,確保數據寫入文件}} catch (IOException e) {e.printStackTrace();}}
}

然后我們設置優惠券數量為200,通過jmeter(一款測試工具,大家自行學習如何使用)模擬數據庫中1000多個用戶總計每秒1000的高并發請求

?從聚合報告中可以看到雖然80%的異常率確實滿足了我們對優惠券的限量要求,通過查看數據庫訂單和庫存也不存在問題,但是我們可以看到我們的平均響應時間在高并發下達到了344ms,吞吐量只有1200左右,如果面臨更高的并發難免因性能局限出現問題。

三、基于消息隊列的異步秒殺

1.問題分析

正如我們一開始發現的,每個請求來到服務器都需要執行一串的數據庫讀寫操作,而寫操作耗時是比較久的,可是當我們確定用戶搶單成功后只要能確保訂單最終寫入即可,無需讓其阻塞請求,所以我們其實可以將讀寫操作分離開。

我們可以利用讀操作完成下單資格的各種校驗,校驗成功即可對請求做出響應,那么后續寫訂單操作怎么完成呢?我們需要根據校驗成功的記錄完成寫操作,那誰來完成校驗成功的記錄呢,這樣記錄是不是又和原來的讀寫串行一樣了呢?

2.工具對比

首先我們的目的是加快請求響應效率,減輕數據庫壓力,其實我們需要的就是一個中間工具做到能夠快速存儲校驗成功的記錄并有限制的可控的逐漸將存儲起來的記錄轉發給數據庫讓其創建訂單,能做到上述要求的工具有很多,這里簡單對比以下三種供大家參考。

特性/技術阻塞隊列RedisMQ消息中間件(如RabbitMQ、Kafka)
系統解耦低,主要用于單機環境中,支持集群部署高,天然用于系統解耦
異步通信支持,但需要手動實現通過發布/訂閱模式實現專為異步通信設計
削峰填谷臨時存儲請求,能力有限緩存請求,需合理設計策略緩存大量請求,后端按速率消費
可靠性和持久性依賴具體實現,需額外持久化支持持久化,可靠性較高高可靠性和持久性,支持消息確認
性能和吞吐量受限于單機處理能力性能較高,支持集群最高,適用于大規模分布式系統
功能豐富性單一,主要用于線程間通信支持多種數據結構和操作支持多種消息協議、路由機制等
開發和維護成本低,但需手動實現異步邏輯中等,易于實現和使用高,需學習和理解相關協議和機制
適用場景小規模、單機環境中小規模、集群部署大規模分布式系統、復雜路由

?3.流程概覽

????????由于阻塞隊列局限較大,MQ中間件比較簡單,這里我們以Redis中的stream為例(除此之外,list和PubSub也能實現,但是局限較大)實現異步秒殺。

對于紅框部分,為了確保原子性,我們借助lua腳本完成,這樣一來我們就將MySQL的讀寫操作分離開來,請求響應中只需要讀取驗證,用redis更高效的io操作完成簡單記錄,隨后異步逐漸處理MySQl的訂單寫入。

4.具體實現

  • lua腳本
    ---
    --- Generated by EmmyLua(https://github.com/EmmyLua)
    --- Created by cds.
    --- DateTime: 2025/3/23 13:03
    ---
    --1.參數列表
    --1.1優惠券id
    local voucherId=ARGV[1]
    --1.2用戶id
    local userId=ARGV[2]
    --1.3訂單id
    local orderId=ARGV[3]--2.數據key
    --2.1庫存key
    local stockKey='seckill:stock:' .. voucherId
    --2.2訂單key
    local orderKey='seckill:order:' .. voucherId--腳本業務
    --判斷庫存是否充足
    if (tonumber(redis.call('get',stockKey))<=0) then--庫存不足返回1return 1
    end
    --判斷用戶是否下單
    if (redis.call('sismember',orderKey,userId)==1) then--下過單返回2return 2
    end
    --扣庫存
    redis.call('incrby',stockKey,-1)
    --下單
    redis.call('sadd',orderKey,userId)
    --發送消息到消息隊列  xadd stream.orders * k1 v1 k2 v2 ..
    redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,"id",orderId)return 0
  • ?具體業務
        @Autowiredprivate IVoucherOrderService proxy;//初始化lua腳本信息private static final DefaultRedisScript<Long> SECKILL_SCRIPT;static {SECKILL_SCRIPT =new DefaultRedisScript<>();SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));SECKILL_SCRIPT.setResultType(Long.class);}//異步單例線程private static final ExecutorService SECKILL_ORDER_EXECTUOR= Executors.newSingleThreadExecutor();//在spring的Bean初始化并注入后開始@PostConstructprivate void init(){SECKILL_ORDER_EXECTUOR.submit(new VoucherOrderHandler());}//線程任務private class VoucherOrderHandler implements Runnable {String queueName = "stream.orders";@Overridepublic void run() {while (true) {try {//獲取消息隊列中的訂單信息  XREAD GROUP group1 c1 count 1 block 2000 streams stream.orders >List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(Consumer.from("group1", "c1"),StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),StreamOffset.create(queueName, ReadOffset.lastConsumed()));//判斷消息是否獲取成if (list == null || list.isEmpty()) {//獲取失敗 沒有消息,繼續循環continue;}//獲取成功,可以下單//解析消息中的訂單信息MapRecord<String, Object, Object> record = list.get(0);Map<Object, Object> value = record.getValue();VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);handleVoucherOrder(voucherOrder);//ACK確認 SACK stream.orders group1 idstringRedisTemplate.opsForStream().acknowledge(queueName, "group1", record.getId());} catch (Exception e) {log.error("創建訂單異常{}", e.getMessage());//有異常去pendingList拿handlePendingList();}}}private void handlePendingList() {while (true) {try {//獲取pending-list隊列中的訂單信息  XREAD GROUP group1 c1 count 1 block 2000 streams stream.orders 0List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(Consumer.from("group1", "c1"),StreamReadOptions.empty().count(1),StreamOffset.create(queueName, ReadOffset.from("0")));//判斷消息是否獲取成if (list == null || list.isEmpty()) {//獲取失敗 pending-list沒有消息,結束循環break;}//獲取成功,可以下單//解析消息中的訂單信息MapRecord<String, Object, Object> record = list.get(0);Map<Object, Object> value = record.getValue();VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);handleVoucherOrder(voucherOrder);//ACK確認 SACK stream.orders group1 idstringRedisTemplate.opsForStream().acknowledge(queueName, "group1", record.getId());} catch (Exception e) {log.error("創建訂單異常{}", e.getMessage());try {Thread.sleep(20);} catch (InterruptedException ex) {throw new RuntimeException(ex);}}}}}private void handleVoucherOrder(VoucherOrder voucherOrder) {Long userId = voucherOrder.getUserId();//創建鎖對象RLock lock = redissonClient.getLock("lock:order:" + userId);//獲取鎖boolean isLock = lock.tryLock();//默認不等待,30秒過期if (!isLock) {//獲取鎖失敗log.info("請勿重復購買!");return;}//拿到spring事務代理try {proxy.createVoucherOrder(voucherOrder);} finally {//釋放鎖lock.unlock();}}//這部分的檢驗是以防stream消息隊列里出現問題導致重復save操作@Transactional//要鎖住事物public void createVoucherOrder(VoucherOrder voucherOrder) {Long userId = voucherOrder.getUserId();//一人一單int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();if (count > 0) {log.error("您最多只可購買一單!");return;}//扣減庫存boolean flag = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update();if (!flag) {log.info("庫存不足!");return;}//創建訂單save(voucherOrder);}}@Overridepublic Result secKill(Long voucherId) {//查詢優惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);//活動是否開始/結束if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {return Result.fail("活動未開始!");}if (voucher.getEndTime().isBefore(LocalDateTime.now())) {return Result.fail("活動已結束!");}//庫存表是否充足if (voucher.getStock() < 1) {return Result.fail("庫存不足!");}//獲取用戶Long userId = UserHolder.getUser().getId();//1執行lua腳本//唯一IDlong orderId = redisIdWorker.nextId("order");Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(), userId.toString(), String.valueOf(orderId));int r = result.intValue();//2判斷lua腳本返回值0if (r != 0) {//2.1不為零無資格return Result.fail(r == 1 ? "庫存不足!" : "不能重復下單!");}return Result.ok(orderId);}

5.測試分析?

我們再次使用jmeter進行同樣的測試,但這次我們需要提前將庫存信息同步到redis?

可以看到經過優化的秒殺業務吞吐量大大增加,平均響應時間降低到30ms左右,得到了十倍左右的提升,大大增加了響應處理效率

?redis訂單記錄

redis消息隊列記錄

如果去控制臺觀察日志可以發現,刪改請求少量穿插在中間,大部分聚集在查詢校驗結束的末尾,讀操作基本都聚集在最前面,DB操作得到有效控制,這就是異步寫入處理的體現

好了,本次分享到這里結束,謝謝閱讀!

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/diannao/74445.shtml
繁體地址,請注明出處:http://hk.pswp.cn/diannao/74445.shtml
英文地址,請注明出處:http://en.pswp.cn/diannao/74445.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

MiniMax GenAI 可觀測性分析:基于阿里云 SelectDB 構建 PB 級別日志系統

“阿里云SelectDB作為MiniMax日志存儲服務的核心支撐&#xff0c;為在線和離線業務提供了高效、穩定的查詢與聚合分析能力。其支持實時物化視圖、租戶資源隔離、冷熱分離等企業級特性&#xff0c;不僅有效解決了日志場景下PB級別數據查詢的性能瓶頸&#xff0c;還通過智能化的資…

【YOLO V3】目標檢測 Darknet 訓練自定義模型

【YOLO V3】目標檢測 Darknet 訓練自定義模型 前言整體思路環境檢查與依賴配置克隆 YOLOv3 Darknet 并編譯Clone Darknet 項目文件修改 Makefile 文件修改模型保存頻率項目編譯 準備數據集配置訓練文件數據集&#xff1a;datasets &#xff08;自制&#xff09;權重文件 yolov3…

Kafka分區分配策略詳解

Kafka分區分配策略詳解 Kafka作為當前最流行的分布式消息隊列系統&#xff0c;其分區分配策略直接影響著系統的性能、可靠性和可擴展性。合理的分區分配不僅能夠提高數據處理的效率&#xff0c;還能確保系統負載的均衡。 Kafka提供了多種內置的分區分配策略&#xff0c;包括R…

C#中 String類API(函數)

字符串屬性 string str "打工人";Console.WriteLine(str);char s str[0];Console.WriteLine(s); 字符串內置API(函數) 1. Concat 拼接字符串 string s1 "打";string s2 "工";string s3 "人";string sthstring.Concat(s1, s2, s…

JavaScript性能優化實戰手冊:從V8引擎到React的毫秒級性能革命

目錄 一、性能優化的本質挑戰1.1 瀏覽器渲染管線的性能瓶頸2.1 內存管理優化2.2 執行效率優化2.3 網絡傳輸優化 三、React框架深度調優3.1 渲染性能優化3.2 性能監控體系 四、企業級優化案例4.1 電商平臺首頁優化4.2 數據可視化大屏優化 五、新一代性能優化技術5.1 WASM性能突破…

【PostgreSQL】pg各版本選用取舍邏輯與docker安裝postgres:15

企業常用 PostgreSQL 版本推薦 1. PostgreSQL 14&#xff08;最常見&#xff0c;穩定&#xff09; 目前許多企業仍在使用 PostgreSQL 14&#xff0c;因為它在性能、并發處理、JSON 支持等方面做了較多優化&#xff0c;同時又非常穩定。官方支持時間&#xff1a;2026 年 11 月…

DeepSeek 助力 Vue3 開發:打造絲滑的表格(Table)之添加導出數據功能

前言:哈嘍,大家好,今天給大家分享一篇文章!并提供具體代碼幫助大家深入理解,徹底掌握!創作不易,如果能幫助到大家或者給大家一些靈感和啟發,歡迎收藏+關注哦 ?? 目錄 DeepSeek 助力 Vue3 開發:打造絲滑的表格(Table)之添加導出數據功能??頁面效果??指令輸入?…

《Python實戰進階》第31集:特征工程:特征選擇與降維技術

第31集&#xff1a;特征工程&#xff1a;特征選擇與降維技術 摘要 特征工程是機器學習和數據科學中不可或缺的一環&#xff0c;其核心目標是通過選擇重要特征和降低維度來提升模型性能并減少計算復雜度。本集聚焦于特征選擇與降維技術&#xff0c;涵蓋過濾法、包裹法、嵌入法等…

避雷 :C語言中 scanf() 函數的錯誤?使用!!!

1. 返回值說明 scanf函數會返回成功匹配并賦值的輸入項個數&#xff0c;而不是返回輸入的數據。 可以通過檢查返回值數量來確認輸入是否成功。若返回值與預期不符&#xff0c;就表明輸入存在問題。 #include <stdio.h>int main() {int num;if (scanf("%d", …

Excel第41套全國人口普查

2. 導入網頁中的表格&#xff1a;數據-現有鏈接-考生文件夾&#xff1a;網頁-找到表格-點擊→變為√-導入刪除外部鏈接關系&#xff1a;數據-點擊鏈接-選中連接-刪除-確定&#xff08;套用表格格式-也會是刪除外部鏈接&#xff09;數值縮小10000倍&#xff08;除以10000即可&am…

WPS宏開發手冊——使用、工程、模塊介紹

目錄 系列文章前言1、開始1.1、宏編輯器使用步驟1.2、工程1.3、工程 系列文章 使用、工程、模塊介紹 JSA語法 第三篇練習練習題&#xff0c;持續更新中… 前言 如果你是開發人員&#xff0c;那么wps宏開發對你來說手拿把切。反之還挺吃力&#xff0c;需要嘻嘻&#xf…

EtherCAT轉CANopen配置CANopen側的PDO映射

EtherCAT轉CANopen配置CANopen側的PDO映射 在工業自動化領域&#xff0c;EtherCAT和CANopen是兩種廣泛應用的通信協議。它們各自具有獨特的優勢&#xff0c;但在某些應用場景下&#xff0c;需要將這兩種協議進行轉換以實現設備間的高效數據交換。本文將詳細介紹如何在使用Ethe…

【QT】Qt creator快捷鍵

Qt creator可以通過以下步驟快捷鍵査看調用關系&#xff1a; 1.打開代碼文件。 2.將光標放在你想要查看調用關系的函數名上。 3.按下鍵盤快捷鍵 CtrlshiftU。 4.彈出菜單中選擇“調用路徑”或“被調用路徑” 5.在彈出的窗口中可以查看函數的調用關系 折疊或展開代碼快捷鍵&…

【RHCE】LVS-NAT模式負載均衡實驗

目錄 題目 IP規劃 配置IP RS1 RS2 RS3 LVS client 配置RS 配置LVS 安裝lvs軟件 啟動ipvsadm服務 lvs規則匹配 ipvsadm部分選項 客戶端測試 總結 題目 使用LVS的 NAT 模式實現 3 臺RS的輪詢訪問&#xff0c;IP地址和主機自己規劃。 IP規劃 主機IP地址RS1-nat模…

排序算法(插入,希爾,選擇,冒泡,堆,快排,歸并)

1.插入排序 插入排序的主要思想是額外申請一個空間cur&#xff0c;讓cur一開始等于數組的第1號位置,設置i1&#xff0c;讓i-1的元素與其比較&#xff0c;如果arr[i-1]>arr[i]&#xff0c;就讓arr[i1] arr[i]&#xff0c;當進行到最后一次對比結束&#xff0c;i-1,再讓arr[…

Java——Random庫

一、作用 Random庫——生成隨機數 二、實現步驟 1.導包&#xff1a;import java.util.Random; #快捷鍵&#xff1a;“Random”回車鍵 2.取得隨機數&#xff1a;Random 變量1 new Random(); 3.調用隨機數&#xff1a;類型 變量2 變量1.nextInt(n); &#xff08;代表變量…

解線性方程組的直接方法:高斯消元法與其程序實現

解線性方程組的直接方法&#xff1a;高斯消元法與其程序實現 1.順序高斯消元法 設線性方程組 A x b \boldsymbol{Ax}\boldsymbol{b} Axb 如果 a k k ( k ) ≠ 0 a_{kk}^{\left( k \right)}\ne 0 akk(k)??0 可以通過高斯消元法轉化為等價的三角形線性方程組&#xff1a; …

LiteIDE中配置golang編譯生成無CMD窗口EXE的步驟

LiteIDE中配置golang編譯生成無CMD窗口EXE的步驟 一、環境配置1、設置GOROOT?2、配置GOPATH? 二、項目編譯參數設置1、新建/打開項目?2、修改編譯配置?3、其他優化選項&#xff08;可選&#xff09;? 三、構建與驗證1、編譯生成EXE?2、驗證無窗口效果? 四、注意事項 一、…

Maya基本操作

基本操作 按住ALT鍵&#xff0c;左鍵旋轉視角&#xff0c;中鍵平移視角&#xff0c;右鍵放大縮小視角。 按空格鍵切換4格視圖。 導入FBX格式文件后&#xff0c;無貼圖顯示。 按6鍵開啟。著色紋理顯示 坐標軸相關 修改菜單-左鍵最上面的虛線。固定修改選項窗口。 選中物體…

Windows打開ftp局域網共享

前提是windows已經設置好開機賬號密碼了&#xff0c;否則教程不適用 第一先打開電腦ftp共享配置 點擊保存即可 2.設置要共享到其他電腦的文件路徑&#xff08;如果你要共享整個盤你就設置整個盤&#xff0c;如果是共享盤中某文件就設置某文件&#xff0c;這里是某文件&#x…