高併發架構思路,附十萬定時任務執行解決方案
一、什麼是高併發
高併發(High Concurrency)是網際網路分散式系統架構設計中必須考慮的因素之一,它通常是指,通過設計保證系統能夠同時並行處理很多請求。
高併發相關常用的一些指標有響應時間(Response Time),吞吐量(Throughput),每秒查詢率QPS(Query Per Second),併發使用者數等。
響應時間:系統對請求做出響應的時間。例如系統處理一個HTTP請求需要200ms,這個200ms就是系統的響應時間。
吞吐量:單位時間內處理的請求數量。
QPS:每秒響應請求數。在網際網路領域,這個指標和吞吐量區分的沒有這麼明顯。
併發使用者數:同時承載正常使用系統功能的使用者數量。例如一個即時通訊系統,同時線上量一定程度上代表了系統的併發使用者數。
二、如何提升系統的併發能力
網際網路分散式架構設計,提高系統併發能力的方式,方法論上主要有兩種:垂直擴充套件(Scale Up)與水平擴充套件(Scale Out)。
垂直擴充套件:提升單機處理能力。垂直擴充套件的方式又有兩種:
(1)增強單機硬體效能,例如:增加CPU核數如32核,升級更好的網絡卡如萬兆,升級更好的硬碟如SSD,擴充硬碟容量如2T,擴充系統記憶體如128G;
(2)提升單機架構效能,例如:使用Cache來減少IO次數,使用非同步來增加單服務吞吐量,使用無鎖資料結構來減少響應時間;
在網際網路業務發展非常迅猛的早期,如果預算不是問題,強烈建議使用“增強單機硬體效能”的方式提升系統併發能力,因為這個階段,公司的戰略往往是發展業務搶時間,而“增強單機硬體效能”往往是最快的方法。
不管是提升單機硬體效能,還是提升單機架構效能,都有一個致命的不足:單機效能總是有極限的。所以網際網路分散式架構設計高併發終極解決方案還是水平擴充套件。
水平擴充套件:只要增加伺服器數量,就能線性擴充系統性能。水平擴充套件對系統架構設計是有要求的,如何在架構各層進行可水平擴充套件的設計,以及網際網路公司架構各層常見的水平擴充套件實踐,是本文重點討論的內容。
三、常見的網際網路分層架構
常見網際網路分散式架構如上,分為:
(1)客戶端層:典型呼叫方是瀏覽器browser或者手機應用APP
(2)反向代理層:系統入口,反向代理
(3)站點應用層:實現核心應用邏輯,返回html或者json
(4)服務層:如果實現了服務化,就有這一層
(5)資料-快取層:快取加速訪問儲存
(6)資料-資料庫層:資料庫固化資料儲存
整個系統各層次的水平擴充套件,又分別是如何實施的呢?
四、分層水平擴充套件架構實踐
反向代理層的水平擴充套件
反向代理層的水平擴充套件,是通過“DNS輪詢”實現的:dns-server對於一個域名配置了多個解析ip,每次DNS解析請求來訪問dns-server,會輪詢返回這些ip。
當nginx成為瓶頸的時候,只要增加伺服器數量,新增nginx服務的部署,增加一個外網ip,就能擴充套件反向代理層的效能,做到理論上的無限高併發。
站點層的水平擴充套件
站點層的水平擴充套件,是通過“nginx”實現的。通過修改nginx.conf,可以設定多個web後端。
當web後端成為瓶頸的時候,只要增加伺服器數量,新增web服務的部署,在nginx配置中配置上新的web後端,就能擴充套件站點層的效能,做到理論上的無限高併發。
服務層的水平擴充套件
服務層的水平擴充套件,是通過“服務連線池”實現的。
站點層通過RPC-client呼叫下游的服務層RPC-server時,RPC-client中的連線池會建立與下游服務多個連線,當服務成為瓶頸的時候,只要增加伺服器數量,新增服務部署,在RPC-client處建立新的下游服務連線,就能擴充套件服務層效能,做到理論上的無限高併發。如果需要優雅的進行服務層自動擴容,這裡可能需要配置中心裡服務自動發現功能的支援。
資料層的水平擴充套件
在資料量很大的情況下,資料層(快取,資料庫)涉及資料的水平擴充套件,將原本儲存在一臺伺服器上的資料(快取,資料庫)水平拆分到不同伺服器上去,以達到擴充系統性能的目的。
網際網路資料層常見的水平拆分方式有這麼幾種,以資料庫為例:
按照範圍水平拆分
每一個數據服務,儲存一定範圍的資料,上圖為例:
user0庫,儲存uid範圍1-1kw
user1庫,儲存uid範圍1kw-2kw
這個方案的好處是:
(1)規則簡單,service只需判斷一下uid範圍就能路由到對應的儲存服務;
(2)資料均衡性較好;
(3)比較容易擴充套件,可以隨時加一個uid[2kw,3kw]的資料服務;
不足是:
(1) 請求的負載不一定均衡,一般來說,新註冊的使用者會比老使用者更活躍,大range的服務請求壓力會更大;
按照雜湊水平拆分
每一個數據庫,儲存某個key值hash後的部分資料,上圖為例:
user0庫,儲存偶數uid資料
user1庫,儲存奇數uid資料
這個方案的好處是:
(1)規則簡單,service只需對uid進行hash能路由到對應的儲存服務;
(2)資料均衡性較好;
(3)請求均勻性較好;
不足是:
(1)不容易擴充套件,擴充套件一個數據服務,hash方法改變時候,可能需要進行資料遷移;
這裡需要注意的是,通過水平拆分來擴充系統性能,與主從同步讀寫分離來擴充資料庫效能的方式有本質的不同。
通過水平拆分擴充套件資料庫效能:
(1)每個伺服器上儲存的資料量是總量的1/n,所以單機的效能也會有提升;
(2)n個伺服器上的資料沒有交集,那個伺服器上資料的並集是資料的全集;
(3)資料水平拆分到了n個伺服器上,理論上讀效能擴充了n倍,寫效能也擴充了n倍(其實遠不止n倍,因為單機的資料量變為了原來的1/n);
通過主從同步讀寫分離擴充套件資料庫效能:
(1)每個伺服器上儲存的資料量是和總量相同;
(2)n個伺服器上的資料都一樣,都是全集;
(3)理論上讀效能擴充了n倍,寫仍然是單點,寫效能不變;
快取層的水平拆分和資料庫層的水平拆分類似,也是以範圍拆分和雜湊拆分的方式居多,就不再展開。
五、總結
高併發(High Concurrency)是網際網路分散式系統架構設計中必須考慮的因素之一,它通常是指,通過設計保證系統能夠同時並行處理很多請求。
提高系統併發能力的方式,方法論上主要有兩種:垂直擴充套件(Scale Up)與水平擴充套件(Scale Out)。前者垂直擴充套件可以通過提升單機硬體效能,或者提升單機架構效能,來提高併發性,但單機效能總是有極限的,網際網路分散式架構設計高併發終極解決方案還是後者:水平擴充套件。
網際網路分層架構中,各層次水平擴充套件的實踐又有所不同:
(1)反向代理層可以通過“DNS輪詢”的方式來進行水平擴充套件;
(2)站點層可以通過nginx來進行水平擴充套件;
(3)服務層可以通過服務連線池來進行水平擴充套件;
(4)資料庫可以按照資料範圍,或者資料雜湊的方式來進行水平擴充套件;
各層實施水平擴充套件後,能夠通過增加伺服器數量的方式來提升系統的效能,做到理論上的效能無限。
秒殺系統如何進行架構
一、秒殺業務為什麼難做
1)im系統,例如qq或者微博,每個人都讀自己的資料(好友列表、群列表、個人資訊);
2)微博系統,每個人讀你關注的人的資料,一個人讀多個人的資料;
3)秒殺系統,庫存只有一份,所有人會在集中的時間讀和寫這些資料,多個人讀一個數據。
例如:小米手機每週二的秒殺,可能手機只有1萬部,但瞬時進入的流量可能是幾百幾千萬。
又例如:12306搶票,票是有限的,庫存一份,瞬時流量非常多,都讀相同的庫存。讀寫衝突,鎖非常嚴重,這是秒殺業務難的地方。那我們怎麼優化秒殺業務的架構呢?
二、優化方向
優化方向有兩個(今天就講這兩個點):
(1)將請求儘量攔截在系統上游(不要讓鎖衝突落到資料庫上去)。傳統秒殺系統之所以掛,請求都壓倒了後端資料層,資料讀寫鎖衝突嚴重,併發高響應慢,幾乎所有請求都超時,流量雖大,下單成功的有效流量甚小。以12306為例,一趟火車其實只有2000張票,200w個人來買,基本沒有人能買成功,請求有效率為0。
(2)充分利用快取,秒殺買票,這是一個典型的讀多些少的應用場景,大部分請求是車次查詢,票查詢,下單和支付才是寫請求。一趟火車其實只有2000張票,200w個人來買,最多2000個人下單成功,其他人都是查詢庫存,寫比例只有0.1%,讀比例佔99.9%,非常適合使用快取來優化。好,後續講講怎麼個“將請求儘量攔截在系統上游”法,以及怎麼個“快取”法,講講細節。
三、常見秒殺架構
常見的站點架構基本是這樣的(絕對不畫忽悠類的架構圖)
(1)瀏覽器端,最上層,會執行到一些JS程式碼
(2)站點層,這一層會訪問後端資料,拼html頁面返回給瀏覽器
(3)服務層,向上遊遮蔽底層資料細節,提供資料訪問
(4)資料層,最終的庫存是存在這裡的,mysql是一個典型(當然還有會快取)
這個圖雖然簡單,但能形象的說明大流量高併發的秒殺業務架構,大家要記得這一張圖。
後面細細解析各個層級怎麼優化。
四、各層次優化細節
第一層,客戶端怎麼優化(瀏覽器層,APP層)
問大家一個問題,大家都玩過微信的搖一搖搶紅包對吧,每次搖一搖,就會往後端傳送請求麼?回顧我們下單搶票的場景,點選了“查詢”按鈕之後,系統那個卡呀,進度條漲的慢呀,作為使用者,我會不自覺的再去點選“查詢”,對麼?繼續點,繼續點,點點點。。。有用麼?平白無故的增加了系統負載,一個使用者點5次,80%的請求是這麼多出來的,怎麼整?
(a)產品層面,使用者點選“查詢”或者“購票”後,按鈕置灰,禁止使用者重複提交請求;
(b)JS層面,限制使用者在x秒之內只能提交一次請求;
APP層面,可以做類似的事情,雖然你瘋狂的在搖微信,其實x秒才向後端發起一次請求。這就是所謂的“將請求儘量攔截在系統上游”,越上游越好,瀏覽器層,APP層就給攔住,這樣就能擋住80%+的請求,這種辦法只能攔住普通使用者(但99%的使用者是普通使用者)對於群內的高階程式設計師是攔不住的。firebug一抓包,http長啥樣都知道,js是萬萬攔不住程式設計師寫for迴圈,呼叫http介面的,這部分請求怎麼處理?
第二層,站點層面的請求攔截
怎麼攔截?怎麼防止程式設計師寫for迴圈呼叫,有去重依據麼?ip?cookie-id?…想複雜了,這類業務都需要登入,用uid即可。在站點層面,對uid進行請求計數和去重,甚至不需要統一儲存計數,直接站點層記憶體儲存(這樣計數會不準,但最簡單)。一個uid,5秒只准透過1個請求,這樣又能攔住99%的for迴圈請求。
5s只透過一個請求,其餘的請求怎麼辦?快取,頁面快取,同一個uid,限制訪問頻度,做頁面快取,x秒內到達站點層的請求,均返回同一頁面。同一個item的查詢,例如車次,做頁面快取,x秒內到達站點層的請求,均返回同一頁面。如此限流,既能保證使用者有良好的使用者體驗(沒有返回404)又能保證系統的健壯性(利用頁面快取,把請求攔截在站點層了)。
頁面快取不一定要保證所有站點返回一致的頁面,直接放在每個站點的記憶體也是可以的。優點是簡單,壞處是http請求落到不同的站點,返回的車票資料可能不一樣,這是站點層的請求攔截與快取優化。
好,這個方式攔住了寫for迴圈發http請求的程式設計師,有些高階程式設計師(黑客)控制了10w個肉雞,手裡有10w個uid,同時發請求(先不考慮實名制的問題,小米搶手機不需要實名制),這下怎麼辦,站點層按照uid限流攔不住了。
第三層 服務層來攔截(反正就是不要讓請求落到資料庫上去)
服務層怎麼攔截?大哥,我是服務層,我清楚的知道小米只有1萬部手機,我清楚的知道一列火車只有2000張車票,我透10w個請求去資料庫有什麼意義呢?沒錯,請求佇列!
對於寫請求,做請求佇列,每次只透有限的寫請求去資料層(下訂單,支付這樣的寫業務)
1w部手機,只透1w個下單請求去db
3k張火車票,只透3k個下單請求去db
如果均成功再放下一批,如果庫存不夠則佇列裡的寫請求全部返回“已售完”。
對於讀請求,怎麼優化?cache抗,不管是memcached還是redis,單機抗個每秒10w應該都是沒什麼問題的。如此限流,只有非常少的寫請求,和非常少的讀快取mis的請求會透到資料層去,又有99.9%的請求被攔住了。
當然,還有業務規則上的一些優化。回想12306所做的,分時分段售票,原來統一10點賣票,現在8點,8點半,9點,...每隔半個小時放出一批:將流量攤勻。
其次,資料粒度的優化:你去購票,對於餘票查詢這個業務,票剩了58張,還是26張,你真的關注麼,其實我們只關心有票和無票?流量大的時候,做一個粗粒度的“有票”“無票”快取即可。
第三,一些業務邏輯的非同步:例如下單業務與 支付業務的分離。這些優化都是結合 業務 來的,我之前分享過一個觀點“一切脫離業務的架構設計都是耍流氓”架構的優化也要針對業務。
好了,最後是資料庫層
瀏覽器攔截了80%,站點層攔截了99.9%並做了頁面快取,服務層又做了寫請求佇列與資料快取,每次透到資料庫層的請求都是可控的。db基本就沒什麼壓力了,閒庭信步,單機也能扛得住,還是那句話,庫存是有限的,小米的產能有限,透這麼多請求來資料庫沒有意義。
全部透到資料庫,100w個下單,0個成功,請求有效率0%。透3k個到資料,全部成功,請求有效率100%。
五、總結
上文應該描述的非常清楚了,沒什麼總結了,對於秒殺系統,再次重複下我個人經驗的兩個架構優化思路:
(1)儘量將請求攔截在系統上游(越上游越好);
(2)讀多寫少的常用多使用快取(快取抗讀壓力);
瀏覽器和APP:做限速
站點層:按照uid做限速,做頁面快取
服務層:按照業務做寫請求佇列控制流量,做資料快取
資料層:閒庭信步
並且:結合業務做優化
10w定時任務,如何高效觸發超時
一、緣起
很多時候,業務有定時任務或者定時超時的需求,當任務量很大時,可能需要維護大量的timer,或者進行低效的掃描。
例如:58到家APP實時訊息通道系統,對每個使用者會維護一個APP到伺服器的TCP連線,用來實時收發訊息,對這個TCP連線,有這樣一個需求:“如果連續30s沒有請求包(例如登入,訊息,keepalive包),服務端就要將這個使用者的狀態置為離線”。
其中,單機TCP同時線上量約在10w級別,keepalive請求包大概30s一次,吞吐量約在3000qps。
一般來說怎麼實現這類需求呢?
“輪詢掃描法”
1)用一個Map<uid, last_packet_time>來記錄每一個uid最近一次請求時間last_packet_time
2)當某個使用者uid有請求包來到,實時更新這個Map
3)啟動一個timer,當Map中不為空時,輪詢掃描這個Map,看每個uid的last_packet_time是否超過30s,如果超過則進行超時處理
“多timer觸發法”
1)用一個Map<uid, last_packet_time>來記錄每一個uid最近一次請求時間last_packet_time
2)當某個使用者uid有請求包來到,實時更新這個Map,並同時對這個uid請求包啟動一個timer,30s之後觸發
3)每個uid請求包對應的timer觸發後,看Map中,檢視這個uid的last_packet_time是否超過30s,如果超過則進行超時處理
方案一:只啟動一個timer,但需要輪詢,效率較低
方案二:不需要輪詢,但每個請求包要啟動一個timer,比較耗資源
特別在同時線上量很大時,很容易CPU100%,如何高效維護和觸發大量的定時/超時任務,是本文要討論的問題。
二、環形佇列法
廢話不多說,三個重要的資料結構:
1)30s超時,就建立一個index從0到30的環形佇列(本質是個陣列)
2)環上每一個slot是一個Set<uid>,任務集合
3)同時還有一個Map<uid, index>,記錄uid落在環上的哪個slot裡
同時:
1)啟動一個timer,每隔1s,在上述環形佇列中移動一格,0->1->2->3…->29->30->0…
2)有一個Current Index指標來標識剛檢測過的slot
當有某使用者uid有請求包到達時:
1)從Map結構中,查找出這個uid儲存在哪一個slot裡
2)從這個slot的Set結構中,刪除這個uid
3)將uid重新加入到新的slot中,具體是哪一個slot呢 => Current Index指標所指向的上一個slot,因為這個slot,會被timer在30s之後掃描到
(4)更新Map,這個uid對應slot的index值
哪些元素會被超時掉呢?
Current Index每秒種移動一個slot,這個slot對應的Set<uid>中所有uid都應該被集體超時!如果最近30s有請求包來到,一定被放到Current Index的前一個slot了,Current Index所在的slot對應Set中所有元素,都是最近30s沒有請求包來到的。
所以,當沒有超時時,Current Index掃到的每一個slot的Set中應該都沒有元素。
優勢:
(1)只需要1個timer
(2)timer每1s只需要一次觸發,消耗CPU很低
(3)批量超時,Current Index掃到的slot,Set中所有元素都應該被超時掉
三、總結
這個環形佇列法是一個通用的方法,Set和Map中可以是任何task,本文的uid是一個最簡單的舉例。