閱讀本文大概須要 2.8 分鐘。程序員
這篇文章,咱們來聊一下對於一個支撐日活百萬用戶的高並系統,他的數據庫架構應該如何設計?算法
看到這個題目,不少人第一反應就是:分庫分表啊!可是實際上,數據庫層面的分庫分表究竟是用來幹什麼的,他的不一樣的做用如何應對不一樣的場景,我以爲不少同窗可能都沒搞清楚。數據庫
用一個創業公司的發展做爲背景引入安全
假如咱們如今是一個小創業公司,註冊用戶就 20 萬,天天活躍用戶就 1 萬,天天單表數據量就 1000,而後高峯期每秒鐘併發請求最多就 10。bash
天哪!就這種系統,隨便找一個有幾年工做經驗的高級工程師,而後帶幾個年輕工程師,隨便乾乾均可以作出來。服務器
由於這樣的系統,實際上主要就是在前期快速的進行業務功能的開發,搞一個單塊系統部署在一臺服務器上,而後鏈接一個數據庫就能夠了。微信
接着你們就是不停的在一個工程裏填充進去各類業務代碼,儘快把公司的業務支撐起來。網絡
以下圖所示:架構
結果呢,沒想到咱們運氣這麼好,碰上個優秀的 CEO 帶着咱們走上了康莊大道!併發
公司業務發展迅猛,過了幾個月,註冊用戶數達到了 2000 萬!天天活躍用戶數 100 萬!天天單表新增數據量達到 50 萬條!高峯期每秒請求量達到 1 萬!
同時公司還順帶着融資了兩輪,估值達到了驚人的幾億美金!一隻朝氣蓬勃的幼年獨角獸的節奏!
好吧,如今你們感受壓力已經有點大了,爲啥呢?由於天天單表新增 50 萬條數據,一個月就多 1500 萬條數據,一年下來單表會達到上億條數據。
通過一段時間的運行,如今我們單表已經兩三千萬條數據了,勉強還能支撐着。
可是,眼見着系統訪問數據庫的性能怎麼愈來愈差呢,單表數據量愈來愈大,拖垮了一些複雜查詢 SQL 的性能啊!
而後高峯期請求如今是每秒 1 萬,我們的系統在線上部署了 20 臺機器,平均每臺機器每秒支撐 500 請求,這個還能抗住,沒啥大問題。可是數據庫層面呢?
若是說此時你仍是一臺數據庫服務器在支撐每秒上萬的請求,負責任的告訴你,每次高峯期會出現下述問題:
你的數據庫服務器的磁盤 IO、網絡帶寬、CPU 負載、內存消耗,都會達到很是高的狀況,數據庫所在服務器的總體負載會很是重,甚至都快不堪重負了。
高峯期時,原本你單表數據量就很大,SQL 性能就不太好,這時加上你的數據庫服務器負載過高致使性能降低,就會發現你的 SQL 性能更差了。
最明顯的一個感受,就是你的系統在高峯期各個功能都運行的很慢,用戶體驗不好,點一個按鈕可能要幾十秒纔出來結果。
若是你運氣不太好,數據庫服務器的配置不是特別的高的話,弄很差你還會經歷數據庫宕機的狀況,由於負載過高對數據庫壓力太大了。
多臺服務器分庫支撐高併發讀寫
首先咱們先考慮第一個問題,數據庫每秒上萬的併發請求應該如何來支撐呢?
要搞清楚這個問題,先得明白通常數據庫部署在什麼配置的服務器上。一般來講,假如你用普通配置的服務器來部署數據庫,那也起碼是 16 核 32G 的機器配置。
這種很是普通的機器配置部署的數據庫,通常線上的經驗是:不要讓其每秒請求支撐超過 2000,通常控制在 2000 左右。
控制在這個程度,通常數據庫負載相對合理,不會帶來太大的壓力,沒有太大的宕機風險。
因此首先第一步,就是在上萬併發請求的場景下,部署個 5 臺服務器,每臺服務器上都部署一個數據庫實例。
而後每一個數據庫實例裏,都建立一個同樣的庫,好比說訂單庫。此時在 5 臺服務器上都有一個訂單庫,名字能夠相似爲:db_order_01,db_order_02,等等。
而後每一個訂單庫裏,都有一個相同的表,好比說訂單庫裏有訂單信息表,那麼此時 5 個訂單庫裏都有一個訂單信息表。
好比 db_order_01 庫裏就有一個 tb_order_01 表,db_order_02 庫裏就有一個 tb_order_02 表。
這就實現了一個基本的分庫分表的思路,原來的一臺數據庫服務器變成了 5 臺數據庫服務器,原來的一個庫變成了 5 個庫,原來的一張表變成了 5 個表。
而後你在寫入數據的時候,須要藉助數據庫中間件,好比 sharding-jdbc,或者是 mycat,均可以。
你能夠根據好比訂單 id 來 hash 後按 5 取模,好比天天訂單表新增 50 萬數據,此時其中 10 萬條數據會落入 db_order_01 庫的 tb_order_01 表,另外 10 萬條數據會落入 db_order_02 庫的 tb_order_02 表,以此類推。
這樣就能夠把數據均勻分散在 5 臺服務器上了,查詢的時候,也能夠經過訂單 id 來 hash 取模,去對應的服務器上的數據庫裏,從對應的表裏查詢那條數據出來便可。
依據這個思路畫出的圖以下所示,你們能夠看看:
作這一步有什麼好處呢?第一個好處,原來好比訂單表就一張表,這個時候不就成了 5 張表了麼,那麼每一個表的數據就變成 1/5 了。
假設訂單表一年有 1 億條數據,此時 5 張表裏每張表一年就 2000 萬數據了。
那麼假設當前訂單表裏已經有 2000 萬數據了,此時作了上述拆分,每一個表裏就只有 400 萬數據了。
並且天天新增 50 萬數據的話,那麼每一個表才新增 10 萬數據,這樣是否是初步緩解了單表數據量過大影響系統性能的問題?
另外就是每秒 1 萬請求到 5 臺數據庫上,每臺數據庫就承載每秒 2000 的請求,是否是一會兒把每臺數據庫服務器的併發請求下降到了安全範圍內?
這樣,下降了數據庫的高峯期負載,同時還保證了高峯期的性能。
大量分表來保證海量數據下的查詢性能
可是上述的數據庫架構還有一個問題,那就是單表數據量仍是過大,如今訂單表才分爲了 5 張表,那麼若是訂單一年有 1 億條,每一個表就有 2000 萬條,這也仍是太大了。
因此還應該繼續分表,大量分表。好比能夠把訂單表一共拆分爲 1024 張表,這樣 1 億數據量的話,分散到每一個表裏也就才 10 萬量級的數據量,而後這上千張表分散在 5 臺數據庫裏就能夠了。
在寫入數據的時候,須要作兩次路由,先對訂單 id hash 後對數據庫的數量取模,能夠路由到一臺數據庫上,而後再對那臺數據庫上的表數量取模,就能夠路由到數據庫上的一個表裏了。
經過這個步驟,就可讓每一個表裏的數據量很是小,每一年 1 億數據增加,可是到每一個表裏才 10 萬條數據增加,這個系統運行 10 年,每一個表裏可能才百萬級的數據量。
這樣能夠一次性爲系統將來的運行作好充足的準備,看下面的圖,一塊兒來感覺一下:
全局惟一 id 如何生成
在分庫分表以後你必然要面對的一個問題,就是 id 咋生成?由於要是一個表分紅多個表以後,每一個表的 id 都是從 1 開始累加自增加,那確定不對啊。
舉個例子,你的訂單表拆分爲了 1024 張訂單表,每一個表的 id 都從 1 開始累加,這個確定有問題了!
你的系統就沒辦法根據表主鍵來查詢訂單了,好比 id = 50 這個訂單,在每一個表裏都有!
因此此時就須要分佈式架構下的全局惟一 id 生成的方案了,在分庫分表以後,對於插入數據庫中的核心 id,不能直接簡單使用表自增 id,要全局生成惟一 id,而後插入各個表中,保證每一個表內的某個 id,全局惟一。
好比說訂單表雖然拆分爲了 1024 張表,可是 id = 50 這個訂單,只會存在於一個表裏。
那麼如何實現全局惟一 id 呢?有如下幾種方案:
方案一:獨立數據庫自增 id
這個方案就是說你的系統每次要生成一個 id,都是往一個獨立庫的一個獨立表裏插入一條沒什麼業務含義的數據,而後獲取一個數據庫自增的一個 id。拿到這個 id 以後再往對應的分庫分表裏去寫入。
好比說你有一個 auto_id 庫,裏面就一個表,叫作 auto_id 表,有一個 id 是自增加的。
那麼你每次要獲取一個全局惟一 id,直接往這個表裏插入一條記錄,獲取一個全局惟一 id 便可,而後這個全局惟一 id 就能夠插入訂單的分庫分表中。
這個方案的好處就是方便簡單,誰都會用。缺點就是單庫生成自增 id,要是高併發的話,就會有瓶頸的,由於 auto_id 庫要是承載個每秒幾萬併發,確定是不現實的了。
方案二:UUID
這個每一個人都應該知道吧,就是用 UUID 生成一個全局惟一的 id。
好處就是每一個系統本地生成,不要基於數據庫來了。很差之處就是,UUID 太長了,做爲主鍵性能太差了,不適合用於主鍵。
若是你是要隨機生成個什麼文件名了,編號之類的,你能夠用 UUID,可是做爲主鍵是不能用 UUID 的。
方案三:獲取系統當前時間
這個方案的意思就是獲取當前時間做爲全局惟一的 id。可是問題是,併發很高的時候,好比一秒併發幾千,會有重複的狀況,這個確定是不合適的。
通常若是用這個方案,是將當前時間跟不少其餘的業務字段拼接起來,做爲一個 id,若是業務上你以爲能夠接受,那麼也是能夠的。
你能夠將別的業務字段值跟當前時間拼接起來,組成一個全局惟一的編號,好比說訂單編號:時間戳 + 用戶 id + 業務含義編碼。
方案四:SnowFlake 算法的思想分析
SnowFlake 算法,是 Twitter 開源的分佈式 id 生成算法。其核心思想就是:使用一個 64 bit 的 long 型的數字做爲全局惟一 id。
這 64 個 bit 中,其中 1 個 bit 是不用的,而後用其中的 41 bit 做爲毫秒數,用 10 bit 做爲工做機器 id,12 bit 做爲序列號。
給你們舉個例子吧,好比下面那個 64 bit 的 long 型數字:
第一個部分,是 1 個 bit:0,這個是無心義的。
第二個部分是 41 個 bit:表示的是時間戳。
第三個部分是 5 個 bit:表示的是機房 id,10001。
第四個部分是 5 個 bit:表示的是機器 id,1 1001。
第五個部分是 12 個 bit:表示的序號,就是某個機房某臺機器上這一毫秒內同時生成的 id 的序號,0000 00000000。
①1 bit:是不用的,爲啥呢?
由於二進制裏第一個 bit 爲若是是 1,那麼都是負數,可是咱們生成的 id 都是正數,因此第一個 bit 統一都是 0。
②41 bit:表示的是時間戳,單位是毫秒。
41 bit 能夠表示的數字多達 2^41 - 1,也就是能夠標識 2 ^ 41 - 1 個毫秒值,換算成年就是表示 69 年的時間。
③10 bit:記錄工做機器 id,表明的是這個服務最多能夠部署在 2^10 臺機器上,也就是 1024 臺機器。
可是 10 bit 裏 5 個 bit 表明機房 id,5 個 bit 表明機器 id。意思就是最多表明 2 ^ 5 個機房(32 個機房),每一個機房裏能夠表明 2 ^ 5 個機器(32 臺機器)。
④12 bit:這個是用來記錄同一個毫秒內產生的不一樣 id。
12 bit 能夠表明的最大正整數是 2 ^ 12 - 1 = 4096,也就是說能夠用這個 12 bit 表明的數字來區分同一個毫秒內的 4096 個不一樣的 id。
簡單來講,你的某個服務假設要生成一個全局惟一 id,那麼就能夠發送一個請求給部署了 SnowFlake 算法的系統,由這個 SnowFlake 算法系統來生成惟一 id。
這個 SnowFlake 算法系統首先確定是知道本身所在的機房和機器的,好比機房 id = 17,機器 id = 12。
接着 SnowFlake 算法系統接收到這個請求以後,首先就會用二進制位運算的方式生成一個 64 bit 的 long 型 id,64 個 bit 中的第一個 bit 是無心義的。
接着 41 個 bit,就能夠用當前時間戳(單位到毫秒),而後接着 5 個 bit 設置上這個機房 id,還有 5 個 bit 設置上機器 id。
最後再判斷一下,當前這臺機房的這臺機器上這一毫秒內,這是第幾個請求,給此次生成 id 的請求累加一個序號,做爲最後的 12 個 bit。
最終一個 64 個 bit 的 id 就出來了,相似於:
這個算法能夠保證說,一個機房的一臺機器上,在同一毫秒內,生成了一個惟一的 id。可能一個毫秒內會生成多個 id,可是有最後 12 個 bit 的序號來區分開來。
下面咱們簡單看看這個 SnowFlake 算法的一個代碼實現,這就是個示例,你們若是理解了這個意思以後,之後能夠本身嘗試改造這個算法。
總之就是用一個 64 bit 的數字中各個 bit 位來設置不一樣的標誌位,區分每個 id。
SnowFlake 算法的實現代碼以下:
public class IdWorker {
private long workerId; // 這個就是表明了機器id
private long datacenterId; // 這個就是表明了機房id
private long sequence; // 這個就是表明了一毫秒內生成的多個id的最新序號
public IdWorker(long workerId, long datacenterId, long sequence) {
// sanity check for workerId
// 這兒不就檢查了一下,要求就是你傳遞進來的機房id和機器id不能超過32,不能小於0
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(
String.format("worker Id can't be greater than %d or less than 0",maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(
String.format("datacenter Id can't be greater than %d or less than 0",maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
this.sequence = sequence;
}
private long twepoch = 1288834974657L;
private long workerIdBits = 5L;
private long datacenterIdBits = 5L;
// 這個是二進制運算,就是5 bit最多隻能有31個數字,也就是說機器id最多隻能是32之內
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 這個是一個意思,就是5 bit最多隻能有31個數字,機房id最多隻能是32之內
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
private long sequenceBits = 12L;
private long workerIdShift = sequenceBits;
private long datacenterIdShift = sequenceBits + workerIdBits;
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private long sequenceMask = -1L ^ (-1L << sequenceBits);
private long lastTimestamp = -1L;
public long getWorkerId(){
return workerId;
}
public long getDatacenterId() {
return datacenterId;
}
public long getTimestamp() {
return System.currentTimeMillis();
}
// 這個是核心方法,經過調用nextId()方法,讓當前這臺機器上的snowflake算法程序生成一個全局惟一的id
public synchronized long nextId() {
// 這兒就是獲取當前時間戳,單位是毫秒
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
System.err.printf(
"clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
// 下面是說假設在同一個毫秒內,又發送了一個請求生成一個id
// 這個時候就得把seqence序號給遞增1,最多就是4096
if (lastTimestamp == timestamp) {
// 這個意思是說一個毫秒內最多隻能有4096個數字,不管你傳遞多少進來,
//這個位運算保證始終就是在4096這個範圍內,避免你本身傳遞個sequence超過了4096這個範圍
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
// 這兒記錄一下最近一次生成id的時間戳,單位是毫秒
lastTimestamp = timestamp;
// 這兒就是最核心的二進制位運算操做,生成一個64bit的id
// 先將當前時間戳左移,放到41 bit那兒;將機房id左移放到5 bit那兒;將機器id左移放到5 bit那兒;將序號放最後12 bit
// 最後拼接起來成一個64 bit的二進制數字,轉換成10進制就是個long型
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) | sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
private long timeGen(){
return System.currentTimeMillis();
}
//---------------測試---------------
public static void main(String[] args) {
IdWorker worker = new IdWorker(1,1,1);
for (int i = 0; i < 30; i++) {
System.out.println(worker.nextId());
}
}
}
複製代碼
SnowFlake 算法一個小小的改進思路:其實在實際的開發中,這個SnowFlake算法能夠作一點點改進。
由於你們能夠考慮一下,咱們在生成惟一 id 的時候,通常都須要指定一個表名,好比說訂單表的惟一 id。因此上面那 64 個 bit 中,表明機房的那 5 個 bit,可使用業務表名稱來替代,好比用 00001 表明的是訂單表。
由於其實不少時候,機房並無那麼多,因此那 5 個 bit 用作機房 id 可能意義不是太大。
這樣就能夠作到,SnowFlake 算法系統的每一臺機器,對一個業務表,在某一毫秒內,能夠生成一個惟一的 id,一毫秒內生成不少 id,用最後 12 個 bit 來區分序號對待。
讀寫分離來支撐按需擴容以及性能提高
這個時候總體效果已經挺不錯了,大量分表的策略保證可能將來 10 年,每一個表的數據量都不會太大,這能夠保證單表內的 SQL 執行效率和性能。
而後多臺數據庫的拆分方式,能夠保證每臺數據庫服務器承載一部分的讀寫請求,下降每臺服務器的負載。
可是此時還有一個問題,假如說每臺數據庫服務器承載每秒 2000 的請求,而後其中 400 請求是寫入,1600 請求是查詢。
也就是說,增刪改的 SQL 才佔到了 20% 的比例,80% 的請求是查詢。此時假如說隨着用戶量愈來愈大,又變成每臺服務器承載 4000 請求了。
那麼其中 800 請求是寫入,3200 請求是查詢,若是說你按照目前的狀況來擴容,就須要增長一臺數據庫服務器。
可是此時可能就會涉及到表的遷移,由於須要遷移一部分表到新的數據庫服務器上去,是否是很麻煩?
其實徹底不必,數據庫通常都支持讀寫分離,也就是作主從架構。
寫入的時候寫入主數據庫服務器,查詢的時候讀取從數據庫服務器,就可讓一個表的讀寫請求分開落地到不一樣的數據庫上去執行。
這樣的話,假如寫入主庫的請求是每秒 400,查詢從庫的請求是每秒 1600。
那麼圖大概以下所示:
寫入主庫的時候,會自動同步數據到從庫上去,保證主庫和從庫數據一致。
而後查詢的時候都是走從庫去查詢的,這就經過數據庫的主從架構實現了讀寫分離的效果了。
如今的好處就是,假如說如今主庫寫請求增長到 800,這個無所謂,不須要擴容。而後從庫的讀請求增長到了 3200,須要擴容了。
這時,你直接給主庫再掛載一個新的從庫就能夠了,兩個從庫,每一個從庫支撐 1600 的讀請求,不須要由於讀請求增加來擴容主庫。
實際上線上生產你會發現,讀請求的增加速度遠遠高於寫請求,因此讀寫分離以後,大部分時候就是擴容從庫支撐更高的讀請求就能夠了。
並且另一點,對同一個表,若是你既寫入數據(涉及加鎖),還從該表查詢數據,可能會牽扯到鎖衝突等問題,不管是寫性能仍是讀性能,都會有影響。
因此一旦讀寫分離以後,對主庫的表就僅僅是寫入,沒任何查詢會影響他,對從庫的表就僅僅是查詢。
高併發下的數據庫架構設計總結
從大的一個簡化的角度來講,高併發的場景下,數據庫層面的架構確定是須要通過精心的設計的。
尤爲是涉及到分庫來支撐高併發的請求,大量分表保證每一個表的數據量別太大,讀寫分離實現主庫和從庫按需擴容以及性能保證。
這篇文章就是從一個大的角度來梳理了一下思路,各位同窗能夠結合本身公司的業務和項目來考慮本身的系統如何作分庫分表。
另外就是,具體的分庫分表落地的時候,須要藉助數據庫中間件來實現分庫分表和讀寫分離,你們能夠本身參考 Sharding-JDBC 或者 MyCAT 的官網便可,裏面的文檔都有詳細的使用描述。
·END·
路雖遠,行則必至
微信ID:cxydczzl