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

減少請求數量
既然請求量大,那么第一個方面可以考慮是否可以讓請求量不那么大,或者說至少進入我們業務系統的量不這么大。除了下面提到的兩點,我們還可以從業務的角度考慮一下,如果這是一個限時活動,那么我們的活動受眾群體是否需要是所有用戶,如果不是的話是否就可以通過減少受眾減少並發;如果需要群發推送讓用戶來參與非秒殺類活動是否要考慮錯時安批推送,避免因為推送引起的人為大並發等等,在技術手段接入之前先看看運營和產品手段能否減少不必要的大流量。
合並請求
每一個獨立的網絡請求都是開銷,我們可以通過合並動態靜態的請求來減少請求數量。現在的Web前端應用基本都會在構件打包階段對腳本、CSS進行壓縮合並等預處理。
對於后端動態請求而言,我們更需要在設計階段考慮接口的粒度,並且區分對待實時處理和批處理的架構,數據批處理的工作不太適合通過循環調用遠程接口的方式實現。
邊緣加速
CDN就是邊緣加速的一個例子,一般而言我們使用CDN不僅僅為了讓用戶訪問數據更快,而且通過在邊緣節點做一定的緩存策略可以讓節點幫我們擋住很大部分的流量(特別是靜態資源,除了回源的請求都可以由CDN擋掉)。更進一步說,一些CDN可以做一些定制化的處理,允許業務方提供一些簡單的腳本在節點做邊緣計算,比如在秒殺場景下根據一定的策略直接在CDN節點進行計算,放行0.1%的用戶流量進入我們的后端系統。
提升處理性能
第二個方面優化的方向是提高單個請求的處理性能,也就是減少請求的處理時間,優化請求處理調度和占用的資源。這里列出的幾個點都是我覺得應該去重點看重點突破的點,你可能會說我們不是應該去優化下程序內部的算法和數據結構嗎?的確應該是,但是對於大部分業務程序來說,性能問題往往不是優化那些細枝末節的東西可以解決的(比如對於Java來說,在編譯時編譯器,在生成機器碼時JVM都會去做一些優化,代碼層面的一些優化往往沒那么重要,代碼層面我們只需要關注可讀性),而是需要重點關注下面提到的幾個方面。
空間換時間
這里可以舉一些空間換時間的策略:
- 緩存。一般有兩種做法,一種是在程序啟動的時候從外部數據源初始化大量的不怎么變的數據到內存中,在內存中形成面向搜索友好的數據結構(比如哈希表),提供快速的數據訪問,之后所有的請求都無需請求數據源,采用定時拉取或監聽變動消息的方式同步變動。一種是利用分布式緩存做計算結果的緩存,具有比較短的過期時間,可以擋掉大量重復請求,對於搜索條件組合較多的請求命中率差。當然,緩存除了使用空間換時間之外,一般還會利用存儲介質的性能差異來提升性能,所以我們看到通過內存緩存數據比較常見。
- 緩沖。和緩存相近但又截然不同的概念是緩沖。IO操作一般都會使用緩沖區,在我們實現業務的時候也可以利用這種思想,對非時間敏感的調用進行適當蓄水,甚至合並,一次性提交到后端服務,比如玩一個抓紅包的游戲,用戶在屏幕上點點點來抓紅包,是否真的有必要每次都向數據庫更新紅包余額呢?還是可以在服務端緩沖一下,10次更新一次余額甚至整個游戲只提交一次?還比如,我們需要對內存中的一些數據做處理,處理的時間會比較久,在處理的時候顯然不能持續服務業務了,為了一致性考慮需要做悲觀鎖處理,這個時候我們就可以考慮開辟一塊所謂的緩沖區,專門用於數據處理,處理好之后把指針指向新的緩沖區,再回收使用老的區域做持續處理,就像JVM中的From和To區域來回倒騰,這也算一種緩沖使用。
- 面向數據讀取優化。比如微博的實現在發微博的時候找出大V下一定數量的活躍的在線粉絲,比如5000個,直接把微博寫入他們的關注微博列表中去(推數據過去),這樣在那些粉絲刷新自己微博首頁的時候就能更快(不用去關聯拉數據了)。
又比如許多時候我們會做所謂的固化視圖的工作,在寫入數據的時候就直接寫為我們之后要讀取的復雜數據結構(比如數據需要Join N個表才能獲得的,在寫入的時候就直接組成這樣的數據寫到數據表)。或者可以說我們做哈希結構,做B樹索引,做倒排索引都是這樣的思路,使用一些有利於我們之后讀取、查詢和搜索的數據結構來加速數據的讀取(雖然寫入的時候耗時多一點,並且需要占用額外的空間)。 - 數據預讀取。說白了就是預測到將來用戶可能會訪問的請求,進行預加載或是預處理,然后之后真正請求到來的時候這個訪問就會特別快。
處理異步化
我們知道高並發的請求如果來源是用戶的點擊,那么這個量不太可控,而且不均衡,對於來自用戶的請求,如果是讀取請求往往沒太多好辦法去異步處理,畢竟你需要同步返回用戶信息,對於操作類的寫入請求可以盡量異步化處理,僅僅把最關鍵的環節作為同步處理,那么直面用戶的同步請求的執行時間就會大大減少。這里可以舉一些異步處理的例子:
- 使用線程池來進行異步處理一些非關鍵的任務。這個和之前說的任務並行化有點區別,這里說的使用線程池進行異步處理是指Fire-and-forget類型的處理,不需要等待處理完成的結果並且返回給前端。
- 使用MQ進行異步處理,比如下單的主流程就是落地和發MQ通知其它模塊,落地后后續出庫、物流的流轉全部是其它模塊在收到MQ消息后異步處理的。
- 極端一點的例子,對於很多廣告系統需要進行計費處理,對於一些增長用戶行為數據分析平台需要接收客戶端上傳的各種事件進行分析,如何可以抗住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
