通俗易懂:如何設計能支撐百萬併發的數據庫架構?

1、引言


相信看到這個標題,很多人的第一反應就是:對數據庫進行分庫分表啊!但是實際上,數據庫層面的分庫分表到底是用來幹什麼的,其不同的作用如何應對不同的場景,我覺得很多同學可能都沒搞清楚。

本篇文章我們一起來學習一下,對於一個支撐日活百萬用戶的高併發系統,數據庫架構應該如何設計呢?

本文的討論和分享,將用一個創業公司的發展作爲背景引入,方便大家理解。

2、相關文章


高性能數據庫方面的文章:
 


分佈式架構方面的入門文章:
 

 

3、小型系統的典型數據庫單機架構和明顯的瓶頸


假如我們現在是一個小創業公司,註冊用戶就 20 萬,每天活躍用戶就 1 萬,每天單表數據量就 1000,然後高峯期每秒鐘併發請求最多就 10。

天吶!就這種系統,隨便找一個有幾年工作經驗的高級工程師,然後帶幾個年輕工程師,隨便乾乾都可以做出來。

因爲這樣的系統,實際上主要就是在前期進行快速的業務功能開發,搞一個單塊系統部署在一臺服務器上,然後連接一個數據庫就可以了。

接着大家就是不停地在一個工程裏填充進去各種業務代碼,儘快把公司的業務支撐起來。

如下圖所示:
通俗易懂:如何設計能支撐百萬併發的數據庫架構?_1.jpg 

結果呢,沒想到我們運氣這麼好,碰上個優秀的 CEO 帶着我們走上了康莊大道!

公司業務發展迅猛,過了幾個月,註冊用戶數達到了 2000 萬!每天活躍用戶數 100 萬!每天單表新增數據量達到 50 萬條!高峯期每秒請求量達到 1 萬!

同時公司還順帶着融資了兩輪,估值達到了驚人的幾億美金!一隻朝氣蓬勃的幼年獨角獸的節奏!

好吧,現在大家感覺壓力已經有點大了,爲啥呢?因爲每天單表新增 50 萬條數據,一個月就多 1500 萬條數據,一年下來單表會達到上億條數據。

經過一段時間的運行,現在咱們單表已經兩三千萬條數據了,勉強還能支撐着。

但是,眼見着系統訪問數據庫的性能怎麼越來越差呢,單表數據量越來越大,拖垮了一些複雜查詢 SQL 的性能啊!

然後高峯期請求現在是每秒 1 萬,咱們的系統在線上部署了 20 臺機器,平均每臺機器每秒支撐 500 請求,這個還能抗住,沒啥大問題。但是數據庫層面呢? 

如果說此時你還是一臺數據庫服務器在支撐每秒上萬的請求,負責任的告訴你,每次高峯期會出現下述問題:
 

  • 1)你的數據庫服務器的磁盤 IO、網絡帶寬、CPU 負載、內存消耗,都會達到非常高的情況,數據庫所在服務器的整體負載會非常重,甚至都快不堪重負了;
  • 2)高峯期時,本來你單表數據量就很大,SQL 性能就不太好,這時加上你的數據庫服務器負載太高導致性能下降,就會發現你的 SQL 性能更差了;
  • 3)最明顯的一個感覺,就是你的系統在高峯期各個功能都運行的很慢,用戶體驗很差,點一個按鈕可能要幾十秒纔出來結果;
  • 4)如果你運氣不太好,數據庫服務器的配置不是特別的高的話,弄不好你還會經歷數據庫宕機的情況,因爲負載太高對數據庫壓力太大了。

 

4、多臺服務器分庫支撐高併發讀寫


首先我們先考慮第一個問題,數據庫每秒上萬的併發請求應該如何來支撐呢?

要搞清楚這個問題,先得明白一般數據庫部署在什麼配置的服務器上。通常來說,假如你用普通配置的服務器來部署數據庫,那也起碼是 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 取模,去對應的服務器上的數據庫裏,從對應的表裏查詢那條數據出來即可。

依據這個思路畫出的圖如下所示,大家可以看看:
通俗易懂:如何設計能支撐百萬併發的數據庫架構?_2.jpg 

做這一步有什麼好處呢?第一個好處,原來比如訂單表就一張表,這個時候不就成了 5 張表了麼,那麼每個表的數據就變成 1/5 了。

假設訂單表一年有 1 億條數據,此時 5 張表裏每張表一年就 2000 萬數據了。

那麼假設當前訂單表裏已經有 2000 萬數據了,此時做了上述拆分,每個表裏就只有 400 萬數據了。

而且每天新增 50 萬數據的話,那麼每個表才新增 10 萬數據,這樣是不是初步緩解了單表數據量過大影響系統性能的問題?

另外就是每秒 1 萬請求到 5 臺數據庫上,每臺數據庫就承載每秒 2000 的請求,是不是一下子把每臺數據庫服務器的併發請求降低到了安全範圍內?

這樣,降低了數據庫的高峯期負載,同時還保證了高峯期的性能。

5、大量分表來保證海量數據下的查詢性能


但是上述的數據庫架構還有一個問題,那就是單表數據量還是過大,現在訂單表才分爲了 5 張表,那麼如果訂單一年有 1 億條,每個表就有 2000 萬條,這也還是太大了。

所以還應該繼續分表,大量分表。比如可以把訂單表一共拆分爲 1024 張表,這樣 1 億數據量的話,分散到每個表裏也就才 10 萬量級的數據量,然後這上千張表分散在 5 臺數據庫裏就可以了。

在寫入數據的時候,需要做兩次路由,先對訂單 ID Hash 後對數據庫的數量取模,可以路由到一臺數據庫上,然後再對那臺數據庫上的表數量取模,就可以路由到數據庫上的一個表裏了。

通過這個步驟,就可以讓每個表裏的數據量非常小,每年 1 億數據增長,但是到每個表裏才 10 萬條數據增長,這個系統運行 10 年,每個表裏可能才百萬級的數據量。

這樣可以一次性爲系統未來的運行做好充足的準備,看下面的圖,一起來感受一下:
通俗易懂:如何設計能支撐百萬併發的數據庫架構?_3.jpg 

6、如何解決分佈數據庫架構中全局唯一ID的生成?

 

6.1概述


在分庫分表之後你必然要面對的一個問題,就是 ID 咋生成?因爲要是一個表分成多個表之後,每個表的 ID 都是從 1 開始累加自增長,那肯定不對啊。

舉個例子,你的訂單表拆分爲了 1024 張訂單表,每個表的 ID 都從 1 開始累加,這個肯定有問題了!

你的系統就沒辦法根據表主鍵來查詢訂單了,比如 ID = 50 這個訂單,在每個表裏都有!

所以此時就需要分佈式架構下的全局唯一 ID 生成的方案了,在分庫分表之後,對於插入數據庫中的核心 ID,不能直接簡單使用表自增 ID,要全局生成唯一 ID,然後插入各個表中,保證每個表內的某個 ID,全局唯一。

比如說訂單表雖然拆分爲了 1024 張表,但是 ID = 50 這個訂單,只會存在於一個表裏。

那麼如何實現全局唯一 ID 呢?有以下幾種方案,我們一一一來看看。
 

6.2方案一:獨立數據庫自增 ID


這個方案就是說你的系統每次要生成一個 ID,都是往一個獨立庫的一個獨立表裏插入一條沒什麼業務含義的數據,然後獲取一個數據庫自增的一個 ID。拿到這個 ID 之後再往對應的分庫分表裏去寫入。

比如說你有一個 auto_id 庫,裏面就一個表,叫做 auto_id 表,有一個 ID 是自增長的。

那麼你每次要獲取一個全局唯一 ID,直接往這個表裏插入一條記錄,獲取一個全局唯一 ID即可,然後這個全局唯一 ID 就可以插入訂單的分庫分表中。

這個方案的好處就是方便簡單,誰都會用。缺點就是單庫生成自增 ID,要是高併發的話,就會有瓶頸的,因爲 auto_id 庫要是承載個每秒幾萬併發,肯定是不現實的了。
 

6.3方案二:UUID


這個每個人都應該知道吧,就是用 UUID 生成一個全局唯一的 ID。

好處就是每個系統本地生成,不要基於數據庫來了。不好之處就是,UUID 太長了,作爲主鍵性能太差了,不適合用於主鍵。

如果你是要隨機生成個什麼文件名了,編號之類的,你可以用 UUID,但是作爲主鍵是不能用 UUID 的。
 

6.4方案三:獲取系統當前時間


這個方案的意思就是獲取當前時間作爲全局唯一的 ID。但是問題是,併發很高的時候,比如一秒併發幾千,會有重複的情況,這個肯定是不合適的。

一般如果用這個方案,是將當前時間跟很多其他的業務字段拼接起來,作爲一個 ID,如果業務上你覺得可以接受,那麼也是可以的。

你可以將別的業務字段值跟當前時間拼接起來,組成一個全局唯一的編號,比如說訂單編號:時間戳 + 用戶 ID + 業務含義編碼。
 

6.5方案四:SnowFlake 算法的思想分析


SnowFlake 算法,是 Twitter 開源的分佈式 ID 生成算法。其核心思想就是:使用一個 64 bit 的 long 型的數字作爲全局唯一 ID。

這 64 個 bit 中,其中 1 個 bit 是不用的,然後用其中的 41 bit 作爲毫秒數,用 10 bit 作爲工作機器 ID,12 bit 作爲序列號。

通俗易懂:如何設計能支撐百萬併發的數據庫架構?_4.jpg

給大家舉個例子吧,如上圖所示,比如下面那個 64 bit 的 long 型數字:
 

  • 1)第一個部分,是 1 個 bit:0,這個是無意義的;
  • 2)第二個部分,是 41 個 bit:表示的是時間戳;
  • 3)第三個部分,是 5 個 bit:表示的是機房 ID,10001;
  • 4)第四個部分,是 5 個 bit:表示的是機器 ID,1 1001;
  • 5)第五個部分,是 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 就出來了,類似於:
通俗易懂:如何設計能支撐百萬併發的數據庫架構?_5.jpg

這個算法可以保證說,一個機房的一臺機器上,在同一毫秒內,生成了一個唯一的 ID。可能一個毫秒內會生成多個 ID,但是有最後 12 個 bit 的序號來區分開來。

下面我們簡單看看這個 SnowFlake 算法的一個代碼實現,這就是個示例,大家如果理解了這個意思之後,以後可以自己嘗試改造這個算法。

總之就是用一個 64 bit 的數字中各個 bit 位來設置不同的標誌位,區分每一個 ID。

SnowFlake 算法的實現代碼如下:

001

002

003

004

005

006

007

008

009

010

011

012

013

014

015

016

017

018

019

020

021

022

023

024

025

026

027

028

029

030

031

032

033

034

035

036

037

038

039

040

041

042

043

044

045

046

047

048

049

050

051

052

053

054

055

056

057

058

059

060

061

062

063

064

065

066

067

068

069

070

071

072

073

074

075

076

077

078

079

080

081

082

083

084

085

086

087

088

089

090

091

092

093

094

095

096

097

098

099

100

101

102

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 來區分序號對待。

7、讀寫分離來支撐按需擴容以及性能提升


這個時候整體效果已經挺不錯了,大量分表的策略保證可能未來 10 年,每個表的數據量都不會太大,這可以保證單表內的 SQL 執行效率和性能。

然後多臺數據庫的拆分方式,可以保證每臺數據庫服務器承載一部分的讀寫請求,降低每臺服務器的負載。

但是此時還有一個問題,假如說每臺數據庫服務器承載每秒 2000 的請求,然後其中 400 請求是寫入,1600 請求是查詢。

也就是說,增刪改的 SQL 才佔到了 20% 的比例,80% 的請求是查詢。此時假如說隨着用戶量越來越大,又變成每臺服務器承載 4000 請求了。

那麼其中 800 請求是寫入,3200 請求是查詢,如果說你按照目前的情況來擴容,就需要增加一臺數據庫服務器。

但是此時可能就會涉及到表的遷移,因爲需要遷移一部分表到新的數據庫服務器上去,是不是很麻煩?

其實完全沒必要,數據庫一般都支持讀寫分離,也就是做主從架構。

寫入的時候寫入主數據庫服務器,查詢的時候讀取從數據庫服務器,就可以讓一個表的讀寫請求分開落地到不同的數據庫上去執行。

這樣的話,假如寫入主庫的請求是每秒 400,查詢從庫的請求是每秒 1600。

那麼圖大概如下所示:
通俗易懂:如何設計能支撐百萬併發的數據庫架構?_6.jpg 

寫入主庫的時候,會自動同步數據到從庫上去,保證主庫和從庫數據一致。

然後查詢的時候都是走從庫去查詢的,這就通過數據庫的主從架構實現了讀寫分離的效果了。

現在的好處就是,假如說現在主庫寫請求增加到 800,這個無所謂,不需要擴容。然後從庫的讀請求增加到了 3200,需要擴容了。

這時,你直接給主庫再掛載一個新的從庫就可以了,兩個從庫,每個從庫支撐 1600 的讀請求,不需要因爲讀請求增長來擴容主庫。

實際上線上生產你會發現,讀請求的增長速度遠遠高於寫請求,所以讀寫分離之後,大部分時候就是擴容從庫支撐更高的讀請求就可以了。

而且另外一點,對同一個表,如果你既寫入數據(涉及加鎖),還從該表查詢數據,可能會牽扯到鎖衝突等問題,無論是寫性能還是讀性能,都會有影響。

所以一旦讀寫分離之後,對主庫的表就僅僅是寫入,沒任何查詢會影響他,對從庫的表就僅僅是查詢。

8、高併發下的數據庫架構設計總結


從大的一個簡化的角度來說,高併發的場景下,數據庫層面的架構肯定是需要經過精心的設計的。

尤其是涉及到分庫來支撐高併發的請求,大量分表保證每個表的數據量別太大,讀寫分離實現主庫和從庫按需擴容以及性能保證。

這篇文章就是從一個大的角度來梳理了一下思路,各位同學可以結合自己公司的業務和項目來考慮自己的系統如何做分庫分表。

另外就是,具體的分庫分表落地的時候,需要藉助數據庫中間件來實現分庫分表和讀寫分離,大家可以自己參考 Sharding-JDBC,或者是 MyCAT 的官網即可,裏面的文檔都有詳細的使用描述。

(原文鏈接:https://juejin.im/post/5c6a9f25518825787e69e70a

 

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