原文链接 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 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 hashRing;
private int numberOfReplicas; // The number of virtual nodes
public ConsistentHash(int numberOfReplicas, List servers)
{
this.numberOfReplicas = numberOfReplicas;
hashRing = new SortedDictionary();
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 rackServers = new List();
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
(译者注:以上几篇文章都是有链接的。需要链接的话,请查看原文。这些都是文章简介,所以没翻译。)