朱曄的網際網路架構實踐心得S2E6:淺談高併發架構設計的16招

powerzhuye發表於2019-05-10

朱曄的網際網路架構實踐心得S2E6:淺談高併發架構設計的16招

概覽

標題中的高併發架構設計是指設計一套比較合適的架構來應對請求、併發量很大的系統,使系統的穩定性、響應時間符合預期並且能在極端的情況下自動調整為相對合理的服務水平。一般而言我們很難用通用的架構設計的手段來解決所有問題,在處理高併發架構的時候也需要根據系統的業務形態有針對性設計架構方案,本文只是列出了大概可以想到一些點,在設計各種方案的時候無非是拿著這些點組合考慮和應用。
有很多高併發架構相關的文章都是在介紹具體的技術點,本文嘗試從根源來總結一些基本的方法,然後再引申出具體的實現方式或例子。下面是本文會介紹的16個方面的大綱:

image_1d9uc55d518fk110qkh8oc8boa9.png-76.2kB

減少請求數量

既然請求量大,那麼第一個方面可以考慮是否可以讓請求量不那麼大,或者說至少進入我們業務系統的量不這麼大。除了下面提到的兩點,我們還可以從業務的角度考慮一下,如果這是一個限時活動,那麼我們的活動受眾群體是否需要是所有使用者,如果不是的話是否就可以通過減少受眾減少併發;如果需要群發推送讓使用者來參與非秒殺類活動是否要考慮錯時安批推送,避免因為推送引起的人為大併發等等,在技術手段接入之前先看看運營和產品手段能否減少不必要的大流量。

合併請求

每一個獨立的網路請求都是開銷,我們可以通過合併動態靜態的請求來減少請求數量。現在的Web前端應用基本都會在構件打包階段對指令碼、CSS進行壓縮合並等預處理。
對於後端動態請求而言,我們更需要在設計階段考慮介面的粒度,並且區分對待實時處理和批處理的架構,資料批處理的工作不太適合通過迴圈呼叫遠端介面的方式實現。

邊緣加速

CDN就是邊緣加速的一個例子,一般而言我們使用CDN不僅僅為了讓使用者訪問資料更快,而且通過在邊緣節點做一定的快取策略可以讓節點幫我們擋住很大部分的流量(特別是靜態資源,除了回源的請求都可以由CDN擋掉)。更進一步說,一些CDN可以做一些定製化的處理,允許業務方提供一些簡單的指令碼在節點做邊緣計算,比如在秒殺場景下根據一定的策略直接在CDN節點進行計算,放行0.1%的使用者流量進入我們的後端系統。

提升處理效能

第二個方面優化的方向是提高單個請求的處理效能,也就是減少請求的處理時間,優化請求處理排程和佔用的資源。這裡列出的幾個點都是我覺得應該去重點看重點突破的點,你可能會說我們不是應該去優化下程式內部的演算法和資料結構嗎?的確應該是,但是對於大部分業務程式來說,效能問題往往不是優化那些細枝末節的東西可以解決的(比如對於Java來說,在編譯時編譯器,在生成機器碼時JVM都會去做一些優化,程式碼層面的一些優化往往沒那麼重要,程式碼層面我們只需要關注可讀性),而是需要重點關注下面提到的幾個方面。

空間換時間

這裡可以舉一些空間換時間的策略:

  1. 快取。一般有兩種做法,一種是在程式啟動的時候從外部資料來源初始化大量的不怎麼變的資料到記憶體中,在記憶體中形成面向搜尋友好的資料結構(比如雜湊表),提供快速的資料訪問,之後所有的請求都無需請求資料來源,採用定時拉取或監聽變動訊息的方式同步變動。一種是利用分散式快取做計算結果的快取,具有比較短的過期時間,可以擋掉大量重複請求,對於搜尋條件組合較多的請求命中率差。當然,快取除了使用空間換時間之外,一般還會利用儲存介質的效能差異來提升效能,所以我們看到通過記憶體快取資料比較常見。
  2. 緩衝。和快取相近但又截然不同的概念是緩衝。IO操作一般都會使用緩衝區,在我們實現業務的時候也可以利用這種思想,對非時間敏感的呼叫進行適當蓄水,甚至合併,一次性提交到後端服務,比如玩一個抓紅包的遊戲,使用者在螢幕上點點點來抓紅包,是否真的有必要每次都向資料庫更新紅包餘額呢?還是可以在服務端緩衝一下,10次更新一次餘額甚至整個遊戲只提交一次?還比如,我們需要對記憶體中的一些資料做處理,處理的時間會比較久,在處理的時候顯然不能持續服務業務了,為了一致性考慮需要做悲觀鎖處理,這個時候我們就可以考慮開闢一塊所謂的緩衝區,專門用於資料處理,處理好之後把指標指向新的緩衝區,再回收使用老的區域做持續處理,就像JVM中的From和To區域來回倒騰,這也算一種緩衝使用。
  3. 面向資料讀取優化。比如微博的實現在發微博的時候找出大V下一定數量的活躍的線上粉絲,比如5000個,直接把微博寫入他們的關注微博列表中去(推資料過去),這樣在那些粉絲重新整理自己微博首頁的時候就能更快(不用去關聯拉資料了)。
    又比如許多時候我們會做所謂的固化檢視的工作,在寫入資料的時候就直接寫為我們之後要讀取的複雜資料結構(比如資料需要Join N個表才能獲得的,在寫入的時候就直接組成這樣的資料寫到資料表)。或者可以說我們做雜湊結構,做B樹索引,做倒排索引都是這樣的思路,使用一些有利於我們之後讀取、查詢和搜尋的資料結構來加速資料的讀取(雖然寫入的時候耗時多一點,並且需要佔用額外的空間)。
  4. 資料預讀取。說白了就是預測到將來使用者可能會訪問的請求,進行預載入或是預處理,然後之後真正請求到來的時候這個訪問就會特別快。

處理非同步化

我們知道高併發的請求如果來源是使用者的點選,那麼這個量不太可控,而且不均衡,對於來自使用者的請求,如果是讀取請求往往沒太多好辦法去非同步處理,畢竟你需要同步返回使用者資訊,對於操作類的寫入請求可以儘量非同步化處理,僅僅把最關鍵的環節作為同步處理,那麼直面使用者的同步請求的執行時間就會大大減少。這裡可以舉一些非同步處理的例子:

  1. 使用執行緒池來進行非同步處理一些非關鍵的任務。這個和之前說的任務並行化有點區別,這裡說的使用執行緒池進行非同步處理是指Fire-and-forget型別的處理,不需要等待處理完成的結果並且返回給前端。
  2. 使用MQ進行非同步處理,比如下單的主流程就是落地和發MQ通知其它模組,落地後後續出庫、物流的流轉全部是其它模組在收到MQ訊息後非同步處理的。
  3. 極端一點的例子,對於很多廣告系統需要進行計費處理,對於一些增長使用者行為資料分析平臺需要接收客戶端上傳的各種事件進行分析,如何可以抗住100萬TPS的併發進行處理?最簡單的方式就是直接搞10臺Nginx負載均衡,Nginx只是記錄AccessLog返回200(單機抗住10萬TPS一點不是問題),後續由定時任務拉取AccessLog進行資料分析。

任務並行化

指的是讓任務中的子任務並行執行,這樣會比一個一個序列執行子任務來的快。比如可以把多個子任務提交到執行緒池執行,然後等待所有任務都完成後進行結果彙總,這樣總的耗費時間就是最慢的那個子任務的執行時間。可以使用Java8的CompletableFuture進行任務編排處理。這種使用任務並行化來提升處理效能的方式我個人不太常用,如果任務執行時間不是那麼長的話,我還是寧願串性執行,比較容易少出錯,畢竟這些任務都是有狀態的需要等待結果的,這和之前說的非同步不是一回事。

合適的儲存

這裡是指選用合適的儲存系統,在《朱曄的網際網路架構實踐心得S1E3:相輔相成的儲存五件套》一文中我詳細介紹了了發揮多種儲存系統優勢,採用同步落地Sharding的關係型資料庫,非同步落地其它NOSQL的架構。這種架構的儲存方式能夠很好應對非常巨大的併發量,原因在於:

  • 每一種資料庫系統,特別是NOSQL都有自己的特性,我們可以充分利用這些特性來打造適合業務,適合高併發讀寫比的服務。
  • 我們可以結合之前非同步化的思想把最重要的關係型資料庫的落庫走同步處理,其它走非同步處理,這樣既可以利用多種資料庫的特性又可以讓資料寫入不影響主流程。

當然,選用了合適的儲存還不夠,每一種儲存系統也都需要精心去調優引數以及使用最佳實踐去訪問和使用儲存(比如關係型資料庫索引如何建立,如何優化查詢)。對於大部分業務服務來說無非是IO操作慢,大部分是網路IO慢,網路IO無非是外部儲存服務或外部服務,所以這裡提到的儲存的優化是非常重要的一環,還有一半就是外部服務的優化,但是外部服務的優化往往需要靠其它團隊,不完全是自己能掌控的。

更快的網路

這裡提到更快的網路意思是純網路層面的鏈路,我們是否理清楚了到底是怎麼走的,比如:

  • 呼叫其它團隊內部服務域名公網解析還是內網解析?訪問鏈路走的是公網還是內網?
  • 我們是如何呼叫其它服務的?詳見《朱曄的網際網路架構實踐心得S2E4:小議微服務的各種玩法》
  • 經過多少防火牆、反向代理?
  • 走HTTPS還是HTTP?
  • 如果是走公網走的是機房什麼出口?
  • 是長連線還是短連結(特別是HTTP請求)?

歸根到底就是我們最好能瞭解這些外部服務在網路層面花費的情況是否達到預期,比如一個外部服務呼叫我們看到耗時1秒的時間,拼命追著下游去優化服務,但是下游說為服務端執行時間只有30ms呀?結果一查發現整個呼叫跨了4個機房走了2次公網2次專線,然後還經過了4個閘道器轉發,這些東西耗時970ms,這就很尷尬了。我覺得一個能接受的情況是內網呼叫網路損耗在5ms以內,公網呼叫在50ms以內(跨國除外)。
對於大併發的系統來說任何一個環節增加很少的延時可能都會導致最前端超時或佇列溢位,之前也遇到過兩個服務之間的呼叫因為專線維護從專線切到走公網+VPN的形式程式碼層面毫無變動,只是網路鏈路的改動因為大家都沒有重視,鏈路切換後的白天在併發上去之後全線崩潰的問題。
當然,對於現在的微服務架構來說需要有很好的分散式追蹤基礎服務我們才好理清服務呼叫和呼叫的損耗。

增加處理能力

優化處理效能往往沒有這麼快,即使能優化往往也無法實現幾十倍幾百倍的效能提高,對於高併發程式來說我們肯定需要有一定的處理資源來應對,最悲慘的事情莫過於有一堆伺服器但是用不起來,最理想的架構是每一個元件都可以橫向擴充套件,並且隨著伺服器資源的增多能相應提升總體處理能力,下面我們來看看增加處理能力的一些方法。

模組拆分

拆分是最好的手段,對於業務應用可以這麼來拆:

  • 直接拆成子站,除了一些公共服務(比如使用者、商戶),其它全部獨立
  • 橫向,按模組拆分成微服務獨立部署
  • 縱向(或者說分層,更多是物理分層),按功能拆分成專門處理資料的服務、專門落地的服務、專門彙總資料的服務等等

對於資料庫來說也是一樣:

  • 拆分資料庫,拆分資料庫到不同的例項(伺服器)
  • 縱向,拆分成幾個1:1的小表
  • 橫向,把同一個表的資料拆分到不同的資料庫

當業務可以拆分的時候其實應對大併發沒這麼難,最困難的是拆無可拆,就是大併發針對的是同一個表同一行的資料的情況,而且讀寫的量都很大,而且要求強一致性的情況,對於這種情況底層資料來源很可能只能用關係型資料庫甚至自己特殊實現的資料結構實現,無法進行拆分,請參閱下面的縱向擴充套件,哈哈。

負載均衡

對於無狀態的服務來說,我們可以通過負載均衡來實現服務的負載分發,需要關注的是幾個點:

  • 負載均衡的策略
  • Backend健康檢測
  • 服務失效後從負載均衡摘除,恢復後的上線
  • 釋出系統和負載均衡的聯動
  • 負載均衡特別是7層覆蓋,對於請求頭做的改動會是怎樣的

對於超大規模的叢集,比如有上萬臺服務需要負載,那麼可能需要10組Nginx來做負載均衡,這10組Nginx本身也需要進行負載均衡,那麼可以在最上層使用硬體F5或Haproxy在4層再做一層負載,也就是類似主備Haproxy->Nginx叢集->tomcat叢集類似的架構。
有一點不能不提,有的時候整個系統雖然已經是一個大叢集但是由於不合理的全域性分散式鎖還是序列在處理任務,這個時候橫向擴充套件不能解決問題。

分割槽處理

又叫做Sharding、Partition,指的是把資料、任務進行分割槽,分發到不同的節點同時處理,提高並行度,這點和拆分有一些相近,但是更多指的是想同的資料和任務需要批量迴圈處理的時候去做下分割槽,然後並行執行,應用這個思想的幾個例子:

  • 資料表的分表分庫,然後由類似Proxy的中介軟體進行資料路由和彙總處理
  • 比如Java 8 parallelStream的思想把資料分成多份在不同的執行緒同時處理
  • 比如ConcurrentHashMap鎖分段的思想,把全域性的鎖改為分段鎖減少衝突

分割槽不但能提高並行度使用更多的資源來處理資料而且還可以減少衝突,但是分割槽處理後最終還是需要Reduce的,這個過程的處理方式以及處理的損耗需要進行考慮,而且每一個分割槽的處理速度不一定均衡,所以不能完全假設分成N份系統的執行速度就提高了N倍。

縱向擴充套件

縱向擴充套件說白了就是升級單臺伺服器的配置或使用更強力的小型機來替換普通伺服器。
有的時候縱向擴充套件也是無奈之舉,就像之前所說的對於一個很小的單表,雖然只有寥寥幾個欄位已無法再瘦身,但是讀寫量超大,強一致,或許也只能使用更強大硬體通過強大的IIOPS撐起這樣的資料庫。
我們之前提到的增加處理能力往往是指使用更多的伺服器來支撐,更多的伺服器意味著通訊需要跨網路,網路有損耗也有不穩定因素存在,分散式服務的狀態需要同步,而且伺服器越多就越可能出現失效的服務(假設1萬臺伺服器,每天出問題的伺服器在千分之一那就是10臺了)。分散式,橫向擴充套件說白了是有很大代價的,在當今硬體沒有這麼昂貴的情況下往往也不失為一種方案:

  • 為快取伺服器提供更大的記憶體
  • 為隨機IO要求高的Mysql、ES等伺服器提供SSD磁碟
  • 為不易做拆分的核心負載均衡處理器提供高配伺服器
  • 為極端高併發的資料庫使用小型機

穩定性和彈性

對於高併發程式來說就像是一個緊繃的橡皮筋,或者一個充滿氣的氣球,任何系統內部外部風吹草動造成的小效能問題都可能造成整個系統崩潰。在穩定性和彈性方面同樣需要做很多工作,否則依賴系統的抖動可能一下子把自己搞死。

壓測

個人認為關鍵鏈路上做的任何變更,包括程式碼修改,網路變更,按道理都需要在準生產或灰度進行壓測後才能正式上線。之前也遇到過幾次這樣的案例:

  • 因為系統多執行了一條SQL導致方法執行時間多了10ms,導致MQ消費速度變慢形成佇列堆積,佇列越積越多,最後MQ扛不住崩潰了
  • 因為內部閘道器開啟了驗籤增加了幾毫秒的處理時間,所有服務的呼叫都經過閘道器,累計的呼叫時間累計增加了幾百毫秒導致業務系統的處理執行緒一下子多起來然後OOM了

在非生產壓測往往結果和生產差異很大,在生產壓測需要考慮對業務的影響以及測試資料的清理,而且壓測需要考慮依賴服務是否可以參與一起壓測,要真正在生產實現全鏈路壓測的落地需要整個公司技術資源的協同,還是非常考驗管理執行力,這往往不是技術問題。

隔離

隔離說的是在設計的時候需要考慮不同業務、不同SLA的服務在共享同一套資源的時候是不是會因為產生效能問題導致相互影響,如果會影響,並且我們不能接受這樣的影響的話就需要考慮各種層次的隔離,比如:

  • 直接在伺服器級別隔離,比如我們需要考慮為VIP建設單獨的伺服器叢集,甚至是IDC網路接入
  • 在服務級別隔離,為重要的業務線單獨分配並且路由到單獨的虛擬機器或POD,為大檔案上傳的服務進行服務拆分部署到具有更高IO和網路頻寬的伺服器上
  • 在程式內部進行資源隔離,比如在使用Java 8 ParallelStream的時候考慮採用單獨的執行緒池來處理任務,比如在使用Netty處理較慢的業務操作的時候配置單獨的業務執行緒池進行處理,和IO處理的執行緒池進行隔離

限流

在做壓力測試的時候我們會發現,隨著壓力的上升系統的吞吐慢慢變大而且這個時候響應時間可以基本保持可控(1秒內),當壓力突破一個邊界後,響應時間一下子會不可控,隨之系統的吞吐就會下降,最後會徹底崩潰。任何系統對於壓力的負荷是有邊界的,超過這個邊界之後系統的SLA肯定無法滿足標準,導致大家都無法好好用這個服務。因為系統的擴充套件往往不是秒級可以做到的,所以這個時候最快的手段就是限流,只有限流了才能保護現在的系統不至於突破這個邊界徹底崩潰。對於業務量超大的系統搞活動,對關鍵服務甚至入口層面做限流是必然的,別無它法,淘寶雙11凌晨0點那一刻也能看到一定比例的下單被限流了。

常見的限流演算法有這麼幾種:

  • 計數器演算法。最簡單的演算法,資源使用加一,釋放減一,達到一定的計數拒絕服務。
  • 令牌桶演算法。按照固定速率往桶裡加令牌,桶裡最多存放n個令牌,填滿丟棄。處理的時候需要獲取令牌,獲取不到則拒絕請求。
  • 漏桶演算法。一個固定容量的漏洞,按照一定的速度流出水滴(任務)。可以以任意速度流入水滴(任務),滿了則溢位丟棄。

令牌桶演算法限制的是平均流入速度,允許一定程度的突發請求,漏桶演算法限制的是常量的流出速率用於平滑流入的速度。實現上,常用的一些開源類庫都會有相關的實現,比如google的Guava提供的RateLimiter就是令牌桶演算法。之後我們會介紹熔斷,熔斷針對的是客戶端保護,限流針對的是服務端保護。

降級

降級往往不是一個純技術手段,需要結合業務一起來考慮,比如:

  • 對於送外賣,計算商家和送餐地點距離的時候,最好的方式是使用騎行距離,騎行距離需要呼叫外部地圖API來得到,在外部地圖API訪問超時的時候需要考慮降級方案,把騎行距離改為根據經緯度算出來的直線距離,雖然不精確,導致配送時間的估算不精確,但是也至少讓服務基本可用
  • 對於電商需要做的超大訪問量的促銷活動頁面在動態請求因為過載無法響應的時候,是否可以考慮降級為客戶端這邊之前寫死的一些靜態的活動商品列表,雖然這個列表無法反映當前活動實際的(商品售賣)情況,但是至少這個活動頁是可看的
  • 之前我們遇到過攜程在出現服務當機的時候直接降級為讓使用者去訪問藝龍,這種屬於整站降級,某些業務場景下甚至我們可以嘗試線上上業務整站當機的時候可以降級為人工客服處理部分業務

說白了降級往往是一個兜底方案,需要在做設計的時候結合業務場景考慮哪些環節可能會出問題,出了問題如何降級,是自動降級還是手動降級,降級後需要啟用怎麼樣的應急處理流程等等。

熔斷

熔斷可以說是也是自動降級的一種,是對客戶端的保護。現在微服務的架構,一個客戶端可能會依賴幾十個其它的服務,有任何一個位於同步呼叫的外部服務出現超時,即使客戶端的ReadTimeOut設定的時間不長也對客戶端是很大的壓力和負擔(這麼多執行緒乾等著,當然了全非同步的服務不需要考慮這個問題,網際網路來大部分請求最終還是同步的HTTP,Web層總是需要等待的,很難像遊戲伺服器做到長連線的全非同步處理)。
所以在外部服務遇到問題的時候要自動進行熔斷,在外部服務恢復後嘗試半恢復,最後完全恢復訪問,一般來說有幾種熔斷策略:

  • 根據請求失敗率熔斷,比如在一定時間內有一定百分比的請求是失敗的,那麼就開啟熔斷
  • 根據響應時間熔斷,比如一定時間內的請求平均響應時間超過N秒則開啟熔斷

一般而言需要在程式碼裡去寫熔斷後的Callback,由回撥函式提供熔斷後返回的臨時資料或者直接出異常不允許請求繼續進行下去。至於選擇臨時資料還是出異常還是取決於實際的業務,對於某些情況熔斷後返回一個不合理的臨時資料往往是不可以接受的。

總結

總結一下,對於高併發應用如何去考慮效能優化,說白了就這麼幾個思路:

  • 要麼是儘可能通過讓併發別這麼大,有的時候真沒必要一擁而上造成人為的大併發
  • 要麼是儘可能優化程式碼、儲存、網路,越簡單,操作需要的CPU、IO、記憶體、網路資源越少在一定的伺服器資源下就越可能應對更多的併發
  • 要麼就是通過擴充套件資源,擴充套件伺服器來提高處理能力
  • 最後一招就是在因為併發過大不穩定的時候系統需要啟用一定的應急手段開啟自保,別人工系統被流量壓垮徹底掛了

閱讀其它文章

如果你對我的文章感興趣,可以進入專欄檢視本系列之前的其它文章:

  • 朱曄的網際網路架構實踐心得S2E5:淺談四種API設計風格(RPC、REST、GraphQL、服務端驅動)
  • 朱曄的網際網路架構實踐心得S2E4:小議微服務的各種玩法(古典、SOA、傳統、K8S、ServiceMesh)
  • 朱曄的網際網路架構實踐心得S2E3:品味Kubernetes的設計理念
  • 朱曄的網際網路架構實踐心得S2E2:寫業務程式碼最容易掉的10種坑
  • 朱曄的網際網路架構實踐心得S2E1:業務程式碼究竟難不難寫?
  • 朱曄的網際網路架構實踐心得S1E10:資料的權衡和折騰
  • 朱曄的網際網路架構實踐心得S1E9:架構評審一百問和設計文件五要素
  • 朱曄的網際網路架構實踐心得S1E8:三十種架構設計模式(下)
  • 朱曄的網際網路架構實踐心得S1E7:三十種架構設計模式(上)
  • 朱曄的網際網路架構實踐心得S1E6:給飛機換引擎和安全意識十原則
  • 朱曄的網際網路架構實踐心得S1E5:不斷耕耘的基礎中介軟體
  • 朱曄的網際網路架構實踐心得S1E4:簡單好用的監控六兄弟
  • 朱曄的網際網路架構實踐心得S1E3:相輔相成的儲存五件套
  • 朱曄的網際網路架構實踐心得S1E2:屢試不爽的架構三馬車
  • 朱曄的網際網路架構實踐心得S1E1:Pilot

相關文章