百萬流量的秒殺系統架構模型設計

 什麼是秒殺?通俗一點講就是網絡商家爲促銷等目的組織的網上限時搶購活動

比如說京東秒殺,就是一種定時定量秒殺,在規定的時間內,無論商品是否秒殺完畢,該場次的秒殺活動都會結束。這種秒殺,對時間不是特別嚴格,只要下手快點,秒中的概率還是比較大的。
淘寶以前就做過一元搶購,一般都是限量 1 件商品,同時價格低到「令人發齒」,這種秒殺一般都在開始時間 1 到3 秒內就已經搶光了,參與這個秒殺一般都是看運氣的,不必太強求。

業務特點:

 瞬時併發量大

秒殺時會有大量用戶在同一時間進行搶購,瞬時併發訪問量突增 10 倍,甚至 100 倍以上都有。

庫存量少

一般秒殺活動商品量很少,這就導致了只有極少量用戶能成功購買到。

業務簡單
流程比較簡單,一般都是下訂單、扣庫存、支付訂單。

技術難點:

現有業務的衝擊
秒殺是營銷活動中的一種,如果和其他營銷活動應用部署在同一服務器上,肯定會對現有其他活動造成衝擊,極端情況下可能導致整個電商系統服務宕機。
直接下訂單
下單頁面是一個正常的 URL 地址,需要控制在秒殺開始前,不能下訂單,只能瀏覽對應活動商品的信息。簡單來說,需要 Disable 訂單按鈕。
頁面流量突增
秒殺活動開始前後,會有很多用戶請求對應商品頁面,會造成後臺服務器的流量突增,同時對應的網絡帶寬增加,需要控制商品頁面的流量不會對後臺服務器、DB、Redis 等組件的造成過大的壓力

架構設計思想:

限流
由於活動庫存量一般都是很少,對應的只有少部分用戶才能秒殺成功。所以我們需要限制大部分用戶流量,只准少量用戶流量進入後端服務器。
削峯
秒殺開始的那一瞬間,會有大量用戶衝擊進來,所以在開始時候會有一個瞬間流量峯值。如何把瞬間的流量峯值變得更平緩,是能否成功設計好秒殺系統的關鍵因素。實現流量削峯填谷,一般的採用緩存和 MQ 中間件來解決。
異步
秒殺其實可以當做高併發系統來處理,在這個時候,可以考慮從業務上做兼容,將同步的業務,設計成異步處理的任務,提高網站的整體可用性。
緩存
秒殺系統的瓶頸主要體現在下訂單、扣減庫存流程中。在這些流程中主要用到 OLTP 的數據庫,類似 MySQL、SQLServer、Oracle。

由於數據庫底層採用 B+ 樹的儲存結構,對應我們隨機寫入與讀取的效率,相對較低。如果我們把部分業務邏輯遷移到內存的緩存或者 Redis 中,會極大的提高併發效率。

 

一:秒殺應該考慮哪些問題

1.1:超賣問題

   分析秒殺的業務場景,最重要的有一點就是超賣問題,假如備貨只有100個,但是最終超賣了200,一般來講秒殺系統的價格都比較低,如果超賣將嚴重影響公司的財產利益,因此首當其衝的就是解決商品的超賣問題。

1.2:高併發

秒殺具有時間短、併發量大的特點,秒殺持續時間只有幾分鐘,而一般公司都爲了製造轟動效應,會以極低的價格來吸引用戶,因此參與搶購的用戶會非常的多。短時間內會有大量請求湧進來,後端如何防止併發過高造成緩存擊穿或者失效,擊垮數據庫都是需要考慮的問題。

1.3:接口防刷

現在的秒殺大多都會出來針對秒殺對應的軟件,這類軟件會模擬不斷向後臺服務器發起請求,一秒幾百次都是很常見的,如何防止這類軟件的重複無效請求,防止不斷髮起的請求也是需要我們針對性考慮的

1.4:秒殺url

對於普通用戶來講,看到的只是一個比較簡單的秒殺頁面,在未達到規定時間,秒殺按鈕是灰色的,一旦到達規定時間,灰色按鈕變成可點擊狀態。這部分是針對小白用戶的,如果是稍微有點電腦功底的用戶,會通過F12看瀏覽器的network看到秒殺的url,通過特定軟件去請求也可以實現秒殺。或者提前知道秒殺url的人,一請求就直接實現秒殺了。這個問題我們需要考慮解決

1.5:數據庫設計

秒殺有把我們服務器擊垮的風險,如果讓它與我們的其他業務使用在同一個數據庫中,耦合在一起,就很有可能牽連和影響其他的業務。如何防止這類問題發生,就算秒殺發生了宕機、服務器卡死問題,也應該讓他儘量不影響線上正常進行的業務

1.6:大量請求問題

按照1.2的考慮,就算使用緩存還是不足以應對短時間的高併發的流量的衝擊。如何承載這樣巨大的訪問量,同時提供穩定低時延的服務保證,是需要面對的一大挑戰。我們來算一筆賬,假如使用的是redis緩存,單臺redis服務器可承受的QPS大概是4W左右,如果一個秒殺吸引的用戶量足夠多的話,單QPS可能達到幾十萬,單體redis還是不足以支撐如此巨大的請求量。緩存會被擊穿,直接滲透到DB,從而擊垮mysql.後臺會將會大量報錯

二:秒殺系統的設計和技術方案

2.1:秒殺系統數據庫設計

針對1.5提出的秒殺數據庫的問題,因此應該單獨設計一個秒殺數據庫,防止因爲秒殺活動的高併發訪問拖垮整個網站。

這裏只需要兩張表,一張是秒殺訂單表,一張是秒殺貨品表

  

其實應該還有幾張表,商品表:可以關聯goods_id查到具體的商品信息,商品圖像、名稱、平時價格、秒殺價格等,還有用戶表:根據用戶user_id可以查詢到用戶暱稱、用戶手機號,收貨地址等其他額外信息,這個具體就不給出實例了。

2.2:秒殺url的設計

爲了避免有程序訪問經驗的人通過下單頁面url直接訪問後臺接口來秒殺貨品,我們需要將秒殺的url實現動態化,即使是開發整個系統的人都無法在秒殺開始前知道秒殺的url。具體的做法就是通過md5加密一串隨機字符作爲秒殺的url,然後前端訪問後臺獲取具體的url,後臺校驗通過之後纔可以繼續秒殺。

2.3:秒殺頁面靜態化

將商品的描述、參數、成交記錄、圖像、評價等全部寫入到一個靜態頁面,用戶請求不需要通過訪問後端服務器,不需要經過數據庫,直接在前臺客戶端生成,這樣可以最大可能的減少服務器的壓力。具體的方法可以使用freemarker模板技術,建立網頁模板,填充數據,然後渲染網頁

2.4:單體redis升級爲集羣redis

秒殺是一個讀多寫少的場景,使用redis做緩存再合適不過。不過考慮到緩存擊穿問題,我們應該構建redis集羣,採用哨兵模式,可以提升redis的性能和可用性。

 2.5:使用nginx

nginx是一個高性能web服務器,它的併發能力可以達到幾萬,而tomcat只有幾百。通過nginx映射客戶端請求,再分發到後臺tomcat服務器集羣中可以大大提升併發能力。

2.6:精簡sql

典型的一個場景是在進行扣減庫存的時候,傳統的做法是先查詢庫存,再去update。這樣的話需要兩個sql,而實際上一個sql我們就可以完成的。可以用這樣的做法:update miaosha_goods  set stock =stock-1 where goos_id ={#goods_id} and  version = #{version} and sock>0;這樣的話,就可以保證庫存不會超賣並且一次更新庫存,還有注意一點這裏使用了版本號的樂觀鎖,相比較悲觀鎖,它的性能較好。

2.7:redis預減庫存

很多請求進來,都需要後臺查詢庫存,這是一個頻繁讀的場景。可以使用redis來預減庫存,在秒殺開始前可以在redis設值,比如redis.set(goodsId,100),這裏預放的庫存爲100可以設值爲常量),每次下單成功之後,Integer stock = (Integer)redis.get(goosId); 然後判斷sock的值,如果小於常量值就減去1;不過注意當取消的時候,需要增加庫存,增加庫存的時候也得注意不能大於之間設定的總庫存數(查詢庫存和扣減庫存需要原子操作,此時可以藉助lua腳本)下次下單再獲取庫存的時候,直接從redis裏面查就可以了。

2.8:接口限流

秒殺最終的本質是數據庫的更新,但是有很多大量無效的請求,我們最終要做的就是如何把這些無效的請求過濾掉,防止滲透到數據庫。限流的話,需要入手的方面很多:

2.8.1:前端限流

首先第一步就是通過前端限流,用戶在秒殺按鈕點擊以後發起請求,那麼在接下來的5秒是無法點擊(通過設置按鈕爲disable)。這一小舉措開發起來成本很小,但是很有效。

2.8.2:同一個用戶xx秒內重複請求直接拒絕

具體多少秒需要根據實際業務和秒殺的人數而定,一般限定爲10秒。具體的做法就是通過redis的鍵過期策略,首先對每個請求都從String value = redis.get(userId);如果獲取到這個

value爲空或者爲null,表示它是有效的請求,然後放行這個請求。如果不爲空表示它是重複性請求,直接丟掉這個請求。如果有效,採用redis.setexpire(userId,value,10).value可以是任意值,一般放業務屬性比較好,這個是設置以userId爲key,10秒的過期時間(10秒後,key對應的值自動爲null)

2.8.3:令牌桶算法限流

接口限流的策略有很多,我們這裏採用令牌桶算法。令牌桶算法的基本思路是每個請求嘗試獲取一個令牌,後端只處理持有令牌的請求,生產令牌的速度和效率我們都可以自己限定,guava提供了RateLimter的api供我們使用。以下做一個簡單的例子,注意需要引入guava

public class TestRateLimiter {

    public static void main(String[] args) {
        //1秒產生1個令牌
        final RateLimiter rateLimiter = RateLimiter.create(1);
        for (int i = 0; i < 10; i++) {
            //該方法會阻塞線程,直到令牌桶中能取到令牌爲止才繼續向下執行。
            double waitTime= rateLimiter.acquire();
            System.out.println("任務執行" + i + "等待時間" + waitTime);
        }
        System.out.println("執行結束");
    }
}

   上面代碼的思路就是通過RateLimiter來限定我們的令牌桶每秒產生1個令牌(生產的效率比較低),循環10次去執行任務。acquire會阻塞當前線程直到獲取到令牌,也就是如果任務沒有獲取到令牌,會一直等待。那麼請求就會卡在我們限定的時間內纔可以繼續往下走,這個方法返回的是線程具體等待的時間。執行如下;

可以看到任務執行的過程中,第1個是無需等待的,因爲已經在開始的第1秒生產出了令牌。接下來的任務請求就必須等到令牌桶產生了令牌纔可以繼續往下執行。如果沒有獲取到就會阻塞(有一個停頓的過程)。不過這個方式不太好,因爲用戶如果在客戶端請求,如果較多的話,直接後臺在生產token就會卡頓(用戶體驗較差),它是不會拋棄任務的,我們需要一個更優秀的策略:如果超過某個時間沒有獲取到,直接拒絕該任務。接下來再來個案例:

public class TestRateLimiter2 {

    public static void main(String[] args) {
        final RateLimiter rateLimiter = RateLimiter.create(1);

        for (int i = 0; i < 10; i++) {
            long timeOut = (long) 0.5;
            boolean isValid = rateLimiter.tryAcquire(timeOut, TimeUnit.SECONDS);
            System.out.println("任務" + i + "執行是否有效:" + isValid);
            if (!isValid) {
                continue;
            }
            System.out.println("任務" + i + "在執行");
        }
        System.out.println("結束");
    }
}

其中用到了tryAcquire方法,這個方法的主要作用是設定一個超時的時間,如果在指定的時間內預估(注意是預估並不會真實的等待),如果能拿到令牌就返回true,如果拿不到就返回false.然後我們讓無效的直接跳過,這裏設定每秒生產1個令牌,讓每個任務嘗試在

0.5秒獲取令牌,如果獲取不到,就直接跳過這個任務(放在秒殺環境裏就是直接拋棄這個請求);程序實際運行如下:

只有第1個獲取到了令牌,順利執行了,下面的基本都直接拋棄了,因爲0.5秒內,令牌桶(1秒1個)來不及生產就肯定獲取不到返回false了。

這個限流策略的效率有多高呢?假如我們的併發請求是400萬瞬間的請求,將令牌產生的效率設爲每秒20個,每次嘗試獲取令牌的時間是0.05秒,那麼最終測試下來的結果是,每次只會放行4個左右的請求,大量的請求會被拒絕,這就是令牌桶算法的優秀之處。

2.9:異步下單

爲了提升下單的效率,並且防止下單服務的失敗。需要將下單這一操作進行異步處理。最常採用的辦法是使用隊列,隊列最顯著的三個優點:異步、削峯、解耦。這裏可以採用rabbitmq,在後臺經過了限流、庫存校驗之後,流入到這一步驟的就是有效請求。然後發送到隊列裏,隊列接受消息,異步下單。下完單,入庫沒有問題可以用短信通知用戶秒殺成功。假如失敗的話,可以採用補償機制,重試。

3.0:服務降級

假如在秒殺過程中出現了某個服務器宕機,或者服務不可用,應該做好後備工作。之前的博客裏有介紹通過Hystrix進行服務熔斷和降級,可以開發一個備用服務,假如服務器真的宕機了,直接給用戶一個友好的提示返回,而不是直接卡死,服務器錯誤等生硬的反饋。

 

三、秒殺系統詳細設計


3.1 創建秒殺活動


3.2 查看秒殺活動

3.3 參與秒殺活動

四、秒殺系統的背景與特點


秒殺特點:短時間內,大量用戶湧入,集中讀和寫有限的庫存。

解決方案:層層攔截,將請求儘量攔截在系統上游,避免將鎖衝落到數據庫上。

4.1客戶端優化

產品層面,用戶點擊“查詢”或者“購票”後,按鈕置灰,禁止用戶重複提交請求; JS層面,限制用戶在x秒之內只能提交一次請求,比如微信搖一搖搶紅包。 基本可以攔截80%的請求。

4.2 站點層面的請求攔截(nginx層,寫流控模塊)

怎麼防止程序員寫for循環調用,有去重依據麼? IP? cookie-id? …想複雜了,這類業務都需要登錄,用uid即可。在站點層面,對uid進行請求計數和去重,甚至不需要統一存儲計數,直接站點層內存存儲(這樣計數會不準,但最簡單,比如guava本地緩存)。一個uid,5秒只准透過1個請求,這樣又能攔住99%的for循環請求。 對於5s內的無效請求,統一返回錯誤提示或錯誤頁面。

這個方式攔住了寫for循環發HTTP請求的程序員,有些高端程序員(黑客)控制了10w個肉雞,手裏有10w個uid,同時發請求(先不考慮實名制的問題,小米搶手機不需要實名制),這下怎麼辦,站點層按照uid限流攔不住了。

4.3 服務攔截層

方案一:寫請求放到隊列中,每次只透有限的寫請求到數據層,如果成功了再放下一批,直到庫存不夠,隊列裏的寫請求全部返回“已售完”。

方案二:或採用漏斗機制,只放一倍的流量進來,多餘的返回“已售完”,把寫壓力轉換成讀壓力。 讀請求,用cache,redis單機可以抗10W QPS,用異步線程定時更新緩存裏的庫存值。

還有提示“模糊化”,比如火車餘票查詢,票剩了58張,還是26張,你真的關注麼,其實我們只關心有票和無票。

4.4 數據庫層

瀏覽器攔截了80%,站點層攔截了99.9%並做了頁面緩存,服務層又做了寫請求隊列與數據緩存,每次透到數據庫層的請求都是可控的。 db基本就沒什麼壓力了,通過自身鎖機制來控制,避免出現超賣。

4.5 總結:

儘量將請求攔截在系統上游(越上游越好);
讀多寫少的多使用緩存(緩存抗讀壓力);

五、秒殺系統可能存在的問題

對於一個日常平穩的業務系統,如果直接開通秒殺功能的話,往往會出現很多問題

 

秒殺本質是要求一個瞬時高發下的承壓系統,這也是其區別於其他業務的核心場景。對日常系統秒殺產生的問題逐一進行拆解分類,秒殺對應到架構設計,其實就是高可用、一致性和高性能的要求。關於秒殺系統的設計思考

高性能。 秒殺涉及高讀和高寫的支持,如何支撐高併發,如何抵抗高IOPS?核心優化理念其實是類似的:高讀就儘量"少讀"或"讀少",高寫就數據拆分。本文將從動靜分離、熱點優化以及服務端性能優化 3 個方面展開。
一致性。 秒殺的核心關注是商品庫存,有限的商品在同一時間被多個請求同時扣減,而且要保證準確性,顯而易見是一個難題。如何做到既不多又不少?本文將從業界通用的幾種減庫存方案切入,討論一致性設計的核心邏輯。
高可用。 大型分佈式系統在實際運行過程中面對的工況是非常複雜的,業務流量的突增、依賴服務的不穩定、應用自身的瓶頸、物理資源的損壞等方方面面都會對系統的運行帶來大大小小的的衝擊。如何保障應用在複雜工況環境下還能高效穩定運行,如何預防和麪對突發問題,系統設計時應該從哪些方面着手?

六、秒殺系統設計策略


6.1 高性能設計


6.1.1 動靜分離策略
大家可能會注意到,秒殺過程中你是不需要刷新整個頁面的,只有時間在不停跳動。這是因爲一般都會對大流量的秒殺系統做系統的靜態化改造,即數據意義上的動靜分離。動靜分離三步走:

數據拆分
靜態緩存
數據整合
6.1.1.1 數據拆分

動靜分離的首要目的是將動態頁面改造成適合緩存的靜態頁面。因此第一步就是分離出動態數據,主要從以下 2 個方面進行:

用戶。用戶身份信息包括登錄狀態以及登錄畫像等,相關要素可以單獨拆分出來,通過動態請求進行獲取;與之相關的廣平推薦,如用戶偏好、地域偏好等,同樣可以通過異步方式進行加載。
時間。秒殺時間是由服務端統一管控的,可以通過動態請求進行獲取。
這裏你可以打開電商平臺的一個秒殺頁面,看看這個頁面裏都有哪些動靜數據。

6.1.1.2 靜態緩存

分離出動靜態數據之後,第二步就是將靜態數據進行合理的緩存,由此衍生出兩個問題:

1、怎麼緩存;

靜態化改造的一個特點是直接緩存整個 HTTP 連接而不是僅僅緩存靜態數據,如此一來,Web 代理服務器根據請求 URL,可以直接取出對應的響應體然後直接返回,響應過程無需重組 HTTP 協議,也無需解析 HTTP 請求頭。而作爲緩存鍵,URL唯一化是必不可少的,只是對於商品系統,URL 天然是可以基於商品 ID 來進行唯一標識的,比如淘寶的 https://item.taobao.com/item...

2、哪裏緩存。

靜態數據緩存到哪裏呢?可以有三種方式:1、瀏覽器;2、CDN ;3、服務端。

瀏覽器當然是第一選擇,但用戶的瀏覽器是不可控的,主要體現在如果用戶不主動刷新,系統很難主動地把消息推送給用戶(注意,當討論靜態數據時,潛臺詞是 “相對不變”,言外之意是 “可能會變”),如此可能會導致用戶端在很長一段時間內看到的信息都是錯誤的。對於秒殺系統,保證緩存可以在秒級時間內失效是不可或缺的。

服務端主要進行動態邏輯計算及加載,本身並不擅長處理大量連接,每個連接消耗內存較多,同時 Servlet 容器解析 HTTP 較慢,容易侵佔邏輯計算資源;另外,靜態數據下沉至此也會拉長請求路徑。

因此通常將靜態數據緩存在 CDN,其本身更擅長處理大併發的靜態文件請求,既可以做到主動失效,又離用戶儘可能近,同時規避 Java 語言層面的弱點。需要注意的是,上 CDN 有以下幾個問題需要解決:

失效問題。任何一個緩存都應該是有時效的,尤其對於一個秒殺場景。所以,系統需要保證全國各地的 CDN 在秒級時間內失效掉緩存信息,這實際對 CDN 的失效系統要求是很高的
命中率問題。高命中是緩存系統最爲核心的性能要求,不然緩存就失去了意義。如果將數據放到全國各地的 CDN ,勢必會導致請求命中同一個緩存的可能性降低,那麼命中率就成爲一個問題
因此,將數據放到全國所有的 CDN 節點是不太現實的,失效問題、命中率問題都會面臨比較大的挑戰。更爲可行的做法是選擇若干 CDN 節點進行靜態化改造,節點的選取通常需要滿足以下幾個條件:

臨近訪問量集中的地區
距離主站較遠的地區
節點與主站間網絡質量良好的地區
基於以上因素,選擇 CDN 的二級緩存比較合適,因爲二級緩存數量偏少,容量也更大,訪問量相對集中,這樣就可以較好解決緩存的失效問題以及命中率問題,是當前比較理想的一種 CDN 化方案。部署方式如下圖所示:

6.1.1.3 數據整合

分離出動靜態數據之後,前端如何組織數據頁就是一個新的問題,主要在於動態數據的加載處理,通常有兩種方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。

ESI 方案:Web 代理服務器上請求動態數據,並將動態數據插入到靜態頁面中,用戶看到頁面時已經是一個完整的頁面。這種方式對服務端性能要求高,但用戶體驗較好
CSI 方案:Web 代理服務器上只返回靜態頁面,前端單獨發起一個異步 JS 請求動態數據。這種方式對服務端性能友好,但用戶體驗稍差
6.1.1.4 動靜分離策略

動靜分離對於性能的提升,抽象起來只有兩點,一是數據要儘量少,以便減少沒必要的請求,二是路徑要儘量短,以便提高單次請求的效率。具體方法其實就是基於這個大方向進行的。

6.1.2 熱點優化策略
熱點分爲熱點操作和熱點數據,以下分開進行討論。零點刷新、零點下單、零點添加購物車等都屬於熱點操作。熱點操作是用戶的行爲,不好改變,但可以做一些限制保護,比如用戶頻繁刷新頁面時進行提示阻斷。熱點數據的處理三步走,一是熱點識別,二是熱點隔離,三是熱點優化。

6.1.2.1 熱點識別

熱點數據分爲靜態熱點和動態熱點,具體如下:

靜態熱點:能夠提前預測的熱點數據。大促前夕,可以根據大促的行業特點、活動商家等緯度信息分析出熱點商品,或者通過賣家報名的方式提前篩選;另外,還可以通過技術手段提前預測,例如對買家每天訪問的商品進行大數據計算,然後統計出 TOP N 的商品,即可視爲熱點商品
動態熱點:無法提前預測的熱點數據。冷熱數據往往是隨實際業務場景發生交替變化的,尤其是如今直播賣貨模式的興起——帶貨商臨時做一個廣告,就有可能導致一件商品在短時間內被大量購買。由於此類商品日常訪問較少,即使在緩存系統中一段時間後也會被逐出或過期掉,甚至在db中也是冷數據。瞬時流量的湧入,往往導致緩存被擊穿,請求直接到達DB,引發DB壓力過大。
因此秒殺系統需要實現熱點數據的動態發現能力,一個常見的實現思路是:

異步採集交易鏈路各個環節的熱點 Key 信息,如 Nginx採集訪問URL或 Agent 採集熱點日誌(一些中間件本身已具備熱點發現能力),提前識別潛在的熱點數據
聚合分析熱點數據,達到一定規則的熱點數據,通過訂閱分發推送到鏈路系統,各系統根據自身需求決定如何處理熱點數據,或限流或緩存,從而實現熱點保護。
需要注意的是:

熱點數據採集最好採用異步方式,一方面不會影響業務的核心交易鏈路,一方面可以保證採集方式的通用性
熱點發現最好做到秒級實時,這樣動態發現纔有意義,實際上也是對核心節點的數據採集和分析能力提出了較高的要求
6.1.2.2 熱點隔離

熱點數據識別出來之後,第一原則就是將熱點數據隔離出來,不要讓 1% 影響到另外的 99%,可以基於以下幾個層次實現熱點隔離:

業務隔離。秒殺作爲一種營銷活動,賣家需要單獨報名,從技術上來說,系統可以提前對已知熱點做緩存預熱
系統隔離。系統隔離是運行時隔離,通過分組部署和另外 99% 進行分離,另外秒殺也可以申請單獨的域名,入口層就讓請求落到不同的集羣中
數據隔離。秒殺數據作爲熱點數據,可以啓用單獨的緩存集羣或者DB服務組,從而更好的實現橫向或縱向能力擴展
當然,實現隔離還有很多種辦法。比如,可以按照用戶來區分,爲不同的用戶分配不同的 Cookie,入口層路由到不同的服務接口中;再比如,域名保持一致,但後端調用不同的服務接口;又或者在數據層給數據打標進行區分等等,這些措施的目的都是把已經識別的熱點請求和普通請求區分開來。

6.1.2.3 熱點優化

熱點數據隔離之後,也就方便對這 1% 的請求做針對性的優化,方式無外乎兩種:

緩存:熱點緩存是最爲有效的辦法。如果熱點數據做了動靜分離,那麼可以長期緩存靜態數據
限流:流量限制更多是一種保護機制。需要注意的是,各服務要時刻關注請求是否觸發限流並及時進行review
6.1.2.4 熱點優化總結

數據的熱點優化與動靜分離是不一樣的,熱點優化是基於二八原則對數據進行了縱向拆分,以便進行鍼對性地處理。熱點識別和隔離不僅對“秒殺”這個場景有意義,對其他的高性能分佈式系統也非常有參考價值。

6.1.3 系統優化
對於一個軟件系統,提高性能可以有很多種手段,如提升硬件水平、調優JVM 性能,這裏主要關注代碼層面的性能優化——

減少序列化:減少 Java 中的序列化操作可以很好的提升系統性能。序列化大部分是在 RPC 階段發生,因此應該儘量減少 RPC 調用,一種可行的方案是將多個關聯性較強的應用進行 “合併部署”,從而減少不同應用之間的 RPC 調用(微服務設計規範)
直接輸出流數據:只要涉及字符串的I/O操作,無論是磁盤 I/O 還是網絡 I/O,都比較耗費 CPU 資源,因爲字符需要轉換成字節,而這個轉換又必須查表編碼。所以對於常用數據,比如靜態字符串,推薦提前編碼成字節並緩存,具體到代碼層面就是通過 OutputStream() 類函數從而減少數據的編碼轉換;另外,熱點方法toString()不要直接調用ReflectionToString實現,推薦直接硬編碼,並且只打印DO的基礎要素和核心要素
裁剪日誌異常堆棧:無論是外部系統異常還是應用本身異常,都會有堆棧打出,超大流量下,頻繁的輸出完整堆棧,只會加劇系統當前負載。可以通過日誌配置文件控制異常堆棧輸出的深度
去組件框架:極致優化要求下,可以去掉一些組件框架,比如去掉傳統的 MVC 框架,直接使用 Servlet 處理請求。這樣可以繞過一大堆複雜且用處不大的處理邏輯,節省毫秒級的時間,當然,需要合理評估你對框架的依賴程度
6.1.4 系統高性能設計總結
性能優化需要一個基準值,所以系統還需要做好應用基線,比如性能基線(何時性能突然下降)、成本基線(去年大促用了多少機器)、鏈路基線(核心流程發生了哪些變化),通過基線持續關注系統性能,促使系統在代碼層面持續提升編碼質量、業務層面及時下掉不合理調用、架構層面不斷優化改進。

6.2 一致性設計
秒殺系統中,庫存是個關鍵數據,賣不出去是個問題,超賣更是個問題。秒殺場景下的一致性問題,主要就是庫存扣減的準確性問題。

6.2.1 減庫存的方式
電商場景下的購買過程一般分爲兩步:下單和付款。“提交訂單”即爲下單,“支付訂單”即爲付款。基於此設定,減庫存一般有以下幾個方式:

下單減庫存。買家下單後,扣減商品庫存。下單減庫存是最簡單的減庫存方式,也是控制最爲精確的一種
付款減庫存。買家下單後,並不立即扣減庫存,而是等到付款後才真正扣減庫存。但因爲付款時才減庫存,如果併發比較高,可能出現買家下單後付不了款的情況,因爲商品已經被其他人買走了
預扣庫存。這種方式相對複雜一些,買家下單後,庫存爲其保留一定的時間(如 15 分鐘),超過這段時間,庫存自動釋放,釋放後其他買家可以購買

6.2.2 減庫存的問題

6.2.2.1 下單減庫存

優勢:用戶體驗最好。下單減庫存是最簡單的減庫存方式,也是控制最精確的一種。下單時可以直接通過數據庫事務機制控制商品庫存,所以一定不會出現已下單卻付不了款的情況。

劣勢:可能賣不出去。正常情況下,買家下單後付款概率很高,所以不會有太大問題。但有一種場景例外,就是當賣家參加某個促銷活動時,競爭對手通過惡意下單的方式將該商品全部下單,導致庫存清零,那麼這就不能正常售賣了——要知道,惡意下單的人是不會真正付款的,這正是 “下單減庫存” 的不足之處。

6.2.2.2 付款減庫存

優勢:一定實際售賣。“下單減庫存” 可能導致惡意下單,從而影響賣家的商品銷售, “付款減庫存” 由於需要付出真金白銀,可以有效避免。

劣勢:用戶體驗較差。用戶下單後,不一定會實際付款,假設有 100 件商品,就可能出現 200 人下單成功的情況,因爲下單時不會減庫存,所以也就可能出現下單成功數遠遠超過真正庫存數的情況,這尤其會發生在大促的熱門商品上。如此一來就會導致很多買家下單成功後卻付不了款,購物體驗自然是比較差的。

6.2.2.3 預扣庫存

優勢:緩解了以上兩種方式的問題。預扣庫存實際就是“下單減庫存”和 “付款減庫存”兩種方式的結合,將兩次操作進行了前後關聯,下單時預扣庫存,付款時釋放庫存。

劣勢:並沒有徹底解決以上問題。比如針對惡意下單的場景,雖然可以把有效付款時間設置爲 10 分鐘,但惡意買家完全可以在 10 分鐘之後再次下單。

6.2.3 實際如何減庫存


業界最爲常見的是預扣庫存。無論是外賣點餐還是電商購物,下單後一般都有個 “有效付款時間”,超過該時間訂單自動釋放,這就是典型的預扣庫存方案。但如上所述,預扣庫存還需要解決惡意下單的問題,保證商品賣的出去;另一方面,如何避免超賣,也是一個痛點。

賣的出去:惡意下單的解決方案主要還是結合安全和反作弊措施來制止。比如,識別頻繁下單不付款的買家並進行打標,這樣可以在打標買家下單時不減庫存;再比如爲大促商品設置單人最大購買件數,一人最多隻能買 N 件商品;又或者對重複下單不付款的行爲進行次數限制阻斷等
避免超賣:庫存超賣的情況實際分爲兩種。對於普通商品,秒殺只是一種大促手段,即使庫存超賣,商家也可以通過補貨來解決;而對於一些商品,秒殺作爲一種營銷手段,完全不允許庫存爲負,也就是在數據一致性上,需要保證大併發請求時數據庫中的庫存字段值不能爲負,一般有多種方案:一是在通過事務來判斷,即保證減後庫存不能爲負,否則就回滾;二是直接設置數據庫字段類型爲無符號整數,這樣一旦庫存爲負就會在執行 SQL 時報錯;三是使用 CASE WHEN 判斷語句——

UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END


業務手段保證商品賣的出去,技術手段保證商品不會超賣,庫存問題從來就不是簡單的技術難題,解決問題的視角是多種多樣的。

6.2.4 一致性性能的優化

庫存是個關鍵數據,更是個熱點數據。對系統來說,熱點的實際影響就是 “高讀” 和 “高寫”,也是秒殺場景下最爲核心的一個技術難題。

6.2.4.1 高併發讀

秒殺場景解決高併發讀問題,關鍵詞是“分層校驗”。即在讀鏈路時,只進行不影響性能的檢查操作,如用戶是否具有秒殺資格、商品狀態是否正常、用戶答題是否正確、秒殺是否已經結束、是否非法請求等,而不做一致性校驗等容易引發瓶頸的檢查操作;直到寫鏈路時,纔對庫存做一致性檢查,在數據層保證最終準確性。

因此,在分層校驗設定下,系統可以採用分佈式緩存甚至LocalCache來抵抗高併發讀。即允許讀場景下一定的髒數據,這樣只會導致少量原本無庫存的下單請求被誤認爲是有庫存的,等到真正寫數據時再保證最終一致性,由此做到高可用和一致性之間的平衡。

實際上,分層校驗的核心思想是:不同層次儘可能過濾掉無效請求,只在“漏斗” 最末端進行有效處理,從而縮短系統瓶頸的影響路徑。

6.2.4.2 高併發寫

高併發寫的優化方式,一種是更換DB選型,一種是優化DB性能,以下分別進行討論。

6.2.4.3 更換DB選型

秒殺商品和普通商品的減庫存是有差異的,核心區別在數據量級小、交易時間短,因此能否把秒殺減庫存直接放到緩存系統中實現呢,也就是直接在一個帶有持久化功能的緩存中進行減庫存操作,比如 Redis?

如果減庫存邏輯非常單一的話,比如沒有複雜的 SKU 庫存和總庫存這種聯動關係的話,個人認爲是完全可以的。但如果有比較複雜的減庫存邏輯,或者需要使用到事務,那就必須在數據庫中完成減庫存操作。

6.2.4.4 優化DB性能

庫存數據落地到數據庫實現其實是一行存儲(MySQL),因此會有大量線程來競爭 InnoDB 行鎖。但併發越高,等待線程就會越多,TPS 下降,RT 上升,吞吐量會受到嚴重影響——注意,這裏假設數據庫已基於上文【性能優化】完成數據隔離,以便於討論聚焦 。

解決併發鎖的問題,有兩種辦法:

應用層排隊。通過緩存加入集羣分佈式鎖,從而控制集羣對數據庫同一行記錄進行操作的併發度,同時也能控制單個商品佔用數據庫連接的數量,防止熱點商品佔用過多的數據庫連接
數據層排隊。應用層排隊是有損性能的,數據層排隊是最爲理想的。業界中,阿里的數據庫團隊開發了針對InnoDB 層上的補丁程序(patch),可以基於DB層對單行記錄做併發排隊,從而實現秒殺場景下的定製優化——注意,排隊和鎖競爭是有區別的,如果熟悉 MySQL 的話,就會知道 InnoDB 內部的死鎖檢測,以及 MySQL Server 和 InnoDB 的切換都是比較消耗性能的。另外阿里的數據庫團隊還做了很多其他方面的優化,如 COMMIT_ON_SUCCESS 和 ROLLBACK_ON_FAIL 的補丁程序,通過在 SQL 里加入提示(hint),實現事務不需要等待實時提交,而是在數據執行完最後一條 SQL 後,直接根據 TARGET_AFFECT_ROW 的結果進行提交或回滾,減少網絡等待的時間(毫秒級)。目前阿里已將包含這些補丁程序的 MySQL 開源:AliSQL

6.3 高可用設計

盯過秒殺流量監控的話,會發現它不是一條蜿蜒而起的曲線,而是一條挺拔的直線,這是因爲秒殺請求高度集中於某一特定的時間點。這樣一來就會造成一個特別高的零點峯值,而對資源的消耗也幾乎是瞬時的。所以秒殺系統的可用性保護是不可或缺的。

6.3.1 流量削峯
對於秒殺的目標場景,最終能夠搶到商品的人數是固定的,無論 100 人和 10000 人蔘加結果都是一樣的,即有效請求額度是有限的。併發度越高,無效請求也就越多。但秒殺作爲一種商業營銷手段,活動開始之前是希望有更多的人來刷頁面,只是真正開始後,秒殺請求不是越多越好。因此係統可以設計一些規則,人爲的延緩秒殺請求,甚至可以過濾掉一些無效請求。

答題

早期秒殺只是簡單的點擊秒殺按鈕,後來才增加了答題。爲什麼要增加答題呢?主要是通過提升購買的複雜度,達到兩個目的:

防止作弊。早期秒殺器比較猖獗,存在惡意買家或競爭對手使用秒殺器掃貨的情況,商家沒有達到營銷的目的,所以增加答題來進行限制
延緩請求。零點流量的起效時間是毫秒級的,答題可以人爲拉長峯值下單的時長,由之前的 <1s 延長到 <10s。這個時間對於服務端非常重要,會大大減輕高峯期併發壓力;另外,由於請求具有先後順序,答題後置的請求到來時可能已經沒有庫存了,因此根本無法下單,此階段落到數據層真正的寫也就非常有限了
需要注意的是,答題除了做正確性驗證,還需要對提交時間做驗證,比如<1s 人爲操作的可能性就很小,可以進一步防止機器答題的情況。答題目前已經使用的非常普遍了,本質是通過在入口層削減流量,從而讓系統更好地支撐瞬時峯值。

排隊

最爲常見的削峯方案是使用消息隊列,通過把同步的直接調用轉換成異步的間接推送緩衝瞬時流量。除了消息隊列,類似的排隊方案還有很多,例如:

線程池加鎖等待
本地內存蓄洪等待
本地文件序列化寫,再順序讀
排隊方式的弊端也是顯而易見的,主要有兩點:

請求積壓。流量高峯如果長時間持續,達到了隊列的水位上限,隊列同樣會被壓垮,這樣雖然保護了下游系統,但是和請求直接丟棄也沒多大區別
用戶體驗。異步推送的實時性和有序性自然是比不上同步調用的,由此可能出現請求先發後至的情況,影響部分敏感用戶的購物體驗
排隊本質是在業務層將一步操作轉變成兩步操作,從而起到緩衝的作用,但鑑於此種方式的弊端,最終還是要基於業務量級和秒殺場景做出妥協和平衡。

過濾

過濾的核心結構在於分層,通過在不同層次過濾掉無效請求,達到數據讀寫的精準觸發。常見的過濾主要有以下幾層:

1、讀限流:對讀請求做限流保護,將超出系統承載能力的請求過濾掉
2、讀緩存:對讀請求做數據緩存,將重複的請求過濾掉
3、寫限流:對寫請求做限流保護,將超出系統承載能力的請求過濾掉
4、寫校驗:對寫請求做一致性校驗,只保留最終的有效數據

過濾的核心目的是通過減少無效請求的數據IO保障有效請求的IO性能。

系統可以通過入口層的答題、業務層的排隊、數據層的過濾達到流量削峯的目的,本質是在尋求商業訴求與架構性能之間的平衡。另外,新的削峯手段也層出不窮,以業務切入居多,比如零點大促時同步發放優惠券或發起抽獎活動,將一部分流量分散到其他系統,這樣也能起到削峯的作用。

6.3.2 Plan B
當一個系統面臨持續的高峯流量時,其實是很難單靠自身調整來恢復狀態的,日常運維沒有人能夠預估所有情況,意外總是無法避免。尤其在秒殺這一場景下,爲了保證系統的高可用,必須設計一個 Plan B 方案來進行兜底。高可用建設,其實是一個系統工程,貫穿在系統建設的整個生命週期。

具體來說,系統的高可用建設涉及架構階段、編碼階段、測試階段、發佈階段、運行階段,以及故障發生時,逐一進行分析:

架構階段:考慮系統的可擴展性和容錯性,避免出現單點問題。例如多地單元化部署,即使某個IDC甚至地市出現故障,仍不會影響系統運轉
編碼階段:保證代碼的健壯性,例如RPC調用時,設置合理的超時退出機制,防止被其他系統拖垮,同時也要對無法預料的返回錯誤進行默認的處理
測試階段:保證CI的覆蓋度以及Sonar的容錯率,對基礎質量進行二次校驗,並定期產出整體質量的趨勢報告
發佈階段:系統部署最容易暴露錯誤,因此要有前置的checklist模版、中置的上下游周知機制以及後置的回滾機制
運行階段:系統多數時間處於運行態,最重要的是運行時的實時監控,及時發現問題、準確報警並能提供詳細數據,以便排查問題
故障發生:首要目標是及時止損,防止影響面擴大,然後定位原因、解決問題,最後恢復服務
對於日常運維而言,高可用更多是針對運行階段而言的,此階段需要額外進行加強建設,主要有以下幾種手段:

預防:建立常態壓測體系,定期對服務進行單點壓測以及全鏈路壓測,摸排水位
管控:做好線上運行的降級、限流和熔斷保護。需要注意的是,無論是限流、降級還是熔斷,對業務都是有損的,所以在進行操作前,一定要和上下游業務確認好再進行。就拿限流來說,哪些業務可以限、什麼情況下限、限流時間多長、什麼情況下進行恢復,都要和業務方反覆確認
監控:建立性能基線,記錄性能的變化趨勢;建立報警體系,發現問題及時預警
恢復:遇到故障能夠及時止損,並提供快速的數據訂正工具,不一定要好,但一定要有
在系統建設的整個生命週期中,每個環節中都可能犯錯,甚至有些環節犯的錯,後面是無法彌補的或者成本極高的。所以高可用是一個系統工程,必須放到整個生命週期中進行全面考慮。同時,考慮到服務的增長性,高可用更需要長期規劃並進行體系化建設。

6.3.3 高可用設計總結
高可用其實是在說 “穩定性”,穩定性是一個平時不重要,但出了問題就要命的事情,然而它的落地又是一個問題——平時業務發展良好,穩定性建設就會降級給業務讓路。解決這個問題必須在組織上有所保障,比如讓業務負責人背上穩定性績效指標,同時在部門中建立穩定性建設小組,小組成員由每條線的核心力量兼任,績效由穩定性負責人來打分,這樣就可以把體系化的建設任務落實到具體的業務系統中了。

七:E-R關係圖

用於指導如何建立領域模型。從E-R圖上我們能看出,幾個比較重要的領域模型:如活動、活動商品,系統真正編碼落地的時候,緊緊圍繞這些領域模型去建模,做到代碼和領域模型的表達一致的。

八:產品邊界

概要設計的目的是爲了明確產品功能和系統邊界,通過領域驅動的界限上下文圖,能清晰地看出完成當前需求需要參與協作的團隊,以及團隊與團隊之間任務劃分邊界。活動上下文是我們關注的重點,同時也應該看到,我們需要商品團隊、交易團隊的協作。

九:秒殺流程圖:

 

 

 不同的秒殺體量針對的技術選型都不一樣,這個流程可以支撐起百萬的流量,如果是成千萬破億那就得重新設計了。比如數據庫的分庫分表、隊列改成用kafka、redis增加集羣數量等手段。

十:秒殺架構總結

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章