隨着memcache、Redis以及其它一些內存K/V數據庫的流行,一致性哈希也愈來愈被開發者所瞭解。由於這些內存K/V數據庫大多不提供分佈式支持(本文以redis爲例),因此若是要提供多臺redis server來提供服務的話,就須要解決如何將數據分散到redis server,而且在增減redis server時如何最大化的不令數據從新分佈,這將是本文討論的範疇。
取模算法
取模運算一般用於獲得某個半開區間內的值:m % n = v,其中n不爲0,值v的半開區間爲:[0, n)。取模運算的算法很簡單:有正整數k,並令k使得k和n的乘積最大但不超過m,則v的值爲:m - kn。好比1 % 5,令k = 0,則k * 5的乘積最大並不超過1,故結果v = 1 - 0 * 5 = 1。
咱們在分表時也會用到取模運算。如一個表要劃分三個表,則可對3進行取模,由於結果老是在[0, 3)以內,也就是取值爲:0、一、2。
可是對於應用到redis上,這種方式就不行了,由於太容易衝突了。
哈希(Hash)
Hash,通常翻譯作「散列」,也有直接音譯爲"哈希"的,就是把任意長度的輸入(又叫作預映射, pre-image),經過散列算法,變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是散列值的空間一般遠小於輸入的空間,不一樣的輸入可能會散列成相同的輸出,而不可能從散列值來惟一的肯定輸入值。
簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數。
目前廣泛採用的哈希算法是time33,又稱DJBX33A (Daniel J. Bernstein, Times 33 with Addition)。這個算法被普遍運用於多個軟件項目,Apache、Perl和Berkeley DB等。對於字符串而言這是目前所知道的最好的哈希算法,緣由在於該算法的速度很是快,並且分類很是好(衝突小,分佈均勻)。
PHP
內核就採用了time33算法來實現HashTable,來看下time33的定義:
- hash(i) = hash(i-1) * 33 + str[i]
有了定義就容易實現了:
- <?php
- function myHash($str) {
- // hash(i) = hash(i-1) * 33 + str[i]
- $hash = 0;
- $s = md5($str);
- $seed = 5;
- $len = 32;
- for ($i = 0; $i < $len; $i++) {
- // (hash << 5) + hash 至關於 hash * 33
- //$hash = sprintf("%u", $hash * 33) + ord($s{$i});
- //$hash = ($hash * 33 + ord($s{$i})) & 0x7FFFFFFF;
- $hash = ($hash << $seed) + $hash + ord($s{$i});
- }
-
- return $hash & 0x7FFFFFFF;
- }
-
- echo myHash("test"); //輸出 786776064
利用取模實現
如今有2臺redis server,因此須要計算鍵的hash並跟2取模。好比有鍵key1和key2,代碼以下:
- <?php
- function myHash($str) {
- // hash(i) = hash(i-1) * 33 + str[i]
- $hash = 0;
- $s = md5($str);
- $seed = 5;
- $len = 32;
- for ($i = 0; $i < $len; $i++) {
- // (hash << 5) + hash 至關於 hash * 33
- //$hash = sprintf("%u", $hash * 33) + ord($s{$i});
- //$hash = ($hash * 33 + ord($s{$i})) & 0x7FFFFFFF;
- $hash = ($hash << $seed) + $hash + ord($s{$i});
- }
-
- return $hash & 0x7FFFFFFF;
- }
-
- echo "key1: " . (myHash("key1") % 2) . "\n";
- echo "key2: " . (myHash("key2") % 2) . "\n";
對於key1和key2來講,同時存儲到一臺服務器上,這彷佛沒什麼問題,但正由於key1和key2是始終存儲到這臺服務器上,一旦這臺服務器下線了,則這臺服務器上的數據所有要從新定位到另外一臺服務器。對於增長服務器也是相似的狀況。並且從新hash(以前跟2進行hash,如今是跟3進行hash)以後,結果就變掉了,致使大多數數據須要從新定位到redis server。
在服務器數量不變的時候,這種方式也是能很好的工做的。
一致性哈希
因爲hash算法結果通常爲unsigned int型,所以對於hash函數的結果應該均勻分佈在[0,2^32-1]區間,若是咱們把一個圓環用2^32 個點來進行均勻切割,首先按照hash(key)函數算出服務器(節點)的哈希值, 並將其分佈到0~2^32的圓環上。
用一樣的hash(key)函數求出須要存儲數據的鍵的哈希值,並映射到圓環上。而後從數據映射到的位置開始順時針查找,將數據保存到找到的第一個服務器(節點)上。如圖所示:
key一、key二、key3和server一、server2經過hash都能在這個圓環上找到本身的位置,而且經過順時針的方式來將key定位到server。按上圖來講,key1和key2存儲到server1,而key3存儲到server2。若是新增一臺server,hash後在key1和key2之間,則只會影響key1(key1將會存儲在新增的server上),其它不變。
上圖這個圓環至關因而一個排好序的數組,咱們先經過代碼來看下key一、key二、key三、server一、server2的hash值,而後再做分析:
- <?php
- function myHash($str) {
- // hash(i) = hash(i-1) * 33 + str[i]
- $hash = 0;
- $s = md5($str);
- $seed = 5;
- $len = 32;
- for ($i = 0; $i < $len; $i++) {
- // (hash << 5) + hash 至關於 hash * 33
- //$hash = sprintf("%u", $hash * 33) + ord($s{$i});
- //$hash = ($hash * 33 + ord($s{$i})) & 0x7FFFFFFF;
- $hash = ($hash << $seed) + $hash + ord($s{$i});
- }
-
- return $hash & 0x7FFFFFFF;
- }
-
- //echo myHash("卻道天涼好個秋~");
- echo "key1: " . myHash("key1") . "\n";
- echo "key2: " . myHash("key2") . "\n";
- echo "key3: " . myHash("key3") . "\n";
- echo "serv1: " . myHash("server1") . "\n";
- echo "serv2: " . myHash("server2") . "\n";
如今咱們根據hash值從新畫一張在圓環上的分佈圖,以下所示:
key一、key2和key3都存儲到了server1上,這是正確的,由於是按順時針來定位。咱們想像一下,全部的server其實就是一個排好序的數組(降序):[server2, server1],而後經過計算key的hash值來獲得處於哪一個server上。來分析下定位過程:若是隻有一臺server,即[server],則直接定位,取數組的第一個元素。若是有多臺server,則要先看經過key計算的hash值是否落在[server2, server1, ...]這個區間上,這個直接跟數組的第一個元素和最後一個元素比較就知道了。而後就能夠經過查找來定位了。
利用一致性哈希實現
下面是一個實現一致性哈希的例子,僅僅實現了addServer和find。其實對於remove的實現跟addServer是相似的。代碼以下:
- <?php
- function myHash($str) {
- // hash(i) = hash(i-1) * 33 + str[i]
- $hash = 0;
- $s = md5($str);
- $seed = 5;
- $len = 32;
- for ($i = 0; $i < $len; $i++) {
- // (hash << 5) + hash 至關於 hash * 33
- //$hash = sprintf("%u", $hash * 33) + ord($s{$i});
- //$hash = ($hash * 33 + ord($s{$i})) & 0x7FFFFFFF;
- $hash = ($hash << $seed) + $hash + ord($s{$i});
- }
-
- return $hash & 0x7FFFFFFF;
- }
-
- class ConsistentHash {
- // server列表
- private $_server_list = array();
- // 延遲排序,由於可能會執行屢次addServer
- private $_layze_sorted = FALSE;
-
- public function addServer($server) {
- $hash = myHash($server);
- $this->_layze_sorted = FALSE;
-
- if (!isset($this->_server_list[$hash])) {
- $this->_server_list[$hash] = $server;
- }
-
- return $this;
- }
-
- public function find($key) {
- // 排序
- if (!$this->_layze_sorted) {
- asort($this->_server_list);
- $this->_layze_sorted = TRUE;
- }
-
- $hash = myHash($key);
- $len = sizeof($this->_server_list);
- if ($len == 0) {
- return FALSE;
- }
-
- $keys = array_keys($this->_server_list);
- $values = array_values($this->_server_list);
-
- // 若是不在區間內,則返回最後一個server
- if ($hash <= $keys[0] || $hash >= $keys[$len - 1]) {
- return $values[$len - 1];
- }
-
- foreach ($keys as $key=>$pos) {
- $next_pos = NULL;
- if (isset($keys[$key + 1]))
- {
- $next_pos = $keys[$key + 1];
- }
-
- if (is_null($next_pos)) {
- return $values[$key];
- }
-
- // 區間判斷
- if ($hash >= $pos && $hash <= $next_pos) {
- return $values[$key];
- }
- }
- }
- }
-
- $consisHash = new ConsistentHash();
- $consisHash->addServer("serv1")->addServer("serv2")->addServer("server3");
- echo "key1 at " . $consisHash->find("key1") . ".\n";
- echo "key2 at " . $consisHash->find("key2") . ".\n";
- echo "key3 at " . $consisHash->find("key3") . ".\n";
- $ php -f test.php
- key1 at server3.
- key2 at server3.
- key3 at serv2.
即便新增或下線服務器,也不會影響所有,只要根據hash順時針定位就能夠了。
結束語
常常有人問在有多臺redis server時,新增或刪除節點如何通知其它節點。之因此會這麼問,是由於不瞭解redis的部署方式。