2N方定点算法

462 阅读7分钟

2N方定点算法

2N方定点算法,是一次机缘巧合 针对DB层水平扩容的定制的算法。支持流量均衡,扩容节点流量只需偏移一台DB机器。

缘起

因最近负责的系统流量增长,需对DB层做水平扩容。我们的系统有以下特点:

  • DB层采用Redis做主库,MySQL做Redis异构库(用于数据持久化)

    流量基本打在Redis上(Cache-As-SoR + Write-Behind)

  • 比赛系统,写多读少,而且流量峰值到来可预估

  • 用户平时不会使用系统,只有比赛期间才使用

    比赛期间,需支持某一地区的所有学生 同时在线答题

分析

基于这个特点,可能很多同学看到了,会立马想到 一致性哈希算法

是的,我一上来也是按照一致性哈希算法去实现。但是在实现的过程中,发现 流量分布特别不均衡

当然最后发现不均衡的原因是 :

(1)比对哈希环的代码有个bug 

(2)虚拟节点设置的量不够

不过这都是 后话了。为了解决流量不均衡这个问题,我误打误撞想出这么个方法,觉得还是比较有趣。便记了下来

当时我就自以为是的分析了一下,不均衡的原因:

  • 一致性哈希算法的节点使用ip来计算,而我们采用序号。

    因为我们使用了阿里云的云服务,不直接连IP。其次 一致性哈希算法的节点环,实际上也不是均衡的,节点少时需设置虚拟节点。而我测试的时候,设置的虚拟节点数不够

  • 一致性哈希算法的流量,是基于用户ip或者服务器的IP来计算哈希环,而我们使用用户ID

不使用用户IP的原因是:

我们是一个比赛系统。我们同其他系统不同,我们的用户IP有可能只有几个(每个学校的机房IP)

不使用服务器IP 使用用户ID的原因是:

其次我们希望比赛过程中,用户的流量不管他是否更换网络都落在一个DB节点上

这样做的好处是:

1. 同个用户的请求没有落在不同节点导致的读写一致性问题
2. 某个节点如果挂了,只影响这个节点的用户。其他节点的用户不受影响。
   某个节点挂了,这个节点的流量偏移到临近的一个节点上。
   增加节点,也只有临近一个节点流量偏移

带来的问题:

因为我们使用了用户ID,而同一个学校下的学生,他的用户ID一般都是连续的 (业务场景决定,后台名单批量导入,且用户系统使用自增ID) 。

每场比赛的用户,一般为同校或同区域的学生。所以,请求的流量中,大部分的用户ID落在一个几乎连续的范围内

对一段连续的ID,如何均衡到各个节点成了这个问题的关键

实现

基于以上分析,我有了个大胆的想法。一致性哈希算法原理其实就是哈希环范围比对。

没有加虚拟节点分布不均衡的原因,在于节点之间的距离长度不相等。既然如此,其实我们可以根据节点对哈希环进行等量切分。这样的话,流量就能均衡流到对应节点。

也就是固定位置,将整个哈希环无限2等分下去。每次新增节点,旧节点保持不变,新节点插入到旧节点的2等分处。注意序号不能更改,序号代表着流量指向。

节点变化图:

  • 1个节点扩展为两个

image-20200912220335565

  • 两个节点扩充为4个

image-20200912222134259

  • 4个节点扩容为8个

image-20200912221720374

这样因为节点总是在哈希环2N次方节点上,且每次插入节点旧节点序号不变,所以流量近乎均衡。

且新增流量,仅分摊顺时钟前一个节点一半的流量。流量倾斜,只影响一个节点。

节点位置

从上面的流量图中,我们可以看到:

1. 两个节点的时候,节点分布在环的 原点和1/2 处。
2. 第三个节点的来的时候,插入在1/4处,第四个节点插入在3/4处
3. 第五个节点在1/8 ....
4. 1/2 = 2/4 = 4/8 也就是节点按2分法插入,插满对应位置。
   再来节点,再做一次2等分切分,原先的节点位置不变,但是新切分位置,多处于本轮新增切分点的1/2处

由此我们可以得到节点哈希环位置公式:

f(k) = (2(k-(2^level-1) +1)/2^level)*2^n
      = (2k-(2^level) +1) * (2^n/2^level)
      = (2k-(2^level) +1) * (2 ^ (n-level))
h(k)  = level

其中k为节点序号,n为假定哈希环的最大长度。level为第几次切分。

别问,如何算出来的。数学渣,算了一个大半个晚上。

用户ID离散

节点已经确定,接下去就是如何将一段连续的用户ID映射到这个环上。这个时候,我就采取一个比较粗暴的方法。

因为哈希环肯定是一个2n次方范围的环,只要将用户ID离散分布到这个环上即可实现流量均衡。

那么如何将这段连续的用户ID离散呢

离散和连续实际是一个有范围限定的概念。一段连续的ID,对小的范围来说其实就是离散的。

如何理解: 假定一段连续用户ID [1024-2047] 步长为1。

这段ID 对于 2^32 的哈希环来说,当然是连续的,它就连续分布在哈希环上1024起,占整个环的 (1/2^22)

但是这段ID对于一个 2^10 的哈希环来说,他是连续的同时,也是离散的,因为它已经同时均匀分摊到整个环。

也就是对于任意个用户ID对应的哈希环位置有:

L(uid) = uid % 1024   
//为啥是1024,因为1024对于一个学校足够小。不会有学校学生低于1024个学生。
//对于节点来说足够大,很少需要扩容超过1024个节点
//1024 = 1GB = 1级棒 魔数口号

代码

基于上面的分析,也就是我们只要比对用户哈希环的范围,和节点哈希环的范围就能实现,流量均衡到相应节点。

为了实现简单,我也不做哈希环等比放大。默认节点环和用户ID环长度相同

<?php
/**
 * @file RingHash.php
 * @synopsis  2N方哈希 ,独家定制的哈希方法
 * @author fangle
 * @version 1
 * @date 2020-08-20
 */

class RingHash
{

    protected $n = 10;  //定义环节点数 为 2^n 次方

    /**
     * nodes array
     * @var array
     */
    protected $nodes = [];

    protected $virtualNodes = [];

    /**
     * sort
     * @var boolean
     */
    protected $isSort = false;



    /**
     * 添加节点
     * @param $node
     * @return bool
     */
    public function addNode($nodeKey,$node)
    {
        if(!is_numeric($nodeKey))
            throw new Exception("nodeKey should be a numeric");

        $this->nodes[] = $node;
        $virtualHash = $this->getNodeHash($nodeKey,$this->getNodeLevel($nodeKey));
        $this->virtualNodes[$virtualHash] = $node;
        return true;
    }


    /**
     * h(k) = level
     * 传入k 找到 k > 2^level k < 2^level+1 (level < n)
     * 先用遍历法。后面采用二分法 递归来优化
     * @param $nodeKey
     * @return int
     */
    protected function getNodeLevel($nodeKey,$maxLev = 0){
        if($nodeKey <= 0)
            return 0;

        if($nodeKey > $this->n)
            return $this->n;


        if($nodeKey >= pow(2,$maxLev))
            $maxLev = $this->getNodeLevel($nodeKey,$maxLev+1);

        return $maxLev;
    }



    /**
     * f(k) = (2(k-(2^level-1) +1)/2^level)*2^n
     *      = (2k-(2^level) +1) * (2^n/2^level)
     *      = (2k-(2^level) +1) * (2 ^ (n-level))
     * @param $nodeKey
     * @param $level
     * @return int
     */
    protected function getNodeHash($nodeKey, $level){
        //if($nodeKey == 0) return 0;
        //var_dump($nodeKey,$level);
        //$hashKey = (floor($nodeKey)-pow(2,$level-1) + 1)/pow(2,$level) * pow(2,$this->n);
        $hashKey = (2*intval($nodeKey)-pow(2,$level) + 1)*(pow(2,$this->n - $level));

        return $hashKey;
    }


    /**
     * 更新添加节点
     * @param $nodes
     */
    public function addNodes($nodes)
    {
        foreach ($nodes as $nodeKey => $node) {
            $this->addNode($nodeKey,$node);
        }
        return true;
    }

    /**
     * 获取节点
     * @param $key 可以用uid之类
     * @return mixed
     */
    public function getNode($key)
    {
        if (!$this->isSort) {
            ksort($this->virtualNodes);
            $this->isSort = true;
        }
        $hashKey = intval($key) % pow(2,$this->n);

        foreach ($this->virtualNodes as $hashNode => $node) {
            if ($hashKey < $hashNode) {
                break; //这个key的hash值大于节点的hash,返回取小节点的。先默认是第一个
            }
            $hitNode = $node;
        }

        reset($this->virtualNodes);

        return $hitNode;
    }
}

大概的实现思路就是这样的。节点环和用户ID环如果长度需要不一致,只需要做缩放即可。

先简单介绍,后面有空再继续完善吧 (立flag小能手)


参考阅读