一致性哈希的通用实现

小小狐狸 2019-06-27

一致性哈希算法在1997年由麻省理工学院的Karger等人在解决分布式Cache中提出的,设计目标是为了解决因特网中的热点(Hot spot)问题

国际惯例,先上源码 https://github.com/manerfan/c...

  1. 可自定义节点数据类型
  2. 可自定义hash函数

原理

一致性哈希可应用于负载均衡、分布式缓存(缓存分片)等场景中,以下以分布式缓存为例

传统方式

如,现有N个缓存实例,将一个对象object映射到某一个缓存上可以采取取模方式 hash(object) % N
我们称之为简单hash算法。一般,简单hash算法确实能够比较均匀地实现分布式映射,但如果考虑缓存实例变动(增删)的情况:

  1. 某一缓存实例宕机,需要将该实例从集群中摘除,则映射公式变为 hash(object) % (N - 1)
  2. 增加一台缓存实例,将该实例加入集群,则映射公式变为 hash(object) % (N + 1)

对于以上情况,无论新增还是移除,大部分object所映射的缓存实例均会改变,缓存命中率大幅度降低从而回源到服务器,短时间内造成缓存雪崩现象

一致性哈希

一致性 Hash 算法简单的说,在移除/添加一个缓存实例时,尽可能小的改变已存在key映射关系,尽可能的满足单调性的要求。

1. 环形空间

通常的hash算法都是将一个value映射到一个32位的key值,我们可以将这个[0, 2^32-1]空间想象成一个首尾相接的环形队列

一致性哈希的通用实现

2. 将对象映射到hash空间

通过hash函数计算对象hash值,将对象映射到hash环形空间

一致性哈希的通用实现

3. 将缓存实例映射到hash空间

使用缓存实例的ip、port等信息,通过hash函数计算其hash值,将缓存实例映射到hash空间

一致性哈希的通用实现

4. 将对象映射到缓存实例

沿着顺时针方向,查找距离对象object最近的缓存实例,并将对象映射到该实例

一致性哈希的通用实现

5. 添加缓存实例

按照同样的算法,在添加实例后发现,只有少部分对象的映射关系改变

一致性哈希的通用实现

6. 移除缓存实例

按照同样的算法,在移除实例后,同样只有少部分对象的映射关系改变

一致性哈希的通用实现

7. 虚拟节点

为了使对象尽可能均匀地映射到所有的缓存实例中(解决缓存实例分布不均匀的问题),引入虚拟节点的概念
虚拟节点其实为真实节点在hash空间中的复制品,一个真实节点可以对应多个虚拟节点

虚拟节点的hash求值可以在真实节点的求值基础上加入编号等信息 hash(realCacheKey#1)hash(realCacheKey#2)

一致性哈希的通用实现

通用实现

实现目标:

  1. 由于一致性哈希应用较多,并不局限于某一特定场景,故需要能够 自定义节点数据类型
  2. 常规hash算法一般采用md5等,但不限制hash函数实现,故需要能够 自定义hash函数

自定义节点数据类型

这里,我们定义一个公共接口

/**
 * 真实节点
 */
interface PhysicalNode {
    fun hashKey(): String
}

自定义节点数据,只需要实现PhysicalNode接口及hashKey方法,程序会通过hashKey的值计算节点的hash值

如,我们定义常规服务节点

/**
 * 常规的服务节点
 *
 * @param name  服务名称
 * @param host  服务host/ip
 * @param port  服务port
 */
data class HostPortPhysicalNode(val name: String, val host: String, val port: Int) : PhysicalNode {
    override fun hashKey() = "$name:$host:$port"
}

这里,我们还需要定义一个虚拟节点

/**
 * 虚拟节点
 *
 * @param parent    真实节点
 * @param replica   虚拟节点id
 */
data class VirtualNode<out T : PhysicalNode>(val parent: T, private val replica: Int) : PhysicalNode {
    override fun hashKey() = "${parent.hashKey()}#$replica"
    fun matches(key: String) = parent.hashKey() == key
}

虚拟节点的hashKey为真实节点hashKey加上节点编号

自定义hash函数

这里,我们同样定义一个公共接口

/**
 * 哈希函数
 */
interface HashFunc {
    fun hash(str: String): Long
}

自定义hash含蓄,只需要实现HashFunc接口及hash方法

如,我们定义md5函数

class Md5 : HashFunc {
    override fun hash(str: String): Long {
        val md5 = MessageDigest.getInstance("MD5")
        md5.reset()
        md5.update(str.toByteArray())
        val bytes = md5.digest()

        var h: Long = 0
        bytes.forEach {
            h = h shl 8
            h = h or (it.toLong() and 0xFF)
        }
        return h
    }
 }

ConsistentHash的使用

ConsistentHashConsistentHashHelper的实现,见 ConsistentHashHelper.kt

ConsistentHashHelper

我们使用ConsistentHashHelper来构建ConsistentHash

val consistentHash = ConsistentHashHelper.create<HostPortPhysicalNode>().build()

如果需要自定义hash函数,可以通过withHash指定,默认使用md5

val consistentHash = ConsistentHashHelper.create<HostPortPhysicalNode>()
        .withHash(MyMd5HashFunc())
        .build()

同样,可以通过withNodes指定在初始化时生成节点信息

val master = HostPortPhysicalNode("master", "192.169.1.1", 8080)
val slave = HostPortPhysicalNode("slave", "192.169.1.2", 8080)

val consistentHash = ConsistentHashHelper.create<HostPortPhysicalNode>()
        .withHash(MyMd5HashFunc())
        .withNodes(listOf(master, slave), 2) // 节点,并指定每个节点的副本数(可以省略,缺省1)
        .build()

ConsistentHash

运行过程中,可以动态增删节点

val backup = HostPortPhysicalNode("backup", "192.168.1.13", 8888)

consistentHash.add(backup, 4) // 增加节点,并指定每个节点的副本数(可以省略,缺省1)
consistentHash.remove(slave.hashKey())

通过getNode函数获取对应object所映射的缓存实例

consistentHash.getNode(hashFunc.hash(object1.key))
consistentHash.getNode(hashFunc.hash(object2.key))

相关推荐