vlambda博客
学习文章列表

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

作者:陶邦仁

来源:my.oschina.net/xianggao/blog/394545


1 分布式算法

在做服务器负载均衡时候可供选择的负载均衡的算法有很多,包括:轮循算法(Round Robin)哈希算法(HASH)最少连接算法(Least Connection)响应速度算法(Response Time)加权法(Weighted)等。其中哈希算法是最为常用的算法.

「典型的应用场景是:有N台服务器提供缓存服务,需要对服务器进行负载均衡,将请求平均分发到每台服务器上,每台机器负责1/N的服务。」

常用的算法是对 hash 结果取余数 (hash() mod N ):对机器编号从 0 到 N-1,按照自定义的 hash()算法,对每个请求的 hash()值按 N 取模,得到余数 i,然后将请求分发到编号为 i 的机器。但这样的算法方法存在致命问题,如果某一台机器宕机,那么应该落在该机器的请求就无法得到正确的处理,这时需要将当掉的服务器从算法从去除,此时候会有(N-1)/N 的服务器的缓存数据需要重新进行计算;如果新增一台机器,会有 N /(N+1) 的服务器的缓存数据需要进行重新计算。对于系统而言,这通常是不可接受的颠簸(因为这意味着大量缓存的失效或者数据需要转移)。那么,如何设计一个负载均衡策略,使得受到影响的请求尽可能的少呢?

在 Memcached、Key-Value Store 、Bittorrent DHT、LVS 中都采用了 Consistent Hashing算法,可以说 Consistent Hashing 是分布式系统负载均衡的首选算法。

2 分布式缓存问题

在大型web应用中,缓存可算是当今的一个标准开发配置了。在大规模的缓存应用中,应运而生了分布式缓存系统。分布式缓存系统的基本原理,大家也有所耳闻。key-value 如何均匀的分散到集群中?说到此,最常规的方式莫过于 hash 取模的方式。比如集群中可用机器适量为 N,那么 key 值为 K 的的数据请求很简单的应该路由到 hash(K) mod N 对应的机器。的确,这种结构是简单的,也是实用的。但是在一些高速发展的 web 系统中,这样的解决方案仍有些缺陷。随着系统访问压力的增长,缓存系统不得不通过增加机器节点的方式提高集群的相应速度和数据承载量。增加机器意味着按照 hash 取模的方式,在增加机器节点的这一时刻,大量的缓存命不中,缓存数据需要重新建立,甚至是进行整体的缓存数据迁移,瞬间会给 DB 带来极高的系统负载,设置导致 DB 服务器宕机。那么就没有办法解决 hash 取模的方式带来的诟病吗?

假设我们有一个网站,最近发现随着流量增加,服务器压力越来越大,之前直接读写数据库的方式不太给力了,于是我们想引入 Memcached 作为缓存机制。现在我们一共有三台机器可以作为 Memcached 服务器,如下图所示。

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

很显然,最简单的策略是将每一次 Memcached 请求随机发送到一台 Memcached 服务器,但是这种策略可能会带来两个问题:一是同一份数据可能被存在不同的机器上而造成数据冗余,二是有可能某数据已经被缓存但是访问却没有命中,因为无法保证对相同 key 的所有访问都被发送到相同的服务器。因此,随机策略无论是时间效率还是空间效率都非常不好。

要解决上述问题只需做到如下一点:保证对相同 key 的访问会被发送到相同的服务器。很多方法可以实现这一点,最常用的方法是计算哈希。例如对于每次访问,可以按如下算法计算其哈希值:

h = Hash(key) % 3

其中 Hash 是一个从字符串到正整数的哈希映射函数。这样,如果我们将 Memcached Server 分别编号为 0、1、2,那么就可以根据上式和 key 计算出服务器编号h,然后去访问。

这个方法虽然解决了上面提到的两个问题,但是存在一些其它的问题。如果将上述方法抽象,可以认为通过:

h = Hash(key) % N

这个算式计算每个 key 的请求应该被发送到哪台服务器,其中 N 为服务器的台数,并且服务器按照 0 – (N-1) 编号。

这个算法的问题在于容错性和扩展性不好。所谓容错性是指当系统中某一个或几个服务器变得不可用时,整个系统是否可以正确高效运行;而扩展性是指当加入新的服务器后,整个系统是否可以正确高效运行。

现假设有一台服务器宕机了,那么为了填补空缺,要将宕机的服务器从编号列表中移除,后面的服务器按顺序前移一位并将其编号值减一,此时每个key就要按h = Hash(key) % (N-1)重新计算;同样,如果新增了一台服务器,虽然原有服务器编号不用改变,但是要按h = Hash(key) % (N+1)重新计算哈希值。因此系统中一旦有服务器变更,大量的key会被重定位到不同的服务器从而造成大量的缓存不命中。而这种情况在分布式系统中是非常糟糕的。

一个设计良好的分布式哈希方案应该具有良好的单调性,即服务节点的增减不会造成大量哈希重定位。一致性哈希算法就是这样一种哈希方案。

Hash 算法的一个衡量指标是单调性( Monotonicity ),定义如下:

单调性是指如果已经有一些内容通过哈希分派到了相应的缓冲中,又有新的缓冲加入到系统中。哈希的结果应能够保证原有已分配的内容可以被映射到新的缓冲中去,而不会被映射到旧的缓冲集合中的其他缓冲区。

容易看到,上面的简单 hash 算法 hash(object)%N 难以满足单调性要求。

3 一致性哈希算法

3.1 算法简述

「一致性哈希算法(Consistent Hashing Algorithm)是一种分布式算法,常用于负载均衡。」 Memcached client也选择这种算法,解决将 key-value 均匀分配到众多 Memcached server 上的问题。它可以取代传统的取模操作,解决了取模操作无法应对增删 Memcached Server 的问题(增删 server 会导致同一个 key,在 get 操作时分配不到数据真正存储的 server,命中率会急剧下降)。

简单来说,一致性哈希将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为 0 - (2^32)-1(即哈希值是一个 32位无符号整形),整个哈希空间环如下:

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

整个空间按顺时针方向组织。0 和(2^32)-1 在零点中方向重合。

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

接下来使用如下算法定位数据访问到相应服务器:将数据 key 使用相同的函数 H计算出哈希值h,通根据 h 确定此数据在环上的位置,从此位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器。

例如我们有A、B、C、D四个数据对象,经过哈希计算后,在环空间上的位置如下:

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

根据一致性哈希算法,数据A会被定为到Server 1上,D被定为到Server 3上,而B、C分别被定为到Server 2上。

3.2 容错性与可扩展性分析

下面分析一致性哈希算法的容错性和可扩展性。现假设 Server 3 宕机了:

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

可以看到此时 A、C、B 不会受到影响,只有 D 节点被重定位到 Server 2。一般的,在一致性哈希算法中,如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即顺着逆时针方向行走遇到的第一台服务器)之间数据,其它不会受到影响。

下面考虑另外一种情况,如果我们在系统中增加一台服务器 Memcached Server 4:

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

此时 A、D、C 不受影响,只有 B 需要重定位到新的 Server 4。一般的,在一致性哈希算法中,如果增加一台服务器,则受影响的数据仅仅是新服务器到其环空间中前一台服务器(即顺着逆时针方向行走遇到的第一台服务器)之间数据,其它不会受到影响。

综上所述,一致性哈希算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。

3.3 虚拟节点

一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。例如我们的系统中有两台服务器,其环分布如下:

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

此时必然造成大量数据集中到 Server 1 上,而只有极少量会定位到 Server 2 上。为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器ip或主机名的后面增加编号来实现。例如上面的情况,我们决定为每台服务器计算三个虚拟节点,于是可以分别计算“Memcached Server 1#1”“Memcached Server 1#2”“Memcached Server 1#3”“Memcached Server 2#1”“Memcached Server 2#2”“Memcached Server 2#3”的哈希值,于是形成六个虚拟节点:

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?

4 Java实现

package com.king.consistenthash;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 一致性Hash算法
 *
 * @param <T> 节点类型
 */

public class ConsistentHash<T{
 /**
  * Hash计算对象,用于自定义hash算法
  */

 HashFunc hashFunc;
 /**
  * 复制的节点个数
  */

 private final int numberOfReplicas;
 /**
  * 一致性Hash环
  */

 private final SortedMap<Long, T> circle = new TreeMap<>();

 /**
  * 构造,使用Java默认的Hash算法
  * @param numberOfReplicas 复制的节点个数,增加每个节点的复制节点有利于负载均衡
  * @param nodes            节点对象
  */

 public ConsistentHash(int numberOfReplicas, Collection<T> nodes) {
  this.numberOfReplicas = numberOfReplicas;
  this.hashFunc = new HashFunc() {

   @Override
   public Long hash(Object key) {
//    return fnv1HashingAlg(key.toString());
    return md5HashingAlg(key.toString());
   }
  };
  //初始化节点
  for (T node : nodes) {
   add(node);
  }
 }

 /**
  * 构造
  * @param hashFunc         hash算法对象
  * @param numberOfReplicas 复制的节点个数,增加每个节点的复制节点有利于负载均衡
  * @param nodes            节点对象
  */

 public ConsistentHash(HashFunc hashFunc, int numberOfReplicas, Collection<T> nodes) {
  this.numberOfReplicas = numberOfReplicas;
  this.hashFunc = hashFunc;
  //初始化节点
  for (T node : nodes) {
   add(node);
  }
 }

 /**
  * 增加节点<br>
  * 每增加一个节点,就会在闭环上增加给定复制节点数<br>
  * 例如复制节点数是2,则每调用此方法一次,增加两个虚拟节点,这两个节点指向同一Node
  * 由于hash算法会调用node的toString方法,故按照toString去重
  *
  * @param node 节点对象
  */

 public void add(T node) {
  for (int i = 0; i < numberOfReplicas; i++) {
   circle.put(hashFunc.hash(node.toString() + i), node);
  }
 }

 /**
  * 移除节点的同时移除相应的虚拟节点
  *
  * @param node 节点对象
  */

 public void remove(T node) {
  for (int i = 0; i < numberOfReplicas; i++) {
   circle.remove(hashFunc.hash(node.toString() + i));
  }
 }

 /**
  * 获得一个最近的顺时针节点
  *
  * @param key 为给定键取Hash,取得顺时针方向上最近的一个虚拟节点对应的实际节点
  * @return 节点对象
  */

 public T get(Object key) {
  if (circle.isEmpty()) {
   return null;
  }
  long hash = hashFunc.hash(key);
  if (!circle.containsKey(hash)) {
   SortedMap<Long, T> tailMap = circle.tailMap(hash); //返回此映射的部分视图,其键大于等于 hash
   hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
  }
  //正好命中
  return circle.get(hash);
 }

 /**
  * 使用MD5算法
  * @param key
  * @return
  */

 private static long md5HashingAlg(String key) {
  MessageDigest md5 = null;
  try {
   md5 = MessageDigest.getInstance("MD5");
   md5.reset();
   md5.update(key.getBytes());
   byte[] bKey = md5.digest();
   long res = ((long) (bKey[3] & 0xFF) << 24) | ((long) (bKey[2] & 0xFF) << 16) | ((long) (bKey[1] & 0xFF) << 8)| (long) (bKey[0] & 0xFF);
   return res;
  } catch (NoSuchAlgorithmException e) {
   e.printStackTrace();
  }
  return 0l;
 }

 /**
  * 使用FNV1hash算法
  * @param key
  * @return
  */

 private static long fnv1HashingAlg(String key) {
  final int p = 16777619;
  int hash = (int2166136261L;
  for (int i = 0; i < key.length(); i++)
   hash = (hash ^ key.charAt(i)) * p;
  hash += hash << 13;
  hash ^= hash >> 7;
  hash += hash << 3;
  hash ^= hash >> 17;
  hash += hash << 5;
  return hash;
 }

 /**
  * Hash算法对象,用于自定义hash算法
  */

 public interface HashFunc {
  public Long hash(Object key);
 }
}

5 总结

Consistent Hashing 最大限度地抑制了 hash 键的重新分布。另外要取得比较好的负载均衡的效果,往往在服务器数量比较少的时候需要增加虚拟节点来保证服务器能均匀的分布在圆环上。因为使用一般的 hash 方法,服务器的映射地点的分布非常不均匀。使用虚拟节点的思想,为每个物理节点(服务器)在圆上分配 100~200 个点。这样就能抑制分布不均匀,最大限度地减小服务器增减时的缓存重新分布。用户数据映射在虚拟节点上,就表示用户数据真正存储位置是在该虚拟节点代表的实际物理服务器上。

赠送书籍
《分布式一致性算法》

大师带你领略各种分布式算法,系统,详尽,生产级的,完整的让你学会Raft算法。实现自己动手,从零开始编写Raft算法来实现分布式一致性算法!


可点击下方链接直接购买

👆👆👆

免费获取方法:

截止到 11月17日12:00,留言获赞最高的朋友,将获得以上书籍一本,要求评论内容与技术相关即可

Java学习之道 发起了一个读者讨论 清不清晰,由你说了算! 精选讨论内容
[文末赠书]说是最清晰的一致性hash算法文章没意见吧?
林卫明

清晰清晰。打卡式参与,来了。

[文末赠书]说是最清晰的一致性hash算法文章没意见吧?
望尽天涯路

周五天天见,开始深入浅出学习分布式系统。

兜兜转转

单机项目的路过,打卡撸书

余下1条讨论内容

觉得不错就点个在看