千萬級並發架構下,關係型資料庫應該如何優化?大廠是如何做分庫分表的!
- 2021 年 10 月 23 日
- 筆記
隨著互聯網的高速發展,帶來了海量數據存儲的問題,比如像物聯網行業,每個智慧終端每天進行數據採集和上報,每天能夠產幾千萬甚至上億的數據。在互聯網電商行業,或者一些O2O平台,每天也能產生上千萬的訂單數據,這些量級的數據在傳統的關係型資料庫中已經無法支撐了,那麼如何解決海量數據存儲和計算等問題,在業內引入了分散式存儲和分散式計算等解決方案,特別是NoSql的生態,我在前面講過的k-v資料庫、文檔資料庫、圖形資料庫等,都是比較主流的分散式資料庫解決方案。
即便如此,關係型資料庫仍然有它不可替代的特性,所以關係型資料庫仍然是核心業務的基礎數據平台,因此關係型資料庫必然會面臨數據量日益增長帶來的海量數據處理問題。
Mysql資料庫海量數據帶來的性能問題
目前幾乎所有的互聯網公司都是採用mysql這個開源資料庫,根據阿里巴巴的《Java開發手冊》上提到的,當單錶行數超過500W行或者單表數據容量超過2G時,就會對查詢性能產生較大影響,這個時候建議對錶進行優化。
其實500W數據只是一個折中的值,具體的數據量和資料庫伺服器配置以及mysql配置有關,因為Mysql為了提升性能,會把表的索引裝載到記憶體,innodb_buffer_pool_size 足夠的情況下,mysql能把全部數據載入進記憶體,查詢不會有問題。
但是,當單表資料庫到達某個量級的上限時,導致記憶體無法存儲其索引,使得之後的 SQL 查詢會產生磁碟 IO,從而導致性能下降。當然,這個還有具體的表結構的設計有關,最終導致的問題都是記憶體限制,這裡,增加硬體配置,可能會帶來立竿見影的性能提升。
innodb_buffer_pool_size 包含數據快取、索引快取等。
Mysql常見的優化手段
當然,我們首先要進行的優化是基於Mysql本身的優化,常見的優化手段有:
- 增加索引,索引是直觀也是最快速優化檢索效率的方式。
- 基於Sql語句的優化,比如最左匹配原則,用索引欄位查詢、降低sql語句的複雜度等
- 表的合理設計,比如符合三範式、或者為了一定的效率破壞三範式設計等
- 資料庫參數優化,比如並發連接數、數據刷盤策略、調整快取大小
- 資料庫伺服器硬體升級
- mysql大家主從複製方案,實現讀寫分離
這些常見的優化手段,在數據量較小的情況下效果非常好,但是數據量到達一定瓶頸時,常規的優化手段已經解決不了實際問題,那怎麼辦呢?
大數據表優化方案
對於大數據表的優化最直觀的方式就是減少單表數據量,所以常見的解決方案是:
-
分庫分表,大表拆小表。
-
冷熱數據分離,所謂的冷熱數據,其實就是根據訪問頻次來劃分的,訪問頻次較多的數據是熱數據,訪問頻次少的數據是冷數據。冷熱數據分離就是把這兩類數據分離到不同的表中,從而減少熱數據表的大小。
其實在很多地方大家都能看到類似的實現,比如去一些網站查詢訂單或者交易記錄,默認只允許查詢1到3個月,3個月之前的數據,基本上大家都很少關心,訪問頻次較少,所以可以把3個月之前的數據保存到冷庫中。
-
歷史數據歸檔,簡單來說就是把時間比較久遠的數據分離出來存檔,保證實時庫的數據的有效生命周期。
其實這些解決方案都是屬於偏業務類的方案,並不完全是技術上的方案,所以在實施的時候,需要根據業務的特性來選擇合適的方式。
詳解分庫分表
分庫分表是非常常見針對單個數據表數據量過大的優化方式,它的核心思想是把一個大的數據表拆分成多個小的數據表,這個過程也叫(數據分片),它的本質其實有點類似於傳統資料庫中的分區表,比如mysql和oracle都支援分區表機制。
分庫分表是一種水平擴展手段,每個分片上包含原來總的數據集的一個子集。這種分而治之的思想在技術中很常見,比如多CPU、分散式架構、分散式快取等等,像前面我們講redis cluster集群時,slot槽的分配就是一種數據分片的思想。
如圖6-1所示,資料庫分庫分表一般有兩種實現方式:
- 水平拆分,基於表或欄位劃分,表結構不同,有單庫的分表,也有多庫的分庫。
- 垂直拆分,基於數據劃分,表結構相同,數據不同,也有同庫的水平切分和多庫的切分。

垂直拆分
垂直拆分有兩種,一種是單庫的垂直拆分,另一種是多個資料庫的垂直拆分。
單庫垂直分表
單個表的欄位數量建議控制在20~50個之間,之所以建議做這個限制,是因為如果欄位加上數據累計的長度超過一個閾值後,數據就不是存儲在一個頁上,就會產生分頁的問題,而這個問題會導致查詢性能下降。
所以如果當某些業務表的欄位過多時,我們一般會拆去垂直拆分的方式,把一個表的欄位拆分成多個表,如圖6-2所示,把一個訂單表垂直拆分成一個訂單主表和一個訂單明細表。

在Innodb引擎中,單表欄位最大限制為1017
參考: //dev.mysql.com/doc/mysql-reslimits-excerpt/5.6/en/column-count-limit.html
多庫垂直分表
多庫垂直拆分實際上就是把存在於一個庫中的多個表,按照一定的緯度拆分到多個庫中,如圖6-3所示。這種拆分方式在微服務架構中也是很常見,基本上會按照業務緯度拆分資料庫,同樣該緯度也會影響到微服務的拆分,基本上服務和資料庫是獨立的。

多庫垂直拆分最大的好處就是實現了業務數據的隔離。其次就是緩解了請求的壓力,原本所有的表在一個庫的時候,所有請求都會打到一個資料庫伺服器上,通過資料庫的拆分,可以分攤掉請求,在這個層面上提升了資料庫的吞吐能力。
水平拆分
垂直拆分的方式並沒有解決單表數據量過大的問題,所以我們還需要通過水平拆分的方式把大表數據做數據分片。
水平切分也可以分成兩種,一種是單庫的,一種是多庫的。
單庫水平分表
如圖6-4所示,表示把一張有10000條數據的用戶表,按照某種規則拆分成了4張表,每張表的數據量是2500條。

兩個案例:
銀行的交易流水表,所有進出的交易都需要登記這張表,因為絕大部分時候客戶都是查詢當天的交易和一個月以內的交易數據,所以我們根據使用頻率把這張表拆分成三張表:
當天表:只存儲當天的數據。
當月表:我們在夜間運行一個定時任務,前一天的數據,全部遷移到當月表。用的是insert into select,然後delete。
歷史表:同樣是通過定時任務,把登記時間超過30天的數據,遷移到history歷史表(歷史表的數據非常大,我們按照月度,每個月建立分區)。
費用表:消費金融公司跟線下商戶合作,給客戶辦理了貸款以後,消費金融公司要給商戶返費用,或者叫提成,每天都會產生很多的費用的數據。為了方便管理,我們每個月建立一張費用表,例如fee_detail_201901……fee_detail_201912。
但是注意,跟分區一樣,這種方式雖然可以一定程度解決單表查詢性能的問題,但是並不能解決單機存儲瓶頸的問題。
多庫水平分表
多庫水平分表,其實有點類似於分庫分表的綜合實現方案,從分表來說是減少了單表的數據量,從分庫層面來說,降低了單個資料庫訪問的性能瓶頸,如圖6-5所示。

常見的水平分表策略
分庫更多的是關注業務的耦合度,也就是每個庫應該放那些表,是由業務耦合度來決定的,這個在前期做領域建模的時候都會先考慮好,所以問題不大,只是分庫之後帶來的其他問題,我們在後續內容中來分析。
而分表這塊,需要考慮的問題會更多一些,也就是我們應該根據什麼樣的策略來水平分表?這裡就需要涉及到分表策略了,下面簡單介紹幾種最常見的分片策略。
哈希取模分片
哈希分片,其實就是通過表中的某一個欄位進行hash演算法得到一個哈希值,然後通過取模運算確定數據應該放在哪個分片中,如圖6-6所示。這種方式非常適合隨機讀寫的場景中,它能夠很好的將一個大表的數據隨機分散到多個小表。

hash取模的問題
hash取模運算有個比較嚴重的問題,假設根據當前數據表的量以及增長情況,我們把一個大表拆分成了4個小表,看起來滿足目前的需求,但是經過一段時間的運行後,發現四個表不夠,需要再增加4個表來存儲,這種情況下,就需要對原來的數據進行整體遷移,這個過程非常麻煩。
一般為了減少這種方式帶來的數據遷移的影響,我們會採用一致性hash演算法。
一致性hash演算法
在前面我們講的hash取模演算法,實際上對目標表或者目標資料庫進行hash取模,一旦目標表或者資料庫發生數量上的變化,就會導致所有數據都需要進行遷移,為了減少這種大規模的數據影響,才引入了一致性hash演算法。
如圖6-7所示,簡單來說,一致性哈希將整個哈希值空間組織成一個虛擬的圓環,如假設某哈希函數H的值空間為0-232-1(即哈希值是一個32位無符號整形),什麼意思呢?
就是我們通過0-232-1的數字組成一個虛擬的圓環,圓環的正上方的點代表0,0點右側的第一個點代表1,以此類推,2、3、4、5、6……直到232-1,也就是說0點左側的第一個點代表232-1。我們把這個由2的32次方個點組成的圓環稱為hash環。

那一致性hash演算法和上面的虛擬環有什麼關係呢?繼續回到前面我們講解hash取模的例子,假設現在有四個表,table_1、table_2、table_3、table_4,在一致性hash演算法中,取模運算不是直接對這四個表來完成,而是對232來實現。
hash(table編號)%232
通過上述公式算出的結果一定是一個0到232-1之間的一個整數,然後在這個數對應的位置標註目標表,如圖6-8所示,四個表通過hash取模之後分別落在hash環的某個位置上。

好了,到目前為止,我們已經把目標表與hash環聯繫在了一起,那麼接下來我們需要把一條數據保存到某個目標表中,怎麼做呢?如圖6-9所示,當添加一條數據時,同樣通過hash和hash環取模運算得到一個目標值,然後根據目標值所在的hash環的位置順時針查找最近的一個目標表,把數據存儲到這個目標表中即可。

不知道大家是否發現了一致性hash的好處,就是hash運算不是直接面向目標表,而是面向hash環,這樣的好處就是當需要刪除某張表或者增加表的時候,對於整個數據變化的影響是局部的,而不是全局。舉個例子,假設我們發現需要增加一張表table_04,如圖6-10所示,增加一個表,並不會對其他四個已經產生了數據的表造成影響,原來已經分片的數據完全不需要做任何改動。
如果需要刪除一個節點,同樣只會影響刪除節點本身的數據,前後表的數據完全不受影響。

hash環偏斜
上述設計有一個問題,理論情況下我們目標表是能夠均衡的分布在整個hash環中,但實際情況有可能是圖6-11所示的樣子。也就是產生了hash環偏斜的現象,這種現象導致的問題就是大量的數據都會保存到同一個表中,倒是數據分配極度不均勻。

為了解決這個問題,必須要保證目標節點要均勻的分布在整個hash環中,但是真實的節點就只有4個,如何均勻分布呢?最簡單的方法就是,把這四個節點分別複製一份出來分散到這個hash環中,這個複製出來的節點叫虛擬節點,根據實際需要可以虛擬出多個節點出來,如圖6-12所示。

按照範圍分片
按範圍分片,其實就是基於數據表的業務特性,按照某種範圍拆分,這個範圍的有很多含義,比如:
- 時間範圍,比如我們按照數據創建時間,按照每一個月保存一個表。基於時間劃分還可以用來做冷熱數據分離,越早的數據訪問頻次越少。
- 區域範圍,區域一般指的是地理位置,比如一個表裡面存儲了來自全國各地的數據,如果數據量較大的情況下,可以按照地域來劃分多個表。
- 數據範圍,比如根據某個欄位的數據區間來進行劃分。
如圖6-7所示,表示按照數據範圍進行拆分。

範圍分片最終要的是選擇一個合適的分片鍵,這個是否合適來自於業務需求,比如之前有個學員是在做智慧家居的,他們賣的是硬體設備,這些設備會採集數據上報到伺服器上,當來自全國範圍的數據統一保存在一個表中後,數據量達到了億級別,所以這種場景比較適合按照城市和地域來拆分。
分庫分表實戰
為了讓大家理解分庫分表以及實操,我們通過一個簡單的案例來演示一下。程式碼詳見:springboot-split-table-example項目
假設存在一個用戶表,用戶表的欄位如下。
該表主要提供註冊、登錄、查詢、修改等功能。

該表的具體的業務情況如下(需要注意,在進行分表之前,需要了解業務層面對這個表的使用情況,然後再決定使用什麼樣的方案,否則脫離業務去設計技術方案是耍流氓)
用戶端: 前台訪問量較大,主要涉及兩類請求:
- 用戶登錄,面向C端,對可用性和一致性要求較高,主要通過login_name、email、phone來查詢用戶資訊,1%的請求屬於這種類型
- 用戶資訊查詢,登錄成功後,通過uid來查詢用戶資訊,99%屬於這種類型。
運營端: 主要是運營後台的資訊訪問,需要支援根據性別、手機號、註冊時間、用戶昵稱等進行分頁查詢,由於是內部系統,訪問量較低,對可用性一致性要求不高。
根據uid進行水平分表
由於99%的請求是基於uid進行用戶資訊查詢,所以毫無疑問我們選擇使用uid進行水平分表。那麼這裡我們採用uid的hash取模方法來進行分表,具體的實施如圖6-9所示,根據uid進行一致性hash取模運算得到目標表進行存儲。

按照圖6-9的結構,分別複製user_info表,重新命名為01~04,如圖6-10所示。

如何生成全局唯一id
當完成上述動作後,就需要開始開始落地實施,這裡需要考慮在數據添加、修改、刪除時,要正確路由到目標數據表,其次是老數據的遷移。
老數據遷移,一般我們是寫一個腳本或者一個程式,把舊錶中的數據查詢出來,然後根據分表規則重新路由分發到新的表中,這裡不是很複雜,就不做展開說明,我們重點說一下數據添加/修改/刪除的路由。
在實施之前,我們需要先考慮一個非常重要的問題,就是在單個表中,我們使用遞增主鍵來保證數據的唯一性,但是如果把數據拆分到了四個表,每個表都採用自己的遞增主鍵規則,就會存在重複id的問題,也就是說遞增主鍵不是全局唯一的。
我們需要知道一個點是,user_info雖然拆分成了多張表,但是本質上它應該還是一個完整的數據整體,當id存在重複的時候,就失去了數據的唯一性,因此我們需要考慮如何生成一個全局唯一ID。
如何實現全局唯一ID
全局唯一ID的特性就是能夠保證ID的唯一性,那麼基於這個特性,我們可以輕鬆找到很多的解決方案。
- 資料庫自增ID(定義全局表)
- UUID
- Redis的原子遞增
- Twitter-Snowflake演算法
- 美團的leaf
- MongoDB的ObjectId
- 百度的UidGenerator
分散式ID的特性
- 唯一性:確保生成的ID是全局唯一的。
- 有序遞增性:確保生成的ID是對於某個用戶或者業務是按一定的數字有序遞增的。
- 高可用性:確保任何時候都能正確的生成ID。
- 帶時間:ID裡面包含時間,一眼掃過去就知道哪天的數據
資料庫自增方案
在資料庫中專門創建一張序列表,利用資料庫表中的自增ID來為其他業務的數據生成一個全局ID,那麼每次要用ID的時候,直接從這個表中獲取即可。
CREATE TABLE `uid_table` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`business_id` int(11) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE (business_type)
)
在應用程式中,每次調用下面這段程式碼,就可以持續獲得一個遞增的ID。
begin;
REPLACE INTO uid_table (business_id) VALUES (2);
SELECT LAST_INSERT_ID();
commit;
其中,replace into是每次刪除原來相同的數據,同時加1條,就能保證我們每次得到的就是一個自增的ID
這個方案的優點是非常簡單,它也有缺點,就是對於資料庫的壓力比較大,而且最好是獨立部署一個DB,而獨立部署又會增加整體的成本,這個在美團的leaf裡面設計了一個很巧妙的設計方案,後面再講
優點:
- 非常簡單,利用現有資料庫系統的功能實現,成本小,有DBA專業維護。
- ID號單調自增,可以實現一些對ID有特殊要求的業務。
缺點:
- 強依賴DB,當DB異常時整個系統不可用,屬於致命問題。配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。
- ID發號性能瓶頸限制在單台MySQL的讀寫性能。
UUID
UUID的格式是: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 8-4-4-4-12共36個字元,它是一個128bit的二進位轉化為16進位的32個字元,然後用4個-連接起來的字元串。
UUID的五種生成方式
- 基於時間的UUID(date-time & MAC address): 主要依賴當前的時間戳及機器mac地址,因此可以保證全球唯一性。(使用了Mac地址,因此會暴露Mac地址和生成時間。)
- 分散式安全的UUID(date-time & group/user id)將版本1的時間戳前四位換為POSIX的UID或GID。
- 基於名字空間的UUID-MD5版(MD5 hash & namespace),基於指定的名字空間/名字生成MD5散列值得到,標準不推薦。
- 基於隨機數的UUID(pseudo-random number):基於隨機數或偽隨機數生成。
- 基於名字空間的UUID-SHA1版(SHA-1 hash & namespace):將版本3的散列演算法改為SHA1
在Java中,提供了基於MD5演算法的UUID、以及基於隨機數的UUID。
優點:
- 本地生成,沒有網路消耗,生成簡單,沒有高可用風險。
缺點:
- 不易於存儲:UUID太長,16位元組128位,通常以36長度的字元串表示,很多場景不適用。
- 資訊不安全:基於MAC地址生成UUID的演算法可能會造成MAC地址泄露,這個漏洞曾被用於尋找梅麗莎病毒的製作者位置。
- 無序查詢效率低:由於生成的UUID是無序不可讀的字元串,所以其查詢效率低。
- UUID不適合用來做資料庫的唯一ID,如果用UUID做主鍵,無序的不遞增,大家都知道,主鍵是有索引的,然後mysql的索引是通過b+樹來實現的,每一次新的UUID數據的插入,為了查詢的優化,都會對索引底層的b+樹進行修改,因為UUID數據是無序的,所以每一次UUID數據的插入都會對主鍵的b+樹進行很大的修改,嚴重影響性能
雪花演算法
SnowFlake 演算法,是 Twitter 開源的分散式 id 生成演算法。其核心思想就是:使用一個 64 bit 的 long 型的數字作為全局唯一 id。雪花演算法比較常見,在百度的UidGenerator、美團的Leaf中,都有用到雪花演算法的實現。
如圖6-11所示,表示雪花演算法的組成,一共64bit,這64個bit位由四個部分組成。
-
第一部分,1bit位,用來表示符號位,而ID一般是正數,所以這個符號位一般情況下是0。
-
第二部分,佔41 個 bit:表示的是時間戳,是系統時間的毫秒數,但是這個時間戳不是當前系統的時間,而是當前
系統時間-開始時間,更大的保證這個ID生成方案的使用的時間! -
那麼我們為什麼需要這個時間戳,目的是為了保證有序性,可讀性,我一看我就能猜到ID是什麼時候生成的。
41位可以241 - 1表示個數字,
如果只用來表示正整數(電腦中正數包含0),可以表示的數值範圍是:0 至 241-1,減1是因為可表示的數值範圍是從0開始算的,而不是1。
也就是說41位可以表示241-1個毫秒的值,轉化成單位年則是(241-1)/1000 * 60 * 60 * 24 *365=69年,也就是能容納69年的時間
-
第三部分,用來記錄工作機器id,id包含10bit,意味著這個服務最多可以部署在 2^10 台機器上,也就是 1024 台機器。
其中這10bit又可以分成2個5bit,前5bit表示機房id、5bit表示機器id,意味著最多支援2^5個機房(32),每個機房可以支援32台機器。
-
第四部分,第四部分由12bit組成,它表示一個遞增序列,用來記錄同毫秒內產生的不同id。
那麼我們為什麼需要這個序列號,設想下,如果是同一毫秒同一台機器來請求,那麼我們怎麼保證他的唯一性,這個時候,我們就能用到我們的序列號,
目的是為了保證同一毫秒內同一機器生成的ID是唯一的,這個其實就是為了滿足我們ID的這個高並發,就是保證我同一毫秒進來的並發場景的唯一性
12位(bit)可以表示的最大正整數是2^12-1=4095,即可以用0、1、2、3、....4094這4095個數字,來表示同一機器同一時間截(毫秒)內產生的4095個ID序號。
12位2進位,如果全部都是1的情況下,那麼最終的值就是4095,也就是12bit能夠存儲的最大的數字是4095.

分庫分表之後的數據DML操作
有序需要用到全局id,所以在user_info表需要添加一個唯一id的欄位。

配置完成之後,在如下程式碼中引入signal方法。
@Slf4j
@RestController
@RequestMapping("/users")
public class UserInfoController {
@Autowired
IUserInfoService userInfoService;
SnowFlakeGenerator snowFlakeGenerator=new SnowFlakeGenerator(1,1,1);
@PostMapping("/batch")
public void user(@RequestBody List<UserInfo> userInfos){
log.info("begin UserInfoController.user");
userInfoService.saveBatch(userInfos);
}
@PostMapping
public void signal(@RequestBody UserInfo userInfo){
Long bizId=snowFlakeGenerator.nextId();
userInfo.setBizId(bizId);
String table=ConsistentHashing.getServer(bizId.toString());
log.info("UserInfoController.signal:{}",table);
MybatisPlusConfig.TABLE_NAME.set(table);
userInfoService.save(userInfo);
}
}
並且,需要增加一個mybatis攔截器,針對user_info表進行攔截和替換,從而實現動態表的路由。
@Configuration
public class MybatisPlusConfig {
public static ThreadLocal<String> TABLE_NAME = new ThreadLocal<>();
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
Map<String, TableNameHandler> tableNameHandlerMap = new HashMap<>();
tableNameHandlerMap.put("user_info", (sql, tableName) -> TABLE_NAME.get());
dynamicTableNameInnerInterceptor.setTableNameHandlerMap(tableNameHandlerMap);
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
return interceptor;
}
}
至此,一個基礎的分庫分表的演練就完成了,但問題仍然還未完全解決。
非分片鍵查詢
我們對user_info表的分片,是基於biz_id來實現的,也就是意味著如果我們想查詢某張表的數據,必須先要使用biz_id路由找到對應的表才能查詢到。
那麼問題來了,如果查詢的欄位不是分片鍵(也就是不是biz_id),比如本次分庫分表實戰案例中,運營端查詢就有根據名字、手機號、性別等欄位來查,這時候我們並不知道去哪張表查詢這些資訊。
非分片鍵和分片鍵建立映射關係
第一種解決辦法就是,把非分片鍵和分片鍵建立映射關係,比如login_name -> biz_id 建立映射,相當於建立一個簡單的索引,當基於login_name查詢數據時,先通過映射表查詢出login_name對應的biz_id,再通過biz_id定位到目標表。
映射表的只有兩列,可以成再很多的數據,當數據量過大時,也可以對映射表做水平拆分。 同時這種映射關係其實就是k-v鍵值對的關係,所以我們可以使用k-v快取來存儲提升性能。
同時因為這種映射關係的變更頻率很低,所以快取命中率很高,性能也很好。
用戶端資料庫和運營端資料庫進行分離
運營端的查詢可能不止於單個欄位的映射來查詢,可能更多的會涉及到一些複雜查詢,以及分頁查詢等,這種查詢本身對資料庫性能影響較大,很可能影響到用戶端對於用戶表的操作,所以一般主流的解決方案就是把兩個庫進行分離。
由於運營端對於數據的一致性和可用性要求不是很高,也不需要實時訪問資料庫,所以我們可以把C端用戶表的數據同步到運營端的用戶表,而且用戶表可以不需要做分表操作,直接全量查表即可。
當然,如果運營端的操作性能實在是太慢了,我們還可以採用ElasticSearch搜索引擎來滿足後台複雜查詢的需求。
實際應用中會遇到的問題
在實際應用中,並不是一開始就會想到未來會對這個表做拆分,因此很多時候我們面臨的問題是在數據量已經達到一定瓶頸的時候,才開始去考慮這個問題。
所以分庫分表最大的難點不是在於拆分的方法論,而是在運行了很長時間的資料庫中,如何根據實際業務情況選擇合適的拆分方式,以及在拆分之前對於數據的遷移方案的思考。而且,在整個數據遷移和拆分過程中,系統仍然需要保持可用。
對於運行中的表的分表,一般會分為三個階段。
階段一,新老庫雙寫
由於老的數據表肯定沒有考慮到未來分表的設計,同時隨著業務的迭代,可能有些模型也需要優化,因此會設計一個新的表來承載老的數據,而這個過程中,需要做幾件事情
- 資料庫表的雙寫,老的資料庫表和新的資料庫表同步寫入數據,事務的成功以老的模型為準,查詢也走老的模型
- 通過定時任務對數據進行核對,補平差異
- 通過定時任務把歷史數據遷移到新的模型中
階段二,以新的模型為準
到了第二個階段,歷史數據已經導完了,並且校驗數據沒有問題。
- 仍然保持數據雙寫,但是事務的成功和查詢都以新模型為準。
- 定時任務進行數據核對,補平數據差異
階段三,結束雙寫
到了第三個階段,說明數據已經完全遷移好了,因此。
- 取消雙寫,所有數據只需要保存到新的模型中,老模型不需要再寫入新的數據。
- 如果仍然有部分老的業務依賴老的模型,所以等到所有業務都改造完成後, 再廢除老的模型。
分庫分表後帶來的問題
分庫分錶帶來性能提升的好處的同時,也帶來了很多的麻煩,
分散式事務問題
分庫分表之後,原本在一個庫中的事務,變成了跨越多個庫,如何保證跨庫數據的一致性問題,也是一個常見的難題。如圖6-13所示,用戶創建訂單時,需要在訂單庫中保存一條訂單記錄,並且修改庫存庫中的商品庫存,這裡就涉及到跨庫事務的一致性問題。也就是說我怎麼保證當前兩個事務操作要麼同時成功,要麼同時失敗。

跨庫查詢
比如查詢在合約資訊的時候要關聯客戶數據,由於是合約數據和客戶數據是在不同的資料庫,那麼我們肯定不能直接使用join的這種方式去做關聯查詢。
我們有幾種主要的解決方案:
- 欄位冗餘,比如我們查詢合約庫的合約表的時候需要關聯客戶庫的客戶表,我們可以直接把一些經常關聯查詢的客戶欄位放到合約表,通過這種方式避免跨庫關聯查詢的問題。
- 數據同步:比如商戶系統要查詢產品系統的產品表,我們乾脆在商戶系統創建一張產品表,通過ETL或者其他方式定時同步產品數據。
- 全局表(廣播表) 比如基礎數據被很多業務系統用到,如果我們放在核心系統,每個系統都要去關聯查詢,這個時候我們可以在所有的資料庫都存儲相同的基礎數據。
- ER表(綁定表),我們有些表的數據是存在邏輯的主外鍵關係的,比如訂單表order_info,存的是匯總的商品數,商品金額;訂單明細表order_detail,是每個商品的價格,個數等等。或者叫做從屬關係,父表和子表的關係。他們之間會經常有關聯查詢的操作,如果父表的數據和子表的數據分別存儲在不同的資料庫,跨庫關聯查詢也比較麻煩。所以我們能不能把父表和數據和從屬於父表的數據落到一個節點上呢?比如order_id=1001的數據在node1,它所有的明細數據也放到node1;order_id=1002的數據在node2,它所有的明細數據都放到node2,這樣在關聯查詢的時候依然是在一個資料庫。
上面的思路都是通過合理的數據分布避免跨庫關聯查詢,實際上在我們的業務中,也是盡量不要用跨庫關聯查詢,如果出現了這種情況,就要分析一下業務或者數據拆分是不是合理。如果還是出現了需要跨庫關聯的情況,那我們就只能用最後一種辦法。
- 系統層組裝
在不同的資料庫節點把符合條件數據的數據查詢出來,然後重新組裝,返回給客戶端。
排序、翻頁、函數計算等問題
跨節點多庫進行查詢時,會出現limit分頁,order by排序的問題。比如有兩個節點,節點1存的是奇數id=1,3,5,7,9……;節點2存的是偶數id=2,4,6,8,10……
執行select * from user_info order by id limit 0,10
需要在兩個節點上各取出10條,然後合併數據,重新排序。
max、min、sum、count之類的函數在進行計算的時候,也需要先在每個分片上執行相應的函數,然後將各個分片的結果集進行匯總和再次計算,最終將結果返回。
全局唯一ID
全局唯一id的問題,前面已經說了,水平分表之後,需要考慮全局唯一id設計問題。
多數據源的問題
分庫分表之後,難免會存在一個應用配置多個數據源。
另外,資料庫層面有可能會設計讀寫分離的方案,也使得一個應用會訪問多個數據源,並且還需要實現讀寫分離的動態路由。
而這些問題在每個應用系統中都會存在並且需要解決,所以為了提供統一的分庫分表相關問題的解決方案,引入了很多的開源技術。
分庫分表解決方案
目前市面上分庫分表的中間件相對來說說比較多,比如
- Cobar,淘寶開源的分庫分表組件,目前基本上沒有維護了。
- Sharding-Sphere,噹噹開源的一個分庫分表組件,已經捐獻給了Apache基金會
- Atlas, 奇虎360開源的分庫分表組件,也是沒怎麼維護了
- Mycat,從阿里cobar升級而來,由開源組織維護。
- Vitess,Google開源的分庫分表組件
目前很多公司選擇較多的是Mycat或者Sharding-Sphere,所以我重點介紹Sharding-Sphere的使用和原理。
對於同類技術的選擇,無非就是看社區活躍度、技術的成熟度、以及功能和當前需求是否匹配。
關注[跟著Mic學架構]公眾號,獲取更多精品原創

