分散式儲存-關係型資料庫層面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_name、email、phone來查詢使用者資訊,1%的請求屬於這種型別
- 使用者資訊查詢,登入成功後,通過uid來查詢使用者資訊,99%屬於這種型別。
運營端:主要是運營後臺的資訊訪問,需要支援根據性別、手機號、註冊時間、使用者暱稱等進行分頁查詢,由於是內部系統,訪問量較低,對可用性一致性要求不高。這種情況下只能通過對映表,或者es進行處理了。
實踐
通過上面的分析我們得知99%的請求是基於uid進行使用者資訊查詢,所以毫無疑問我們選擇使用uid進行水平分表。那麼這裡我們採用uid的hash取模方法來進行分表。
但是這裡有一個問題,我們需要提前解決;我們知道當單個表中,我們使用遞增主鍵來保證資料的唯一性,但是如果把資料拆分到了四個表,每個表都採用自己的遞增主鍵規則,就會存在重複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 Codehash一致性演算法工具類
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
分庫分表問題
有時候通過非分片鍵查詢如果知道查詢那個表?:有時候在運營這邊會有多個條件查詢,上面的這種方式就不合適了,怎麼辦? 把資料庫分離,因為運營這邊查詢的要求不是那麼高,我們可以進行非同步同步資料的方式,把客戶端的資料庫同步到運營端這樣就可以了。 如果資料需要分頁如何處理? 這個時候只能在程式碼這裡進行查詢,並且組裝,或者儲存在nosql中 在實際應用中,並不是一開始就會想到未來會對這個表做拆分,因此很多時候我們面臨的問題是在資料量已經達到一定瓶頸的時候,才開始去考慮這個問題。所以分庫分表最大的難點不是在於拆分的方法論,而是在運行了很長時間的資料庫中,如何根據實際業務情況選擇合適的拆分方式,以及在拆分之前對於資料的遷移方案的思考。而且,在整個資料遷移和拆 分過程中,系統仍然需要保持可用。對於執行中的表的分表,一般會分為三個階段。
- 分片鍵和分片鍵建立對映關係,比如查詢電話,那就通過id去獲取表,然後在查詢相關表。
- key-value進行快取
- 新老庫雙寫
- 老的資料庫表和新的資料庫表同步寫入資料,事務的成功以老的模型為準,查詢也走老的模型
- 通過定時任務對資料進行核對,補平差異
- 通過定時任務把歷史資料遷移到新的模型中
- 以新的模型為準【歷史資料已經導完了,並且校驗資料沒有問題】
- 仍然保持資料雙寫,但是事務的成功和查詢都以新模型為準。
- 定時任務進行資料核對,補平資料差異
- 結束雙寫【資料已經完成遷徙】
- 取消雙寫,所有資料只需要儲存到新的模型中,老模型不需要再寫入新的資料。
- 如果仍然有部分老的業務依賴老的模型,所以等到所有業務都改造完成後, 再廢除老的模型