如何設計一個高并發系統?
前言
最近有位粉絲問了我一個問題:如何設計一個高并發系統?
這是一個非常高頻的面試題,面試官可以從多個角度,考查技術的廣度和深度。
今天這篇文章跟大家一起聊聊高并發系統設計一些關鍵點,希望對你會有所幫助。
圖片
1 頁面靜態化
對于高并發系統的頁面功能,我們必須要做靜態化設計。
如果并發訪問系統的用戶非常多,每次用戶訪問頁面的時候,都通過服務器動態渲染,會導致服務端承受過大的壓力,而導致頁面無法正常加載的情況發生。
我們可以使用Freemarker或Velocity模板引擎,實現頁面靜態化功能。
以商城官網首頁為例,我們可以在Job中,每隔一段時間,查詢出所有需要在首頁展示的數據,匯總到一起,使用模板引擎生成到html文件當中。
然后將該html文件,通過shell腳本,自動同步到前端頁面相關的服務器上。
2 CSDN加速
雖說頁面靜態化可以提升網站網頁的訪問速度,但還不夠,因為用戶分布在全國各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很遠,他們訪問網站的網速各不相同。
如何才能讓用戶最快訪問到活動頁面呢?
這就需要使用CDN,它的全稱是Content Delivery Network,即內容分發網絡。
圖片
使用戶就近獲取所需內容,降低網絡擁塞,提高用戶訪問響應速度和命中率。
CDN加速的基本原理是:將網站的靜態內容(如圖片、CSS、JavaScript文件等)復制并存儲到分布在全球各地的服務器節點上。
當用戶請求訪問網站時,CDN系統會根據用戶的地理位置,自動將內容分發給離用戶最近的服務器,從而實現快速訪問。
國內常見的CDN提供商有阿里云CDN、騰訊云CDN、百度云加速等,它們提供了全球分布的節點服務器,為全球范圍內的網站加速服務。
3 緩存
在高并發的系統中,緩存可以說是必不可少的技術之一。
目前緩存有兩種:
- 基于應用服務器的內存緩存,也就是我們說的二級緩存。
- 使用緩存中間件,比如:Redis、Memcached等,這種是分布式緩存。
這兩種緩存各有優缺點。
二級緩存的性能更好,但因為是基于應用服務器內存的緩存,如果系統部署到了多個服務器節點,可能會存在數據不一致的情況。
而Redis或Memcached雖說性能上比不上二級緩存,但它們是分布式緩存,避免多個服務器節點數據不一致的問題。
緩存的用法一般是這樣的:
圖片
使用緩存之后,可以減輕訪問數據庫的壓力,顯著的提升系統的性能。
有些業務場景,甚至會分布式緩存和二級緩存一起使用。
比如獲取商品分類數據,流程如下:
圖片
不過引入緩存,雖說給我們的系統性能帶來了提升,但同時也給我們帶來了一些新的問題,比如:《數據庫和緩存雙向數據庫一致性問題》、《緩存穿透、擊穿和雪崩問題》等。
我們在使用緩存時,一定要結合實際業務場景,切記不要為了緩存而緩存。
4 異步
有時候,我們在高并發系統當中,某些接口的業務邏輯,沒必要都同步執行。
比如有個用戶請求接口中,需要做業務操作,發站內通知,和記錄操作日志。為了實現起來比較方便,通常我們會將這些邏輯放在接口中同步執行,勢必會對接口性能造成一定的影響。
接口內部流程圖如下:
圖片
這個接口表面上看起來沒有問題,但如果你仔細梳理一下業務邏輯,會發現只有業務操作才是核心邏輯,其他的功能都是非核心邏輯。
在這里有個原則就是:核心邏輯可以同步執行,同步寫庫。非核心邏輯,可以異步執行,異步寫庫。
上面這個例子中,發站內通知和用戶操作日志功能,對實時性要求不高,即使晚點寫庫,用戶無非是晚點收到站內通知,或者運營晚點看到用戶操作日志,對業務影響不大,所以完全可以異步處理。
通常異步主要有兩種:多線程 和 mq。
4.1 線程池
使用線程池改造之后,接口邏輯如下:
圖片
發站內通知和用戶操作日志功能,被提交到了兩個單獨的線程池中。
這樣接口中重點關注的是業務操作,把其他的邏輯交給線程異步執行,這樣改造之后,讓接口性能瞬間提升了。
但使用線程池有個小問題就是:如果服務器重啟了,或者是需要被執行的功能出現異常了,無法重試,會丟數據。
那么這個問題該怎么辦呢?
4.2 mq
使用mq改造之后,接口邏輯如下:
圖片
對于發站內通知和用戶操作日志功能,在接口中并沒真正實現,它只發送了mq消息到mq服務器。然后由mq消費者消費消息時,才真正的執行這兩個功能。
這樣改造之后,接口性能同樣提升了,因為發送mq消息速度是很快的,我們只需關注業務操作的代碼即可。
5 多線程處理
在高并發系統當中,用戶的請求量很大。
假如我們現在用mq處理業務邏輯。
一下子有大量的用戶請求,產生了大量的mq消息,保存到了mq服務器。
而mq的消費者,消費速度很慢。
可能會導致大量的消息積壓問題。
從而嚴重影響數據的實時性。
我們需要對消息的消費者做優化。
最快的方式是使用多線程消費消息,比如:改成線程池消費消息。
當然核心線程數、最大線程數、隊列大小 和 線程回收時間,一定要做成配置的,后面可以根據實際情況動態調整。
這樣改造之后,我們可以快速解決消息積壓問題。
除此之外,在很多數據導入場景,用多線程導入數據,可以提升效率。
溫馨提醒一下:使用多線程消費消息,可能會出現消息的順序問題。如果你的業務場景中,需要保證消息的順序,則要用其他的方式解決問題。感興趣的小伙伴,可以找我私聊。
6 分庫分表
有時候,高并發系統的吞吐量受限的不是別的,而是數據庫。
當系統發展到一定的階段,用戶并發量大,會有大量的數據庫請求,需要占用大量的數據庫連接,同時會帶來磁盤IO的性能瓶頸問題。
此外,隨著用戶數量越來越多,產生的數據也越來越多,一張表有可能存不下。由于數據量太大,sql語句查詢數據時,即使走了索引也會非常耗時。
這時該怎么辦呢?
答:需要做分庫分表。
如下圖所示:
圖片
圖中將用戶庫拆分成了三個庫,每個庫都包含了四張用戶表。
如果有用戶請求過來的時候,先根據用戶id路由到其中一個用戶庫,然后再定位到某張表。
路由的算法挺多的:
- 根據id取模,比如:id=7,有4張表,則7%4=3,模為3,路由到用戶表3。
- 給id指定一個區間范圍,比如:id的值是0-10萬,則數據存在用戶表0,id的值是10-20萬,則數據存在用戶表1。
- 一致性hash算法
分庫分表主要有兩個方向:垂直和水平。
說實話垂直方向(即業務方向)更簡單。
在水平方向(即數據方向)上,分庫和分表的作用,其實是有區別的,不能混為一談。
- 分庫:是為了解決數據庫連接資源不足問題,和磁盤IO的性能瓶頸問題。
- 分表:是為了解決單表數據量太大,sql語句查詢數據時,即使走了索引也非常耗時問題。此外還可以解決消耗cpu資源問題。
- 分庫分表:可以解決 數據庫連接資源不足、磁盤IO的性能瓶頸、檢索數據耗時 和 消耗cpu資源等問題。
如果在有些業務場景中,用戶并發量很大,但是需要保存的數據量很少,這時可以只分庫,不分表。
如果在有些業務場景中,用戶并發量不大,但是需要保存的數量很多,這時可以只分表,不分庫。
如果在有些業務場景中,用戶并發量大,并且需要保存的數量也很多時,可以分庫分表。
關于分庫分表更詳細的內容,可以看看我另一篇文章,里面講的更深入《阿里二面:為什么分庫分表?》
7 池化技術
其實不光是高并發系統,為了性能考慮,有些低并發的系統,也在使用池化技術,比如:數據庫連接池、線程池等。
池化技術是多例設計模式的一個體現。
我們都知道創建和銷毀數據庫連接是非常耗時耗資源的操作。
如果每次用戶請求,都需要創建一個新的數據庫連接,勢必會影響程序的性能。
為了提升性能,我們可以創建一批數據庫連接,保存到內存中的某個集合中,緩存起來。
這樣的話,如果下次有需要用數據庫連接的時候,就能直接從集合中獲取,不用再額外創建數據庫連接,這樣處理將會給我們提升系統性能。
圖片
當然用完之后,需要及時歸還。
目前常用的數據庫連接池有:Druid、C3P0、hikari和DBCP等。
8 讀寫分離
不知道你有沒有聽說過二八原則,在一個系統當中可能有80%是讀數據請求,另外20%是寫數據請求。
不過這個比例也不是絕對的。
我想告訴大家的是,一般的系統讀數據請求會遠遠大于寫數據請求。
如果讀數據請求和寫數據請求,都訪問同一個數據庫,可能會相互搶占數據庫連接,相互影響。
我們都知道,一個數據庫的數據庫連接數量是有限,是非常寶貴的資源,不能因為讀數據請求,影響到寫數據請求吧?
這就需要對數據庫做讀寫分離了。
于是,就出現了主從讀寫分離架構:
圖片
考慮剛開始用戶量還沒那么大,選擇的是一主一從的架構,也就是常說的一個master,一個slave。
所有的寫數據請求,都指向主庫。一旦主庫寫完數據之后,立馬異步同步給從庫。這樣所有的讀數據請求,就能及時從從庫中獲取到數據了(除非網絡有延遲)。
但這里有個問題就是:如果用戶量確實有些大,如果master掛了,升級slave為master,將所有讀寫請求都指向新master。
但此時,如果這個新master根本扛不住所有的讀寫請求,該怎么辦?
這就需要一主多從的架構了:
圖片
上圖中我列的是一主兩從,如果master掛了,可以選擇從庫1或從庫2中的一個,升級為新master。假如我們在這里升級從庫1為新master,則原來的從庫2就變成了新master的的slave了。
調整之后的架構圖如下:
圖片
這樣就能解決上面的問題了。
除此之外,如果查詢請求量再增大,我們還可以將架構升級為一主三從、一主四從...一主N從等。
9 索引
在高并發的系統當中,用戶經常需要查詢數據,對數據庫增加索引,是必不可少的一個環節。
尤其是表中數據非常多時,加了索引,跟沒加索引,執行同一條sql語句,查詢相同的數據,耗時可能會相差N個數量級。
雖說索引能夠提升SQL語句的查詢速度,但索引也不是越多越好。
在insert數據時,需要給索引分配額外的資源,對insert的性能有一定的損耗。
我們要根據實際業務場景來決定創建哪些索引,索引少了,影響查詢速度,索引多了,影響寫入速度。
很多時候,我們需要經常對索引做優化。
- 可以將多個單個索引,改成一個聯合索引。
- 刪除不要索引。
- 使用explain關鍵字,查詢SQL語句的執行計劃,看看哪些走了索引,哪些沒有走索引。
- 要注意索引失效的一些場景。
- 必要時可以使用force index來強制查詢sql走某個索引。
如果你想進一步了解explain的詳細用法,可以看看我的另一篇文章《explain | 索引優化的這把絕世好劍,你真的會用嗎?》。
如果你想進一步了解哪些情況下索引會失效,可以看看我的另一篇文章《聊聊索引失效的10種場景,太坑了》。
10 批處理
有時候,我們需要從指定的用戶集合中,查詢出有哪些是在數據庫中已經存在的。
實現代碼可以這樣寫:
public List<User> queryUser(List<User> searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List<User> result = Lists.newArrayList();
searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
return result;
}
這里如果有50個用戶,則需要循環50次,去查詢數據庫。我們都知道,每查詢一次數據庫,就是一次遠程調用。
如果查詢50次數據庫,就有50次遠程調用,這是非常耗時的操作。
那么,我們如何優化呢?
答:批處理。
具體代碼如下:
public List<User> queryUser(List<User> searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List<Long> ids = searchList.stream().map(User::getId).collect(Collectors.toList());
return userMapper.getUserByIds(ids);
}
提供一個根據用戶id集合批量查詢用戶的接口,只遠程調用一次,就能查詢出所有的數據。
這里有個需要注意的地方是:id集合的大小要做限制,最好一次不要請求太多的數據。要根據實際情況而定,建議控制每次請求的記錄條數在500以內。
11 集群
系統部署的服務器節點,可能會down機,比如:服務器的磁盤壞了,或者操作系統出現內存不足問題。
為了保證系統的高可用,我們需要部署多個節點,構成一個集群,防止因為部分服務器節點掛了,導致系統的整個服務不可用的情況發生。
集群有很多種:
- 應用服務器集群
- 數據庫集群
- 中間件集群
- 文件服務器集群
我們以中間件Redis為例。
在高并發系統中,用戶的數據量非常龐大時,比如用戶的緩存數據總共大小有40G,一個服務器節點只有16G的內存。
這樣需要部署3臺服務器節點。
該業務場景,使用普通的master/slave模式,或者使用哨兵模式都行不通。
40G的數據,不能只保存到一臺服務器節點,需要均分到3個master服務器節點上,一個master服務器節點保存13.3G的數據。
當有用戶請求過來的時候,先經過路由,根據用戶的id或者ip,每次都訪問指定的服務器節點。
圖片
這用就構成了一個集群。
但這樣有風險,為了防止其中一個master服務器節點掛掉,導致部分用戶的緩存訪問不了,還需要對數據做備份。
這樣每一個master,都需要有一個slave,做數據備份。
圖片
如果master掛了,可以將slave升級為新的master,而不影響用戶的正常使用。
12 負載均衡
如果我們的系統部署到了多臺服務器節點。那么哪些用戶的請求,訪問節點a,哪些用戶的請求,訪問節點b,哪些用戶的請求,訪問節點c?
我們需要某種機制,將用戶的請求,轉發到具體的服務器節點上。
這就需要使用負載均衡機制了。
在linux下有Nginx、LVS、Haproxy等服務可以提供負載均衡服務。
在SpringCloud微服務架構中,大部分使用的負載均衡組件就是Ribbon、OpenFegin或SpringCloud Loadbalancer。
硬件方面,可以使用F5實現負載均衡。它可以基于交換機實現負載均衡,性能更好,但是價格更貴一些。
常用的負載均衡策略有:
- 輪詢:每個請求按時間順序逐一分配到不同的服務器節點,如果服務器節點down掉,能自動剔除。
- weight權重:weight代表權重默認為1,權重越高,服務器節點被分配到的概率越大。weight和訪問比率成正比,用于服務器節點性能不均的情況。
- ip hash:每個請求按訪問ip的hash結果分配, 這樣每個訪客固定訪問同一個服務器節點,它是解訣Session共享的問題的解決方案之一。
- 最少連接數:把請求轉發給連接數較少的服務器節點。輪詢算法是把請求平均的轉發給各個服務器節點,使它們的負載大致相同;但有些請求占用的時間很長,會導致其所在的服務器節點負載較高。這時least_conn方式就可以達到更好的負載均衡效果。
- 最短響應時間:按服務器節點的響應時間來分配請求,響應時間短的服務器節點優先被分配。
當然還有其他的策略,在這里就不給大家一一介紹了。
13 限流
對于高并發系統,為了保證系統的穩定性,需要對用戶的請求量做限流。
特別是秒殺系統中,如果不做任何限制,絕大部分商品可能是被機器搶到,而非正常的用戶,有點不太公平。
所以,我們有必要識別這些非法請求,做一些限制。那么,我們該如何現在這些非法請求呢?
目前有兩種常用的限流方式:
- 基于nginx限流
- 基于redis限流
13.1 對同一用戶限流
為了防止某個用戶,請求接口次數過于頻繁,可以只針對該用戶做限制。
圖片
限制同一個用戶id,比如每分鐘只能請求5次接口。
13.2 對同一ip限流
有時候只對某個用戶限流是不夠的,有些高手可以模擬多個用戶請求,這種nginx就沒法識別了。
這時需要加同一ip限流功能。
圖片
限制同一個ip,比如每分鐘只能請求5次接口。
但這種限流方式可能會有誤殺的情況,比如同一個公司或網吧的出口ip是相同的,如果里面有多個正常用戶同時發起請求,有些用戶可能會被限制住。
13.3 對接口限流
別以為限制了用戶和ip就萬事大吉,有些高手甚至可以使用代理,每次都請求都換一個ip。
這時可以限制請求的接口總次數。
圖片
在高并發場景下,這種限制對于系統的穩定性是非常有必要的。但可能由于有些非法請求次數太多,達到了該接口的請求上限,而影響其他的正常用戶訪問該接口。看起來有點得不償失。
13.4 加驗證碼
相對于上面三種方式,加驗證碼的方式可能更精準一些,同樣能限制用戶的訪問頻次,但好處是不會存在誤殺的情況。
圖片
通常情況下,用戶在請求之前,需要先輸入驗證碼。用戶發起請求之后,服務端會去校驗該驗證碼是否正確。只有正確才允許進行下一步操作,否則直接返回,并且提示驗證碼錯誤。
此外,驗證碼一般是一次性的,同一個驗證碼只允許使用一次,不允許重復使用。
普通驗證碼,由于生成的數字或者圖案比較簡單,可能會被破解。優點是生成速度比較快,缺點是有安全隱患。
還有一個驗證碼叫做:移動滑塊,它生成速度比較慢,但比較安全,是目前各大互聯網公司的首選。
14 服務降級
前面已經說過,對于高并發系統,為了保證系統的穩定性,需要做限流。
但光做限流還不夠。
我們需要合理利用服務器資源,保留核心的功能,將部分非核心的功能,我們可以選擇屏蔽或者下線掉。
我們需要做服務降級。
我們在設計高并發系統時,可以預留一些服務降級的開關。
比如在秒殺系統中,核心的功能是商品的秒殺,對于商品的評論功能,可以暫時屏蔽掉。
在服務端的分布式配置中心,比如:apollo中,可以增加一個開關,配置是否展示評論功能,默認是true。
前端頁面通過服務器的接口,獲取到該配置參數。
如果需要暫時屏蔽商品評論功能,可以將apollo中的參數設置成false。
此外,我們在設計高并發系統時,還可以預留一些兜底方案。
比如某個分類查詢接口,要從redis中獲取分類數據,返回給用戶。但如果那一條redis掛了,則查詢數據失敗。
這時候,我們可以增加一個兜底方案。
如果從redis中獲取不到數據,則從apollo中獲取一份默認的分類數據。
目前使用較多的熔斷降級中間件是:Hystrix 和 Sentinel。
- Hystrix是Netflix開源的熔斷降級組件。
- Sentinel是阿里中間件團隊開源的一款不光具有熔斷降級功能,同時還支持系統負載保護的組件。
二者的區別如下圖所示:
圖片
15 故障轉移
在高并發的系統當中,同一時間有大量的用戶訪問系統。
如果某一個應用服務器節點處于假死狀態,比如CPU使用率100%了,用戶的請求沒辦法及時處理,導致大量用戶出現請求超時的情況。
如果這種情況下,不做任何處理,可能會影響系統中部分用戶的正常使用。
這時我們需要建立故障轉移機制。
當檢測到經常接口超時,或者CPU打滿,或者內存溢出的情況,能夠自動重啟那臺服務器節點上的應用。
在SpringCloud微服務當中,可以使用Ribbon做負載均衡器。
Ribbon是Spring Cloud中的一個負載均衡器組件,它可以檢測服務的可用性,并根據一定規則將請求分發至不同的服務節點。在使用Ribbon時,需要注意以下幾個方面:
- 設置請求超時時間,當請求超時時,Ribbon會自動將請求轉發到其他可用的服務上。
- 設置服務的健康檢查,Ribbon會自動檢測服務的可用性,并將請求轉發至可用的服務上。
此外,還需要使用Hystrix做熔斷處理。
Hystrix是SpringCloud中的一個熔斷器組件,它可以自動地監測所有通過它調用的服務,并在服務出現故障時自動切換到備用服務。在使用Hystrix時,需要注意以下幾個方面:
- 設置斷路器的閾值,當故障率超過一定閾值后,斷路器會自動切換到備用服務上。
- 設置服務的超時時間,如果服務在指定的時間內無法返回結果,斷路器會自動切換到備用服務上。到其他的能夠正常使用的服務器節點上。
16 異地多活
有些高并發系統,為了保證系統的穩定性,不只部署在一個機房當中。
為了防止機房斷電,或者某些不可逆的因素,比如:發生地震,導致機房掛了。
需要把系統部署到多個機房。
我們之前的游戲登錄系統,就部署到了深圳、天津和成都,這三個機房。
這三個機房都有用戶的流量,其中深圳機房占了40%,天津機房占了30%,成都機房占了30%。
如果其中的某個機房突然掛了,流量會被自動分配到另外兩個機房當中,不會影響用戶的正常使用。
這就需要使用異地多活架構了。
圖片
用戶請求先經過第三方的DNS服務器解析,然后該用戶請求到達路由服務器,部署在云服務器上。
路由服務器,根據一定的算法,會將該用戶請求分配到具體的機房。
異地多活的難度是多個機房需要做數據同步,如何保證數據的一致性?
17 壓測
高并發系統,在上線之前,必須要做的一件事是做壓力測試。
我們先要預估一下生產環境的請求量,然后對系統做壓力測試,之后評估系統需要部署多少個服務器節點。
比如預估有10000的qps,一個服務器節點最大支持1000pqs,這樣我們需要部署10個服務器節點。
但假如只部署10個服務器節點,萬一突增了一些新的用戶請求,服務器可能會扛不住壓力。
因此,部署的服務器節點,需要把預估用戶請求量的多一些,比如:按3倍的用戶請求量來計算。
這樣我們需要部署30個服務器節點。
壓力測試的結果跟環境有關,在dev環境或者test環境,只能壓測一個大概的趨勢。
想要更真實的數據,我們需要在pre環境,或者跟生產環境相同配置的專門的壓測環境中,進行壓力測試。
目前市面上做壓力測試的工具有很多,比如開源的有:Jemter、LoaderRunnder、Locust等等。
收費的有:阿里自研的云壓測工具PTS。
18 監控
為了出現系統或者SQL問題時,能夠讓我們及時發現,我們需要對系統做監控。
目前業界使用比較多的開源監控系統是:Prometheus。
它提供了 監控 和 預警 的功能。
架構圖如下:
圖片
我們可以用它監控如下信息:
- 接口響應時間
- 調用第三方服務耗時
- 慢查詢sql耗時
- cpu使用情況
- 內存使用情況
- 磁盤使用情況
- 數據庫使用情況
等等。。。
它的界面大概長這樣子:
圖片
可以看到mysql當前qps,活躍線程數,連接數,緩存池的大小等信息。
如果發現數據量連接池占用太多,對接口的性能肯定會有影響。
這時可能是代碼中開啟了連接忘了關,或者并發量太大了導致的,需要做進一步排查和系統優化。
截圖中只是它一小部分功能,如果你想了解更多功能,可以訪問Prometheus的官網:https://prometheus.io/
其實,高并發的系統中,還需要考慮安全問題,比如:
- 遇到用戶不斷變化ip刷接口怎辦?
- 遇到用戶大量訪問緩存中不存在的數據,導致緩存雪崩怎么辦?
- 如果用戶發起ddos攻擊怎么辦?
- 用戶并發量突增,導致服務器扛不住了,如何動態擴容?