系統設計面試題 之 一致性雜湊
原文連結 http://www.acodersjourney.com/2017/10/system-design-interview-consistent-hashing
一致性雜湊是構建可擴充套件的儲存架構的關鍵技術之一。
在一個分散式系統中,一致性雜湊可以解決以下兩個應用場景的問題:
1.為快取伺服器提供彈性擴充套件(彈性擴充套件是指我們可以基於負載動態地增減伺服器);
2.為NoSql之類的應用擴充套件儲存節點。
這是一個在系統面試中經常出現的概念。當解決後端系統的瓶頸問題的時候你可能需要用到這個概念。你也可能被直接問到這個概念並且實現一致性演算法。
(一)為什麼我們需要一致性雜湊?
假設你要為你的web應用建立一個如下圖的有n臺數據庫伺服器的可擴充套件的資料庫後端叢集。在我們的這個簡單的例子中,我們將儲存一個類似"Country:Canada"的key:value對。
圖1:一個有資料庫叢集的分散式系統
我們的目標是設計一個有如下功能的儲存系統:
1.我們應該能夠把收到的查詢請求均勻地分發到資料庫叢集的各臺伺服器,使得各臺伺服器的負載接近;
2.我們應該能夠動態地增減資料庫伺服器;
3.當我們增減伺服器的時候,我們只需要移動最少的資料。
所以我們有必要把一個查詢的各個分片傳送到一臺特定的伺服器。一個簡單的方法如下:
1)基於收到的查詢的key生成雜湊值:"hashValue = HashFunction(Key)"
2)我們可以把雜湊值對資料庫伺服器的數量n取餘來找到處理該請求的資料庫伺服器: "serverIndex = hashValue % n"
讓我們把下面這個簡單的例子過一遍:
1)假設我們有4臺數據庫伺服器;
2)假設我們的hashFunction的值域是0到7;
3)假設key0的hashFunction的計算結果是0,key1的hashFunction的計算結果是1,以此類推;
4)所以key0對應的的資料庫伺服器索引是0,key1的伺服器索引是1。
我們假設key的資料是如下圖所示均勻分佈的。我們收到了8片資料,然後我們的雜湊演算法會把這些資料均勻的傳送到我們的4臺數據庫伺服器上。
圖2:跨越若干臺數據庫伺服器的分片/分散式資料
問題貌似解決了,但其實並非如此。該方法有兩個問題:一是水平擴充套件;二是資料分佈不均勻。
水平擴充套件
本設計無法水平擴充套件。如果我們向這個資料庫叢集增減伺服器的話,全部已經存在的對映就會被破壞。這是因為用來計算伺服器索引的取餘函式中的n發生變化了。結果就是全部已經存在的資料需要被重新對映並且遷移到不同的伺服器上。這會是一個很困難的任務,因為它或者需要系統shutdown以升級對映或者需要建立已存在系統的只讀拷貝以在資料遷移過程中繼續提供服務。換句話說,苦不堪言並且成本高昂。
下圖展示了當我們新增一臺新的伺服器的時候遇到的問題。請結合圖1的初始設計來看下圖中的問題。注意:我們需要更新初始的4臺伺服器中的3臺,也就是有75%的伺服器需要被更新!
圖3:新增一臺新的資料庫伺服器到叢集中
當一臺伺服器down掉的時候更糟糕,我們需要更新所有的伺服器!
圖4:從資料庫叢集中移除一臺伺服器
資料分佈 - 避免叢集中的"熱點"資料
我們不能假設所有的資料都是均勻分佈的。例如,可能有大量的key對映到3號伺服器,只有少量的key對映到其他伺服器。在這種情況下3號伺服器會成為資料查詢的熱點。一致性雜湊將會解決所有這些問題。且看下回分解!
(二)一致性雜湊是如何工作的?
當資料分佈於一個節點集合的時候,一致性雜湊能夠確保新增刪除節點只需要最少的資料重新對映或者重新組織。以下是工作原理:
1.建立雜湊key空間:假設我們有一個hash函式,其值域是[0, 2^32-1)。我們可以用一個有2^32 -1個槽的陣列來表示這個值域。我們把第一個槽稱為x(0),最後一個稱為x(n – 1)。
圖5:雜湊key空間
2.用一個環表示雜湊空間:假設第1步中的key值空間存放於一個環,那麼最後一個元素和第一個元素就是互相銜接的。
圖6:把雜湊key空間視覺化為一個環
3.把雜湊函式的值域(環)與資料庫伺服器相關聯:假設我們現在有一個數據庫伺服器集合。我們可以通過雜湊函式把每臺數據庫伺服器對映到環上的某個位置。例如,如果我們有4臺伺服器,那麼我們可以把伺服器的ip地址對映到環上,如下圖所示。
圖7:把資料庫伺服器對映到雜湊環上
4.決定key存放於哪臺伺服器:為了找到key與伺服器的對應關係(也就是查詢key或者插入key到伺服器),我們做如下操作:
1)使用對映伺服器到雜湊環的同一個雜湊函式來對映key;
2)然後我們得到一個整數值,這個整數值對應於雜湊空間的某個位置。有兩種情況:
a)雜湊值對映到一個沒有資料庫伺服器的位置。這時,我們可以順時針掃描雜湊環直到我們找到一個伺服器為止。然後我們再把key值插入那臺伺服器。對於key值查詢操作也是同樣的邏輯。
b)雜湊值映正好射到一個數據庫伺服器的位置。這時,我們可以直接把key插入那臺伺服器。
例如,假設我們有4個key:key0、key1、key2和key3,這些key值沒有一個直接對映到雜湊環上的4臺伺服器上。所以我們會從這些點順時針掃描雜湊環直到我們找到一臺伺服器的位置為止。如下圖8所示。
圖8:key與伺服器位置的對應關係
5.新增一臺伺服器到雜湊環:如果我們新增一臺伺服器server 4到雜湊環,那麼我們需要重新對映key。然而只有server3和server0之間的鍵值需要被對映到server 4。平均而言,我們只需要對映k/n的key,在這裡k是所有的key總數而n是所有的伺服器總數。與之形成鮮明對比的是,基於取餘的方法中我們需要重新對映幾乎全部的key。
下圖展示了插入了一個新的伺服器節點server4。由於server4位於key0和server0之間,所以key0將會被從server0重對映到server4。
圖9:新增一臺新伺服器到雜湊環
6.從雜湊環移除伺服器:伺服器有可能down掉,我們的一致性設計確保了只有最少的key和伺服器會被影響。
正如我們在下圖看到的,如果server0 down掉,那麼只有server0和server3的key需要被重新對映到server1(也就是下圖中的黃色區域)。餘下的鍵值不受影響。
圖10:從雜湊環移除伺服器的效果
到目前為止,一致性雜湊已經成功地解決了水平擴充套件的問題,它可以確保我們每次向上或者向下擴充套件的時候,都不必重新組織所有的key或者全部的資料庫伺服器!
但是資料分佈於多臺伺服器的情況該如何處理?我們可能會遇到資料庫伺服器不均勻地分佈於雜湊環的情況,這樣每臺伺服器負責的分割槽大小不同。但是你可能會問這是怎麼發生的?假設我們有三臺伺服器(server0、server1和server2),它們或多或少的均勻分佈於雜湊環上。如果其中一臺伺服器down掉了,那麼緊跟著這臺down掉的伺服器的那臺伺服器的負載會陡增。這是假設資料均勻分佈的情況。在實際生產中,這個問題會更加複雜,因為大多數情況下資料是不均勻分佈的。所以這兩個問題耦合在一起會導致下文的情況。在這裡server0的負載看起來很高是因為:
1)資料是不均勻分佈的,所以server2包含了很多熱點;
2)server2最終down掉了並且不得不從雜湊環中移除(注意:server0現在接管了server2所有的key)。
圖11:key不均勻地分佈於雜湊環的伺服器上
所以我們該如何解決這個問題?
我們有標準化的方法解決這個問題。我們可以為雜湊環上的各個伺服器節點引入很多讀拷貝或者虛擬節點。例如,server0可能在環上有兩個讀拷貝節點。
圖12:使用虛擬節點來分配每個伺服器節點覆蓋的key空間
但是如何使用讀拷貝節點來讓key分佈更均勻?這裡有個例子:圖13展示了key值分佈於雜湊環的兩臺沒有讀拷貝節點的伺服器上。我們可以觀察到server1處理了75%的key。
圖13:在一個雜湊環中沒有讀拷貝節點的時候key的不均勻分佈
如果我們為雜湊環上的每個伺服器引入一個或者多個只讀拷貝,那麼key分佈就會像圖14所示。現在server0負責50%的key,而server1負責另外50%的key。
圖14:在雜湊環上使用虛擬節點/只讀拷貝建立更好的key分佈
當雜湊環中的只讀拷貝或者虛擬節點增加的時候,key的分佈會變得越來越均勻。在真實的生產環境中,虛擬節點或者只讀拷貝的數量很多(>100)。
到目前為止,一致性雜湊已經成功地解決了跨越資料庫叢集的資料分佈不均勻的問題(熱點)。
(三)系統設計面試中與一致性雜湊有關的幾個要點
使用一致性雜湊的場景:
1)我們有一個數據庫伺服器叢集,我們要根據負載彈性擴充套件該叢集。例如,我們要在聖誕節期間新增更多的伺服器。
2)我們需要根據負載來彈性擴充套件快取伺服器叢集。
一致性雜湊的優勢:
1)支援資料庫/快取伺服器叢集的彈性擴充套件;
2)支援跨伺服器的資料的只讀拷貝節點和分割槽;
3)資料分割槽可以實現均勻分佈,從而解決“熱點”問題;
4)以上三點能實現系統的高可用性。
實現一致性雜湊
請注意以下程式碼僅僅為了demo的目的,不保證強壯性和穩定性。我們要實現三個要點:
1)一個類似雜湊表的資料結構,可以用來模擬key空間或者雜湊環。在這個程式碼實現裡,我們會使用SortedDictionary。
2)一個雜湊函式,可以用來基於伺服器的ip地址和key來生成雜湊環;
3)伺服器物件本身。
首先我們會定義一個伺服器類,該類封裝了物理層和一個ip地址。
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ConsistentHashing
{
class Server
{
public String ipAddress;
public Server(String ipAddress)
{
this.ipAddress = ipAddress;
}
}
}
接下來我們定一個雜湊函式,該函式用來基於ip地址和key值生成一個int。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
/*
* This code is taken from the stackoverflow article:
* https://stackoverflow.com/questions/12272296/32-bit-fast-uniform-hash-function-use-md5-sha1-and-cut-off-4-bytes
*/
namespace ConsistentHashing
{
public static class FNVHash
{
public static uint To32BitFnv1aHash(string toHash, bool separateUpperByte = false)
{
IEnumerable<byte> bytesToHash;
if (separateUpperByte)
bytesToHash = toHash.ToCharArray()
.Select(c => new[] { (byte)((c - (byte)c) >> 8), (byte)c })
.SelectMany(c => c);
else
bytesToHash = toHash.ToCharArray()
.Select(Convert.ToByte);
//this is the actual hash function; very simple
uint hash = FnvConstants.FnvOffset32;
foreach (var chunk in bytesToHash)
{
hash ^= chunk;
hash *= FnvConstants.FnvPrime32;
}
return hash;
}
}
public static class FnvConstants
{
public static readonly uint FnvPrime32 = 16777619;
public static readonly ulong FnvPrime64 = 1099511628211;
public static readonly uint FnvOffset32 = 2166136261;
public static readonly ulong FnvOffset64 = 14695981039346656037;
}
}
最後,我們定一個一致性雜湊類封裝了以下邏輯:
1)建立雜湊環;
2)向雜湊環新增伺服器;
3)從雜湊環中移除伺服器;
4)從雜湊環中,獲取需要更新/查詢key的那個伺服器的位置。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ConsistentHashing
{
class ConsistentHash
{
private SortedDictionary<uint, Server> hashRing;
private int numberOfReplicas; // The number of virtual nodes
public ConsistentHash(int numberOfReplicas, List<Server> servers)
{
this.numberOfReplicas = numberOfReplicas;
hashRing = new SortedDictionary<uint, Server>();
if(servers != null)
foreach(Server s in servers)
{
this.addServerToHashRing(s);
}
}
public void addServerToHashRing(Server server)
{
for(int i=0; i < numberOfReplicas; i++)
{
//Fuse the server ip with the replica number
string serverIdentity = String.Concat(server.ipAddress, ":", i);
//Get the hash key of the server
uint hashKey = FNVHash.To32BitFnv1aHash(serverIdentity);
//Insert the server at the hashkey in the Sorted Dictionary
this.hashRing.Add(hashKey, server);
}
}
public void removeServerFromHashRing(Server server)
{
for (int i = 0; i < numberOfReplicas; i++)
{
//Fuse the server ip with the replica number
string serverIdentity = String.Concat(server.ipAddress, ":", i);
//Get the hash key of the server
uint hashKey = FNVHash.To32BitFnv1aHash(serverIdentity);
//Insert the server at the hashkey in the Sorted Dictionary
this.hashRing.Remove(hashKey);
}
}
// Get the Physical server where a key is mapped to
public Server GetServerForKey(String key)
{
Server serverHoldingKey;
if(this.hashRing.Count==0)
{
return null;
}
// Get the hash for the key
uint hashKey = FNVHash.To32BitFnv1aHash(key);
if(this.hashRing.ContainsKey(hashKey))
{
serverHoldingKey = this.hashRing[hashKey];
}
else
{
uint[] sortedKeys = this.hashRing.Keys.ToArray();
//Find the first server key greater than the hashkey
uint firstServerKey = sortedKeys.FirstOrDefault(x => x >= hashKey);
// Get the Server at that Hashkey
serverHoldingKey = this.hashRing[firstServerKey];
}
return serverHoldingKey;
}
}
}
然後是一個測試程式,測試以上程式碼。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Security.Cryptography;
namespace ConsistentHashing
{
class Program
{
static void Main(string[] args)
{
List<Server> rackServers = new List<Server>();
rackServers.Add(new Server("10.0.0.1"));
rackServers.Add(new Server("10.0.0.2"));
int numberOfReplicas = 1;
ConsistentHash serverDistributor = new ConsistentHash(numberOfReplicas, rackServers);
//add a new server to the mix
Server newServer = new Server("10.0.0.3");
serverDistributor.addServerToHashRing(newServer);
//Assume you have a key "key0"
Server serverForKey = serverDistributor.GetServerForKey("key0");
Console.WriteLine("Server: " + serverForKey.ipAddress + " holds key: Key0");
// Now remove a server
serverDistributor.removeServerFromHashRing(newServer);
// Now check on which server "key0" landed up
serverForKey = serverDistributor.GetServerForKey("key0");
Console.WriteLine("Server: " + serverForKey.ipAddress + " holds key: Key0");
}
}
}
輸出:
Server: 10.0.0.3 holds key: Key0
Server: 10.0.0.2 holds key: Key0
(四)生產系統中的一致性雜湊
以下系統使用了一致性雜湊:
Couchbase automated data partitioning
Partitioning component of Amazon's storage system Dynamo
Data partitioning in Apache Cassandra
Riak, a distributed key-value database
Akamai Content Delivery Network
Discord chat application
(譯者注:以上幾個專案都是有連結的。需要連結的話,請檢視原文。這些都是專案名稱,所以沒翻譯。)
(五)有關一致性雜湊的擴充套件閱讀
1. Tom White's article on Consistent Hashing is the one i used to initially learn about this technique. The C# implementation in this article is loosely based on his java implementation.
2. Tim Berglund's Distributed System in One Lesson is a fantastic resource to learn about read replication, sharding and consistent hashing. Unfortunately, you'll need a safari membership for this.
3. David Karger and Eric Lehman's original paper on Consistent Hashing
4. David Karger and Alex Sherman's paper on Web Caching with Consistent Hashing
(譯者注:以上幾篇文章都是有連結的。需要連結的話,請檢視原文。這些都是文章簡介,所以沒翻譯。)