引子
最近在阅读dubbo源码的时候,发现了一段比较有意思的代码,初读的时候也一下子没太明白,出自AdaptiveLoadBalance
类的selectByP2C
方法,原始代码是这样的:
我们重点看下上图中红框框出来的这部分,短短5行,是我们今天要聊的重点,为了方便查看,我把源码复制出来
ini
int pos1 = ThreadLocalRandom.current().nextInt(length);
int pos2 = ThreadLocalRandom.current().nextInt(length - 1);
if (pos2 >= pos1) {
pos2 = pos2 + 1;
}
虽然这段代码出自dubbo,但是实际上跟dubbo关系不大,就算你之前完全不了解dubbo,也不影响阅读本文。
P2C算法
从方法名不难看出来,这个方法主要作用是通过负载均衡P2C算法来选择出一个节点。说起负载均衡算法,比较常用有随机、轮询、加权随机、加权轮询等,这些都非常好理解,实现起来也比较直接。
P2C(The Power of Two Random Choices)是另一种负载均衡算法,出自论文《The Power of Two Random Choices: A Survey of Techniques and Results》,主要的策略有2步:
- 从备选节点中随机选出2个不同的节点,记作
nodeA
和nodeB
- 比较
nodeA
和nodeB
的负载情况,选择负载低的
上面说的5行代码,其实就是实现第1步,随机选择2个节点。
疑问
上面的代码从字面意思上不难解读,这几行代码含义分别为:
- 从下标[0, n-1]这n个节点中随机选出1个节点,作为pos1
- 从下标[0, n-2]这n-1个节点中随机选出1个节点,作为pos2
- 如果pos2下标大于等于pos1下标,pos2下标自增1
但是疑问在于,为什么通过这样的策略,选择出来的2个节点是随机的,做到每个节点被选择到的概率都是一样的?
证明
从定性来看,第1步是从n个节点随机选1个,所以每个节点被选择到的概率是一样的;第2步只从[0, n-2]随机取,那么最后1个节点n-1没有机会被选择到,对最后1个节点是不公平的;第3步,如果符合pos2 >= pos1
条件的话,pos2要自增1,那下标为0的节点就不会被选择到,对第1个节点是不公平的。
从上面简单的定性分析看,出现偏差的步骤在2和3,分别对最后1个节点和第1个节点是不公平的,所以,关键点在于,这种不公平是否是对等的。
我们来看步骤3,有多大的概率会触发pos2 >= pos1
条件:
- 步骤1,可能选出的结果是0,1,2...n-2,n-1,每个概率是1/n
- 步骤2,可能选出的结果是0,1,2...n-2,每个概率是1/(n-1)
我们遍历步骤2的每个结果,看步骤1有多少种可能会满足pos2 >= pos1
条件:
- 如果步骤2选择的结果是0,那么步骤1必须选择0,综合概率为
1/(n-1) * 1/n = 1/n*(n-1)
- 如果步骤2选择的结果是1,那么步骤1可以选择0,1,综合概率为
1/(n-1) * 2/n = 1/n*(n-1)
遍历到这里,是不是找到一点感觉了:
- 如果步骤2选择的结果是n-2,那么步骤1可以选择0,1,2...n-2,综合概率为
1/(n-1) * (n-1)/n = (n-1)/n*(n-1)
如果,我们把这些概率都累加起来,会发现分母都是一样的n*(n-1)
,分子就是1+2+...+n-1,这不就是高斯求和么。
所以,这里分子的和就是n*(n-1)/2
,分母是n*(n-1)
,综合的概率就是1/2,把步骤2和步骤3整合起来看,对每个元素还是公平的。
后话
写到这里,相信你可以大概明白,为什么这几行代码可以做到等概率随机选择2个元素,虽然证明的过程比较复杂,但是这个算法整体来看还是比较简单和高效的。并且这种写法在go zero中也存在,不知道是不是互相借鉴的:
go
switch len(p.conns) {
case 0:// 没有节点,返回错误
return emptyPickResult, balancer.ErrNoSubConnAvailable
case 1:// 有一个节点,直接返回这个节点
chosen = p.choose(p.conns[0], nil)
case 2:// 有两个节点,计算负载,返回负载低的节点
chosen = p.choose(p.conns[0], p.conns[1])
default:// 有多个节点,p2c 挑选两个节点,比较这两个节点的负载,返回负载低的节点
var node1, node2 *subConn
// 3次随机选择两个节点
for i := 0; i < pickTimes; i++ {
a := p.r.Intn(len(p.conns))
b := p.r.Intn(len(p.conns) - 1)
if b >= a {
b++
}
node1 = p.conns[a]
node2 = p.conns[b]
// 如果这次选择的节点达到了健康要求, 就中断选择
if node1.healthy() && node2.healthy() {
break
}
}
// 比较两个节点的负载情况,选择负载低的
chosen = p.choose(node1, node2)
}
当然本文并不是鼓吹这样的写法,代码虽然主要是给机器执行,但是程序员可阅读和易于理解也是另外一个非常重要的维度,对于随机选择2个元素,我们也可以这样直观来写:
less
Object node1 = nodeList.remove(ThreadLocalRandom.current().nextInt(nodeList.size()-1));
Object node2 = nodeList.remove(ThreadLocalRandom.current().nextInt(nodeList.size()-1));
这种写法看一眼就能理解,坏处在于破坏了原始的nodeList,所以这里使用的nodeList可能需要额外复制出来。
看到这里了,点个赞再走呗