看完這篇高性能數據庫集群架構文章,你能比大多數人知道得更多!

  • 2019 年 10 月 6 日
  • 筆記

雖然近十年來各種存儲技術飛速發展,但關係數據庫由於其 ACID 的特性和功能強大的 SQL 查詢,目前還是各種業務系統中關鍵和核心的存儲系統,很多場景下高性能的設計最核心的部分就是關係數據庫的設計。

不管是為了滿足業務發展的需要,還是為了提升自己的競爭力,關係數據庫廠商(Oracle、DB2、MySQL 等)在優化和提升單個數據庫服務器的性能方面也做了非常多的技術優化和改進。但業務發展速度和數據增長速度,遠遠超出數據庫廠商的優化速度,尤其是互聯網業務興起之後,海量用戶加上海量數據的特點,單個數據庫服務器已經難以滿足業務需要,必須考慮數據庫集群的方式來提升性能。

高性能數據庫集群的第一種方式是「讀寫分離」,其本質是將訪問壓力分散到集群中的多個節點,但是沒有分散存儲壓力;第二種方式是「分庫分表」,既可以分散訪問壓力,又可以分散存儲壓力。

讀寫分離

讀寫分離原理

讀寫分離的基本原理是將數據庫讀寫操作分散到不同的節點上,下面是其基本架構圖。

讀寫分離的基本實現是:

  • 數據庫服務器搭建主從集群,一主一從、一主多從都可以。
  • 數據庫主機負責讀寫操作,從機只負責讀操作。
  • 數據庫主機通過複製將數據同步到從機,每台數據庫服務器都存儲了所有的業務數據。
  • 業務服務器將寫操作發給數據庫主機,將讀操作發給數據庫從機。

需要注意的是,這裡用的是「主從集群」,而不是「主備集群」。「從機」的「從」可以理解為「僕從」,僕從是要幫主人幹活的,「從機」是需要提供讀數據的功能的;而「備機」一般被認為僅僅提供備份功能,不提供訪問功能。所以使用「主從」還是「主備」,是要看場景的,這兩個詞並不是完全等同的。

讀寫分離的實現邏輯並不複雜,但有兩個細節點將引入設計複雜度:主從複製延遲分配機制

複製延遲

以 MySQL 為例,主從複製延遲可能達到 1 秒,如果有大量數據同步,延遲 1 分鐘也是有可能的。主從複製延遲會帶來一個問題:如果業務服務器將數據寫入到數據庫主服務器後立刻(1 秒內)進行讀取,此時讀操作訪問的是從機,主機還沒有將數據複製過來,到從機讀取數據是讀不到最新數據的,業務上就可能出現問題。例如,用戶剛註冊完後立刻登錄,業務服務器會提示他「你還沒有註冊」,而用戶明明剛才已經註冊成功了。

解決主從複製延遲有幾種常見的方法:

  1. 寫操作後的讀操作指定發給數據庫主服務器 例如,註冊賬號完成後,登錄時讀取賬號的讀操作也發給數據庫主服務器。這種方式和業務強綁定,對業務的侵入和影響較大,如果哪個新來的程序員不知道這樣寫代碼,就會導致一個 bug。
  2. 讀從機失敗後再讀一次主機 這就是通常所說的「二次讀取」,二次讀取和業務無綁定,只需要對底層數據庫訪問的 API 進行封裝即可,實現代價較小,不足之處在於如果有很多二次讀取,將大大增加主機的讀操作壓力。例如,黑客暴力破解賬號,會導致大量的二次讀取操作,主機可能頂不住讀操作的壓力從而崩潰。
  3. 關鍵業務讀寫操作全部指向主機,非關鍵業務採用讀寫分離 例如,對於一個用戶管理系統來說,註冊 + 登錄的業務讀寫操作全部訪問主機,用戶的介紹、愛好、等級等業務,可以採用讀寫分離,因為即使用戶改了自己的自我介紹,在查詢時卻看到了自我介紹還是舊的,業務影響與不能登錄相比就小很多,還可以忍受。

分配機制

將讀寫操作區分開來,然後訪問不同的數據庫服務器,一般有兩種方式:程序代碼封裝中間件封裝

程序代碼封裝

程序代碼封裝指在代碼中抽象一個數據訪問層(所以有的文章也稱這種方式為「中間層封裝」),實現讀寫操作分離和數據庫服務器連接的管理。例如,基於 Hibernate 進行簡單封裝,就可以實現讀寫分離,基本架構是:

程序代碼封裝的方式具備幾個特點:

  • 實現簡單,而且可以根據業務做較多定製化的功能。
  • 每個編程語言都需要自己實現一次,無法通用,如果一個業務包含多個編程語言寫的多個子系統,則重複開發的工作量比較大。
  • 故障情況下,如果主從發生切換,則可能需要所有系統都修改配置並重啟。

目前開源的實現方案中,淘寶的 TDDL(Taobao Distributed Data Layer,外號: 頭都大了)是比較有名的。它是一個通用數據訪問層,所有功能封裝在 jar 包中提供給業務代碼調用。其基本原理是一個基於集中式配置的 jdbc datasource 實現,具有主備、讀寫分離、動態數據庫配置等功能,基本架構是:

中間件封裝

中間件封裝指的是獨立一套系統出來,實現讀寫操作分離和數據庫服務器連接的管理。中間件對業務服務器提供 SQL 兼容的協議,業務服務器無須自己進行讀寫分離。對於業務服務器來說,訪問中間件和訪問數據庫沒有區別,事實上在業務服務器看來,中間件就是一個數據庫服務器。其基本架構是:

數據庫中間件的方式具備的特點是:

  • 能夠支持多種編程語言,因為數據庫中間件對業務服務器提供的是標準 SQL 接口。
  • 數據庫中間件要支持完整的 SQL 語法和數據庫服務器的協議(例如,MySQL 客戶端和服務器的連接協議),實現比較複雜,細節特別多,很容易出現 bug,需要較長的時間才能穩定。
  • 數據庫中間件自己不執行真正的讀寫操作,但所有的數據庫操作請求都要經過中間件,中間件的性能要求也很高。
  • 數據庫主從切換對業務服務器無感知,數據庫中間件可以探測數據庫服務器的主從狀態。例如,向某個測試表寫入一條數據,成功的就是主機,失敗的就是從機。

由於數據庫中間件的複雜度要比程序代碼封裝高出一個數量級,一般情況下建議採用程序語言封裝的方式,或者使用成熟的開源數據庫中間件。如果是大公司,可以投入人力去實現數據庫中間件,因為這個系統一旦做好,接入的業務系統越多,節省的程序開發投入就越多,價值也越大。

目前的開源數據庫中間件方案中,MySQL 官方先是提供了 MySQL Proxy,但 MySQL Proxy 一直沒有正式 GA,現在 MySQL 官方推薦 MySQL Router。MySQL Router 的主要功能有讀寫分離、故障自動切換、負載均衡、連接池等,其基本架構如下:

奇虎 360 公司也開源了自己的數據庫中間件 Atlas,Atlas 是基於 MySQL Proxy 實現的,基本架構如下:

Atlas 是一個位於應用程序與 MySQL 之間中間件。在後端 DB 看來,Atlas 相當於連接它的客戶端,在前端應用看來,Atlas 相當於一個 DB。Atlas 作為服務端與應用程序通信,它實現了 MySQL 的客戶端和服務端協議,同時作為客戶端與 MySQL 通信。它對應用程序屏蔽了 DB 的細節,同時為了降低 MySQL 負擔,它還維護了連接池。

分庫分表

讀寫分離分散了數據庫讀寫操作的壓力,但沒有分散存儲壓力,當數據量達到千萬甚至上億條的時候,單台數據庫服務器的存儲能力會成為系統的瓶頸,主要體現在這幾個方面:

  • 數據量太大,讀寫的性能會下降,即使有索引,索引也會變得很大,性能同樣會下降。
  • 數據文件會變得很大,數據庫備份和恢復需要耗費很長時間。
  • 數據文件越大,極端情況下丟失數據的風險越高(例如,機房火災導致數據庫主備機都發生故障)。

基於上述原因,單個數據庫服務器存儲的數據量不能太大,需要控制在一定的範圍內。為了滿足業務數據存儲的需求,就需要將存儲分散到多台數據庫服務器上。

「分庫分表」是一種常見的分散存儲方法,其中包括「分庫」和「分表」兩大類。

業務分庫

業務分庫指的是按照業務模塊將數據分散到不同的數據庫服務器。例如,一個簡單的電商網站,包括用戶、商品、訂單三個業務模塊,我們可以將用戶數據、商品數據、訂單數據分開放到三台不同的數據庫服務器上,而不是將所有數據都放在一台數據庫服務器上。

雖然業務分庫能夠分散存儲和訪問壓力,但同時也帶來了新的問題。

  1. join 操作問題 業務分庫後,原本在同一個數據庫中的表分散到不同數據庫中,導致無法使用 SQL 的 join 查詢。 例如:「查詢購買了化妝品的用戶中女性用戶的列表」這個功能,雖然訂單數據中有用戶的 ID 信息,但是用戶的性別數據在用戶數據庫中,如果在同一個庫中,簡單的 join 查詢就能完成;但現在數據分散在兩個不同的數據庫中,無法做 join 查詢,只能採取先從訂單數據庫中查詢購買了化妝品的用戶 ID 列表,然後再到用戶數據庫中查詢這批用戶 ID 中的女性用戶列表,這樣實現就比簡單的 join 查詢要複雜一些。
  2. 事務問題 原本在同一個數據庫中不同的表可以在同一個事務中修改,業務分庫後,表分散到不同的數據庫中,無法通過事務統一修改。雖然數據庫廠商提供了一些分佈式事務的解決方案(例如,MySQL 的 XA),但性能實在太低,與高性能存儲的目標是相違背的。 例如,用戶下訂單的時候需要扣商品庫存,如果訂單數據和商品數據在同一個數據庫中,我們可以使用事務來保證扣減商品庫存和生成訂單的操作要麼都成功要麼都失敗,但分庫後就無法使用數據庫事務了,需要業務程序自己來模擬實現事務的功能。例如,先扣商品庫存,扣成功後生成訂單,如果因為訂單數據庫異常導致生成訂單失敗,業務程序又需要將商品庫存加上;而如果因為業務程序自己異常導致生成訂單失敗,則商品庫存就無法恢復了,需要人工通過日誌等方式來手工修復庫存異常。
  3. 成本問題 業務分庫同時也帶來了成本的代價,本來 1 台服務器搞定的事情,現在要 3 台,如果考慮備份,那就是 2 台變成了 6 台。

基於上述原因,對於小公司初創業務,並不建議一開始就這樣拆分,主要有幾個原因:

  • 初創業務存在很大的不確定性,業務不一定能發展起來,業務開始的時候並沒有真正的存儲和訪問壓力,業務分庫並不能為業務帶來價值。
  • 業務分庫後,表之間的 join 查詢、數據庫事務無法簡單實現了。
  • 業務分庫後,因為不同的數據要讀寫不同的數據庫,代碼中需要增加根據數據類型映射到不同數據庫的邏輯,增加了工作量。而業務初創期間最重要的是快速實現、快速驗證,業務分庫會拖慢業務節奏。

有的架構師可能會想:如果業務真的發展很快,豈不是很快就又要進行業務分庫了?那為何不一開始就設計好呢?

首先,這裡的「如果」事實上發生的概率比較低,做 10 個業務有 1 個業務能活下去就很不錯了,更何況快速發展,和中彩票的概率差不多。如果我們每個業務上來就按照淘寶、微信的規模去做架構設計,不但會累死自己,還會害死業務。

其次,如果業務真的發展很快,後面進行業務分庫也不遲。因為業務發展好,相應的資源投入就會加大,可以投入更多的人和更多的錢,那業務分庫帶來的代碼和業務複雜的問題就可以通過增加人來解決,成本問題也可以通過增加資金來解決。

第三,單台數據庫服務器的性能其實也沒有想像的那麼弱,一般來說,單台數據庫服務器能夠支撐 10 萬用戶量量級的業務,初創業務從 0 發展到 10 萬級用戶,並不是想像得那麼快。

而對於業界成熟的大公司來說,由於已經有了業務分庫的成熟解決方案,並且即使是嘗試性的新業務,用戶規模也是海量的,這與前面提到的初創業務的小公司有本質區別,因此最好在業務開始設計時就考慮業務分庫。例如,在淘寶上做一個新的業務,由於已經有成熟的數據庫解決方案,用戶量也很大,需要在一開始就設計業務分庫甚至接下來介紹的分表方案。

分表

將不同業務數據分散存儲到不同的數據庫服務器,能夠支撐百萬甚至千萬用戶規模的業務,但如果業務繼續發展,同一業務的單表數據也會達到單台數據庫服務器的處理瓶頸。例如,淘寶的幾億用戶數據,如果全部存放在一台數據庫服務器的一張表中,肯定是無法滿足性能要求的,此時就需要對單表數據進行拆分。

單表數據拆分有兩種方式:垂直分表水平分表。示意圖如下:

為了形象地理解垂直拆分和水平拆分的區別,可以想像你手裡拿着一把刀,面對一個蛋糕切一刀:

  • 從上往下切就是垂直切分,因為刀的運行軌跡與蛋糕是垂直的,這樣可以把蛋糕切成高度相等(面積可以相等也可以不相等)的兩部分,對應到表的切分就是表記錄數相同但包含不同的列。例如,示意圖中的垂直切分,會把表切分為兩個表,一個表包含 ID、name、age、sex 列,另外一個表包含 ID、nickname、description 列。
  • 從左往右切就是水平切分,因為刀的運行軌跡與蛋糕是平行的,這樣可以把蛋糕切成面積相等(高度可以相等也可以不相等)的兩部分,對應到表的切分就是表的列相同但包含不同的行數據。例如,示意圖中的水平切分,會把表分為兩個表,兩個表都包含 ID、name、age、sex、nickname、description 列,但是一個表包含的是 ID 從 1 到 999999 的行數據,另一個表包含的是 ID 從 1000000 到 9999999 的行數據。

上面這個示例比較簡單,只考慮了一次切分的情況,實際架構設計過程中並不局限切分的次數,可以切兩次,也可以切很多次,就像切蛋糕一樣,可以切很多刀。

單表進行切分後,是否要將切分後的多個表分散在不同的數據庫服務器中,可以根據實際的切分效果來確定,並不強制要求單表切分為多表後一定要分散到不同數據庫中。原因在於單表切分為多表後,新的表即使在同一個數據庫服務器中,也可能帶來可觀的性能提升,如果性能能夠滿足業務要求,是可以不拆分到多台數據庫服務器的,畢竟我們在上面業務分庫的內容看到業務分庫也會引入很多複雜性的問題;如果單表拆分為多表後,單台服務器依然無法滿足性能要求,那就不得不再次進行業務分庫的設計了。

分表能夠有效地分散存儲壓力和帶來性能提升,但和分庫一樣,也會引入各種複雜性。

  1. 垂直分表 垂直分表適合將表中某些不常用且佔了大量空間的列拆分出去。例如,前面示意圖中的 nickname 和 description 字段,假設我們是一個婚戀網站,用戶在篩選其他用戶的時候,主要是用 age 和 sex 兩個字段進行查詢,而 nickname 和 description 兩個字段主要用於展示,一般不會在業務查詢中用到。description 本身又比較長,因此我們可以將這兩個字段獨立到另外一張表中,這樣在查詢 age 和 sex 時,就能帶來一定的性能提升。 垂直分表引入的複雜性主要體現在表操作的數量要增加。例如,原來只要一次查詢就可以獲取 name、age、sex、nickname、description,現在需要兩次查詢,一次查詢獲取 name、age、sex,另外一次查詢獲取 nickname、description。
  2. 水平分表 水平分表適合錶行數特別大的表,有的公司要求單錶行數超過 5000 萬就必須進行分表,這個數字可以作為參考,但並不是絕對標準,關鍵還是要看錶的訪問性能。對於一些比較複雜的表,可能超過 1000 萬就要分表了;而對於一些簡單的表,即使存儲數據超過 1 億行,也可以不分表。但不管怎樣,當看到表的數據量達到千萬級別時,作為架構師就要警覺起來,因為這很可能是架構的性能瓶頸或者隱患。 水平分表相比垂直分表,會引入更多的複雜性,主要表現在下面幾個方面:
    • 路由 水平分表後,某條數據具體屬於哪個切分後的子表,需要增加路由算法進行計算,這個算法會引入一定的複雜性。 常見的路由算法有: 範圍路由: 選取有序的數據列(例如,整形、時間戳等)作為路由的條件,不同分段分散到不同的數據庫表中。以最常見的用戶 ID 為例,路由算法可以按照 1000000 的範圍大小進行分段,1 ~ 999999 放到數據庫 1 的表中,1000000 ~ 1999999 放到數據庫 2 的表中,以此類推。 範圍路由設計的複雜點主要體現在分段大小的選取上,分段太小會導致切分後子表數量過多,增加維護複雜度;分段太大可能會導致單表依然存在性能問題,一般建議分段大小在 100 萬至 2000 萬之間,具體需要根據業務選取合適的分段大小。 範圍路由的優點是可以隨着數據的增加平滑地擴充新的表。例如,現在的用戶是 100 萬,如果增加到 1000 萬,只需要增加新的表就可以了,原有的數據不需要動。 範圍路由的一個比較隱含的缺點是分佈不均勻,假如按照 1000 萬來進行分表,有可能某個分段實際存儲的數據量只有 1000 條,而另外一個分段實際存儲的數據量有 900 萬條。 Hash 路由: 選取某個列(或者某幾個列組合也可以)的值進行 Hash 運算,然後根據 Hash 結果分散到不同的數據庫表中。同樣以用戶 ID 為例,假如我們一開始就規划了 10 個數據庫表,路由算法可以簡單地用 user_id % 10 的值來表示數據所屬的數據庫表編號,ID 為 985 的用戶放到編號為 5 的子表中,ID 為 10086 的用戶放到編號為 6 的字表中。 Hash 路由設計的複雜點主要體現在初始表數量的選取上,表數量太多維護比較麻煩,表數量太少又可能導致單表性能存在問題。而用了 Hash 路由後,增加字表數量是非常麻煩的,所有數據都要重分佈。 Hash 路由的優缺點和範圍路由基本相反,Hash 路由的優點是表分佈比較均勻,缺點是擴充新的表很麻煩,所有數據都要重分佈。 配置路由: 配置路由就是路由表,用一張獨立的表來記錄路由信息。同樣以用戶 ID 為例,我們新增一張 user_router 表,這個表包含 user_id 和 table_id 兩列,根據 user_id 就可以查詢對應的 table_id。 配置路由設計簡單,使用起來非常靈活,尤其是在擴充表的時候,只需要遷移指定的數據,然後修改路由表就可以了。 配置路由的缺點就是必須多查詢一次,會影響整體性能;而且路由表本身如果太大(例如,幾億條數據),性能同樣可能成為瓶頸,如果我們再次將路由表分庫分表,則又面臨一個死循環式的路由算法選擇問題。
    • join 操作 水平分表後,數據分散在多個表中,如果需要與其他表進行 join 查詢,需要在業務代碼或者數據庫中間件中進行多次 join 查詢,然後將結果合併。
    • count() 操作 水平分表後,雖然物理上數據分散到多個表中,但某些業務邏輯上還是會將這些表當作一個表來處理。例如,獲取記錄總數用於分頁或者展示,水平分表前用一個 count() 就能完成的操作,在分表後就沒那麼簡單了。常見的處理方式有下面兩種: count() 相加: 具體做法是在業務代碼或者數據庫中間件中對每個表進行 count() 操作,然後將結果相加。這種方式實現簡單,缺點就是性能比較低。例如,水平分表後切分為 20 張表,則要進行 20 次 count(*) 操作,如果串行的話,可能需要幾秒鐘才能得到結果。 記錄數表: 具體做法是新建一張表,假如表名為「記錄數表」,包含 table_name、row_count 兩個字段,每次插入或者刪除子表數據成功後,都更新「記錄數表」。 這種方式獲取表記錄數的性能要大大優於 count() 相加的方式,因為只需要一次簡單查詢就可以獲取數據。缺點是複雜度增加不少,對子表的操作要同步操作「記錄數表」,如果有一個業務邏輯遺漏了,數據就會不一致;且針對「記錄數表」的操作和針對子表的操作無法放在同一事務中進行處理,異常的情況下會出現操作子表成功了而操作記錄數表失敗,同樣會導致數據不一致。 此外,記錄數表的方式也增加了數據庫的寫壓力,因為每次針對子表的 insert 和 delete 操作都要 update 記錄數表,所以對於一些不要求記錄數實時保持精確的業務,也可以通過後台定時更新記錄數表。定時更新實際上就是「count() 相加」和「記錄數表」的結合,即定時通過 count() 相加計算表的記錄數,然後更新記錄數表中的數據。
    • order by 操作 水平分表後,數據分散到多個子表中,排序操作無法在數據庫中完成,只能由業務代碼或者數據庫中間件分別查詢每個子表中的數據,然後匯總進行排序。

實現方法

和數據庫讀寫分離類似,分庫分表具體的實現方式也是「程序代碼封裝」和「中間件封裝」,但實現會更複雜。讀寫分離實現時只要識別 SQL 操作是讀操作還是寫操作,通過簡單的判斷 SELECT、UPDATE、INSERT、DELETE 幾個關鍵字就可以做到,而分庫分表的實現除了要判斷操作類型外,還要判斷 SQL 中具體需要操作的表、操作函數(例如 count 函數)、order by、group by 操作等,然後再根據不同的操作進行不同的處理。例如 order by 操作,需要先從多個庫查詢到各個庫的數據,然後再重新 order by 才能得到最終的結果。

來源:小馬過河 原文:http://t.cn/EKRaLwx 題圖:來自谷歌圖片搜索 版權:本文版權歸原作者所有 投稿:歡迎投稿,投稿郵箱: [email protected]