分散式場景下的秒殺架構與秒殺實現
隨著專案的上線與穩定執行,有關小程式秒殺系統的工作也算是告一段落了,最近也是抽空整理整理相關資料,留下了這篇文件;
分析,在做秒殺系統的設計之初,一直在思考如何去設計這個秒殺系統,使之在現有的技術基礎和認知範圍內,能夠做到最好;同時也能充分的利用公司現有的中介軟體來完成系統的實現。
我們都知道,正常去實現一個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) 9 public SeckillInfoResponse goSeckill(@RequestBody JSONObject jsonObject) { 10 int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1; //活動Id 11 AssertUtil.isTrue(stallActivityId != -1, "非法引數"); 12 int purchaseNum = jsonObject.containsKey("purchaseNum") ? jsonObject.getInteger("purchaseNum") : 1; //購買數量 13 AssertUtil.isTrue(purchaseNum != -1, "非法引數"); 14 String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null; 15 AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法引數"); 16 String formId = jsonObject.containsKey("formId") ? jsonObject.getString("formId") : null; 17 AssertUtil.isTrue(!StringUtil.isEmpty(formId), 1101, "非法引數"); 18 long addressId = jsonObject.containsKey("addressId") ? jsonObject.getLong("addressId") : -1; 19 AssertUtil.isTrue(addressId != -1, "非法引數"); 20 //通過分享入口進來的引數 21 String shareCode = jsonObject.getString("shareCode"); 22 String shareSource = jsonObject.getString("shareSource"); 23 String userCode = jsonObject.getString("userId"); 24 25 return 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) 9 public SeckillInfoResponse seckillPolling(@RequestBody JSONObject jsonObject) { 10 int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1; //活動Id 11 AssertUtil.isTrue(stallActivityId != -1, "非法引數"); 12 String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null; 13 AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法引數"); 14 15 SeckillInfoResponse response = new SeckillInfoResponse(); 16 if( redisRepository.exists("BM_MARKET_LOCK_POLLING_" + stallActivityId + "_" + openId) ) { 17 //如果快取中存在鎖定秒殺和使用者ID的key,則證明該訂單尚未處理完成,需要繼續等待 18 response.setIsSuccess(true); 19 response.setResponseCode(6103); 20 response.setResponseMsg("排隊中,請稍後"); 21 response.setRefreshTime(1000); 22 } else { 23 //如果快取中該key已經不存在,則表明該訂單已經下單成功,可以進入支付操作,並取出orderId返回 24 String redisOrderInfo = redisRepository.get("BM_MARKET_SECKILL_ORDERID_" + stallActivityId + "_" + openId); 25 if( redisOrderInfo == null ) { 26 response.setIsSuccess(false); 27 response.setResponseCode(6106); 28 response.setResponseMsg("秒殺失敗,下單出現異常,請重試!"); 29 response.setOrderId(0); 30 response.setOrderCode(null); 31 response.setRefreshTime(0); 32 }else { 33 String[] orderInfo = redisOrderInfo.split("_"); 34 long orderId = Integer.parseInt(orderInfo[0]); 35 String orderCode = orderInfo[1]; 36 response.setIsSuccess(true); 37 response.setResponseCode(6104); 38 response.setResponseMsg("秒殺成功"); 39 response.setOrderId(orderId); 40 response.setOrderCode(orderCode); 41 response.setRefreshTime(0); 42 } 43 } 44 return response; 45 }
1 @Override 2 @Transactional 3 public SeckillInfoResponse startSeckill(int stallActivityId, int purchaseNum, String openId, String formId, long addressId, 4 String shareCode, String shareSource, String userCode) { 5 SeckillInfoResponse response = new SeckillInfoResponse(); 6 //判斷秒殺活動是否開始 7 if( !checkStartSeckill(stallActivityId) ) { 8 response.setIsSuccess(false); 9 response.setResponseCode(6205); 10 response.setResponseMsg("秒殺活動尚未開始,請稍等!"); 11 response.setRefreshTime(0); 12 return response; 13 } 14 DistributedExclusiveRedisLock lock = new DistributedExclusiveRedisLock(redisTemplate); //構造鎖的時候需要帶入RedisTemplate例項 15 lock.setLockKey("BM_MARKET_SECKILL_" + stallActivityId); //控制鎖的顆粒度 16 lock.setExpires(2L); //每次操作預計的超時時間,單位秒 17 try { 18 lock.lock(); //獲取鎖 19 //做使用者重複購買校驗 20 if( redisRepository.exists("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId) ) { 21 response.setIsSuccess(false); 22 response.setResponseCode(6105); 23 response.setResponseMsg("您正在參與該活動,不能重複購買"); 24 response.setRefreshTime(0); 25 } else { 26 String redisStock = redisRepository.get("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId); 27 int surplusStock = Integer.parseInt(redisStock == null ? "0" : redisStock); //剩餘庫存 28 //如果剩餘庫存大於購買數量,則進入消費佇列 29 if( surplusStock >= purchaseNum ) { 30 try { 31 //鎖定庫存,並將請求放入消費佇列 32 surplusStock = surplusStock - purchaseNum; 33 redisRepository.set("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId, Integer.toString(surplusStock)); 34 JSONObject jsonStr = new JSONObject(); 35 jsonStr.put("stallActivityId", stallActivityId); 36 jsonStr.put("purchaseNum", purchaseNum); 37 jsonStr.put("openId", openId); 38 jsonStr.put("addressId", addressId); 39 jsonStr.put("formId", formId); 40 jsonStr.put("shareCode", shareCode); 41 jsonStr.put("shareSource", shareSource); 42 jsonStr.put("userCode", userCode); 43 //放入kafka訊息佇列 44 messageQueueService.sendMessage("bm_market_seckill", jsonStr.toString(), true); 45 //此處還應該標記一個seckillId和openId的唯一標誌來給輪詢介面判斷請求是否已經處理完成,需要在下單完成之後去維護刪除該標誌,並且建立一個新的標誌,並存放orderId 46 redisRepository.set("BM_MARKET_LOCK_POLLING_" + stallActivityId + "_" + openId, "true"); 47 //維護一個key,防止使用者在該活動重複購買,當支付過期之後應該維護刪除該標誌 48 redisRepository.setExpire("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId, "true", 3600*24*7); 49 50 response.setIsSuccess(true); 51 response.setResponseCode(6101); 52 response.setResponseMsg("排隊中,請稍後"); 53 response.setRefreshTime(1000); 54 } catch (Exception e) { 55 e.printStackTrace(); 56 response.setIsSuccess(false); 57 response.setResponseCode(6102); 58 response.setResponseMsg("秒殺失敗,商品已經售罄"); 59 response.setRefreshTime(0); 60 } 61 }else { 62 //需要在消費端維護一個真實的庫存損耗值,用來顯示是否還有未完成支付的使用者 63 String redisRealStock = redisRepository.get("BM_MARKET_SECKILL_REAL_STOCKNUM_" + stallActivityId); 64 int realStock = Integer.parseInt(redisRealStock == null ? "0" : redisRealStock); //剩餘的真實庫存 65 if( realStock > 0 ) { 66 response.setIsSuccess(false); 67 response.setResponseCode(6103); 68 response.setResponseMsg("秒殺失敗,還有部分訂單未完成支付,超時將返還庫存"); 69 response.setRefreshTime(0); 70 } else { 71 response.setIsSuccess(false); 72 response.setResponseCode(6102); 73 response.setResponseMsg("秒殺失敗,商品已經售罄"); 74 response.setRefreshTime(0); 75 } 76 } 77 } 78 } catch (Exception e) { 79 e.printStackTrace(); 80 response.setIsSuccess(false); 81 response.setResponseCode(6102); 82 response.setResponseMsg("秒殺失敗,商品已經售罄"); 83 response.setRefreshTime(0); 84 } finally { 85 lock.unlock(); //釋放鎖 86 } 87 return response; 88 }
如有不妥之處,歡迎來交流和分享,接受批評和指正。
如果時機合適,會將相關的原始碼整理出來分享;同時,也會陸續完善關於這一塊的分享;