vlambda博客
学习文章列表

Golang 一致性Hash算法实现

一致性 hash 算法是在分布式应用中使用广泛。其主要作用是为了解决服务中的热点问题。例如在分布式数据存储,比如Redis缓存集群、有状态的任务作业等,通过其解决请求的热点问题,并且可以缓解当发生服务变动时出现的负载不平衡情况。

本文将简单介绍一致性HASH算法,并分析golang的一个开源实现包(github.com/stathat/consistent)

一致性hash算法原理

解决的问题

在分布式存储中,为了保证不同机器缓存的数据均衡性以及负载的均衡性,一般会在代理层先做一次hash。正常情况下,我们会做取模操作,然后将服务分配给不同的机器。但是这种情况下当某台服务宕机或者有新机器加入时,会直接影响到所有机器的缓存。为了解决这个问题,所以出现了一致性hash算法。

如何解决

一致性hash算法,主要是解决如何将hash后的值映射到既定的机器中。下面是hash步骤:

1,将hash值构造为一个圆环,一般为 [0,2^32 - 1] 2. 在圆环上均匀的挑选hash点,作为机器的节点 3. 在访问机器时,hash到圆环的某个点上,然后顺时针访问到的第一个节点即为需要访问的机器。

这样,当某台机器宕机时,宕机机器的下一台机器将承包宕机机器的存储任务。如果hash环中的两个机器节点中添加了一个机器节点,那原有的节点存储任务将被分一部分存储任务给新增加的节点。

升华

正常情况下,如果使用上面说的一致性hash算法,出现宕机时,一台机器将干两台机器的活,这显然是不科学的。因此还需要有虚拟节点的概念。将虚拟节点放在圆环上,然后真正的服务节点维护多个虚拟节点(并且需要保证一台服务节点上的多个虚拟节点需要离散分布)。这样,当一个服务节点宕机后,其实是多个离散的虚拟节点从hash环中消失,这样可以保证正常的机器在保留原有数据的基础上,还能承载宕机对应的离散节点的负载。

一致性hash算法的golang实现

github.com/stathat/consistent 是golang实现的一个一致性hash算法。

数据结构

在实现中,增加了分布式缓存中较为实用的副本的概念。结构如下:

type Consistent struct {
  circle           map[uint32]string  // 保存环
  members          map[string]bool  // 成员标记
  sortedHashes     uints // []int32 类型  标记了环上的虚拟节点,有序的slice
  NumberOfReplicas int // 副本数量
  count            int64 // 节点数量
  scratch          [64]byte // 未使用
  UseFnv           bool // 标记使用 FNV-1a hash
  sync.RWMutex  // 读写锁标记
}

设置节点

初始化节点时,需要提供各个节点的名称,以及副本的数量。初始化将进行如下操作:

  1. 将hash与node名的映射存入circle中
  2. 将members 标记为true
  3. 将hash值放入有序的sortedHashes中
func (c *Consistent) Set(elts []string) {
  c.Lock()
  defer c.Unlock()
  for k := range c.members {  // 首先将清除不在elts 集合中的数据
    found := false
    for _, v := range elts {
      if k == v {
        found = true
        break
      }
    }
    if !found {
      c.remove(k)
    }
  }
  for _, v := range elts {  // 之后依次添加
    _, exists := c.members[v]
    if exists {
      continue
    }
    c.add(v)
  }
}
func (c *Consistent) add(elt string) {
  for i := 0; i < c.NumberOfReplicas; i++ {
    c.circle[c.hashKey(c.eltKey(elt, i))] = elt
  }
  c.members[elt] = true
  c.updateSortedHashes()  // 更新sortedHashes 字段,保证有序
  c.count++
}

访问

当通过name映射一个node时,首先计算hash值,然后通过二分查找的方法从sortedHashes slice中拿到对应的node的hash值,最后通过circle映射出对应的node name.

func (c *Consistent) Get(name string) (string, error) {
  c.RLock()
  defer c.RUnlock()
  if len(c.circle) == 0 {
    return "", ErrEmptyCircle
  }
  key := c.hashKey(name)
  i := c.search(key)
  return c.circle[c.sortedHashes[i]], nil
}

删除

当某台机器宕机时,可以删除对应的node 节点,以释放快速调整服务请求。

func (c *Consistent) remove(elt string) {
  // 代码中有加锁
  for i := 0; i < c.NumberOfReplicas; i++ {
    delete(c.circle, c.hashKey(c.eltKey(elt, i)))
  }
  delete(c.members, elt)
  c.updateSortedHashes()
  c.count--
}

总结

从代码的实现中,我们可以看出:

  • 从环中查找下一个节点,只需要将所有节点的hash值存放在一个有序的slice中,做二分查找即可。为了保证每个节点数据的均衡性,一般会添加较多的虚拟节点,为了保证访问请求的速度,又不能过多。例如,在Codis中,虚拟节点默认一般有1024个(当然也可以配置更多的槽)
  • 一致性hash对服务在做调整时可以做平滑过度
  • 比较常见的应用,例如分布式缓存服务 Redis-Cluster, Codis;或者 服务代理 twemproxy 等