Ketama一致性哈希算法整理

爲方便自己後期自己查看,把網上Hash映射做了整理:

那節點是怎樣放入這個環中的呢?


         
    //對所有節點,生成nCopies個虛擬結點  
            for(Node node : nodes) {  
                //每四個虛擬結點爲一組,爲什麼這樣?下面會說到  
                for(int i=0; i<nCopies / 4; i++) {  
                    //getKeyForNode方法爲這組虛擬結點得到惟一名稱  
                    byte[] digest=HashAlgorithm.computeMd5(getKeyForNode(node, i));  
                /** Md5是一個16字節長度的數組,將16字節的數組每四個字節一組, 
                            分別對應一個虛擬結點,這就是爲什麼上面把虛擬結點四個劃分一組的原因*/  
                    for(int h=0;h<4;h++) {  
                      //對於每四個字節,組成一個long值數值,做爲這個虛擬節點的在環中的惟一key  
                        Long k = ((long)(digest[3+h*4]&0xFF) << 24)  
                            | ((long)(digest[2+h*4]&0xFF) << 16)  
                            | ((long)(digest[1+h*4]&0xFF) << 8)  
                            | (digest[h*4]&0xFF);  
                          
                        allNodes.put(k, node);  
                    }  
                }  
            }  


上面的流程大概可以這樣歸納:四個虛擬結點爲一組,以getKeyForNode方法得到這組虛擬節點的name,Md5編碼後,每個虛擬結點對應Md5碼16個字節中的4個,組成一個long型數值,做爲這個虛擬結點在環中的惟一key。第12行k爲什麼是Long型的呢?呵呵,就是因爲Long型實現了Comparator接口。

處理完正式結點在環上的分佈後,可以開始key在環上尋找節點的遊戲了。
對於每個key還是得完成上面的步驟:計算出Md5,根據Md5的字節數組,通過Kemata Hash算法得到key在這個環中的位置。


 final Node rv;  
        byte[] digest = hashAlg.computeMd5(keyValue);  
        Long key = hashAlg.hash(digest, 0);  
        //如果找到這個節點,直接取節點,返回  
        if(!ketamaNodes.containsKey(key)) {  
        //得到大於當前key的那個子Map,然後從中取出第一個key,就是大於且離它最近的那個key  
            SortedMap<Long, Node> tailMap=ketamaNodes.tailMap(key);  
            if(tailMap.isEmpty()) {  
                key=ketamaNodes.firstKey();  
            } else {  
                key=tailMap.firstKey();  
            }  
            //在JDK1.6中,ceilingKey方法可以返回大於且離它最近的那個key  
            //For JDK1.6 version  
//          key = ketamaNodes.ceilingKey(key);  
//          if (key == null) {  
//              key = ketamaNodes.firstKey();  
//          }  
        }  
          
          
        rv=allNodes.get(key);  


引文中已詳細描述過這種取節點邏輯:在環上順時針查找,如果找到某個節點,就返回那個節點;如果沒有找到,則取整個環的第一個節點。
上文爲簡單的節點如何放到環上,和節點獲取邏輯講解,下文爲某作者實際用例講解:

http://blog.chinaunix.net/uid-20498361-id-4303232.html

Ketama的Hash算法,以虛擬節點的思想,解決Memcached的分佈式問題。

寫完memcached集羣輕客戶端有一段時間了,使用了ketama的第3方一致性hash算法庫。這裏分析一下它的實現。


1,簡介
    若我們在後臺使用NoSQL集羣,必然會涉及到key的分配問題,集羣中某臺機器宕機時如何key又該如何分配的問題。
    若我們用一種簡單的方法,n = hash( key)%N來選擇n號服務器,一切都運行正常,若再考慮如下的兩種情況;  
(1) 一個 cache 服務器 m down 掉了(在實際應用中必須要考慮這種情況),這樣所有映射到 cache m 的對象都會失效,怎麼辦,需要把 cache m 從 cache 中移除,這時候 cache 是 N-1 臺,映射公式變成了 hash(object)%(N-1) ;  
(2) 由於訪問加重,需要添加 cache ,這時候 cache 是 N+1 臺,映射公式變成了 hash(object)%(N+1) ;  
1 和 2 意味着什麼?這意味着突然之間幾乎所有的 cache 都失效了。對於服務器而言,這是一場災難,洪水般的訪問都會直接衝向後臺服務器; 
(3) 再來考慮一個問題,由於硬件能力越來越強,你可能想讓後面添加的節點多做點活,顯然上面的 hash 算法也做不到。
以上三個問題,可以用一致性hash算法來解決。關於一致性hash算法的理論網上很多,這裏分析幾種一致性hash算法的實現。

2,ketama實現分析
2.1 實現流程介紹
ketama對一致性hash算法的實現思路是:
(1) 通過配置文件,建立一個服務器列表,其形式如:(1.1.1.1:11211, 2.2.2.2:11211,9.8.7.6:11211...)
(2) 對每個服務器列表中的字符串,通過Hash算法,hash成幾個無符號型整數。
    注意:如何通過hash算法來計算呢?
(3) 把這幾個無符號型整數放到一個環上,這個換被稱爲continuum。(我們可以想象,一個從0到2^32的鐘表)
(4) 可以建立一個數據結構,把每個數和服務器的ip地址對應在一起,這樣,每個服務器就出現在這個環上的這幾個位置上。
    注意:這幾個數,不能隨着服務器的增加和刪除而變化,這樣才能保證集羣增加/刪除機器後,以前的那些key都映射到同樣的ip地址上。後面將會詳細說明怎麼做。
(5) 爲了把一個key映射到一個服務器上,先要對key做hash,形成一個無符號型整數un,然後在環continuum上查找大於un的下一個數值。若找到,就把key保存到這臺服務器上。
(6) 若你的hash(key)值超過continuum上的最大整數值,就直接回饒到continuum環的開始位置。
    這樣,添加或刪除集羣中的結點,就只會影響一少部分key的分佈。
    注意:這裏說的會影響一部分key是相對的。其實影響的key的多少,由該ip地址佔的權重大小決定的。在ketama的配置文件中,需要指定每個ip地址的權重。權重大的在環上佔的點就多。

2.2 源碼分析
在github上下載源碼後,解壓,進入ketama-master/libketama目錄。一致性hash算法的實現是在ketama.c文件中。
在該文件中,還用到了共享內存,這裏不分析這一部分,只分析一致性hash算法的核心實現部分。

2.2.1 數據結構

// 服務器信息,主要記錄服務器的ip地址和權重值
typedef struct
{
    char addr[22];                   //服務器ip地址
    unsigned long memory;   // 權重值
} serverinfo;

// 以下數據結構就是continuum環上的結點,換上的每個點其實代表了一個ip地址,該結構把點和ip地址一一對應起來。
// 環上的結點
typedef struct
{
    unsigned int point;          //在環上的點,數組下標值
    char ip[22];                       // 對應的ip地址
} mcs;
2.2.2 一致性hash環的創建
該函數是創建continuum的核心函數,它先從配置文件中讀取集羣服務器ip和端口,以及權重信息。創建continuum環,並把這些服務器信息和環上的數組下標對應起來。

// 其中key是爲了訪問共享內存而設定的,在使用時可以把共享內存部分去掉。
static int
ketama_create_continuum( key_t key, char* filename )
{
    // 若不使用共享內存,可以不管
    if (shm_ids == NULL) {
        init_shm_id_tracker();
    }
   // 共享內存相關,用不着時,可以去掉
    if (shm_data == NULL) {
        init_shm_data_tracker();
    }
    int shmid;
    int* data;                                              /* Pointer to shmem location */
    // 該變量來記錄共從配置文件中共讀取了多少個服務器
    unsigned int numservers = 0;
    // 該變量是配置文件中所有服務器權重值得總和
    unsigned long memory;
    // 從配置文件中讀取到的服務器信息,包括ip地址,端口,權重值
    serverinfo* slist;

    // 從配置文件filename中讀取服務器信息,把服務器總數保存到變量numservers中,把所有服務器的權重值保存到memory中。
    slist = read_server_definitions( filename, &numservers, &memory );

    /* Check numservers first; if it is zero then there is no error message
     * and we need to set one. */
    // 以下幾行是檢查配置文件內容是否正確
    // 若總服務器數量小於1,錯誤。
    if ( numservers < 1 )
    {
        sprintf( k_error, "No valid server definitions in file %s", filename );
        return 0;
    }
    else if ( slist == 0 )  // 若服務器信息數組爲空,錯誤
    {
        /* read_server_definitions must've set error message. */
        return 0;
    }

    // 以下代碼開始構建continuum環
    /* Continuum will hold one mcs for each point on the circle: */

    // 平均每臺服務器要在這個環上布160個點,這個數組的元素個數就是服務器個數*160。
    // 具體多少個點,需要根據事情的服務器權重值進行計算得到。
    // 爲什麼要選擇160個點呢?主要是通過md5計算出來的是16個整數,把這個整數分成4等分,每份是4位整數。
    // 而每進行一次hash計算,我們可以獲得4個點。
    mcs continuum[ numservers * 160 ];
    unsigned int i, k, cont = 0;
    // 遍歷所有服務器開始在環上部點
    for( i = 0; i < numservers; i++ )
    {
        // 計算服務器i在所有服務器權重的佔比
        float pct = (float)slist[i].memory / (float)memory;
        // 由於計算一次可以得到4個點,所有對每一臺機器來說,總的計算只需要計算40*numservers次。
        // 按權重佔比進行劃分,就是以下的計算得到的次數
        unsigned int ks = floorf( pct * 40.0 * (float)numservers );

#ifdef DEBUG
        int hpct = floorf( pct * 100.0 );
        syslog( LOG_INFO, "Server no. %d: %s (mem: %lu = %u%% or %d of %d)\n",
            i, slist[i].addr, slist[i].memory, hpct, ks, numservers * 40 );
#endif

        // 計算出總次數,每次可以得到4個點
        for( k = 0; k < ks; k++ )
        {
            /* 40 hashes, 4 numbers per hash = 160 points per server */
            char ss[30];
            unsigned char digest[16];
            
            // 通過計算hash值來得到下標值,該hash值是字符串:"-n",其中的n是通過權重計算出來的該主機應該部點的總數/4。
            sprintf( ss, "%s-%d", slist[i].addr, k );
            // 計算其字符串的md5值,該值計算出來後是一個unsigned char [16]的數組,也就是可以保存16個字節
            ketama_md5_digest( ss, digest );

            /* Use successive 4-bytes from hash as numbers for the points on the circle: */
            // 通過對16個字節的每組4個字節進行移位,得到一個0到2^32之間的整數,這樣環上的一個結點就準備好了。
            int h;
            // 共有16個字節,可以處理4次,得到4個點的值
            for( h = 0; h < 4; h++ )
            {
                // 把計算出來的連續4位的數字,進行移位。
                // 把第一個數字一道一個整數的最高8位,後面的一次移動次高8位,後面一次補零,這樣就得到了一個32位的整數值。移動後
                continuum[cont].point = ( digest[3+h*4] << 24 )
                                      | ( digest[2+h*4] << 16 )
                                      | ( digest[1+h*4] << 8 )
                                      | digest[h*4];
                // 複製對應的ip地址到該點上
                memcpy( continuum[cont].ip, slist[i].addr, 22 );
                cont++;
            }
        }
    }
    free( slist );
    
    // 以下代碼對計算出來的環上點的值進行排序,方便進行查找
    // 這裏要注意:排序是按照point的值(計算出來的整數值)進行的,也就是說原來的數組下標順序被打亂了。
    /* Sorts in ascending order of "point" */
    qsort( (void*) &continuum, cont, sizeof( mcs ), (compfn)ketama_compare );
    
    // 到這裏算法的實現就結束了,環上的點(0^32整數範圍內)都已經建立起來,每個點都是0到2^32的一個整數和ip地址的結構。
    // 這樣查找的時候,只是需要hash(key),並在環上找到對應的數的位置,取得該節點的ip地址即可。

2.2.3 在環上查找元素
* 計算key的hash值的實現
unsigned int ketama_hashi( char* inString ) 
{
    unsigned char digest[16];
    // 對key的值做md5計算,得到一個有16個元素的unsigned char數組
    ketama_md5_digest( inString, digest );
    // 取數組中的前4個字符,並移位,形成一個整數作爲hash得到的值返回
    return (unsigned int)(( digest[3] << 24 )
                        | ( digest[2] << 16 )
                        | ( digest[1] << 8 )
                        | digest[0] );
}

* 在環上查找相應的結點
mcs* ketama_get_server( char* key, ketama_continuum cont ) 
{
    // 計算key的hash值,並保存到變量h中
    unsigned int h = ketama_hashi( key );
    // 該變量cont->numpoints是總的數組埋點數
    int highp = cont->numpoints;
    // 數組結點的值
    mcs (*mcsarr)[cont->numpoints] = cont->array;
    int lowp = 0, midp;
    unsigned int midval, midval1;
    // divide and conquer array search to find server with next biggest
    // point after what this key hashes to
    while ( 1 )
    {
        // 從數組的中間位置開始找
        // 注意此時的數組是按照point的值排好序了
        midp = (int)( ( lowp+highp ) / 2 );
        // 若中間位置等於最大點數,直接繞回到0位置
        if ( midp == cont->numpoints )
            return &( (*mcsarr)[0] ); // if at the end, roll back to zeroth
       
        // 取的中間位置的point值
        midval = (*mcsarr)[midp].point;
        // 再取一個值:若中間位置下標爲0,直接返回0,若中間位置的下標不爲0,直接返回上一個結點的point值
        midval1 = midp == 0 ? 0 : (*mcsarr)[midp-1].point;
        // 把h的值和取的兩個值point值進行比較,若在這兩個point值之間說明h值應該放在較大的那個point值的下標對應的ip地址上
        if ( h <= midval && h > midval1 )
            return &( (*mcsarr)[midp] );
        // 否則繼續2分
        if ( midval < h )
            lowp = midp + 1;
        else
            highp = midp - 1;
       // 若沒有找到,直接返回0位置的值,這種情況應該很少
        if ( lowp > highp )
            return &( (*mcsarr)[0] );
    }
<strong>}</strong>

2.2.4 添加刪除機器時會怎樣
    先說明一下刪除機器的情況。機器m1被刪除後,以前分配到m1的key需要重新分配,而且最好是均勻分配到現存的機器上。
    我們來看看,ketama是否能夠做到?
    當m1機器宕機後,continuum環需要重構,需要把m1的ip對應的點從continuum環中去掉。
我們來回顧一下環的創建過程:
    按每個ip平均160個點,可以計算出總數t。按每個ip的權重值佔比和總數t的乘積得到該ip應該在該環上部的點數。若一臺機器宕機,那麼每臺機器的權重佔比增加,在該環上部的點數也就相應的增加,當然這個增加也是按每臺機器的佔比來的,佔比多的增加的點數就多,佔比少的增加的點數就少。但,每個ip的點數一定是增加的。
    創建環上的點值的過程是:
        先計算hash值:      
           
    for( k = 0; k < ks; k++ )     {    //其中ks是每個ip地址對應的總點數
                    ...
                    sprintf( ss, "%s-%d", slist[i].addr, k );  
                    ketama_md5_digest( ss, digest );
                    ... 
                }


        循環移位hash值:
  continuum[cont].point = ( digest[3+h*4] << 24 )
                                      | ( digest[2+h*4] << 16 )
                                      | ( digest[1+h*4] << 8 )
                                      | digest[h*4];

 由於此時每個ip的佔比增加,ks就增加了:
 
       float pct = (float)slist[i].memory / (float)memory;   // 此時這個值增加
        unsigned int ks = floorf( pct * 40.0 * (float)numservers );  //該值也增加
這樣,每個ip地址對應的point值就多了,但以前的point值不會變。依然在這個環上相同的點值上。也就是說把影響平均分攤到現有的各臺機器上。
當然,刪除的情況和添加的情況相似,都是把影響平均分攤到現有的各個機器上了。

小結:
(1) 環上的點是通過對ip地址加一個整數(形如:-N)作爲一個字符串做hash,然後移位得到4個點數。
(2) 排序後,通過2分查找進行查詢,效率較高。
(3) 這樣,添加ip時,環上以前部的點不會變化,而且把影響分攤到現有的各個ip上。

問題:
這裏我也對該算法提出了兩點疑問,
問題1:創建環和在環上查找,都是使用的hash值4位取數的辦法,那麼是否存在查找某個key時,計算的值在環上不存在?當然這裏也做了處理(找不到直接返回0號位置的ip地址: return &( (*mcsarr)[0] );),但若這種情況比較多時,誤差可能比較大。
    通過測試發現,這種情況出現的概率並不大,幾乎沒有。

問題2:其實當ip地址有變動時,還是又可能使原來的key對應的ip地址有變化,只是這種情況概率比較小?那麼能不能使得原來的key對應的ip地址不變化?還有待改進。

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