分散式場景下的秒殺架構與秒殺實現
隨著專案的上線與穩定執行,有關小程式秒殺系統的工作也算是告一段落了,最近也是抽空整理整理相關資料,留下了這篇文件;
分析,在做秒殺系統的設計之初,一直在思考如何去設計這個秒殺系統,使之在現有的技術基礎和認知範圍內,能夠做到最好;同時也能充分的利用公司現有的中介軟體來完成系統的實現。
我們都知道,正常去實現一個WEB端的秒殺系統,前端的處理和後端的處理一樣重要;前端一般會做CDN,後端一般會做分散式部署,限流,效能優化等等一系列的操作,並完成一些網路的優化,比如IDC多線路(電信、聯通、移動)的接入,頻寬的升級等等。而由於目前系統前端是基於微信小程式,所以關於前端部分的優化就儘可能都是在程式碼中完成,CDN這一步就可以免了;
1、架構介紹
後端專案是基於SpringCloud+SpringBoot搭建的微服務框架架構
前端在微信小程式商城上
### 核心支撐元件
- 服務閘道器 Zuul
- 服務註冊發現 Eureka+Ribbon
- 認證授權中心 Spring Security OAuth2、JWTToken
- 服務框架 Spring MVC/Boot
- 服務容錯 Hystrix
- 分散式鎖 Redis
- 服務呼叫 Feign
- 訊息佇列 Kafka
- 檔案服務 私有云盤
- 富文字元件 UEditor
- 定時任務 xxl-job
- 配置中心 apollo
2、關於秒殺的場景特點分析
#### 秒殺系統的場景特點
- 秒殺時大量使用者會在同一時間同時進行搶購,網站瞬時訪問流量激增;
- 秒殺一般是訪問請求量遠遠大於庫存數量,只有少部分使用者能夠秒殺成功;
- 秒殺業務流程比較簡單,一般就是下訂單操作;
#### 秒殺架構設計理念
-限流 :鑑於只有少部分使用者能夠秒殺成功,所以要限制大部分流量,只允許少部分流量進入服務後端(暫未處理);
-削峰 :對於秒殺系統瞬時的大量使用者湧入,所以在搶購開始會有很高的瞬時峰值。實現削峰的常用方法有利用快取或者訊息中介軟體等技術;
-非同步處理 :對於高併發系統,採用非同步處理模式可以極大地提高系統併發量,非同步處理就是削峰的一種實現方式;
-記憶體快取 :秒殺系統最大的瓶頸最終都可能會是資料庫的讀寫,主要體現在的磁碟的I/O,效能會很低,如果能把大部分的業務邏輯都搬到快取來處理,效率會有極大的提升;
-可拓展 :如果需要支援更多的使用者或者更大的併發,將系統設計為彈性可拓展的,如果流量來了,拓展機器就好;
#### 秒殺設計思路
- 由於前端是屬於小程式端,所以不存在前端部分的訪問壓力,所以前端的訪問壓力就無從談起;
- 1、秒殺相關的活動頁面相關的介面,所有查詢能加快取的,全部新增redis的快取;
- 2、活動相關真實庫存、鎖定庫存、限購、下單處理狀態等全放redis;
- 3、當有請求進來時,進入活動ID為粒度的分散式鎖,第一步進行使用者購買的重複性校驗,滿足條件進入下一步,否則返回已下單的提示;
- 4、第二步,判斷當前可鎖定的庫存是否大於購買的數量,滿足條件進入下一步,否則返回已售罄的提示;
- 5、第三步,鎖定當前請求的購買庫存,從鎖定庫存中減除,並將下單的請求放入kafka訊息佇列;
- 6、第四步,在redis中標記一個polling的key(用於輪詢的請求介面判斷使用者是否下訂單成功),在kafka消費端消費完成建立訂單之後需要刪除該key,並且維護一個活動id+使用者id的key,防止重複購買;
- 7、第五步,訊息佇列消費,建立訂單,建立訂單成功則扣減redis中的真實庫存,並且刪除polling的key。如果下單過程出現異常,則刪除限購的key,返還鎖定庫存,提示使用者下單失敗;
- 8、第六步,提供一個輪詢介面,給前端在完成搶購動作後,檢查最終下訂單操作是否成功,主要判斷依據是redis中的polling的key的狀態;
- 9、整個流程會將所有到後端的請求攔截的在redis的快取層面,除了最終能下訂單的庫存限制訂單會與資料庫存在互動外,基本上無其他的互動,將資料庫I/O壓力降到了最低;
#### 關於限流
SpringCloud zuul的層面有很好的限流策略,可以防止同一使用者的惡意請求行為
1 zuul: 2 ratelimit: 3 key-prefix: your-prefix #對應用來標識請求的key的字首 4 enabled: true 5 repository: REDIS #對應儲存型別(用來儲存統計資訊) 6 behind-proxy: true #代理之後 7 default-policy: #可選 - 針對所有的路由配置的策略,除非特別配置了policies 8 limit: 10 #可選 - 每個重新整理時間視窗對應的請求數量限制 9 quota: 1000 #可選- 每個重新整理時間視窗對應的請求時間限制(秒) 10 refresh-interval: 60 # 重新整理時間視窗的時間,預設值 (秒) 11 type: #可選 限流方式 12 - user 13 - origin 14 - url 15 policies: 16 myServiceId: #特定的路由 17 limit: 10 #可選- 每個重新整理時間視窗對應的請求數量限制 18 quota: 1000 #可選- 每個重新整理時間視窗對應的請求時間限制(秒) 19 refresh-interval: 60 # 重新整理時間視窗的時間,預設值 (秒) 20 type: #可選 限流方式 21 - user 22 - origin 23 - url
#### 關於負載與分流
當一個活動的訪問量級特別大的時候,可能從域名分發進來的nginx就算是做了高可用,但實際上最終還是單機線上,始終敵不過超大流量的壓力時,我們可以考慮域名的多IP對映。也就是說同一個域名下面對映多個外網的IP,再對映到DMZ的多組高可用的nginx服務上,nginx再配置可用的應用服務叢集來減緩壓力;
這裡也順帶介紹redis可以採用redis cluster的分散式實現方案,同時springcloud hystrix 也能有服務容錯的效果;
而關於nxinx、springboot的tomcat、zuul等一系列引數優化操作對於效能的訪問提升也是至關重要;
補充說明一點,即使前端是基於小程式實現,但是活動相關的圖片資源都放在自己的雲盤服務上,所以活動前活動相關的圖片資源上傳CDN也是至關重要,否則哪怕是你IDC有1G的流量頻寬,也會分分鐘被吃完;
2、主要程式碼實現
1 /** 2* 06.04-去秒殺,建立秒殺訂單 3* <p>Title: testSeckill</p> 4* <p>Description: 秒殺下單</p> 5* @param jsonObject 6* @return 7*/ 8@RequestMapping(value="/goSeckill", method=RequestMethod.POST) 9public SeckillInfoResponse goSeckill(@RequestBody JSONObject jsonObject) { 10int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1;//活動Id 11AssertUtil.isTrue(stallActivityId != -1, "非法引數"); 12int purchaseNum = jsonObject.containsKey("purchaseNum") ? jsonObject.getInteger("purchaseNum") : 1;//購買數量 13AssertUtil.isTrue(purchaseNum != -1, "非法引數"); 14String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null; 15AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法引數"); 16String formId = jsonObject.containsKey("formId") ? jsonObject.getString("formId") : null; 17AssertUtil.isTrue(!StringUtil.isEmpty(formId), 1101, "非法引數"); 18long addressId = jsonObject.containsKey("addressId") ? jsonObject.getLong("addressId") : -1; 19AssertUtil.isTrue(addressId != -1, "非法引數"); 20//通過分享入口進來的引數 21String shareCode =jsonObject.getString("shareCode"); 22String shareSource =jsonObject.getString("shareSource"); 23String userCode =jsonObject.getString("userId"); 24 25return seckillService.startSeckill(stallActivityId, purchaseNum, openId, formId, addressId, shareCode, shareSource, userCode); 26}
1 /** 2* 06.05-輪詢請求當前使用者是否秒殺下單成功 3* <p>Title: seckillPolling</p> 4* <p>Description: </p> 5* @param jsonObject 6* @return 7*/ 8@RequestMapping(value="/seckillPolling", method=RequestMethod.POST) 9public SeckillInfoResponse seckillPolling(@RequestBody JSONObject jsonObject) { 10int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1;//活動Id 11AssertUtil.isTrue(stallActivityId != -1, "非法引數"); 12String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null; 13AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法引數"); 14 15SeckillInfoResponse response = new SeckillInfoResponse(); 16if( redisRepository.exists("BM_MARKET_LOCK_POLLING_" + stallActivityId + "_" + openId) ) { 17//如果快取中存在鎖定秒殺和使用者ID的key,則證明該訂單尚未處理完成,需要繼續等待 18response.setIsSuccess(true); 19response.setResponseCode(6103); 20response.setResponseMsg("排隊中,請稍後"); 21response.setRefreshTime(1000); 22} else { 23//如果快取中該key已經不存在,則表明該訂單已經下單成功,可以進入支付操作,並取出orderId返回 24String redisOrderInfo = redisRepository.get("BM_MARKET_SECKILL_ORDERID_" + stallActivityId + "_" + openId); 25if( redisOrderInfo == null ) { 26response.setIsSuccess(false); 27response.setResponseCode(6106); 28response.setResponseMsg("秒殺失敗,下單出現異常,請重試!"); 29response.setOrderId(0); 30response.setOrderCode(null); 31response.setRefreshTime(0); 32}else { 33String[] orderInfo = redisOrderInfo.split("_"); 34long orderId = Integer.parseInt(orderInfo[0]); 35String orderCode = orderInfo[1]; 36response.setIsSuccess(true); 37response.setResponseCode(6104); 38response.setResponseMsg("秒殺成功"); 39response.setOrderId(orderId); 40response.setOrderCode(orderCode); 41response.setRefreshTime(0); 42} 43} 44return response; 45}
1 @Override 2@Transactional 3public SeckillInfoResponse startSeckill(int stallActivityId, int purchaseNum, String openId, String formId, long addressId, 4String shareCode, String shareSource, String userCode) { 5SeckillInfoResponse response = new SeckillInfoResponse(); 6//判斷秒殺活動是否開始 7if( !checkStartSeckill(stallActivityId) ) { 8response.setIsSuccess(false); 9response.setResponseCode(6205); 10response.setResponseMsg("秒殺活動尚未開始,請稍等!"); 11response.setRefreshTime(0); 12return response; 13} 14DistributedExclusiveRedisLock lock = new DistributedExclusiveRedisLock(redisTemplate); //構造鎖的時候需要帶入RedisTemplate例項 15lock.setLockKey("BM_MARKET_SECKILL_" + stallActivityId);//控制鎖的顆粒度 16lock.setExpires(2L);//每次操作預計的超時時間,單位秒 17try { 18lock.lock();//獲取鎖 19//做使用者重複購買校驗 20if( redisRepository.exists("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId) ) { 21response.setIsSuccess(false); 22response.setResponseCode(6105); 23response.setResponseMsg("您正在參與該活動,不能重複購買"); 24response.setRefreshTime(0); 25} else { 26String redisStock = redisRepository.get("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId); 27int surplusStock = Integer.parseInt(redisStock == null ? "0" : redisStock);//剩餘庫存 28//如果剩餘庫存大於購買數量,則進入消費佇列 29if( surplusStock >= purchaseNum ) { 30try { 31//鎖定庫存,並將請求放入消費佇列 32surplusStock = surplusStock - purchaseNum; 33redisRepository.set("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId, Integer.toString(surplusStock)); 34JSONObject jsonStr = new JSONObject(); 35jsonStr.put("stallActivityId", stallActivityId); 36jsonStr.put("purchaseNum", purchaseNum); 37jsonStr.put("openId", openId); 38jsonStr.put("addressId", addressId); 39jsonStr.put("formId", formId); 40jsonStr.put("shareCode", shareCode); 41jsonStr.put("shareSource", shareSource); 42jsonStr.put("userCode", userCode); 43//放入kafka訊息佇列 44messageQueueService.sendMessage("bm_market_seckill", jsonStr.toString(), true); 45//此處還應該標記一個seckillId和openId的唯一標誌來給輪詢介面判斷請求是否已經處理完成,需要在下單完成之後去維護刪除該標誌,並且建立一個新的標誌,並存放orderId 46redisRepository.set("BM_MARKET_LOCK_POLLING_" + stallActivityId + "_" + openId, "true"); 47//維護一個key,防止使用者在該活動重複購買,當支付過期之後應該維護刪除該標誌 48redisRepository.setExpire("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId, "true", 3600*24*7); 49 50response.setIsSuccess(true); 51response.setResponseCode(6101); 52response.setResponseMsg("排隊中,請稍後"); 53response.setRefreshTime(1000); 54} catch (Exception e) { 55e.printStackTrace(); 56response.setIsSuccess(false); 57response.setResponseCode(6102); 58response.setResponseMsg("秒殺失敗,商品已經售罄"); 59response.setRefreshTime(0); 60} 61}else { 62//需要在消費端維護一個真實的庫存損耗值,用來顯示是否還有未完成支付的使用者 63String redisRealStock = redisRepository.get("BM_MARKET_SECKILL_REAL_STOCKNUM_" + stallActivityId); 64int realStock = Integer.parseInt(redisRealStock == null ? "0" : redisRealStock);//剩餘的真實庫存 65if( realStock > 0 ) { 66response.setIsSuccess(false); 67response.setResponseCode(6103); 68response.setResponseMsg("秒殺失敗,還有部分訂單未完成支付,超時將返還庫存"); 69response.setRefreshTime(0); 70} else { 71response.setIsSuccess(false); 72response.setResponseCode(6102); 73response.setResponseMsg("秒殺失敗,商品已經售罄"); 74response.setRefreshTime(0); 75} 76} 77} 78} catch (Exception e) { 79e.printStackTrace(); 80response.setIsSuccess(false); 81response.setResponseCode(6102); 82response.setResponseMsg("秒殺失敗,商品已經售罄"); 83response.setRefreshTime(0); 84} finally { 85lock.unlock();//釋放鎖 86} 87return response; 88}
如有不妥之處,歡迎來交流和分享,接受批評和指正。
如果時機合適,會將相關的原始碼整理出來分享;同時,也會陸續完善關於這一塊的分享;