Hash問題


一、哈希函數

1.1 什么是哈希函數

  哈希函數(Hash Function),也稱為散列函數。是將一個大文件映射成一個小串字符。與指紋一樣,就是以較短的信息來保證文件的唯一性的標志,這種標志與文件的每一個字節都相關,而且難以找到逆向規律。

   舉個例子: 

        服務器存了10個文本文件,你現在想判斷一個新的文本文件和那10個文件有沒有一個是一樣的。你不可能去比對每個文本里面的每個字節,很有可能,兩個文本文件都是5000個字節,但是只有最后一位有所不同,但這樣的,你前面4999位的比較就是毫無意義。那一個解決辦法,就是在存儲那10個文本文件的時候,都將每個文件映射成一個hash字符串。服務器只需要存儲10個hash字符串,在判斷的時候,只需要判斷新的這個文本文件的hash值是否和那10個文件的hash值一致,那就可以解決這個問題了。

        簡單點說,hash就是將任意長度的消息壓縮成某一固定長度的消息摘要的函數。

        由於文件是無限的,而映射后的字符串能表示的位數是有限的。因此可能會存在不同的key對應相同的Hash值。這就是存在碰撞的可能。

        Hash算法是不可逆的,即不同通過Hash值逆向推出key的值。

1.2 哈希函數的性質

  對於經典哈希函數來說,它具有以下5點性質:

  1. 輸入域無窮大
  2. 輸出域有窮盡
  3. 輸入一樣,輸出肯定一樣
  4. 當輸入不一樣,輸出也可能一樣(哈希碰撞)
  5. 不同輸入會均勻分布在輸出域上(哈希函數的離散性)
    例如輸入域是0-98這99個數字,而我們使用的哈希函數的輸出域為0,1,2,當我們將0-98這99個數字通過該哈希函數,得到的返回值,0,1,2數量都會接近33個,不會出現某個返回值數量特別多,而某個返回值特別少。

  注意:對於哈希函數來說,有規律的輸入並不能得到有規律的輸入,例如十個1Mb的字符串,只有最后1byte的內容不一樣,在經過哈希函數后得到的返回值會千差萬別,而不會有規律,所以它可以來打亂輸入規律。

  通常哈希函數的輸出域都很大,例如常見的MD5算法,它的輸出域是0到264-1,但是往往我們都會將哈希函數的返回值模上一個較小的數m,讓哈希函數的輸出域縮減為0到m-1,並且模完后的0到m-1這個域上也是均勻分布的。

1.3 如何快速生成多個哈希函數

  假如你急需要1000個哈希函數,並且這1000個哈希函數都要求相互獨立,不能有相關性。這時,錯誤的方法是去在網上尋找1000個哈希函數。我們可以通過一個哈希函數來生成這樣的1000個獨立的哈希函數。

  假如,你有一個哈希函數f,它的輸出域是264,也就是16字節的字符串,每個位置上是16進制的數字0-9,a-f。

  我們將這16字節的輸出域分為兩半,高八位和低八位是相互獨立的(這16位都相互獨立)。這樣,我們將高八位作為新的哈希函數f1的輸出域,低八位作為新的哈希函數f2的輸出域,得到兩個新的哈希函數,它們之間相互獨立。

  故此可以通過以下算式得到1000個哈希函數:

f1+1*f2=f3
f1+2*f2=f4
f1+3*f2=f5
……

1.4 哈希函數在大數據中的應用

  需求:我們有一個10TB的大文件存在分布式文件系統上,存的是100億行字符串,並且字符串無序排列,現在我們要統計該文件中重復的字符串。

  整體思路:利用哈希函數分流,以及哈希表的性質:相同輸入導致相同輸出,不同輸入均勻分布。

  假設,我們可以調用100台機器來計算該文件。

  那么,現在我們需要怎樣通過哈希函數來統計重復字符串呢。

  首先,我們需要將這一百台機器分別從0-99標好號,然后我們在分布式文件系統中一行行讀取文件(多台機器並行讀取),通過哈希函數計算hashcode,將計算出的hashcode模以100,根據模出來的值,將該行存入對應的機器中。

  根據哈希函數的性質,我們很容易看出,相同的字符串會存入相同的機器中。

  然后我們就能並行100台機器,每台機器各自統計有哪些重復的字符串,這樣就能大大加加快統計的速度。

  如果還嫌單個機器處理的數據過大,可以把機器里的文件再通過哈希函數按照同樣的方法把它分成小文件,然后在一台機器中並行多個進程,處理數據。

  注意:這10TB文件並不是均分成100GB,分給100台機器,而是將這10TB文件中不同字符串的種類,均分到100台機器中。

二、哈希表 

  我們知道,哈希表中存入的數據是key,value類型的,哈希表能夠put(key,value),同樣也能get(key,value)或者remove(key,value)。當我們需要向哈希表中put(插入記錄)時,我們將key拿出,通過哈希函數計算hashcode。假設我們預先留下的空間大小為17,我們就需要將通過key計算出的hashcode模以17,得到0-16之間的任意整數,然后我們將記錄掛在相應位置的下面(包括key,value)。
  假如我們要將(zhangsan,20)這樣一條記錄插入哈希表中,首先我們通過哈希函數,計算出zhangsan的hashcode,然后模以17,假如我們得到的值是3,哈希表會先去檢查6位置下是否存在數據。如果有,檢查該節點中的key是否等於zhangsan,如果等於,則將該節點中的value替換為20;如果不等於,則在鏈表的最后新添加一個節點,保存我們的記錄。

   

  由於哈希函數的性質,得到的hashcode會均勻分布在輸出域上,所以模上17之后,即便不同的輸入,也會在0到16上均勻分布。這就意味着我們哈希表每個位置下面的鏈表長度相近。

  在實際哈希表應用中,它的查詢速度近乎O(1),這是因為通過key計算hashcode的時間是常數項時間,而數組尋址的時間也是常數時間。

  怎么保證哈希表的效率為O(1)呢?這里的哈希表長度只有17,如果N很大的話,每條鏈的長度就是N/17,那就是O(N)的復雜度了,根本就做不到O(1)啊?

  所以,當樣本量逼近一個數量,比如說我發現某條鏈的長度已經到3了,那我可以認為其它鏈的長度也差不多到3了,如果接下來還是這樣操作,效率可能就不行了,此時就會經歷哈希表的擴容。

  假設我們將哈希表的范圍由原來的17擴到104,擴容的時候就是把每一個數據都拿出來,重新用哈希函數算完之后,再模上104,然后重新分配在新表的哪一個位置上,這樣就完成了哈希表的擴容。

  你可能會說,擴容代價不用計算嗎?為什么能夠做到O(1)呢?

  一方面,樣本量為N時,如果每次擴容過程中,都讓原來的長度增加一倍,擴到N需要擴logN次;如果說每次擴容時長度增加5倍,那么擴容的平均復雜度就是log5N,所以復雜度可以壓得很低,而且,擴容這個代價不是時刻都發生的,雖然某一次擴容會消耗一些代價,但問題在於你是成倍擴容的,擴容一次之后可能很久都不用擴容了,所以平均下來這個復雜度就非常低了。

  你又會有疑問,這也不足以說是O(1)吧?

  實際在使用過程中,還可以離線擴容。比如說這個哈希表的長度是1000,在用的時候發現某條鏈上的長度為5了,長度為5並不影響使用,增刪改查還是O(1),只是再往上加的時候它的效率快不行了,但是你在get或put時還讓你使用原來的結構,於此同時,我在后台給你分配一個更大的區域,比如容量為3000。一個數據經過哈希函數算完后,拿到新結構里放,如果用戶有put行為,就同步往新老結構上塞,用戶使用get時,從老結構上拿,也就是說不讓使用者等待。當后台徹底擴容完成后,用戶再用的時候,就把請求切換到新的結構上,然后把老結構銷毀,這就是離線擴容。

  因為有這么多的優化技巧,所以我們說哈希表的增刪改查是O(1)的。

三、設計RandomPool結構

【題目】

  設計一種結構,在該結構中有如下三個功能:insert(key):將某個key加入到該結構,做到不重復加入。delete(key):將原本在結構中的某個key移除。 getRandom():等概率隨機返回結構中的任何一個key。

  要求:Insert、delete和getRandom方法的時間復雜度都是O(1)

【分析】

  這個題的結構和哈希表的結構很像,不同的是哈希表是get(key,value),而這題沒有value,只有key,但有getRandom()這個函數。

  如果用一張哈希表不能做到等概率隨機返回任何一個key,哈希表的結構是,表中的每個位置上都掛一些鏈,如果樣本量很少,必然會出現某一個位置上有數據,其它位置沒數據的情況,此時又不能遍歷,因為遍歷就不是O(1)了;樣本量很多的時候,雖然說會均勻分布, 每個位置鏈的長度幾乎差不多,但也不是嚴格一樣,所以只用一張哈希表是做不到嚴格等概率返回一個key的。

  准備兩個哈希表,假設A~Z依次進入,哈希表的結構就是:

  

  如果要等概率隨機返回一個,可以使用Math.random() * size 隨機產生[0,25)中等概率的一個數,隨機出哪個數字,就在map2里把該數字對應的字符串返回,這就能做到絕對等概率。

  以上是insert(key)和getRandom()的行為。

  delete(key)又該怎么做呢?

  

  如果直接在map1和map2中進行刪除操作的話,會產生一個個的“洞”,如果0~999這1000個數中,執行了999個刪除操作,那么0~999中產生了999個“洞”,只有一個位置上有數據,此時,如果getRandom()的話就會非常慢,這樣就不能保證O(1)的時間復雜度。

  正確的做法應該是:假設刪除了map1中str2上的數據,map2對應的數據也會同步刪除,然后把str999放到str2的位置上,再讓str2對應的字符串改為999,然后刪掉最后一條記錄,size變為999。

  即產生“洞”的時候,拿最后一個數據“填”這個“洞”,再把最后幾個記錄刪掉,這樣就能保證size的index區域還是連續的,此時getRandom()產生的隨機數的位置就不會為空了。

  注意:value上的0~999這個順序並不是有序的,因為map本身就是亂序的,我們也不需要value是有序的,我們只要保證map上不存在“洞”,每條記錄都是連續的,這樣在getRandom()時就不會找不到數。 

public class RandomPool {
    public static class Pool<K> {
        private HashMap<K, Integer> keyIndexMap;
        private HashMap<Integer, K> indexKeyMap;
        private int size;

        public Pool() {
            this.keyIndexMap = new HashMap<>();
            this.indexKeyMap = new HashMap<>();
            this.size = 0;
        }

        public void insert(K key) {
            if (!this.keyIndexMap.containsKey(key)) {
                this.keyIndexMap.put(key, this.size);
                this.indexKeyMap.put(this.size++, key);
            }
        }

        public void delete(K key) {
            if (this.keyIndexMap.containsKey(key)) {
                int deleteIndex = this.keyIndexMap.get(key);
                int lastIndex = --this.size;
                K lastKey = this.indexKeyMap.get(lastIndex);
                //把最后一條記錄"填"到要刪除的位置
                this.keyIndexMap.put(lastKey, deleteIndex);
                this.indexKeyMap.put(deleteIndex, lastKey);
                this.keyIndexMap.remove(key);
                this.indexKeyMap.remove(lastIndex);

            }
        }

        public K getRandom() {
            if (this.size == 0) {
                return null;
            }
            //0 ~ size -1
            int random = (int) (Math.random() * this.size);
            return this.indexKeyMap.get(random);
        }
    }

    public static void main(String[] args) {
        Pool<String> pool = new Pool<>();
        pool.insert("A");
        pool.insert("B");
        pool.insert("C");
        System.out.println(pool.getRandom());
        System.out.println(pool.getRandom());
        System.out.println(pool.getRandom());
        System.out.println(pool.getRandom());
        System.out.println(pool.getRandom());
        System.out.println(pool.getRandom());
    }
}

四、認識布隆過濾器

4.1 前言

  在日常生活中,包括在設計計算機軟件時,我們經常要判斷一個元素是否在一個集合中。比如在字處理軟件中,需要檢查一個英語單詞是否拼寫正確(也就是要判斷它是否在已知的字典中);在 FBI,一個嫌疑人的名字是否已經在嫌疑名單上;在網絡爬蟲里,一個網址是否被訪問過等等。最直接的方法就是將集合中全部的元素存在計算機中,遇到一個新元素時,將它和集合中的元素直接比較即可。一般來講,計算機中的集合是用哈希表(hash table)來存儲的。它的好處是快速准確,缺點是費存儲空間。當集合比較小時,這個問題不顯著,但是當集合巨大時,哈希表存儲效率低的問題就顯現出來了。比如說,一個象 Yahoo,Hotmail 和 Gmai 那樣的公眾電子郵件(email)提供商,總是需要過濾來自發送垃圾郵件的人(spamer)的垃圾郵件。一個辦法就是記錄下那些發垃圾郵件的 email 地址。由於那些發送者不停地在注冊新的地址,全世界少說也有幾十億個發垃圾郵件的地址,將他們都存起來則需要大量的網絡服務器。如果用哈希表,每存儲一億個 email 地址, 就需要 1.6GB 的內存。因此存貯幾十億個郵件地址可能需要上百 GB 的內存。除非是超級計算機,一般服務器是無法存儲的。接下來,我們介紹一種稱作布隆過濾器的數學工具,它只需要哈希表 1/8 到 1/4 的大小就能解決同樣的問題。

4.2 什么是布隆過濾器

  布隆過濾器 (Bloom Filter)是由Burton Howard Bloom於1970年提出,它是一種space efficient的概率型數據結構,用於判斷一個元素是否在集合中。在垃圾郵件過濾的黑白名單方法、爬蟲(Crawler)的網址判重模塊中等等經常被用到。哈希表也能用於判斷元素是否在集合中,但是布隆過濾器只需要哈希表的1/8或1/4的空間復雜度就能完成同樣的問題。布隆過濾器可以插入元素,但不可以刪除已有元素。其中的元素越多,false positive rate(誤報率)越大,但是false negative (漏報)是不可能的。即布隆過濾器可以用來告訴你 “某樣東西一定不存在或者可能存在”。

4.3 算法思想

  Bloom-Filter算法的核心思想就是利用多個不同的Hash函數來解決“沖突”
  計算某元素x是否在一個集合中,首先能想到的方法就是將所有的已知元素保存起來構成一個集合R,然后用元素x跟這些R中的元素一一比較來判斷是否存在於集合R中;我們可以采用鏈表等數據結構來實現。但是,隨着集合R中元素的增加,其占用的內存將越來越大。試想,如果有幾千萬個不同網頁需要下載,所需的內存將足以占用掉整個進程的內存地址空間。即使用MD5,UUID這些方法將URL轉成固定的短小的字符串,內存占用也是相當巨大的。
  於是,我們會想到用Hash table的數據結構,運用一個足夠好的Hash函數將一個URL映射到二進制位數組(位圖數組)中的某一位。如果該位已經被置為1,那么表示該URL已經存在。
  但是Hash存在一個沖突(碰撞)的問題,用同一個Hash得到的兩個URL的值有可能相同。為了減少沖突,我們可以多引入幾個Hash,如果通過其中的一個Hash值我們得出某元素不在集合中,那么該元素肯定不在集合中。只有在所有的Hash函數告訴我們該元素在集合中時,才能確定該元素存在於集合中。這便是Bloom-Filter的基本思想。

4.4 實現過程

  首先需要准備:一個位數組、K個獨立hash函數

1)位數組:
  假設Bloom Filter使用一個m比特的數組來保存信息,初始狀態時,Bloom Filter是一個包含m位的位數組,每一位都置為0,即BF整個數組的元素都設置為0。

  

2)添加元素,k個獨立hash函數

  為了表達S={x1, x2,…,xn}這樣一個n個元素的集合,Bloom Filter使用k個相互獨立的哈希函數(Hash Function),它們分別將集合中的每個元素映射到{1,…,m}的范圍中。2)添加元素,k個獨立hash函數

當我們往Bloom Filter中增加任意一個元素x時候,我們使用k個哈希函數得到k個哈希值(可以對應數組下標),然后將數組中對應的比特位設置為1。即第i個哈希函數映射的位置hashi(x)就會被置為1(1≤i≤k)。

注意,如果一個位置多次被置為1,那么只有第一次會起作用,后面幾次將沒有任何效果。在下圖中,k=3,且有兩個哈希函數選中同一個位置(從左邊數第五位,即第二個“1“處)。

  

3)判斷元素是否存在集合

  在判斷y是否屬於這個集合時,我們只需要對y使用k個哈希函數得到k個哈希值,如果所有hashi(y)的位置都是1(1≤i≤k),即k個位置都被設置為1了,那么我們就認為y是集合中的元素,否則就認為y不是集合中的元素。下圖中y1就不是集合中的元素(因為y1有一處指向了“0”位)。y2或者屬於這個集合,或者剛好是一個false positive。

  很顯然這個判斷並不能保證查找的結果是100%正確的。

  接下來再舉個具體的例子來解釋上面過程:

  准備一個布隆過濾器( bit 數組),長這樣:

  如果我們要映射一個值到布隆過濾器中,我們需要使用多個不同的哈希函數生成多個哈希值,並對每個生成的哈希值指向的 bit 位置1,例如針對值 “baidu” 和三個不同的哈希函數分別生成了哈希值 1、4、7,則上圖轉變為:
  

  Ok,我們現在再存一個值 “tencent”,如果哈希函數返回 3、4、8 的話,圖繼續變為:

  
  值得注意的是,4 這個 bit 位由於兩個值的哈希函數都返回了這個 bit 位,因此它被覆蓋了。現在我們如果想查詢 “dianping” 這個值是否存在,哈希函數返回了 1、5、8三個值,結果我們發現 5 這個 bit 位上的值為 0,說明沒有任何一個值映射到這個 bit 位上,因此我們可以很確定地說 “dianping” 這個值不存在。而當我們需要查詢 “baidu” 這個值是否存在的話,那么哈希函數必然會返回 1、4、7,然后我們檢查發現這三個 bit 位上的值均為 1,那么我們可以說 “baidu” 存在了么?答案是不可以,只能是 “baidu” 這個值可能存在。
  這是為什么呢?答案很簡單,因為隨着增加的值越來越多,被置為 1 的 bit 位也會越來越多,這樣某個值 “taobao” 即使沒有被存儲過,但是萬一哈希函數返回的三個 bit 位都被其他值置位了 1 ,那么程序還是會判斷 “taobao” 這個值存在。
  解析一下為什么布隆過濾器不能刪除已有元素,例如上圖中的 bit 位 4 被兩個值共同覆蓋的話,一旦你刪除其中一個值例如 “tencent” 而將其置位 0,那么下次判斷另一個值例如 “baidu” 是否存在的話,會直接返回 false,而實際上你並沒有刪除它。

4.5 確定哈希函數個數和布隆過濾器長度

  很顯然,過小的布隆過濾器很快所有的 bit 位均為 1,那么查詢任何值都會返回“可能存在”,起不到過濾的目的了。布隆過濾器的長度會直接影響誤報率,布隆過濾器越長其誤報率越小。

  另外,哈希函數的個數也需要權衡,個數越多則布隆過濾器 bit 位置位 1 的速度越快,且布隆過濾器的效率越低;但是如果太少的話,那我們的誤報率會變高。

  

  k 為哈希函數個數,m 為布隆過濾器長度,n 為插入的元素個數,p 為誤報率。  

  接下來介紹設計和應用布隆過濾器的方法:

  實際應用時首先要先由用戶決定要插入的元素數n和希望的誤差率P。這也是一個設計完整的布隆過濾器需要用戶輸入的僅有的兩個參數,之后的所有參數將由系統計算,並由此建立布隆過濾器。

  系統首先要計算需要的內存大小m bits:(如果m算出來是小數的話,向上取整如果m算出來是小數的話,向上取整)

  

  再由m,n得到哈希函數的個數:

  

  當m和k向上取整確定了后,真實的失誤率是:

  

五、認識一致性哈希

  在了解一致性哈希算法之前,最好先了解一下緩存中的一個應用場景,了解了這個應用場景之后,再來理解一致性哈希算法,就容易多了,也更能體現出一致性哈希算法的優點,那么,我們先來描述一下這個經典的分布式緩存的應用場景。

5.1 場景描述

  假設,我們有三台緩存服務器,用於緩存圖片,我們為這三台緩存服務器編號為0號、1號、2號,現在,有3萬張圖片需要緩存,我們希望這些圖片被均勻的緩存到這3台服務器上,以便它們能夠分攤緩存的壓力。也就是說,我們希望每台服務器能夠緩存1萬張左右的圖片,那么,我們應該怎樣做呢?如果我們沒有任何規律的將3萬張圖片平均的緩存在3台服務器上,可以滿足我們的要求嗎?可以!但是如果這樣做,當我們需要訪問某個緩存項時,則需要遍歷3台緩存服務器,從3萬個緩存項中找到我們需要訪問的緩存,遍歷的過程效率太低,時間太長,當我們找到需要訪問的緩存項時,時長可能是不能被接受的,也就失去了緩存的意義,緩存的目的就是提高速度,改善用戶體驗,減輕后端服務器壓力,如果每次訪問一個緩存項都需要遍歷所有緩存服務器的所有緩存項,想想就覺得很累,那么,我們該怎么辦呢?原始的做法是對緩存項的鍵進行哈希,將hash后的結果對緩存服務器的數量進行取模操作,通過取模后的結果,決定緩存項將會緩存在哪一台服務器上,這樣說可能不太容易理解,我們舉例說明,仍然以剛才描述的場景為例,假設我們使用圖片名稱作為訪問圖片的key,假設圖片名稱是不重復的,那么,我們可以使用如下公式,計算出圖片應該存放在哪台服務器上。

  hash(圖片名稱)% N

  因為圖片的名稱是不重復的,所以,當我們對同一個圖片名稱做相同的哈希計算時,得出的結果應該是不變的,如果我們有3台服務器,使用哈希后的結果對3求余,那么余數一定是0、1或者2,沒錯,正好與我們之前的服務器編號相同,如果求余的結果為0, 我們就把當前圖片名稱對應的圖片緩存在0號服務器上,如果余數為1,就把當前圖片名對應的圖片緩存在1號服務器上,如果余數為2,同理,那么,當我們訪問任意一個圖片的時候,只要再次對圖片名稱進行上述運算,即可得出對應的圖片應該存放在哪一台緩存服務器上,我們只要在這一台服務器上查找圖片即可,如果圖片在對應的服務器上不存在,則證明對應的圖片沒有被緩存,也不用再去遍歷其他緩存服務器了,通過這樣的方法,即可將3萬張圖片隨機的分布到3台緩存服務器上了,而且下次訪問某張圖片時,直接能夠判斷出該圖片應該存在於哪台緩存服務器上,這樣就能滿足我們的需求了,我們暫時稱上述算法為HASH算法或者取模算法,取模算法的過程可以用下圖表示。

   

  但是,使用上述HASH算法進行緩存時,會出現一些缺陷,試想一下,如果3台緩存服務器已經不能滿足我們的緩存需求,那么我們應該怎么做呢?沒錯,很簡單,多增加兩台緩存服務器不就行了,假設,我們增加了一台緩存服務器,那么緩存服務器的數量就由3台變成了4台,此時,如果仍然使用上述方法對同一張圖片進行緩存,那么這張圖片所在的服務器編號必定與原來3台服務器時所在的服務器編號不同,因為除數由3變為了4,被除數不變的情況下,余數肯定不同,這種情況帶來的結果就是當服務器數量變動時,所有緩存的位置都要發生改變,換句話說,當服務器數量發生改變時,所有緩存在一定時間內是失效的,當應用無法從緩存中獲取數據時,則會向后端服務器請求數據,同理,假設3台緩存中突然有一台緩存服務器出現了故障,無法進行緩存,那么我們則需要將故障機器移除,但是如果移除了一台緩存服務器,那么緩存服務器數量從3台變為2台,如果想要訪問一張圖片,這張圖片的緩存位置必定會發生改變,以前緩存的圖片也會失去緩存的作用與意義,由於大量緩存在同一時間失效,造成了緩存的雪崩,此時前端緩存已經無法起到承擔部分壓力的作用,后端服務器將會承受巨大的壓力,整個系統很有可能被壓垮,所以,我們應該想辦法不讓這種情況發生,但是由於上述HASH算法本身的緣故,使用取模法進行緩存時,這種情況是無法避免的,為了解決這些問題,一致性哈希算法誕生了。

  我們來回顧一下使用上述算法會出現的問題。

問題1:當緩存服務器數量發生變化時,會引起緩存的雪崩,可能會引起整體系統壓力過大而崩潰(大量緩存同一時間失效)。

問題2:當緩存服務器數量發生變化時,幾乎所有緩存的位置都會發生改變,怎樣才能盡量減少受影響的緩存呢?

 

  其實,上面兩個問題是一個問題,那么,一致性哈希算法能夠解決上述問題嗎?

  我們現在就來了解一下一致性哈希算法。

5.2 一致性哈希算法的基本概念

  其實,一致性哈希算法也是使用取模的方法,只是,剛才描述的取模法是對服務器的數量進行取模,而一致性哈希算法是對2^32取模,什么意思呢?我們慢慢聊。

  首先,我們把二的三十二次方想象成一個圓,就像鍾表一樣,鍾表的圓可以理解成由60個點組成的圓,而此處我們把這個圓想象成由2^32個點組成的圓,示意圖如下:

   

  圓環的正上方的點代表0,0點右側的第一個點代表1,以此類推,2、3、4、5、6……直到232-1,也就是說0點左側的第一個點代表232-1

  我們把這個由2的32次方個點組成的圓環稱為hash環。

  那么,一致性哈希算法與上圖中的圓環有什么關系呢?我們繼續聊,仍然以之前描述的場景為例,假設我們有3台緩存服務器,服務器A、服務器B、服務器C,那么,在生產環境中,這三台服務器肯定有自己的IP地址,我們使用它們各自的IP地址進行哈希計算,使用哈希后的結果對2^32取模,可以使用如下公式示意。

  hash(服務器A的IP地址) %  232  

  通過上述公式算出的結果一定是一個0到2^32-1之間的一個整數,我們就用算出的這個整數,代表服務器A,既然這個整數肯定處於0到2^32-1之間,那么,上圖中的hash環上必定有一個點與這個整數對應,而我們剛才已經說明,使用這個整數代表服務器A,那么,服務器A就可以映射到這個環上,用下圖示意

  

 

  同理,服務器B與服務器C也可以通過相同的方法映射到上圖中的hash環中

  hash(服務器B的IP地址) %  232

  hash(服務器C的IP地址) %  232

  通過上述方法,可以將服務器B與服務器C映射到上圖中的hash環上,示意圖如下
 
  

 

  假設3台服務器映射到hash環上以后如上圖所示(當然,這是理想的情況,我們慢慢聊)。

  好了,到目前為止,我們已經把緩存服務器與hash環聯系在了一起,我們通過上述方法,把緩存服務器映射到了hash環上,那么使用同樣的方法,我們也可以將需要緩存的對象映射到hash環上。

  假設,我們需要使用緩存服務器緩存圖片,而且我們仍然使用圖片的名稱作為找到圖片的key,那么我們使用如下公式可以將圖片映射到上圖中的hash環上。
   hash(圖片名稱) %  232

  映射后的示意圖如下,下圖中的橘黃色圓形表示圖片

  

  好了,現在服務器與圖片都被映射到了hash環上,那么上圖中的這個圖片到底應該被緩存到哪一台服務器上呢?上圖中的圖片將會被緩存到服務器A上,為什么呢?因為從圖片的位置開始,沿順時針方向遇到的第一個服務器就是A服務器,所以,上圖中的圖片將會被緩存到服務器A上,如下圖所示。

  

  沒錯,一致性哈希算法就是通過這種方法,判斷一個對象應該被緩存到哪台服務器上的,將緩存服務器與被緩存對象都映射到hash環上以后,從被緩存對象的位置出發,沿順時針方向遇到的第一個服務器,就是當前對象將要緩存於的服務器,由於被緩存對象與服務器hash后的值是固定的,所以,在服務器不變的情況下,一張圖片必定會被緩存到固定的服務器上,那么,當下次想要訪問這張圖片時,只要再次使用相同的算法進行計算,即可算出這個圖片被緩存在哪個服務器上,直接去對應的服務器查找對應的圖片即可。

  剛才的示例只使用了一張圖片進行演示,假設有四張圖片需要緩存,示意圖如下

  

  1號、2號圖片將會被緩存到服務器A上,3號圖片將會被緩存到服務器B上,4號圖片將會被緩存到服務器C上。

5.3 一致性哈希算法的優點

  經過上述描述,我想兄弟你應該已經明白了一致性哈希算法的原理了,但是話說回來,一致性哈希算法能夠解決之前出現的問題嗎,我們說過,如果簡單的對服務器數量進行取模,那么當服務器數量發生變化時,會產生緩存的雪崩,從而很有可能導致系統崩潰,那么使用一致性哈希算法,能夠避免這個問題嗎?我們來模擬一遍,即可得到答案。

  假設,服務器B出現了故障,我們現在需要將服務器B移除,那么,我們將上圖中的服務器B從hash環上移除即可,移除服務器B以后示意圖如下。

   

 

  在服務器B未移除時,圖片3應該被緩存到服務器B中,可是當服務器B移除以后,按照之前描述的一致性哈希算法的規則,圖片3應該被緩存到服務器C中,因為從圖片3的位置出發,沿順時針方向遇到的第一個緩存服務器節點就是服務器C,也就是說,如果服務器B出現故障被移除時,圖片3的緩存位置會發生改變

  

  但是,圖片4仍然會被緩存到服務器C中,圖片1與圖片2仍然會被緩存到服務器A中,這與服務器B移除之前並沒有任何區別,這就是一致性哈希算法的優點,如果使用之前的hash算法,服務器數量發生改變時,所有服務器的所有緩存在同一時間失效了,而使用一致性哈希算法時,服務器的數量如果發生改變,並不是所有緩存都會失效,而是只有部分緩存會失效,前端的緩存仍然能分擔整個系統的壓力,而不至於所有壓力都在同一時間集中到后端服務器上。

  這就是一致性哈希算法所體現出的優點。

5.4 hash環的偏斜

  在介紹一致性哈希的概念時,我們理想化的將3台服務器均勻的映射到了hash環上,如下圖所示

   

  但是,理想很豐滿,現實很骨感,我們想象的與實際情況往往不一樣。

  在實際的映射中,服務器可能會被映射成如下模樣。

  

  聰明如你一定想到了,如果服務器被映射成上圖中的模樣,那么被緩存的對象很有可能大部分集中緩存在某一台服務器上,如下圖所示。

  

  上圖中,1號、2號、3號、4號、6號圖片均被緩存在了服務器A上,只有5號圖片被緩存在了服務器B上,服務器C上甚至沒有緩存任何圖片,如果出現上圖中的情況,A、B、C三台服務器並沒有被合理的平均的充分利用,緩存分布的極度不均勻,而且,如果此時服務器A出現故障,那么失效緩存的數量也將達到最大值,在極端情況下,仍然有可能引起系統的崩潰,上圖中的情況則被稱之為hash環的偏斜,那么,我們應該怎樣防止hash環的偏斜呢?一致性hash算法中使用"虛擬節點"解決了這個問題,我們繼續聊。

5.5 虛擬節點

  話接上文,由於我們只有3台服務器,當我們把服務器映射到hash環上的時候,很有可能出現hash環偏斜的情況,當hash環偏斜以后,緩存往往會極度不均衡的分布在各服務器上,聰明如你一定已經想到了,如果想要均衡的將緩存分布到3台服務器上,最好能讓這3台服務器盡量多的、均勻的出現在hash環上,但是,真實的服務器資源只有3台,我們怎樣憑空的讓它們多起來呢,沒錯,就是憑空的讓服務器節點多起來,既然沒有多余的真正的物理服務器節點,我們就只能將現有的物理節點通過虛擬的方法復制出來,這些由實際節點虛擬復制而來的節點被稱為"虛擬節點"。加入虛擬節點以后的hash環如下。

  

  "虛擬節點"是"實際節點"(實際的物理服務器)在hash環上的復制品,一個實際節點可以對應多個虛擬節點。

  從上圖可以看出,A、B、C三台服務器分別虛擬出了一個虛擬節點,當然,如果你需要,也可以虛擬出更多的虛擬節點。引入虛擬節點的概念后,緩存的分布就均衡多了,上圖中,1號、3號圖片被緩存在服務器A中,5號、4號圖片被緩存在服務器B中,6號、2號圖片被緩存在服務器C中,如果你還不放心,可以虛擬出更多的虛擬節點,以便減小hash環偏斜所帶來的影響,虛擬節點越多,hash環上的節點就越多,緩存被均勻分布的概率就越大。

 

 

 

 

 

 

 

參考:https://blog.csdn.net/u014209205/article/details/80820263

https://blog.csdn.net/qq_38180223/article/details/80911868

https://www.jianshu.com/p/8004c2d2ad59

https://china.googleblog.com/2007/07/bloom-filter_7469.html

http://www.cnblogs.com/zengdan-develpoer/p/4425167.html

https://www.cnblogs.com/allensun/archive/2011/02/16/1956532.html

http://www.zsythink.net/archives/1182/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM