1. 程式人生 > 其它 >分散式儲存-關係型資料庫層面Outline

分散式儲存-關係型資料庫層面Outline

分散式儲存-關係型資料庫層面【Outline】

前面我們聊了NoSql中的Redis,但是實際上,大部分公司儲存依然使用的是關係型資料庫,因為在很多場景下,關係型資料庫依然是一個很好的儲存解決方案,而Nosql這些元件實際上做的更多的是一些輔助工作,這一篇想在全域性的層間聊聊,會提到一些分庫分表的一些東西,後面會介紹一些中介軟體幫助我們讓這些事情更加 方便。

關係型資料庫層面的高併發優化

以MySql為例,MySql出現的效能問題都有:

  • 表的資料量過大
  • sql查詢太複雜
  • sql查詢沒有走索引
  • 資料庫服務區的效能太低
  • ......

表資料過大的解決方案:阿里開發手冊中提到:單錶行數超過500W或者單表資料容量超過2G就對效能產生影響,其實還是和有多少列有關係,所以還是要根據真實的情況對資料表進行拆分

  • 分庫分表:減少單個表的資料量
  • 歷史資料歸檔:放在磁碟上,等用的使用進行解析。
  • 冷熱資料分離:實時庫中只展示最新的熱點資料,其他不經常用的使用放在一個備份庫中或者歷史庫中。如果真的要查詢冷資料
    • 們可以傳送一個請求,然後傳送一個指令進行非同步匯出,然後傳送到相關人的郵箱中。
    • 設定查詢區間,這個時候通過查詢區間進行路由,就可以查詢到新老庫的資料。

資料庫的分庫分表:是一個很常見的資料儲存問題而導致效能問題的解決方案,不管是對關係型或者非關係形。例如

  • kafka->資料分片(partition)
  • redis -redis-cluster -> slot
  • mysql-分片
  • 。。。

【常見的形式】:

  • 水平拆分:資料表的欄位格式不變,把資料分成多個分
    • 水平分表:如果說有使用者1-10000 那我們就分成不同的表,給起不同的字尾名 【使用者 1-2500】【使用者2501-5000】【。。。】
    • 水平分庫:把資料切割出來放在不同的資料庫中
  • 垂直拆分:
    • 垂直分表:原先表冗餘在一起,現在拆分成不同的表。比如把訂單和訂單明細分開。
    • 垂直分庫:實際上就是按照業務領域的垂直拆分。比如訂單庫、商品庫、使用者庫

【分庫分表策略】:

  • 【雜湊取模分片】:其實就是通過表中的某一個欄位進行hash演算法得到一個雜湊值,然後通過取模運算確定資料應該放在哪個分片中。
    • 【問題】:假設根據當前資料表的量以及增長情況,我們把一個大表拆分成了4個小表,看起來滿足目前的需求,但是經過一段時間的執行後,發現四個表不夠,需要再增加4個表來儲存,這種情況下,就需要對原來的資料進行整體遷移,這個過程非常麻煩
    • 【解決方案】:
      •  首先備份原先的資料,然後使用一個程式去跑批,把資料同步到新表中,在同步的過程中肯定有新的資料產生,這個時候要對這些新的資料進行處理。
      •  修改的時候,同時兩個表都要修改。比如說修改歷史表,那我們的新表也要同步修改
  • 【一致性hash演算法】:因為表的數量增加從而導致資料遷徙的問題,所以才引入了一致性hash演算法。簡單來說,一致性雜湊將整個雜湊值空間組織成一個虛擬的圓環,如假設某雜湊函式H的值空間為0-2的23次方-1。那麼這個圓環的最頂端就是0,他的左側就是2的32次方-1,他的右側就是1,2,。。。一直到2的32次方減一。簡而言之,就是用這些數字圍成一個虛擬的hash環。
    •  
    • 假設 :現在有四個表,table_1、table_2、table_3、table_4,在一致性hash演算法中,取模運算不是直接對這四個表來完成,而是對2的32次方來實現。
    • 流程如下:
      • 通過hash(table編號)%2的32次方算出一個0-2的32次方減一的數字
      • 然後在這個數對應的位置標註目標表,
    • 當新增一條資料時,同樣通過hash和hash環取模運算得到一個目標值,然後根據目標值所在的hash環的位置順時針查詢最近的一個目標表,把資料儲存到這個目標 表中即可
      •  
    • 好處:hash運算不是直接面向目標表,而是面向hash環,當需要刪除某張表或者增加表的時候,對於整個資料變化的影響是區域性的,而不是全域性

      假設我們發現需要增加一張表table_04,增加一個表,並不會對其他四個已經產生了資料的表造成影響,原來已經分片的資料完全不需要做任何改動。
      •   
    • 壞處【hash環偏斜】:理論情況下我們目標表是能夠均衡的分佈在整個hash環中,但是有可能在計算的時候導致了計算出來的資料的區間挨的比較近,這種現象導致的問題就是大量的資料都會儲存到同一個表中,導致資料分配極度不均勻
    • 解決方案:把這四個節點分別複製一份出來分散到這個hash環中,這個複製出來的節點叫虛擬節點,根據實際需要可以虛擬出多個節點出來。
  • 【範圍分片】
    • 其實就是基於資料表的業務特性,按照某種範圍拆分,一個重要的因素就是分片鍵,如果說現在是根據ID進行分片的,但是想通過名稱去查詢,那肯定查詢不到。所以在選擇分片鍵的時候一定要慎重,需要去調研。常見的有。
    • 【時間範圍】: 比如我們按照資料建立時間,按照每一個月儲存一個表。基於時間劃分還可以用來做冷熱資料分離,越早的資料訪問頻次越少。 【區域範圍】: 區域一般指的是地理位置,比如一個表裡面儲存了來自全國各地的資料,如果資料量較大的情況下,可以按照地域來劃分多個表。 【資料範圍】: 比如根據某個欄位的資料區間來進行劃分

分庫分表實戰

分析 假設存在一個使用者表,使用者表的欄位如下。

首先我們要知道這個表格有什麼功能:註冊、登入、查詢、修改等功能。

然後要知道它的使用範圍:【因為我們要知道大部分的情況下是使用那個欄位進行查詢的,如果大部分是通過id那我使用id進行資料分片就能解決很多問題

使用者端:

  • 使用者登入,面向C端,對可用性和一致性要求較高,主要通過login_nameemailphone來查詢使用者資訊,1%的請求屬於這種型別
  • 使用者資訊查詢,登入成功後,通過uid來查詢使用者資訊,99%屬於這種型別。

運營端:主要是運營後臺的資訊訪問,需要支援根據性別、手機號、註冊時間、使用者暱稱等進行分頁查詢,由於是內部系統,訪問量較低,對可用性一致性要求不高。這種情況下只能通過對映表,或者es進行處理了。

實踐

通過上面的分析我們得知99%的請求是基於uid進行使用者資訊查詢,所以毫無疑問我們選擇使用uid進行水平分表。那麼這裡我們採用uidhash取模方法來進行分表。

但是這裡有一個問題,我們需要提前解決;我們知道當單個表中,我們使用遞增主鍵來保證資料的唯一性,但是如果把資料拆分到了四個表,每個表都採用自己的遞增主鍵規則,就會存在重複id的問題,也就是說遞增主鍵不是全域性唯一的。因此我們需要考慮如何生成一個全域性唯一ID。有很多方式我們可以進行生成全域性ID

  • 資料庫自增ID(定義全域性表)缺點:當DB異常時整個系統不可用,屬於致命問題
    • 在資料庫中專門建立一張序列表,利用資料庫表中的自增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) )
    • begin; REPLACE INTO uid_table (business_id) VALUES (2); SELECT LAST_INSERT_ID(); commit;
      replace into是每次刪除原來相同的資料,同時加1條,就能保證我們每次得到的就是一個自增的ID
  • UUID:在Java中,提供了基於MD5演算法的UUID、以及基於隨機數的UUID 優點:簡單 缺點:不易於儲存:UUID太長、無序查詢效率低
  • Redis的原子遞增
  • Twitter-Snowflflake演算法:使用一個 64 bit 的 long 型的數字作為全域性唯一 id,這64個bit位由四個部分組成。
    • 1bit位,用來表示符號位,而ID一般是正數,所以這個符號位一般情況下是0
    • 佔41 個 bit表示的是時間戳,是系統時間的毫秒數,但是這個時間戳不是當前系統的時間,而是當前 系統時間-開始時間 ,更大的保證這個ID生成方案的使用的時間 目的是為了保證有序性,可讀性。
    • 用來記錄工作機器id,id包含10bit。
    • 第四部分由12bit組成,它表示一個遞增序列,用來記錄同毫秒內產生的不同id
  • 美團的leaf
  • MongoDB的ObjectId
  • 百度的UidGenerator

新增資料的時候:首先通過雪花演算法生成一個id,然後通過一致性hash演算法得到目標表,然後使用mybatis的攔截器進行攔截,同時路由到算出來的表名中的表中

查詢資料的時候:還是使用一致性演算法通過id獲得目標表格,然後查詢資料即可

一個新增的例子

雪花演算法工具類

public class SnowFlakeGenerator {
    //下面兩個每個5位,加起來就是10位的工作機器id
    private long workerId;    //工作id
    private long datacenterId;   //資料id
    //12位的序列號
    private long sequence;

    public SnowFlakeGenerator(long workerId, long datacenterId, long sequence){
        // sanity check for workerId
        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 = 1626357044220L;

    //長度為5位
    private long workerIdBits = 5L;
    private long datacenterIdBits = 5L;
    //最大值
    private long maxWorkerId = -1L ^ (-1L << workerIdBits);
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
    //序列號id長度
    private long sequenceBits = 12L;
    //序列號最大值
    private long sequenceMask = -1L ^ (-1L << sequenceBits);

    //工作id需要左移的位數,12位
    private long workerIdShift = sequenceBits;
    //資料id需要左移位數 12+5=17位
    private long datacenterIdShift = sequenceBits + workerIdBits;
    //時間戳需要左移位數 12+5+5=22位
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

    //上次時間戳,初始值為負數
    private long lastTimestamp = -1L;

    public long getWorkerId(){
        return workerId;
    }

    public long getDatacenterId(){
        return datacenterId;
    }

    public long getTimestamp(){
        return System.currentTimeMillis();
    }

    //下一個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));
        }

        //獲取當前時間戳如果等於上次時間戳(同一毫秒內),則在序列號加一;否則序列號賦值為0,從0開始。
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }

        //將上次時間戳值重新整理
        lastTimestamp = timestamp;

        /**
         * 返回結果:
         * (timestamp - twepoch) << timestampLeftShift) 表示將時間戳減去初始時間戳,再左移相應位數
         * (datacenterId << datacenterIdShift) 表示將資料id左移相應位數
         * (workerId << workerIdShift) 表示將工作id左移相應位數
         * | 是按位或運算子,例如:x | y,只有當x,y都為0的時候結果才為0,其它情況結果都為1。
         * 因為個部分只有相應位上的值有意義,其它位上都是0,所以將各部分的值進行 | 運算就能得到最終拼接好的id
         */
        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();
    }

}
View Code

hash一致性演算法工具類

public class ConsistentHashing {


    /**
     * 真實結點列表,考慮到伺服器上線、下線的場景,即新增、刪除的場景會比較頻繁,這裡使用LinkedList會更好
     */
    private static List<String> realNodes = new LinkedList<String>();

    /**
     * 虛擬節點,key表示虛擬節點的hash值,value表示虛擬節點的名稱
     */
    private static SortedMap<Integer, String> virtualNodes =
            new TreeMap<Integer, String>();

    /**
     * 虛擬節點的數目,這裡寫死,為了演示需要,一個真實結點對應5個虛擬節點
     */
    private static final int VIRTUAL_NODES = 5;

    static{
        // 先把原始的伺服器新增到真實結點列表中
        Collections.addAll(realNodes, Constants.USER_INFO_TABLES);
        // 再新增虛擬節點,遍歷LinkedList使用foreach迴圈效率會比較高
        for (String str : realNodes){
            for (int i = 0; i < VIRTUAL_NODES; i++){
                String virtualNodeName = str + "&&VN" + i;
                int hash = getHash(virtualNodeName);
                virtualNodes.put(hash, virtualNodeName);
            }
        }
    }

    /**
     * 使用FNV1_32_HASH演算法計算伺服器的Hash值,這裡不使用重寫hashCode的方法,最終效果沒區別
     */
    private static int getHash(String str){
        final int p = 16777619;
        int hash = (int)2166136261L;
        for (int i = 0; i < str.length(); i++) {
            hash = (hash ^ str.charAt(i)) * p;
        }
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;
        // 如果算出來的值為負數則取其絕對值
        if (hash < 0) {
            hash = Math.abs(hash);
        }
        return hash;
    }

    /**
     * 得到應當路由到的結點
     */
    public static String getServer(String node){
        // 得到帶路由的結點的Hash值
        int hash = getHash(node);
        // 得到大於該Hash值的所有Map
        SortedMap<Integer, String> subMap =
                virtualNodes.tailMap(hash);
        // 第一個Key就是順時針過去離node最近的那個結點
        Integer i = subMap.firstKey();
        // 返回對應的虛擬節點名稱,這裡字串稍微擷取一下
        String virtualNode = subMap.get(i);
        return virtualNode.substring(0, virtualNode.indexOf("&&"));
    }
}
View Code

具體實現

public void signal(@RequestBody UserInfo userInfo){
        //生成id
        Long bizId=snowFlakeGenerator.nextId();
        userInfo.setBizId(bizId);
        //一致性hash演算法得到目標表
        String table=ConsistentHashing.getServer(bizId.toString());
        log.info("UserInfoController.signal:{}",table);
        //mybatis修改表名,重定向
        MybatisPlusConfig.TABLE_NAME.set(table);
        userInfoService.save(userInfo);
    }        
View Code

分庫分表問題

有時候通過非分片鍵查詢如果知道查詢那個表?
  • 分片鍵和分片鍵建立對映關係,比如查詢電話,那就通過id去獲取表,然後在查詢相關表。
  • key-value進行快取
有時候在運營這邊會有多個條件查詢,上面的這種方式就不合適了,怎麼辦? 把資料庫分離,因為運營這邊查詢的要求不是那麼高,我們可以進行非同步同步資料的方式,把客戶端的資料庫同步到運營端這樣就可以了。 如果資料需要分頁如何處理? 這個時候只能在程式碼這裡進行查詢,並且組裝,或者儲存在nosql中 在實際應用中,並不是一開始就會想到未來會對這個表做拆分,因此很多時候我們面臨的問題是在資料量已經達到一定瓶頸的時候,才開始去考慮這個問題。所以分庫分表最大的難點不是在於拆分的方法論,而是在運行了很長時間的資料庫中如何根據實際業務情況選擇合適的拆分方式以及在拆分之前對於資料的遷移方案的思考。而且,在整個資料遷移和拆 分過程中,系統仍然需要保持可用。對於執行中的表的分表,一般會分為三個階段。
  • 新老庫雙寫
    • 老的資料庫表和新的資料庫表同步寫入資料,事務的成功以老的模型為準,查詢也走老的模型
    • 通過定時任務對資料進行核對,補平差異
    • 通過定時任務把歷史資料遷移到新的模型中
  • 以新的模型為準【歷史資料已經導完了,並且校驗資料沒有問題】
    • 仍然保持資料雙寫,但是事務的成功和查詢都以新模型為準
    • 定時任務進行資料核對,補平資料差異
  • 結束雙寫【資料已經完成遷徙】
    • 取消雙寫,所有資料只需要儲存到新的模型中,老模型不需要再寫入新的資料。
    • 如果仍然有部分老的業務依賴老的模型,所以等到所有業務都改造完成後, 再廢除老的模型