記一次億級表的分表實踐

1、背景

系統中有一張交易記錄表,從系統商用到現在,儘管MySQL單表數據已經累計到一億,但是基於72核CPU和384G內存的配置,導也是相安無事。不過防患於未然,運維向我們提出了整改的要求,並限制單表數據不超過2000萬。

2、分表方案

業務上這張表只會根據userId進行查詢,對此我們決定採取水平分表的方案,目前單表總共一億的數據,並且每個月產生1000萬的增量數據。
在此我們要算出滿足未來三年數據量增長的總表數。
12kw*3+10kw=46kw,46kw/2kw=23,爲了有更多的容錯空間,我們最終確定分爲30張表,從tabale01-table30。
初步討論對userId取模來確定對應分表,但是這裏存在兩個問題
1.userId並不是連續不斷的數字,對其取餘會出現分配不均的情況
2.如果數據增長太快,超出預計,就需要再添加分表,取模的值發生改變,所有的數據都需要重新遷移到分表中,工作量太大。

3、一致性Hash算法

就有大神給我們提出辦法,讓我們使用一致性Hash算法進行分表,就能解決以上的問題,那什麼是一致性Hash算法呢?

一致性hash:對節點和數據,都做一次hash運算,然後比較節點和數據的hash值,數據值和節點最相近的節點作爲處理節點。爲了分佈得更均勻,通過使用虛擬節點的方式,每個節點計算出n個hash值,均勻地放在hash環上這樣數據就能比較均勻地分佈到每個節點。

(1)環形Hash空間
這裏有一個叫一致性Hash環的數據結構,環的起點是0,終點是2^32 - 1,並且首尾相連,環的中間的整數按逆時針分佈,這個環的整數分佈範圍是[0, 2^32-1]。如下圖:
Hash環
(2)對錶名進行Hash獲取對應key並映射到Hash環上,相同的對象Hash值是一致的,如下圖:
Hash表名
(3)對userId進行Hash獲取對應key並映射到Hash環上,如下圖:
Hash用戶Id
(4)然後Hash(userId)的key以順時針方向計算,得到與Hash(分表名)的key最近的節點對應的分表名,如下圖:
尋找對應分表
經過以上的四個步驟完成了一致性Hash算法,得到userId對應的分表。

4、一致性Hash算法Java實現

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 一致性Hash算法
 *
 * @param <T> 節點類型
 */
public class ConsistentHash<T> {
    /**
     * 複製的節點個數
     */
    private final int numberOfReplicas;
    /**
     * 一致性Hash環
     */
    private final SortedMap<Long, T> circle = new TreeMap<>();
    /**
     * Hash計算對象,用於自定義hash算法
     */
    HashFunction hashFunction;

    /**
     * 構造,使用Java默認的Hash算法
     *
     * @param numberOfReplicas 複製的節點個數,增加每個節點的複製節點有利於負載均衡
     * @param nodes            節點對象
     */
    public ConsistentHash(int numberOfReplicas, Collection<T> nodes) {
        this.numberOfReplicas = numberOfReplicas;
        this.hashFunction = new HashFunction() {

            @Override
            public Long hash(Object key) {
                //return fnv1HashingAlg(key.toString());
                return md5HashingAlg(key.toString());
            }
        };
        //初始化節點
        for (T node : nodes) {
            add(node);
        }
    }

    /**
     * 構造
     *
     * @param hashFunction         hash算法對象
     * @param numberOfReplicas 複製的節點個數,增加每個節點的複製節點有利於負載均衡
     * @param nodes            節點對象
     */
    public ConsistentHash(HashFunction hashFunction, int numberOfReplicas, Collection<T> nodes) {
        this.numberOfReplicas = numberOfReplicas;
        this.hashFunction = hashFunction;
        //初始化節點
        for (T node : nodes) {
            add(node);
        }
    }

    /**
     * 使用MD5算法
     *
     * @param key
     * @return
     */
    private static long md5HashingAlg(String key) {
        MessageDigest md5 = null;
        try {
            md5 = MessageDigest.getInstance("MD5");
            md5.reset();
            md5.update(key.getBytes());
            byte[] bKey = md5.digest();
            long res = ((long) (bKey[3] & 0xFF) << 24) | ((long) (bKey[2] & 0xFF) << 16) | ((long) (bKey[1] & 0xFF) << 8) | (long) (bKey[0] & 0xFF);
            return res;
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return 0l;
    }

    /**
     * 使用FNV1hash算法
     *
     * @param key
     * @return
     */
    private static long fnv1HashingAlg(String key) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < key.length(); i++)
            hash = (hash ^ key.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;
        return hash;
    }

    /**
     * 增加節點<br>
     * 每增加一個節點,就會在閉環上增加給定複製節點數<br>
     * 例如複製節點數是2,則每調用此方法一次,增加兩個虛擬節點,這兩個節點指向同一Node
     * 由於hash算法會調用node的toString方法,故按照toString去重
     *
     * @param node 節點對象
     */
    public void add(T node) {
        for (int i = 0; i < numberOfReplicas; i++) {
            circle.put(hashFunction.hash(node.toString() + i), node);
        }
    }

    /**
     * 移除節點的同時移除相應的虛擬節點
     *
     * @param node 節點對象
     */
    public void remove(T node) {
        for (int i = 0; i < numberOfReplicas; i++) {
            circle.remove(hashFunction.hash(node.toString() + i));
        }
    }

    /**
     * 獲得一個最近的順時針節點
     *
     * @param key 爲給定鍵取Hash,取得順時針方向上最近的一個虛擬節點對應的實際節點
     * @return 節點對象
     */
    public T get(Object key) {
        if (circle.isEmpty()) {
            return null;
        }
        long hash = hashFunction.hash(key);
        if (!circle.containsKey(hash)) {
            SortedMap<Long, T> tailMap = circle.tailMap(hash); //返回此映射的部分視圖,其鍵大於等於 hash
            hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
        }
        //正好命中
        return circle.get(hash);
    }

    /**
     * Hash算法對象,用於自定義hash算法
     */
    public interface HashFunction {
        Long hash(Object key);
    }
}

5、虛擬節點

實現了一致性Hash算法,我們的工作還不能算完成了,拿大量數據測試後發現,發現每個分表上分配的userId並不平均,甚至差異很大。
原來Hash(分表名)的key在Hash環上分部並不均勻,導致數據的分配也不平均。這時我們可以對真是節點創建虛擬節點,每個真實節點創建一定數量的虛擬節點。
以散佈在Hash環上,虛擬節點的作用相同,順時針計算找到最近的節點(真實或虛擬),都會返回真是節點對應表名。增加虛擬節點後儘可能的將表名對應的節點平均散佈在Hash環。
當然這個虛擬節點數量是根據大量數據測試符合平均分佈的要求來確定的。

6、分表模擬測試

下面的代碼模擬瞭如何根據userId來獲取對應分表,然後將分配到分表的數據進行統計,輸出結果,判斷最後是否能夠滿足隨機性。
分表總數:20
虛擬節點數量:2000
以下爲測試代碼

import org.junit.Test;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

/**
 * @author : alex
 * @date : 2020/2/4
 */
public class ConsistentHashTest {

    @Test
    public void testConsistentHash() {
        int numberOfReplicas = 2000;//虛擬節點數量
        ArrayList list = new ArrayList(20);
        Map<String, Integer> keyvals = new HashMap<>();
        //分表數量
        for (int i = 0; i < 20; i++) {
            list.add("tableName[" + i + "]");
            keyvals.put("tableName[" + i + "]", 0);
        }
        ConsistentHash hash = new ConsistentHash(numberOfReplicas, list);
        //模擬數據數量
        for (int i = 0; i < 100000; i++) {
            //獲取數據i對應的key,即爲分表名
            String key = hash.get(i).toString();
            Integer count = keyvals.get(key);
            count++;
            keyvals.put(key, count);
        }
        Integer totalCount = 0;
        //遍歷每個分表分配到的數據,查看數據分佈
        for (Map.Entry<String, Integer> entry : keyvals.entrySet()) {
            String tableIndex = entry.getKey();
            Integer count = entry.getValue();
            totalCount += count;
            System.out.println("tableIndex=" + tableIndex + ",count=" + count);
        }
        System.out.println("total:" + totalCount);
    }
}

7、思維發散

除了以上的內容,其實還應根據實際情況要考慮到分庫,分表後分頁處理等問題
(1)是否分庫
是否分庫應該根據實際數據庫服務器壓力進行判斷,從業務邏輯上來看,分表和分庫是沒有區別的。
從實現上來說,分表可以直接通過代碼實現其功能,但若採用分庫的方案,更加建議引入第三方開源分表分庫中間件,如:ShardingSphere、MyCat、Cobar等等。
(2)分表如何分頁查詢
分表之後,就產生新的問題了,如果用戶需要進行分頁查詢就會很麻煩了。
方案:添加一張關聯表,這個表裏的數據包含分表關聯數據(能找到對應分表裏全量數據)和索引數據(即用戶可選的查詢條件)。查詢時先通過索引查詢到關聯數據,然後通過關聯數據查到對應分表的全量數據。

以上兩點在本次的業務中沒有遇到這樣的場景,所以並沒有去實踐,是對可能遇到的問題進行發散和對應的思路,若要實踐必須要根據系統的實際情況來做方案更加合適。

如有問題歡迎留言,若文章對你有幫助,請點贊、收藏和分享。

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