白菜Java自習室 涵蓋核心知識算法
分庫分表難題(一) 分表分頁/跨庫分頁 難玩卻不表明沒有玩法
分庫分表難題(二) 跨庫/跨實例 Join 鏈接 不是非得依賴中間件數據庫
互聯網不少業務都有分頁拉取數據的需求,例如:服務器
- 電商商城系統運營端,分頁拉取訂單列表查看;
- 貼吧社區系統看帖子,分頁拉取帖子的回覆;
- 手機APP右上角的小紅點,點開拉取消息列表;
這些業務場景若是用數據庫去實現,每每有着這樣一些共性:markdown
- 數據量每每比較大;
- 通常都會設計業務主鍵ID;
- 分頁排序並不是按主鍵排序,而是按照建立時間排序;
在數據量不大時,能夠經過在排序字段 time 上創建索引,利用 SQL 提供的 offset/limit 功能就能知足分頁查詢需求:網絡
SELECT * FROM `table` ORDER BY `time` LIMIT #{offset}, #{limit}
複製代碼
當業務數據達到必定量級(好比:MySql單表記錄量大於1千萬)後,一般會考慮「分庫分表」將數據分散到不一樣的庫或表中(數據的水平切分),這樣能夠大大提升讀/寫性能。架構
高併發大流量的互聯網架構,通常經過服務層來訪問數據庫,隨着數據量的增大,數據庫須要進行水平切分,分庫後將數據分佈到不一樣的數據庫實例(甚至物理機器)上,以達到下降數據量,增長實例數的擴容目的。 一旦涉及分庫,逃不開「分庫依據」(patition key) 的概念,使用哪個字段來水平切分數據庫呢:大部分的業務場景,會使用業務主鍵ID。併發
肯定了分庫依據(patition key)後,接下來要肯定的是分庫算法:大部分的業務場景,會使用 業務主鍵ID取模的算法 來分庫,這樣 既可以保證每一個庫的數據分佈是均勻的,又可以保證每一個庫的請求分佈是均勻的,實在是簡單實現負載均衡的好方法,此法在互聯網架構中應用頗多。負載均衡
可是問題來了,對於 SELECT * FROM table ORDER BY time LIMIT #{offset}, #{limit}
這種分頁方式,原來一條語句就能夠簡單搞定的事情會變得很複雜,本文將與你們一塊兒探討分庫分表後"分頁"面臨的新問題。高併發
注意:本文主要探討「分頁」面臨的問題(數據水平切分場景),上邊只是舉了個最簡單的分庫分表算法例子,實際生產環境中會複雜的多,須要根據具體業務需求來肯定分庫分表方案。post
如圖所示,服務層經過 id 取模將數據分佈到兩個庫上去以後,每一個數據庫都失去了全局視野,數據按照 time 局部排序以後,無論哪一個分庫的第 3 頁數據,都不必定是全局排序的第 3 頁數據。
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3 | db1-page3 |
... (order by time) | ... (order by time) |
若是兩個庫的數據徹底相同,只須要每一個庫 offset 一半,再取半頁,就是最終想要的數據(如圖所示):
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3(取一半) | db1-page3(取一半) |
... (order by time) | ... (order by time) |
也可能兩個庫的數據分佈及其不均衡,例如 db0 的全部數據的 time 都大於 db1 的全部數據的 time,則可能出現:一個庫的第 3 頁數據,就是全局排序後的第 3 頁數據(如圖所示):
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3(同一個庫) | db1-page3 |
... (order by time) | ... (order by time) |
正常狀況下,全局排序的第 3 頁數據,每一個庫都會包含一部分(如圖所示):
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2(包含部分) | db1-page2 |
db0-page3 | db1-page3(包含部分) |
... (order by time) | ... (order by time) |
因爲不清楚究竟是哪一種狀況,因此 必須每一個庫都返回 3 頁數據,所獲得的 6 頁數據在服務層進行內存排序,獲得數據全局視野,再取第 3 頁數據,便可以獲得想要的全局分頁數據。
總結一下這個方案的步驟:
- 將
order by time offset X limit Y
,改寫成order by time offset 0 limit X+Y
;- 服務層將改寫後的 SQL 語句發往各個分庫:即例子中的各取 3 頁數據;
- 假設共分爲 N 個庫,服務層將獲得 N*(X+Y) 條數據:即例子中的 6 頁數據;
- 服務層對獲得的 N*(X+Y) 條數據進行內存排序,內存排序後再取偏移量 X 後的 Y 條記錄,就是全局視野所需的一頁數據。
方案優勢:
方案缺點(顯而易見):
「全局視野法」雖然性能較差,但其業務無損,數據精準,不失爲一種方案,有沒有性能更優的方案呢? 「任何脫離業務的架構設計都是耍流氓」,技術方案須要折衷,在技術難度較大的狀況下,業務需求的折衷可以極大的簡化技術方案。
在數據量很大,翻頁數不少的時候,不少產品並不提供「直接跳到指定頁面」的功能,而只提供「下一頁」的功能,這一個小小的業務折衷,就能極大的下降技術方案的複雜度。
如圖所示,不容許跳頁,那麼第一次只可以查第一頁:
- 將查詢
order by time offset 0 limit 100
,改寫成order by time where time > 0 limit 100
;- 上述改寫和
offset 0 limit 100
的效果相同,都是每一個分庫返回了一頁數據(如圖所示);- 服務層獲得 2 頁數據,內存排序,取出前 100 條數據,做爲最終的第一頁數據,這個全局的第一頁數據,通常來講每一個分庫都包含一部分數據(如圖所示);
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1(第一頁) | db1-page1(第一頁) |
db0-page2 | db1-page2 |
db0-page3 | db1-page3 |
... (order by time) | ... (order by time) |
疑問:這個方案也須要服務器內存排序,豈不是和「全局視野法」同樣麼?第一頁數據的拉取確實同樣,但每一次「下一頁」拉取的方案就不同了。
點擊「下一頁」時,須要拉取第二頁數據,在第一頁數據的基礎之上,可以找到第一頁數據 time 的最大值:
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1(time 最大值) | db1-page1 |
db0-page2 | db1-page2(time 最大值) |
db0-page3 | db1-page3 |
... (order by time) | ... (order by time) |
這個上一頁記錄的 time_max,會做爲第二頁數據拉取的查詢條件:
- 將查詢
order by time offset 100 limit 100
,改寫成order by time where time > > $time_max limit 100
;- 這下不是返回 2 頁數據了(「全局視野法,會改寫成
offset 0 limit 200
」),每一個分庫仍是返回一頁數據(如圖所示);- 服務層獲得 2 頁數據,內存排序,取出前 100 條數據,做爲最終的第 2 頁數據,這個全局的第 2 頁數據,通常來講也是每一個分庫都包含一部分數據(如圖所示);
- 如此往復,查詢全局視野第 100 頁數據時,不是將查詢條件改寫爲
offset 0 limit 9900+100
(返回 100 頁數據),而是改寫爲time > $time_max99 limit 100
(每一個分庫仍是返回一頁數據),以保證數據的傳輸量和排序的數據量不會隨着不斷翻頁而致使性能降低。
「全局視野法」可以返回業務無損的精確數據,在查詢頁數較大,例如第 100 頁時,會有性能問題,此時業務上是否可以接受,返回的 100 頁不是精準的數據,而容許有一些數據誤差呢?
數據庫分庫-數據均衡原理
使用 patition key 進行分庫,在數據量較大,數據分佈足夠隨機的狀況下,各分庫全部非 patition key 屬性,在各個分庫上的數據分佈,統計機率狀況是一致的。
例如,在 uid 隨機的狀況下,使用 uid 取模分兩庫,db0 和 db1:
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3(取一半) | db1-page3(取一半) |
... (order by time) | ... (order by time) |
利用這一原理,要查詢全局 100 頁數據,
offset 9900 limit 100
改寫爲offset 4950 limit 50
,每一個分庫偏移 4950(一半),獲取 50 條數據(半頁),獲得的數據集的並集,基本可以認爲,是全局數據的offset 9900 limit 100
的數據,固然,這一頁數據的精度,並非精準的。
根據實際業務經驗,用戶都要查詢第 100 頁網頁、帖子、郵件的數據了,這一頁數據的精準性損失,業務上每每是能夠接受的,但此時技術方案的複雜度便大大下降了,既不須要返回更多的數據,也不須要進行服務內存排序了。
有沒有一種技術方案,即可以知足業務的精確須要,無需業務折衷,又高性能的方法呢?這就是接下來要介紹的終極武器:「二次查詢法」。
爲了方便舉例,假設一頁只有 5 條數據,查詢第 200 頁的 SQL 語句爲 select * from T order by time offset 1000 limit 5
;
將 select * from T order by time offset 1000 limit 5
改寫爲 select * from T order by time offset 500 limit 5
, 並投遞給全部的分庫,注意,這個 offset 的 500,來自於全局offset的總偏移量 1000,除以水平切分數據庫個數 2。
若是是 3 個分庫,則能夠改寫爲 select * from T order by time offset 333 limit 5
,爲了更加直觀一點,咱們按照 3 個分庫的例子來演示,而且 time 用簡單的 8 位數字來表示(如圖所示):
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
10000123 | 10000133 | 10000143 |
10000223 | 10000233 | 10000243 |
10000323 | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
能夠看到,每一個分庫都是返回的按照 time 排序的一頁數據。
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
10000123(最小值) | 10000133 | 10000143 |
10000223 | 10000233 | 10000243 |
10000323 | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
這三頁數據中,time 最小值來自第一個庫,time_min = 10000123
,這個過程只須要比較各個分庫第一條數據,時間複雜度很低。
第一次改寫的 SQL 語句是 select * from T order by time offset 333 limit 5
;
第二次要改寫成一個 between 語句,between 的起點是 time_min,between 的終點是原來每一個分庫各自返回數據的最小值(between 是指 >= 和 <=):
select * from T order by time where time between time_min and 10000133
;select * from T order by time where time between time_min and 10000143
;第二次查詢,假設這三個分庫返回的數據以下(固然咱們只須要查詢 2 個庫):
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
- | - | 10000141 |
- | 10000132 | 10000142 |
10000123 | 10000133 | 10000143 |
咱們保持 time 排序不變,把二次查詢的結果集拼起來,獲得一個最新的結果集(如圖所示):
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
- | - | 10000141(第二次) |
- | 10000132(第二次) | 10000142(第二次) |
10000123 | 10000133 | 10000143 |
10000223 | 10000233 | 10000243 |
10000323 | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
如今咱們來作一個簡單的思惟推理:
- 咱們最初的需求是要
select * from T order by time offset 1000 limit 5
;- 而後由於分庫的緣由,咱們分別對 3 個分庫中
select * from T order by time offset 333 limit 5
;- 此時咱們獲得最小值 time_min,因此能夠先假設這 3 個分庫中比 time_min 小的結果,一共有 333 * 3 = 999 個(SQL 語句第 1 個結果的 offset 是 0, offset = 333 實際上是第 334 個結果),因此假設 time_min 的 offset = 999;
- 若是不進行二次查詢,咱們沒法獲得 offset = 1000 ~ 1004 的結果,由於我沒法肯定在 time_min(10000123) 和(10000133)之間,time_min(10000123) 和(10000143)之間是否存在其它結果,並不能獲得全局視野;
- 進行二次查詢,第二個分庫,改寫爲
select * from T order by time where time between time_min and 10000133
,第三個分庫,改寫爲select * from T order by time where time between time_min and 10000143
,獲得全局視野,咱們就能在內存中排序進行標號;- 進行二次查詢之前,假設比 time_min 小的結果一共有 999 個,因爲二次查詢出告終果,(10000132,10000141,10000142)三個結果是算在咱們假設的 999 個之中的,也就是這三個結果須要後移,此時 time_min 的 offset = 996 = 999 - 3;
獲得了 time_min 在全局的 offset,就至關於有了全局視野,根據總共二次的結果集,就可以獲得全局offset 1000 limit 5
的記錄。
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
- | - | 10000141(offset=999) |
- | 10000132(offset=997) | 10000142(offset=1000) |
10000123(offset=996) | 10000133(offset=998) | 10000143(offset=1001) |
10000223(offset=1002) | 10000233(offset=1003) | 10000243(offset=1004) |
10000323(offset=......) | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
方案優勢:
方案缺點:
分庫分表難題(一) 分表分頁/跨庫分頁 難玩卻不表明沒有玩法
分庫分表難題(二) 跨庫/跨實例 Join 鏈接 不是非得依賴中間件