面试官:一致性hash算法了解吗?实现原理是什么?能解决什么问题?
你:呃。。。要不俺现场撸一个golang的简单版吧。

Talk is cheap, show me your code!

不得不说,如今这个以微服务高并发为主导的coder时代,掌握一致性hash的基本原理还是很有必要的。
一致性哈希的原理其实并不复杂,简单来说,就是:

  • N台服务器生成互不相同的keyword(比如机器的hostname);
  • 将关键词用hash算法映射为数字,并使得该数字始终维持在一个范围,如1到2^32次方
  • 将hash后的数字正序排序,如此以来,N台服务器就会根据自身的keyword生成的数字,分布到一个数字形成的环上,如1到2^32次方首尾相连的环;
  • 将需要存储的变量key同样使用hash算法计算,得到一个1到2^32次方范围内的数字;
  • 对比变量key的数字,与N台服务器的数字值,找到第一个 >=变量key的hash数值 的服务器,即为需要存储到的机器。

此处仅是简单描述一致性哈希的实现步骤,方便后续的代码编写,如果对其原理尚有疑惑的小伙伴,可移步any搜索工具自行查找。

  • 结构预定义:

    // 定义一致性hash的方法类型,便于扩展其他hash算法,
    type HashFn func(data []byte) uint32
    
    type ConsistentHashMap struct {
      hashFn  HashFn         // 一致性哈希函数,默认crc32
      copyNum int            // 机器节点的拷贝份数
      vhosts  []int          // 哈希环上的虚拟节点列表
      trueMap map[int]string // 虚拟节点与真实节点的映射map
    }
  • 初始化

    func NewChash(copyNum int, fn HashFn) *ConsistentHashMap {
      m := &ConsistentHashMap{
          copyNum: copyNum,
          hashFn:  fn,
          trueMap: make(map[int]string),
      }
      if m.hashFn == nil {
          m.hashFn = crc32.ChecksumIEEE
      }
      return m
    }
  • 添加机器节点

    func (c *ConsistentHashMap) AddChash(keys ...string) {
      for _, realHostKey := range keys {
          // 根据自定义的拷贝份数,将真实节点复制对应倍数
          for i := 0; i < c.copyNum; i++ {
              // 真实key添加后缀以生成虚拟key节点
              vhostKey := []string{strconv.Itoa(i), realHostKey}
              // 计算虚拟key的crc32值
              vhostNum := int(c.hashFn([]byte(strings.Join(vhostKey, ""))))
              c.vhosts = append(c.vhosts, vhostNum) // 追加到虚拟节点数组
              c.trueMap[vhostNum] = realHostKey // 保存虚拟节点到真实节点的映射关系
          }
      }
      sort.Ints(c.vhosts) // 每次添加节点都要对虚拟节点执行排序
    }
  • 获取 变量key 被映射到的真实机器节点

    func (c *ConsistentHashMap) GetChash(sk string) string {
      if len(c.vhosts) == 0 {
          panic("none-c-host")
      }
      kNum := int(c.hashFn([]byte(sk)))
      // 二分查找第一个 >=kNum 的虚拟节点下标
      // 即,kNum 在vhosts中的插入位置
      L, R := 0, len(c.vhosts)-1
      for L <= R {
          m := (L + R) / 2
          if c.vhosts[m] >= kNum {
              R = m - 1
          } else {
              L = m + 1 // if c.vhosts[m] < kNum
          }
      }
      // 插入到虚拟节点的下标:R+1
      insertId := (R + 1) % len(c.vhosts)
      return c.trueMap[c.vhosts[insertId]]
    }

这个简单版的一致性hash,大约60行代码左右,其中涉及几个小 trick,此处简略提一下:

  • hash函数做了自定义扩展;其实默认使用crc32算法基本够用了,之所以自定义,一则是方便后续扩展,二则是为了测试时浅显易懂;
  • 节点数做了虚拟化倍数扩展(代码中的copyNum);之所以如此,是因为生产环境中的变量key经过hash计算得到的数字,不大可能如我们所愿,均匀分布到1到2^32次方范围内,既然变量key不可控,那就扩展节点数量,节点数量足够多的时候,变量key的分布就会被动变均匀;就和工作一样,如果改变不了他人的看法,那就努力提升自己的实力;
  • 节点的虚拟化扩展方式,就是简单将索引下标添加到服务器keyword之前(代码中的AddChash方法),这个方式可以按实际情况或自己的心情随意定,上面的Demo如此定义是为了便于观察算法结果(参考文末的测试代码);
  • 获取经过一致性哈希的变量key对应的机器时(GetChash),使用了二分查找,其实如果只是测试,直接遍历虚拟节点列表vhosts,找到第一个大于变量key的机器即可(vhosts已排序),但实际应用时,是有性能问题的,假设虚拟化了100个节点,QPS是1万,直接遍历的代价是100万次循环,二分却可使其降到7万次,差距还是很明显的;
  • 二分逻辑旨在获取变量key的hash数值在虚拟节点列表vhosts中的插入位置,但该位置存在溢出情况,即数值超过了vhosts中的所有值,此时的插入位置在列表末尾,下标值变为vhosts长度,超出了下标界限,因此采用了一个取余的小trick,以保证溢出时,下标insertId会取余变为0,保障一致性hash的正常运行。

测试代码如下:

func main() {
    // trick-1:自定义 hash 方法,专为测试和验证 使用;
    // 直接将下标id添加到机器key之前,并将其转换为数字,便于验证我们的算法结果
    hash := NewChash(3, func(key []byte) uint32 {
        i, _ := strconv.Atoi(string(key))
        return uint32(i)
    })

    // 我们添加 2、4、6 这三台机器key,此时根据我们上面的自定义hash方法可推导出
    // 环节点经拼接下标id后共产生:2, 4, 6, 12, 14, 16, 22, 24, 26 等虚拟节点
    hash.AddChash("6", "4", "2")
    // 验证结论
    testCases := map[string]string{
        "2":  "2", // 变量key如果为2,则实际映射到机器2
        "11": "2", // 变量key如果为11,同样映射到机器2
        "23": "4", // 23,映射到机器4
        "27": "2", // 27,映射到机器2
    }
    for k, v := range testCases {
        if hash.GetChash(k) != v {
            // 如果不符合我们的预期,则说明,一致性hash算法存在问题
            fmt.Printf("Asking for %s, should have yielded %s\n", k, v)
        }
    }

    hash.AddChash("8")    // 添加机器8,虚拟节点产生:8, 18, 28
    testCases["27"] = "8" // 此时,变量key的27 就应当被映射到机器 8

    for k, v := range testCases {
        if hash.GetChash(k) != v {
            fmt.Printf("Asking for %s, should have yielded %s\n", k, v)
        }
    }
}
注:本文代码参考了go公共库 groupcache 的部分源码

后厂村村长
7 声望2 粉丝

Hello, Debug World