1. 程式人生 > >分散式場景下的秒殺架構與秒殺實現

分散式場景下的秒殺架構與秒殺實現

 隨著專案的上線與穩定執行,有關小程式秒殺系統的工作也算是告一段落了,最近也是抽空整理整理相關資料,留下了這篇文件;

分析,在做秒殺系統的設計之初,一直在思考如何去設計這個秒殺系統,使之在現有的技術基礎和認知範圍內,能夠做到最好;同時也能充分的利用公司現有的中介軟體來完成系統的實現。

我們都知道,正常去實現一個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     }

如有不妥之處,歡迎來交流和分享,接受批評和指正。 

如果時機合適,會將相關的原始碼整理出來分享;同時,也會陸續完善關於這一塊的分享;