高頻數據庫分庫分表面試題解析

一.爲啥要分庫分表?

假如我們現在是一個小創業公司(或者是一個 BAT 公司剛興起的一個新部門),現在註冊用戶就 20 萬,每天活躍用戶就 1 萬,每天單表數據量就 1000,然後高峯期每秒鐘併發請求最多就 10。天,就這種系統,隨便找一個有幾年工作經驗的,然後帶幾個剛培訓出來的,隨便乾乾都可以。

結果沒想到我們運氣居然這麼好,碰上個 CEO 帶着我們走上了康莊大道,業務發展迅猛,過了幾個月,註冊用戶數達到了 2000 萬!每天活躍用戶數 100 萬!每天單表數據量 10 萬條!高峯期每秒最大請求達到 1000!同時公司還順帶着融資了兩輪,進賬了幾個億人民幣啊!公司估值達到了驚人的幾億美金!這是小獨角獸的節奏!

好吧,沒事,現在大家感覺壓力已經有點大了,爲啥呢?因爲每天多 10 萬條數據,一個月就多 300 萬條數據,現在咱們單表已經幾百萬數據了,馬上就破千萬了。但是勉強還能撐着。高峯期請求現在是 1000,咱們線上部署了幾臺機器,負載均衡搞了一下,數據庫撐 1000QPS 也還湊合。但是大家現在開始感覺有點擔心了,接下來咋整呢......

再接下來幾個月,我的天,CEO 太牛逼了,公司用戶數已經達到 1 億,公司繼續融資幾十億人民幣啊!公司估值達到了驚人的幾十億美金,成爲了國內今年最牛逼的明星創業公司!天,我們太幸運了。

但是我們同時也是不幸的,因爲此時每天活躍用戶數上千萬,每天單表新增數據多達 50 萬,目前一個表總數據量都已經達到了兩三千萬了!扛不住啊!數據庫磁盤容量不斷消耗掉!高峯期併發達到驚人的 5000~8000!別開玩笑了,哥。我跟你保證,你的系統支撐不到現在,已經掛掉了!好吧,所以你看到這裏差不多就理解分庫分表是怎麼回事兒了,實際上這是跟着你的公司業務發展走的,你公司業務發展越好,用戶就越多,數據量越大,請求量越大,那你單個數據庫一定扛不住。

分表:

比如你單表都幾千萬數據了,你確定你能扛住麼?絕對不行,單表數據量太大,會極大影響你的 sql 執行的性能,到了後面你的 sql 可能就跑的很慢了。一般來說,就以我的經驗來看,單表到幾百萬的時候,性能就會相對差一些了,你就得分表了。分表是啥意思?就是把一個表的數據放到多個表中,然後查詢的時候你就查一個表。比如按照用戶 id 來分表,將一個用戶的數據就放在一個表中。然後操作的時候你對一個用戶就操作那個表就好了。這樣可以控制每個表的數據量在可控的範圍內,比如每個表就固定在 200 萬以內。

分庫:

分庫是啥意思?就是你一個庫一般我們經驗而言,最多支撐到併發 2000,一定要擴容了,而且一個健康的單庫併發值你最好保持在每秒 1000 左右,不要太大。那麼你可以將一個庫的數據拆分到多個庫中,訪問的時候就訪問一個庫好了。

  分庫分表前 分庫分表後
併發支撐情況 MySQL 單機部署,扛不住高併發 MySQL從單機到多機,能承受的併發增加了多倍
磁盤使用情況 MySQL 單機磁盤容量幾乎撐滿 拆分爲多個庫,數據庫服務器磁盤使用率大大降低
SQL 執行性能 單表數據量太大,SQL 越跑越慢 單表數據量減少,SQL 執行效率明顯提升

二.用過哪些分庫分表中間件?不同的分庫分表中間件都有什麼優點和缺點?

  • cobar:阿里 b2b 團隊開發和開源的,屬於 proxy 層方案。早些年還可以用,但是最近幾年都沒更新了,基本沒啥人用,差不多算是被拋棄的狀態吧。而且不支持讀寫分離、存儲過程、跨庫 join 和分頁等操作。
  • atlas:360 開源的,屬於 proxy 層方案,以前是有一些公司在用的,但是確實有一個很大的問題就是社區最新的維護都在 5 年前了。所以,現在用的公司基本也很少了。
  • sharding-jdbc:噹噹開源的,屬於 client 層方案。確實之前用的還比較多一些,因爲 SQL 語法支持也比較多,沒有太多限制,而且目前推出到了 2.0 版本,支持分庫分表、讀寫分離、分佈式 id 生成、柔性事務(最大努力通知型事務、TCC 事務)。而且確實之前使用的公司會比較多一些(這個在官網有登記使用的公司,可以看到從 2017 年一直到現在,是有不少公司在用的),目前社區也還一直在開發和維護,還算是比較活躍,算是一個現在也可以選擇的方案。
  • mycat:基於 cobar 改造的,屬於 proxy 層方案,支持的功能非常完善,而且目前應該是非常火的而且不斷流行的數據庫中間件,社區很活躍,也有一些公司開始在用了。但是確實相比於 sharding jdbc 來說,年輕一些,經歷的錘鍊少一些。

綜上,現在其實建議考量的,就是 sharding-jdbc 和 mycat,這兩個都可以去考慮使用。sharding-jdbc 這種 client 層方案的優點在於不用部署,運維成本低,不需要代理層的二次轉發請求,性能很高,但是如果遇到升級啥的需要各個系統都重新升級版本再發布,各個系統都需要耦合 sharding-jdbc 的依賴mycat 這種 proxy 層方案的缺點在於需要部署,自己運維一套中間件,運維成本高,但是好處在於對於各個項目是透明的,如果遇到升級之類的都是自己中間件那裏搞就行了。通常來說,這兩個方案其實都可以選用,建議中小型公司選用 sharding-jdbc,client 層方案輕便,而且維護成本低,不需要額外增派人手,而且中小型公司系統複雜度會低一些,項目也沒那麼多;但是中大型公司最好還是選用 mycat 這類 proxy 層方案,因爲可能大公司系統和項目非常多,團隊很大,人員充足,那麼最好是專門弄個人來研究和維護 mycat,然後大量項目直接透明使用即可。

三:如何對數據庫進行垂直拆分或水平拆分的?

1.垂直拆分

就是把一個有很多字段的表給拆分成多個表,或者是多個庫上去。每個庫表的結構都不一樣,每個庫表都包含部分字段。一般來說,會將較少的訪問頻率很高的字段放到一個表裏去,然後將較多的訪問頻率很低的字段放到另外一個表裏去。因爲數據庫是有緩存的,你訪問頻率高的行字段越少,就可以在緩存裏緩存更多的行,性能就越好。這個一般在表層面做的較多一些。

2.水平拆分

就是把一個表的數據給弄到多個庫的多個表裏去,但是每個庫的表結構都一樣,只不過每個庫表放的數據是不同的,所有庫表的數據加起來就是全部數據。水平拆分的意義,就是將數據均勻放更多的庫裏,然後用多個庫來抗更高的併發,還有就是用多個庫的存儲容量來進行擴容。

還有表層面的拆分,就是分表,將一個表變成 N 個表,就是讓每個表的數據量控制在一定範圍內,保證 SQL 的性能。否則單表數據量越大,SQL 性能就越差。一般是 200 萬行左右,不要太多,但是也得看具體你怎麼操作,也可能是 500 萬,或者是 100 萬。你的SQL越複雜,就最好讓單錶行數越少。

好了,無論分庫還是分表,上面說的那些數據庫中間件都是可以支持的。就是基本上那些中間件可以做到你分庫分表之後,中間件可以根據你指定的某個字段值,比如說 userid,自動路由到對應的庫上去,然後再自動路由到對應的表裏去

你就得考慮一下,你的項目裏該如何分庫分表?一般來說,垂直拆分,你可以在表層面來做,對一些字段特別多的表做一下拆分;水平拆分,你可以說是併發承載不了,或者是數據量太大,容量承載不了,你給拆了,按什麼字段來拆,你自己想好;分表,你考慮一下,你如果哪怕是拆到每個庫裏去,併發和容量都ok了,但是每個庫的表還是太大了,那麼你就分表,將這個表分開,保證每個表的數據量並不是很大。

而且這兒還有兩種分庫分表的方式:

  • 一種是按照 range範圍來分,就是每個庫一段連續的數據,這個一般是按比如時間範圍來的,但是這種一般較少用,因爲很容易產生熱點問題,大量的流量都打在最新的數據上了。
  • 或者是按照某個字段hash一下均勻分散,這個較爲常用。

range 來分,好處是擴容的時候很簡單,因爲你只要預備好,給每個月都準備一個庫就可以了,到了一個新的月份的時候,自然而然,就會寫新的庫了;缺點,但是大部分的請求,都是訪問最新的數據。實際生產用 range,要看場景。hash 分發,好處在於說,可以平均分配每個庫的數據量和請求壓力;壞處在於說擴容起來比較麻煩,會有一個數據遷移的過程,之前的數據需要重新計算 hash 值重新分配到不同的庫或表。

四:現在有一個未分庫分表的系統,未來要分庫分表,如何設計纔可以讓系統從未分庫分表動態切換到分庫分表上?

1.停機遷移方案

大家夥兒凌晨 12 點開始運維,網站或者 app 掛個公告,說 0 點到早上 6 點進行運維,無法訪問。接着到 0 點停機,系統停掉,沒有流量寫入了,此時老的單庫單表數據庫靜止了。然後你之前得寫好一個導數的一次性工具,此時直接跑起來,然後將單庫單表的數據嘩嘩譁讀出來,寫到分庫分表裏面去。導數完了之後,就 ok 了,修改系統的數據庫連接配置啥的,包括可能代碼和 SQL 也許有修改,那你就用最新的代碼,然後直接啓動連到新的分庫分表上去。驗證一下,ok了。但是這個方案比較 low,誰都能幹,我們來看看高大上一點的方案。

2.雙寫遷移方案

簡單來說,就是在線上系統裏面,之前所有寫庫的地方,增刪改操作,除了對老庫增刪改,都加上對新庫的增刪改,這就是所謂的雙寫,同時寫倆庫,老庫和新庫。然後系統部署之後,新庫數據差太遠,用之前說的導數工具,跑起來讀老庫數據寫新庫,寫的時候要根據修改時間這類字段判斷這條數據最後修改的時間,除非是讀出來的數據在新庫裏沒有,或者是比新庫的數據新纔會寫。簡單來說,就是不允許用老數據覆蓋新數據。導完一輪之後,有可能數據還是存在不一致,那麼就程序自動做一輪校驗,比對新老庫每個表的每條數據,接着如果有不一樣的,就針對那些不一樣的,從老庫讀數據再次寫。反覆循環,直到兩個庫每個表的數據都完全一致爲止。接着當數據完全一致了,就 ok 了,基於僅僅使用分庫分表的最新代碼,重新部署一次,不就僅僅基於分庫分表在操作了麼,還沒有幾個小時的停機時間,很穩。所以現在基本玩兒數據遷移之類的,都是這麼幹的。

五:如何設計可以動態擴容縮容的分庫分表方案?

1.停機擴容(不推薦)

這個方案就跟停機遷移一樣,步驟幾乎一致,唯一的一點就是那個導數的工具,是把現有庫表的數據抽出來慢慢導入到新的庫和表裏去。但是最好別這麼玩兒,有點不太靠譜,因爲既然分庫分表就說明數據量實在是太大了,可能多達幾億條,甚至幾十億,你這麼玩兒,可能會出問題。從單庫單表遷移到分庫分表的時候,數據量並不是很大,單表最大也就兩三千萬。那麼你寫個工具,多弄幾臺機器並行跑,1小時數據就導完了。這沒有問題。如果 3 個庫 + 12 個表,跑了一段時間了,數據量都 1~2 億了。光是導 2 億數據,都要導個幾個小時,6 點,剛剛導完數據,還要搞後續的修改配置,重啓系統,測試驗證,10 點纔可以搞完。所以不能這麼搞

2.擴容方案升級

一開始上來就是 32 個庫,每個庫 32 個表,那麼總共是 1024 張表。談分庫分表的擴容,第一次分庫分表,就一次性給他分個夠,32 個庫,1024 張表,可能對大部分的中小型互聯網公司來說,已經可以支撐好幾年了。起始有4臺數據庫服務器,每臺服務器上8個數據庫,每個庫有32張表,如果容量不夠了,在增加4臺數據庫服務器,從原來的服務器中每個服務器遷移出4個庫(dba做數據遷移)放到現在新加的服務器上,這樣就有8臺服務器,每個服務器4個數據庫,每個庫32張表,以此類推,最多擴到32臺服務器,每臺服務器1個庫,每個庫32張表,爲啥只遷移庫不遷移表?因爲這樣一個庫中的表不會變化,對於應用程序不用做遷移數據的工作,因爲程序中的數據路由是基於庫的。數據寫入原則:根據某個 id 先根據 32 取模路由到庫,再根據 32 取模路由到庫裏的表。

 3.縮容:就是按照上面擴容的方式按倍數縮容就可以了,然後修改一下路由規則。

六:分庫分表之後,id 主鍵如何處理?

1.數據庫自增 id

每次得到一個 id,都是往一個庫的一個表裏插入一條沒什麼業務含義的數據,然後獲取一個數據庫自增的一個 id。拿到這個 id 之後再往對應的分庫分表裏去寫入。這個方案的好處就是方便簡單,誰都會用;缺點就是單庫生成自增 id,要是高併發的話,就會有瓶頸的;如果你硬是要改進一下,那麼就專門開一個服務出來,這個服務每次就拿到當前 id 最大值,然後自己遞增幾個 id,一次性返回一批 id,然後再把當前最大 id 值修改成遞增幾個 id 之後的一個值;但是無論如何都是基於單個數據庫。適合的場景:你分庫分表就倆原因,要不就是單庫併發太高,要不就是單庫數據量太大;除非是你併發不高,但是數據量太大導致的分庫分表擴容,你可以用這個方案,因爲可能每秒最高併發最多就幾百,那麼就走單獨的一個庫和表生成自增主鍵即可

2.設置數據庫 sequence 或者表自增字段步長

可以通過設置數據庫 sequence 或者表的自增字段步長來進行水平伸縮。比如說,現在有 8 個服務節點,每個服務節點使用一個 sequence 功能來產生 ID,每個 sequence 的起始 ID 不同,並且依次遞增,步長都是 8。適合的場景:在用戶防止產生的 ID 重複時,這種方案實現起來比較簡單,也能達到性能目標。但是服務節點固定,步長也固定,將來如果還要增加服務節點,就不好搞了。

3.UUID:好處就是本地生成,不要基於數據庫來了;不好之處就是,UUID 太長了、佔用空間大,作爲主鍵性能太差了;

4.獲取系統當前時間

這個就是獲取當前時間即可,但是問題是,併發很高的時候,比如一秒併發幾千,會有重複的情況,這個是肯定不合適的。基本就不用考慮了適合的場景:一般如果用這個方案,是將當前時間跟很多其他的業務字段拼接起來,作爲一個 id,如果業務上你覺得可以接受,那麼也是可以的。你可以將別的業務字段值跟當前時間拼接起來,組成一個全局唯一的編號

5.snowflake 算法(雪花算法)

snowflake 算法是 twitter 開源的分佈式 id 生成算法,採用 Scala 語言實現,是把一個 64 位的 long 型的 id,1 個 bit 是不用的,用其中的 41 bit 作爲毫秒數,用 10 bit 作爲工作機器 id,12 bit 作爲序列號。

0 | 0001100 10100010 10111110 10001001 01011100 00 | 10001 | 11001 | 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。

實現代碼:

public class IdWorker {

    private long workerId;//機器id
    private long datacenterId;//機房id
    private long sequence;

    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));
        }
        System.out.printf(
                "worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
                timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);

        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();
    }

    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));
        }

        if (lastTimestamp == timestamp) {
            // 這個意思是說一個毫秒內最多隻能有4096個數字
            // 無論你傳遞多少進來,這個位運算保證始終就是在4096這個範圍內,避免你自己傳遞個sequence超過了4096這個範圍
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }

        // 這兒記錄一下最近一次生成id的時間戳,單位是毫秒
        lastTimestamp = timestamp;

        // 這兒就是將時間戳左移,放到 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());
        }
    }

}

七:說說MySQL讀寫分離原理?主從複製延時咋解決?

1.如何實現 MySQL 的讀寫分離

就是基於主從複製架構,簡單來說,就搞一個主庫,掛多個從庫,然後我們就單單只是寫主庫,然後主庫會自動把數據給同步到從庫上去。

2.MySQL主從複製原理

主庫將變更寫入 binlog 日誌,然後從庫連接到主庫之後,從庫有一個 IO 線程,將主庫的 binlog 日誌拷貝到自己本地,寫入一個 relay 中繼日誌中。接着從庫中有一個 SQL 線程會從中繼日誌讀取 binlog,然後執行 binlog 日誌中的內容,也就是在自己本地再次執行一遍 SQL,這樣就可以保證自己跟主庫的數據是一樣的

這裏有一個非常重要的一點,就是從庫同步主庫數據的過程是串行化的(從庫拉取binlog日誌在mysql5.6.x後是多線程的,但是從庫讀取中繼日誌是單線程的),也就是說主庫上並行的操作,在從庫上會串行執行。所以這就是一個非常重要的點了,由於從庫從主庫拷貝日誌以及串行執行 SQL 的特點,在高併發場景下,從庫的數據一定會比主庫慢一些,是有延時的,主庫寫併發越高,從庫延遲越高。所以經常出現,剛寫入主庫的數據可能是讀不到的,要過幾十毫秒,甚至幾百毫秒才能讀取到。而且這裏還有另外一個問題,就是如果主庫突然宕機,然後恰好數據還沒同步到從庫,那麼有些數據可能在從庫上是沒有的,有些數據可能就丟失了。所以 MySQL 實際上在這一塊有兩個機制,一個是半同步複製,用來解決主庫數據丟失問題;一個是並行複製,用來解決主從同步延時問題這個所謂半同步複製,也叫 semi-sync 複製,指的就是主庫寫入 binlog 日誌之後,就會將強制此時立即將數據同步到從庫,從庫將日誌寫入自己本地的 relay log 之後,接着會返回一個 ack 給主庫,主庫接收到至少一個從庫的 ack 之後纔會認爲寫操作完成了,客戶端在寫時,主庫宕機,客戶端從新寫請求,此時從庫可能升級爲主庫,在寫會成功。所謂並行複製(mysql5.7),指的是從庫開啓多SQL個線程,並行讀取 relay log 中不同庫的日誌,然後併發重放不同庫的日誌,這是庫級別的並行。

經驗值:主庫寫併發1000/s,從庫延遲幾毫秒,主庫寫併發2000/s,從庫延遲幾十毫秒,主庫寫併發4000/s,6000/s,8000/s都快宕機了,從庫延遲達到幾秒。

3.解決主從複製延遲(精華)

通過show status查看 Seconds_Behind_Master,可以看到從庫複製主庫的數據落後了幾 ms。插入主庫,馬上查詢從庫,可能查詢不到,後面操作就失敗。

  • 分庫,將一個主庫拆分爲多個主庫,每個主庫的寫併發就減少了幾倍,此時主從延遲可以忽略不計。
  • 打開 MySQL 支持的並行複製,多個庫並行複製。如果說某個庫的寫入併發就是特別高,單庫寫併發達到了 2000/s,並行複製還是沒意義
  • 重寫代碼,寫代碼的同學,要慎重,插入數據時立馬查詢可能查不到。
  • 如果確實是存在必須先插入,立馬要求就查詢到,然後立馬就要反過來執行一些操作,對這個查詢設置直連主庫。不推薦這種方法,你要是這麼搞,讀寫分離的意義就喪失了。
  • 開啓半同步複製,拉取binlog日誌要是多線程的

八:分庫分表後跨庫join,跨庫分頁實現?

1.跨庫join

全局表:所謂全局表,就是有可能系統中所有模塊都可能會依賴到的一些表。比較類似我們理解的“數據字典”。爲了避免跨庫join查詢,我們可以將這類表在其他每個數據庫中均保存一份。同時,這類數據通常也很少發生修改(甚至幾乎不會),所以也不用太擔心“一致性”問題。

字段冗餘:違反數據庫設計三範式,例如:“訂單表”中保存“賣家Id”的同時,將賣家的“Name”字段也冗餘,這樣查詢訂單詳情的時候就不需要再去查詢“賣家用戶表”。段冗餘能帶來便利,是一種“空間換時間”的體現。但其適用場景也比較有限,比較適合依賴字段較少的情況。最複雜的還是數據一致性問題,這點很難保證,可以藉助數據庫中的觸發器或者在業務代碼層面去保證。當然,也需要結合實際業務場景來看一致性的要求。就像上面例子,如果賣家修改了Name之後,是否需要在訂單信息中同步更新呢?

數據同步:定時A庫中的tab_a表和B庫中tbl_b有關聯,可以定時將指定的表做同步。當然,同步本來會對數據庫帶來一定的影響,需要性能影響和數據時效性中取得一個平衡。這樣來避免複雜的跨庫查詢

系統組裝:在系統層面,通過調用不同模塊的組件或者服務,獲取到數據並進行字段拼裝。我們只需要先獲取“主表”數據,然後再根據關聯關係,調用其他模塊的組件或服務來獲取依賴的其他字段(如例中依賴的用戶信息),最後將數據進行組裝。通常,我們都會通過緩存來避免頻繁RPC通信和數據庫查詢的開銷。

2.跨庫分頁

全局視野法:

正常情況下,全局排序的第3頁數據,每個庫都會包含一部分,由於不清楚到底是哪種情況,所以必須每個庫都返回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條記錄,就是全局視野所需的一頁數據

優點:通過服務層修改SQL語句,擴大數據召回量,能夠得到全局視野,業務無損,精準返回所需數據。

缺點:

  • 每個分庫需要返回更多的數據,增大了網絡傳輸量(耗網絡); 
  • 除了數據庫按照time進行排序,服務層還需要進行二次排序,增大了服務層的計算量(耗CPU); 
  • 最致命的,這個算法隨着頁碼的增大,性能會急劇下降,這是因爲SQL改寫後每個分庫要返回X+Y行數據:返回第3頁,offset中的X=200;假如要返回第100頁,offset中的X=9900,即每個分庫要返回100頁數據,數據量和排序量都將大增,性能平方級下降。

禁止跳頁查詢:

在數據量很大,翻頁數很多的時候,很多產品並不提供“直接跳到指定頁面”的功能,而只提供“下一頁”的功能,這一個小小的業務折衷,就能極大的降低技術方案的複雜度。

  • 首先在各個分庫中查詢第一頁數據,返回到服務層做排序,得到全局第一頁數據
  • 獲取下一頁時,根據上一頁的條件,作爲這次查詢的條件(比如根據時間升序分頁,得到第一頁數據後記錄最後一條記錄的時間max_time,下次分頁以這個時間做過濾條件,time>=max_time),各個分庫再次返回這一頁數據,服務層在全局排序得到數據
  • 這樣循環往復執行,每次返回的都是各個分庫的每頁顯示條數的數據,而不像全局視野法那樣,查詢頁數越深分庫返回到服務層數據就越多

允許數據精度損失:

在數據量較大,數據分佈足夠隨機的情況下,各分庫所有非patition key屬性,在各個分庫上的數據分佈,統計概率情況是一致的。例如,在uid隨機的情況下,使用uid取模分兩庫,db0和db1: 

  • 性別屬性,如果db0庫上的男性用戶佔比70%,則db1上男性用戶佔比也應爲70% 
  • 年齡屬性,如果db0庫上18-28歲少女用戶比例佔比15%,則db1上少女用戶比例也應爲15% 
  • 時間屬性,如果db0庫上每天10:00之前登錄的用戶佔比爲20%,則db1上應該是相同的統計規律 

利用這一原理,要查詢全局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;將原sql語句改寫爲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 假設這三個分庫返回的數據(time, uid)如下: 

可以看到,每個分庫都是返回的按照time排序的一頁數據。

找到所返回3頁全部數據的最小值,比如說:第一個庫,5條數據的time最小值是1487501123 ,第二個庫,5條數據的time最小值是1487501133 ,第三個庫,5條數據的time最小值是1487501143 

故,三頁數據中,time最小值來自第一個庫,time_min=1487501123,這個過程只需要比較各個分庫第一條數據,時間複雜度很低。

查詢二次改寫,第一次改寫的SQL語句是select * from T order by time offset 333 limit 5 ,第二次要改寫成一個between語句,between的起點是time_min,between的終點是原來每個分庫各自返回數據的最大值: 第一個分庫,第一次返回數據的最大值是1487501523 ,所以查詢改寫爲select * from T order by time where time between time_min and 1487501523,第二個分庫,第一次返回數據的最大值是1487501323 ,所以查詢改寫爲select * from T order by time where time between time_min and 1487501323,第三個分庫,第一次返回數據的最大值是1487501553 ,所以查詢改寫爲select * from T order by time where time between time_min and 1487501553,相對第一次查詢,第二次查詢條件放寬了,故第二次查詢會返回比第一次查詢結果集更多的數據,假設這三個分庫返回的數據(time, uid)如下: 

可以看到: 
由於time_min來自原來的分庫一,所以分庫一的返回結果集和第一次查詢相同(所以其實這次訪問是可以省略的); 
分庫二的結果集,比第一次多返回了1條數據,頭部的1條記錄(time最小的記錄)是新的(上圖中粉色記錄); 
分庫三的結果集,比第一次多返回了2條數據,頭部的2條記錄(time最小的2條記錄)是新的(上圖中粉色記錄);

在每個結果集中虛擬一個time_min記錄,找到time_min在全局的offset:

在第一個庫中,time_min在第一個庫的offset是333 
在第二個庫中,(1487501133, uid_aa)的offset是333(根據第一次查詢條件得出的),故虛擬time_min在第二個庫的offset是331 
在第三個庫中,(1487501143, uid_aaa)的offset是333(根據第一次查詢條件得出的),故虛擬time_min在第三個庫的offset是330,綜上,time_min在全局的offset是333+331+330=994

既然得到了time_min在全局的offset,就相當於有了全局視野,根據第二次的結果集,就能夠得到全局offset 1000 limit 5的記錄

第二次查詢在各個分庫返回的結果集是有序的,又知道了time_min在全局的offset是994,一路排下來,容易知道全局offset 1000 limit 5的一頁記錄(上圖中黃色記錄)。是不是非常巧妙?這種方法的優點是:可以精確的返回業務所需數據,每次返回的數據量都非常小,不會隨着翻頁增加數據的返回量。不足是:需要進行兩次數據庫查詢

跨庫分頁原文地址:https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=2651959942&idx=1&sn=e9d3fe111b8a1d44335f798bbb6b9eea&chksm=bd2d075a8a5a8e4cad985b847778aa83056e22931767bb835132c04571b66d5434020fd4147f&mpshare=1&scene=23&srcid=06058n49pOx2xQJoDrL1OyMT#rd

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