一致性哈希算法-應用

一致性Hash負載均衡算法實現

1. Hash函數

要將對象和服務器映射到Hash環中,需要計算出來哈希碼,這就需要有Hash函數來完成,也就是關係到使用的哈希算法。使用一個好的哈希算法是很重要的,爲什麼這麼說呢,拿我們上面提到的緩存服務來說,一個完美的解決方案是需要數據分配的平衡,假如Hash環的映射是這樣的:

哈希碼聚集.jpg

Hash碼數值落在一個小區間內,出現Hash碼聚集情況,那麼從上圖可以看到緩存數據全部由c3節點的服務器存儲,出現數據分配不平衡。那麼就需要一個好的哈希處理使得哈希碼在環中的分配儘可能得分散,類似這樣:

哈希碼分散.jpg

上面說到過,環中數值點的取值範圍爲[0,2^32-1],也就是說我們通過Hash函數計算出來的這些哈希碼數值應該避免集中在某一小區間範圍內。

Hash算法對於一致性Hash負載均衡的作用可見一斑,而寫出好的適用於一致性Hash負載均衡的Hash算法是需要些技術能力的,這裏不研究如何寫,而是查閱已有的實現方式。

xmemcached:哈希函數

xmemcached是memcached的java版本的客戶端。它其中包含了一致性Hash算法的實現。

網上內容摘抄:Memcached在實現分佈集羣部署時,Memcached服務端的之間是沒有通訊的,服務端是僞分佈式,實現分佈式是由客戶端實現的,客戶端實現了分佈式算法把數據保存到不同的Memcached服務端。

HashAlgorithm.java

package net.rubyeye.xmemcached;

import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.zip.CRC32;
import net.rubyeye.xmemcached.exception.MemcachedClientException;
import net.rubyeye.xmemcached.utils.ByteUtils;

/**
 * Known hashing algorithms for locating a server for a key. Note that all hash algorithms return
 * 64-bits of hash, but only the lower 32-bits are significant. This allows a positive 32-bit number
 * to be returned for all cases.
 */
public enum HashAlgorithm {

  /**
   * Native hash (String.hashCode()).
   */
  NATIVE_HASH,
  /**
   * CRC32_HASH as used by the perl API. This will be more consistent both across multiple API users
   * as well as java versions, but is mostly likely significantly slower.
   */
  CRC32_HASH,
  /**
   * FNV hashes are designed to be fast while maintaining a low collision rate. The FNV speed allows
   * one to quickly hash lots of data while maintaining a reasonable collision rate.
   * 
   * @see http://www.isthe.com/chongo/tech/comp/fnv/
   * @see http://en.wikipedia.org/wiki/Fowler_Noll_Vo_hash
   */
  FNV1_64_HASH,
  /**
   * Variation of FNV.
   */
  FNV1A_64_HASH,
  /**
   * 32-bit FNV1.
   */
  FNV1_32_HASH,
  /**
   * 32-bit FNV1a.
   */
  FNV1A_32_HASH,
  /**
   * MD5-based hash algorithm used by ketama.
   */
  KETAMA_HASH,

  /**
   * From mysql source
   */
  MYSQL_HASH,

  ELF_HASH,

  RS_HASH,

  /**
   * From lua source,it is used for long key
   */
  LUA_HASH,

  ELECTION_HASH,
  /**
   * The Jenkins One-at-a-time hash ,please see http://www.burtleburtle.net/bob/hash/doobs.html
   */
  ONE_AT_A_TIME;

  private static final long FNV_64_INIT = 0xcbf29ce484222325L;
  private static final long FNV_64_PRIME = 0x100000001b3L;

  private static final long FNV_32_INIT = 2166136261L;
  private static final long FNV_32_PRIME = 16777619;

  /**
   * Compute the hash for the given key.
   * 
   * @return a positive integer hash
   */
  public long hash(final String k) {
    long rv = 0;
    switch (this) {
      case NATIVE_HASH:
        rv = k.hashCode();
        break;
      case CRC32_HASH:
        // return (crc32(shift) >> 16) & 0x7fff;
        CRC32 crc32 = new CRC32();
        crc32.update(ByteUtils.getBytes(k));
        rv = crc32.getValue() >> 16 & 0x7fff;
        break;
      case FNV1_64_HASH: {
        // Thanks to [email protected] for the pointer
        rv = FNV_64_INIT;
        int len = k.length();
        for (int i = 0; i < len; i++) {
          rv *= FNV_64_PRIME;
          rv ^= k.charAt(i);
        }
      }
        break;
      case FNV1A_64_HASH: {
        rv = FNV_64_INIT;
        int len = k.length();
        for (int i = 0; i < len; i++) {
          rv ^= k.charAt(i);
          rv *= FNV_64_PRIME;
        }
      }
        break;
      case FNV1_32_HASH: {
        rv = FNV_32_INIT;
        int len = k.length();
        for (int i = 0; i < len; i++) {
          rv *= FNV_32_PRIME;
          rv ^= k.charAt(i);
        }
      }
        break;
      case FNV1A_32_HASH: {
        rv = FNV_32_INIT;
        int len = k.length();
        for (int i = 0; i < len; i++) {
          rv ^= k.charAt(i);
          rv *= FNV_32_PRIME;
        }
      }
        break;
      case ELECTION_HASH:
      case KETAMA_HASH:
        byte[] bKey = computeMd5(k);
        rv = (long) (bKey[3] & 0xFF) << 24 | (long) (bKey[2] & 0xFF) << 16
            | (long) (bKey[1] & 0xFF) << 8 | bKey[0] & 0xFF;
        break;

      case MYSQL_HASH:
        int nr2 = 4;
        for (int i = 0; i < k.length(); i++) {
          rv ^= ((rv & 63) + nr2) * k.charAt(i) + (rv << 8);
          nr2 += 3;
        }
        break;
      case ELF_HASH:
        long x = 0;
        for (int i = 0; i < k.length(); i++) {
          rv = (rv << 4) + k.charAt(i);
          if ((x = rv & 0xF0000000L) != 0) {
            rv ^= x >> 24;
            rv &= ~x;
          }
        }
        rv = rv & 0x7FFFFFFF;
        break;
      case RS_HASH:
        long b = 378551;
        long a = 63689;
        for (int i = 0; i < k.length(); i++) {
          rv = rv * a + k.charAt(i);
          a *= b;
        }
        rv = rv & 0x7FFFFFFF;
        break;
      case LUA_HASH:
        int step = (k.length() >> 5) + 1;
        rv = k.length();
        for (int len = k.length(); len >= step; len -= step) {
          rv = rv ^ (rv << 5) + (rv >> 2) + k.charAt(len - 1);
        }
        break;
      case ONE_AT_A_TIME:
        try {
          int hash = 0;
          for (byte bt : k.getBytes("utf-8")) {
            hash += (bt & 0xFF);
            hash += (hash << 10);
            hash ^= (hash >>> 6);
          }
          hash += (hash << 3);
          hash ^= (hash >>> 11);
          hash += (hash << 15);
          rv = hash;
        } catch (UnsupportedEncodingException e) {
          throw new IllegalStateException("Hash function error", e);
        }
        break;
      default:
        assert false;
    }

    return rv & 0xffffffffL; /* Convert to unsigned 32-bits */
  }

  private static ThreadLocal<MessageDigest> md5Local = new ThreadLocal<MessageDigest>();

  /**
   * Get the md5 of the given key.
   */
  public static byte[] computeMd5(String k) {
    MessageDigest md5 = md5Local.get();
    if (md5 == null) {
      try {
        md5 = MessageDigest.getInstance("MD5");
        md5Local.set(md5);
      } catch (NoSuchAlgorithmException e) {
        throw new RuntimeException("MD5 not supported", e);
      }
    }
    md5.reset();
    md5.update(ByteUtils.getBytes(k));
    return md5.digest();
  }

  // public static void main(String[] args) {
  // HashAlgorithm alg=HashAlgorithm.CRC32_HASH;
  // long h=0;
  // long start=System.currentTimeMillis();
  // for(int i=0;i<100000;i++)
  // h=alg.hash("MYSQL_HASH");
  // System.out.println(System.currentTimeMillis()-start);
  // }
}

Dubbo:哈希函數

/**
 * ConsistentHashLoadBalance
 */
public class ConsistentHashLoadBalance extends AbstractLoadBalance {
   
    // 代碼省略
    ......

    private static final class ConsistentHashSelector<T> {

        // 代碼省略
        ......

        private long hash(byte[] digest, int number) {
            return (((long) (digest[3 + number * 4] & 0xFF) << 24)
                    | ((long) (digest[2 + number * 4] & 0xFF) << 16)
                    | ((long) (digest[1 + number * 4] & 0xFF) << 8)
                    | (digest[number * 4] & 0xFF))
                    & 0xFFFFFFFFL;
        }

        private byte[] md5(String value) {
            MessageDigest md5;
            try {
                md5 = MessageDigest.getInstance("MD5");
            } catch (NoSuchAlgorithmException e) {
                throw new IllegalStateException(e.getMessage(), e);
            }
            md5.reset();
            byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
            md5.update(bytes);
            return md5.digest();
        }

    }

}

2. 環存儲數據結構

環由[0, 2^32-1]這個區間內的整數值組成。體現在程序中就是用一種數據結構存儲這些值。

比如說用列表來存儲,將服務器標識通過Hash函數計算後得到的哈希碼存入到列表中,類似這樣:

列表表示環.jpg

現在這個列表就表示了哈希碼環。現在假設對象標識經過Hash函數計算後得到的哈希碼值87。那麼現在h=87,從環中找c點。如何確定c點呢?觀察一下哈希碼環,我們可以發現順時針行走,哈希碼值越來越小;逆時針行走哈希碼值越來越大,而上面我們說到確定了h點後,逆時針行走查找c點,既然是逆時針行走那麼就是找第一個大於h點的c,也就是說從列表中查找第一個大於h的元素。

滿足這個需求的實現方法當然有很多了,這裏有一種方式,就是先對列表進行從小到大排序,排序後列表結構如下:

排序後的列表.jpg

循環列表進行查找,第一個大於h的點就是88。查找涉及到時間複雜度,這種方式需要遍歷列表,在查找性能上並不是最好的。

數據有序並且查找的時間複雜度小。使用Java容器類中的TreeMap比較合適。

更詳細說明參考:https://www.cnblogs.com/xrq730/p/5186728.html

3. 代碼實現

參考Dubbo的ConsistentHashLoadBalance類

ConsistentHashLoadBalancer.java

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

/**
 * @Author rocky.hu
 * @Date 2019-04-20 20:34
 */
public class ConsistentHashLoadBalancer implements LoadBalancerStrategy<Server> {

    private CandidateSelector<Server> candidateSelector;

    @Override
    public Server choose(List<Server> candidates) {
        return null;
    }

    public Server choose(List<Server> candidates, String key) {
        int identityHashCode = System.identityHashCode(candidates);
        if (candidateSelector == null || candidateSelector.identityHashCode != identityHashCode) {
            candidateSelector = new CandidateSelector<Server>(candidates, identityHashCode);
        }

        return candidateSelector.select(key);
    }

    private static final class CandidateSelector<T> {

        // 引入虛擬節點概念,此屬性表示Hash環中總的虛擬節點數
        private final TreeMap<Long, Server> virtualCandidates;
        // 每臺真實服務器節點的虛擬節點數,這個值可做成可配置化的
        private final int replicaNumber = 160;
        // 服務器列表的Hash碼,做緩存作用,用來判斷服務器列表長度的變化
        private final int identityHashCode;

        CandidateSelector(List<Server> candidates, int identityHashCode) {
            this.virtualCandidates = new TreeMap<Long, Server>();
            this.identityHashCode = identityHashCode;

            // 將服務器節點映射到Hash環中
            for (Server server : candidates) {
                String address = server.getAddress();
                for (int i = 0; i < replicaNumber / 4; i++) {
                    byte[] digest = md5(address + i);
                    for (int h = 0; h < 4; h++) {
                        long m = hash(digest, h);
                        virtualCandidates.put(m, server);
                    }
                }
            }

        }

        public Server select(String key) {
            byte[] digest = md5(key);
            return selectForKey(hash(digest, 0));
        }

        private Server selectForKey(long hash) {
            // 使用TreeMap的ceilingEntry方法返回鍵值大於或等於的指定鍵的Entry(相當於Hash環逆時針行走查找服務器節點)
            Map.Entry<Long, Server> entry = virtualCandidates.ceilingEntry(hash);
            if (entry == null) {
                entry = virtualCandidates.firstEntry();
            }
            return entry.getValue();
        }

        private long hash(byte[] digest, int number) {
            return (((long) (digest[3 + number * 4] & 0xFF) << 24)
                    | ((long) (digest[2 + number * 4] & 0xFF) << 16)
                    | ((long) (digest[1 + number * 4] & 0xFF) << 8)
                    | (digest[number * 4] & 0xFF))
                    & 0xFFFFFFFFL;
        }

        private byte[] md5(String value) {
            MessageDigest md5;
            try {
                md5 = MessageDigest.getInstance("MD5");
            } catch (NoSuchAlgorithmException e) {
                throw new IllegalStateException(e.getMessage(), e);
            }
            md5.reset();
            byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
            md5.update(bytes);
            return md5.digest();
        }
    }

}

Server.java

/**
 * @Author rocky.hu
 * @Date 2019-04-21 00:47
 */
public class Server {

    private String address;


    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}

 

 

 

 

 

一致性哈希算法及其在分佈式存儲中的應用

“外行看熱鬧,內行看門道”,一位做分佈式存儲的同仁看到了說:接近93%的存儲利用率,還在不停寫數據進去,說明OStorage-EOS數據分佈的均勻性很好,否則,如果數據分佈不夠均勻,就有可能出現其他的節點或盤還有很多空間,但某一個盤或者某一個節點寫滿了,這時還繼續寫數據進去就會出問題。

 

OStorage的老大李明宇隨手發了一個朋友圈,是該公司企業級對象存儲產品OStorage-EOS的監控界面截圖,感慨一個200多TB的集羣很快被用戶用到了92%以上。

“外行看熱鬧,內行看門道”,一位做分佈式存儲的同仁看到了說:接近93%的存儲利用率,還在不停寫數據進去,說明OStorage-EOS數據分佈的均勻性很好,否則,如果數據分佈不夠均勻,就有可能出現其他的節點或盤還有很多空間,但某一個盤或者某一個節點寫滿了,這時還繼續寫數據進去就會出問題。

 

 

那麼OStorage-EOS分佈式對象存儲是如何讓數據均勻分佈到各個盤上的呢?原來是使用了一個算法叫做“一致性哈希(Consistent Hashing)”,並且在一致性哈希基礎上做了改進,增加了權重、副本、機櫃感知、地域感知等機制。

一致性哈希算法也是分佈式系統領域的經典算法,在很多地方都有應用,下面,我們就一起來了解一下它:

哈希函數

仔細研究一致性哈希之前,我們先來了解一下基本的哈希,舉個例子說明了我們如何使用哈希函數來確定對象存儲在哪裏。

先看一個定位數據相對簡單的方法,使用MD5算法來得到對象的邏輯位置的哈希值,然後除以可用的磁盤數量,得到餘數。***將餘數值映射到驅動器ID。

例如,對象的存儲位置爲 /accountA/container1/objectX ,並且使用四個磁盤來存儲數據,我們稱之爲磁盤0到磁盤3。這裏我們先計算MD5值:


 
  1. md5 -s /accountA/container1/objectX    
  2. MD5 ("/account/container/object") =  
  3. f9db0f833f1545be2e40f387d6c271de 

然後我們用哈希值(十六進制數值)除以磁盤數,取餘數(取模)。以上十六進制數值轉化爲十進制爲:

332115198597019796159838990710599741918

取模函數在大多數編程語言中用%運算符表示:

332115198597019796159838990710599741918 % 4 = 2

因爲餘數是2,所以對象將被存儲在磁盤2。

這種算法***的缺點是計算結果取決於除數也就是磁盤數量。任何時候添加或移除某個磁盤(除數變化了),同一個對象可能得到不同的餘數,從而映射到不同的磁盤。爲了說明這一點,下面的表顯示了當添加磁盤時,哪一個磁盤將成爲對象新的存儲位置。

 

注意,幾乎每次添加新磁盤,對象都必須移動到新的磁盤上,這僅僅一個對象的情況,將這種行爲推廣開來,在增加或者移除節點、磁盤時,幾乎集羣中的所有數據都需要進行移動。集羣將不得不花費大量資源來進行這些遷移,還將產生繁重的網絡負載,以及數據不可讀取的情況。

一致性哈希算法

當從集羣中的增加或者移除磁盤、節點時,一致性哈希(Consistent Hashing)可以減少移動的對象數量。一致性哈希不是將每個值直接映射到一個磁盤,而是通過將所有可能的哈希值建模爲一個環。一致性哈希算法除了計算對象的哈希以外,還計算設備的哈希,根據磁盤的IP地址、盤符等計算哈希值,每個磁盤被映射到哈希環的某個點上,如圖所示。

 

當一個對象需要被存儲時,先計算對象的哈希值,然後定位到環上,如圖所示“hash of object”的位置。系統按順時針搜索環上面下一個磁盤的哈希然後定位該磁盤,用這個磁盤存儲數據。上圖中可以看到,對象將被存儲在磁盤4。按照這種算法,哈希環上某個區間的哈希值會被映射到一個磁盤上,如圖所示,我們用不同顏色表示不同區間和它們對應的磁盤,若某個對象的哈希值落在藍色的區間內,則它會被存儲在磁盤1上。

有了這樣的哈希環,當我們添加一塊新的磁盤時,比如磁盤5,那麼圖中粉色部分將不再屬於磁盤4,因爲這部分數據目前全部屬於新的磁盤5。所以這部分位於磁盤4上的對象將會被移動到磁盤5,而其他數據均不受影響。

 

使用這種方案,添加一個盤或者一個節點,只需要移動少量數據,比前面那種最基本的依靠計算哈希值並模除來確定數據存放位置的方案要好很多,在前面那種方案中需要移動很多數據。

在實際應用的一致性哈希算法中,每個實際的磁盤或節點會對在環上對應到多個標記,這些標記在一些文獻中也被成爲“虛節點(Virtual Node)”,實際應用中,一個磁盤會對應很多標記/虛節點,甚至每個磁盤對應數百個標記。多個標記意味着每塊磁盤對應環的哈希值範圍從一個大區域切分成了數個小區域。這樣做有兩個效果,一個效果是一個新添加的磁盤可能從多個磁盤那裏遷移對象數據,進一步降低了數據遷移的壓力,另一個效果是總體的數據分佈更加的平均。

 

以上就是一致性哈希的基本原理,OStorage-EOS基於一致性哈希算法實現了數據的均勻分佈,並加以改進,引入副本、權重、機櫃感知、地域感知等機制,以滿足企業級用戶的需求。

 

 

mycat一致性hash配置:

1、 mycat一致性hash算法分片測試結果
配置el_user_user_info表使用一致性hash算法進行分片。 
schema.xml

<?xml version="1.0"?>
<!DOCTYPE mycat:schema SYSTEM "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
        <schema name="mycatdb" checkSQLschema="false" sqlMaxLimit="100">
            <table name="el_user_user_info" dataNode="dn$1-2" rule="sharding-by-murmur-userid" />
        </schema>
        <dataNode name="dn$1-16" dataHost="localhost1" database="db$1-16" />
                <dataHost name="localhost1" maxCon="500" minCon="100" balance="0"
                          writeType="0" dbType="mysql" dbDriver="native" switchType="1"  slaveThreshold="100">
                <heartbeat>select user()</heartbeat>

                <writeHost host="hostM1" url="localhost:3306" user="root"
                                   password="123456" >
                </writeHost>

        </dataHost>
</mycat:schema>

rule.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mycat:rule SYSTEM "rule.dtd">
<mycat:rule xmlns:mycat="http://io.mycat/">

        <tableRule name="sharding-by-murmur-userid">
                <rule>
                        <columns>UserID</columns>
                        <algorithm>murmur-userid</algorithm>
                </rule>
        </tableRule>
        <function name="murmur-userid" class="io.mycat.route.function.PartitionByMurmurHash">
                <property name="seed">0</property><!-- 默認是0 -->
                <property name="type">1</property><!-- 默認是0, 表示integer, 非0表示string-->
                <property name="count">2</property><!-- 要分片的數據庫節點數量,必須指定,否則沒法分片 -->
                <property name="virtualBucketTimes">160</property><!-- 一個實際的數據庫節點被映射爲這麼多虛擬節點,默認是160倍,也就是虛擬節點數是物理節點數的160倍 -->
                <!-- <property name="weightMapFile">weightMapFile</property> 節點的權重,沒有指定權重的節點默認是1。以properties文件的格式填寫,以從0開始到count-1的整數值也就是節點索引爲key,以節點權重值爲值。>所有權重值必須是正整數,否則以1代替 -->
                <property name="bucketMapPath">/usr/local/mycat/logs/bucketMapPath-murmur-userid</property>
                <!-- 用於測試時觀察各物理節點與虛擬節點的分佈情況,如果指定了這個屬性,會把虛擬節點的murmur hash值與物理節點的映射按行輸出到這個文件,沒有默認值,如果不指定,就不會輸出任何東西 -->
        </function>
</mycat:rule>

使用一致性hash算法(murmur),在MyCat中插入49911條數據,分爲2個分片,數據量基本平衡。 


2、 mycat一致性hash算法擴容測試結果
先看擴容後的結果: 


有兩種擴容方案:

方案一、
a) 停止數據服務,導出全部分片表數據,進行備份; 
b) 清空分片表; 
c) 在mycat中重新導入分片表,完成遷移。 
這種方案缺點很明顯,需要導出導入的數據量大時,操作很費時,且容易出錯。

方案二、
a) 修改MyCAT配置,使用擴容後的配置啓動; 
b) 自己編寫腳本,利用MyCAT的explain語法,分析出各個MySQL節點中分片表需要重新hash的數據,並記錄ID(或數據)到文件中; 
b) 導出各節點中的需要重新hash的數據進行備份, 
c) 確認備份無誤,清除原節點中的數據記錄; 
c) 在mycat中重新導入備份的數據,完成遷移。
 

 

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