序
在大型互聯網應用架構中,通常由多臺Memcache緩存服務器來構建Memcache集羣,也叫做分佈式Memcache。數據在寫入緩存和從緩存中讀取時,都會採用某中Hash算法,將數據Hash到某臺具體的Memcache上,爲了使應用在線的動態增加和移除Memcache服務器而不影響或很少影響其他已部署的Memcache服務器(也就是其他Memcache服務器中緩存的數據還能正常使用),這種Hash算法被稱作爲一致性Hash。
由一篇文章---Memcache分佈式實現原理我們知道,Java_Memcahe支持一般Hash算法和實現應用中使用最廣泛的一致性Hash算法,這兩種算法都支持Server配置權重的虛擬節點Hash算法。下面我們由淺入深一一來探討學習一下Java_Memcache的各種Hash 算法。
根據Memcache操作的步驟
SockIOPool sockpool= SockIOPool.getInstance();
//設置緩存服務器地址,可以設置多個實現分佈式緩存
sockpool.setServers(new String[]{"127.0.0.1:11211","127.0.0.1:11212"});
//設置初始連接5
sockpool.setInitConn(5);
......
sockpool.initialize();//根據server以及一些配置信息初始化
memCache = new MemCachedClient();
//使用memcache操作數據memCache.get() & memCache.set()
一般Hash算法
初始化
在SockIOPool類初始化時會調用SchoonerSocketIOPool的PopulateBuckets方法,保存好Server信息,以便後續操作數據時使用。
private void populateBuckets()
{
buckets = new ArrayList();
for(int i = 0; i < servers.length; i++)
{
if(weights != null && weights.length > i)
{
for(int j = 0; j < weights[i].intValue(); j++)
buckets.add(servers[i]);
} else
{
buckets.add(servers[i]);//按Server的個數保存好Server地址及端口
}
Object obj;
if(authInfo != null)
obj = new AuthSchoonerSockIOFactory(servers[i], isTcp, bufferSize, socketTO, socketConnectTO, nagle, authInfo);
else
obj = new SchoonerSockIOFactory(servers[i], isTcp, bufferSize, socketTO, socketConnectTO, nagle);
GenericObjectPool genericobjectpool = new GenericObjectPool(((org.apache.commons.pool.PoolableObjectFactory) (obj)), maxConn, (byte)1, maxIdle, maxConn);
((SchoonerSockIOFactory) (obj)).setSockets(genericobjectpool);
socketPool.put(servers[i], genericobjectpool);//保存好Server與對應的對象工廠,該工廠管理與Server建立Socket的連接
}
}
在不考慮權重時,按Server的個數保存好Server信息,並保存與該server對應的對象工廠,用於管理與server建立Socket連接。
操作數據
在使用MemcacheClient進行get/set 時實際操作的是AscIIClient的get/set操作,由Memcache分佈式原理我們知道,get方法最後執行的是get(String s, String s1, Integer integer, boolean flag)方法。
public class AscIIClient extends MemCachedClient
{
private Object get(String s, String s1, Integer integer, boolean flag)
{
SchoonerSockIO schoonersockio;
String s2;
......
s1 = sanitizeKey(s1);//UTF-8編碼
schoonersockio = pool.getSock(s1, integer);//獲取對應的memcache server socket,後續直接從該server上讀取數據
......
}
}
然後我們再看看pool.getSocket
public class SchoonerSockIOPool
{
public final SchoonerSockIO getSock(String s, Integer integer)
{
......
if(i == 1){
//這裏作了一點優化,如果只有一臺Memcache服務器,就直接拿List中的第一個Server,沒必要後面去Hash
SchoonerSockIO schoonersockio = hashingAlg != 3 ? getConnection((String)buckets.get(0)) : getConnection((String)consistentBuckets.get(consistentBuckets.firstKey()));
return schoonersockio;
}
HashSet hashset = new HashSet(Arrays.asList(servers));
long l = getBucket(s, integer);//利用key--s的hashcode%Server個數,找到某個server在list中的位置
String s1 = hashingAlg != 3 ? (String)buckets.get((int)l) : (String)consistentBuckets.get(Long.valueOf(l));
do
{
if(hashset.isEmpty())
break;
SchoonerSockIO schoonersockio1 = getConnection(s1);//根據Server IP和商品,建立Socket
if(schoonersockio1 != null)
return schoonersockio1; //返回Socket
......
} while(true);
return null;
}
private final long getBucket(String s, Integer integer)
{
long l = getHash(s, integer);
......
long l1 = l % (long)buckets.size(); //利用HashCode % server個數
if(l1 < 0L)
l1 *= -1L;
return l1;
}
private final long getHash(String s, Integer integer)
{
if(integer != null)
if(hashingAlg == 3)
return integer.longValue() & 4294967295L;
else
return integer.longValue();
switch(hashingAlg)
{
case 0: // '\0'
return (long)s.hashCode();//直接返回key的hashCode
case 1: // '\001'
return origCompatHashingAlg(s);
case 2: // '\002'
return newCompatHashingAlg(s);
case 3: // '\003'
return md5HashingAlg(s);
}
hashingAlg = 0;
return (long)s.hashCode();
}
小結: 在初始化時將所有的Memcache Server存放在List中,後面操作數據時,根據數據的HashCode % server個數,計算出數據真正操作在哪臺Server上,然後再建立與該Server的socket連接,操作數據。
問題:在site上,如果後期動態增加或移除Memcache server,數據的hashcode沒有發生變化,但是由於server數據發生變化,取模運算的模變化了,計算的結果也會發生變化,所有Memcache Server上的數據都會失效,這對後臺數據庫來說簡單就是災難。
基於權重的Hash算法
在上述算法中,利用取模運算,每臺Memcache Server具體等同的概率被命中,但是在實際應用中,各個server的硬件,軟件配置不同,都使用等同的命中率是不太合適的。例如,server A和C的可供Memcach使用內存都是2G,另外一臺server B是8G,使用相同的命中率顯然會浪費寶貴的內存資源。
爲了解決這一問題,Memcache Client引入了權重的概念,算法會根據每臺Server的權重來分配命中到該機器上的概率。例如:server A和C的權重都設置爲2,server B的權重設置爲8,那麼A、C的命中率爲2/12,B的命中率爲8/12。話不多話,直接上代碼。
private void populateBuckets()
{
buckets = new ArrayList();
for(int i = 0; i < servers.length; i++)
{
if(weights != null && weights.length > i)
{
//如果配置了權重,根據權重在list中保存server信息時增加虛擬server
for(int j = 0; j < weights[i].intValue(); j++)
buckets.add(servers[i]);
}
......
結合前面的例子會更容易讓人理解,在list中保存2次server A的信息,8次server B的信息,2次server C的信息,總共12個server。後面使用相同的Hash算法進行%12運算,根據概率的平均分佈,list中每個server的概率都是1/12,但是其中2個server對應的是server A,8個server對應的是server B,2個server對應的是server C。
這樣,根據各個server的權重就能決定server的數據命中率,以達到合理使用資源的要求了。
一致性Hash算法
初始化
在SockIOPool初始化時根據配置的hash算法完成初始化, SocketIOPool中hashingAlg ==3表示一致性Hash.
public class SchoonerSockIOPool
{
private TreeMap consistentBuckets;
......
private void populateConsistentBuckets()
{
consistentBuckets = new TreeMap();
MessageDigest messagedigest = (MessageDigest)MD5.get();
if(totalWeight.intValue() <= 0 && weights != null)
{
for(int i = 0; i < weights.length; i++)
{
SchoonerSockIOPool schoonersockiopool = this;
schoonersockiopool.totalWeight = Integer.valueOf(schoonersockiopool.totalWeight.intValue() + (weights[i] != null ? weights[i].intValue() : 1));
}
}
if(weights == null)
totalWeight = Integer.valueOf(servers.length);
for(int j = 0; j < servers.length; j++)
{
int k = 1;
if(weights != null && weights[j] != null)
k = weights[j].intValue();
double d = Math.floor((double)(40 * servers.length * k) / (double)totalWeight.intValue());
for(long l = 0L; (double)l < d; l++)
{
byte abyte0[] = messagedigest.digest((new StringBuilder()).append(servers[j]).append("-").append(l).toString().getBytes());
for(int i1 = 0; i1 < 4; i1++)
{
Long long1 = Long.valueOf((long)(abyte0[3 + i1 * 4] & 255) << 24 | (long)(abyte0[2 + i1 * 4] & 255) << 16 | (long)(abyte0[1 + i1 * 4] & 255) << 8 | (long)(abyte0[0 + i1 * 4] & 255));
consistentBuckets.put(long1, servers[j]);
}
}
Object obj;
if(authInfo != null)
obj = new AuthSchoonerSockIOFactory(servers[j], isTcp, bufferSize, socketTO, socketConnectTO, nagle, authInfo);
else
obj = new SchoonerSockIOFactory(servers[j], isTcp, bufferSize, socketTO, socketConnectTO, nagle);
GenericObjectPool genericobjectpool = new GenericObjectPool(((org.apache.commons.pool.PoolableObjectFactory) (obj)), maxConn, (byte)1, maxIdle, maxConn);
((SchoonerSockIOFactory) (obj)).setSockets(genericobjectpool);
socketPool.put(servers[j], genericobjectpool);
}
}
上面的算法理解起來比較費力,先考慮只有沒有配置權重的情況,算法簡化爲
for(int j = 0; j < servers.length; j++)
{
double d = 40;
for(long l = 0L; (double)l < 40 ; l++)
{
byte abyte0[] = messagedigest.digest((new StringBuilder()).append(servers[j]).append("-").append(l).toString().getBytes());
for(int i1 = 0; i1 < 4; i1++)
{
Long long1 = Long.valueOf((long)(abyte0[3 + i1 * 4] & 255) << 24 | (long)(abyte0[2 + i1 * 4] & 255) << 16 | (long)(abyte0[1 + i1 * 4] & 255) << 8 | (long)(abyte0[0 + i1 * 4] & 255));
consistentBuckets.put(long1, servers[j]);
}
}
}
首先利用server信息進行MD5加密產生字符數組,然後最內層的for循環將字符數據分割成4片,每一片轉換爲32位的long值
a[3] a[2] a[1] a[0]
a[7] a[6] a[5] a[4]
a[11] a[10] a[9] a[8]5
a[15] a[14] a[13] a[12]
歸根到底是爲了保證每個long值的唯一性。
每臺server都會保存160份信息在Map中。
操作數據
在使用MemcacheClient進行get/set 時實際操作的是AscIIClient的get/set操作,get方法最後執行的是get(String s, String s1, Integer integer, boolean flag)方法,先調用 pool.getSocket獲取對應memache server的socket,然後再操作數據。
public class SchoonerSockIOPool
{
public final SchoonerSockIO getSock(String s, Integer integer)
{
......
HashSet hashset = new HashSet(Arrays.asList(servers));
long l = getBucket(s, integer);
String s1 = hashingAlg != 3 ? (String)buckets.get((int)l) : (String)consistentBuckets.get(Long.valueOf(l));
do
{
if(hashset.isEmpty())
break;
SchoonerSockIO schoonersockio1 = getConnection(s1);//根據Server IP和商品,建立Socket
if(schoonersockio1 != null)
return schoonersockio1; //返回Socket
......
} while(true);
return null;
}
private final long getBucket(String s, Integer integer)
{
long l = getHash(s, integer);
if(hashingAlg == 3)
return findPointFor(Long.valueOf(l)).longValue();
......
}
private final long getHash(String s, Integer integer)
{
......
switch(hashingAlg)
{
......
case 3: // '\003'
return md5HashingAlg(s);//返回key的MD5 hashCode
}
hashingAlg = 0;
return (long)s.hashCode();
}
private static long md5HashingAlg(String s)
{
//根據key值進行MD5加密,取前32位作爲hashCode
MessageDigest messagedigest = (MessageDigest)MD5.get();
messagedigest.reset();
messagedigest.update(s.getBytes());
byte abyte0[] = messagedigest.digest();
long l = (long)(abyte0[3] & 255) << 24 | (long)(abyte0[2] & 255) << 16 | (long)(abyte0[1] & 255) << 8 | (long)(abyte0[0] & 255);
//在初始化時也用到了該值,只不過初始化時計算MD5是用的server信息
return l;
}
private final Long findPointFor(Long long1)
{
SortedMap sortedmap = consistentBuckets.tailMap(long1);
return sortedmap.isEmpty() ? (Long)consistentBuckets.firstKey() : (Long)sortedmap.firstKey();
}
這裏需要了解一下tailMap API
翻譯過來就是該方法返回Map中key值大於或等於參數的所有元素的視圖,對該視圖的修改都會反映到Map中,反之對Map的修改也會反映到視圖中。
在找到Hash值大於key Hash值的所有server後,使用第一個server。整個處理流程可以表示爲下圖。
小結:一致性Hash算法首先利用server信息進行MD5 Hash,將server映射到32位的圓環上(只取32位),在操作數據時,用MD5對data進行Hash,將data映射到32位的圓環上(也取32位),順時針找到第一個Hash值比data Hash值大的server。
需要注意的是,該算法也加入了虛擬節點的思想,每節server進行MD5 Hash映射到圓環上時都虛擬到了很多個節點,在本例中每臺server虛擬出了160個節點,這樣做是爲了保證數據能較均勻的分佈到各個server上。
採用MD5 對server和data進行Hash時不依賴於其他server,因此Hash值的計算是一致性的,而僅僅在將data映射到某臺server上時取決於第一個Hash值大於或等於data Hash值的server,在動態增加和移除server時僅會影響該server右邊直到逆時針下一個server之間的data,而其他區間的data不受影響,該算法被廣泛應用中Memcache集羣中。
基於權重的一致性Hash算法
對於一般Hash算法,一致性Hash算法還有一個很重要的區別:在沒有配置權重時,一致性Hash算法也會增加虛擬節點。基於權重的一致性Hash算法也是爲了保證數據的命中率。
結合一般Hash算法中的例子,Server A的權重是2,Server B的權重是8,Server C的權重是2,在初始化populateConsistentBuckets 中,Server A會分配80個虛擬節點,Server B會分配320個虛擬節點,Server C會分配80個虛擬節點。
總結
通過對源碼的分析,我們理清了Memcache 的一般Hash算法和一致性Hash算法,揭開了Memcache分佈式算法的真實面目,在理解完算法後,我們是不是應該更加透澈地理解Memcache了呢?(寫於2015-05-17)