淺談高併發業務系統設計

 

序言

筆者工作近一年期間在做一些營銷平臺相關的事,負責一部分營銷活動。這些營銷活動併發度比較高,此處整理一下一個高併發業務系統設計的一些方法論。

1、什麼是高併發

高併發是一種短時間內有大量請求到服務端的現象。對於這種現象,我們需要關注的系統指標有:

  • 響應時間(Response Time),也就是我們常說的RT。它表示的是我們的系統對一個請求的處理時間,一般在數十毫秒。如果響應時間太高,一方面,用戶的體驗會很糟糕;另一方面,響應時間太長也會使得我們的系統資源會喫不消,常見的比如IO資源、數據庫鏈接池資源、線程池資源、CPU資源、甚至JVM資源,這些資源喫緊之後就會造成之後的請求無法正常處理,導致故障的產生。

  • 每秒查詢數(Query Per second),也就是我們常說的QPS。他可以用來衡量系統的吞吐量,也就是在規定時間內處理請求的多少,衡量系統的處理能力。

2、爲什麼高併發難

按照我自己的理解,高併發無非難在幾個地方:

  • 我們的系統資源CPU、內存、網絡、IO是有限的,我們需要用盡可能少的系統資源去支持儘可能多的請求。任何一個資源都可能成爲我們的系統瓶頸,而避免這些資源成爲我們系統瓶頸的方法無非優化單次請求的資源開銷、增加系統資源、減少請求數以減少資源開銷。

  • 在高併發場景下,會出現併發度較低場景下不會出現的業務異常。比如對於一個HashMap,在多線程put的時候,甚至有可能會出現CPU打滿的情況。

  • 在高併發場景下,一個很小的系統bug會被放大無限倍,對系統可用性要求較高

  • 在高併發場景下,我們系統依賴的二方或者三方服務抖動都有可能導致我們的系統產生故障

3、高併發的“利刃”

此小節簡述一下解決高併發的一些常用解決方案,當然具體的高併發解決方案還要看具體的場景,一味堆砌反而會給系統帶來不必要的複雜度和維護成本。

3.1、緩存

3.1.1、什麼是緩存

提到高併發的解決方案,不得不提的一個點就是緩存(Cache)。緩存利用某些數據讀多寫少的特點,將這部分數據拷貝到讀取速度更快的容器上,然後讀取數據的時候優先從速度更快的容器讀取,沒有讀取到再從更低層級的容器中讀取,從而使得查詢操作儘可能發生在讀取速度更快的容器中,以提高查詢速度。當然更快的容器往往意味着更貴,不可能全量數據都往裏塞,往往只會往裏塞一些需要頻繁查詢的熱點數據。典型的比如CPU的L1緩存、L2緩存、L3緩存,L3緩存、L2緩存、L1緩存的大小依次變小,但是訪問速度依次變大,這些緩存上會存放熱點數據,當尋找數據的時候會按照L1緩存、L2緩存、L3緩存、內存的順序依序訪問,這樣數據訪問能做到儘可能快。

但是當告訴的緩存容器沒有查詢到數據時,請求還是需要到更低級的容器裏進行處理。這種情況不但不會增加查詢速度,反而會增加一次數據查詢的時間開銷。因此我們需要用讀取數據的時候成功從高速緩存容器中讀取到數據的機率來評估緩存的工作情況,這就是緩存命中率,其值爲從緩存中讀取到數據的次數/讀取總次數。這個值越高表示我們緩存工作得越良好。

緩存的速度再快終究只是一種存儲容器,有一定的容量限制,爲了能讓新的緩存數據能進入緩存,需要一定的緩存回收策略來將舊的緩存數據回收。常見的回收策略有:

先進先出算法(First In First Out, FIFO):先放入緩存的數據先被移除。

最近最少使用算法(Least Recently Used, LRU):數據最近一次使用時間最早的先被移除。

當然還有基於時間的回收策略,比如存活期(Time To Live,TTL):自緩存創建並經過指定時間之後,將緩存的數據移除。

除了上文提到的CPU多級緩存,典型的計算機科學領域的緩存有:

內容分發網絡(Content Delivery Network, CDN):CDN是一組服務器,它們分佈在不同位置,上面放有一些很少會去改變的靜態文件,比如js文件、css文件、圖片等。當用戶嘗試訪問這些靜態資源的時候,就會根據用戶的位置選擇一個最近的服務器節點進行訪問。相當於使用戶的靜態資源查詢操作發生在了離用戶最近的服務器節點這個存儲中。

域名系統(Domain Name System,DNS):DNS是負責將域名解析爲ip地址的一組服務器。DNS服務器也是分有多級的,有根服務器、頂級域名服務器、權限域名服務器、本地域名服務器。在進行域名解析時,主機會對本地域名服務器遞歸查詢,而本地域名服務器會先後對根域名服務器、頂級域名服務器、權限域名服務器進行迭代查詢,各級域名服務器都會緩存一段時間的查詢結果,甚至主機的操作系統、瀏覽器也會緩存域名解析的查詢結果。顯然,這裏也是使用了緩存技術,讓域名解析查詢儘可能發生在了更快的地方。

而在系統研發中,數據一般都是去數據庫訪問的,但是我們知道磁盤的IO是比內存IO慢非常多的,根據這一點,我們可以將熱點數據放入內存提供查詢,從而提高請求的響應時間,這就是系統開發經常涉及到的緩存技術。如圖:

 

3.1.2、緩存類型有哪些

緩存可以簡單分爲本地緩存分佈式緩存,本地緩存即一臺機器會對應一片單獨的緩存,這種本地緩存比較簡單,但是會受到單機內存容量的限制,而且在集羣環境下,會有不同機器的緩存的數據一致性的問題。這時候就需要使用分佈式緩存了。

本地緩存。對於本地緩存可以簡單分爲堆緩存堆外緩存,堆緩存由JVM管理,GC不需要開發者關心,但是這也使得在有大量緩存數據時其GC開銷會很大。堆外緩存由操作系統直接管理,不會出現GC時stop the world的情況,但GC需要開發者額外關心,而且由於讀取數據時需要涉及到序列化和反序列化,這也使得堆外內存的讀取會比堆內存慢,當然其還是比磁盤快非常多的。因此我理解當開發者對明確了什麼時候需要回收掉緩存數據時就可以使用堆外緩存,以此來減小垃圾回收器對大量緩存數據的無用掃描。無特殊要求時簡單使用堆緩存即可。

分佈式緩存。爲了解決單機容量問題,或者對數據一致性有一定要求時就需要使用分佈式緩存。分佈式緩存部署在負責緩存的集羣中,而非業務系統中。該分佈式緩存集羣會屏蔽緩存數據的數據尋址、數據複製、數據防丟等細節,當業務系統嘗試訪問緩存時可以從對應的分佈式緩存集羣中讀取數據,當然這將涉及到緩存對象的序列化/反序列化,而且還有一定的網絡開銷。因此對於某些很熱的、對時延要求很高的數據還是需要使用本地緩存來減小網絡開銷和序列化/反序列化開銷。當然某些分佈式緩存比如tair也提供了local cache的功能來解決這個問題。

3.1.3、什麼時候更新緩存

什麼時候更新緩存是一個難題,因爲使用緩存後,數據會同時存在於數據庫和緩存,而當更新數據時,數據庫和緩存都需要進行更新,而無論如何更新都會導致短暫的數據不一致。目前常見的緩存更新模式有:

3.1.3.1、Cache Aside,這種更新模式在更新的時候會先更新數據庫,然後把緩存失效。如圖:

這裏比較反直覺的一個點是,在更新的時候爲什麼更新完數據庫是去失效緩存而不是更新緩存。假如我們更新完數據庫之後是去更新緩存而不是失效緩存,存在線程A和線程B先後分別去更新數據爲數據A和數據B,有可能會出現線程A更新緩存到數據A晚於線程B更新緩存到數據B的情況。這種情況會導致緩存中的數據是數據A而不是我們預期的數據B。除此之外,這種雙寫數據源帶來了不必要的邏輯複雜度,還提前在緩存中加載了可能不會讀取到的緩存。

另一個需要關注的點是,此模式的失效緩存操作是晚於更新數據庫的。假如我們先失效緩存,再去更新數據庫,存在線程A和線程B先後分別進行更新老數據爲數據A和讀取數據,線程A失效緩存卻還未來得及更新數據庫的時候,線程B執行了讀數據的流程,讀到了老數據並且將緩存中的數據置爲老數據,最後線程A更新老數據爲數據A。這樣最後緩存中的數據爲老數據,數據庫中數據爲數據A,顯然不符合我們預期。

當然這種方法也有造成最終緩存和數據庫中數據不一致的情況。存在線程A和線程B在緩存已經失效的情況下先後分別去讀取數據和更新老數據爲數據B,線程A的讀請求會先去讀數據庫,讀到了老數據,讀操作結束後線程B開始更新數據庫操作,並且將緩存置爲失效,此時線程A再去將老數據寫入緩存。最終緩存中的數據爲老數據,數據庫中的數據爲數據B,顯然數據不一致。但是這種情況只會在寫緩存時延大於寫數據庫時延加上失效緩存時延時發生,導致先發出的數據庫更新數據請求在後發出的緩存寫老數據之前完成,概率很低。爲了最大限度地避免這個問題,可以使用延時雙刪的方式來保證緩存中的髒數據能被失效:

// 刪除對應緩存
deleteCache(key);
// 更新數據庫
updateDB(data);
// 延時 時間試寫緩存的響應時間而定
Thread.sleep(500);
// 再次刪除對應緩存
deleteCache(key);

當然這樣的延時增加了接口的響應時間,可以視情況將延時刪除緩存的操作異步化。

3.1.3.2、Read/Write Through,前面提到的Cache Aside模式也有不好的地方,那就是對於調用方來說比較複雜,需要關心緩存和數據庫的數據一致性問題,而Read/Write Through則是讓調用方只需和緩存進行交互,如果有必要涉及到緩存和數據庫的交互則客戶端等待緩存層去執行。如圖:

從這種模式的名字也可以看出,調用端只需要直接對緩存進行讀寫即可,不需要調用端來關心數據庫和緩存的數據一致性問題。在讀的時候直接讀緩存層,緩存層來負責載入數據,而寫的時候也直接寫緩存層,緩存層負責將數據同步到數據庫。guava中有CacheLoaderWriter來提供緩存載入數據和緩存更新數據到數據庫的功能,具體的載入時機和緩存更新時機可進行配置。

需要注意的是,這個模式這裏緩存層更新完數據庫之後是去更新緩存而不是失效緩存,我個人理解是緩存層做了併發更新的併發控制,這個和我們的Cache Aside又有點不太一樣。

3.1.3.3、Write Behind,這種模式同樣只需要調用端和緩存進行交互,具體的做法爲調用端直接對緩存進行讀寫,然後緩存異步刷到數據庫。這種方式顯然對調用端來說響應時間是最短的,因爲只涉及到簡單的緩存讀寫,但是這種方式會導致緩存和數據庫中的數據不一致,而且有可能因爲異步調度任務的執行失敗、緩存數據丟失等問題導致數據沒有寫成功到數據庫。

Cache Aside從名字上也可以看出這是一個以數據庫爲準的緩存使用模式,因爲它是先更新數據庫,之後失效緩存,這樣也使得數據庫中的數據是準確的。Cache Aside也可以通過延時雙刪最大限度地保證數據庫數據和緩存數據的一致性,但由於要考慮緩存和數據庫的數據一致性,使得這些代碼對業務邏輯有一定侵入。而Read/Write Through和Write Behind則可以通過只和緩存層交互來避免這個問題,其中Read/Write Through模式中,調用端將數據載入和更新操作移交給緩存層來進行,緩存層去做統一的併發控制;在Write Behind模式中,調用端同樣只讀寫緩存,緩存層會去異步將緩存數據刷到數據庫,這種模式對客戶端來說響應時間最短,但是有數據丟失的可能。

3.1.4、緩存使用注意事項

3.1.4.1、調用端頻繁請求不存在的緩存值

假如調用端查詢某個key對應的值時,這個key不存在,但是調用端一直在調用,這時這些請求就會直接打到我們的數據庫上,顯然這樣不符合我們對緩存的要求,這樣大量的請求直接打到我們的數據庫上也有可能把我們的數據庫給打掛。

對應的解決辦法就是緩存空數據,對應一個查詢關係對(key, Null),其中key爲對應查詢值不存在的查詢鍵,Null爲我們包裝的空對象。這樣調用端查詢的時候我們會直接把空對象返回給他,而不需要去數據庫進行一次查詢。

但是假如調用端惡意調用我們的查詢,先後來查詢key1、key2、key3、...,這樣即使我們緩存了空對象,也會使得緩存中大量垃圾數據,造成大量正常請求受到影響,緩存命中率大大降低。

這時候就需要在查詢緩存前進行一次查詢鍵的存在性判斷,典型的解決方法就是使用布隆過濾器或者布穀鳥過濾器來利用較小空間進行數據的存在性判斷,雖然有一定的誤判率,會將不存在的數據判斷爲存在,但是這也大大攔截了查詢鍵不存在的請求。

3.1.4.2、大量緩存值突然失效

通常我們在寫緩存的時候會設定一個緩存的過期時間,這個過期時間一般是一個固定的時間長度,所以當寫緩存的時間是集中在某個時刻時,會導致將來的某個時刻緩存集體失效,大量請求打在數據庫上,大大增加了數據庫的壓力。

解決這個問題的第一種方法是每個緩存的失效時間在原本失效時間的基礎上增加一個隨機的時間段,這樣當寫緩存的時間集中在某個時刻時,將來緩存失效的時間會錯開,而不是集體一起失效。

當然上一種方法指標不治本,最根本的方法還是需要限制住緩存集體失效時對大量請求對數據庫的壓力,通過併發控制保證緩存失效期間只能有一個線程能打到數據庫,其他線程阻塞住,直到訪問數據庫的線程返回數據並且寫入緩存

3.1.4.3、命中率驟降

命中率驟降一般是新上了一個功能,或者業務上有大變化。比如新上一個功能大量用到了緩存,這些緩存數據之前都不存在於緩存中,或者比如某個促銷活動對某種緩存中沒有數據的冷門商品進行了促銷,導致這種商品被進行了大量查詢,使得緩存命中率下降。這就需要上線前對新上線的功能,或者在業務變動前對這種大的業務流量變動進行評估,如果這些變動會導致緩存命中率驟降,則需要進行緩存預熱,模擬這些流量,使得緩存中存放着我們將來需要讀取到的數據,從而讓業務變動或者系統功能變動不會導致緩存命中率驟降,給數據庫帶來巨大的壓力。

3.1.4.4、分佈式緩存響應時間飆高

前面提到了有種緩存是分佈式緩存,緩存服務由專門的集羣提供,其中涉及到網絡傳輸,這就使得這個服務的響應時間是不穩定的,可能非常長。而且調用端cpu資源不足、Full GC等原因有可能導致緩存服務線程得不到調度,也可能使得緩存服務的結果遲遲無法得到。因此分佈式緩存需要設置超時時間,當執行時間超過超時時間時快速失敗,返回錯誤。而當頻繁超時時說明緩存服務出問題了,需要降級分佈式緩存,使用其他能承受該流量的存儲,比如本地緩存,避免分佈式緩存的響應時間飆高導致整個服務被拖垮。

3.1.4、緩存總結

不得不說緩存的確是解決大流量場景下的“銀彈”,但是不是在系統的各個模塊都應該使用緩存來堆砌性能。新增的高速緩存空間意味着成本的加大、意味着系統更加複雜、意味着緩存相關的代碼可能侵入到正常的業務邏輯,數據庫和緩存兩份數據也意味着數據可能會出現數據的不一致性。某些業務場景不是讀數據的量遠大於寫數據的量,或者使用數據庫索引完全可以達成流量的要求,這時候就沒必要使用緩存來增加自己出錯的機會。

3.2、遠程過程調用

3.2.1、什麼是遠程過程調用

前面我們提到了機器的資源比如CPU、IO、內存、網絡等是有限的,但是我們的業務是需要不停擴展的,單機資源總有一天會支撐不了這些業務,好在我們可以讓服務調用不發生在本機,而是去調用某臺遠程主機,然後獲取返回結果,這就是遠程過程調用(Remote Procedure Call, RPC),其實就是爲了解決主機之間的遠程通信問題。

RPC聽起來和HTTP協議做的事很像,事實上RPC可以用HTTP來實現,利用HTTP那一套來進行協議編碼解碼、序列化/反序列化、數據傳輸,然後再加上一些RPC的東西,比如服務註冊、服務發現、負載均衡等。當然如果不想像如圖的HTTP的頭部信息一樣這樣複雜,或者想要自定義協議的話,最好還是基於TCP協議來進行實現:

RPC的出現不僅使得系統能突破單機的資源限制,而且由於需要進行服務拆分,也能使得各個服務模塊之間松耦合,並且也不會因爲其中一個模塊掛了而引起所有服務的雪崩。

3.2.2、遠程過程調用怎麼實現的

調用端調用服務提供端提供的服務時,顯然服務提供端需要知道調用端需要調用的服務、調用涉及到服務的名稱、調用的參數,除此以外調用端還需要知曉自己調用的服務哪臺或者哪些(當服務提供端是一個集羣的時候)機器能提供。服務調用端需要和目標主機建立鏈接,然後傳輸RPC參數,比如服務名、方法名、調用參數。當然傳輸過程需要涉及RPC參數序列化爲二進制字節流,之後才能進行網絡傳輸。待服務提供端拿到這些RPC參數的二進制字節流時,需要反序列化爲我們能使用的對象,然後在本地的服務調用線程池中反射調用。調用完成之後服務提供端將結果序列化爲二進制字節流,網絡傳輸回調用端,最後調用端將結果的二進制字節流反序列化爲對象進行使用。由於建立連接是一個開銷比較大的操作,我們一般會把連接對象進行池化,池化連接之後爲了保證連接的可用還需要進行心跳驗活,而且網絡傳輸還涉及到多種io方式的考量,調用方式也可分同步、異步、Future調用等,此處不細講了。

一次調用流程已經結束,但是調用端怎麼知道自己所需服務哪臺或者哪些機器能提供呢?這就涉及到服務註冊服務發現。服務提供端發佈一個服務時會在一個註冊中心註冊自己能提供的服務和自己的IP地址,並且在本機開闢線程池來提供該服務的調用,這就是服務註冊。而調用端需要調用時就可以去註冊中心查詢自己所需的服務哪些IP地址的機器能夠提供,這就是服務發現。如果一個服務由多臺機器提供,那還需要從這多臺機器中選出一臺機器,就涉及到負載均衡選址

3.2.2、遠程過程調用中如何進行模塊拆分

在涉及RPC的時候,哪些服務應該放在哪些模塊,這就是系統模塊如何拆分的問題。我們可以想一下拆分的目的是什麼,是爲了讓模塊的職責單一,讓模塊之間調用關係不會錯綜負責,對模塊直接進行松耦合。比如假如對於營銷的權益發放來說,最初只有一個系統,這個系統負責對用戶暴露服務,處理定製業務邏輯,也負責處理包括髮放條件控制、庫存控制的權益發放邏輯,也負責管理權益,顯然就是一個耦合在一起的系統。當然如果定製活動少、權益發放邏輯簡單、權益管理也不復雜,那這樣問題也不大。但是如果之後會有無數個權益不斷加入到我們的系統來對接,而且活動一個接一個,定製的活動邏輯不停在變,發放邏輯也越來越複雜,那整個系統肯定會變得越來越臃腫,越來越難以維護。因此這就需要對系統進行拆分,拆分成不同自模塊,模塊之間RPC,從而避免單機的資源限制,並且對系統松耦合。

拆分過程我理解需要考慮的有模塊之間的調用層級是否複雜、模塊之間的職責是否單一、模塊協同是否符合團隊的結構。就拿上面的例子,我們當前是把定製活動邏輯作爲了一個業務系統模塊,核心發放邏輯作爲一個平臺系統模塊,權益管理作爲一個平臺系統模塊,調用關係爲定製活動業務系統模塊調用核心發放邏輯平臺系統模塊,核心發放邏輯平臺系統模塊調用權益管理平臺系統模塊。這樣保證每個調用層級簡單,且不會出現雙向依賴的情況,模塊之間職責儘量單一,各個模塊由不同同學負責。當然這只是我們當前量級的業務的拆分方式,如果業務繼續發展,各個模塊又有許多不同的大分支,那當前這些模塊還需要細化。因此我理解模塊拆分是一個業務量級、團隊規模與服務粒度之間的一個平衡

3.2.3、遠程過程調用的注意點

3.2.3.1、機器一個服務響應飆高導致該機器所有服務不可用

需要注意的是這裏需要不同服務開闢不同的線程池,而不是共用一個大線程池。因爲不同的服務的重要性、穩定性、響應時間是不一樣的。比如對於一個機器能提供一個很快很重要的服務和一個很慢卻沒那麼重要的服務,如果共用一個線程池則會導致這個線程池中被又慢又不重要的那個服務給打滿,這臺機器就不能提供那個重要服務的調用了。因此不同服務對應的線程池一定要進行隔離

3.2.3.2、服務提供端服務響應時間飆高,導致調用端自己的服務不可用

調用端調用的服務是在遠程的,有一定的不穩定性,沒法保證RPC響應時間不會因爲網絡問題、服務提供端的機器狀況等因素而飆高。因此一定要設置RPC的超時時間,否則可能會導致滿請求把自己的服務拖垮,這個超時時間視業務敏感度決定。

3.2.3.3、遠程過程調用重試導致出現髒數據

RPC是有可能超時的,而且也會出現某臺服務提供端的機器不可用的情況,因此一般RPC框架會進行重試。可是超時不以爲着執行失敗,可能只是執行時間超出了超時時間,但是它執行成功了。而且對於用戶來說他看到自己的請求失敗了往往也會進行重試,這就要求我們對RPC進行冪等。冪等就是對於同樣的請求,執行一次和執行同次對系統產生的影響是一樣的。換句話說就是對於請求x,請求處理函數f,請求對系統的影響f(x),f(x) = f(f(x)) = f(f(f(x))) = ...恆成立。

顯然我們的讀請求是自帶冪等的,但是寫操作如果不做特殊處理的話多次請求和一次請求對系統造成的影響是不一樣的。寫操作分爲更新和新增,常見的冪等方式有:

全局唯一ID:這種方式會根據請求生成一個全局唯一ID,這個全局唯一ID可以通過分佈式緩存來做存在性判斷,如果這個全局唯一ID存在說明這個請求處理過了,直接快速返回。或者使用一個去重數據庫表,以全局唯一ID爲唯一索引,將寫去重表和處理請求的操作放在同一個數據庫事務中。這樣如果該請求處理過了就會因爲觸發了去重表的唯一索引而無法再被處理。

update的where語句:比如我們需要通過sql進行update操作,原本的sql是update table set status = 1 where id = 1,我們可以將sql改寫爲update table set status = 1 where id = 1 and status = 0,由於第一次執行該sql之後id爲1的status就變爲1了,之後再執行這個sql就不會再去修改id爲1的status。這樣也可以實現冪等。

3.2.3.4、服務提供端被超出自己能承受的大量請求打垮

服務提供端提供出去服務之後調用端就可以調用了,但是調用的量是由調用端決定的,因此可能會出現調用端調用量太大,超出服務提供端所能承受範圍的情況。這時候就需要限流,對系統的吞吐量進行限制,超出限制的請求直接拒絕服務,以此來保護我們的系統。具體的限流方法和細節會在之後的小節闡述。

3.3、分庫分表

3.3.1、什麼是分庫分表

我們可以通過緩存來在讀多寫少的業務場景避免讓大量請求打到數據庫,但是某些場景是不適合使用緩存的,比如用戶的權益領取記錄表,勢必會有大量請求落在數據庫,而且數據庫的數據肯定會越來越多,隨着數據量的增大,數據庫的讀寫會越來越慢。爲了解決這個問題一種常見的解決方法就是分庫分表(sharding),分庫分表可以分爲水平拆分垂直拆分

水平拆分:顧名思義水平拆分就是對一個大表按照一定的拆分算法進行橫向的拆分,拆分到同一個數據的不同表,甚至不同的數據庫中,拆到不同的表可以避免同一個數據表數據太多導致讀寫速度太慢,拆到不同的數據庫不但可以加快讀寫速度,還可以隔離開數據庫的資源,比如CPU、IO、內存,但是分庫會導致數據庫數據庫事務不再支持。拆分過程如圖:

圖中我們使用user_id%2作爲我們的分庫分表算法,將數據打散到兩個數據表中。這種簡單的取模方式是很常見的分庫分表算法,在這裏只要保證user_id這個分表鍵是隨機的,數據就可以均勻地打散在不同的數據表中。因爲我們的分表鍵是user_id,我們做sql操作的時候where條件一定要帶上這個分表鍵,否則只能所有分表都查詢一遍。

垂直拆分:垂直拆分就是根據業務維度將寬表進行拆解,有點像上面提到RPC服務拆解,使表更易維護。拆分過程如圖:

圖中假設用戶的信息包含一些基礎信息和工作信息,且這兩個信息對應不同的服務,有不同的團隊維護,我們就需要將原來的大表按照這兩個業務維度進行拆分。

3.3.2、分庫分錶帶來的問題和解決方案

分庫分表分的時候確實爽了,可是這會使得之前我們單表下很簡單的sql操作變得麻煩起來。但是我們需要知道的是我們的所有操作在分庫分表之後需要儘量下推到數據庫層面進行,這樣能減少應用內存中的額外計算和開銷。

3.3.2.1、分庫分表之後基礎查詢

原來我們是一個表的時候只需要查詢單表即可,但是我們做了拆分之後,原先的基礎查詢要發生變化了。

對於垂直拆分後的查詢來說,如果拆分之後的表在同一個數據庫,我們可以直接將拆分後的表進行join來查詢。如果不在同一個庫則可以在內存中進行join。

對於水平拆分的查詢來說,我們的數據拆分成多個表了,意味這我們要根據分庫分表算法路由到多個表中查詢數據,然後對所有數據做union。比如原來的sql是select * from table where user_id in (10001, 10002, 10003, 10004),表被水平拆分成了table1和table2,那這個sql需要改寫爲(select * from table_0 where user_id in (10000, 10002)) union (select * from table_1 where user_id in (10001, 10003)),其中10000和10002這兩個個user_id數據在table_0,10001和10003這兩個user_id數據在table_1,如圖:

3.3.2.2、水平拆分之後的join

水平拆分前的join操作很簡單,因爲全量數據都在一個表裏,但是做了水平拆分之後的join就不一樣了。這樣有幾種情況需要考慮:

a、join的on條件明確了分表鍵的值,且一定在同一個表的情況:比如原先sql是select t1.user_id, t1.age from table as t1 join table as t2 on t1.user_id = 10001 and t2.user_id = 10001。這種sql明確了join關係一定在同一個表,直接用那一個表join即可。

b、join的on條件沒明確分表鍵的值,但是join關係一定發生在同一個表的情況:比如select t1.user, t1.age from table as t1 join table as t2 on t1.user_id = t2.user_id。這種sql雖然沒有指明分表鍵,但是可以看出只有同一個表才能進行join,這種情況對所有拆分後的表執行該join操作,然後進行union即可。

c、join的表很小,且很少改動卻又經常join的表的情況:比如原先sql是select *from table join config on XXX,其中config是一個很小而且很少改動的表。我們進行水平拆分前,全量數據一定是在同一個數據庫的,想要join當前數據庫的一個其他表很簡單。可是拆分之後表可能會打散到不同的數據庫,但是我們再去join一個數據庫的表就比較麻煩了。但是對於很小,且很少改動,又經常需要join的表,可以使用小表廣播。這些小表會被複制到各個分庫,保證join的正常進行。

d、不再可下推到數據庫執行的情況:比如原先sql是select * from table as t1 join table as t2 on t1.age = t2.weight。這種sql我們無法讓拆分後的數據庫來執行,因爲已經跨表甚至垮庫了,因此內存計算在所難免。常見的內存join計算的方式有三種:

sort merge join。對於上面的那個sql,我們可以先對一份數據按照age排序,再對一份數據按照weight排序,這樣判斷age等於weight的行直接做歸併即可,不需要每次對數據從頭掃到尾。而排序操作和歸併操作都不需要完全在內存中進行,這也使得這種方法允許數據特別大的情況。這種方式雖然需要排序,但是如果數據本來就已經排序好了的時候這種方法就可以發揮很好的作用。

hash join。hash join一般會使用join關係中較小的那個表的join鍵建立一個hash表,然後用另一個表的join鍵來進行關聯,找出所匹配的行。同樣的,如果建立的hash表太大,無法一次放入內存,則需要進行partition。這種方法顯然適合在兩個表數據量相差特別大的情況,使用小表來建立hash表。

index nested loop。對於兩個表,這種連接方式遍歷其中一個表的數據,然後去根據這個表的數據的關聯鍵去查詢另一個表的數據,以此來實現join。這種方式適合遍歷的表比較小,並且被查詢的表在使用關聯鍵查詢的時候索引工作良好的情況

3.3.2.3、外鍵約束

顯然拆分到多個庫之後我們的外鍵約束會受到影響。外鍵的使用可以減少應用的開發量,但是外鍵有一定的開銷,而且鎖主鍵表時會導致外鍵表也被鎖住,因此我們一般不使用外鍵,而是在應用層面來做這個判斷約束。另一方面我們進行數據拆分之後是希望數據在同一個數據庫內是內聚的,而不會需要一個數據庫的數據受到另一個數據庫的數據的約束。比如常見的,我們對用戶維度的數據進行水平拆分的時候,我們總是希望拆分之後同一個用戶的數據落在同一個庫裏,這個可以通過分庫分表時模運算user_id%n中n的值固定來保證。

3.3.2.3、數據庫唯一ID的生成

在單表的時候生成唯一ID很簡單,直接讓數據庫表ID自增即可,可是現在我們將數據庫表進行了拆分,ID自增的方式用不了了。需要注意的是我們需要這個ID不但滿足唯一性,還要滿足一定的有序性,這樣對數據庫的索引比較友好,而不會使得索引中的B+樹頻繁節點分裂。生成唯一、且基本有序的方法主要有:

不同分表設置不同的起始ID。比如存在4個分表table1、table2、table3、table4,可以把他們的ID初始值分別設置爲1,2,3,4,然後讓他們的ID步長設置爲4,這樣他們的ID不會重複,且在各個數據庫中的ID可以滿足有序。這種方法比較簡單,而且利用了數據庫的自增ID,缺點是擴容的時候需要重新設置步長,維護成本較高。而且這種方法每獲取一次ID都要讀一次數據庫。

snowflake算法。這種方法能按照時間有序生成ID,效率也比較高,但是這種方法生成ID會依賴系統的時鐘,一旦系統時鐘回撥則有可能導致ID亂序甚至重複。而且在分佈式環境中,時鐘很難保證嚴格一致,這樣會導致ID不是嚴格有序。

每臺機器取一段id號,然後在內存中進行分配使用。這是我們當前使用比較多的一種方式,如圖:

用專門的一張表來進行序列號段分配,分配的序列號段長度爲步長。比如假如步長爲1000,應用機器的序列號段用完之後就會將這張表中value新增1000,來表示自己拿到了當前value值往後1000個序列號。拿到這些序列號之後應用機器就可以在內存中自己將這些序列號分配給對應的數據行,然後寫存儲數據庫。這個步長視ID使用速度決定,步長太長會導致應用機器重啓時大量步長被浪費,步長太短又會導致我們的序列號段分配表的讀寫壓力較大。

3.3.2.3、排序、group by、聚集函數、分頁

顯然,拆分之後我們的group by、聚集函數、排序、分頁都會變得和原來的單表不一樣。而且由於數據量可能很大,這裏的這些方法都要保證無需將全量數據加載入內存,當然這裏還是不可避免地會涉及到大量內存計算。

排序:通常拆分之後我們的排序方法爲merge sort,拆分之後的多個表分別進行排序,然後進行多路歸併。

group by:至於group by操作,可以先將數據進行排序,排序之後能group by的數據一定是相鄰的數據,因此排序之後簡單遍歷排序後的數據即可做到group by。

聚集函數:聚集函數有count、sum、avg、max、min,原先的聚集函數會涉及到一定的改寫,比如原來對單表的count會變成對拆分後所有表取count,再求和。原先單表sum會變成對拆分後所有表取sum,再求和。原先單表avg變成對拆分後的所有表求總sum,求總count,然後計算sum/count。而對於max和min,需要計算所有拆分表的max和min,然後彙總出對應最值。

分頁:分頁又分爲非排序分頁select * from table limit a, b和排序分頁select * from table order by age limit a, b。對於非排序分頁只需要將分頁所需數據拆分到不同分表中,然後將得到的數據等比例或者等步長混合。但是對於排序分頁則需要在不同的分表中執行select * from table order by age limit 0, (a+b),取出各個表中前(a+b)大小個數據,然後再在內存中根據各個分表的前(a+b)大小個數據求出總排名a到a+b的數據。可以看出這個排序邏輯是比較複雜的,因此sql中的a不應該過大。

3.3.2、分庫分表總結

可以看到分庫分表能將數據拆到不同表甚至不同數據庫,減少讀寫的響應和對單庫上的資源搶佔,但是這也會帶來很多問題,原來一個sql能解決的問題會變得很複雜。不過好在有一些分庫分表中間件,比如公司的TDDL,可以讓用戶感知不到分庫分表邏輯,正常當成單表使用就行。而TDDL內部會根據用戶的sql和分庫分表情況進行sql解析和sql改寫,儘可能將數據操作下推到數據庫執行,以減少不必要的內存開銷和額外計算。有必要時,TDDL也會在中間層進行數據彙總計算,將得到的數據集結果返回。

除此以外,我們在分庫分表的時候還應該關心拆分之後的單庫的數據是否是內聚的,比如一個用戶在該系統的內聚的一些數據,在進行拆分後是否仍然還是在一個庫的。以及還需要評估數據量是否真的大到了需要分庫分表的地步,是否可以通過刪除無用歷史數據或者添加索引來解決即可。

3.4、隊列

3.4.1、什麼是隊列

隊列(Queue)是一種數據結構,滿足數據先入先出(First In First Out, FIFO)的條件。這種看起來不起眼的數據結構有時候卻能發揮很好的效果。

在系統開發中對請求的處理很多時候是同步、串行進行的,比如對於發獎流程發獎條件判斷->緩存中庫存扣減->緩存庫存扣減同步到數據庫->獎品發放,有一個同步串行依次執行的過程。但是可能事實上我們不需要等待着緩存庫存同步到數據庫結束就可以去處理獎品發放流程,只需要保證緩存庫存扣減同步到數據庫在未來的什麼時候結束掉即可。我們這時候就可以庫存同步將流程的處理請求放入隊列,處理這個流程的一端空閒時可以來隊列取出請求,然後進行處理。這樣我們就無需按照原先的關係進行調用,只需要進行發獎流程發獎條件判斷->緩存中庫存扣減->獎品發放的調用,響應時間也會減少,而庫存同步保證會在未來的某個時刻完成即可。

事實上隊列的作用有:

異步化。將非核心流程異步化可以提高請求的響應時間,因爲我們可以從隊列中批量取請求,這還能使得這些非核心流程能夠集中在一起批量處理。

松耦合。比如對於營銷平臺系統會進行權益發放,某些業務系統希望權益發放出去之後進行某些操作。但是顯然作爲一個平臺方不可能去反過來調用業務系統,因爲調用關係不合理,而且將來還會有更多的業務系統需要這樣的操作。這時候就可以將權益發放成功的數據信息,我們稱之爲消息(Message),放入隊列中。而業務系統就可以取出消息,進行後續的處理。這樣就成功實現了松耦合。

消峯填谷。很多時候流量不是均衡的,某些時段會出現一個峯值,但是我們的系統的無法承載下這個峯值。假如業務允許這個流量處理有一定延時,我們就可以將這些請求放入隊列,請求處理端按照自己的處理能力從隊列中取請求進行處理。這樣就做到了流量的消峯填谷。

3.4.2、隊列如何實現

當前市面上是有很多隊列中間件的,比如metaq、notify、kafka,我們可以簡單設想下假如我們來實現一個簡單的隊列,需要考慮些什麼。首先先來明確一下這個隊列可能有些什麼核心功能。根據前面我們對隊列作用的分析,這個隊列核心功能不外乎能暫存消息發送端發來的數據,並且能在合適的時間點讓消息接收端來把消息取走,或者把消息發送給消息接收端。爲了將這個隊列和我們的系統松耦合,這個隊列相關的服務一般是獨立出來部署在遠程機器上。

3.4.2.1、消息存儲

我們的隊列是需要暫存發送端發來的數據的,可是這些消息數據存放在哪呢?常見地,我們可以會想到文件和數據庫。顯然對於數據庫我們可以開箱即用,而且數據庫可靠性也比較好。而如果對性能要求比較高,而且有某些定製要求,也可以使用文件系統自建索引來進行數據存儲。

3.4.2.1、消息發送

ack機制。我們的隊列存儲發送端發來的數據的最終目的是爲了將消息發出去,這裏就會涉及到主機之間的遠程通信,其實就是我們上面說的RPC。在這裏的隊列設計時,消息從隊列接收到投遞出去至少會有兩種RPC,一次是我們的隊列接收了消息發送端發來的消息數據,一次是我們的隊列和消息接收端的遠程通信,讓消息接收端拿到對應消息數據。但是這種簡單的兩次RPC沒法保證消息接收端一定接收到了隊列的消息,因爲網絡和機器狀態都是不穩定的,可能由於網絡問題消息接收端接收消息失敗了,有可能消息接收端因爲暫時不方便處理或者業務處理失敗了。這些情況相當於消息發送失敗了,因此我們需要消息接收端做一個消息的確認收到,如果這個消息在一段時間內沒有被消息接收端確認,則需要過一段時間重傳這個消息,這個確認我們稱之爲ack(Acknowledge, ack)

消息重複問題。我們引入了ack之後,由於有ack的存在,只要做了消息在隊列中的持久化,消息就不會丟失了。可是有些時候網絡抖動消息接收端接收到了消息,卻沒有把ack發出來,於是隊列認爲消息接收端沒有接收到消息,便進行了重傳,因此不可避免地出現了消息重複。這種消息重複是因爲ack機制導致的,不可避免的現象。如果要求完全不出現重複也可以,但是就會出現消息丟失的情況。這種消息重複根本原因還是因爲網絡抖動導致的消息接收端ack沒有發出,爲了解決這個問題可以在接收端進行消息去重,像處理RPC冪等那樣使用唯一ID去重表或者保證業務邏輯的本身的冪等性。

消息順序問題。消息發送端和消息接收端一般都是一個集羣,如何保證消息接收端收到消息能夠順序消費呢?我們可以對消息進行標號,消息接收端收到消息之後按照消息標號順序進行消費。除此之外,我們還可以讓顯然可以讓消息發送端和消息接收端都單線程發送單線程接收,但是對性能影響非常大。爲了讓效率和順序保持均衡,我們可以讓需要滿足順序的消息進入一個隊列,其他消息進入其他隊列。

消息傳輸模式。我們需要讓隊列中的數據傳輸到消息消費端,可是應該讓消息消費端從隊列中拉取還是讓隊列向服務端主動推送數據呢?這就涉及到消息傳輸模式。

        pull模式。消息接收端可以主動從隊列中拉數據,這就是pull模式。pull模式由消息消費端直接從隊列拉取數據,消費者端可以根據自己的負載來決定拉取數據的時機,但是這會導致消費者端處理消息會有一定延時。除此以外,pull模式可以在從隊列拉取消息的時候儘可能拉取足夠多的消息來進行消息批量處理

        push模式。隊列直接推送消息到消息消費端,這就是push模式。這使得消息處理實時性能夠很好,但是隊列無法知道消息消費端的負載情況和消息處理能力,而且對於不同消費能力的機器顯然推送相同量的消息是不合理的。普通pull模式在隊列沒消息時也會去拉取數據,會導致一些無用拉取的開銷。

        長輪訓模式。長輪訓模式是一種特殊的pull模式,它允許消息消費端在隊列中沒有消息的時候阻塞住,等待消息到來,在消息到來或者超時之後阻塞結束。但是如果消息一直都有,相當於消息消費端一直在對隊列執行間隔很短的拉取動作,爲了減少網絡IO的開銷,可以在pull的時候加一個buffer,buffer滿了或者超時了返回給消息消費端。而且由於buffer容量是已知的,隊列也可以根據buffer容量來評估消息消費端的負載。

事務消息。(未完,待續)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章