写点什么

2N 方定点算法

发布于: 2020 年 10 月 08 日



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个节点扩展为两个





  • 两个节点扩充为4个



  • 4个节点扩容为8个





这样因为节点总是在哈希环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小能手)






参考阅读







用户头像

还未添加个人签名 2018.08.07 加入

还未添加个人简介

评论

发布
暂无评论
2N方定点算法