推送架构的演进
架構是為了更好的為業務提供更好的服務。架構最終會以產品的方式提供給客戶使用。因此,在我們開始討論這套系統架構的演進之前,請由我對我們系統做一個簡單的介紹:
TD-Push,產品代號魔推。如上圖所示,TD-Push是一款為移動APP提供的一套推送營銷組件。我們的SDK擁有體積小、耗電少的特點,同時支持公有、私有云的部署。對于推送內容的報文,在傳輸過程中是全程密文,并且每個終端的秘鑰都不相同。它使用了Go語言編寫,擁有部署簡單,成本低廉的特點。同時它的功能豐富,能夠對每次推送的效果進行跟蹤。
首先通過一個圖來對我們的系統進行初步的了解:
如上圖所示,客戶的APP通過集成Push的SDK,既可完成集成的工作。通過系統提供的Portal,即可完成推送和運營等相關的工作。好的開始是成功的一半。系統從一開始,就采用分布式的架構,這套架構可以支持橫向擴展低廉的硬件,來支撐更大的數據和更高的并發。從數據庫,到每個系統組件都是分布式的,支持橫向擴容。
先對系統的架構組件進行一個系統的了解,如圖所示:
從這個系統架構圖上,可以清晰的看業務到系統的業務組件之間,都是通過Rest API進行調度的。系統組件分為SDK、Connector、APNS、A3PNS、WPNS、Gateway、Controller 。在Gateway和Controller里面,包含了Collector, Dispatcher, DataService, TaskContainer, Portal。企業的員工,可以通過Portal執行相關的營銷推送。企業的應用可以通過調用API,對業務進行更多更靈活的處理。
隨著業務的發展、需求的演進、數據量級的增加,百萬、千萬、數億、數十億,架構和組件并未做太多調整。但也確實碰到了一些棘手的問題。我把這些棘手的問題,稱之為甜蜜的負擔,也可以叫做“成長的煩擾”。我們把問題列舉如下:
1> 數據庫不堪重負:? 究竟是程序編寫的問題?還是數據庫選型需要調整?在傳統應用系統編程的那些非常實用方法、技巧、規律,在數據量級提升,并發數的提升下。表現不盡人意。
2> 系統突如其來的請求高峰: 系統會存在大量密集的請求。峰值的請求,通常都會是普通情況下的好幾倍甚至更多。如果把系統的容量,根據峰值進行評估,那會是平時的硬件的好幾倍。雖然這樣可以解決問題,但這對于客戶和我們自己的云平臺來說,都是一種極大的浪費。
3> 系統出現大量的Time Wait: 系統的業務組件之間,通過Rest API進行調度。業務組件和數據庫之間,通過Socket進行操作。當系統業務量很高的時候,業務組件和業務組件之間,業務組件和數據庫之間,會存在大量的網絡操作。此時,從系統運行的日志里面,會發現一些奇怪的日志。比如 Request time out , Aerospike 出現 EOF,使用Netstat進行查看,發現有大量的Time wait。
4> 臨界區鎖保護,性能提升不明顯:? 敏感資源,我們稱為臨界區。我們使用鎖、讀寫鎖 進行保護。但當并發量越來越大的時候,發現鎖其實也很耗費資源。
5> 內存寶貴,如何榨取更多硬件資源: 在我們系統的組件里面,使用了Map為Cache,使用了隊列等數據結構。這些結構的數據,能帶來性能的提升,但通常隨著數據量的增大,內存耗費也會增大。對于CPU來說,用滿了,也不會產生明顯的硬傷。但作對內存來說,一旦你用滿了,程序就會出現OOM。但是我們需要更高的性能,更大的容量,更少的網絡請求。
這都是我們遇到的一些問題。面對這些問題,我們都一一的,在日常的工作中,進行了處理:
1> 我們把一些可以拆分的并發邏輯處理單元,把它們拆分成CSP的結構。
2> 從普通的Sync Map到多元化的Cache。
3> 數據庫程序的優化,從Open Session In View,到基于代理的數據庫連接。
4> 針對Dispatch組件進行升級,負載和調度的算法進行升級。
5> 使用http2的協議,針對組件之間的調度進行網絡I/O的優化。
?
CSP模型介紹
CSP是Communicating Sequential Processes的縮寫,它的意思是順序通信進程。也就是通過通信的方式,來代替函數的調用。
如圖所示:圖中的Channel 就是通信的管道,Worker就是處理單元。? Channel可以打個簡單的比方,它和系統常見的隊列看起來很相似。Worker和Java的線程相似。當然這里說的是相似,也就是說它們之間存在差異。大家可以先按照Channel是隊列,Work是線程的這種方式去理解。這套模型雖然我們是在Go語言中是使用,同時這套模型在其它的語言理也同樣適用,大家可以基于自己的理解,進行靈活的應用。模型中,Worker之間不直接彼此聯系,而是通過不同Channel進行消息發布和偵聽。消息的發送者和接收者之間通過Channel松耦合,發送者不知道自己消息被哪個接收者消費了,接收者也不知道是哪個發送者發送的消息。
函數
在Golang的CSP模型里,它的Worker,就是一個普通的函數。在函數的前面放上一個Go 進行執行,這個函數便是一個并發的攜程。在Golang里面,函數是一等公民。函數可以作為變量、參數、返回值。如果您擅長函數式編程的話,Golang的函數也能支持您基于函數式編程。剛才我們提到了,函數可以作為變量、參數、返回值。函數作為返回值,可以提高函數的抽象層級、并且可以減少全局變量的暴露。
?
GoRoutine
模型中的每個Worker,都是一個GoRoutine。 協程(Coroutine)這個概念最早是Melvin Conway在1963年提出的,是并發運算中的概念。構建一個協程默認只會產生4K的內存,構建一個線程的時候,會產生4M左右的內存。線程的調度依賴于操作系統進行調度。協程的調度是輕量級的,它是在進程里進行調度的。如圖所示:
Go的調度器內部有三個重要的結構:M,P,S
M:代表真正的內核OS線程,和POSIX里的Thread差不多,真正干活的人
G:代表一個Goroutine,它有自己的棧,Instruction Pointer和其它信息(正在等待的Channel等等),用于調度。
P:代表調度的上下文,可以把它看做一個局部的調度器,使Go代碼在一個線程上跑,它是實現從N:1到N:M映射的關鍵。
?
Channel
在CSP模型中,Channel是一個數據傳輸的紐帶。在申明Channel的時候,可以指定Channel的只讀、只寫、讀寫權限和Buffer大小。在對Channel進行操作的時候,可以使用阻塞和非阻塞的方式。這里列舉一些Channel的應用:
??????? 基于Channel返回大量的數據:
基于Channel返回大量的數據??梢栽诤瘮抵蟹祷匾粋€Channel 。然后一邊往Channel里面寫,一邊往Channel里面讀。一方面節省了內存,另一方面提高了運算。
??????? 使用Channel實現超時、心跳等:
使用定時和超時的Channel來驅動處理函數,來實現超時、心跳等多項自定義業務。
??????? 基于Channel實現Latch,控制并發數:
如上圖所示,通過一個固定長度的Channel,實現并發數的控制。獲取并發權限的時候,往Channel里面寫入一個對象。釋放并發權限的時候,往Channel里面讀取一個對象。Channel的長度,就是并發數。
??????? 使用Channel實現Recycling memory buffers
左側是Give Channel , 右側是Get Channel .中間是 Recyling Buffer.左側的Give Channel負責,收回已經借出去的內存.中間的Recycling Buffer負責在缺少內存時產生Buffer,并根據過期規則過期多余的buffer.右側的Get Channel負責,把Buffer借出去。
使用了CSP模型之后,給系統帶來了穩定的收益。這些收益來自于:
無鎖: 使用通訊代替共享內存的方式,減少鎖競爭帶來的性能下降。
Channel: 使用了Channel,把大量處理的任務以Worker的方式執行,在負載過高時,系統依舊平穩。
GoRoutine:為系統帶來了計算和并發性能提升
CSP模型:可以讓代碼的結構清晰,易于維護。從而增加了軟件的可維護性和可擴展性。
多元化的Cache
??? 在最起初的時候,數據量很小,我們使用一個普通的Map,作為Cache,放在內存中。對于部分業務,甚至沒有Cache,直接查數據庫。隨著數據量和并發量的提升。這種做法已經不能應對當前的情況了。因此,我們在演進的過程中Cache實現了多元化。如圖所示:
?
?
首先我們在Map的基礎上,引入了Heap 。在Heap里面存放了基于Score排序好的Key。這些Key可以是最后一次使用時間或者過期時間或者使用頻次等。由此我們就有了Timer Cache, LRU Cache.
其次在數據過期的時候,我們想處理更多的邏輯,因此我們引入了回調函數?;卣{函數作為閉包,在構造的時候,傳入。過期時Cache會自動執行回調函數
隨著數據量變得更大之后,我們發現內存十分寶貴。我們需要向硬盤索取更大的容量。我們采用了LevelDB存儲引擎,把較大的Value存放在磁盤上。
在分布式環境中,當一臺服務器更新了Cache數據,而其它主機沒有同步刷新此條Cache的時候,會出現一些數據不一致的問題。因此我們系統引入了哨兵,來保證Cache數據的一致性。
至此,我們在Cache的優化的道路上,一直努力著。比如:前段時間我們發現,即便你有各種過期算法,但也不能明確和量化Cache具體使用的內存大小。因此目前我們在Cache的容量上限也做了一定的控制。
?
數據層的優化
我們架構的這套系統是一套業務系統,數據庫的性能,直接影響程序的性能。數據層的優化如下圖所示:
系統在早期的雛形中,使用了MongoDB和Redis. MongoDB負責所有業務的數據存儲。Redis支撐起了離線消息存儲。在系統的程序里,我們使用依賴注入和Open Session In View的模式。
隨著數據量的上升。SDK會周期性的上報自己的基礎信息。系統的寫比例遠高于讀取的比例,MongoDB寫入出現瓶頸。此時引入了分布式內存數據庫Aerospike。讓Aerospike提供無中心的、高效的讀寫性能。
就這樣系統平穩了好一段時間。突然有一天,系統發現Mongo連接池不夠用。一開始我們懷疑是否自己程序有問題?驅動有使用不恰當的地方?最后經過我們發現,其實是我們的Mongo連接資源占用的時間較長導致的。再此情況下,我們編寫了一個數據源代理程序,用于減少敏感的數據庫連接的占用時間。就這樣,連接數降低一個數量級。而且也穩定在這個數量級。
如此往復系統又平穩了好一段時間。突然發現系統的吞吐量下降了。QPS、TPS都下降了。為何之前一直運行平穩的系統,性能出現抖動?加入直方圖,監控一些性能影響因素,我們發現了特定代碼的在特定情況下的表現出現了2ms的抖動,導致了這一現象的發生。直方圖對問題的定位起到了很好的作用。
現在我們針對Mongo客戶端的使用,進行一個總結。如下圖所示:
圖的左側是Open Session In View的方式。也是我們系統起初使用的方式。它能夠確保每個http請求,都有數據處理的能力。同時又能保證每個數據庫連接都能夠被關閉。基于類型注入,對變量的作用域,也有一個很好的管理。這種方式,在并發量不大的時候,還是不錯的。隨著并發量的增大,它的缺點也暴露出來了:
圖中小方塊,每一格,代表一個單位的時間。Open Session In View的方式占用的Session的時間太長,以至于出現了等待、連接池不夠用的現象。
為了應對這一現象,我們引入了連接代理。圖片的右側沒有等待連接的現象
而且每個數據庫操作的時間非常短
同時它也保證了之前Open Session In View里擁有的優點,代碼改動也很小。
那對于新的這種方式,我們做了以下工作:
??????? 沿用了OSIV
??????? 使用了代理連接,也就是說:每次Open Session的時候,Open的是一個代理類,并未產生真正的數據庫連接
??????? 代理連接支持自動打開和重復關閉
??????? 在真正操作數據庫的時候,建立連接。
?
在Aerospike的使用過程中,我們積極的向社區反饋問題。比如驅動拋出的錯誤日志、數據庫在特定場景下出現的一些問題等等。
凡事預則立不預則廢。對于硬件擴容需要有一個規劃。容量和內存評估,為硬件規劃提供很好的理論基礎。
運維監控:可以通過運維,觀察到系統的數據總量、以及增長速度。并且為系統存在風險進行預警。
在客戶端的讀寫參數調整:我們根據不同的數據等級,采用了不同的寫入策略。針對不同查詢數量級,使用不同的查詢策略。
在項目的初期,我們把Aerospike放在云的虛擬機上。即便是分布式內存數據庫,面對高峰時的成噸的并發,也會有不盡人意的地方。我們在客戶端,將密集的寫請求改成CSP的模式。讓性能穩定在一個最佳值。與此同時優化讀寫策略。內存如此寶貴,把Aerospike從全內存模式,變成SSD的模式。同等內存條件下,存儲容量得到了1個數量級的提升。通過容量評估,為硬件規劃提供很好的理論基礎。在客戶端的數據訪問層我們做了一些優化工作:
1> 引入Cache,減少讀取壓力
2> 將并發密集的寫,改成CSP模式
3> 引入直方圖監控,量化性能指標,實現針對性的優化。
?
任務分發與調度的演進
任務分發和調度演進,大致可以通過下圖進行一個概括:
在之前的調度流程中,我們包含Dispatch/任務分發/健康檢查的工作內容。
l? Dispatch: 當一個終端連接上來的時候,需要根據Connector當前的負載和權值,進行連接地址的指定。
l? 任務分發:推送消息下發。由于下發任務的服務器,并不清楚,哪個連接歸屬于哪臺服務器,因此需要做二次發送,第一次在在線發送,并且記錄發送失敗的設備。在第二次發送的時候,Shuffle打亂在進行離線發送。
l? 健康檢查:是定期檢查Connector的負載,并記錄。
但其實這么發送,是存在一定的資源浪費的。針對這些資源的讓費,我們使用了一致性哈希算法,優化了這些流程。我們先來看看基于權值和監控檢查的路由和分發是如何工作又存在什么問題。這個流程大致如下圖方式進行工作:
圖片的左上角是Dispatch流程,中間是健康檢查流程,最右邊是任務下發流程。隨著結點數的增加,(健康檢查、任務下發)的網絡請求會成倍增加。隨著Dispatch數量級的增加,并發也相應增加。又因為健康檢查存在時間窗口,導致誤差會隨著設備數量級的增加而增加。
下發性能會隨著下發的數量的增加而成倍增加,導致任務下發出現瓶頸。面臨這個現狀,我們使用了一致性哈希算法,調整后的流程如下圖:
首先,我們砍掉了健康檢查這個流程,無須了解各個Connector的負載
其次,在Dispatch,直接根據哈希算法進行指定Connector
然后,在任務下發的時候,具有很強的針對性。一步到位,減少了往復的流程。
?
網絡I/O的優化
我們在網絡I/O上確實碰到了一些偶發性的問題。即便Aerospike服務器和客戶端都做了很多優化的工作。但日志里面仍然會出現EOF的問題。在我們系統的組件之間,存在著頻繁、密集的交互。這些交互都是Http1的協議。當系統之間頻繁交互(如:獲取推送業務指標和狀態、密集的單推和廣播等推送業務)。系統會在極端的情況下出現Time Wait,Close Wait等現象。針對上述現象。我們使用了Http2的協議,在代碼小幅度調整的情況下,實現了請求的連接的多路復用。起初我們思考,為什么非得使用HTTP而不用RPC呢?Rest API,既能用于 前端頁面的展現,又便于其它語言進行集成。Http相對于RPC更輕量級。使用Passive Feed Back的方式,把系統里面可以合并的請求進行合并。將請求從被動的Post,變成主動的Get。使用Redis Pipe Line 的方式,合并Redis的操作,將多個操作打包成一個Pipe Line進行發送。節省了socket成本。
?
?
在這篇博文中,涵蓋了CSP并發編程模型、數據庫訪問層的優化、Dispatch流程的優化,多元化的Cache以及網絡I/O相關的話題。如果大家對Push和相關的技術感興趣,可以通過TalkingData聯系到我,進行相關的探討。
?
?
總結
- 上一篇: android 最低兼容版本,vue c
- 下一篇: 简单的wchar_t 和 char 转换