一致性哈希
由於hash算法結果一般為unsigned int型,因此對於hash函數的結果應該均勻分布在[0,2^32-1]區間,如果我們把一個圓環用2^32 個點來進行均勻切割,首先按照hash(key)函數算出服務器(節點)的哈希值, 並將其分布到0~2^32的圓環上。
用同樣的hash(key)函數求出需要存儲數據的鍵的哈希值,並映射到圓環上。然后從數據映射到的位置開始順時針查找,將數據保存到找到的第一個服務器(節點)上。如圖所示:
key1、key2、key3和server1、server2通過hash都能在這個圓環上找到自己的位置,並且通過順時針的方式來將key定位到 server。按上圖來說,key1和key2存儲到server1,而key3存儲到server2。如果新增一台server,hash后在key1 和key2之間,則只會影響key1(key1將會存儲在新增的server上),其它則不變。
虛擬節點
在上圖中,很容易看出一個問題,沿順時針方向看,server2到server1之間的區間跨度大,而server1到server2的區間跨度小,這就會導致一個問題:數據分布不均勻。大部分數據都分配到server1了,只有小部分數據分布在server2。在服務器數據很少的時候,數據不均勻會表現的非常明顯。
解決這個問題的方法是使用虛擬節點,一個真實服務器對應多個虛擬節點,所有虛擬節點按hash值分布在一致性哈希圓環上。具體實現方法可以這樣做,為真實服務器設置副本數量,然后根據各真實服務器的IP和端口號再加上一個遞增的索引數計算hash值。
class RedisCache { public $servers = array(); //真實服務器 private $_servers = array(); //虛擬節點 const SERVER_REPLICAS = 10000; //服務器副本數量,提高一致性哈希算法的數據分布均勻程度 public function __construct( $servers ){ $this->servers = $servers; //Redis虛擬節點哈希表 foreach ($this->servers as $k => $server) { for ($i = 0; $i < self::SERVER_REPLICAS; $i++) { $hash = crc32($server['host'] . '#' .$server['port'] . '#'. $i); $this->_servers[$hash] = $k; } } ksort($this->_servers); // something else... } }
副本數量可以按真實服務器的數量調節,真實服務器多則副本數量可以設置小一點,真實服務器少則副本數量需要設置多一點。在虛擬節點數量很大的時候,出於性能考慮所以不能使用循環的方法查找key對應的虛擬節點,可以使用二分法快速查找。一個優化的算法是不論副本數量設置為10,100,還是10000的時候,對查找所需要的時間基本是沒有影響的。
故障轉移
使用一次性哈希實現Redis分布式部署了,還需要考慮系統的可用性和穩定性。需要做到,在某一台或者多台server故障的時候,程序能夠自動檢測到故障,並將數據重新定位到其它server。
我們可以考慮,根據key查找到的虛擬節點所對應的真實服務器故障的時候,我們在一次性哈希圓環上沿順時針方向順移一步,找到下一點虛擬節點對應的真實服務器,將所要存儲的數據存放上去。但也很有可能下一個虛擬節點所對應的真實服務器與前一個虛擬節點相同,還是那台故障的服務器,而每次嘗試連接故障的redis服務是一個很大的性能開銷。所以在第一次檢測到故障服務器的時候就需要記錄下來,然后在順移到下一個虛擬節點的時候先判斷是不是之前那一台故障的服務器,如果是那就不要再嘗試進行連接,直接查找下一個虛擬節點,直到找到可用的服務器將數據存儲上去。
完整示例代碼
class RedisCache { public $servers = array(); //真實服務器 private $_servers = array(); //虛擬節點 private $_serverKeys = array(); private $_badServers = array(); // 故障服務器列表 private $_count = 0; const SERVER_REPLICAS = 10000; //服務器副本數量,提高一致性哈希算法的數據分布均勻程度 public function __construct( $servers ){ $this->servers = $servers; $this->_count = count($this-> servers); //Redis虛擬節點哈希表 foreach ($this ->servers as $k => $server) { for ($i = 0; $i < self::SERVER_REPLICAS; $i++) { $hash = crc32($server[ 'host'] . '#' .$server['port'] . '#'. $i); $this->_servers [$hash] = $k; } } ksort( $this->_servers ); $this->_serverKeys = array_keys($this-> _servers); } /** * 使用一致性哈希分派服務器,附加故障檢測及轉移功能 */ private function getRedis($key){ $hash = crc32($key); $slen = $this->_count * self:: SERVER_REPLICAS; // 快速定位虛擬節點 $sid = $hash > $this->_serverKeys [$slen-1] ? 0 : $this->quickSearch($this->_serverKeys, $hash, 0, $slen); $conn = false; $i = 0; do { $n = $this->_servers [$this->_serverKeys[$sid]]; !in_array($n, $this->_badServers ) && $conn = $this->getRedisConnect($n); $sid = ($sid + 1) % $slen; } while (!$conn && $i++ < $slen); return $conn ? $conn : new Redis(); } /** * 二分法快速查找 */ private function quickSearch($stack, $find, $start, $length) { if ($length == 1) { return $start; } else if ($length == 2) { return $find <= $stack[$start] ? $start : ($start +1); } $mid = intval($length / 2); if ($find <= $stack[$start + $mid - 1]) { return $this->quickSearch($stack, $find, $start, $mid); } else { return $this->quickSearch($stack, $find, $start+$mid, $length-$mid); } } private function getRedisConnect($n=0){ static $REDIS = array(); if (!$REDIS[$n]){ $REDIS[$n] = new Redis(); try{ $ret = $REDIS[$n]->pconnect( $this->servers [$n]['host'], $this->servers [$n]['port']); if (!$ret) { unset($REDIS[$n]); $this->_badServers [] = $n; return false; } } catch(Exception $e){ unset($REDIS[$n]); $this->_badServers [] = $n; return false; } } return $REDIS[$n]; } public function getValue($key){ try{ $getValue = $this->getRedis($key)->get($key); } catch(Exception $e){ $getValue = null; } return $getValue; } public function setValue($key,$value,$expire){ if($expire == 0){ try{ $ret = $this->getRedis($key)->set($key, $value); } catch(Exception $e){ $ret = false; } } else{ try{ $ret = $this->getRedis($key)->setex($key, $expire, $value); } catch(Exception $e){ $ret = false; } } return $ret; } public function deleteValue($key){ return $this->getRedis($key)->delete($key); } public function flushValues(){ //TODO return true; } } // Usage: $redis_servers = array( array( 'host' => '10.0.0.1', 'port' => 6379, ), array( 'host' => '10.0.0.2', 'port' => 6379, ), array( 'host' => '10.0.0.3', 'port' => 6379, ), array( 'host' => '10.0.0.3', 'port' => 6928, ), ); $redisCache = new RedisCache($redis_servers); $testKey = 'test_key'; $testValue = 'test_value_object'; $redisCache->setValue($testKey, $testValue, 3600); $value = $redisCache->getValue($testKey);